Let the forge's tools never weaken protection or merge past the checks
mesh/merge-gate pass: builds gitea → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request

gitea_api took any endpoint with a token minted for the forge's
administrator with write:admin: an agent could drop a branch's protection
or its required checks, set the merge check's status, write to main or
merge its own pull request (novox/hq ADR 0266 §7, the review of
2026-10-09). gitea_api now reads anything but the administration and
writes only what an author writes; protection is only strengthened; a
merge needs every required status a success and merges only that head; a
guarded repository is not deleted; and the token is minted without
write:admin, a kept one re-minted at the next start.
This commit is contained in:
jochen
2026-10-09 11:47:36 +02:00
parent 7339ce8556
commit 080c113d69
6 changed files with 476 additions and 31 deletions
+26 -6
View File
@@ -34,15 +34,18 @@ export const TOKEN_NAME = "mesh-tools";
* It sits under the `user` category despite listing repositories, not `repository`
* — confirmed against the running forge (1.27.3), which answered
* `required=[read:user]` to a token carrying only the other two.
* write:admin — /admin/users: the forge's own users are the mesh's to settle, such as making
* the builder's login a site admin so every repository the mesh may build is
* clonable (novox/hq 229). Nothing under /orgs or write:user.
*
* **Not write:admin** (novox/hq ADR 0266). It was here so a person could settle the forge's users through
* `gitea_api` (novox/hq 229); whoever may call a tool includes agents, and the forge's administration is the
* operator's. The provisioner settles users with the admin account's own login (GiteaAdmin), never this token.
* A token kept with a different list is minted again with this one at the next start (the scopes are kept
* beside it), and the forge drops the old one by name — so the scope a kept token had does not outlive this.
*
* A token kept from before a scope was added lacks it: the forge answers such a call with
* `403 token does not have at least one of required scope(s)`, and the client treats that like a
* 401 — the source re-mints by name, with the whole list, and the call is retried once.
*/
export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user", "write:admin"];
export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user"];
/** Where a client's token comes from, and what to do when the forge says it is wrong. */
export interface TokenSource {
@@ -153,11 +156,12 @@ export class MintedToken implements TokenSource {
if (!this.readFile) {
this.readFile = true;
const kept = this.read();
if (kept !== null) {
if (kept !== null && this.keptScopes() === this.scopesLine()) {
this.held = kept;
this.log(`reusing the token kept at ${this.opts.file}`);
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");
}
return this.mint("no token kept — minting one");
}
@@ -202,13 +206,29 @@ export class MintedToken implements TokenSource {
}
}
/** Write the token at 0600, whole or not at all: a temp file beside it, then a rename. */
/** The scopes as kept beside the token: sorted, one line. */
private scopesLine(): string {
return [...this.scopes].sort().join(",");
}
/** The scopes the kept token was minted with, "" when not kept (a token kept before they were). */
private keptScopes(): string {
try {
return readFileSync(`${this.opts.file}.scopes`, "utf8").trim();
} catch {
return "";
}
}
/** Write the token at 0600, whole or not at all: a temp file beside it, then a rename. Its scopes beside it. */
private keep(token: string): void {
mkdirSync(dirname(this.opts.file), { recursive: true, mode: 0o700 });
const tmp = `${this.opts.file}.tmp`;
writeFileSync(tmp, token + "\n", { mode: 0o600 });
chmodSync(tmp, 0o600);
renameSync(tmp, this.opts.file);
writeFileSync(`${this.opts.file}.scopes.tmp`, this.scopesLine() + "\n", { mode: 0o600 });
renameSync(`${this.opts.file}.scopes.tmp`, `${this.opts.file}.scopes`);
}
private async doMint(): Promise<string> {