Make the forge's tools act as an account of their own, not the administrator (issue 469, ADR 0365 clause 2)
mesh/merge-gate head 48f0662fdea2 merged into trunk 5ce2d07c7f47 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 48f0662fdea2 merged into trunk 5ce2d07c7f47 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/merge-gate head 48f0662fdea2 merged into trunk 5ce2d07c7f47 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 48f0662fdea2 merged into trunk 5ce2d07c7f47 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The tools' token reached everything the administrator may do, so their boundary rested on their own guards. The gitea module now makes a plain account, mesh-tools, with the admin login, gives it write on code, issues and pull requests of every novox repository through one team, and mints the tools' token for it; the admin's old token goes only after the new one is kept. The Go forge bundle sends that token, never the admin login. The forge shows protection rules only to a repository administrator, so every check reads a branch's protection from the branch. mesh-admin stays a ready author, so marks made before the move keep counting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -52,7 +52,10 @@ base64-encoded. The Go bundle `cmd/forge` serves the commit, and the reads an ag
|
||||
| `gitea_search_code` | r | the lines of a ref's text files matching a literal string or a regular expression, optionally under a path |
|
||||
|
||||
**Where a commit may go** is the rule `gitea_api` already holds for a file written (novox/hq ADR 0266):
|
||||
never the repository's default branch, never a branch a protection rule names. A rule covers a branch as the
|
||||
never the repository's default branch, never a branch a protection rule names. The tools' account may not read the
|
||||
rules themselves (the forge keeps them for a repository's administrator, novox/hq ADR 0365): for it an existing branch
|
||||
is judged by the protection the forge says it applies there, and a branch not made yet by the forge itself, which
|
||||
refuses the commit that would make a branch a rule guards. A rule covers a branch as the
|
||||
forge itself decides it (Gitea 1.27, novox/hq issue 539): a plain name covers that branch in any case; a glob
|
||||
(`*`, `**`, `?`, `[abc]`, `[!abc]`, `{a,b}`, `\` escaping) covers case-sensitively with `/` as separator; a glob
|
||||
the forge cannot compile covers its own text. `cmd/forge/rules.go` and `guard.ts` hold this one rule. The rules are read before anything is sent; rules that cannot be read refuse the
|
||||
@@ -66,7 +69,7 @@ the commit the branch (or, for a new branch, its base) was read at, never at its
|
||||
forge's diff endpoint, which applies it; for a small change to a large file this is far shorter than the
|
||||
file. Neither endpoint takes a commit to build on, only a branch (Gitea 1.27), so the parent answered is the
|
||||
one the forge says it made the commit on; when the branch moved between the read and the commit, the answer
|
||||
says so. One commit carries at most 200 files, 4 MiB per file and 16 MiB together, or a patch of 4 MiB. Either way the commit is the forge's own, made as the forge's admin account, so its sha is not the
|
||||
says so. One commit carries at most 200 files, 4 MiB per file and 16 MiB together, or a patch of 4 MiB. Either way the commit is the forge's own, made as the tools' account, so its sha is not the
|
||||
local commit's: the merge check judges the head on the forge. After the write the branch is read back, and a
|
||||
branch not at the commit the forge named is a failure, never a success.
|
||||
|
||||
@@ -75,8 +78,10 @@ is read once and searched in memory; a binary file is passed over, a file over 4
|
||||
`skipped`, and at most `max` matches (default 100) are answered, with `truncated` when more were found. A
|
||||
search that reaches the call's deadline stops there and answers what it found, with `stopped_at_deadline`.
|
||||
|
||||
It reaches the forge through the same HTTP client as the npm-registry bundle (`internal/forgehttp`), and one
|
||||
tool call takes at most 50 seconds.
|
||||
It reaches the forge through the same HTTP client as the npm-registry bundle (`internal/forgehttp`), as the
|
||||
tools' own account (novox/hq ADR 0365 §2, issue 469): the token the TypeScript bundle mints for `mesh-tools` and
|
||||
keeps (`MESH_GITEA_TOKEN_FILE`), sent only when its scopes line names that account, never the admin login. One tool
|
||||
call takes at most 50 seconds.
|
||||
`go test ./cmd/forge` runs it against a fake forge: the files of one commit created, updated and deleted on
|
||||
a new branch from its base; an existing branch committed on; the default branch, a protected branch and
|
||||
unreadable rules refused before anything is sent; a patch sent to the forge; a branch read back at another
|
||||
|
||||
+51
-12
@@ -8,6 +8,7 @@ import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
|
||||
import { protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js";
|
||||
import { Refused, checkSentPath, pathSegment, pathSegments } from "./guard.js";
|
||||
import { isCommit } from "./checks.js";
|
||||
import { TOOLS_ACCOUNT } from "./tools-account.js";
|
||||
|
||||
/** A repository's path on the forge, its owner and name each one segment (guard.ts pathSegment): uniform for every
|
||||
* call, whether the names came from a caller or from the forge's own answer. */
|
||||
@@ -124,10 +125,12 @@ export interface ReadyMark {
|
||||
|
||||
/** **The accounts whose "ready at" comment counts** (novox/hq issue 722): the trusted setting ready-authors, which
|
||||
* the mesh writes to the file MESH_GITEA_READY_AUTHORS_FILE names as a line `authors=<account> <account> …`. A file
|
||||
* that cannot be read leaves the mesh's own forge account alone, said; never an empty list read as everyone. */
|
||||
* that cannot be read leaves the mesh's own forge accounts alone, said; never an empty list read as everyone: the
|
||||
* account the tools act as (novox/hq ADR 0365) and the admin account, whose marks were made before it. */
|
||||
export function readyAuthors(file = process.env.MESH_GITEA_READY_AUTHORS_FILE, own = process.env.MESH_GITEA_ADMIN_USER,
|
||||
read: (f: string) => string = (f) => readFileSync(f, "utf8")): string[] {
|
||||
const fallback = own ? [own] : [];
|
||||
read: (f: string) => string = (f) => readFileSync(f, "utf8"),
|
||||
tools = process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT): string[] {
|
||||
const fallback = [...new Set([tools, own].filter((a): a is string => !!a))];
|
||||
if (!file) return fallback;
|
||||
try {
|
||||
const line = read(file).split("\n").find((l) => l.startsWith("authors="));
|
||||
@@ -444,13 +447,15 @@ export class GiteaClient {
|
||||
* fault in the announcer cannot hide itself. Reads only. */
|
||||
async openPulls(readyAuthors: string[] = []): Promise<OpenPull[]> {
|
||||
const out: OpenPull[] = [];
|
||||
const rules = new Map<string, BranchProtection[] | undefined>();
|
||||
const rules = new Map<string, BranchProtection | null | undefined>();
|
||||
for (const found of await this.searchOpenPulls()) {
|
||||
const full = `${found.owner}/${found.name}`;
|
||||
const pull = await this.getPullRequest(found.owner, found.name, found.number);
|
||||
if (pull.state !== "open" || !pull.head_sha) continue;
|
||||
if (!rules.has(full)) rules.set(full, await this.branchProtections(found.owner, found.name).catch(() => undefined));
|
||||
const rule = rules.get(full)?.find((p) => (p.rule_name ?? p.branch_name) === (pull.base || "main"));
|
||||
// The base's protection as the forge applies it to that branch (appliedProtection): readable without administration.
|
||||
const base = `${full}:${pull.base || "main"}`;
|
||||
if (!rules.has(base)) rules.set(base, await this.appliedProtection(found.owner, found.name, pull.base || "main").catch(() => undefined));
|
||||
const rule = rules.get(base);
|
||||
const checks = await this.headChecks(found.owner, found.name, pull.head_sha);
|
||||
const labels = found.labels ?? [];
|
||||
// The ready mark (novox/hq issue 722): its timeline is read, whole, only for a pull request that carries the label.
|
||||
@@ -673,16 +678,44 @@ export class GiteaClient {
|
||||
|
||||
// ---- Branch protection ----
|
||||
|
||||
/** A repository's branch protection rules. */
|
||||
/** A repository's branch protection rules. The forge answers them to a repository's administrator alone, which the
|
||||
* tools' account is not (novox/hq ADR 0365 §2): for it this is refused (403), and what a branch's protection asks
|
||||
* is read with appliedProtection. */
|
||||
async branchProtections(owner: string, repo: string): Promise<BranchProtection[]> {
|
||||
return (await this.request<BranchProtection[]>(`${repoPath(owner, repo)}/branch_protections`)) ?? [];
|
||||
}
|
||||
|
||||
/** The rule named for a branch, null when it has none. */
|
||||
/** The rule named for a branch, null when it has none — from the rules' list, so a repository administrator's. */
|
||||
async branchProtection(owner: string, repo: string, rule: string): Promise<BranchProtection | null> {
|
||||
return (await this.branchProtections(owner, repo)).find((p) => (p.rule_name ?? p.branch_name) === rule) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The protection that covers an existing branch, as the forge applies it, or null when none does. Read from the
|
||||
* branch itself, which anyone who reads the code may read — the rules' own list is a repository administrator's
|
||||
* (novox/hq ADR 0365 §2) — and as the forge matches it, a glob rule included (its name is the rule's). The
|
||||
* branch's `user_can_push` stands for `enable_push`: whether this account may push there. A branch that does not
|
||||
* exist is null: nothing covers it yet, and the forge judges a push that makes it.
|
||||
*/
|
||||
async appliedProtection(owner: string, repo: string, branch: string): Promise<BranchProtection | null> {
|
||||
let b: any;
|
||||
try {
|
||||
b = await this.request<any>(`${repoPath(owner, repo)}/branches/${pathSegments("branch", branch)}`);
|
||||
} catch (err) {
|
||||
if (/: 404 /.test(String((err as Error)?.message))) return null;
|
||||
throw err;
|
||||
}
|
||||
if (!b?.protected) return null;
|
||||
return {
|
||||
rule_name: String(b.effective_branch_protection_name || branch),
|
||||
branch_name: branch,
|
||||
enable_push: Boolean(b.user_can_push),
|
||||
enable_status_check: Boolean(b.enable_status_check),
|
||||
status_check_contexts: Array.isArray(b.status_check_contexts) ? b.status_check_contexts.map(String) : [],
|
||||
required_approvals: Number(b.required_approvals ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** Make a branch require these commit statuses to merge (novox/hq ADR 0237): the rule named for the
|
||||
* branch is edited — its required statuses replaced by these, everything else it says kept unless
|
||||
* asked — or created, refusing direct pushes unless asked otherwise. Answers the rule as it is now. */
|
||||
@@ -948,12 +981,16 @@ export class GiteaAdmin {
|
||||
// unit the team does not have — so code read must be said here: without it gitea answers a
|
||||
// member's clone of a private repository with "not found", which is how the builder's first
|
||||
// credentialed clone failed against a team that named only packages.
|
||||
const units = {
|
||||
return this.ensureTeamWith(org, team, {
|
||||
permission: "read",
|
||||
units_map: { "repo.code": "read", "repo.packages": packageWrite ? "write" : "read" },
|
||||
includes_all_repositories: true,
|
||||
can_create_org_repo: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Ensure a team exists with exactly these units, and return its id (ensureTeam; the tools' team, tools-account.ts). */
|
||||
async ensureTeamWith(org: string, team: string, units: Record<string, unknown>): Promise<number> {
|
||||
const found = await this.findTeam(org, team);
|
||||
if (found !== null) {
|
||||
const patch = await this.request(`/teams/${found}`, {
|
||||
@@ -988,7 +1025,7 @@ export class GiteaAdmin {
|
||||
* The edit path is taken only when the user actually exists. A 422 from the create is also what
|
||||
* a plain validation failure returns, and reading it as "already there" made the follow-up edit
|
||||
* 404 — burying the create's own message, which is the one that says what is actually wrong. */
|
||||
async ensureUser(username: string, password: string, email: string): Promise<void> {
|
||||
async ensureUser(username: string, password: string, email: string, plain = false): Promise<void> {
|
||||
const res = await this.request("/admin/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, email, password, must_change_password: false }),
|
||||
@@ -1002,7 +1039,9 @@ export class GiteaAdmin {
|
||||
// login_name is required by the admin edit endpoint; for a local user it is the username.
|
||||
// active and prohibit_login: a deactivated or login-prohibited user is refused like a wrong
|
||||
// password, so the provisioner's check reports it lost; applying again must undo both.
|
||||
body: JSON.stringify({ login_name: username, password, must_change_password: false, active: true, prohibit_login: false }),
|
||||
// plain: never a site administrator — one made so by hand is made a plain account again (novox/hq ADR 0365).
|
||||
body: JSON.stringify({ login_name: username, password, must_change_password: false, active: true, prohibit_login: false,
|
||||
...(plain ? { admin: false } : {}) }),
|
||||
});
|
||||
if (patch.status === 200) return;
|
||||
GiteaAdmin.fail(`/admin/users/${username}`, patch);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
// The commit and the reads act as the tools' own account (novox/hq ADR 0365 §2, issue 469): the token the module
|
||||
// keeps for `mesh-tools`, never the admin login. That account is no repository's administrator, so the forge
|
||||
// refuses it the protection rules (403, as gitea's reqAdmin does); the commit then judges an existing branch by the
|
||||
// protection the forge says it applies, and leaves a branch not made yet to the forge's own judgement.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea/internal/forgehttp"
|
||||
)
|
||||
|
||||
const toolsToken = "tools-token-never-shown"
|
||||
|
||||
// asTools serves the fake forge to a bundle holding the tools' token: only that token is taken, the rules are
|
||||
// refused as to a non-administrator, and the branches named protected say so as the forge does.
|
||||
func asTools(t *testing.T, f *fakeForge, protected map[string]string, scopes string) (*Forge, *[]string) {
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "token")
|
||||
if err := os.WriteFile(file, []byte(toolsToken+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(file+".scopes", []byte(scopes+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var seen []string
|
||||
const repo = "/api/v1/repos/acme/app"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = append(seen, r.Method+" "+r.URL.Path)
|
||||
if _, _, basic := r.BasicAuth(); basic || r.Header.Get("Authorization") != "token "+toolsToken {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == repo+"/branch_protections" {
|
||||
http.Error(w, `{"message":"token does not have the administration of this repository"}`, 403)
|
||||
return
|
||||
}
|
||||
if name := strings.TrimPrefix(r.URL.Path, repo+"/branches/"); r.Method == "GET" && name != r.URL.Path {
|
||||
if rule, ok := protected[name]; ok {
|
||||
_, _ = w.Write([]byte(`{"name":"` + name + `","commit":{"id":"c0"},"protected":true,"effective_branch_protection_name":"` + rule + `"}`))
|
||||
return
|
||||
}
|
||||
}
|
||||
r.SetBasicAuth("mesh-admin", f.password) // the fake's own door, past the token checked above
|
||||
f.handle(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return &Forge{Client: forgehttp.Client{URL: srv.URL, TokenFile: file, Account: "mesh-tools"}}, &seen
|
||||
}
|
||||
|
||||
func TestTheToolsAccountCommitsOnANewBranchWithItsTokenThoughTheRulesAreNotItsToRead(t *testing.T) {
|
||||
f := newFake(t)
|
||||
forge, _ := asTools(t, f, nil, "read:user,write:issue,write:repository for mesh-tools")
|
||||
got, err := commit(context.Background(), forge, ask(t, map[string]any{"branch": "fix/365",
|
||||
"files": filesArg(map[string]any{"path": "x", "content": "x"})}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.writes) != 1 || !got.Created {
|
||||
t.Fatalf("%d writes, %+v", len(f.writes), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheToolsAccountIsRefusedAProtectedBranchTheForgeSaysItGuardsAndTheDefaultBranch(t *testing.T) {
|
||||
for branch, rule := range map[string]string{"release/1.0": "release/*", "fix/1-old": "fix/1-old", "main": ""} {
|
||||
f := newFake(t)
|
||||
protected := map[string]string{}
|
||||
if rule != "" {
|
||||
protected[branch] = rule
|
||||
}
|
||||
forge, _ := asTools(t, f, protected, "read:user,write:issue,write:repository for mesh-tools")
|
||||
_, err := commit(context.Background(), forge, ask(t, map[string]any{"branch": branch,
|
||||
"files": filesArg(map[string]any{"path": "x", "content": "x"})}))
|
||||
if err == nil || !strings.Contains(err.Error(), "refused") || !strings.Contains(err.Error(), "ADR 0266") {
|
||||
t.Errorf("%s: %v", branch, err)
|
||||
}
|
||||
if rule != "" && !strings.Contains(err.Error(), rule) {
|
||||
t.Errorf("%s: the rule %s is not named: %v", branch, rule, err)
|
||||
}
|
||||
if len(f.writes)+len(f.patches) != 0 {
|
||||
t.Errorf("%s: something was written", branch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestATokenKeptForTheAdministratorIsNeverSent(t *testing.T) {
|
||||
f := newFake(t)
|
||||
// What the module kept before ADR 0365: the admin's token, its scopes line naming no account.
|
||||
forge, seen := asTools(t, f, nil, "read:user,write:issue,write:repository")
|
||||
_, err := commit(context.Background(), forge, ask(t, map[string]any{"branch": "fix/365",
|
||||
"files": filesArg(map[string]any{"path": "x", "content": "x"})}))
|
||||
if err == nil || !strings.Contains(err.Error(), "not mesh-tools's") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if len(*seen) != 0 {
|
||||
t.Fatalf("the forge was asked %v", *seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBundleHandedATokenFileNeedsNoAdminLogin(t *testing.T) {
|
||||
t.Setenv("MESH_GITEA_URL", "http://127.0.0.1:3000")
|
||||
t.Setenv("MESH_GITEA_TOKEN_FILE", "/state/token")
|
||||
t.Setenv("MESH_GITEA_TOOLS_USER", "")
|
||||
t.Setenv("MESH_GITEA_ADMIN_USER", "")
|
||||
t.Setenv("MESH_GITEA_ADMIN_PASSWORD_FILE", "")
|
||||
f, err := ForgeFromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.TokenFile != "/state/token" || f.Account != "mesh-tools" || f.User != "" || f.PasswordFile != "" {
|
||||
t.Fatalf("%+v", f.Client)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea/internal/forgehttp"
|
||||
)
|
||||
|
||||
// gitea_commit (novox/hq issue 375): one commit on a branch of its own, so an agent follows the branch / pull
|
||||
@@ -211,7 +213,32 @@ func commit(ctx context.Context, f *Forge, a CommitAsk) (*Committed, error) {
|
||||
// Read the rules, or refuse: a branch the rules could not be read for is never written (fails closed).
|
||||
var rules []Protection
|
||||
if err := f.GetJSON(ctx, repo+"/branch_protections", &rules); err != nil {
|
||||
return nil, fmt.Errorf("the protection rules of %s/%s could not be read, so the commit was not judged and nothing was sent: %w", a.Owner, a.Repo, err)
|
||||
var status *forgehttp.StatusError
|
||||
if !errors.As(err, &status) || status.Status != http.StatusForbidden {
|
||||
return nil, fmt.Errorf("the protection rules of %s/%s could not be read, so the commit was not judged and nothing was sent: %w", a.Owner, a.Repo, err)
|
||||
}
|
||||
// The rules are a repository administrator's to read, and the tools' account is none (novox/hq ADR 0365
|
||||
// §2). What it may read is the protection the forge applies to an existing branch: one protected is
|
||||
// refused here. A branch not made yet is covered by no rule this account can see, and the forge itself
|
||||
// judges the commit that makes it against every rule, refusing one a rule guards.
|
||||
if err := targetRefused(a.Branch, r.DefaultBranch, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := f.branch(ctx, repo, a.Branch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("whether a protection rule guards %s of %s/%s could not be read, so nothing was sent: %w", a.Branch, a.Owner, a.Repo, err)
|
||||
}
|
||||
if b != nil && b.Protected {
|
||||
rule := b.Rule
|
||||
if rule == "" {
|
||||
rule = a.Branch
|
||||
}
|
||||
rules = []Protection{{RuleName: rule}}
|
||||
if !ruleCovers(rule, a.Branch) {
|
||||
return nil, refuse("a commit on %s is refused: the protection rule %s guards it (novox/hq ADR 0266). Put it on a "+
|
||||
"branch of its own. Nothing was sent", a.Branch, rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := targetRefused(a.Branch, r.DefaultBranch, rules); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"gitea/internal/forgehttp"
|
||||
)
|
||||
|
||||
// The forge as this bundle reaches it: its own HTTP interface on the machine, as the forge's admin account
|
||||
// (internal/forgehttp, shared with the npm-registry bundle beside it).
|
||||
// The forge as this bundle reaches it: its own HTTP interface on the machine, as the tools' own account (novox/hq
|
||||
// ADR 0365 §2; internal/forgehttp, shared with the npm-registry bundle beside it).
|
||||
|
||||
// Forge is the forge's HTTP interface.
|
||||
type Forge struct {
|
||||
@@ -91,12 +91,15 @@ func (p Protection) name() string {
|
||||
return p.BranchName
|
||||
}
|
||||
|
||||
// Branch is one branch with the commit it points at.
|
||||
// Branch is one branch with the commit it points at, and the protection the forge applies to it: what an account
|
||||
// that may not read the rules themselves (the tools' account, novox/hq ADR 0365) knows of them.
|
||||
type Branch struct {
|
||||
Name string `json:"name"`
|
||||
Commit struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"commit"`
|
||||
Protected bool `json:"protected"`
|
||||
Rule string `json:"effective_branch_protection_name"`
|
||||
}
|
||||
|
||||
// branch is the named branch, nil when the forge has none of that name.
|
||||
|
||||
@@ -619,11 +619,17 @@ func TestToolsAreTheOnesTheReadmeNamesAndTheManifestBuilds(t *testing.T) {
|
||||
if len(a.Loads) != 1 || a.Loads[0] != a.Binary {
|
||||
t.Errorf("the bundle loads %v, not its binary %s", a.Loads, a.Binary)
|
||||
}
|
||||
for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE"} {
|
||||
// The tools' own account, never the admin login (novox/hq ADR 0365 §2).
|
||||
for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_TOOLS_USER", "MESH_GITEA_TOKEN_FILE"} {
|
||||
if a.Env[k] == "" {
|
||||
t.Errorf("the bundle is not given %s", k)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE"} {
|
||||
if a.Env[k] != "" {
|
||||
t.Errorf("the bundle is given %s: it acts as the tools' account, not the administrator", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
|
||||
+1
-17
@@ -42,7 +42,7 @@ import { commitStatusTool } from "./delivery.js";
|
||||
import { mergeTool } from "./merge.js";
|
||||
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
|
||||
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked,
|
||||
type RepoCheckFacts, reposToLook } from "./pulls.js";
|
||||
repoCheckFacts, reposToLook } from "./pulls.js";
|
||||
import { GateWriter, clonedAs, type BuildStarted } from "./gate.js";
|
||||
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
|
||||
import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js";
|
||||
@@ -400,22 +400,6 @@ async function setVerdict(client: GiteaClient, event: { body: unknown }): Promis
|
||||
console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}`);
|
||||
}
|
||||
|
||||
// **What only the forge knows of a repository check said as a warning** (novox/hq issue 293): whether the
|
||||
// head holds a merge-check.sh at all, and — when it does not — whether the base branch's protection
|
||||
// requires mesh/repo-check. Asked only for a warning; unknown is left undefined, which statusesFor reads
|
||||
// as possibly required: a failure on a status nothing requires blocks nothing, a success on one that is
|
||||
// required would let an untested repository merge.
|
||||
async function repoCheckFacts(client: GiteaClient, c: Checked, base: string | undefined): Promise<RepoCheckFacts> {
|
||||
if (c["repo-check"]?.verdict !== "warning") return {};
|
||||
const defined = await client.holdsFile(c.owner, c.repo, c.commit, "merge-check.sh").catch(() => undefined);
|
||||
if (defined !== false) return { defined };
|
||||
const rules = await client.branchProtections(c.owner, c.repo).catch(() => undefined);
|
||||
if (!rules) return { defined };
|
||||
const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === (base || "main"));
|
||||
const required = !!rule?.enable_status_check && (rule.status_check_contexts ?? []).includes(REPO_CHECK_CONTEXT);
|
||||
return { defined, required };
|
||||
}
|
||||
|
||||
// **This module is the one writer of a head's mesh/merge-gate** (novox/hq issue 446): the announcement's pending words,
|
||||
// where the check is when a build machine takes it, and the verdict — each head's writes in one chain, the verdict last
|
||||
// (gate.ts). A second writer elsewhere raced the verdict and could leave a pass reading pending.
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
// Package forgehttp is the forge's HTTP interface as this module's Go bundles reach it (cmd/forge,
|
||||
// cmd/npm-registry): the forge on its own machine, as its admin account, whose password the vault delivered
|
||||
// into a file this module owns (novox/hq ADR 0086), by basic authentication. The password is read when a call
|
||||
// is made and never logged, answered or put in an error. One copy for both bundles (novox/hq issue 539).
|
||||
// cmd/npm-registry), on the forge's own machine. One copy for both bundles (novox/hq issue 539).
|
||||
//
|
||||
// Two ways in, by what the manifest hands the bundle:
|
||||
//
|
||||
// - **as the tools' account** (novox/hq ADR 0365 §2), when MESH_GITEA_TOKEN_FILE names the token the module
|
||||
// keeps for it (token.ts mints it, for `mesh-tools`, and keeps it at 0600 with its scopes line beside it). A
|
||||
// token whose scopes line does not say it is the tools' account's — one kept from before, the administrator's —
|
||||
// is never sent: the call fails, saying so, until the module has minted the account's own. This bundle mints
|
||||
// nothing; a token the forge rejects is renewed by the module's next call, and said here.
|
||||
// - **as the admin account**, whose password the vault delivered into a file this module owns (novox/hq ADR
|
||||
// 0086), by basic authentication, when no token file is named.
|
||||
//
|
||||
// A credential is read when a call is made and never logged, answered or put in an error.
|
||||
package forgehttp
|
||||
|
||||
import (
|
||||
@@ -25,12 +35,31 @@ type Client struct {
|
||||
URL string // e.g. http://127.0.0.1:3000
|
||||
User string
|
||||
PasswordFile string
|
||||
HTTP *http.Client // when nil, a client with Timeout
|
||||
Timeout time.Duration // one request's limit when HTTP is nil; 20 s when zero
|
||||
// TokenFile, when set, is the tools' account's token (MESH_GITEA_TOKEN_FILE), sent instead of the admin login;
|
||||
// Account the account its scopes line must name (MESH_GITEA_TOOLS_USER, default mesh-tools).
|
||||
TokenFile string
|
||||
Account string
|
||||
HTTP *http.Client // when nil, a client with Timeout
|
||||
Timeout time.Duration // one request's limit when HTTP is nil; 20 s when zero
|
||||
}
|
||||
|
||||
// FromEnv is the forge as the manifest hands it to a bundle, and the names of the variables it left unset.
|
||||
// ToolsAccount is the account the forge's tools act as when the manifest names none (token.ts, tools-account.ts).
|
||||
const ToolsAccount = "mesh-tools"
|
||||
|
||||
// FromEnv is the forge as the manifest hands it to a bundle, and the names of the variables it left unset. A bundle
|
||||
// handed MESH_GITEA_TOKEN_FILE acts as the tools' account and needs no admin login.
|
||||
func FromEnv() (Client, []string) {
|
||||
if file := os.Getenv("MESH_GITEA_TOKEN_FILE"); file != "" {
|
||||
c := Client{URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"), TokenFile: file,
|
||||
Account: os.Getenv("MESH_GITEA_TOOLS_USER")}
|
||||
if c.Account == "" {
|
||||
c.Account = ToolsAccount
|
||||
}
|
||||
if c.URL == "" {
|
||||
return c, []string{"MESH_GITEA_URL"}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
c := Client{
|
||||
URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"),
|
||||
User: os.Getenv("MESH_GITEA_ADMIN_USER"),
|
||||
@@ -80,14 +109,45 @@ func (c *Client) password() (string, error) {
|
||||
return strings.TrimSpace(string(raw)), nil
|
||||
}
|
||||
|
||||
// token is the tools' account's token, refused unless its scopes line names that account.
|
||||
func (c *Client) token() (string, error) {
|
||||
raw, err := os.ReadFile(c.TokenFile)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the tools' token is not kept yet (the module mints it for %s at its first call): %w", c.Account, err)
|
||||
}
|
||||
line, err := os.ReadFile(c.TokenFile + ".scopes")
|
||||
if err != nil || !strings.HasSuffix(strings.TrimSpace(string(line)), " for "+c.Account) {
|
||||
return "", fmt.Errorf("the token kept at %s is not %s's (novox/hq ADR 0365): the module mints that account's own at its next call; nothing was sent", c.TokenFile, c.Account)
|
||||
}
|
||||
token := strings.TrimSpace(string(raw))
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("the token kept at %s is empty; nothing was sent", c.TokenFile)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// authorize puts the bundle's credential on a request: the tools' token, or the admin login.
|
||||
func (c *Client) authorize(req *http.Request) error {
|
||||
if c.TokenFile != "" {
|
||||
token, err := c.token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+token)
|
||||
return nil
|
||||
}
|
||||
password, err := c.password()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.SetBasicAuth(c.User, password)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Do sends one request, with payload as its JSON body when not nil, and answers the body, read up to limit;
|
||||
// 404 is ErrNotFound. One byte past the limit is read, so a body the limit cut is told from one that fits it
|
||||
// exactly: a cut body is refused, never read as if it were whole.
|
||||
func (c *Client) Do(ctx context.Context, method, path string, payload any, limit int64) ([]byte, error) {
|
||||
password, err := c.password()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
raw, err := json.Marshal(payload)
|
||||
@@ -100,7 +160,9 @@ func (c *Client) Do(ctx context.Context, method, path string, payload any, limit
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(c.User, password)
|
||||
if err := c.authorize(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -118,6 +180,9 @@ func (c *Client) Do(ctx context.Context, method, path string, payload any, limit
|
||||
return nil, fmt.Errorf("the forge's answer to %s %s is larger than %d MiB and was not read whole", method, path, limit>>20)
|
||||
}
|
||||
switch {
|
||||
case res.StatusCode == http.StatusUnauthorized && c.TokenFile != "":
|
||||
return nil, fmt.Errorf("the forge rejected the tools' token for %s (401): the module mints it afresh at its next call; nothing was done: %w",
|
||||
method+" "+path, &StatusError{method, path, res.StatusCode, "unauthorized"})
|
||||
case res.StatusCode == http.StatusNotFound:
|
||||
return nil, fmt.Errorf("%s %s: %w", method, path, ErrNotFound)
|
||||
case res.StatusCode >= 300:
|
||||
|
||||
@@ -54,9 +54,10 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
||||
const method = args.method ? String(args.method) : "merge";
|
||||
if (!MERGE_METHODS.has(method)) throw new Refused(`${method} is not a merge method: ${[...MERGE_METHODS].join(", ")}`);
|
||||
const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch);
|
||||
// **Only a head whose required statuses all succeeded** (novox/hq ADR 0266): read here rather than left
|
||||
// to the forge, whose administrator — the account this tool's token is minted for — may merge past a
|
||||
// rule that lets it. A rule that cannot be read is no rule passed.
|
||||
// **Only a head whose required statuses all succeeded** (novox/hq ADR 0266): read here as well as by the
|
||||
// forge, which holds the tools' own account to it (novox/hq ADR 0365 §2). The protection is the one the forge
|
||||
// applies to the base, read from the branch: the rules' own list is a repository administrator's. A rule that
|
||||
// cannot be read is no rule passed.
|
||||
// **Only the head that was reviewed** (novox/hq issue 381): mesh-controller#197 was reviewed at 065cfa0d,
|
||||
// force-pushed to 3ced96f, checked green there, and merged at 3ced96f, because this tool merged whatever the
|
||||
// head was and nothing told it which head the review had approved. The caller names it; a head that moved
|
||||
@@ -80,7 +81,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its head is ${pull.head_sha.slice(0, 12)}, not the ` +
|
||||
`${named} you named. If you reviewed an earlier head, review the one it has now, then name it. Nothing was merged`);
|
||||
}
|
||||
const rule = await gitea.branchProtection(owner, repo, pull.base);
|
||||
const rule = await gitea.appliedProtection(owner, repo, pull.base);
|
||||
// Only into a branch whose protection requires the checks: a pull request whose base was moved to a
|
||||
// branch nothing guards would merge past every check (novox/hq ADR 0266).
|
||||
if (!rule || !rule.enable_status_check || !(rule.status_check_contexts ?? []).filter(Boolean).length) {
|
||||
|
||||
@@ -159,8 +159,8 @@
|
||||
"settings": {
|
||||
"ready-authors": {
|
||||
"kind": "preference",
|
||||
"default": "mesh-admin jschoubben",
|
||||
"why": "the forge accounts whose comment \"ready at <full head sha>\" marks a pull request ready for mesh-delivery to merge, separated by spaces: the mesh's own forge account and the operator's (novox/hq issue 722). A comment by any other account is ignored and said in the forge's log. Trusted: who may have a change merged is the operator's word"
|
||||
"default": "mesh-tools mesh-admin jschoubben",
|
||||
"why": "the forge accounts whose comment \"ready at <full head sha>\" marks a pull request ready for mesh-delivery to merge, separated by spaces: the account the forge's tools act as (novox/hq ADR 0365), the admin account for marks made before the tools acted as their own, and the operator's (novox/hq issue 722). A comment by any other account is ignored and said in the forge's log. Trusted: who may have a change merged is the operator's word"
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
@@ -308,6 +308,7 @@
|
||||
"MESH_GITEA_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||
"MESH_GITEA_ADMIN_USER": "mesh-admin",
|
||||
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
|
||||
"MESH_GITEA_TOOLS_USER": "mesh-tools",
|
||||
"MESH_GITEA_STATE_DIR": "${dir:runtime-state}",
|
||||
"MESH_RECEIVES": "${dir:grants}/npm.json",
|
||||
"MESH_GITEA_READY_AUTHORS_FILE": "${dir:mesh-state}/ready-authors",
|
||||
@@ -345,8 +346,8 @@
|
||||
],
|
||||
"env": {
|
||||
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
|
||||
"MESH_GITEA_ADMIN_USER": "mesh-admin",
|
||||
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret"
|
||||
"MESH_GITEA_TOOLS_USER": "mesh-tools",
|
||||
"MESH_GITEA_TOKEN_FILE": "${dir:runtime-state}/token"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+19
-1
@@ -9,7 +9,7 @@
|
||||
//
|
||||
// Pure functions here, so they are tested without a forge; index.ts does the asking and the setting.
|
||||
|
||||
import type { CommitStatus, GiteaPull, GiteaRepo } from "./client.js";
|
||||
import type { CommitStatus, GiteaClient, GiteaPull, GiteaRepo } from "./client.js";
|
||||
import { movedSince } from "./client.js";
|
||||
|
||||
/** The status context a merge check's gate is kept under: one per commit, the newest replacing the last. */
|
||||
@@ -176,6 +176,24 @@ export interface RepoCheckFacts {
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
// **What only the forge knows of a repository check said as a warning** (novox/hq issue 293): whether the
|
||||
// head holds a merge-check.sh at all, and — when it does not — whether the base branch's protection
|
||||
// requires mesh/repo-check. Asked only for a warning; unknown is left undefined, which statusesFor reads
|
||||
// as possibly required: a failure on a status nothing requires blocks nothing, a success on one that is
|
||||
// required would let an untested repository merge. The base's protection is the one the forge applies, read
|
||||
// from the branch: the rules' own list is a repository administrator's, which the tools' account is not
|
||||
// (novox/hq ADR 0365).
|
||||
export async function repoCheckFacts(
|
||||
forge: Pick<GiteaClient, "holdsFile" | "appliedProtection">, c: Checked, base: string | undefined): Promise<RepoCheckFacts> {
|
||||
if (c["repo-check"]?.verdict !== "warning") return {};
|
||||
const defined = await forge.holdsFile(c.owner, c.repo, c.commit, "merge-check.sh").catch(() => undefined);
|
||||
if (defined !== false) return { defined };
|
||||
const rule = await forge.appliedProtection(c.owner, c.repo, base || "main").catch(() => undefined);
|
||||
if (rule === undefined) return { defined };
|
||||
const required = !!rule?.enable_status_check && (rule.status_check_contexts ?? []).includes(REPO_CHECK_CONTEXT);
|
||||
return { defined, required };
|
||||
}
|
||||
|
||||
/** The description of a required repository check that is not defined. */
|
||||
export const UNDEFINED_REQUIRED = "fail: mesh/repo-check is required here and this head defines no merge-check.sh — " +
|
||||
"a person adds one, or lifts the requirement from the branch's protection";
|
||||
|
||||
@@ -47,11 +47,18 @@ test("every open pull request is read with what its base requires and what its h
|
||||
if (url.startsWith("/api/v1/repos/novox/mesh-sdk/pulls/14")) {
|
||||
return send({ number: 14, state: "open", base: { ref: "main" }, head: { ref: "y", sha: "bbb2222" }, html_url: "s" });
|
||||
}
|
||||
if (url.startsWith("/api/v1/repos/novox/hq/branch_protections")) {
|
||||
return send([{ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/repo-check"] }]);
|
||||
// The base's protection, read from the branch as the tools' account may (novox/hq ADR 0365); the rules' list is refused it.
|
||||
if (url.includes("/branch_protections")) {
|
||||
res.statusCode = 403;
|
||||
return res.end(JSON.stringify({ message: "the rules are a repository administrator's" }));
|
||||
}
|
||||
if (url.startsWith("/api/v1/repos/novox/mesh-sdk/branch_protections")) {
|
||||
return send([{ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] }]);
|
||||
if (url.startsWith("/api/v1/repos/novox/hq/branches/main")) {
|
||||
return send({ name: "main", protected: true, effective_branch_protection_name: "main", enable_status_check: true,
|
||||
status_check_contexts: ["mesh/repo-check"] });
|
||||
}
|
||||
if (url.startsWith("/api/v1/repos/novox/mesh-sdk/branches/main")) {
|
||||
return send({ name: "main", protected: true, effective_branch_protection_name: "main", enable_status_check: true,
|
||||
status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] });
|
||||
}
|
||||
if (url.includes("/commits/aaa1111/status")) return send({ statuses: [] });
|
||||
if (url.includes("/commits/bbb2222/status")) return send({ statuses: [{ context: "mesh/merge-gate", status: "pending",
|
||||
@@ -251,6 +258,8 @@ test("a long thread hides nothing: every page of the timeline is read", async ()
|
||||
test("the accounts that may mark are the setting's, and only the mesh's own when it cannot be read", async () => {
|
||||
const { readyAuthors } = await import("../client.ts");
|
||||
assert.deepEqual(readyAuthors("f", "mesh-admin", () => "# x\nauthors=mesh-admin jschoubben\n"), ["mesh-admin", "jschoubben"]);
|
||||
assert.deepEqual(readyAuthors("f", "mesh-admin", () => { throw new Error("gone"); }), ["mesh-admin"]);
|
||||
assert.deepEqual(readyAuthors("f", "mesh-admin", () => "authors=\n"), ["mesh-admin"]);
|
||||
// The tools' account and the admin's, whose marks were made before ADR 0365 (issue 469).
|
||||
assert.deepEqual(readyAuthors("f", "mesh-admin", () => { throw new Error("gone"); }, "mesh-tools"), ["mesh-tools", "mesh-admin"]);
|
||||
assert.deepEqual(readyAuthors("f", "mesh-admin", () => "authors=\n", "mesh-tools"), ["mesh-tools", "mesh-admin"]);
|
||||
assert.deepEqual(readyAuthors(undefined, "mesh-admin", undefined, "mesh-tools"), ["mesh-tools", "mesh-admin"]);
|
||||
});
|
||||
|
||||
@@ -30,6 +30,9 @@ const state = {
|
||||
// What the tools said on the bus.
|
||||
emitted: [] as { type: string; body: any }[],
|
||||
rules: [] as any[],
|
||||
// Whether the token is a repository administrator's. The tools' account is not (novox/hq ADR 0365): the forge
|
||||
// refuses it the rules' list, as gitea's reqAdmin does, and it reads a branch's protection from the branch.
|
||||
admin: false,
|
||||
lastCommit: "2026-01-01T00:00:00Z",
|
||||
// The pull request's head; the head the merge commit joined (the same, unless a test says otherwise) — the forge's
|
||||
// record of the pull request keeps saying `head` either way, as it would in the race issue 381 is about; and the
|
||||
@@ -59,6 +62,7 @@ beforeEach(() => {
|
||||
state.trunk = TRUNK;
|
||||
state.emitted = [];
|
||||
state.rules = [protectedMain()];
|
||||
state.admin = false;
|
||||
state.head = REVIEWED;
|
||||
state.landed = "";
|
||||
state.merged = false;
|
||||
@@ -87,8 +91,18 @@ const server = createServer(async (req, res) => {
|
||||
if (method !== "GET") state.sent.push({ method, path, body });
|
||||
const repo = "/repos/novox/mesh-catalog";
|
||||
if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: "main" });
|
||||
if (path.startsWith(`${repo}/branch_protections`) && !state.admin) {
|
||||
return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
|
||||
}
|
||||
if (method === "GET" && path === `${repo}/branch_protections`) return json(res, 200, state.rules);
|
||||
if (method === "GET" && path === `${repo}/branches/main`) return json(res, 200, { name: "main", commit: { id: state.trunk } });
|
||||
const branch = method === "GET" && path.startsWith(`${repo}/branches/`) ? decodeURIComponent(path.slice(`${repo}/branches/`.length)) : null;
|
||||
if (branch !== null) {
|
||||
// As the forge says it to any reader of the code: the rule that covers the branch, as the forge matches it.
|
||||
const rule = (state.rules as any[]).find((r) => ruleCovers(String(r.rule_name ?? r.branch_name), branch));
|
||||
return json(res, 200, { name: branch, commit: { id: branch === "main" ? state.trunk : "c0ffee" }, protected: !!rule,
|
||||
...(rule ? { effective_branch_protection_name: rule.rule_name ?? rule.branch_name, enable_status_check: !!rule.enable_status_check,
|
||||
status_check_contexts: rule.status_check_contexts ?? [], required_approvals: rule.required_approvals ?? 0, user_can_push: false } : {}) });
|
||||
}
|
||||
if (method === "GET" && path === `${repo}/pulls/7/files`) {
|
||||
return json(res, 200, url.searchParams.get("page") === "1" ? [{ filename: "modules/gitea/x.ts", status: "modified" }] : []);
|
||||
}
|
||||
@@ -199,6 +213,7 @@ test("a protection rule covers a branch as the forge decides it: classes, altern
|
||||
});
|
||||
|
||||
test("gitea_api still writes what an author writes", async () => {
|
||||
state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365)
|
||||
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/issues/3/comments", method: "POST", body: { body: "hi" } });
|
||||
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7/reviews", method: "POST", body: { event: "COMMENT" } });
|
||||
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/branch_protections" });
|
||||
@@ -206,6 +221,7 @@ test("gitea_api still writes what an author writes", async () => {
|
||||
});
|
||||
|
||||
test("gitea_branch_protection_set only strengthens", async () => {
|
||||
state.admin = true; // a repository administrator's token: the tools' account is refused the rules by the forge
|
||||
const set = (args: Record<string, unknown>) =>
|
||||
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "main", ...args });
|
||||
await assert.rejects(set({ status_checks: "" }), /drops the required status mesh\/merge-gate, mesh\/repo-check/);
|
||||
@@ -396,6 +412,7 @@ test("gitea_merge_pull_request reads back a fast-forward, and says it cannot rea
|
||||
});
|
||||
|
||||
test("gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full", async () => {
|
||||
state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365)
|
||||
const del = (owner: string, name: string, confirm: unknown) => tool("gitea_delete_repo").run({ owner, name, confirm });
|
||||
await assert.rejects(del("novox", "mesh-catalog", true), /confirm must be the repository's full name/);
|
||||
await assert.rejects(del("novox", "mesh-catalog", "novox/mesh-catalog"), /not a repository of the tools' own user/);
|
||||
@@ -427,6 +444,7 @@ test("gitea_merge_pull_request merges only into a branch whose protection requir
|
||||
});
|
||||
|
||||
test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => {
|
||||
state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365)
|
||||
state.rules.push({ rule_name: "release/*", enable_push: false, enable_status_check: true,
|
||||
status_check_contexts: ["mesh/merge-gate"], block_admin_merge_override: true });
|
||||
const set = (args: Record<string, unknown>) =>
|
||||
|
||||
@@ -45,6 +45,7 @@ interface Forge {
|
||||
|
||||
function fakeForge(): Promise<Forge> {
|
||||
const forge = {
|
||||
mintedFor: "",
|
||||
mints: 0,
|
||||
lastScopes: null as string[] | null,
|
||||
tokens: new Map<string, string>(), // name -> value
|
||||
@@ -83,7 +84,14 @@ function fakeForge(): Promise<Forge> {
|
||||
const tokens = url.pathname.match(/^\/api\/v1\/users\/([^/]+)\/tokens(?:\/([^/]+))?$/);
|
||||
if (tokens) {
|
||||
const user = basic(req);
|
||||
if (user === null || user !== decodeURIComponent(tokens[1])) return json(res, 401, { message: "auth required" });
|
||||
// gitea's reqSelfOrAdmin: the account itself, or the administrator for another (the tools' account, ADR 0365).
|
||||
if (user === null || (user !== decodeURIComponent(tokens[1]) && user !== ADMIN)) return json(res, 401, { message: "auth required" });
|
||||
// This fake keeps one account's tokens: the tools' account's when the module mints for it (ADR 0365), and the
|
||||
// admin's own list is then empty — nothing of it is retired.
|
||||
if (decodeURIComponent(tokens[1]) === ADMIN && forge.mintedFor === "mesh-tools") {
|
||||
return req.method === "GET" ? json(res, 200, []) : json(res, 404, { message: "token not found" });
|
||||
}
|
||||
if (req.method === "POST") forge.mintedFor = decodeURIComponent(tokens[1]);
|
||||
if (req.method === "POST") {
|
||||
const { name, scopes } = await body(req);
|
||||
if (forge.tokens.has(name)) return json(res, 400, { message: "token name has already been used" });
|
||||
@@ -186,6 +194,17 @@ function fakeForge(): Promise<Forge> {
|
||||
{ full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" },
|
||||
]);
|
||||
}
|
||||
// What setting up the tools' account asks with the admin login (tools-account.ts, ADR 0365): answered plainly.
|
||||
if (basic(req) === ADMIN) {
|
||||
const p = url.pathname;
|
||||
if (req.method === "GET" && p === "/api/v1/user") return json(res, 200, { login: ADMIN, is_admin: true });
|
||||
if (req.method === "GET" && p === "/api/v1/users/mesh-tools") return json(res, 200, { login: "mesh-tools", is_admin: false });
|
||||
if (req.method === "POST" && p === "/api/v1/admin/users") return json(res, 422, { message: "user already exists" });
|
||||
if (req.method === "PATCH" && p === "/api/v1/admin/users/mesh-tools") return json(res, 200, { login: "mesh-tools" });
|
||||
if (req.method === "GET" && p === "/api/v1/orgs/novox/teams") return json(res, 200, [{ id: 7, name: "mesh-tools" }]);
|
||||
if (req.method === "PATCH" && p === "/api/v1/teams/7") return json(res, 200, { id: 7 });
|
||||
if (req.method === "PUT" && p === "/api/v1/teams/7/members/mesh-tools") return json(res, 204, null);
|
||||
}
|
||||
const adminUser = url.pathname.match(/^\/api\/v1\/admin\/users\/([^/]+)$/);
|
||||
if (adminUser && req.method === "PATCH") {
|
||||
const h = req.headers.authorization ?? "";
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
// The forge's tools act as an account of their own, not the site administrator (novox/hq ADR 0365 §2, issue 469):
|
||||
// the token the tools and the watcher use is minted for `mesh-tools`, a plain account made by the module with the
|
||||
// administrator's login and given write on the mesh's repositories through one team; the administrator's own
|
||||
// token by that name goes; a token kept from before, the administrator's, is minted again for the account.
|
||||
//
|
||||
// The forge is a fake that keeps accounts, their tokens and the organisation's teams apart, and answers as gitea
|
||||
// does: an administrator may mint for another account (reqSelfOrAdmin), anyone else only for itself; a protection
|
||||
// edit needs the repository's administration, which neither a plain account nor its write team holds.
|
||||
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { mkdtemp, readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { MintedToken } from "../dist/token.js";
|
||||
import { GiteaClient } from "../dist/client.js";
|
||||
import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS } from "../dist/tools-account.js";
|
||||
import { repoCheckFacts } from "../dist/pulls.js";
|
||||
|
||||
const ADMIN = "mesh-admin";
|
||||
const PASSWORD = "the-vault-minted-this";
|
||||
|
||||
interface Account { admin: boolean; password: string; active: boolean }
|
||||
interface Token { id: number; name: string; sha1: string; user: string; scopes: string[] }
|
||||
interface Team { id: number; name: string; units: any; members: Set<string> }
|
||||
|
||||
function fakeForge() {
|
||||
const users = new Map<string, Account>([[ADMIN, { admin: true, password: PASSWORD, active: true }]]);
|
||||
const tokens: Token[] = [];
|
||||
const teams: Team[] = [];
|
||||
const calls: string[] = [];
|
||||
// What the forge does wrong when a test says so: a token mint or delete it fails, a team another process made
|
||||
// between this one's look and its create (gitea answers the create 422).
|
||||
const faults = { mint: false, deleteToken: false, teamRace: false };
|
||||
let next = 0;
|
||||
const json = (res: ServerResponse, status: number, body: unknown): void => {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(body === null ? "" : JSON.stringify(body));
|
||||
};
|
||||
const read = (req: IncomingMessage): Promise<any> =>
|
||||
new Promise((resolve) => {
|
||||
let t = "";
|
||||
req.on("data", (c) => (t += c));
|
||||
req.on("end", () => resolve(t ? JSON.parse(t) : null));
|
||||
});
|
||||
const who = (req: IncomingMessage): string | null => {
|
||||
const h = req.headers.authorization ?? "";
|
||||
if (h.startsWith("Basic ")) {
|
||||
const [u, p] = Buffer.from(h.slice(6), "base64").toString().split(":");
|
||||
return users.get(u)?.password === p ? u : null;
|
||||
}
|
||||
if (h.startsWith("token ")) return tokens.find((t) => t.sha1 === h.slice(6))?.user ?? null;
|
||||
return null;
|
||||
};
|
||||
const isAdmin = (u: string | null) => u !== null && users.get(u)?.admin === true;
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://fake");
|
||||
const p = url.pathname.replace(/^\/api\/v1/, "");
|
||||
const me = who(req);
|
||||
calls.push(`${req.method} ${p} as ${me}`);
|
||||
if (me === null) return json(res, 401, { message: "unauthorized" });
|
||||
|
||||
let m = p.match(/^\/users\/([^/]+)\/tokens(?:\/(\d+))?$/);
|
||||
if (m) {
|
||||
const owner = decodeURIComponent(m[1]);
|
||||
if (!(req.headers.authorization ?? "").startsWith("Basic ")) return json(res, 401, { message: "basic auth required" });
|
||||
if (owner !== me && !isAdmin(me)) return json(res, 403, { message: "reqSelfOrAdmin" });
|
||||
if (!users.has(owner)) return json(res, 404, { message: "user does not exist" });
|
||||
if (req.method === "POST") {
|
||||
if (faults.mint) return json(res, 500, { message: "the forge is not well" });
|
||||
const { name, scopes } = await read(req);
|
||||
if (tokens.some((t) => t.user === owner && t.name === name)) return json(res, 400, { message: "token name has already been used" });
|
||||
const t = { id: ++next, name, sha1: `sha-${owner}-${next}`, user: owner, scopes };
|
||||
tokens.push(t);
|
||||
return json(res, 201, { id: t.id, name, sha1: t.sha1, scopes });
|
||||
}
|
||||
if (req.method === "GET") {
|
||||
const page = Number(url.searchParams.get("page") ?? "1");
|
||||
const mine = tokens.filter((t) => t.user === owner);
|
||||
return json(res, 200, page === 1 ? mine.map((t) => ({ id: t.id, name: t.name, sha1: "" })) : []);
|
||||
}
|
||||
if (req.method === "DELETE" && m[2]) {
|
||||
if (faults.deleteToken && owner === ADMIN) return json(res, 500, { message: "the forge is not well" });
|
||||
const i = tokens.findIndex((t) => t.user === owner && t.id === Number(m![2]));
|
||||
if (i < 0) return json(res, 404, { message: "not found" });
|
||||
tokens.splice(i, 1);
|
||||
return json(res, 204, null);
|
||||
}
|
||||
}
|
||||
if (req.method === "GET" && p === "/user") return json(res, 200, { login: me, is_admin: isAdmin(me) });
|
||||
m = p.match(/^\/users\/([^/]+)$/);
|
||||
if (m && req.method === "GET") {
|
||||
const u = users.get(decodeURIComponent(m[1]));
|
||||
if (!u) return json(res, 404, { message: "user does not exist" });
|
||||
return json(res, 200, { login: m[1], is_admin: u.admin, active: u.active, prohibit_login: false });
|
||||
}
|
||||
if (p.startsWith("/admin/") && !isAdmin(me)) return json(res, 403, { message: "must be a site administrator" });
|
||||
if (req.method === "POST" && p === "/admin/users") {
|
||||
const b = await read(req);
|
||||
if (users.has(b.username)) return json(res, 422, { message: "user already exists" });
|
||||
if (!b.password || b.password.length < 8) return json(res, 422, { message: "password is too short" });
|
||||
users.set(b.username, { admin: b.admin === true, password: b.password, active: true });
|
||||
return json(res, 201, { login: b.username, is_admin: b.admin === true });
|
||||
}
|
||||
m = p.match(/^\/admin\/users\/([^/]+)$/);
|
||||
if (m && req.method === "PATCH") {
|
||||
const u = users.get(decodeURIComponent(m[1]));
|
||||
if (!u) return json(res, 404, { message: "user does not exist" });
|
||||
const b = await read(req);
|
||||
if (b.admin !== undefined) u.admin = b.admin;
|
||||
if (b.active !== undefined) u.active = b.active;
|
||||
return json(res, 200, { login: m[1], is_admin: u.admin });
|
||||
}
|
||||
if (p === "/orgs/novox/teams") {
|
||||
if (!isAdmin(me)) return json(res, 403, { message: "must be an owner of the organisation" });
|
||||
if (req.method === "GET") return json(res, 200, teams.map((t) => ({ id: t.id, name: t.name })));
|
||||
const b = await read(req);
|
||||
const t = { id: ++next, name: b.name, units: b, members: new Set<string>() };
|
||||
teams.push(t);
|
||||
if (faults.teamRace) return json(res, 422, { message: "team already exists" });
|
||||
return json(res, 201, { id: t.id, name: t.name });
|
||||
}
|
||||
m = p.match(/^\/teams\/(\d+)(?:\/members\/([^/]+))?$/);
|
||||
if (m) {
|
||||
if (!isAdmin(me)) return json(res, 403, { message: "must be an owner of the organisation" });
|
||||
const t = teams.find((x) => x.id === Number(m![1]));
|
||||
if (!t) return json(res, 404, { message: "team does not exist" });
|
||||
if (req.method === "PATCH") { t.units = await read(req); return json(res, 200, { id: t.id }); }
|
||||
if (req.method === "PUT" && m[2]) { t.members.add(decodeURIComponent(m[2])); return json(res, 204, null); }
|
||||
}
|
||||
// Repository administration: a site administrator, or a team member whose team administers the repository.
|
||||
m = p.match(/^\/repos\/novox\/([^/]+)\/branches\/(.+)$/);
|
||||
if (m && req.method === "GET") {
|
||||
if (m[2] === "main") return json(res, 200, { name: "main", protected: true, effective_branch_protection_name: "main",
|
||||
enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"], user_can_push: false });
|
||||
if (m[2] === "feat/unguarded") return json(res, 200, { name: m[2], protected: false });
|
||||
return json(res, 404, { message: "branch does not exist" });
|
||||
}
|
||||
// gitea's reqAdmin on the whole group: reading the rules is administration too.
|
||||
m = p.match(/^\/repos\/novox\/([^/]+)\/branch_protections(?:\/.*)?$/);
|
||||
if (m) {
|
||||
const admins = isAdmin(me) || teams.some((t) => t.members.has(me) && t.units.permission === "admin");
|
||||
if (!admins) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
|
||||
return json(res, 201, { rule_name: "main" });
|
||||
}
|
||||
m = p.match(/^\/repos\/novox\/([^/]+)\/statuses\/([0-9a-f]+)$/);
|
||||
if (m && req.method === "POST") {
|
||||
const writes = isAdmin(me) || teams.some((t) => t.members.has(me) && t.units.units_map?.["repo.code"] === "write");
|
||||
if (!writes) return json(res, 403, { message: "write on code required" });
|
||||
return json(res, 201, { context: (await read(req)).context, creator: { login: me } });
|
||||
}
|
||||
if (req.method === "GET" && p === "/repos/search") return json(res, 200, { data: [] });
|
||||
return json(res, 404, { message: "no such route in the fake" });
|
||||
});
|
||||
return new Promise<{ url: string; users: typeof users; tokens: typeof tokens; teams: typeof teams; calls: string[];
|
||||
faults: typeof faults; close(): Promise<void> }>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server.address() as { port: number };
|
||||
resolve({ url: `http://127.0.0.1:${port}`, users, tokens, teams, calls, faults,
|
||||
close: () => new Promise((r) => server.close(() => r())) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const forge = await fakeForge();
|
||||
after(() => forge.close());
|
||||
|
||||
async function environment(): Promise<NodeJS.ProcessEnv & { MESH_GITEA_STATE_DIR: string }> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "gitea-tools-account-"));
|
||||
const passwordFile = join(dir, "admin.secret");
|
||||
await writeFile(passwordFile, PASSWORD + "\n", { mode: 0o600 });
|
||||
return {
|
||||
MESH_GITEA_URL: forge.url,
|
||||
MESH_GITEA_ADMIN_USER: ADMIN,
|
||||
MESH_GITEA_ADMIN_PASSWORD_FILE: passwordFile,
|
||||
MESH_GITEA_STATE_DIR: join(dir, "state"),
|
||||
};
|
||||
}
|
||||
|
||||
/** A source as a fresh process builds it from the manifest's environment (fromEnv shares one per file). */
|
||||
function fresh(env: NodeJS.ProcessEnv): GiteaClient {
|
||||
return GiteaClient.fromEnv(env);
|
||||
}
|
||||
|
||||
test("the tools' token is minted for an account of their own, which is no administrator and writes through its team", async () => {
|
||||
const env = await environment();
|
||||
await fresh(env).listRepos();
|
||||
|
||||
const account = forge.users.get(TOOLS_ACCOUNT);
|
||||
assert.ok(account, "the module made the tools' account");
|
||||
assert.equal(account!.admin, false, "the tools' account is no administrator");
|
||||
const held = forge.tokens.filter((t) => t.name === "mesh-tools");
|
||||
assert.deepEqual(held.map((t) => t.user), [TOOLS_ACCOUNT], "the one token is the tools' account's, none is the administrator's");
|
||||
assert.equal(await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8"), held[0].sha1 + "\n");
|
||||
|
||||
const team = forge.teams.find((t) => t.name === "mesh-tools");
|
||||
assert.ok(team, "the team is made in the mesh's organisation");
|
||||
assert.ok(team!.members.has(TOOLS_ACCOUNT));
|
||||
assert.equal(team!.units.permission, "write");
|
||||
assert.equal(team!.units.includes_all_repositories, true);
|
||||
assert.equal(team!.units.can_create_org_repo, false, "a repository's maker would administer it");
|
||||
assert.deepEqual(team!.units.units_map, TOOLS_TEAM_UNITS.units_map);
|
||||
assert.ok(!Object.values(team!.units.units_map).includes("admin"));
|
||||
});
|
||||
|
||||
test("with the tools' token, the forge writes a status and refuses a protection edit — the forge itself, not a guard", async () => {
|
||||
const env = await environment();
|
||||
const client = fresh(env);
|
||||
await client.setCommitStatus("novox", "hq", "abc123def4567890abc123def4567890abc12345", { state: "success", context: "mesh/merge-gate", description: "ok" } as any);
|
||||
await assert.rejects(
|
||||
client.setBranchProtection("novox", "hq", "main", { statusChecks: ["mesh/merge-gate"] }),
|
||||
/403/,
|
||||
);
|
||||
});
|
||||
|
||||
test("what a base's protection asks is read from the branch, which the tools' account may read, not from the rules' list it may not", async () => {
|
||||
const env = await environment();
|
||||
const client = fresh(env);
|
||||
await assert.rejects(client.branchProtections("novox", "hq"), /403/);
|
||||
const rule = await client.appliedProtection("novox", "hq", "main");
|
||||
assert.deepEqual(rule?.status_check_contexts, ["mesh/merge-gate", "mesh/repo-check"]);
|
||||
assert.equal(rule?.enable_status_check, true);
|
||||
assert.equal(rule?.rule_name, "main");
|
||||
assert.equal(await client.appliedProtection("novox", "hq", "feat/unguarded"), null);
|
||||
assert.equal(await client.appliedProtection("novox", "hq", "no/such-branch"), null);
|
||||
});
|
||||
|
||||
test("the administrator's token from before goes: a kept admin token is minted again for the tools' account, and the forge drops the old one", async () => {
|
||||
const env = await environment();
|
||||
// What the module kept before ADR 0365: a token of the administrator's own, by the same name, its scopes beside it.
|
||||
const old = { id: 900, name: "mesh-tools", sha1: "sha-admin-old", user: ADMIN, scopes: ["write:repository", "write:issue", "read:user"] };
|
||||
forge.tokens.push(old);
|
||||
await mkdir(env.MESH_GITEA_STATE_DIR, { recursive: true });
|
||||
await writeFile(join(env.MESH_GITEA_STATE_DIR, "token"), old.sha1 + "\n", { mode: 0o600 });
|
||||
await writeFile(join(env.MESH_GITEA_STATE_DIR, "token.scopes"), "read:user,write:issue,write:repository\n", { mode: 0o600 });
|
||||
|
||||
await fresh(env).listRepos();
|
||||
|
||||
assert.ok(!forge.tokens.some((t) => t.user === ADMIN), "the administrator holds no tools' token any more");
|
||||
const kept = (await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8")).trim();
|
||||
assert.notEqual(kept, old.sha1);
|
||||
assert.equal(forge.tokens.find((t) => t.sha1 === kept)?.user, TOOLS_ACCOUNT);
|
||||
assert.match(await readFile(join(env.MESH_GITEA_STATE_DIR, "token.scopes"), "utf8"), / for mesh-tools\n$/);
|
||||
});
|
||||
|
||||
test("a tools' account someone made an administrator is made a plain account again at the next mint", async () => {
|
||||
const env = await environment();
|
||||
forge.users.get(TOOLS_ACCOUNT)!.admin = true;
|
||||
await fresh(env).listRepos();
|
||||
assert.equal(forge.users.get(TOOLS_ACCOUNT)!.admin, false);
|
||||
});
|
||||
|
||||
test("the tools never act as the administrator: MESH_GITEA_TOOLS_USER naming it is refused", async () => {
|
||||
const env = await environment();
|
||||
assert.throws(() => MintedToken.fromEnv(forge.url, { ...env, MESH_GITEA_TOOLS_USER: ADMIN }), /names the admin account/);
|
||||
});
|
||||
|
||||
test("the manifest hands the tools their account, and the merge-ready authors name it rather than the administrator", async () => {
|
||||
const manifest = JSON.parse(await readFile(new URL("../module.json", import.meta.url), "utf8"));
|
||||
const code = manifest.build.artifacts.find((a: any) => a.name === "code");
|
||||
assert.equal(code.env.MESH_GITEA_TOOLS_USER, TOOLS_ACCOUNT);
|
||||
const authors = String(manifest.settings["ready-authors"].default).split(/\s+/);
|
||||
assert.ok(authors.includes(TOOLS_ACCOUNT));
|
||||
// Kept for now (review of issue 469): a pull request the admin account marked ready before the tools acted as
|
||||
// their own keeps counting as ready after the delivery.
|
||||
assert.ok(authors.includes(ADMIN), "marks made by the admin account before ADR 0365 keep counting");
|
||||
// The Go forge bundle reads the token the tools' account holds, never the administrator's password.
|
||||
const forgeBundle = manifest.build.artifacts.find((a: any) => a.name === "forge");
|
||||
assert.ok(forgeBundle.env.MESH_GITEA_TOKEN_FILE);
|
||||
assert.equal(forgeBundle.env.MESH_GITEA_ADMIN_PASSWORD_FILE, undefined);
|
||||
});
|
||||
|
||||
// Review of issue 469: what reads the rules' list is moved to the branch, and nothing leaves the tools without a token.
|
||||
|
||||
test("a repository check said as a warning on a head without merge-check.sh reads the base's protection from the branch", async () => {
|
||||
const client = fresh(await environment());
|
||||
const forgeOf = { holdsFile: async () => false, appliedProtection: client.appliedProtection.bind(client) };
|
||||
const c = { owner: "novox", repo: "hq", commit: "abc123def4567890abc123def4567890abc12345", "repo-check": { verdict: "warning" } } as any;
|
||||
// main requires mesh/repo-check: required, so a head that defines no check fails it for a person to see.
|
||||
assert.deepEqual(await repoCheckFacts(forgeOf, c, "main"), { defined: false, required: true });
|
||||
// A base nothing guards: not required, so the head's repository check passes rather than fails.
|
||||
assert.deepEqual(await repoCheckFacts(forgeOf, c, "feat/unguarded"), { defined: false, required: false });
|
||||
});
|
||||
|
||||
test("a mint that fails leaves the administrator's old token in place: the tools are never left without one", async () => {
|
||||
const env = await environment();
|
||||
const old = { id: 901, name: "mesh-tools", sha1: "sha-admin-kept", user: ADMIN, scopes: ["read:user"] };
|
||||
forge.tokens.push(old);
|
||||
forge.faults.mint = true;
|
||||
try {
|
||||
await assert.rejects(fresh(env).listRepos(), /500/);
|
||||
} finally {
|
||||
forge.faults.mint = false;
|
||||
}
|
||||
assert.ok(forge.tokens.some((t) => t.sha1 === old.sha1), "the admin's token was deleted before a new one was kept");
|
||||
forge.tokens.splice(forge.tokens.indexOf(old), 1);
|
||||
});
|
||||
|
||||
test("an old token that cannot be deleted after the mint is said, the new token kept, and deleted at the next start", async () => {
|
||||
const env = await environment();
|
||||
const old = { id: 902, name: "mesh-tools", sha1: "sha-admin-stuck", user: ADMIN, scopes: ["read:user"] };
|
||||
forge.tokens.push(old);
|
||||
forge.faults.deleteToken = true;
|
||||
try {
|
||||
await fresh(env).listRepos();
|
||||
} finally {
|
||||
forge.faults.deleteToken = false;
|
||||
}
|
||||
const kept = (await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8")).trim();
|
||||
assert.equal(forge.tokens.find((t) => t.sha1 === kept)?.user, TOOLS_ACCOUNT, "the account's token is kept and works");
|
||||
assert.ok(forge.tokens.some((t) => t.sha1 === old.sha1));
|
||||
// The next process reuses the kept token and retires the old one it could not before.
|
||||
const next = new MintedToken({ url: forge.url, admin: ADMIN, passwordFile: env.MESH_GITEA_ADMIN_PASSWORD_FILE!,
|
||||
file: join(env.MESH_GITEA_STATE_DIR, "token"), account: TOOLS_ACCOUNT, log: () => {} });
|
||||
assert.equal(await next.current(), kept);
|
||||
assert.ok(!forge.tokens.some((t) => t.sha1 === old.sha1), "the admin's old token was deleted at the next start");
|
||||
});
|
||||
|
||||
test("a team another process made between the look and the create is found, not a failure", async () => {
|
||||
forge.teams.splice(0);
|
||||
forge.faults.teamRace = true;
|
||||
try {
|
||||
await fresh(await environment()).listRepos();
|
||||
} finally {
|
||||
forge.faults.teamRace = false;
|
||||
}
|
||||
const team = forge.teams.find((t) => t.name === "mesh-tools");
|
||||
assert.ok(team?.members.has(TOOLS_ACCOUNT));
|
||||
});
|
||||
+115
-31
@@ -22,15 +22,21 @@
|
||||
// - replaced by id: an old token by this name is found in the account's list and deleted by its id,
|
||||
// every one of them, so a name held twice is cleared rather than refused.
|
||||
//
|
||||
// - minted for the tools' account, not the administrator (novox/hq ADR 0365 §2, issue 469): the administrator's
|
||||
// login mints it for `mesh-tools` (tools-account.ts), after making that account and its team, and deletes any
|
||||
// token by this name the administrator itself still holds — the token the tools used before. A token kept
|
||||
// for another account than the one asked (the scopes line says whose) is minted again at the next start.
|
||||
//
|
||||
// An explicitly configured token still wins, and is never minted over: if it is rejected, that is
|
||||
// reported, not repaired — somebody chose it.
|
||||
//
|
||||
// The token is never logged. Lines say that one was minted, reused or renewed, and where it is
|
||||
// kept; never what it is.
|
||||
|
||||
import { chmodSync, closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
||||
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { dirname, join } from "node:path";
|
||||
import { setUpToolsAccount, TOOLS_ACCOUNT, type AdminCall } from "./tools-account.js";
|
||||
|
||||
/** The name the token carries in the forge's own list — one per mesh runtime, found by name. */
|
||||
export const TOKEN_NAME = "mesh-tools";
|
||||
@@ -104,6 +110,14 @@ export interface MintedTokenOptions {
|
||||
readonly file: string;
|
||||
readonly name?: string;
|
||||
readonly scopes?: readonly string[];
|
||||
/**
|
||||
* The account the token is minted for: the tools' own (novox/hq ADR 0365 §2). The admin login mints it, after
|
||||
* `setUp` made the account, and the admin's own tokens by this name are deleted. Left out, the token is the
|
||||
* admin's own, as it was before.
|
||||
*/
|
||||
readonly account?: string;
|
||||
/** Run with the admin's password before each mint for `account`: makes the account and its team (tools-account.ts). */
|
||||
readonly setUp?: (password: string) => Promise<void>;
|
||||
readonly log?: (line: string) => void;
|
||||
readonly fetch?: typeof fetch;
|
||||
}
|
||||
@@ -115,12 +129,14 @@ export class MintedToken implements TokenSource {
|
||||
private inflight: Promise<string> | null = null;
|
||||
private readonly name: string;
|
||||
private readonly scopes: readonly string[];
|
||||
private readonly account: string;
|
||||
private readonly log: (line: string) => void;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
|
||||
constructor(private readonly opts: MintedTokenOptions) {
|
||||
this.name = opts.name ?? TOKEN_NAME;
|
||||
this.scopes = opts.scopes ?? TOKEN_SCOPES;
|
||||
this.account = opts.account ?? opts.admin;
|
||||
this.log = opts.log ?? ((line) => console.log(`[gitea] ${line}`));
|
||||
this.fetchImpl = opts.fetch ?? fetch;
|
||||
}
|
||||
@@ -157,7 +173,17 @@ export class MintedToken implements TokenSource {
|
||||
if (missing.length) {
|
||||
throw new Error(`no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or ${missing.join(", ")}`);
|
||||
}
|
||||
return { url, admin: admin!, passwordFile: passwordFile!, file: join(stateDir!, "token") };
|
||||
// The tools' own account, never the administrator (novox/hq ADR 0365 §2): MESH_GITEA_TOOLS_USER names it.
|
||||
const account = env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT;
|
||||
if (account === admin) throw new Error(`MESH_GITEA_TOOLS_USER names the admin account "${admin}"; the tools act as an account of their own (novox/hq ADR 0365)`);
|
||||
return {
|
||||
url, admin: admin!, passwordFile: passwordFile!, file: join(stateDir!, "token"), account,
|
||||
// GiteaAdmin is loaded when first used: client.ts imports this file.
|
||||
setUp: async (password) => {
|
||||
const { GiteaAdmin } = await import("./client.js");
|
||||
await setUpToolsAccount(new GiteaAdmin(url, admin!, password), account, () => randomBytes(24).toString("base64url"));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async current(): Promise<string> {
|
||||
@@ -168,6 +194,8 @@ export class MintedToken implements TokenSource {
|
||||
if (kept !== null && this.keptScopes() === this.scopesLine()) {
|
||||
this.held = kept;
|
||||
this.log(`reusing the token kept at ${this.opts.file}`);
|
||||
// A retirement of the admin's old token that failed after the mint is tried again, once a process.
|
||||
if (existsSync(this.retireFile)) await this.locked(() => this.retireAdmin()).catch((err) => this.log(`${err}`));
|
||||
return kept;
|
||||
}
|
||||
if (kept !== null) return this.mint("the kept token was minted with other scopes than the tools take now — minting one with them", kept);
|
||||
@@ -293,9 +321,10 @@ export class MintedToken implements TokenSource {
|
||||
}
|
||||
}
|
||||
|
||||
/** The scopes as kept beside the token: sorted, one line. */
|
||||
/** The scopes as kept beside the token: sorted, one line, and whose it is when it is not the admin's. */
|
||||
private scopesLine(): string {
|
||||
return [...this.scopes].sort().join(",");
|
||||
const scopes = [...this.scopes].sort().join(",");
|
||||
return this.account === this.opts.admin ? scopes : `${scopes} for ${this.account}`;
|
||||
}
|
||||
|
||||
/** The scopes the kept token was minted with, "" when not kept (a token kept before they were). */
|
||||
@@ -318,17 +347,20 @@ export class MintedToken implements TokenSource {
|
||||
renameSync(`${this.opts.file}.scopes.tmp`, `${this.opts.file}.scopes`);
|
||||
}
|
||||
|
||||
private async doMint(): Promise<string> {
|
||||
let password: string;
|
||||
/** One call to the forge's API with the admin login, the password read now. */
|
||||
private password(): string {
|
||||
try {
|
||||
password = readFileSync(this.opts.passwordFile, "utf8").replace(/\n$/, "");
|
||||
return readFileSync(this.opts.passwordFile, "utf8").replace(/\n$/, "");
|
||||
} catch (err) {
|
||||
throw new Error(`cannot read the admin password at ${this.opts.passwordFile}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private adminCall(password = this.password()): AdminCall {
|
||||
const authorization = "Basic " + Buffer.from(`${this.opts.admin}:${password}`).toString("base64");
|
||||
const tokens = `${this.opts.url.replace(/\/+$/, "")}/api/v1/users/${encodeURIComponent(this.opts.admin)}/tokens`;
|
||||
const call = async (method: string, path = "", body?: unknown): Promise<{ status: number; body: any }> => {
|
||||
const res = await this.fetchImpl(tokens + path, {
|
||||
const api = `${this.opts.url.replace(/\/+$/, "")}/api/v1`;
|
||||
const admin: AdminCall = async (method, path, body) => {
|
||||
const res = await this.fetchImpl(api + path, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", Authorization: authorization },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
@@ -340,6 +372,44 @@ export class MintedToken implements TokenSource {
|
||||
}
|
||||
return { status: res.status, body: parsed };
|
||||
};
|
||||
return admin;
|
||||
}
|
||||
|
||||
/** Where a retirement of the admin's old token that failed is remembered, to be tried again (retireAdmin). */
|
||||
private get retireFile(): string {
|
||||
return `${this.opts.file}.retire-admin`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin's own token by this name goes: what the tools used before must not outlive the move (novox/hq ADR
|
||||
* 0365 §2). Run after the account's token is minted and kept, so a failure here never leaves the tools without
|
||||
* a token: it is said, remembered beside the kept token, and tried again when a process next reads that token.
|
||||
*/
|
||||
private async retireAdmin(admin: AdminCall = this.adminCall()): Promise<void> {
|
||||
if (this.account === this.opts.admin) return;
|
||||
try {
|
||||
const retired = await this.dropNamed(admin, `/users/${encodeURIComponent(this.opts.admin)}/tokens`, this.opts.admin);
|
||||
if (retired > 0) this.log(`deleted ${retired} token(s) named "${this.name}" the admin account "${this.opts.admin}" held: the tools act as "${this.account}" now`);
|
||||
rmSync(this.retireFile, { force: true });
|
||||
} catch (err) {
|
||||
writeFileSync(this.retireFile, `${new Date().toISOString()}\n`, { mode: 0o600 });
|
||||
this.log(`FAILED to delete the token(s) named "${this.name}" the admin account "${this.opts.admin}" holds — tried again at the next start: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async doMint(): Promise<string> {
|
||||
const password = this.password();
|
||||
const admin = this.adminCall(password);
|
||||
const tokensOf = (account: string) => `/users/${encodeURIComponent(account)}/tokens`;
|
||||
const call = (method: string, path = "", body?: unknown) => admin(method, tokensOf(this.account) + path, body);
|
||||
|
||||
if (this.account !== this.opts.admin) {
|
||||
// The tools' account and its team first (novox/hq ADR 0365 §2): a failure here fails the mint, tried again
|
||||
// at the next call.
|
||||
const me = await admin("GET", "/user");
|
||||
if (me.status === 401 || me.status === 403) throw new AdminRefused(this.opts.admin, me.status);
|
||||
await this.opts.setUp?.(password);
|
||||
}
|
||||
|
||||
let res = await call("POST", "", { name: this.name, scopes: this.scopes });
|
||||
if (res.status === 401 || res.status === 403) throw new AdminRefused(this.opts.admin, res.status);
|
||||
@@ -348,37 +418,51 @@ export class MintedToken implements TokenSource {
|
||||
// went while the forge's data stayed, or two processes once minted at once and left two. It is
|
||||
// ours to replace: find every one by this name in the account's list and delete each by its id
|
||||
// (gitea refuses a delete by a name held twice: "multiple matches for token name"), then mint.
|
||||
const held: number[] = [];
|
||||
for (let page = 1; page <= 100; page++) {
|
||||
const listed = await call("GET", `?page=${page}&limit=50`);
|
||||
if (listed.status !== 200 || !Array.isArray(listed.body)) {
|
||||
throw new Error(`Gitea GET /users/${this.opts.admin}/tokens: ${listed.status} ${detail(listed.body)}`);
|
||||
}
|
||||
if (listed.body.length === 0) break;
|
||||
for (const t of listed.body) if (t?.name === this.name && typeof t.id === "number") held.push(t.id);
|
||||
}
|
||||
this.log(held.length === 1
|
||||
? `the forge already holds a token named "${this.name}" — replacing it`
|
||||
: `the forge holds ${held.length} tokens named "${this.name}" — deleting each by its id and minting one`);
|
||||
for (const id of held) {
|
||||
const dropped = await call("DELETE", `/${id}`);
|
||||
if (dropped.status !== 204 && dropped.status !== 404) {
|
||||
throw new Error(`Gitea DELETE /users/${this.opts.admin}/tokens/${id} (named ${this.name}): ${dropped.status} ${detail(dropped.body)}`);
|
||||
}
|
||||
}
|
||||
await this.dropNamed(admin, tokensOf(this.account), this.account, true);
|
||||
res = await call("POST", "", { name: this.name, scopes: this.scopes });
|
||||
}
|
||||
if (res.status !== 201 && res.status !== 200) {
|
||||
throw new Error(`Gitea POST /users/${this.opts.admin}/tokens: ${res.status} ${detail(res.body)}`);
|
||||
throw new Error(`Gitea POST /users/${this.account}/tokens: ${res.status} ${detail(res.body)}`);
|
||||
}
|
||||
const token = typeof res.body?.sha1 === "string" ? res.body.sha1 : null;
|
||||
if (!token) throw new Error(`Gitea POST /users/${this.opts.admin}/tokens: ${res.status} but no token in the reply`);
|
||||
if (!token) throw new Error(`Gitea POST /users/${this.account}/tokens: ${res.status} but no token in the reply`);
|
||||
|
||||
this.keep(token);
|
||||
this.held = token;
|
||||
this.log(`minted a token for "${this.opts.admin}" (${this.scopes.join(", ")}), kept at ${this.opts.file}`);
|
||||
this.log(`minted a token for "${this.account}" (${this.scopes.join(", ")}), kept at ${this.opts.file}`);
|
||||
// Only now, with the account's own token kept, does the admin's old one go.
|
||||
await this.retireAdmin(admin);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every token by this name an account holds, each by its id (gitea refuses a delete by a name held twice:
|
||||
* "multiple matches for token name"); answers how many. `replacing` says the line for a mint that found its name
|
||||
* taken.
|
||||
*/
|
||||
private async dropNamed(admin: AdminCall, tokens: string, account: string, replacing = false): Promise<number> {
|
||||
const held: number[] = [];
|
||||
for (let page = 1; page <= 100; page++) {
|
||||
const listed = await admin("GET", `${tokens}?page=${page}&limit=50`);
|
||||
if (listed.status !== 200 || !Array.isArray(listed.body)) {
|
||||
throw new Error(`Gitea GET /users/${account}/tokens: ${listed.status} ${detail(listed.body)}`);
|
||||
}
|
||||
if (listed.body.length === 0) break;
|
||||
for (const t of listed.body) if (t?.name === this.name && typeof t.id === "number") held.push(t.id);
|
||||
}
|
||||
if (replacing) {
|
||||
this.log(held.length === 1
|
||||
? `the forge already holds a token named "${this.name}" — replacing it`
|
||||
: `the forge holds ${held.length} tokens named "${this.name}" — deleting each by its id and minting one`);
|
||||
}
|
||||
for (const id of held) {
|
||||
const dropped = await admin("DELETE", `${tokens}/${id}`);
|
||||
if (dropped.status !== 204 && dropped.status !== 404) {
|
||||
throw new Error(`Gitea DELETE /users/${account}/tokens/${id} (named ${this.name}): ${dropped.status} ${detail(dropped.body)}`);
|
||||
}
|
||||
}
|
||||
return held.length;
|
||||
}
|
||||
}
|
||||
|
||||
function detail(body: unknown): string {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// The forge's tools act as an account of their own, not as the site administrator (novox/hq ADR 0365 §2,
|
||||
// issue 469).
|
||||
//
|
||||
// The administrator's token reached everything the forge holds, so the tools' boundary rested on their own guards
|
||||
// (guard.ts): a protection edit, say, the forge itself would have taken. Now the tools' token is minted for a plain
|
||||
// account, `mesh-tools`, which is no administrator and writes to the mesh's repositories through one team of the
|
||||
// mesh's organisation:
|
||||
//
|
||||
// - **the account**: made by the module with the administrator's login (that login sets the forge up and does
|
||||
// nothing else), never an administrator — one found made an administrator is made a plain account again. Its
|
||||
// password is random, sent once and kept nowhere: the account is reached only by the tokens the administrator
|
||||
// mints for it (gitea lets an administrator mint for another account: `reqSelfOrAdmin`).
|
||||
// - **the team**: `mesh-tools` in the organisation `novox`, on every repository of it, writing code (pushes to a
|
||||
// branch, commit statuses, merges), issues (notes, comments, labels) and pull requests. No administration of a
|
||||
// repository, so the forge refuses the account a protection edit, a repository's settings and its deletion;
|
||||
// no repository made in the organisation, since its maker would be that repository's administrator.
|
||||
//
|
||||
// Every step is idempotent (GiteaAdmin's, client.ts), and run with the administrator's login before each mint of the tools' token, so a team
|
||||
// or a membership lost behind the module's back is made again at the next mint.
|
||||
|
||||
/** The account the tools act as. */
|
||||
export const TOOLS_ACCOUNT = "mesh-tools";
|
||||
/** The organisation whose repositories are the mesh's, and the team through which the tools' account writes there. */
|
||||
export const TOOLS_ORG = "novox";
|
||||
export const TOOLS_TEAM = "mesh-tools";
|
||||
|
||||
/** What the team grants: write on code, issues and pull requests of every repository of the organisation, and no more. */
|
||||
export const TOOLS_TEAM_UNITS = {
|
||||
permission: "write",
|
||||
units_map: { "repo.code": "write", "repo.issues": "write", "repo.pulls": "write" },
|
||||
includes_all_repositories: true,
|
||||
can_create_org_repo: false,
|
||||
} as const;
|
||||
|
||||
/** One call to the forge's API with the administrator's login: a path under /api/v1, the status and body answered. */
|
||||
export type AdminCall = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: any }>;
|
||||
|
||||
/** What setting the account up needs of the forge's admin API: GiteaAdmin's own (client.ts), shared with the
|
||||
* package provisioner, so a create race (422) and a user made by hand are handled once. */
|
||||
export interface ToolsForge {
|
||||
ensureUser(username: string, password: string, email: string, plain?: boolean): Promise<void>;
|
||||
ensureTeamWith(org: string, team: string, units: Record<string, unknown>): Promise<number>;
|
||||
addUserToTeam(teamId: number, username: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Make the tools' account, plain, a member of its team on every repository of the organisation. Its password is
|
||||
* set afresh each time and kept nowhere: the account is reached only by the tokens the administrator mints. */
|
||||
export async function setUpToolsAccount(forge: ToolsForge, account = TOOLS_ACCOUNT, password: () => string): Promise<void> {
|
||||
await forge.ensureUser(account, password(), `${account}@noreply.localhost`, true);
|
||||
const team = await forge.ensureTeamWith(TOOLS_ORG, TOOLS_TEAM, { name: TOOLS_TEAM, ...TOOLS_TEAM_UNITS });
|
||||
await forge.addUserToTeam(team, account);
|
||||
}
|
||||
@@ -504,7 +504,7 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
||||
base = base || "main";
|
||||
let rule: BranchProtection | null = null, unreadable: string | undefined;
|
||||
try {
|
||||
rule = await gitea.branchProtection(owner, repo, base);
|
||||
rule = await gitea.appliedProtection(owner, repo, base);
|
||||
} catch (err) {
|
||||
unreadable = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
@@ -589,8 +589,12 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
||||
const { owner, repo, branch, newBranch } = judged.contents;
|
||||
const found = await gitea.api<{ default_branch?: string }>(`/repos/${checkName("owner", owner)}/${checkName("name", repo)}`);
|
||||
const defaultBranch = String(found?.default_branch ?? "main");
|
||||
const refused = judgeContentsWrite(newBranch ?? branch ?? defaultBranch, defaultBranch,
|
||||
await gitea.branchProtections(owner, repo));
|
||||
// The protection the forge applies to the branch written, read from the branch: the rules' own list is a
|
||||
// repository administrator's, which the tools' account is not (novox/hq ADR 0365). A branch not made yet is
|
||||
// left to the forge, which refuses a write that would make a branch a rule guards.
|
||||
const target = newBranch ?? branch ?? defaultBranch;
|
||||
const applied = target === defaultBranch ? null : await gitea.appliedProtection(owner, repo, target);
|
||||
const refused = judgeContentsWrite(target, defaultBranch, applied ? [applied] : []);
|
||||
if (refused) throw new Refused(refused);
|
||||
}
|
||||
// The path judged is the path sent: normalised, with only the allow-listed query keys (guard.ts).
|
||||
|
||||
Reference in New Issue
Block a user