Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1faee6e279 | ||
|
|
6f7d06726c |
@@ -4,6 +4,13 @@ The forge, and the mesh's npm package registry: it claims the `git` and `npm-pac
|
||||
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.
|
||||
|
||||
**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
|
||||
tarball at: `https://` and the forge's route's composed public name, under the npm path
|
||||
(`${bound:route:name-web}`, novox/hq ADR 0346). A lock file holds that address, and npm 12 installs a
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -68,7 +68,8 @@
|
||||
"npm-package-registry": {
|
||||
"scheme": "http",
|
||||
"port": 3000,
|
||||
"npm-path": "/api/packages/novox/npm/"
|
||||
"npm-path": "/api/packages/novox/npm/",
|
||||
"public-root": "https://${bound:route:name-web}/api/packages/novox/npm/"
|
||||
},
|
||||
"git": {
|
||||
"scheme": "http",
|
||||
|
||||
@@ -275,7 +275,7 @@ const packument = (tarball: string) => new Response(JSON.stringify({
|
||||
|
||||
test("a run names the package registry by the address the registry gives its tarballs", async () => {
|
||||
const r = aRegistry(() => packument("https://forge.example.org/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz"));
|
||||
const got = await npmRegistry("http://forge.example.org:3000/", r.get);
|
||||
const got = await npmRegistry("http://forge.example.org:3000/", r.get, "https://forge.example.org/api/packages/novox/npm/");
|
||||
assert.deepEqual(got, { registry: "https://forge.example.org/api/packages/novox/npm/" });
|
||||
assert.deepEqual(r.asked, ["http://forge.example.org:3000/api/packages/novox/npm/@novox%2fmesh-sdk"]);
|
||||
// The forge's own scheme is taken too, with its port.
|
||||
@@ -285,8 +285,8 @@ test("a run names the package registry by the address the registry gives its tar
|
||||
});
|
||||
|
||||
// What the registry answers may come over plain HTTP, so it never decides where packages come from: only
|
||||
// the forge's own host, at https or the forge's scheme, under the npm path, with nothing after it
|
||||
// (review of mesh-catalog #275).
|
||||
// the forge's public root or its own address, scheme, host and port, under the npm path, with nothing after
|
||||
// it (review of mesh-catalog #275, ADR 0346).
|
||||
test("a registry that does not give an address the lab takes leaves the forge's, and the reason is said", async () => {
|
||||
const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
|
||||
for (const [answer, why] of [
|
||||
@@ -294,13 +294,13 @@ test("a registry that does not give an address the lab takes leaves the forge's,
|
||||
[() => new Response("{}", { status: 200 }), /no tarball/],
|
||||
[() => { throw new Error("connect ECONNREFUSED"); }, /ECONNREFUSED/],
|
||||
[() => packument(`https://forge.example.org/elsewhere/mesh-sdk-0.1.14.tgz`), /not under \/api\/packages\/novox\/npm\//],
|
||||
[() => packument(`https://attacker.example.net${sdk}`), /attacker\.example\.net, which is not the forge's host novox\.internal/],
|
||||
[() => packument(`https://attacker.example.net${sdk}`), /at https:\/\/attacker\.example\.net, which is neither the forge's public root nor its own address/],
|
||||
[() => packument(`https://attacker.example.net/x?u=https://novox.internal${sdk}`), /query or a fragment/],
|
||||
[() => packument(`https://novox.internal${sdk}?x=1`), /query or a fragment/],
|
||||
[() => packument(`https://novox.internal${sdk}#frag`), /query or a fragment/],
|
||||
[() => packument(`https://attacker.example.net/#https://novox.internal${sdk}`), /query or a fragment/],
|
||||
[() => packument(`ftp://novox.internal${sdk}`), /ftp: — neither https: nor the forge's http:/],
|
||||
[() => packument(`file:///api/packages/novox/npm/x.tgz`), /file: — neither/],
|
||||
[() => packument(`ftp://novox.internal${sdk}`), /at ftp:\/\/novox\.internal, which is neither/],
|
||||
[() => packument(`file:///api/packages/novox/npm/x.tgz`), /which is neither/],
|
||||
[() => packument(`https://novox.internal/api/packages/novox/npm/\nregistry=https://attacker.example.net/\nallow-remote=all`), /whitespace or a control character/],
|
||||
[() => packument(`https://user:pw@novox.internal${sdk}`), /user or password/],
|
||||
] as [() => Response, RegExp][]) {
|
||||
@@ -319,3 +319,65 @@ test("a run's npm configuration names the registry it was given, and the forge's
|
||||
const none = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {});
|
||||
assert.match(none, /'@novox:registry=http:\/\/novox\.internal:20000\/api\/packages\/novox\/npm\/'/);
|
||||
});
|
||||
|
||||
// The public root the forge serves in the lab's binding (novox/hq ADR 0346) is the one other origin a
|
||||
// tarball may come from, by scheme, host and port: the review of #275 found the port ignored.
|
||||
import { publicRootOf } from "../tools/runs.ts";
|
||||
|
||||
test("a tarball is taken at the forge's public root, and at no other port or scheme of its host", async () => {
|
||||
const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
|
||||
const root = "https://git.example.org/api/packages/novox/npm/";
|
||||
const at = (tarball: string, publicRoot?: string) =>
|
||||
npmRegistry("http://novox.internal:20000", aRegistry(() => packument(tarball)).get, publicRoot);
|
||||
assert.deepEqual(await at(`https://git.example.org${sdk}`, root), { registry: root });
|
||||
for (const tarball of [`https://git.example.org:8443${sdk}`, `http://git.example.org${sdk}`, `https://example.org${sdk}`]) {
|
||||
const got = await at(tarball, root);
|
||||
assert.equal(got.registry, "http://novox.internal:20000/api/packages/novox/npm/", tarball);
|
||||
assert.match(got.problem ?? "", /neither the forge's public root nor its own address/, tarball);
|
||||
}
|
||||
// Without a public root, the forge's public name is not trusted on its own word.
|
||||
assert.match((await at(`https://git.example.org${sdk}`)).problem ?? "", /neither/);
|
||||
// And the forge's own address only at its own port.
|
||||
assert.match((await at(`http://novox.internal${sdk}`, root)).problem ?? "", /neither/);
|
||||
});
|
||||
|
||||
test("the public root is read from the binding, and only an https root at the npm path is taken", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "lab-root-"));
|
||||
const binding = (root: unknown) => {
|
||||
const f = join(dir, `b-${Math.random()}.json`);
|
||||
writeFileSync(f, JSON.stringify({ provision: "npm-package-registry", at: "home", serves: { scheme: "http", port: 3000,
|
||||
"npm-path": "/api/packages/novox/npm/", ...(root === undefined ? {} : { "public-root": root }) } }));
|
||||
return f;
|
||||
};
|
||||
assert.deepEqual(publicRootOf(binding("https://git.example.org/api/packages/novox/npm/")),
|
||||
{ root: "https://git.example.org/api/packages/novox/npm/" });
|
||||
assert.deepEqual(publicRootOf(binding("https://git.example.org:8443/api/packages/novox/npm/")),
|
||||
{ root: "https://git.example.org:8443/api/packages/novox/npm/" });
|
||||
for (const [root, why] of [
|
||||
[undefined, /serves no public-root/],
|
||||
["http://git.example.org/api/packages/novox/npm/", /is not https:\/\/<host>/],
|
||||
["https://git.example.org/elsewhere/", /is not https:\/\/<host>/],
|
||||
["https://git.example.org/api/packages/novox/npm/?x=1", /is not https:\/\/<host>/],
|
||||
["https://u:p@git.example.org/api/packages/novox/npm/", /is not https:\/\/<host>/],
|
||||
["https://git.example.org/api/packages/novox/npm/\nallow-remote=all", /whitespace or a control character/],
|
||||
["https://${bound:route:name-web}/api/packages/novox/npm/", /not a URL|is not https/],
|
||||
] as [unknown, RegExp][]) {
|
||||
const got = publicRootOf(binding(root));
|
||||
assert.equal(got.root, undefined, String(root));
|
||||
assert.match(got.problem ?? "", why, String(root));
|
||||
}
|
||||
assert.match(publicRootOf(join(dir, "none.json")).problem ?? "", /could not be read/);
|
||||
assert.match(publicRootOf(undefined).problem ?? "", /no npm-package-registry binding/);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// A forge setting that is not http(s) is never a trusted origin (review of #280): a `file:` tarball's origin
|
||||
// is "null", as is a `file:` forge's, and the two must not match.
|
||||
test("a forge setting that is not http(s) trusts no origin of its own", async () => {
|
||||
const got = await npmRegistry("file:///srv/forge", aRegistry(() => packument("file:///api/packages/novox/npm/x.tgz")).get);
|
||||
assert.match(got.problem ?? "", /neither the forge's public root nor its own address/);
|
||||
const root = "https://git.example.org/api/packages/novox/npm/";
|
||||
const viaRoot = await npmRegistry("ftp://novox.internal", aRegistry(() =>
|
||||
packument("ftp://novox.internal/api/packages/novox/npm/x.tgz")).get, root);
|
||||
assert.match(viaRoot.problem ?? "", /neither/);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
checkDir, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, readStatus, REPOSITORIES, running, start,
|
||||
checkDir, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, publicRootOf, readStatus, REPOSITORIES, running, start,
|
||||
stop, tail, writeCredential,
|
||||
} from "./runs.js";
|
||||
|
||||
@@ -19,6 +19,14 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
// without restarting the runtime. MESH_LAB_FORGE itself still wins, for a hand-run instance.
|
||||
const forgeOf = (): string => (env.MESH_LAB_FORGE ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE")).replace(/\/+$/, "");
|
||||
// The forge credential the mesh binds and seals to the lab (novox/hq issue 624), read at each call.
|
||||
// The registry's public root the forge serves in the lab's binding (novox/hq ADR 0346), read at each call.
|
||||
const bindingOf = (): string | undefined =>
|
||||
env.MESH_LAB_FORGE_BINDING ?? (wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING") || undefined);
|
||||
const registryOf = async (forge: string) => {
|
||||
const root = publicRootOf(bindingOf());
|
||||
const registry = await npmRegistry(forge, fetch, root.root);
|
||||
return registry.problem && root.problem ? { ...registry, problem: `${registry.problem}; ${root.problem}` } : registry;
|
||||
};
|
||||
const credentialOf = (forge: string): string | undefined => forgeCredential(forge,
|
||||
env.MESH_LAB_FORGE_BINDING ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING"),
|
||||
env.MESH_LAB_FORGE_SECRET ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_SECRET"));
|
||||
@@ -43,7 +51,7 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
|
||||
problems = setupProblems(forge, `${dir}/setup`, credentialOf(forge));
|
||||
// And the address a run names the package registry by (npmRegistry): said when the registry does not say it.
|
||||
const registry = await npmRegistry(forge);
|
||||
const registry = await registryOf(forge);
|
||||
if (registry.problem) problems.push(registry.problem);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
@@ -90,7 +98,7 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
if (stranger.length > 0) return { started: false, reason: `the lab does not build ${stranger.join(", ")}` };
|
||||
const busy = running(work);
|
||||
if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}; one run at a time`, running: busy };
|
||||
const registry = await npmRegistry(forge);
|
||||
const registry = await registryOf(forge);
|
||||
return { started: true, run: start(work, forge, tests, refs, credentialOf(forge), registry.registry),
|
||||
registry: registry.registry, ...(registry.problem ? { problem: registry.problem } : {}) };
|
||||
},
|
||||
|
||||
+49
-11
@@ -63,9 +63,38 @@ fi`;
|
||||
/** Where the forge serves the mesh's npm packages, under its own address (gitea's npm registry for the novox org). */
|
||||
export const NPM_PATH = "/api/packages/novox/npm/";
|
||||
|
||||
/**
|
||||
* The registry's public root, as the forge serves it in the lab's npm-package-registry binding
|
||||
* (`serves.public-root`, novox/hq ADR 0346): the forge's route's composed public name under the npm path.
|
||||
* The mesh writes the binding, and it is checked all the same: an https URL whose path is the npm path and
|
||||
* nothing else. Undefined, with why, when the binding has none or it is not that shape — a forge on a
|
||||
* machine with no public domain serves none (ADR 0346 §3).
|
||||
*/
|
||||
export function publicRootOf(bindingFile: string | undefined): { root?: string; problem?: string } {
|
||||
if (!bindingFile) return { problem: "the lab has no npm-package-registry binding" };
|
||||
let said: unknown;
|
||||
try {
|
||||
said = (JSON.parse(readFileSync(bindingFile, "utf8")) as { serves?: Record<string, unknown> }).serves?.["public-root"];
|
||||
} catch (e) {
|
||||
return { problem: `its npm-package-registry binding could not be read: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
if (typeof said !== "string" || !said) return { problem: "its npm-package-registry binding serves no public-root" };
|
||||
if (/[\s\u0000-\u001f\u007f]/.test(said)) return { problem: "the binding's public-root holds whitespace or a control character" };
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(said);
|
||||
} catch {
|
||||
return { problem: `the binding's public-root ${JSON.stringify(said)} is not a URL` };
|
||||
}
|
||||
if (u.protocol !== "https:" || u.username || u.password || u.search || u.hash || u.pathname !== NPM_PATH) {
|
||||
return { problem: `the binding's public-root ${JSON.stringify(said)} is not https://<host>${NPM_PATH}` };
|
||||
}
|
||||
return { root: `${u.origin}${NPM_PATH}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* The address a run names the @novox registry by: the one the registry names itself by, when that is the
|
||||
* forge's own host (novox/hq issue 624).
|
||||
* forge's public root or the forge's own address (novox/hq issue 624, ADR 0346).
|
||||
*
|
||||
* **npm 12 installs a locked tarball only from the registry it is configured with.** Its `allow-remote`
|
||||
* defaults to `none`, and a lock file's `resolved` URL counts as the registry's only when its origin and
|
||||
@@ -75,18 +104,16 @@ export const NPM_PATH = "/api/packages/novox/npm/";
|
||||
* for the SDK's newest tarball and names the registry by the origin of that URL.
|
||||
*
|
||||
* **What the registry answers is not trusted for where packages come from** (review of mesh-catalog #275):
|
||||
* it may be fetched over plain HTTP. The tarball URL is parsed, and taken only when it is https (or the
|
||||
* forge's own scheme), its path is under the npm path, it carries no query or fragment, and its host is
|
||||
* the forge's own host. The registry is then built from
|
||||
* it may be fetched over plain HTTP. The tarball URL is parsed, and taken only when it carries no
|
||||
* whitespace, control character, user, password, query or fragment, its path is under the npm path, and
|
||||
* its origin — scheme, host and port — is one the lab already trusts: the public root the forge serves in
|
||||
* the lab's binding (`publicRoot`, ADR 0346), or the forge setting's own. The registry is then built from
|
||||
* the parsed origin and the npm path alone, never from the string answered, so nothing the registry says
|
||||
* reaches the run's npm configuration but a scheme, a host and a port. Anything else falls back to the
|
||||
* forge's own address and says why, so a run still starts and lab_check names it.
|
||||
*
|
||||
* So the lab's forge setting is the address the forge names itself by (its public https name), not a
|
||||
* second name for it: a forge set as another name of the same server is refused here, named by lab_check,
|
||||
* and its runs stop at the first locked @novox package.
|
||||
*/
|
||||
export async function npmRegistry(forge: string, get: typeof fetch = fetch): Promise<{ registry: string; problem?: string }> {
|
||||
export async function npmRegistry(forge: string, get: typeof fetch = fetch, publicRoot?: string):
|
||||
Promise<{ registry: string; problem?: string }> {
|
||||
const asked = `${forge.replace(/\/+$/, "")}${NPM_PATH}`;
|
||||
const url = `${asked}@novox%2fmesh-sdk`;
|
||||
const fallback = (why: string) => ({
|
||||
@@ -99,6 +126,16 @@ export async function npmRegistry(forge: string, get: typeof fetch = fetch): Pro
|
||||
} catch {
|
||||
return fallback(`the forge ${JSON.stringify(forge)} is not a URL`);
|
||||
}
|
||||
// The forge setting's own origin is trusted only as an http(s) one: any other scheme's origin is
|
||||
// "null", or names no registry npm would fetch from.
|
||||
const trusted = own.protocol === "https:" || own.protocol === "http:" ? [own.origin] : [];
|
||||
if (publicRoot) {
|
||||
try {
|
||||
trusted.unshift(new URL(publicRoot).origin);
|
||||
} catch {
|
||||
// publicRootOf hands only a parsed root; a caller's other string is not trusted.
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await get(url, { signal: AbortSignal.timeout(15_000) });
|
||||
if (!res.ok) return fallback(`it answered ${res.status}`);
|
||||
@@ -113,11 +150,12 @@ export async function npmRegistry(forge: string, get: typeof fetch = fetch): Pro
|
||||
} catch {
|
||||
return fallback("its tarball is not a URL");
|
||||
}
|
||||
if (t.protocol !== "https:" && t.protocol !== own.protocol) return fallback(`its tarball is ${t.protocol} — neither https: nor the forge's ${own.protocol}`);
|
||||
if (t.username || t.password) return fallback("its tarball URL carries a user or password");
|
||||
if (t.search || t.hash) return fallback("its tarball URL carries a query or a fragment");
|
||||
if (!t.pathname.startsWith(NPM_PATH)) return fallback(`its tarball is not under ${NPM_PATH}`);
|
||||
if (t.hostname !== own.hostname) return fallback(`its tarball is at ${t.hostname}, which is not the forge's host ${own.hostname}`);
|
||||
if (!trusted.includes(t.origin)) {
|
||||
return fallback(`its tarball is at ${t.origin}, which is neither the forge's public root nor its own address (${trusted.join(", ")})`);
|
||||
}
|
||||
return { registry: `${t.origin}${NPM_PATH}` };
|
||||
} catch (e) {
|
||||
return fallback(e instanceof Error ? e.message : String(e));
|
||||
|
||||
Reference in New Issue
Block a user