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:
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user