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
This commit is contained in:
2026-11-04 14:01:32 +01:00
parent 4fa457b9bb
commit 63322ef06f
7 changed files with 14 additions and 210 deletions
+7 -3
View File
@@ -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).
+5 -31
View File
@@ -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"
+1 -1
View File
@@ -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": {
-54
View File
@@ -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<void> {
// 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<void> {
// 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<boolean> {
return holds(gitea, p);
},
});
-64
View File
@@ -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<Record<string, unknown>> | 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<void>;
ensureTeam(org: string, team: string, packageWrite: boolean): Promise<number>;
ensureUser(username: string, password: string, email: string): Promise<void>;
addUserToTeam(teamId: number, username: string): Promise<void>;
removeUserFromTeam(teamId: number, username: string): Promise<void>;
holdsTeamMember(org: string, team: string, username: string, password: string): Promise<boolean>;
}
export interface Consumer {
readonly as: string;
readonly password: string;
readonly values: Readonly<Record<string, unknown>>;
}
/**
* 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<void> {
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<boolean> {
return forge.holdsTeamMember(ORG, teamFor(p.values).team, p.as, p.password);
}
-56
View File
@@ -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<string, number> = {};
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"]);
});
+1 -1
View File
@@ -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"]
}