A provisioner is told what the mesh derived for each consumer (hq ADR 0201)

Provision gains `derived`; the harness keeps it per applied login so remove()
is told it too, and includes it in the change hash — a provider that renames
what it derives has given the consumer a different resource.

Version 0.1.7. Rebased onto main after the bundles refactor; the record this
cites was written as 0188 and renumbered to 0201, because ADR 0188 on main is
now the bundles record.
This commit is contained in:
2026-10-04 02:44:50 +02:00
parent 0b018562e3
commit f1485257a8
4 changed files with 90 additions and 13 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@novox/mesh-sdk",
"version": "0.1.1",
"version": "0.1.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@novox/mesh-sdk",
"version": "0.1.1",
"version": "0.1.7",
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@novox/mesh-sdk",
"version": "0.1.6",
"version": "0.1.7",
"description": "The stable spine a Novox Mesh module's own code builds against.",
"type": "module",
"exports": {
+38 -10
View File
@@ -23,6 +23,16 @@ export interface Provision {
readonly password: string;
/** What the consumer contributed, per the interface's spec keys (e.g. `{ name: "umami" }`). */
readonly values: Readonly<Record<string, unknown>>;
/** What this provider's own definition said it derives for this consumer, already derived by
* the mesh (novox/hq ADR 0201).
*
* **The rule is declared, not computed here.** A provider that names the resource it gives each
* consumer — a bucket, a database, a vhost — writes that name in its `serves` block with
* `${consumer:as}` or `${consumer:as:dns}` in it. The mesh fills it for this consumer and hands
* the same filled value to the consumer's own configuration, so the two ends cannot disagree.
* An adapter reads it from here; an adapter that recomputed it would be the second rule this
* removed. Empty for a provider that derives nothing. */
readonly derived: Readonly<Record<string, unknown>>;
/** Where the consumer is, for a provider that must reach back to it. Usually unused. */
readonly at?: string;
/** The consumer node, for logging and lifecycle events. */
@@ -34,7 +44,12 @@ export interface Provision {
* the credential is the mesh's, already delivered to the consumer. */
export interface Adapter {
create(p: Provision): Promise<void>;
remove(p: { readonly as: string }): Promise<void>;
/** Withdraw what `create` made for this consumer. `derived` is what the mesh last derived for
* it, remembered by the harness: the mesh has already dropped the consumer from the
* contributions file by the time this is called, so it cannot be read again, and a provider
* that recomputed it would be keeping the second copy of a rule this removed
* (novox/hq ADR 0201). */
remove(p: { readonly as: string; readonly derived: Readonly<Record<string, unknown>> }): Promise<void>;
/** Optional: whether the backend still holds this consumer's credential exactly as `p` says.
* Asked of every consumer already applied, every `verifyEveryMs`. `false` makes the harness apply
* it again on the same pass, so a backend that lost what it was given (a server restarted
@@ -69,6 +84,7 @@ interface Contribution {
readonly node?: string;
readonly at?: string;
readonly values?: Readonly<Record<string, unknown>>;
readonly derived?: Readonly<Record<string, unknown>>;
}
/**
@@ -88,7 +104,9 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
// hour rather than one a minute, and it is said loudly rather than quietly repeated.
const lost = new Map<string, { times: number; nextAt: number }>();
const applied = new Map<string, string>(); // login (`as`) -> hash of what was last applied
// login (`as`) -> what was last applied: the hash that says whether it changed, and the
// derivation to hand back on withdrawal, which by then is no longer in the file.
const applied = new Map<string, { hash: string; derived: Readonly<Record<string, unknown>> }>();
let stopped = false;
async function reconcile(): Promise<void> {
@@ -112,12 +130,14 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
console.error(`[provisioner:${resource}] ${g.as}: secret not readable yet (${g.secret}): ${err}`);
continue;
}
const h = hash(g.as, password, g.values ?? {});
const p: Provision = { as: g.as, password, values: g.values ?? {}, at: g.at, consumer: g.node };
const h = hash(g.as, password, g.values ?? {}, g.derived ?? {});
const p: Provision = {
as: g.as, password, values: g.values ?? {}, derived: g.derived ?? {}, at: g.at, consumer: g.node,
};
// Set when the backend reported this consumer lost: how many successful re-applies in a row
// it has now needed, counting this one.
let reapplying: number | undefined;
if (applied.get(g.as) === h) {
if (applied.get(g.as)?.hash === h) {
if (!verifying) continue;
const brake = lost.get(g.as);
if (brake && Date.now() < brake.nextAt) continue;
@@ -148,7 +168,7 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
}
try {
await adapter.create(p);
applied.set(g.as, h);
applied.set(g.as, { hash: h, derived: p.derived });
if (reapplying === undefined) {
// Applied for a new login, password or values: whatever was counted before does not carry over.
lost.delete(g.as);
@@ -171,10 +191,10 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi
// Withdraw every login the provider made that the mesh no longer asks for. The mesh drops a
// consumer from the file when it goes away; that is how a login is withdrawn rather than kept
// working for ever. Only logins this harness created are touched.
for (const as of [...applied.keys()]) {
for (const [as, was] of [...applied.entries()]) {
if (wantByAs.has(as)) continue;
try {
await adapter.remove({ as });
await adapter.remove({ as, derived: was.derived });
applied.delete(as);
lost.delete(as);
} catch (err) {
@@ -247,8 +267,16 @@ function scrub(err: unknown, password: string): string {
return text;
}
function hash(as: string, password: string, values: Readonly<Record<string, unknown>>): string {
return JSON.stringify([as, password, values]);
function hash(
as: string,
password: string,
values: Readonly<Record<string, unknown>>,
derived: Readonly<Record<string, unknown>>,
): string {
// The derivation is in the hash because it can change without the login or the password
// changing: a provider that renames what it derives has given this consumer a different
// resource, and an adapter trusted from memory would never be told (novox/hq ADR 0201).
return JSON.stringify([as, password, values, derived]);
}
function envOrThrow(name: string): string {
+49
View File
@@ -74,6 +74,55 @@ test("provisioner creates each consumer with the mesh's login and password, remo
stop();
});
test("provisioner hands the adapter what the mesh derived, and still has it at withdrawal", async () => {
// novox/hq ADR 0201: the provider declares what it derives for each consumer and the mesh fills
// it. The adapter must be able to read it on create, must see a changed derivation as a change
// worth re-applying, and must still be told it when the consumer is withdrawn — by which time
// the mesh has already dropped it from the file.
const dir = await mkdtemp(join(tmpdir(), "prov-derived-"));
const created: unknown[] = [];
const removed: unknown[] = [];
await writeFile(join(dir, "ncloud.secret"), "minted-pw\n");
const receives = join(dir, "store.json");
const doc = (given: unknown[]): string =>
JSON.stringify({ contributions: 1, requirement: "s3-bucket", given });
const entry = (bucket: string) => ({
from: "nextcloud", node: "anchor", as: "mesh_anchor_ncloud",
secret: join(dir, "ncloud.secret"), values: {}, derived: { bucket },
});
await writeFile(receives, doc([entry("mesh-anchor-ncloud")]));
const stop = runProvisioner(
"s3-bucket",
{
async create(p) {
created.push(p.derived);
},
async remove(p) {
removed.push(p.derived);
},
},
{ receives, everyMs: 20 },
);
await waitFor(() => created.length === 1, 2000);
assert.deepEqual(created[0], { bucket: "mesh-anchor-ncloud" });
// A changed derivation is a changed consumer: the login and the password are the same, and the
// resource it is being given is not. Trusted from memory, this would never be applied.
await writeFile(receives, doc([entry("mesh-anchor-files")]));
await waitFor(() => created.length === 2, 2000);
assert.deepEqual(created[1], { bucket: "mesh-anchor-files" });
// Withdrawn: the file no longer says anything about this consumer, and the adapter is still
// told which bucket to take away.
await writeFile(receives, doc([]));
await waitFor(() => removed.length === 1, 2000);
assert.deepEqual(removed[0], { bucket: "mesh-anchor-files" });
stop();
});
test("provisioner applies again what the backend no longer holds, and trusts memory without holds", async () => {
const dir = await mkdtemp(join(tmpdir(), "prov-holds-"));
await writeFile(join(dir, "webapp.secret"), "minted-pw\n");