CAT-G2: the packages token is renewed on the forge's refusal, said as a finding, never on a timer

The Go bundle raises packages-token-refused in the module's findings state when the forge
answers 401 to a write; the watcher mints afresh on that key's event and clears it (lead's
ruling on A3n, map-onto-how-the-mesh-is-built: nothing waits on a timer).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 14:47:26 +01:00
parent 929b1ce13e
commit 4492bc3013
7 changed files with 103 additions and 20 deletions
+4 -2
View File
@@ -246,8 +246,10 @@ with the token the TypeScript bundle keeps for it (`MESH_GITEA_TOKEN_FILE`, scop
**writes** (a publish, a remove, retention's deletion) as the packages account `mesh-packages`
(`MESH_GITEA_PACKAGES_USER`), with its own token (`MESH_GITEA_PACKAGES_TOKEN_FILE`, the one scope `write:package`).
That account is plain, a member of the team `mesh-packages` that writes the organisation's packages and nothing else;
the watcher makes it with the administrator's login and mints and keeps its token at its start, looks at it every ten
minutes and mints it afresh when the forge rejects it (`packages-account.ts`). This bundle mints nothing; a token is
the watcher makes it with the administrator's login and mints and keeps its token at its start (`packages-account.ts`).
No timer looks at it after that: when the forge refuses a write (401) this bundle raises the finding
`packages-token-refused` (key `seat.gitea.packages-token-refused_write` in the module's `findings` state), the watcher,
watching that key, mints the token afresh and clears it; a write the forge takes clears it too. This bundle mints nothing; a token is
read per call and never logged, answered or put in an error. The registry's owner is `MESH_NPM_OWNER`, the owner in
the seat's `npm-path`.
+30 -2
View File
@@ -27,6 +27,32 @@ type Forge struct {
Owner string // the package registry's owner, as the seat serves it
// Type is the registry's package type retention reads: TypeNpm when empty, or TypeGo.
Type string
// Refused says whether the forge refused the write side's token (401), as the finding PackagesTokenRefused in the
// module's findings state: the TypeScript bundle mints a new token on that key's event and clears it (no timer
// looks at the token). Nil says nothing.
Refused func(raised bool)
}
// PackagesTokenRefused is the finding this bundle raises when the forge refuses the packages account's token, and its
// key in the module's findings state (packages-account.ts PACKAGES_TOKEN_REFUSED watches that key).
const (
PackagesTokenRefused = "packages-token-refused"
PackagesTokenRefusedKey = "seat.gitea.packages-token-refused_write"
)
// wrote is a write's answer, said on Refused: a 401 raises the finding, a write the forge took clears it.
func (f *Forge) wrote(err error) error {
if f.Refused == nil {
return err
}
var status *forgehttp.StatusError
switch {
case errors.As(err, &status) && status.Status == http.StatusUnauthorized:
f.Refused(true)
case err == nil:
f.Refused(false)
}
return err
}
// The package types the retention reads: npm packages (the npm seat) and Go modules (the Go seat).
@@ -59,7 +85,9 @@ func ForgeFromEnv() (*Forge, error) {
missing = append(missing, m)
}
}
f := &Forge{Client: c, Writer: &w, Owner: os.Getenv("MESH_NPM_OWNER")}
f := &Forge{Client: c, Writer: &w, Owner: os.Getenv("MESH_NPM_OWNER"), Refused: func(raised bool) {
finding(PackagesTokenRefused, "write", "the forge refused the package seats' write token; the module mints it afresh", raised)
}}
if f.Owner == "" {
missing = append(missing, "MESH_NPM_OWNER")
}
@@ -152,7 +180,7 @@ func (f *Forge) Delete(ctx context.Context, name, version string) error {
return err
}
_, err = w.Do(ctx, http.MethodDelete, f.versionPath(name, version), nil, maxBody)
return err
return f.wrote(err)
}
// Repository is one repository on the forge, as far as reading its manifests needs.
+2 -2
View File
@@ -90,14 +90,14 @@ func (s seatStore) Put(seat string, p packageseats.Publish, file []byte) (string
return "", err
}
_, err = w.Send(ctx, http.MethodPut, fmt.Sprintf("/api/packages/%s/go/upload", url.PathEscape(f.Owner)), file, "application/zip", maxBody)
return module, err
return module, f.wrote(err)
}
doc, err := npmDocument(f.URL, f.Owner, p, file)
if err != nil {
return "", err
}
_, err = w.Do(ctx, http.MethodPut, fmt.Sprintf("/api/packages/%s/npm/%s", url.PathEscape(f.Owner), url.PathEscape(p.Name)), doc, maxBody)
return "", err
return "", f.wrote(err)
}
func (s seatStore) Versions(seat, name string) ([]packageseats.Version, error) {
@@ -19,6 +19,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/health"
"gitea/internal/forgehttp"
"gitea/internal/packageseats"
)
@@ -63,7 +64,10 @@ func (g *registry) handle(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
want = "token writer"
}
if got := r.Header.Get("Authorization"); got != want {
if got := r.Header.Get("Authorization"); got == "token stale" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
} else if got != want {
http.Error(w, r.Method+" "+p+" came as "+got+", not "+want, http.StatusForbidden)
return
}
@@ -125,6 +129,19 @@ func TestThePackageSeatsStoreIntoTheForge(t *testing.T) {
t.Errorf("a forge handed no write side wrote: %v", err)
}
s := seatStore{forge: func() (*Forge, error) { return forge, nil }}
// The forge refuses the write side's token: the finding the TypeScript bundle renews on is raised under its key,
// and the next write the forge takes clears it (no timer looks at the token).
var refused []bool
forge.Refused = func(raised bool) { refused = append(refused, raised) }
if k := health.FindingKey(health.ScopeSeat, packageseats.Self, PackagesTokenRefused+".write"); k != PackagesTokenRefusedKey {
t.Errorf("the refusal's key is %s, the watcher watches %s", k, PackagesTokenRefusedKey)
}
_ = os.WriteFile(writer.TokenFile, []byte("stale\n"), 0o600)
if _, err := s.Put(packageseats.GoSeat, packageseats.Publish{Version: "0.1.15"}, moduleZip(t, "git.example/acme/sdk@v0.1.15")); err == nil ||
len(refused) != 1 || !refused[0] {
t.Errorf("a write the forge refused with 401 raised no finding: %v %v", err, refused)
}
_ = os.WriteFile(writer.TokenFile, []byte("writer\n"), 0o600)
tgz := tarball(t, `{"name":"@acme/contracts","version":"0.1.15-feat-x.2","main":"index.js"}`)
p := packageseats.Publish{Name: "@acme/contracts", Version: "0.1.15-feat-x.2", Tag: "feat-x", Kind: packageseats.KindPackage, Digest: "sha256:" + sha(tgz)}
@@ -174,4 +191,7 @@ func TestThePackageSeatsStoreIntoTheForge(t *testing.T) {
g.deleted[0] != "go/git.example%2Facme%2Fsdk/v0.1.15-feat.1" {
t.Errorf("remove: %v %v", err, g.deleted)
}
if len(refused) < 2 || refused[len(refused)-1] {
t.Errorf("a write the forge took after the refusal did not clear it: %v", refused)
}
}
+7 -8
View File
@@ -64,7 +64,7 @@ import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommi
import { ReadAll, readAllTools } from "./readall.js";
import { DeliveredWrites, INDEXER_MODULES, writeTools, type ModulesChange } from "./writes.js";
import { TOOLS_ACCOUNT, TOOLS_ORG, meshAccounts } from "./tools-account.js";
import { keepPackagesToken, packagesToken } from "./packages-account.js";
import { PACKAGES_TOKEN_REFUSED, keepPackagesToken, packagesToken, renewOnRefusal } from "./packages-account.js";
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
@@ -634,16 +634,15 @@ if (gitea) {
// Main takes no push and only the tools' account merges, in every repository, kept and not only when a token is
// minted: at the start and every ten minutes after, so a repository made later gets it too (issue 948, ADR 0398 §2).
// Only while the setting keep-main-rules is true (mainrules.ts); false, the default, leaves every repository's rule as set.
// The package seats' write token (packages-account.ts): minted at the start and looked at every ten minutes, since the
// Go bundle that writes with it mints nothing; a token the forge rejects is minted afresh at the next look.
// The package seats' write token (packages-account.ts): minted at the start; after that renewed only when the Go
// bundle that writes with it says the forge refused it (the finding PACKAGES_TOKEN_REFUSED), never on a timer.
try {
const url = process.env.MESH_GITEA_URL ?? "http://127.0.0.1:3000";
const source = packagesToken(url);
let last = "";
tick(() => keepPackagesToken(source, url).then((said) => {
if (said !== last) console.log(`[gitea] ${said}`);
last = said;
}), 600_000);
const say = (line: string) => console.log(`[gitea] ${line}`);
void keepPackagesToken(source, url).then(say, (err) => console.error(`[gitea] the package seats' write token could not be minted at the start — ${err instanceof Error ? err.message : err}`));
const findings = state<unknown>("findings");
void bindUntilHeld("findings", () => findings.watch(renewOnRefusal(source, url, findings, say), { key: PACKAGES_TOKEN_REFUSED }));
} catch (err) {
console.error(`[gitea] the package seats cannot write — ${err instanceof Error ? err.message : err}`);
}
+27 -2
View File
@@ -13,8 +13,9 @@
// 0600 in the module's runtime state as `packages-token`, its scopes line beside it saying whose it is
// (MintedToken: one mint at a time across the bundle's processes, re-minted when the kept one is gone or other).
//
// The watcher mints it at its start and looks again every ten minutes (keepPackagesToken): the Go bundle mints
// nothing, so a token the forge rejects is renewed here, at the next look, and said.
// The watcher mints it at its start (keepPackagesToken). No timer looks at it after that: the Go bundle mints nothing,
// and when the forge refuses its write it raises the finding PACKAGES_TOKEN_REFUSED in the module's findings state; the
// watcher, watching that key, looks again (a refused token is minted afresh) and clears the finding (renewOnRefusal).
import { randomBytes } from "node:crypto";
import { join } from "node:path";
@@ -86,3 +87,27 @@ export async function keepPackagesToken(source: MintedToken, url: string, owner
if (!res.ok) throw new Error(`the forge answered ${res.status} to the package token's read`);
return "the package seats' write token holds";
}
/** The finding the Go bundle raises in the module's findings state when the forge refuses the write token (cmd/package-registry PackagesTokenRefusedKey). */
export const PACKAGES_TOKEN_REFUSED = "seat.gitea.packages-token-refused_write";
/** One change of the findings state, as its watch hands it. */
export interface FindingChange { readonly key: string; readonly op: "put" | "delete" }
/**
* The watcher's answer to the Go bundle's refusal: on the finding's put, look at the token (a refused one is minted
* afresh) and clear the finding; a look that fails leaves the finding standing, said. Other keys are not its own.
*/
export function renewOnRefusal(source: MintedToken, url: string, findings: { delete(key: string): Promise<void> },
log: (line: string) => void, fetchImpl: typeof fetch = fetch): (change: FindingChange) => Promise<void> {
return async (change) => {
if (change.op !== "put" || change.key !== PACKAGES_TOKEN_REFUSED) return;
try {
log(await keepPackagesToken(source, url, TOOLS_ORG, fetchImpl));
} catch (err) {
log(`the package seats' write token was refused and could not be minted afresh; the finding stands — ${err instanceof Error ? err.message : err}`);
return;
}
await findings.delete(change.key);
};
}
+12 -3
View File
@@ -17,7 +17,7 @@ import { join } from "node:path";
import { MintedToken, TOKEN_SCOPES } from "../dist/token.js";
import { GiteaClient } from "../dist/client.js";
import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS, BUILDER_ACCOUNT, AGENT_ACCOUNT, meshAccounts } from "../dist/tools-account.js";
import { PACKAGES_ACCOUNT, PACKAGES_TEAM_UNITS, keepPackagesToken, packagesToken } from "../dist/packages-account.js";
import { PACKAGES_ACCOUNT, PACKAGES_TEAM_UNITS, PACKAGES_TOKEN_REFUSED, keepPackagesToken, packagesToken, renewOnRefusal } from "../dist/packages-account.js";
import { repoCheckFacts } from "../dist/pulls.js";
const ADMIN = "mesh-admin";
@@ -391,9 +391,18 @@ test("the package seats write as an account of their own: plain, in a team writi
const team = forge.teams.find((t) => t.name === "mesh-packages");
assert.ok(team?.members.has(PACKAGES_ACCOUNT));
assert.deepEqual(team!.units.units_map, PACKAGES_TEAM_UNITS.units_map, "the team writes packages and nothing else");
// The forge drops the token behind the module's back: the next look mints it afresh.
// The forge drops the token behind the module's back: the Go bundle's refusal (the finding's put) mints it afresh and
// clears the finding; no timer looks at it, and another key of the findings state is not its own.
forge.tokens.splice(forge.tokens.indexOf(held[0]), 1);
assert.equal(await keepPackagesToken(source, forge.url), "the forge rejected the package seats' write token; minted afresh");
const cleared: string[] = [];
const said: string[] = [];
const renew = renewOnRefusal(source, forge.url, { delete: async (k) => { cleared.push(k); } }, (l) => said.push(l));
await renew({ key: "seat.gitea.other", op: "put" });
await renew({ key: PACKAGES_TOKEN_REFUSED, op: "delete" });
assert.equal(forge.tokens.filter((t) => t.name === "mesh-packages").length, 0, "nothing renews but the refusal");
await renew({ key: PACKAGES_TOKEN_REFUSED, op: "put" });
assert.deepEqual(said, ["the forge rejected the package seats' write token; minted afresh"]);
assert.deepEqual(cleared, [PACKAGES_TOKEN_REFUSED]);
assert.equal(forge.tokens.filter((t) => t.name === "mesh-packages").length, 1);
// The tools' token reads packages too: read:package (design 51's read-only tool token).
assert.ok(TOKEN_SCOPES.includes("read:package"));