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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2302-roles-part-2-a-guest-cannot-own-a-reposi → main 4 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. 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). | |
| 751 | 751 | |
| 752 | 752 | **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. |
| 753 | 753 | **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 | ||
| 5 | 5 | guest. `src/core/roles.ts` holds the one table below, and `test/roles.test.ts` |
| 6 | 6 | fails if this copy differs from it. |
| 7 | 7 | |
| 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. | |
| 10 | 21 | |
| 11 | 22 | On `/settings/people` an admin promotes a guest to member, with a starter |
| 12 | 23 | model allowance (dollars a month, default none, through the grant of #1145), |
src/actions/follow.ts +3
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * came from. The doors only word the answer. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { NOT_A_MEMBER } from "../core/roles.ts"; | |
| 8 | 9 | import type { App } from "../app.ts"; |
| 9 | 10 | import { DIRECTION_NOTE } from "../crew/direct.ts"; |
| 10 | 11 | import { convergeFork, type Converge } from "../crew/converge.ts"; |
| @@ -104,6 +105,8 @@ export function convergeWords( | ||
| 104 | 105 | }; |
| 105 | 106 | case "forbidden": |
| 106 | 107 | return { tone: "danger", message: DIRECTION_NOTE }; |
| 108 | case "not_member": | |
| 109 | return { tone: "danger", message: `Not started: ${NOT_A_MEMBER}.` }; | |
| 107 | 110 | case "ceiling": |
| 108 | 111 | return { |
| 109 | 112 | tone: "danger", |
src/actions/issues.ts +5 −1
| @@ -20,6 +20,7 @@ | ||
| 20 | 20 | |
| 21 | 21 | import { canSeeSealed, mayView, setSealed } from "../core/sealed.ts"; |
| 22 | 22 | import { requireVerified } from "./verify.ts"; |
| 23 | import { requireAbility } from "./roles.ts"; | |
| 23 | 24 | import type { App } from "../app.ts"; |
| 24 | 25 | import { crewRole, isCrew } from "../core/agents.ts"; |
| 25 | 26 | import { moveBlockersToChildren } from "../core/dependencies.ts"; |
| @@ -294,7 +295,7 @@ export function assignIssue( | ||
| 294 | 295 | variantGroup?: string, |
| 295 | 296 | ): Result< |
| 296 | 297 | Issue, |
| 297 | AccessError | "assignee_cannot_read" | "agent_not_on_crew" | "unverified" | |
| 298 | AccessError | "assignee_cannot_read" | "agent_not_on_crew" | "unverified" | "not_member" | |
| 298 | 299 | > { |
| 299 | 300 | const writer = requireWriter(app, actor, repo); |
| 300 | 301 | if (!writer.ok) return writer; |
| @@ -312,6 +313,9 @@ export function assignIssue( | ||
| 312 | 313 | // A run costs: only a proven account may start one. |
| 313 | 314 | if (assignee.kind === "agent" && !requireVerified(app, writer.value).ok) |
| 314 | 315 | 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"); | |
| 315 | 319 | } |
| 316 | 320 | const updated = setAssignee(app.db, issue.id, assignee?.id ?? null); |
| 317 | 321 | if (!updated) return ok(issue); |
src/actions/make_it_yours.ts +10
| @@ -41,6 +41,8 @@ import { | ||
| 41 | 41 | } from "../core/repos.ts"; |
| 42 | 42 | import { isActive, type User } from "../core/users.ts"; |
| 43 | 43 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 44 | import { requireVerified } from "./verify.ts"; | |
| 45 | import { requireAbility } from "./roles.ts"; | |
| 44 | 46 | import { requireAppRoom, appLimitWords } from "./app_limit.ts"; |
| 45 | 47 | import { enableApp } from "./apps.ts"; |
| 46 | 48 | import { FORKS_PER_DAY, forkRepo, repositoryLimitMessage } from "./repos.ts"; |
| @@ -67,6 +69,8 @@ export type MakeItYoursError = | ||
| 67 | 69 | | { readonly kind: "not_found" } |
| 68 | 70 | | { readonly kind: "unauthorized" } |
| 69 | 71 | | { readonly kind: "unverified" } |
| 72 | /** A guest: the owner has not made this account a member. */ | |
| 73 | | { readonly kind: "not_member" } | |
| 70 | 74 | | { readonly kind: "own" } |
| 71 | 75 | /** Today's copies are made; another may be made from `resetsAt`. */ |
| 72 | 76 | | { readonly kind: "limit"; readonly resetsAt: string } |
| @@ -149,6 +153,10 @@ export async function makeItYours( | ||
| 149 | 153 | message: "an agent does not make apps its own", |
| 150 | 154 | }); |
| 151 | 155 | 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" }); | |
| 152 | 160 | const dryRun = input.dryRun !== false; |
| 153 | 161 | const name = input.name?.trim() || source.name; |
| 154 | 162 | const visibility = input.visibility ?? "private"; |
| @@ -226,6 +234,8 @@ export async function makeItYours( | ||
| 226 | 234 | return err({ kind: "unauthorized" }); |
| 227 | 235 | case "unverified": |
| 228 | 236 | return err({ kind: "unverified" }); |
| 237 | case "not_member": | |
| 238 | return err({ kind: "not_member" }); | |
| 229 | 239 | case "name_taken": |
| 230 | 240 | return err({ |
| 231 | 241 | kind: "invalid", |
src/actions/migrate.ts +3
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | * thing: make it run here, adapting rather than rewriting. |
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | import { NOT_A_MEMBER } from "../core/roles.ts"; | |
| 20 | 21 | import { requireVerified } from "./verify.ts"; |
| 21 | 22 | import type { App } from "../app.ts"; |
| 22 | 23 | import { getAgentByName } from "../core/agents.ts"; |
| @@ -197,6 +198,8 @@ export function migrateMessage(error: MigrateError, limit = "at the repositories | ||
| 197 | 198 | return "Verify your email first: follow the link we sent, or ask for another under Settings."; |
| 198 | 199 | case "refused": |
| 199 | 200 | return "New accounts cannot create repositories yet."; |
| 201 | case "not_member": | |
| 202 | return `Not made: ${NOT_A_MEMBER}.`; | |
| 200 | 203 | case "repository_limit": |
| 201 | 204 | return `Not made: ${limit}.`; |
| 202 | 205 | case "storage_limit": |
src/actions/proposals.ts +4
| @@ -18,6 +18,8 @@ import { ensureDirectionIssue } from "../crew/autonomy.ts"; | ||
| 18 | 18 | import { queueAgentRun } from "../crew/dispatch.ts"; |
| 19 | 19 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 20 | 20 | import { MAX_BODY } from "../core/issues.ts"; |
| 21 | import { NOT_A_MEMBER } from "../core/roles.ts"; | |
| 22 | import { requireAbility } from "./roles.ts"; | |
| 21 | 23 | import { assignIssue, addComment } from "./issues.ts"; |
| 22 | 24 | import { openPlan, parsePlan, type Planned } from "./plan.ts"; |
| 23 | 25 | |
| @@ -90,6 +92,8 @@ export function redirectProposal( | ||
| 90 | 92 | const found = mine(app, actor, id, ["pending", "later"]); |
| 91 | 93 | if (!found.ok) return found; |
| 92 | 94 | 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); | |
| 93 | 97 | const planner = listCrewRoles(app.db, repo.id).find((c) => c.role === "planner")?.agent; |
| 94 | 98 | if (planner === undefined) return fail("invalid", "no planner on this crew to hand it to"); |
| 95 | 99 | const direction = ensureDirectionIssue(app, found.value.actor, repo); |
src/actions/repos.ts +7 −1
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { requireVerified } from "./verify.ts"; |
| 8 | import { requireAbility } from "./roles.ts"; | |
| 8 | 9 | import type { App } from "../app.ts"; |
| 9 | 10 | import { canSeeSealed } from "../core/sealed.ts"; |
| 10 | 11 | import { capacityValue } from "../core/capacity.ts"; |
| @@ -72,6 +73,7 @@ export type CreateRepoActionError = | ||
| 72 | 73 | | "unauthorized" |
| 73 | 74 | | "forbidden_owner" |
| 74 | 75 | | "refused" |
| 76 | | "not_member" | |
| 75 | 77 | | "repository_limit" |
| 76 | 78 | | "storage_limit"; |
| 77 | 79 | |
| @@ -142,6 +144,8 @@ export async function createRepo( | ||
| 142 | 144 | if (user.value.kind === "agent") return err("forbidden_owner"); |
| 143 | 145 | const guarded = requireNewAccountGuard(app, user.value); |
| 144 | 146 | if (!guarded.ok) return guarded; |
| 147 | const member = requireAbility(app, user.value, "make_it_yours"); | |
| 148 | if (!member.ok) return member; | |
| 145 | 149 | const ownerId = resolveOwner(app, user.value, input.owner ?? ""); |
| 146 | 150 | if (ownerId === undefined) return err("forbidden_owner"); |
| 147 | 151 | if (ownerId === user.value.id && repositoryLimitRefusal(app, user.value) !== undefined) |
| @@ -179,13 +183,15 @@ export async function forkRepo( | ||
| 179 | 183 | parent: Repo, |
| 180 | 184 | name?: string, |
| 181 | 185 | 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">> { | |
| 183 | 187 | const user = requireParticipant(app, actor, parent); |
| 184 | 188 | if (!user.ok) return user; |
| 185 | 189 | const proven = requireVerified(app, user.value); |
| 186 | 190 | if (!proven.ok) return proven; |
| 187 | 191 | const guarded = requireNewAccountGuard(app, user.value); |
| 188 | 192 | if (!guarded.ok) return guarded; |
| 193 | const member = requireAbility(app, user.value, "make_it_yours"); | |
| 194 | if (!member.ok) return member; | |
| 189 | 195 | if (repositoryLimitRefusal(app, user.value) !== undefined) return err("repository_limit"); |
| 190 | 196 | if ((await storageRefusal(app, user.value, user.value.id)) !== undefined) |
| 191 | 197 | return err("storage_limit"); |
src/actions/roles.ts +8 −2
| @@ -1,16 +1,22 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Promote a guest to member, or demote a member, with a starter model |
| 3 | 3 | * 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. | |
| 5 | 6 | */ |
| 6 | 7 | |
| 7 | 8 | import type { App } from "../app.ts"; |
| 8 | 9 | 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"; | |
| 10 | 11 | import { getUserByName, type User } from "../core/users.ts"; |
| 11 | 12 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 12 | 13 | import { requireUser } from "./access.ts"; |
| 13 | 14 | |
| 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 | ||
| 14 | 20 | export type RoleError = "not_found" | "not_allowed" | "invalid_amount"; |
| 15 | 21 | |
| 16 | 22 | export function setRole( |
src/actions/runs.ts +4 −1
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | |
| 11 | 11 | import { mayViewId } from "../core/sealed.ts"; |
| 12 | 12 | import { requireVerified } from "./verify.ts"; |
| 13 | import { requireAbility } from "./roles.ts"; | |
| 13 | 14 | import type { App } from "../app.ts"; |
| 14 | 15 | import { steerRun } from "../crew/runner.ts"; |
| 15 | 16 | import { getIssueById } from "../core/issues.ts"; |
| @@ -200,7 +201,7 @@ export function requeueRepoRun( | ||
| 200 | 201 | actor: User | undefined, |
| 201 | 202 | repo: Repo, |
| 202 | 203 | 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"> { | |
| 204 | 205 | const writer = requireWriter(app, actor, repo); |
| 205 | 206 | if (!writer.ok) return writer; |
| 206 | 207 | // A run costs money; like assigning, queueing one is a person's call. |
| @@ -208,6 +209,8 @@ export function requeueRepoRun( | ||
| 208 | 209 | const run = getRun(app.db, runId); |
| 209 | 210 | if (!run || run.repoId !== repo.id) return err("not_found"); |
| 210 | 211 | if (run.kind !== "agent") return err("not_agent"); |
| 212 | const member = requireAbility(app, writer.value, "run_crew"); | |
| 213 | if (!member.ok) return member; | |
| 211 | 214 | if (run.state !== "cancelled" && run.state !== "failed") |
| 212 | 215 | return err("not_finished"); |
| 213 | 216 | const task = taskOf(app, run); |
src/core/capacity.ts +9 −1
| @@ -21,7 +21,8 @@ export type CapacityKey = | ||
| 21 | 21 | | "checks.shards" |
| 22 | 22 | | "children.max" |
| 23 | 23 | | "cells.egress" |
| 24 | | "guard_new_accounts"; | |
| 24 | | "guard_new_accounts" | |
| 25 | | "guard_guests"; | |
| 25 | 26 | |
| 26 | 27 | interface Definition { |
| 27 | 28 | /** What the settings page and the API call it. */ |
| @@ -80,6 +81,13 @@ export const CAPACITY: Record<CapacityKey, Definition> = { | ||
| 80 | 81 | fallback: "off", |
| 81 | 82 | 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.", |
| 82 | 83 | }, |
| 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 | }, | |
| 83 | 91 | }; |
| 84 | 92 | |
| 85 | 93 | export const CAPACITY_KEYS = Object.keys(CAPACITY) as CapacityKey[]; |
src/core/roles.ts +22 −2
| @@ -2,11 +2,13 @@ | ||
| 2 | 2 | * Roles: an account is a guest, a member or an admin. Admin is the `is_admin` |
| 3 | 3 | * flag, a member is a row in `member`, everyone else is a guest. `CAN` is the |
| 4 | 4 | * 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. | |
| 7 | 8 | */ |
| 8 | 9 | |
| 9 | 10 | import type { Database } from "bun:sqlite"; |
| 11 | import { capacityValue } from "./capacity.ts"; | |
| 10 | 12 | |
| 11 | 13 | export const ROLES = ["guest", "member", "admin"] as const; |
| 12 | 14 | export type Role = (typeof ROLES)[number]; |
| @@ -44,6 +46,24 @@ export function rolesTable(): string { | ||
| 44 | 46 | return [`| Ability | ${ROLES.join(" | ")} |`, `| --- | ${ROLES.map(() => "---").join(" | ")} |`, ...rows].join("\n"); |
| 45 | 47 | } |
| 46 | 48 | |
| 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 | ||
| 47 | 67 | export function roleOf(db: Database, user: { id: string; isAdmin: boolean }): Role { |
| 48 | 68 | if (user.isAdmin) return "admin"; |
| 49 | 69 | 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 @@ | ||
| 14 | 14 | * Nothing here ever moves the fork's default branch by itself. |
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | import { requireAbility } from "../actions/roles.ts"; | |
| 17 | 18 | import { mayDirectCrew } from "./direct.ts"; |
| 18 | 19 | import type { App } from "../app.ts"; |
| 19 | 20 | import { openIssue } from "../actions/issues.ts"; |
| @@ -67,6 +68,8 @@ export type Converge = | ||
| 67 | 68 | | { kind: "nobody" } |
| 68 | 69 | /** The person asking may not direct the crew here (`direct.ts`). */ |
| 69 | 70 | | { kind: "forbidden" } |
| 71 | /** A guest: the run is a member's to start, refused before anything is written. */ | |
| 72 | | { kind: "not_member" } | |
| 70 | 73 | | { kind: "ceiling" }; |
| 71 | 74 | |
| 72 | 75 | /** The fork's open converge issue, if the parent's updates are in hand. */ |
| @@ -144,6 +147,8 @@ export async function convergeFork( | ||
| 144 | 147 | // fork's own autonomy, which its owner turned on. |
| 145 | 148 | if (actor !== undefined && !mayDirectCrew(app.db, fork, actor)) |
| 146 | 149 | return { kind: "forbidden" }; |
| 150 | if (actor !== undefined && !requireAbility(app, actor, "run_crew").ok) | |
| 151 | return { kind: "not_member" }; | |
| 147 | 152 | const relation = await forkRelation(app, fork, parent); |
| 148 | 153 | if (relation === undefined || relation.state !== "diverged" || relation.clean) |
| 149 | 154 | return { kind: "nothing", relation }; |
src/crew/dispatch.ts +5
| @@ -53,6 +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 | 57 | import { createLog } from "../kernel/log.ts"; |
| 57 | 58 | import { issueOfBranch, type RunReason } from "./brief.ts"; |
| 58 | 59 | import { detectClaude } from "./claude.ts"; |
| @@ -289,6 +290,10 @@ export function queueAgentRun( | ||
| 289 | 290 | ): Run | undefined { |
| 290 | 291 | // A suspended or removed owner's repositories run nothing, whoever asks. |
| 291 | 292 | 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 This gate refuses quietly, and some callers write state before they get here. |
||
| 292 | 297 | // The day's ceiling gates every run the crew starts on its own, not only |
| 293 | 298 | // the picks: a review, a feedback run or a conflict run past the ceiling |
| 294 | 299 | // is money the person did not agree to. A person's own assignment is not |
src/crew/proposal.ts +4
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * the resolution to the branch and its report becomes the note on the pull. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { requireAbility } from "../actions/roles.ts"; | |
| 9 | 10 | import type { App } from "../app.ts"; |
| 10 | 11 | import { getIssueById, listComments } from "../core/issues.ts"; |
| 11 | 12 | import { pullDir, pullStatus, type Pull } from "../core/pulls.ts"; |
| @@ -25,6 +26,7 @@ export type ProposeError = | ||
| 25 | 26 | | "no_conflict" |
| 26 | 27 | | "no_agent" |
| 27 | 28 | | "busy" |
| 29 | | "not_member" | |
| 28 | 30 | | "not_queued"; |
| 29 | 31 | |
| 30 | 32 | /** What the brief carries at most: a hand's worth, never a whole history. */ |
| @@ -47,6 +49,7 @@ export async function proposeResolution( | ||
| 47 | 49 | ): Promise<Result<Run, ProposeError>> { |
| 48 | 50 | if (!canWrite(app.db, repo, actor)) return err("forbidden"); |
| 49 | 51 | if (pull.state !== "open") return err("not_open"); |
| 52 | if (actor !== undefined && !requireAbility(app, actor, "run_crew").ok) return err("not_member"); | |
| 50 | 53 | const issue = getIssueById(app.db, pull.id); |
| 51 | 54 | const agent = issue === undefined ? undefined : agentFor(app, issue); |
| 52 | 55 | if (issue === undefined || agent === undefined) return err("no_agent"); |
| @@ -214,6 +217,7 @@ export const PROPOSE_WHY: Record<ProposeError, string> = { | ||
| 214 | 217 | no_conflict: "That pull has no conflicts to resolve.", |
| 215 | 218 | no_agent: "No agent holds this pull, so the crew has no hand to ask.", |
| 216 | 219 | 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.", | |
| 217 | 221 | not_queued: "The crew could not take it on right now (its ceiling or breaker); try again later.", |
| 218 | 222 | }; |
| 219 | 223 |
src/fleet/verbs.ts +2
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | * and append their own events; a verb never touches the database itself. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { NOT_A_MEMBER } from "../core/roles.ts"; | |
| 13 | 14 | import type { App } from "../app.ts"; |
| 14 | 15 | import { |
| 15 | 16 | addComment, |
| @@ -108,6 +109,7 @@ export interface Verb { | ||
| 108 | 109 | /** An action's refusal codes, in words a receipt can show. */ |
| 109 | 110 | const WORDS: Record<string, string> = { |
| 110 | 111 | ...dependencyWords, |
| 112 | not_member: NOT_A_MEMBER, | |
| 111 | 113 | not_found: "not found", |
| 112 | 114 | forbidden: "not allowed", |
| 113 | 115 | unauthorized: "not signed in", |
src/http/api.ts +4 −2
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { setRole } from "../actions/roles.ts"; |
| 12 | import { listPeople } from "../core/roles.ts"; | |
| 12 | import { listPeople, NOT_A_MEMBER } from "../core/roles.ts"; | |
| 13 | 13 | import { setAccountCeilings, setAccountGrant } from "../actions/model_grant.ts"; |
| 14 | 14 | import { storageLimitMessage } from "../core/storage.ts"; |
| 15 | 15 | import { voteVariant } from "../actions/variants.ts"; |
| @@ -2246,7 +2246,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2246 | 2246 | ? 200 |
| 2247 | 2247 | : outcome.kind === "ceiling" |
| 2248 | 2248 | ? 429 |
| 2249 | : outcome.kind === "nobody" || outcome.kind === "forbidden" | |
| 2249 | : outcome.kind === "nobody" || outcome.kind === "forbidden" || outcome.kind === "not_member" | |
| 2250 | 2250 | ? 403 |
| 2251 | 2251 | : 409; |
| 2252 | 2252 | return json( |
| @@ -4455,6 +4455,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4455 | 4455 | ? apiError("repository_limit", 403, made.error.message) |
| 4456 | 4456 | : made.error.kind === "storage_limit" |
| 4457 | 4457 | ? apiError("storage_limit", 403, made.error.message) |
| 4458 | : made.error.kind === "not_member" | |
| 4459 | ? apiError("not_member", 403, NOT_A_MEMBER) | |
| 4458 | 4460 | : made.error.kind === "invalid" |
| 4459 | 4461 | ? apiError("invalid", 422, made.error.message) |
| 4460 | 4462 | : made.error.kind === "own" |
src/http/mcp.ts +5 −1
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | * no page. `ro` tokens read; `rw` tokens act. |
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | import { NOT_A_MEMBER } from "../core/roles.ts"; | |
| 20 | 21 | import { voteVariant } from "../actions/variants.ts"; |
| 21 | 22 | import { mayView, mayViewId, sealedScope } from "../core/sealed.ts"; |
| 22 | 23 | import { listAccess } from "../actions/access_log.ts"; |
| @@ -1450,6 +1451,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1450 | 1451 | return bad("not_a_fork", "this repository follows nothing"); |
| 1451 | 1452 | const outcome = await convergeFork(ctx.app, repo, parent, user); |
| 1452 | 1453 | if (outcome.kind === "forbidden") return bad("forbidden", DIRECTION_NOTE); |
| 1454 | if (outcome.kind === "not_member") return bad("not_member", NOT_A_MEMBER); | |
| 1453 | 1455 | return good({ |
| 1454 | 1456 | kind: outcome.kind, |
| 1455 | 1457 | issue: "issue" in outcome ? outcome.issue.number : null, |
| @@ -1702,6 +1704,8 @@ const TOOLS: readonly Tool[] = [ | ||
| 1702 | 1704 | ? bad("invalid", made.error.message) |
| 1703 | 1705 | : made.error.kind === "own" |
| 1704 | 1706 | ? bad("own", "this app is already yours") |
| 1707 | : made.error.kind === "not_member" | |
| 1708 | ? bad("not_member", NOT_A_MEMBER) | |
| 1705 | 1709 | : made.error.kind === "unverified" |
| 1706 | 1710 | ? bad("unverified", "prove your email first") |
| 1707 | 1711 | : bad("not_found", "no such app or repository (owner/name)"); |
| @@ -2342,7 +2346,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2342 | 2346 | properties: { |
| 2343 | 2347 | key: { |
| 2344 | 2348 | 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"], | |
| 2346 | 2350 | }, |
| 2347 | 2351 | value: { type: "string", maxLength: 16 }, |
| 2348 | 2352 | dryRun: { type: "boolean", description: "Plan only (the default). Send false to change it." }, |
src/http/respond.ts +3
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { bodyCapFor, readBounded } from "./limits.ts"; |
| 8 | 8 | import { scopeAllows, type TokenScope } from "../core/tokens.ts"; |
| 9 | import { NOT_A_MEMBER } from "../core/roles.ts"; | |
| 9 | 10 | import { NEW_ACCOUNT_GUARD_MESSAGE } from "../core/capacity.ts"; |
| 10 | 11 | import type { User } from "../core/users.ts"; |
| 11 | 12 | import { crossOrigin, type Ctx } from "./context.ts"; |
| @@ -154,6 +155,8 @@ export function respondAccess(error: string, status: number): Response { | ||
| 154 | 155 | // clients see no difference between the trigger and the setting. |
| 155 | 156 | if (error === "refused") |
| 156 | 157 | return json({ error, message: NEW_ACCOUNT_GUARD_MESSAGE }, 403); |
| 158 | if (error === "not_member") | |
| 159 | return json({ error, message: NOT_A_MEMBER }, 403); | |
| 157 | 160 | if (error === "unauthorized") return unauthorized(); |
| 158 | 161 | if (error === "forbidden") return forbidden(); |
| 159 | 162 | return json({ error }, status); |
src/http/server.ts +3
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * verdict. Auth is resolved once per request and handed to handlers as data. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { NOT_A_MEMBER } from "../core/roles.ts"; | |
| 8 | 9 | import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts"; |
| 9 | 10 | import { toolbarComment, toolbarPage } from "../web/studio/toolbar.ts"; |
| 10 | 11 | import { TOOLBAR_NAME, toolbarResponse } from "../apps/toolbar.ts"; |
| @@ -1374,6 +1375,8 @@ async function withGitAccess( | ||
| 1374 | 1375 | const ownerId = resolveOwner(ctx.app, ctx.auth.user, ctx.params.owner!) ?? ctx.auth.user.id; |
| 1375 | 1376 | return gitRefused(ctx, storageRefusedText(storageSentence(ctx.app, ownerId) ?? "at the limit")); |
| 1376 | 1377 | } |
| 1378 | else if (created.error === "not_member") | |
| 1379 | return gitRefused(ctx, `Not made: ${NOT_A_MEMBER}`); | |
| 1377 | 1380 | else if (created.error === "repository_limit") |
| 1378 | 1381 | return gitRefused( |
| 1379 | 1382 | ctx, |
src/tui/screens/common.ts +2
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * of them. Nothing here knows about repos or issues — that is the point. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { NOT_A_MEMBER } from "../../core/roles.ts"; | |
| 10 | 11 | import type { Ctx, Screen } from "../app/app.ts"; |
| 11 | 12 | import { binding, type Binding } from "../app/keys.ts"; |
| 12 | 13 | import type { KeyEvent } from "../term/input.ts"; |
| @@ -814,6 +815,7 @@ export function slug(path: string): string { | ||
| 814 | 815 | // ---- errors and time ---- |
| 815 | 816 | |
| 816 | 817 | const MESSAGES: Record<string, string> = { |
| 818 | not_member: NOT_A_MEMBER, | |
| 817 | 819 | not_found: "not found", |
| 818 | 820 | signup_closed: SIGNUP_CLOSED_MESSAGE, |
| 819 | 821 | 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 @@ | ||
| 10 | 10 | * to. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { NOT_A_MEMBER } from "../core/roles.ts"; | |
| 13 | 14 | import { holdOpen } from "../http/caller.ts"; |
| 14 | 15 | import { join } from "node:path"; |
| 15 | 16 | import type { App as Forge } from "../app.ts"; |
| @@ -264,6 +265,10 @@ export async function serveGitCommand( | ||
| 264 | 265 | ); |
| 265 | 266 | session.exit(1); |
| 266 | 267 | return; |
| 268 | } else if (created.error === "not_member") { | |
| 269 | session.writeStderr(`forge: Not made: ${NOT_A_MEMBER}${CRLF}`); | |
| 270 | session.exit(1); | |
| 271 | return; | |
| 267 | 272 | } else if (created.error === "repository_limit") { |
| 268 | 273 | session.writeStderr( |
| 269 | 274 | `forge: Not made: ${repositoryLimitMessage(forge, user, resolveOwner(forge, user, parsed.owner) ?? user.id)}${CRLF}`, |
src/web/pages/repo.ts +3
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * path segment and can never smuggle a path of its own. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { NOT_A_MEMBER } from "../../core/roles.ts"; | |
| 11 | 12 | import { storageLimitMessage } from "../../core/storage.ts"; |
| 12 | 13 | import { canRead, listForks, type Repo } from "../../core/repos.ts"; |
| 13 | 14 | import { FORKS_PER_DAY, forkRepo, repositoryLimitMessage } from "../../actions/repos.ts"; |
| @@ -196,6 +197,8 @@ export async function forkSubmit(ctx: Ctx): Promise<Response> { | ||
| 196 | 197 | const reason = |
| 197 | 198 | forked.error === "name_taken" |
| 198 | 199 | ? "You already have a repository by that name." |
| 200 | : forked.error === "not_member" | |
| 201 | ? `Not made: ${NOT_A_MEMBER}.` | |
| 199 | 202 | : forked.error === "repository_limit" |
| 200 | 203 | ? `Not made: ${repositoryLimitMessage(ctx.app, ctx.auth.user)}.` |
| 201 | 204 | : forked.error === "storage_limit" |
src/web/pages/site.ts +2
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * typed still in it. A dead end that loses input is a bug, not a message. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { NOT_A_MEMBER } from "../../core/roles.ts"; | |
| 11 | 12 | import { storageLimitMessage } from "../../core/storage.ts"; |
| 12 | 13 | import { limitValue } from "../../settings/limits.ts"; |
| 13 | 14 | import { signOutHere } from "../../actions/signins.ts"; |
| @@ -740,6 +741,7 @@ export async function newRepoSubmit(ctx: Ctx): Promise<Response> { | ||
| 740 | 741 | if (created.error === "forbidden_owner" || created.error === "unauthorized") |
| 741 | 742 | return new Response("forbidden", { status: 403 }); |
| 742 | 743 | const messages: Record<string, string> = { |
| 744 | not_member: `Not made: ${NOT_A_MEMBER}.`, | |
| 743 | 745 | repository_limit: `Not made: ${repositoryLimitMessage(ctx.app, ctx.auth.user)}.`, |
| 744 | 746 | storage_limit: `Not made: ${storageLimitMessage(ctx.app, resolveOwner(ctx.app, ctx.auth.user, values.owner) ?? ctx.auth.user.id)}.`, |
| 745 | 747 | 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"; | ||
| 18 | 18 | import { html, when } from "../html.ts"; |
| 19 | 19 | import { flashTo, page, redirect } from "../layout.ts"; |
| 20 | 20 | import { btn, card } from "../ui.ts"; |
| 21 | import { NOT_A_MEMBER } from "../../core/roles.ts"; | |
| 21 | 22 | |
| 22 | 23 | // GET /:owner/:name/make-it-yours |
| 23 | 24 | export async function makeItYoursPage(ctx: Ctx): Promise<Response> { |
| @@ -39,7 +40,9 @@ export async function makeItYoursPage(ctx: Ctx): Promise<Response> { | ||
| 39 | 40 | message: |
| 40 | 41 | plan.error.kind === "invalid" |
| 41 | 42 | ? 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.", | |
| 43 | 46 | }); |
| 44 | 47 | } |
| 45 | 48 | const p = plan.value; |
| @@ -130,6 +133,8 @@ export async function makeItYoursSubmit(ctx: Ctx): Promise<Response> { | ||
| 130 | 133 | ? made.error.message |
| 131 | 134 | : made.error.kind === "repository_limit" || made.error.kind === "storage_limit" |
| 132 | 135 | ? `Not made: ${made.error.message}.` |
| 136 | : made.error.kind === "not_member" | |
| 137 | ? `Not made: ${NOT_A_MEMBER}.` | |
| 133 | 138 | : made.error.kind === "own" |
| 134 | 139 | ? "This app is already yours." |
| 135 | 140 | : 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 @@ | ||
| 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"; | |
With this line the guard is off for every suite except |
The checks here are right, but the push-to-create callers of
createRepo(src/http/server.tssmart HTTP,src/tui/ssh.ts) never mapnot_member. A guest pushing a new repository gets a 404 / not-found with no reason. Map it there withNOT_A_MEMBER, and add a test.