From 63322ef06f736222af243d0526f826157908262b Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 14:01:32 +0100 Subject: [PATCH] CAT-G2: both package provisions carry no credential; the npm pair provisioner is gone Reads of the organisation's packages are anonymous and every write goes through the seats' verbs (design 51 *Publishing*, prepare Q4), so npm-package-registry is form none like go-package-registry: receives, grants, the consumers' data entry, the grants directory and the TypeScript pair provisioner with its teams go. Pairs with BN3 (mesh-build-node) and the lab, which still require the pair. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- modules/gitea/README.md | 10 +++-- modules/gitea/module.json | 36 +++-------------- modules/gitea/package.json | 2 +- modules/gitea/provisioner/index.ts | 54 ------------------------- modules/gitea/provisioner/teams.ts | 64 ------------------------------ modules/gitea/test/teams.test.ts | 56 -------------------------- modules/gitea/tsconfig.json | 2 +- 7 files changed, 14 insertions(+), 210 deletions(-) delete mode 100644 modules/gitea/provisioner/index.ts delete mode 100644 modules/gitea/provisioner/teams.ts delete mode 100644 modules/gitea/test/teams.test.ts diff --git a/modules/gitea/README.md b/modules/gitea/README.md index 0de14370..b4634cd5 100644 --- a/modules/gitea/README.md +++ b/modules/gitea/README.md @@ -1,8 +1,8 @@ # gitea The forge, and the mesh's npm package registry: it claims the `git` and `npm-package-registry` seats. -Its tools, its events and the provisioner of registry accounts are the TypeScript bundle (`index.ts`, -`tools/`, `provisioner/`). This README covers the two Go bundles beside it. +Its tools and its events are the TypeScript bundle (`index.ts`, +`tools/`). This README covers the two Go bundles beside it. **What a registry consumer is told** (`serves.npm-package-registry`): where the registry answers on the private network (`scheme`, `port`, `npm-path`), and its `public-root`, the address the forge writes every @@ -11,6 +11,11 @@ tarball at: `https://` and the forge's route's composed public name, under the n locked tarball only from the registry it is configured with, so a consumer that installs from lock files trusts that root. On a machine with no public domain the key is left out, and the plan says so. +**Both package provisions carry no credential** (`credential.form` none, design 51 *Publishing*): reads of the +organisation's packages are anonymous, and every write goes through the seats' verbs, so no consumer is given an +account of the forge. The registry accounts the TypeScript pair provisioner made before stay on the forge until +removed by hand; nothing hands their passwords out any more. + ## Announcing pull requests to the merge check (novox/hq to-be 45 §9, issue 347) Every new head of an open pull request is announced as `pull.updated`, once, and the controller has it checked. @@ -359,5 +364,4 @@ Each directory or archive this module declares says how it treats a file it did - `${dir:mesh-state}/state`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). - `/var/lib/mesh/forge-patches`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). - `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). -- `${dir:grants}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). - `${dir:data}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). diff --git a/modules/gitea/module.json b/modules/gitea/module.json index cd0000e9..baeeb3c0 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -147,12 +147,6 @@ "go-path": "/api/packages/novox/go" } }, - "receives": { - "npm-package-registry": "${dir:grants}/npm.json" - }, - "grants": { - "npm-package-registry": "${dir:grants}" - }, "claims": [ { "name": "npm-package-registry", @@ -187,14 +181,7 @@ "class": "valuable", "why": "every repository, its issues and attachments, and the package registry" } - ], - "consumers": { - "npm-package-registry": { - "class": "rebuildable", - "in": "data", - "why": "a consumer's packages are published again from its source" - } - } + ] }, "config": { "url": { @@ -278,12 +265,6 @@ "place": ".", "unknown": "program" }, - { - "id": "grants", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, { "id": "server-env", "type": "file", @@ -448,13 +429,9 @@ { "name": "npm-package-registry", "credential": { - "form": "pair" + "form": "none" }, - "scope": "mesh", - "identity": { - "max": 40, - "in": "a Gitea user name" - } + "scope": "mesh" }, { "name": "go-package-registry", @@ -495,13 +472,11 @@ "language": "typescript", "entrypoints": [ "index.js", - "tools/index.js", - "provisioner/index.js" + "tools/index.js" ], "loads": [ "index.js", - "tools/index.js", - "provisioner/index.js" + "tools/index.js" ], "env": { "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", @@ -510,7 +485,6 @@ "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_HOOK_URL": "http://172.17.0.1:3047", "MESH_GITEA_HOOK_PORT": "3047", "MESH_GITEA_HOOK_HOST": "172.17.0.1" diff --git a/modules/gitea/package.json b/modules/gitea/package.json index 8c6171fe..8e33075b 100644 --- a/modules/gitea/package.json +++ b/modules/gitea/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "scripts": { - "build": "tsc client.ts token.ts index.ts provisioner/index.ts provisioner/teams.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", + "build": "tsc client.ts token.ts index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", "test": "npm run build && tsc --module NodeNext --moduleResolution NodeNext --target ES2022 --types node --rootDir . --outDir . $(find . -name '*.ts' ! -name '*.d.ts' ! -path './test/*' ! -path './node_modules/*' ! -path './dist/*') && node --test --experimental-strip-types 'test/*.test.ts'" }, "dependencies": { diff --git a/modules/gitea/provisioner/index.ts b/modules/gitea/provisioner/index.ts deleted file mode 100644 index 9efc083b..00000000 --- a/modules/gitea/provisioner/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -// gitea's provisioner — the adapter that makes gitea a provider of the mesh -// `npm-package-registry` interface. The reconcile loop, the contributions file, and reading the -// mesh's minted password are the sdk harness's; this writes only the per-service half: how gitea -// creates and removes a consumer's npm credential (novox/hq ADR 0048/0076). -// -// **A package registry seat is one per ecosystem (novox/hq ADR 0109).** gitea holds the npm seat -// (ADR 0110). Adding cargo or PyPI is adding a provision — another `provides` entry, another -// `receives` path and another registration below — not widening this one. `git`, which gitea also -// provides, mints nothing and so registers nothing here: the mesh's own repositories are public, -// and a clone credential is not yet decided (ADR 0111). -// -// The `npm-package-registry` interface: a consumer authenticates to the npm registry at -// `/api/packages/novox/npm/` with basic auth, as `as` with the password the mesh minted, and can -// read packages under the `@novox` scope, and write them unless it asked for read only (teams.ts). The -// registry's npm owner is the gitea org `novox`; a consumer is a gitea *user* on one of that org's teams. -// -// **The user name and password are the mesh's, not the provisioner's (ADR 0048).** The mesh derives -// the login and hands it to both ends, and mints the password. gitea creates a user under exactly -// that login and sets exactly that password every run — so a rotation takes — and seals nothing: the -// consumer already has its copy through the mesh's own channel. -// -// The admin calls run through GiteaAdmin (basic auth as the mesh's gitea admin), which is the -// module's one boundary to the forge's admin API (see client.ts). - -import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; -import { GiteaAdmin } from "../client.js"; -import { grant, holds } from "./teams.js"; - -// Which team a consumer joins — `packages` to publish, `readers` to read only — is teams.ts's. - -const gitea = GiteaAdmin.fromEnv(); - -// Where this registration's contributions land comes from $MESH_RECEIVES, never a path written -// here: the mesh writes the file where the manifest's `receives` says, and a second copy of that -// path in code would drift from it. One variable carries one path, so a second registration in this -// module needs the mesh to say where each provision's file is — not yet possible, and not faked. -runProvisioner("npm-package-registry", { - async create(p: Provision): Promise { - // The org and its teams are ensured per create: idempotent, and no separate bootstrap step. The - // user carries the consumer's login and the mesh's minted password, set every run so a rotation - // takes; membership of its team is what grants it what it may do. - await grant(gitea, p); - }, - - async remove(p: { as: string }): Promise { - // Login prohibited, never deleted (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once): deleting purges every repository the user owns. - await gitea.prohibitLogin(p.as); - }, - // Asked every minute by the harness: whether the backend still holds this consumer exactly as - // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). - async holds(p: Provision): Promise { - return holds(gitea, p); - }, -}); diff --git a/modules/gitea/provisioner/teams.ts b/modules/gitea/provisioner/teams.ts deleted file mode 100644 index 61100be8..00000000 --- a/modules/gitea/provisioner/teams.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Which team of the forge's npm owner a consumer of `npm-package-registry` joins, and the per-consumer -// half of the provisioner around it — pure of the forge's address, so it is tested with a stand-in. -// -// **Two teams, by what the consumer said it needs** (novox/hq issue 624). A consumer that publishes — -// the build modules, whose builds publish the SDK and every TypeScript module's packages — joins `packages`: -// code read on the org's repositories and write on its packages. A consumer that contributes -// `{"access": "read"}` joins `readers`: code read and package read, and no write. The lab is the first: it -// clones branches that are not yet reviewed and runs their code beside its credential, and code that is -// not reviewed must not be able to publish the mesh's packages with it. - -/** The npm registry's owner: the gitea org every consumer's packages live under (ADR 0076). */ -export const ORG = "novox"; -/** The team whose members read and write the org's packages. */ -export const PACKAGE_TEAM = "packages"; -/** The team whose members read the org's code and packages, and write nothing. */ -export const READ_TEAM = "readers"; - -/** The team a consumer joins, from what it contributed; an access it may not ask for is refused. */ -export function teamFor(values: Readonly> | undefined): { team: string; packageWrite: boolean } { - const access = values?.access; - if (access === undefined || access === "write") return { team: PACKAGE_TEAM, packageWrite: true }; - if (access === "read") return { team: READ_TEAM, packageWrite: false }; - throw new Error(`npm-package-registry: access ${JSON.stringify(access)} is not "read" or "write"`); -} - -/** What the adapter asks of the forge's admin API (client.ts GiteaAdmin). */ -export interface Forge { - ensureOrg(org: string): Promise; - ensureTeam(org: string, team: string, packageWrite: boolean): Promise; - ensureUser(username: string, password: string, email: string): Promise; - addUserToTeam(teamId: number, username: string): Promise; - removeUserFromTeam(teamId: number, username: string): Promise; - holdsTeamMember(org: string, team: string, username: string, password: string): Promise; -} - -export interface Consumer { - readonly as: string; - readonly password: string; - readonly values: Readonly>; -} - -/** - * grant makes the consumer's user, with exactly the mesh's password, a member of its one team. A consumer - * that asks for read only is also taken out of the package team, so one that asked for write before and - * read now loses the write at once. - */ -export async function grant(forge: Forge, p: Consumer): Promise { - const { team, packageWrite } = teamFor(p.values); - await forge.ensureOrg(ORG); - const teamId = await forge.ensureTeam(ORG, team, packageWrite); - // The address is gitea's own convention for one that is not real: its email validation requires a - // dotted domain, so `@localhost` was refused at create, while `@noreply.localhost` is the shape gitea - // itself gives hidden addresses. - await forge.ensureUser(p.as, p.password, `${p.as}@noreply.localhost`); - await forge.addUserToTeam(teamId, p.as); - if (!packageWrite) { - await forge.removeUserFromTeam(await forge.ensureTeam(ORG, PACKAGE_TEAM, true), p.as); - } -} - -/** holds is whether the forge still holds the consumer as the mesh gave it: its login, in its team. */ -export async function holds(forge: Forge, p: Consumer): Promise { - return forge.holdsTeamMember(ORG, teamFor(p.values).team, p.as, p.password); -} diff --git a/modules/gitea/test/teams.test.ts b/modules/gitea/test/teams.test.ts deleted file mode 100644 index 11def05e..00000000 --- a/modules/gitea/test/teams.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; - -import { grant, holds, PACKAGE_TEAM, READ_TEAM, teamFor, type Forge } from "../provisioner/teams.ts"; - -// Defends novox/hq issue 624: the lab reads the forge with a credential of its own that can write no -// package. A consumer that contributes {"access": "read"} joins a team with code and package read only. - -function aForge(): { forge: Forge; did: string[] } { - const did: string[] = []; - const teams: Record = {}; - const forge: Forge = { - async ensureOrg(org) { did.push(`org ${org}`); }, - async ensureTeam(org, team, packageWrite) { - did.push(`team ${org}/${team} packages:${packageWrite ? "write" : "read"}`); - return (teams[team] ??= Object.keys(teams).length + 1); - }, - async ensureUser(username) { did.push(`user ${username}`); }, - async addUserToTeam(id, username) { did.push(`add ${username} to ${id}`); }, - async removeUserFromTeam(id, username) { did.push(`remove ${username} from ${id}`); }, - async holdsTeamMember(org, team, username) { did.push(`holds ${username} in ${org}/${team}`); return true; }, - }; - return { forge, did }; -} - -test("a consumer that asks nothing, or write, publishes: the package team", () => { - assert.deepEqual(teamFor({}), { team: PACKAGE_TEAM, packageWrite: true }); - assert.deepEqual(teamFor({ access: "write" }), { team: PACKAGE_TEAM, packageWrite: true }); -}); - -test("a consumer that asks to read joins the readers, who write no package", () => { - assert.deepEqual(teamFor({ access: "read" }), { team: READ_TEAM, packageWrite: false }); - assert.throws(() => teamFor({ access: "admin" }), /not "read" or "write"/); -}); - -test("a reader is made, put on the readers team, and taken off the package team", async () => { - const { forge, did } = aForge(); - await grant(forge, { as: "lab-shanks", password: "pw", values: { access: "read" } }); - assert.deepEqual(did, [ - "org novox", - "team novox/readers packages:read", - "user lab-shanks", - "add lab-shanks to 1", - "team novox/packages packages:write", - "remove lab-shanks from 2", - ]); - did.length = 0; - await holds(forge, { as: "lab-shanks", password: "pw", values: { access: "read" } }); - assert.deepEqual(did, ["holds lab-shanks in novox/readers"]); -}); - -test("a publisher is made and put on the package team, as before", async () => { - const { forge, did } = aForge(); - await grant(forge, { as: "build-agent-ace", password: "pw", values: {} }); - assert.deepEqual(did, ["org novox", "team novox/packages packages:write", "user build-agent-ace", "add build-agent-ace to 1"]); -}); diff --git a/modules/gitea/tsconfig.json b/modules/gitea/tsconfig.json index 7b5440b7..5e851876 100644 --- a/modules/gitea/tsconfig.json +++ b/modules/gitea/tsconfig.json @@ -8,5 +8,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["client.ts", "token.ts", "pulls.ts", "protection.ts", "index.ts", "provisioner/index.ts", "provisioner/teams.ts", "tools/index.ts"] + "include": ["client.ts", "token.ts", "pulls.ts", "protection.ts", "index.ts", "tools/index.ts"] }