CAT-G2: the package seats write as an account of their own, the tools' token reads packages

Design 51 keeps each holder's write side as an own secret beside a read-only tool token. The Go
bundle wrote as the forge's administrator; it now reads with the tools' token (read:package added)
and writes with the mesh-packages account's token (write:package alone), which the watcher makes,
mints, keeps and renews.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 14:14:14 +01:00
parent 63322ef06f
commit 11acf04976
13 changed files with 271 additions and 47 deletions
+10 -5
View File
@@ -240,11 +240,16 @@ version already gone counts as deleted.
### How it reaches the forge
Over the forge's HTTP interface on the machine (`MESH_GITEA_URL`), as the admin account the vault
delivered (`MESH_GITEA_ADMIN_USER`, the password in `MESH_GITEA_ADMIN_PASSWORD_FILE` — the same file
the TypeScript bundle mints its token with), by basic authentication: this bundle mints no token and
keeps nothing. The password 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`.
Over the forge's HTTP interface on the machine (`MESH_GITEA_URL`), never as the admin account (design 51: each
holder keeps its write side as an own secret beside a read-only tool token). It **reads** as the tools' account,
with the token the TypeScript bundle keeps for it (`MESH_GITEA_TOKEN_FILE`, scopes including `read:package`), and
**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
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`.
Calls to the forge run eight at a time, and one tool call reads for at most 50 seconds, inside a
module's 60-second ask.
+23 -17
View File
@@ -688,28 +688,34 @@ func TestToolsAreTheOnesTheReadmeNamesAndTheManifestBuilds(t *testing.T) {
if !goProvided {
t.Error("go-package-registry is not provided at mesh scope with no credential")
}
found := false
// The tools' own account, never the admin login (novox/hq ADR 0365 §2); the package registry's bundle also writes
// as the packages account (design 51: the write side an own secret beside a read-only tool token).
wants := map[string][]string{
"cmd/forge": {"MESH_GITEA_URL", "MESH_GITEA_TOOLS_USER", "MESH_GITEA_TOKEN_FILE"},
"cmd/package-registry": {"MESH_GITEA_URL", "MESH_GITEA_TOOLS_USER", "MESH_GITEA_TOKEN_FILE", "MESH_GITEA_PACKAGES_USER", "MESH_GITEA_PACKAGES_TOKEN_FILE"},
}
for _, a := range m.Build.Artifacts {
if a.From == "cmd/forge" {
found = true
if len(a.Loads) != 1 || a.Loads[0] != a.Binary {
t.Errorf("the bundle loads %v, not its binary %s", a.Loads, a.Binary)
want, ok := wants[a.From]
if !ok {
continue
}
delete(wants, a.From)
if len(a.Loads) != 1 || a.Loads[0] != a.Binary {
t.Errorf("%s loads %v, not its binary %s", a.From, a.Loads, a.Binary)
}
for _, k := range want {
if a.Env[k] == "" {
t.Errorf("%s is not given %s", a.From, k)
}
// 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)
}
}
for _, k := range []string{"MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE"} {
if a.Env[k] != "" {
t.Errorf("%s is given %s: it acts as an account of its own, not the administrator", a.From, k)
}
}
}
if !found {
t.Error("the manifest builds no cmd/forge bundle")
for from := range wants {
t.Errorf("the manifest builds no %s bundle", from)
}
}
+29 -7
View File
@@ -13,13 +13,17 @@ 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 forge bundle beside it). Basic authentication, so this bundle mints
// nothing and keeps nothing: the TypeScript bundle's token is its own.
// The forge as this bundle reaches it: its own HTTP interface on the machine (internal/forgehttp, shared with the
// forge bundle beside it), never as the admin account. It reads as the tools' account, whose token carries
// read:package, and writes (a publish, a remove, retention's deletion) as the packages account, whose token carries
// write:package (design 51: each holder keeps its write side as an own secret beside a read-only tool token). The
// TypeScript bundle mints and keeps both tokens (token.ts, packages-account.ts); this bundle mints nothing.
// Forge is the forge's HTTP interface.
type Forge struct {
forgehttp.Client
forgehttp.Client // reads, as the tools' account
// Writer writes into the package registry, as the packages account; nil writes nothing.
Writer *forgehttp.Client
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
@@ -49,7 +53,13 @@ func (f *Forge) As(typ string) *Forge {
// ForgeFromEnv is the forge as the manifest hands it to this bundle.
func ForgeFromEnv() (*Forge, error) {
c, missing := forgehttp.FromEnv()
f := &Forge{Client: c, Owner: os.Getenv("MESH_NPM_OWNER")}
w, writeMissing := forgehttp.WriterFromEnv()
for _, m := range writeMissing {
if m != "MESH_GITEA_URL" {
missing = append(missing, m)
}
}
f := &Forge{Client: c, Writer: &w, Owner: os.Getenv("MESH_NPM_OWNER")}
if f.Owner == "" {
missing = append(missing, "MESH_NPM_OWNER")
}
@@ -127,9 +137,21 @@ func (f *Forge) DistTags(ctx context.Context, name string) (map[string]string, e
return meta.DistTags, nil
}
// Delete removes one version from the registry.
// write is the registry's write side, refused when the manifest handed none.
func (f *Forge) write() (*forgehttp.Client, error) {
if f.Writer == nil {
return nil, errors.New("the package registry's write side is not handed to this bundle (MESH_GITEA_PACKAGES_TOKEN_FILE); nothing was written")
}
return f.Writer, nil
}
// Delete removes one version from the registry, as the packages account.
func (f *Forge) Delete(ctx context.Context, name, version string) error {
_, err := f.do(ctx, http.MethodDelete, f.versionPath(name, version))
w, err := f.write()
if err != nil {
return err
}
_, err = w.Do(ctx, http.MethodDelete, f.versionPath(name, version), nil, maxBody)
return err
}
@@ -42,7 +42,8 @@ func (f *fakeForge) serve() *Forge {
if err := os.WriteFile(file, []byte(f.password+"\n"), 0o600); err != nil {
f.t.Fatal(err)
}
return &Forge{Client: forgehttp.Client{URL: srv.URL, User: "admin", PasswordFile: file}, Owner: "acme"}
c := forgehttp.Client{URL: srv.URL, User: "admin", PasswordFile: file}
return &Forge{Client: c, Writer: &c, Owner: "acme"}
}
var (
@@ -482,7 +483,7 @@ func TestToolsAreTheOnesTheReadmeNamesAndSayWhatTheyDo(t *testing.T) {
for _, a := range m.Build.Artifacts {
if a.From == "cmd/package-registry" {
found = true
for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE", "MESH_NPM_OWNER"} {
for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_TOKEN_FILE", "MESH_GITEA_PACKAGES_TOKEN_FILE", "MESH_NPM_OWNER"} {
if a.Env[k] == "" {
t.Errorf("the bundle is not given %s", k)
}
+7 -4
View File
@@ -79,20 +79,24 @@ func (s seatStore) Put(seat string, p packageseats.Publish, file []byte) (string
if err != nil {
return "", err
}
w, err := f.write()
if err != nil {
return "", err
}
ctx := context.Background()
if seat == packageseats.GoSeat {
module, err := goModuleOf(file, forgeVersion(seat, p.Version))
if err != nil {
return "", err
}
_, err = f.Send(ctx, http.MethodPut, fmt.Sprintf("/api/packages/%s/go/upload", url.PathEscape(f.Owner)), file, "application/zip", maxBody)
_, err = w.Send(ctx, http.MethodPut, fmt.Sprintf("/api/packages/%s/go/upload", url.PathEscape(f.Owner)), file, "application/zip", maxBody)
return module, err
}
doc, err := npmDocument(f.URL, f.Owner, p, file)
if err != nil {
return "", err
}
_, err = f.Do(ctx, http.MethodPut, fmt.Sprintf("/api/packages/%s/npm/%s", url.PathEscape(f.Owner), url.PathEscape(p.Name)), doc, maxBody)
_, 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
}
@@ -136,8 +140,7 @@ func (s seatStore) Remove(seat, name, version string) error {
if err != nil {
return err
}
_, err = f.do(context.Background(), http.MethodDelete, f.packagePath(forgeType(seat), name, forgeVersion(seat, version)))
return err
return f.As(forgeType(seat)).Delete(context.Background(), name, forgeVersion(seat, version))
}
// goModuleOf is the Go module path of a module zip whose every file sits under <module>@<version>/, as Go's module
@@ -58,6 +58,15 @@ func (g *registry) handle(w http.ResponseWriter, r *http.Request) {
defer g.mu.Unlock()
body, _ := io.ReadAll(r.Body)
p := r.URL.EscapedPath()
// Reads come as the tools' account (read:package), writes as the packages account (write:package), never the admin.
want := "token reader"
if r.Method != http.MethodGet {
want = "token writer"
}
if got := r.Header.Get("Authorization"); got != want {
http.Error(w, r.Method+" "+p+" came as "+got+", not "+want, http.StatusForbidden)
return
}
switch {
case r.Method == http.MethodPut && p == "/api/packages/acme/go/upload":
if r.Header.Get("Content-Type") != "application/zip" {
@@ -101,9 +110,20 @@ func TestThePackageSeatsStoreIntoTheForge(t *testing.T) {
g := &registry{uploaded: map[string][]byte{}, held: map[string][]byte{}}
srv := httptest.NewServer(http.HandlerFunc(g.handle))
defer srv.Close()
file := filepath.Join(t.TempDir(), "admin.secret")
_ = os.WriteFile(file, []byte("pw\n"), 0o600)
forge := &Forge{Client: forgehttp.Client{URL: srv.URL, User: "admin", PasswordFile: file}, Owner: "acme"}
dir := t.TempDir()
kept := func(name, token, line string) string {
file := filepath.Join(dir, name)
_ = os.WriteFile(file, []byte(token+"\n"), 0o600)
_ = os.WriteFile(file+".scopes", []byte(line+"\n"), 0o600)
return file
}
reader := forgehttp.Client{URL: srv.URL, TokenFile: kept("token", "reader", "read:package,read:user for mesh-tools"), Account: "mesh-tools"}
writer := forgehttp.Client{URL: srv.URL, TokenFile: kept("packages-token", "writer", "write:package for mesh-packages"), Account: "mesh-packages"}
forge := &Forge{Client: reader, Writer: &writer, Owner: "acme"}
if _, err := (seatStore{forge: func() (*Forge, error) { return &Forge{Client: reader, Owner: "acme"}, nil }}).Put(packageseats.GoSeat, packageseats.Publish{}, nil); err == nil ||
!strings.Contains(err.Error(), "write side") {
t.Errorf("a forge handed no write side wrote: %v", err)
}
s := seatStore{forge: func() (*Forge, error) { return forge, nil }}
tgz := tarball(t, `{"name":"@acme/contracts","version":"0.1.15-feat-x.2","main":"index.js"}`)
+14
View File
@@ -64,6 +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";
// 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
@@ -633,6 +634,19 @@ 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.
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);
} catch (err) {
console.error(`[gitea] the package seats cannot write — ${err instanceof Error ? err.message : err}`);
}
tick(mainRulesStep(keepMainRules(), () => GiteaAdmin.fromEnv().ensureMainMerges("novox"), (line) => console.log(line)), 600_000);
tick(() => pollRepos(client), 60_000);
// **No timer asks the forge for pull requests or merges** (issue 1422): the forge's events announce them. One pass over
+26 -3
View File
@@ -46,6 +46,29 @@ type Client struct {
// ToolsAccount is the account the forge's tools act as when the manifest names none (token.ts, tools-account.ts).
const ToolsAccount = "mesh-tools"
// PackagesAccount is the account the package seats write as when the manifest names none (packages-account.ts):
// its token carries write:package and nothing else (design 51: each holder keeps its write side as an own secret
// beside a read-only tool token).
const PackagesAccount = "mesh-packages"
// WriterFromEnv is the package registry's write side as the manifest hands it to a bundle: the packages account's
// token (MESH_GITEA_PACKAGES_TOKEN_FILE, kept by the module beside the tools' token) and the names of the variables
// it left unset. Never the admin login: a bundle handed no writer's token writes nothing.
func WriterFromEnv() (Client, []string) {
c := Client{URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"), TokenFile: os.Getenv("MESH_GITEA_PACKAGES_TOKEN_FILE"),
Account: os.Getenv("MESH_GITEA_PACKAGES_USER")}
if c.Account == "" {
c.Account = PackagesAccount
}
var missing []string
for _, kv := range [][2]string{{"MESH_GITEA_URL", c.URL}, {"MESH_GITEA_PACKAGES_TOKEN_FILE", c.TokenFile}} {
if kv[1] == "" {
missing = append(missing, kv[0])
}
}
return c, missing
}
// 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) {
@@ -113,7 +136,7 @@ func (c *Client) password() (string, error) {
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)
return "", fmt.Errorf("the token for %s is not kept yet (the module mints it): %w", c.Account, err)
}
line, err := os.ReadFile(c.TokenFile + ".scopes")
if err != nil || !strings.HasSuffix(strings.TrimSpace(string(line)), " for "+c.Account) {
@@ -190,8 +213,8 @@ func (c *Client) Send(ctx context.Context, method, path string, raw []byte, cont
}
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"})
return nil, fmt.Errorf("the forge rejected %s's token for %s (401): the module mints it afresh; nothing was done: %w",
c.Account, 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:
+4 -2
View File
@@ -502,8 +502,10 @@
],
"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",
"MESH_GITEA_PACKAGES_USER": "mesh-packages",
"MESH_GITEA_PACKAGES_TOKEN_FILE": "${dir:runtime-state}/packages-token",
"MESH_NPM_OWNER": "novox"
}
},
+88
View File
@@ -0,0 +1,88 @@
// The package seats write as an account of their own, never the administrator (design 51: each holder keeps its write
// side as an own secret beside a read-only tool token; the forge module's is `write:package`, its tool token
// `read:package`).
//
// The Go bundle (cmd/package-registry) serves both package seats' verbs: it reads the registry with the tools' token
// (token.ts, which carries `read:package`) and writes (a publish, a remove, retention's deletion) with this one:
//
// - **the account**: `mesh-packages`, plain, made by the module with the administrator's login as tools-account.ts
// makes `mesh-tools`; its password random, sent once and kept nowhere.
// - **the team**: `mesh-packages` in the organisation that owns the registry, writing packages and nothing else:
// no code, no issues, no pull requests, no administration.
// - **the token**: minted for the account by the administrator's login with the one scope `write:package`, kept at
// 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.
import { randomBytes } from "node:crypto";
import { join } from "node:path";
import { MintedToken } from "./token.js";
import { TOOLS_ORG, type ToolsForge } from "./tools-account.js";
/** The account the package seats write as (internal/forgehttp PackagesAccount). */
export const PACKAGES_ACCOUNT = "mesh-packages";
/** Its team in the registry's organisation, and the token's name in the account's list. */
export const PACKAGES_TEAM = "mesh-packages";
export const PACKAGES_TOKEN_NAME = "mesh-packages";
/** The one scope the write side needs; reads go with the tools' token. */
export const PACKAGES_TOKEN_SCOPES: readonly string[] = ["write:package"];
/** Where the token is kept in the module's runtime state (the manifest's MESH_GITEA_PACKAGES_TOKEN_FILE). */
export const PACKAGES_TOKEN_FILE = "packages-token";
/** What the team grants: write on the organisation's packages, and no more. */
export const PACKAGES_TEAM_UNITS = {
units_map: { "repo.packages": "write" },
includes_all_repositories: false,
can_create_org_repo: false,
} as const;
/** Make the packages account, plain, a member of its team. Its password is set afresh each time and kept nowhere. */
export async function setUpPackagesAccount(forge: ToolsForge, account = PACKAGES_ACCOUNT, password: () => string): Promise<void> {
await forge.ensureUser(account, password(), `${account}@noreply.localhost`, true);
const team = await forge.ensureTeamWith(TOOLS_ORG, PACKAGES_TEAM, { name: PACKAGES_TEAM, ...PACKAGES_TEAM_UNITS });
await forge.addUserToTeam(team, account);
}
/** The packages account's token, minted by the administrator's login from what the manifest hands the watcher. */
export function packagesToken(url: string, env: NodeJS.ProcessEnv = process.env, log?: (line: string) => void): MintedToken {
const admin = env.MESH_GITEA_ADMIN_USER;
const passwordFile = env.MESH_GITEA_ADMIN_PASSWORD_FILE;
const stateDir = env.MESH_GITEA_STATE_DIR;
const missing = [
admin ? null : "MESH_GITEA_ADMIN_USER",
passwordFile ? null : "MESH_GITEA_ADMIN_PASSWORD_FILE",
stateDir ? null : "MESH_GITEA_STATE_DIR",
].filter((v): v is string => v !== null);
if (missing.length) throw new Error(`the package seats' write token cannot be minted: ${missing.join(", ")} not set by the manifest`);
const account = env.MESH_GITEA_PACKAGES_USER || PACKAGES_ACCOUNT;
if (account === admin) throw new Error(`MESH_GITEA_PACKAGES_USER names the admin account "${admin}"; the package seats write as an account of their own`);
return new MintedToken({
url, admin: admin!, passwordFile: passwordFile!, file: join(stateDir!, PACKAGES_TOKEN_FILE), account,
name: PACKAGES_TOKEN_NAME, scopes: PACKAGES_TOKEN_SCOPES, log,
setUp: async (password) => {
const { GiteaAdmin } = await import("./client.js");
await setUpPackagesAccount(new GiteaAdmin(url, admin!, password), account, () => randomBytes(24).toString("base64url"));
},
});
}
/**
* One look at the packages token: mint it when none is kept, then ask the forge one read the scope covers (the
* organisation's package list); a 401 renews it. Answers what was done, for the watcher's line.
*/
export async function keepPackagesToken(source: MintedToken, url: string, owner = TOOLS_ORG, fetchImpl: typeof fetch = fetch): Promise<string> {
const token = await source.current();
const look = (t: string) => fetchImpl(`${url.replace(/\/+$/, "")}/api/v1/packages/${encodeURIComponent(owner)}?limit=1`,
{ headers: { Authorization: `token ${t}`, Accept: "application/json" } });
const res = await look(token);
if (res.status === 401 || MintedToken.lacksScope(res.status, await res.text())) {
const fresh = await source.renew(token);
const again = await look(fresh);
if (!again.ok) throw new Error(`the forge answered ${again.status} to the renewed package token's read`);
return "the forge rejected the package seats' write token; minted afresh";
}
if (!res.ok) throw new Error(`the forge answered ${res.status} to the package token's read`);
return "the package seats' write token holds";
}
+2 -2
View File
@@ -293,11 +293,11 @@ test("first start: mints with the admin account, keeps the token at 0600, asks f
assert.equal(repos[0]?.full_name, "novox/hq");
assert.equal(forge.mints, 1);
// Never write:admin (novox/hq ADR 0266): the forge's administration is the operator's, not a tool's.
assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user"]);
assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user", "read:package"]);
assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]);
const token = forge.tokens.get("mesh-tools")!;
assert.equal(await readFile(file, "utf8"), token + "\n");
assert.equal(await readFile(file + ".scopes", "utf8"), "read:user,write:issue,write:repository\n");
assert.equal(await readFile(file + ".scopes", "utf8"), "read:package,read:user,write:issue,write:repository\n");
assert.equal((await stat(file)).mode & 0o777, 0o600);
// Said that it minted, and where it keeps it — never what it is.
assert.ok(logs.some((l) => l.startsWith("minted a token")), logs.join("\n"));
+38 -1
View File
@@ -14,9 +14,10 @@ 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 { 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 { repoCheckFacts } from "../dist/pulls.js";
const ADMIN = "mesh-admin";
@@ -117,6 +118,13 @@ function fakeForge() {
}
}
if (req.method === "GET" && p === "/user") return json(res, 200, { login: me, is_admin: isAdmin(me) });
// The organisation's packages: a token whose scope reaches packages, of a member of a team writing them.
if (req.method === "GET" && p === "/packages/novox") {
const scopes = tokens.find((t) => `token ${t.sha1}` === req.headers.authorization)?.scopes ?? [];
if (!scopes.some((s) => /:package$/.test(s))) return json(res, 403, { message: "token does not have at least one of required scope(s): [read:package]" });
if (!teams.some((t) => t.members.has(me) && t.units.units_map?.["repo.packages"] === "write")) return json(res, 403, { message: "no access to the packages" });
return json(res, 200, []);
}
m = p.match(/^\/users\/([^/]+)$/);
if (m && req.method === "GET") {
const u = users.get(decodeURIComponent(m[1]));
@@ -360,6 +368,35 @@ test("the manifest hands the tools their account, and the Go forge bundle holds
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);
// The package registry's bundle reads with the tools' token and writes with the packages account's (design 51).
const packages = manifest.build.artifacts.find((a: any) => a.name === "package-registry");
assert.equal(packages.env.MESH_GITEA_TOKEN_FILE, forgeBundle.env.MESH_GITEA_TOKEN_FILE);
assert.equal(packages.env.MESH_GITEA_PACKAGES_USER, PACKAGES_ACCOUNT);
assert.match(packages.env.MESH_GITEA_PACKAGES_TOKEN_FILE, /^\$\{dir:runtime-state\}\/packages-token$/);
assert.equal(packages.env.MESH_GITEA_ADMIN_PASSWORD_FILE, undefined);
assert.equal(code.env.MESH_GITEA_STATE_DIR, "${dir:runtime-state}", "the watcher mints the packages token where the bundle reads it");
});
test("the package seats write as an account of their own: plain, in a team writing only packages, its token write:package alone, renewed when the forge rejects it", async () => {
const env = await environment();
const source = packagesToken(forge.url, env, () => {});
assert.equal(await keepPackagesToken(source, forge.url), "the package seats' write token holds");
const account = forge.users.get(PACKAGES_ACCOUNT);
assert.ok(account, "the module made the packages account");
assert.equal(account!.admin, false, "the packages account is no administrator");
const held = forge.tokens.filter((t) => t.name === "mesh-packages");
assert.deepEqual(held.map((t) => [t.user, t.scopes]), [[PACKAGES_ACCOUNT, ["write:package"]]]);
assert.equal(await readFile(join(env.MESH_GITEA_STATE_DIR, "packages-token"), "utf8"), held[0].sha1 + "\n");
assert.equal(await readFile(join(env.MESH_GITEA_STATE_DIR, "packages-token.scopes"), "utf8"), "write:package for mesh-packages\n");
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.
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");
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"));
});
// Review of issue 469: what reads the rules' list is moved to the branch, and nothing leaves the tools without a token.
+4 -1
View File
@@ -47,6 +47,9 @@ export const TOKEN_NAME = "mesh-tools";
* The least the fifteen tools and the watcher need (gitea's route groups, 1.20+ scoped tokens):
* write:repository — create/delete repositories, pull requests (list/get/open/merge);
* write:issue — issues, comments, labels;
* read:package — the package registry's reads by the Go bundle (cmd/package-registry): the package seats'
* held, versions and the retention's survey (design 51: a read-only tool token; the
* writes go with the packages account's own token, packages-account.ts);
* read:user — GET /user/repos, which the watcher's poll and gitea_list_repos both call.
* It sits under the `user` category despite listing repositories, not `repository`
* — confirmed against the running forge (1.27.3), which answered
@@ -62,7 +65,7 @@ export const TOKEN_NAME = "mesh-tools";
* `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"];
export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user", "read:package"];
/** Where a client's token comes from, and what to do when the forge says it is wrong. */
export interface TokenSource {