diff --git a/modules/gitea/README.md b/modules/gitea/README.md index 4a4e356f..5b0f8934 100644 --- a/modules/gitea/README.md +++ b/modules/gitea/README.md @@ -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`. diff --git a/modules/gitea/cmd/package-registry/forge.go b/modules/gitea/cmd/package-registry/forge.go index 4f454fe0..f752a411 100644 --- a/modules/gitea/cmd/package-registry/forge.go +++ b/modules/gitea/cmd/package-registry/forge.go @@ -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. diff --git a/modules/gitea/cmd/package-registry/seats.go b/modules/gitea/cmd/package-registry/seats.go index a20f6a32..759bdfad 100644 --- a/modules/gitea/cmd/package-registry/seats.go +++ b/modules/gitea/cmd/package-registry/seats.go @@ -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) { diff --git a/modules/gitea/cmd/package-registry/seats_test.go b/modules/gitea/cmd/package-registry/seats_test.go index 155141e7..92ac71e4 100644 --- a/modules/gitea/cmd/package-registry/seats_test.go +++ b/modules/gitea/cmd/package-registry/seats_test.go @@ -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) + } } diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index 3994116d..f25a9440 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -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("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}`); } diff --git a/modules/gitea/packages-account.ts b/modules/gitea/packages-account.ts index 01a55eae..ffbfab7b 100644 --- a/modules/gitea/packages-account.ts +++ b/modules/gitea/packages-account.ts @@ -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 }, + log: (line: string) => void, fetchImpl: typeof fetch = fetch): (change: FindingChange) => Promise { + 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); + }; +} diff --git a/modules/gitea/test/tools-account.test.ts b/modules/gitea/test/tools-account.test.ts index f3a9316b..e507660a 100644 --- a/modules/gitea/test/tools-account.test.ts +++ b/modules/gitea/test/tools-account.test.ts @@ -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"));