Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d59b35cf7 | ||
|
|
b485379505 | ||
|
|
306d01d74d | ||
|
|
19a4055bb5 | ||
|
|
1bc6daf31b | ||
|
|
15b2e6b86e | ||
|
|
9208f7409a | ||
|
|
a80af7a97f | ||
|
|
83a51832d7 | ||
|
|
9e63a258d0 | ||
|
|
328d90fb88 | ||
|
|
ca5ab288f6 | ||
|
|
5fd0f72221 | ||
|
|
5003dc0377 | ||
|
|
0a78d130e5 | ||
|
|
4295aad88e | ||
|
|
9dfd3b1105 | ||
|
|
22e8714040 | ||
|
|
11e7ede8a4 | ||
|
|
27315d35cf | ||
|
|
525c639041 | ||
|
|
42c80fa9e1 | ||
|
|
56e0830700 | ||
|
|
0844b35ebb | ||
|
|
566739e02c | ||
|
|
38b56a7877 | ||
|
|
0596503db5 | ||
|
|
3668b02b94 | ||
|
|
c0159ca0a1 | ||
|
|
159ed53103 | ||
|
|
723e676b75 | ||
|
|
661114370f | ||
|
|
a1d7b9ad5a | ||
|
|
5548b0f4e9 | ||
|
|
295cc59e1e | ||
|
|
6a7e4ebd5e | ||
|
|
9e8146192c | ||
|
|
6171d747db | ||
|
|
84609c0373 | ||
|
|
28d5e7f939 | ||
|
|
4128380a3d | ||
|
|
cf57d3fd8f | ||
|
|
da8a46cfe8 | ||
|
|
ed50130a6a | ||
|
|
bd2123166f | ||
|
|
d9db931bd0 | ||
|
|
69f2efb591 | ||
|
|
568674fef7 | ||
|
|
3c6b70845c | ||
|
|
35ef72081f | ||
|
|
59c42b2086 | ||
|
|
3b8164f1c0 | ||
|
|
8877f893e5 | ||
|
|
043ae17fbf | ||
|
|
944f086ec7 | ||
|
|
dd93cfd613 | ||
|
|
64cc292d7e | ||
|
|
7e889adf71 | ||
|
|
7e9ef899c1 | ||
|
|
03e729d103 | ||
|
|
eab335b755 | ||
|
|
f42b58f789 | ||
|
|
5737752744 |
@@ -1,22 +0,0 @@
|
|||||||
# anthropic-consumer's runtime: the tool runtime, carrying this module's compiled code.
|
|
||||||
#
|
|
||||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
|
||||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
|
||||||
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
|
|
||||||
# happens to have the siblings.
|
|
||||||
#
|
|
||||||
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
|
|
||||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
|
||||||
ARG BUILD_BASE
|
|
||||||
ARG RUNTIME_BASE
|
|
||||||
|
|
||||||
FROM ${BUILD_BASE} AS build
|
|
||||||
WORKDIR /app/modules/anthropic-consumer
|
|
||||||
COPY . .
|
|
||||||
RUN node /app/node_modules/typescript/bin/tsc apply/index.ts usage/index.ts \
|
|
||||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
|
||||||
|
|
||||||
FROM ${RUNTIME_BASE}
|
|
||||||
COPY --from=build /app/modules/anthropic-consumer/dist /app/modules/anthropic-consumer/dist
|
|
||||||
# No serve-time entrypoints: every container of this module names its command (`run` on a
|
|
||||||
# schedule), so nothing here serves — deliberately no MESH_TOOL_MODULES.
|
|
||||||
@@ -14,19 +14,10 @@
|
|||||||
"secrets": {
|
"secrets": {
|
||||||
"model-access": "${dir:state}/access-token"
|
"model-access": "${dir:state}/access-token"
|
||||||
},
|
},
|
||||||
"own-secrets": {
|
|
||||||
"broker": "${dir:mesh-state}/broker"
|
|
||||||
},
|
|
||||||
"emits": [
|
"emits": [
|
||||||
"usage.session"
|
"usage.session"
|
||||||
],
|
],
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
|
||||||
"id": "mesh-state",
|
|
||||||
"type": "directory",
|
|
||||||
"mode": "0700",
|
|
||||||
"place": "mesh"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "state",
|
"id": "state",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
@@ -46,66 +37,39 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "apply",
|
"id": "apply",
|
||||||
"type": "container",
|
"type": "process",
|
||||||
"name": "mesh-anthropic-consumer-apply",
|
"name": "anthropic-consumer-apply",
|
||||||
"network": "host",
|
"artifact": "code",
|
||||||
|
"run": [
|
||||||
|
"node",
|
||||||
|
"apply/index.js"
|
||||||
|
],
|
||||||
"schedule": "*/5 * * * *",
|
"schedule": "*/5 * * * *",
|
||||||
"args": [
|
|
||||||
"run",
|
|
||||||
"/app/modules/anthropic-consumer/dist/apply/index.js"
|
|
||||||
],
|
|
||||||
"volumes": [
|
|
||||||
"${dir:state}:/run/state"
|
|
||||||
],
|
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/access-token",
|
"MESH_MODEL_ACCESS_SECRET_FILE": "${dir:state}/access-token",
|
||||||
"MESH_MODEL_ACCESS_BIND_FILE": "/run/state/model.json",
|
"MESH_MODEL_ACCESS_BIND_FILE": "${dir:state}/model.json",
|
||||||
"MESH_CLAUDE_CREDENTIALS_FILE": "/run/state/claude/.credentials.json",
|
"MESH_CLAUDE_CREDENTIALS_FILE": "${dir:state}/claude/.credentials.json",
|
||||||
"MESH_CLAUDE_IDENTITY_FILE": "/run/state/claude/.claude.json"
|
"MESH_CLAUDE_IDENTITY_FILE": "${dir:state}/claude/.claude.json"
|
||||||
},
|
}
|
||||||
"artifact": "runtime"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "usage",
|
|
||||||
"type": "container",
|
|
||||||
"name": "mesh-anthropic-consumer-usage",
|
|
||||||
"network": "host",
|
|
||||||
"schedule": "*/5 * * * *",
|
|
||||||
"args": [
|
|
||||||
"run",
|
|
||||||
"/app/modules/anthropic-consumer/dist/usage/index.js"
|
|
||||||
],
|
|
||||||
"volumes": [
|
|
||||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
|
||||||
"${dir:state}:/run/state"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
|
||||||
"MESH_CLAUDE_PROJECTS_DIR": "/run/state/claude/projects",
|
|
||||||
"MESH_ANTHROPIC_USAGE_OUT": "/run/state/out/session-usage.json",
|
|
||||||
"MESH_TOOLS_MAIN": "/app/dist/main.js"
|
|
||||||
},
|
|
||||||
"artifact": "runtime"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"on": [
|
|
||||||
{
|
|
||||||
"arg": "BUILD_BASE",
|
|
||||||
"module": "mesh-tools",
|
|
||||||
"artifact": "build"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"arg": "RUNTIME_BASE",
|
|
||||||
"module": "mesh-tools",
|
|
||||||
"artifact": "runtime"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
"name": "runtime",
|
"name": "code",
|
||||||
"kind": "image",
|
"kind": "bundle",
|
||||||
"from": "Dockerfile"
|
"language": "typescript",
|
||||||
|
"entrypoints": [
|
||||||
|
"apply/index.js",
|
||||||
|
"usage/index.js"
|
||||||
|
],
|
||||||
|
"loads": [
|
||||||
|
"usage/index.js"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"MESH_CLAUDE_PROJECTS_DIR": "${dir:state}/claude/projects",
|
||||||
|
"MESH_ANTHROPIC_USAGE_OUT": "${dir:state}/out/session-usage.json"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,15 @@
|
|||||||
// per session. The consumer IS the (node,module) session's fixed binding, so no per-message account
|
// per session. The consumer IS the (node,module) session's fixed binding, so no per-message account
|
||||||
// attribution is done — just the totals (port map "don't-map" #3).
|
// attribution is done — just the totals (port map "don't-map" #3).
|
||||||
//
|
//
|
||||||
// Runs as `mesh-tools run` (no broker), so events are emitted best-effort via the sibling mesh-tools
|
// Runs in the node's runtime (novox/hq ADR 0198), every five minutes, so events are emitted through
|
||||||
// `emit` primitive; the totals are also written to a file so the reading is observable without one.
|
// the runtime as this module; the totals are also written to a file so the reading is observable
|
||||||
|
// without one.
|
||||||
|
|
||||||
import { readdirSync, statSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
import { readdirSync, statSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
|
|
||||||
|
import { emit } from "@novox/mesh-sdk/events";
|
||||||
|
|
||||||
import { readSessionFile, type SessionUsage } from "../transcript.js";
|
import { readSessionFile, type SessionUsage } from "../transcript.js";
|
||||||
|
|
||||||
/** The vendor-neutral usage row ADR 0054 fixes — the shape the model-usage store upserts. Kept local
|
/** The vendor-neutral usage row ADR 0054 fixes — the shape the model-usage store upserts. Kept local
|
||||||
@@ -116,22 +119,20 @@ function atomicWrite(path: string, content: string): void {
|
|||||||
renameSync(tmp, path);
|
renameSync(tmp, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Emit best-effort via the sibling mesh-tools `emit`, which wires a broker a run step has none. */
|
/** Emit best-effort through the runtime: a reading that could not be announced is still in the file. */
|
||||||
async function emitUsage(body: Record<string, unknown>): Promise<void> {
|
async function emitUsage(body: Record<string, unknown>): Promise<void> {
|
||||||
const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js";
|
try {
|
||||||
const { spawn } = await import("node:child_process");
|
await emit("usage.session", body);
|
||||||
await new Promise<void>((resolve) => {
|
} catch (err) {
|
||||||
const child = spawn(
|
console.error(`[anthropic-consumer] could not emit usage: ${err}`);
|
||||||
process.execPath,
|
}
|
||||||
[main, "emit", "usage.session", JSON.stringify(body)],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
child.on("exit", () => resolve());
|
|
||||||
child.on("error", (err) => {
|
|
||||||
console.error(`[anthropic-consumer] could not emit usage: ${err}`);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await main();
|
// The cadence the scheduled container had: once at start, then every five minutes. Not awaited, so the
|
||||||
|
// runtime's handshake is answered while a long first reading is still under way.
|
||||||
|
const EVERY_MS = 5 * 60 * 1000;
|
||||||
|
const tick = (): void => {
|
||||||
|
void main().catch((err) => console.error(`[anthropic-consumer] usage reading failed: ${err}`));
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
setInterval(tick, EVERY_MS);
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
# audit-logger's runtime: the shared runtime image, carrying this module's compiled code.
|
|
||||||
#
|
|
||||||
# **Built from this module's own directory and nothing else.** The toolkit is in the base image, so
|
|
||||||
# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this from a
|
|
||||||
# repository and a path (novox/hq ADR 0069) rather than only on a workstation that happens to have
|
|
||||||
# the siblings laid out beside it.
|
|
||||||
|
|
||||||
# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in.
|
|
||||||
# They are different images on purpose — the first carries a compiler and the second must not, or
|
|
||||||
# every running container would carry one it never invokes. The mesh answers both with the copies it
|
|
||||||
# holds, because a fingerprint written here would name one particular copy and no other mesh has it
|
|
||||||
# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build
|
|
||||||
# nobody told stops here and says which module to build first.
|
|
||||||
ARG BUILD_BASE
|
|
||||||
ARG RUNTIME_BASE
|
|
||||||
|
|
||||||
FROM ${BUILD_BASE} AS build
|
|
||||||
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
|
|
||||||
# node_modules — the module is compiled against exactly the toolkit it will run against.
|
|
||||||
WORKDIR /app/modules/audit-logger
|
|
||||||
COPY . .
|
|
||||||
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
|
|
||||||
# symlinks to a launcher that requires its library relatively — resolved away when the base image
|
|
||||||
# was assembled.
|
|
||||||
RUN node /app/node_modules/typescript/bin/tsc audit.ts index.ts \
|
|
||||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
|
||||||
|
|
||||||
FROM ${RUNTIME_BASE}
|
|
||||||
COPY --from=build /app/modules/audit-logger/dist /app/modules/audit-logger/dist
|
|
||||||
# **Served, not run.** This subscribes on import, and the serve mode binds the broker before it
|
|
||||||
# imports anything — `run` exists for a step that works offline and exits, and would leave this
|
|
||||||
# with nothing to subscribe to.
|
|
||||||
ENV MESH_TOOL_MODULES=/app/modules/audit-logger/dist/index.js
|
|
||||||
@@ -5,27 +5,21 @@
|
|||||||
"consumes": [
|
"consumes": [
|
||||||
"**"
|
"**"
|
||||||
],
|
],
|
||||||
"own-secrets": {
|
|
||||||
"broker": "${dir:state}/broker"
|
|
||||||
},
|
|
||||||
"build": {
|
"build": {
|
||||||
"on": [
|
|
||||||
{
|
|
||||||
"arg": "BUILD_BASE",
|
|
||||||
"module": "mesh-tools",
|
|
||||||
"artifact": "build"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"arg": "RUNTIME_BASE",
|
|
||||||
"module": "mesh-tools",
|
|
||||||
"artifact": "runtime"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
"name": "runtime",
|
"name": "code",
|
||||||
"kind": "image",
|
"kind": "bundle",
|
||||||
"from": "Dockerfile"
|
"language": "typescript",
|
||||||
|
"entrypoints": [
|
||||||
|
"index.js"
|
||||||
|
],
|
||||||
|
"loads": [
|
||||||
|
"index.js"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"AUDIT_LOG": "${dir:trail}/audit.log"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -40,21 +34,6 @@
|
|||||||
"id": "trail",
|
"id": "trail",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"mode": "0700"
|
"mode": "0700"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "run",
|
|
||||||
"type": "container",
|
|
||||||
"name": "mesh-audit-logger",
|
|
||||||
"network": "host",
|
|
||||||
"volumes": [
|
|
||||||
"${dir:state}/broker:/run/secrets/broker:ro",
|
|
||||||
"${dir:trail}:/trail"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
|
||||||
"AUDIT_LOG": "/trail/audit.log"
|
|
||||||
},
|
|
||||||
"artifact": "runtime"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ test("audit-logger records every event to the trail as one line each", async ()
|
|||||||
const path = join(dir, "audit.log");
|
const path = join(dir, "audit.log");
|
||||||
|
|
||||||
// The audit-logger's whole behaviour: consume everything, record it.
|
// The audit-logger's whole behaviour: consume everything, record it.
|
||||||
await on("**", async (event) => record(event, path));
|
await on("#", async (event) => record(event, path)); // the pattern index.ts subscribes
|
||||||
|
|
||||||
process.env.MESH_MODULE = "umami";
|
process.env.MESH_MODULE = "umami";
|
||||||
process.env.MESH_NODE = "anchor";
|
process.env.MESH_NODE = "anchor";
|
||||||
@@ -24,7 +24,9 @@ test("audit-logger records every event to the trail as one line each", async ()
|
|||||||
|
|
||||||
const lines = (await readFile(path, "utf8")).trim().split("\n").map((l) => JSON.parse(l));
|
const lines = (await readFile(path, "utf8")).trim().split("\n").map((l) => JSON.parse(l));
|
||||||
assert.equal(lines.length, 2);
|
assert.equal(lines.length, 2);
|
||||||
assert.deepEqual(lines.map((l) => l.type), ["umami.site.created", "node.anchor.joined"]);
|
// A module names its events locally (design 29); the module is the `source`, which together with
|
||||||
|
// the type says whose event it was. This broker does no namespacing, so the type is as emitted.
|
||||||
|
assert.deepEqual(lines.map((l) => l.type), ["site.created", "node.anchor.joined"]);
|
||||||
assert.equal(lines[0].source, "umami");
|
assert.equal(lines[0].source, "umami");
|
||||||
assert.equal(lines[0].node, "anchor");
|
assert.equal(lines[0].node, "anchor");
|
||||||
assert.equal(lines[0].body.domain, "my-app");
|
assert.equal(lines[0].body.domain, "my-app");
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# claude-code
|
||||||
|
|
||||||
|
The operator's agent on a machine (novox/hq design 36): its package, its machine-wide managed
|
||||||
|
configuration, and the consumer side of the Anthropic licence manager (design 39, ADR 0183).
|
||||||
|
|
||||||
|
## What it owns
|
||||||
|
|
||||||
|
Two directories, declared, so the mesh refuses a second module owning either:
|
||||||
|
|
||||||
|
- `/etc/claude-code`, the agent's machine-wide managed directory, root's, `0755`.
|
||||||
|
- `~/.claude` under the operator account's home, the operator's, `0700`. The module owns the directory —
|
||||||
|
that it exists, who owns it, its mode — and of what is inside only what it writes. Everything else
|
||||||
|
in it (memory, history, projects, local settings, a person's own rules and skills) is the person's
|
||||||
|
and is never read or written (hq ADR 0182). Unassigned, the module leaves the directory: the host
|
||||||
|
removes a directory only when it is empty.
|
||||||
|
|
||||||
|
## What it writes
|
||||||
|
|
||||||
|
Under the agent's managed directory, `/etc/claude-code`, owned whole by this module and rewritten
|
||||||
|
whenever the node's tool runtime collects the module's tools:
|
||||||
|
|
||||||
|
| file | holds |
|
||||||
|
|---|---|
|
||||||
|
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
||||||
|
| `managed-settings.json` | the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, and the key-helper while the node holds an API-key licence |
|
||||||
|
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions |
|
||||||
|
|
||||||
|
Under the operator's home, only `~/.claude/.credentials.json`, and only when the licence manager hands
|
||||||
|
this node a subscription token. Nothing else under the home is read or written.
|
||||||
|
|
||||||
|
## Over NATS
|
||||||
|
|
||||||
|
Everything between this module and the rest of the mesh is NATS, in three kinds: an **event** says that
|
||||||
|
something happened and carries no secret, because a stream keeps it; a **request** carries a token,
|
||||||
|
because nothing keeps it (hq design 32 §10); and **state** is the current value of something every node
|
||||||
|
must see, a node that joins later included — kept, so it carries no secret either (hq ADR 0201).
|
||||||
|
|
||||||
|
| what | how |
|
||||||
|
|---|---|
|
||||||
|
| what this node holds | the module's `holdings` state, one key for this node — the account, the kind, fingerprints and expiries, never a token — written at start and whenever the credentials file changes (hq ADR 0206) |
|
||||||
|
| a person ran `/login` here | the credentials file gains a refresh token this module never writes; its next report shows it, and the licence manager asks `claude_code_grant` for it, giving its key — the one time a refresh token leaves the node, for the manager to adopt by refreshing it |
|
||||||
|
| what this node should hold | the licence manager's `bindings` state, this node's key; on a newer generation this module asks `anthropic-licence-manager.current` for its token, sealed to the key it sends, and writes it access-token-only — so the agent here never refreshes. A node that was off reads its key when it is back |
|
||||||
|
| an MCP server registered through this module | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. An entry with a secret in its `env` or `headers` is refused by the runtime |
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
`claude_code_status`, `claude_code_render`, `claude_code_pull`, `claude_code_grant` (for the licence
|
||||||
|
manager), `claude_code_mcp_list`,
|
||||||
|
`claude_code_mcp_register` (this node by default; `nodes: "all"` or a list for more — called for this
|
||||||
|
node alone, its answer names the other nodes running claude-code), `claude_code_mcp_unregister`.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
Per node or for the whole mesh, through `mesh-controller.settings module=claude-code`:
|
||||||
|
|
||||||
|
- `role` — what this node is, in a few words; shown to every session.
|
||||||
|
- `mcp_servers` — extra tool servers, set by the operator for the mesh or a node, beside the ones
|
||||||
|
registered through the tools; keyed by name, in the vendor's `.mcp.json` entry shape
|
||||||
|
(`{"type":"http","url":…}` or `{"type":"stdio","command":…,"args":[…]}`). The name `mesh` is the
|
||||||
|
module's own and cannot be set. Put a person's own servers here, or they stop loading.
|
||||||
|
|
||||||
|
## On a machine that carried the predecessor
|
||||||
|
|
||||||
|
Remove these by hand, once; the mesh removes nothing it did not make (ADR 0182):
|
||||||
|
|
||||||
|
- `~/.claude/CLAUDE.md`
|
||||||
|
- `~/.claude/rules/00-hal-mesh.md`, `~/.claude/rules/conventions.md`
|
||||||
|
- `~/.claude/skills/cleanup/`, `~/.claude/skills/hal-switch-license/`
|
||||||
|
- the hand-made console entry in `~/.claude.json` under `mcpServers` — it is ignored now anyway
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
|
||||||
|
Writing `/etc/claude-code` needs root. The runtime runs as the operator account, and the module uses
|
||||||
|
that account's passwordless `sudo`; on a machine without it, `claude_code_render` says so and nothing
|
||||||
|
is written.
|
||||||
|
|
||||||
|
## Code
|
||||||
|
|
||||||
|
Go, one binary (`cmd/claude-code`) the node's runtime launches. Tested with `go test ./...`; the managed
|
||||||
|
instruction file is held to the TypeScript renderer it replaced (`testdata/rendered-by-typescript.json`),
|
||||||
|
and the sealed box is the licence manager's own format.
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var now = time.Now().UnixMilli()
|
||||||
|
|
||||||
|
func node(t *testing.T, name string) (Paths, map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
p := Paths{State: filepath.Join(root, "state"), Facts: filepath.Join(root, "state", "facts.json"),
|
||||||
|
Settings: filepath.Join(root, "state", "settings.json"), Home: filepath.Join(root, "home"), Node: name}
|
||||||
|
_ = os.MkdirAll(p.State, 0o700)
|
||||||
|
_ = os.MkdirAll(filepath.Join(p.Home, ".claude"), 0o700)
|
||||||
|
_ = os.WriteFile(p.Facts, []byte(`{"node":"`+name+`","console":"http://127.0.0.1:4270/mcp"}`), 0o600)
|
||||||
|
_ = os.WriteFile(p.Settings, []byte(`{"role":"","mcp_servers":{}}`), 0o600)
|
||||||
|
return p, map[string]string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writer(w map[string]string) WriteManaged {
|
||||||
|
return func(name, content string) (string, error) { w[name] = content; return name + ": written", nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func creds(t *testing.T, p Paths) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var c map[string]any
|
||||||
|
raw, _ := os.ReadFile(p.credentials())
|
||||||
|
if err := json.Unmarshal(raw, &c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return c["claudeAiOauth"].(map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the renderer, held to the TypeScript it replaced -------------------------------------------------
|
||||||
|
|
||||||
|
func TestTheRendererWritesWhatTheTypeScriptOneWrote(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("testdata/rendered-by-typescript.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var f struct {
|
||||||
|
Facts Facts `json:"facts"`
|
||||||
|
Settings Settings `json:"settings"`
|
||||||
|
Registered Servers `json:"registered"`
|
||||||
|
WithKey map[string]string `json:"withKey"`
|
||||||
|
Plain map[string]string `json:"plain"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &f); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
same := func(label string, got, want map[string]string) {
|
||||||
|
if got["CLAUDE.md"] != want["CLAUDE.md"] {
|
||||||
|
t.Errorf("%s: CLAUDE.md differs from the TypeScript's:\n--- go\n%s\n--- typescript\n%s", label, got["CLAUDE.md"], want["CLAUDE.md"])
|
||||||
|
}
|
||||||
|
for _, file := range []string{"managed-mcp.json", "managed-settings.json"} {
|
||||||
|
var a, b any
|
||||||
|
_ = json.Unmarshal([]byte(got[file]), &a)
|
||||||
|
_ = json.Unmarshal([]byte(want[file]), &b)
|
||||||
|
if !reflect.DeepEqual(a, b) {
|
||||||
|
t.Errorf("%s: %s means something else:\n--- go\n%s\n--- typescript\n%s", label, file, got[file], want[file])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
same("with an API key", Render(f.Facts, f.Settings, &Binding{Licence: "api", Kind: "api-key"}, "/state/api-key-helper", f.Registered), f.WithKey)
|
||||||
|
same("plain", Render(f.Facts, Settings{}, nil, "/h", Servers{}), f.Plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T) {
|
||||||
|
out := Render(Facts{Node: "w", Console: "http://127.0.0.1:4270/mcp"},
|
||||||
|
Settings{MCPServers: map[string]map[string]any{"mesh": {"type": "http", "url": "http://evil"}, "bad name": {}}}, nil, "/h", nil)
|
||||||
|
var mcp struct {
|
||||||
|
MCPServers map[string]map[string]any `json:"mcpServers"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(out["managed-mcp.json"]), &mcp)
|
||||||
|
if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil {
|
||||||
|
t.Fatalf("%v", mcp.MCPServers)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil)) {
|
||||||
|
t.Fatal("rendering is not deterministic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the credentials file -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func i64(v int64) *int64 { return &v }
|
||||||
|
|
||||||
|
func TestTheLineageRules(t *testing.T) {
|
||||||
|
const hour = 3_600_000
|
||||||
|
g := func(at string, exp int64, rtExp int64) Grant {
|
||||||
|
return Grant{AccessToken: at, ExpiresAt: exp, RefreshTokenExpiresAt: i64(rtExp)}
|
||||||
|
}
|
||||||
|
month := now + 30*24*hour
|
||||||
|
if d := DecideApply(&Grant{AccessToken: "A", ExpiresAt: now + hour, RefreshTokenExpiresAt: i64(month)}, g("B", now+2*hour, month), false); !d.Apply {
|
||||||
|
t.Fatal("a newer rotation was refused")
|
||||||
|
}
|
||||||
|
if d := DecideApply(&Grant{AccessToken: "new", ExpiresAt: now + 2*hour, RefreshTokenExpiresAt: i64(month)}, g("old", now+hour, month), false); d.Apply || d.Reason != "not-newer" {
|
||||||
|
t.Fatalf("a late older rotation: %+v", d)
|
||||||
|
}
|
||||||
|
if d := DecideApply(&Grant{AccessToken: "A", ExpiresAt: now + 8*hour, RefreshTokenExpiresAt: i64(month)}, g("re", now+hour, now+5*24*hour), false); !d.Apply || !d.Reissued {
|
||||||
|
t.Fatalf("a re-issued grant: %+v", d)
|
||||||
|
}
|
||||||
|
if d := DecideApply(&Grant{AccessToken: "A", ExpiresAt: now + 8*hour}, g("other", now+hour, month), true); !d.Apply {
|
||||||
|
t.Fatal("a switch was refused")
|
||||||
|
}
|
||||||
|
if d := DecideApply(&Grant{AccessToken: "A"}, Grant{AccessToken: "A"}, true); d.Apply || d.Reason != "already-current" {
|
||||||
|
t.Fatalf("the same token: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestALoginIsSeenAndStrippedWhenTheNodesOwnGrantIsWritten(t *testing.T) {
|
||||||
|
p, _ := node(t, "laptop")
|
||||||
|
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-login","refreshToken":"rt-login","expiresAt":1700000000000},"other":1}`)
|
||||||
|
login := ReadCredentials(p.credentials())
|
||||||
|
if !HoldsLogin(login) {
|
||||||
|
t.Fatal("a login was not seen")
|
||||||
|
}
|
||||||
|
if err := WriteCredentials(p.credentials(), WithGrant(login, Grant{AccessToken: "at-mesh", ExpiresAt: 1, Scopes: []string{"user:inference"}})); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
back := ReadCredentials(p.credentials())
|
||||||
|
raw, _ := os.ReadFile(p.credentials())
|
||||||
|
info, _ := os.Stat(p.credentials())
|
||||||
|
if HoldsLogin(back) || GrantOf(back).AccessToken != "at-mesh" || back["other"] == nil || strings.Contains(string(raw), "rt-login") || info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("written %s (mode %v)", raw, info.Mode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheAccountIsReadFromTheAgentsStateFileAndNeverGuessed(t *testing.T) {
|
||||||
|
p, _ := node(t, "laptop")
|
||||||
|
writeFile(t, p.account(), `{"oauthAccount":{"accountUuid":"u-1","emailAddress":"a@example.org"},"other":2}`)
|
||||||
|
if id := ReadIdentity(p.account()); id == nil || id.AccountUUID != "u-1" || id.EmailAddress != "a@example.org" {
|
||||||
|
t.Fatalf("%+v", id)
|
||||||
|
}
|
||||||
|
if ReadIdentity("/nonexistent/.claude.json") != nil {
|
||||||
|
t.Fatal("an identity from nothing")
|
||||||
|
}
|
||||||
|
writeFile(t, p.account(), `{}`)
|
||||||
|
if ReadIdentity(p.account()) != nil {
|
||||||
|
t.Fatal("an identity from an empty file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the licence, ADR 0206 ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestWhatANodeHoldsIsReportedWithFingerprintsAndItsAccountNeverAToken(t *testing.T) {
|
||||||
|
p, _ := node(t, "laptop")
|
||||||
|
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-secret","refreshToken":"rt-secret","expiresAt":2000,"refreshTokenExpiresAt":9000}}`)
|
||||||
|
writeFile(t, p.account(), `{"oauthAccount":{"accountUuid":"u-1","emailAddress":"a@example.org"}}`)
|
||||||
|
h := HoldingsOf(p)
|
||||||
|
if h.Node != "laptop" || h.Identity.AccountUUID != "u-1" || *h.Kind != "subscription" || !h.Refresh.Present ||
|
||||||
|
!strings.HasPrefix(*h.Refresh.Fingerprint, "sha256:") || h.Access.ExpiresAt != 2000 || h.ChangedAt == nil {
|
||||||
|
t.Fatalf("%+v", h)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(h)
|
||||||
|
if strings.Contains(string(raw), "at-secret") || strings.Contains(string(raw), "rt-secret") {
|
||||||
|
t.Fatalf("a token is in the report: %s", raw)
|
||||||
|
}
|
||||||
|
var keys map[string]any
|
||||||
|
_ = json.Unmarshal(raw, &keys)
|
||||||
|
for k := range keys {
|
||||||
|
if strings.Contains(strings.ToLower(k), "token") || strings.Contains(strings.ToLower(k), "secret") {
|
||||||
|
t.Fatalf("a field the runtime would refuse: %s", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The manager reads exactly this shape.
|
||||||
|
if _, err := time.Parse(time.RFC3339Nano, *h.ChangedAt); err != nil {
|
||||||
|
t.Fatalf("the manager cannot read the report's time: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheGrantAnswersOnlyAWaitingLoginSealedToTheManagersKey(t *testing.T) {
|
||||||
|
p, _ := node(t, "laptop")
|
||||||
|
manager, _ := GenerateKeyPair()
|
||||||
|
if a, _ := GrantFor(p, manager.PublicKey); a.Sealed != nil || a.Waiting == nil || *a.Waiting {
|
||||||
|
t.Fatalf("%+v", a)
|
||||||
|
}
|
||||||
|
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at","refreshToken":"rt-login","expiresAt":1}}`)
|
||||||
|
writeFile(t, p.account(), `{"oauthAccount":{"accountUuid":"u-9"}}`)
|
||||||
|
a, err := GrantFor(p, manager.PublicKey)
|
||||||
|
if err != nil || a.Identity.AccountUUID != "u-9" {
|
||||||
|
t.Fatalf("%+v %v", a, err)
|
||||||
|
}
|
||||||
|
plain, _ := Open(*a.Sealed, manager.PrivateKey)
|
||||||
|
if !strings.Contains(plain, `"refreshToken":"rt-login"`) {
|
||||||
|
t.Fatalf("opened %s", plain)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(a)
|
||||||
|
if strings.Contains(string(raw), "rt-login") {
|
||||||
|
t.Fatal("the refresh token crossed in the clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seat answers `current` as the manager does: the grant sealed to the key the node sent.
|
||||||
|
func seat(t *testing.T, licence, token string, gen int64, asked *[]string) Ask {
|
||||||
|
return func(address string, args any) (json.RawMessage, error) {
|
||||||
|
*asked = append(*asked, address)
|
||||||
|
key := args.(map[string]any)["public_key"].(string)
|
||||||
|
g, _ := json.Marshal(Grant{AccessToken: token, ExpiresAt: now + 3_600_000})
|
||||||
|
box, err := Seal(string(g), key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return json.Marshal(Current{Licence: licence, Kind: "subscription", Generation: gen, Sealed: &box})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestANewerGenerationFetchesTheTokenOnceByTheSeatsVerb(t *testing.T) {
|
||||||
|
p, w := node(t, "laptop")
|
||||||
|
var asked []string
|
||||||
|
ask := seat(t, "personal", "at-1", 3, &asked)
|
||||||
|
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(asked) != 1 || asked[0] != "seat:anthropic-licence-manager.current" || creds(t, p)["accessToken"] != "at-1" {
|
||||||
|
t.Fatalf("asked %v, credentials %v", asked, creds(t, p))
|
||||||
|
}
|
||||||
|
if done, _ := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); done != "" || len(asked) != 1 {
|
||||||
|
t.Fatal("an equal generation asked again")
|
||||||
|
}
|
||||||
|
if HoldingsOf(p).Generation != 3 || w["managed-mcp.json"] == "" {
|
||||||
|
t.Fatal("the generation or the managed files were not written")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheTokenANodeIsHandedReplacesALoginsGrantAndLeavesNoRefreshToken(t *testing.T) {
|
||||||
|
p, w := node(t, "laptop")
|
||||||
|
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-old","refreshToken":"rt-spent","expiresAt":`+
|
||||||
|
strings.TrimSpace(string(mustJSON(now+7_200_000)))+`}}`)
|
||||||
|
var asked []string
|
||||||
|
out, err := Pull(p, seat(t, "personal", "at-new", 1, &asked), writer(w))
|
||||||
|
if err != nil || out["applied"] != true {
|
||||||
|
t.Fatalf("%v %v", out, err)
|
||||||
|
}
|
||||||
|
c := creds(t, p)
|
||||||
|
if c["accessToken"] != "at-new" || c["refreshToken"] != nil || HoldingsOf(p).Refresh.Present {
|
||||||
|
t.Fatalf("%v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b }
|
||||||
|
|
||||||
|
// ---- MCP servers in state, ADR 0201 -------------------------------------------------------------------
|
||||||
|
|
||||||
|
// bus is the `servers` state as every node in a test shares it, with each node's watch.
|
||||||
|
type bus struct {
|
||||||
|
kept map[string]map[string]any
|
||||||
|
watchers []func(ServerChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bus) Put(key string, value any) error {
|
||||||
|
v := value.(map[string]any)
|
||||||
|
b.kept[key] = v
|
||||||
|
for _, w := range b.watchers {
|
||||||
|
w(ServerChange{Key: key, Op: "put", Value: v})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bus) Delete(key string) error {
|
||||||
|
delete(b.kept, key)
|
||||||
|
for _, w := range b.watchers {
|
||||||
|
w(ServerChange{Key: key, Op: "delete"})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bus) Keys() ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
for k := range b.kept {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// join is a node joining: its view takes the current state, then every change.
|
||||||
|
func (b *bus) join(p Paths, w map[string]string) *ServerView {
|
||||||
|
v := NewServerView(p)
|
||||||
|
for k, val := range b.kept {
|
||||||
|
_, _ = OnServerChange(v, ServerChange{Key: k, Op: "put", Value: val}, p, writer(w))
|
||||||
|
}
|
||||||
|
b.watchers = append(b.watchers, func(c ServerChange) { _, _ = OnServerChange(v, c, p, writer(w)) })
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func noOthers() ([]string, error) { return nil, nil }
|
||||||
|
|
||||||
|
func TestRegisteringHerePutsItUnderThisNodesKeyAndAsksAboutTheOthers(t *testing.T) {
|
||||||
|
p, w := node(t, "laptop")
|
||||||
|
b := &bus{kept: map[string]map[string]any{}}
|
||||||
|
v := b.join(p, w)
|
||||||
|
r, err := RegisterServer(p, Registration{Name: "search", Entry: map[string]any{"type": "http", "url": "https://s.example/mcp"}}, b, v, writer(w),
|
||||||
|
func() ([]string, error) { return []string{"laptop", "server", "desktop"}, nil })
|
||||||
|
if err != nil || r["here"] != "changed" || !strings.Contains(r["also"].(string), "server, desktop") || b.kept["laptop.search"] == nil {
|
||||||
|
t.Fatalf("%v %v %v", r, err, b.kept)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w["managed-mcp.json"], `"search"`) {
|
||||||
|
t.Fatal("not rendered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEveryNodeRegistrationReachesTheOthersAndALateNodeReadsIt(t *testing.T) {
|
||||||
|
a, wa := node(t, "laptop")
|
||||||
|
s, ws := node(t, "server")
|
||||||
|
b := &bus{kept: map[string]map[string]any{}}
|
||||||
|
va := b.join(a, wa)
|
||||||
|
b.join(s, ws)
|
||||||
|
_, _ = RegisterServer(a, Registration{Name: "docs", Entry: map[string]any{"type": "stdio", "command": "docs-mcp"}, Nodes: []string{"all"}}, b, va, writer(wa), noOthers)
|
||||||
|
if Registered(s)["docs"] == nil || !strings.Contains(ws["managed-mcp.json"], "docs-mcp") {
|
||||||
|
t.Fatalf("the other node did not take it: %v", Registered(s))
|
||||||
|
}
|
||||||
|
late, wl := node(t, "desktop")
|
||||||
|
b.join(late, wl)
|
||||||
|
if Registered(late)["docs"] == nil {
|
||||||
|
t.Fatal("a node joining later did not read the current set")
|
||||||
|
}
|
||||||
|
_, _ = RegisterServer(a, Registration{Name: "docs", Nodes: []string{"all"}}, b, va, writer(wa), noOthers)
|
||||||
|
if Registered(s)["docs"] != nil || Registered(late)["docs"] != nil {
|
||||||
|
t.Fatal("an unregistration did not reach every node")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestANodesOwnRegistrationOverridesTheOneForEveryNode(t *testing.T) {
|
||||||
|
a, wa := node(t, "laptop")
|
||||||
|
s, ws := node(t, "server")
|
||||||
|
b := &bus{kept: map[string]map[string]any{}}
|
||||||
|
va := b.join(a, wa)
|
||||||
|
b.join(s, ws)
|
||||||
|
_, _ = RegisterServer(a, Registration{Name: "x", Entry: map[string]any{"type": "http", "url": "https://all"}, Nodes: []string{"all"}}, b, va, writer(wa), noOthers)
|
||||||
|
_, _ = RegisterServer(a, Registration{Name: "x", Entry: map[string]any{"type": "http", "url": "https://laptop"}}, b, va, writer(wa), noOthers)
|
||||||
|
if Registered(a)["x"]["url"] != "https://laptop" || Registered(s)["x"]["url"] != "https://all" {
|
||||||
|
t.Fatalf("%v %v", Registered(a), Registered(s))
|
||||||
|
}
|
||||||
|
r, _ := RegisterServer(a, Registration{Name: "x"}, b, va, writer(wa), noOthers)
|
||||||
|
if !strings.Contains(r["still"].(string), "still applies here") || Registered(a)["x"]["url"] != "https://all" {
|
||||||
|
t.Fatalf("%v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABadEntryIsRefusedBeforeAnythingIsPut(t *testing.T) {
|
||||||
|
p, w := node(t, "laptop")
|
||||||
|
b := &bus{kept: map[string]map[string]any{}}
|
||||||
|
v := b.join(p, w)
|
||||||
|
r, _ := RegisterServer(p, Registration{Name: "mesh", Entry: map[string]any{"type": "http", "url": "https://x"}}, b, v, writer(w), noOthers)
|
||||||
|
if r["registered"] != false || len(b.kept) != 0 {
|
||||||
|
t.Fatalf("%v %v", r, b.kept)
|
||||||
|
}
|
||||||
|
if done, _ := OnServerChange(v, ServerChange{Key: "server.b", Op: "put", Value: map[string]any{"type": "http", "url": "https://b"}}, p, writer(w)); done != "" {
|
||||||
|
t.Fatal("another node's key changed this one")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The agent's credentials file, and whether an offered grant may replace what it holds (novox/hq ADR 0183,
|
||||||
|
// ADR 0206, design 36 §5). Pure where it decides, so the rules are tested without a file.
|
||||||
|
//
|
||||||
|
// The file is the vendor's: `{ claudeAiOauth: { accessToken, expiresAt, refreshTokenExpiresAt?, scopes?,
|
||||||
|
// subscriptionType?, rateLimitTier? }, ... }`. A node bound to a licence never holds a refresh token, so
|
||||||
|
// the one this module writes never carries one; a refresh token found there is a person's login.
|
||||||
|
//
|
||||||
|
// The lineage rule is the predecessor's, with the incidents that earned it: a rotation of the same licence
|
||||||
|
// is applied only if newer; a grant re-issued by a login is adopted whatever its expiry; a switch to another
|
||||||
|
// licence is applied regardless, because across licences the expiries are unrelated numbers.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Grant is what the manager hands a node: an access token and its expiries, never a refresh token.
|
||||||
|
type Grant struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
ExpiresAt int64 `json:"expiresAt"`
|
||||||
|
RefreshTokenExpiresAt *int64 `json:"refreshTokenExpiresAt,omitempty"`
|
||||||
|
Scopes []string `json:"scopes,omitempty"`
|
||||||
|
SubscriptionType string `json:"subscriptionType,omitempty"`
|
||||||
|
RateLimitTier string `json:"rateLimitTier,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision is whether a handed grant is applied, and why not.
|
||||||
|
type Decision struct {
|
||||||
|
Apply bool
|
||||||
|
Reissued bool
|
||||||
|
Reason string // already-current | not-newer
|
||||||
|
}
|
||||||
|
|
||||||
|
// generationTolerance: two refresh-token expiries within a day are one lineage; a login starts a fresh
|
||||||
|
// window weeks away.
|
||||||
|
const generationTolerance = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
func sameGeneration(a, b *int64) bool {
|
||||||
|
if a == nil || b == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return math.Abs(float64(*a-*b)) <= generationTolerance
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecideApply says whether an offered grant replaces the one held; switch is a move to another licence.
|
||||||
|
func DecideApply(local *Grant, offered Grant, switching bool) Decision {
|
||||||
|
if local == nil || local.AccessToken == "" {
|
||||||
|
return Decision{Apply: true}
|
||||||
|
}
|
||||||
|
if local.AccessToken == offered.AccessToken {
|
||||||
|
return Decision{Reason: "already-current"}
|
||||||
|
}
|
||||||
|
reissued := !sameGeneration(local.RefreshTokenExpiresAt, offered.RefreshTokenExpiresAt)
|
||||||
|
if !switching && !reissued && local.ExpiresAt >= offered.ExpiresAt {
|
||||||
|
return Decision{Reason: "not-newer"}
|
||||||
|
}
|
||||||
|
return Decision{Apply: true, Reissued: reissued}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credentials is the file as found, every key kept — the vendor's other keys are not this module's.
|
||||||
|
type Credentials map[string]any
|
||||||
|
|
||||||
|
func (c Credentials) oauth() map[string]any {
|
||||||
|
o, _ := c["claudeAiOauth"].(map[string]any)
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadCredentials reads the file, keeping numbers as written; nil when there is none.
|
||||||
|
func ReadCredentials(path string) Credentials {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
dec.UseNumber()
|
||||||
|
var c Credentials
|
||||||
|
if dec.Decode(&c) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func number(v any) (int64, bool) {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case json.Number:
|
||||||
|
i, err := n.Int64()
|
||||||
|
if err != nil {
|
||||||
|
f, err := n.Float64()
|
||||||
|
return int64(f), err == nil
|
||||||
|
}
|
||||||
|
return i, true
|
||||||
|
case float64:
|
||||||
|
return int64(n), true
|
||||||
|
case int64:
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantOf is the grant the file holds, or nil.
|
||||||
|
func GrantOf(c Credentials) *Grant {
|
||||||
|
o := c.oauth()
|
||||||
|
at, _ := o["accessToken"].(string)
|
||||||
|
if at == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
g := &Grant{AccessToken: at}
|
||||||
|
g.ExpiresAt, _ = number(o["expiresAt"])
|
||||||
|
if v, ok := number(o["refreshTokenExpiresAt"]); ok {
|
||||||
|
g.RefreshTokenExpiresAt = &v
|
||||||
|
}
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
// HoldsLogin says the file holds a refresh token — which this module never writes, so a person's login.
|
||||||
|
func HoldsLogin(c Credentials) bool {
|
||||||
|
rt, _ := c.oauth()["refreshToken"].(string)
|
||||||
|
return rt != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshTokenOf is the refresh token a login left, or "".
|
||||||
|
func RefreshTokenOf(c Credentials) string {
|
||||||
|
rt, _ := c.oauth()["refreshToken"].(string)
|
||||||
|
return rt
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithGrant lays the handed grant over what is there, and deletes any refresh token.
|
||||||
|
func WithGrant(local Credentials, g Grant) Credentials {
|
||||||
|
next := Credentials{}
|
||||||
|
for k, v := range local {
|
||||||
|
next[k] = v
|
||||||
|
}
|
||||||
|
oauth := map[string]any{}
|
||||||
|
for k, v := range local.oauth() {
|
||||||
|
oauth[k] = v
|
||||||
|
}
|
||||||
|
oauth["accessToken"] = g.AccessToken
|
||||||
|
oauth["expiresAt"] = g.ExpiresAt
|
||||||
|
if g.RefreshTokenExpiresAt != nil {
|
||||||
|
oauth["refreshTokenExpiresAt"] = *g.RefreshTokenExpiresAt
|
||||||
|
}
|
||||||
|
if len(g.Scopes) > 0 {
|
||||||
|
oauth["scopes"] = g.Scopes
|
||||||
|
}
|
||||||
|
if g.SubscriptionType != "" {
|
||||||
|
oauth["subscriptionType"] = g.SubscriptionType
|
||||||
|
}
|
||||||
|
if g.RateLimitTier != "" {
|
||||||
|
oauth["rateLimitTier"] = g.RateLimitTier
|
||||||
|
}
|
||||||
|
delete(oauth, "refreshToken")
|
||||||
|
next["claudeAiOauth"] = oauth
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplacedBy is the handed grant in place of the old licence's, whole — scopes and subscription included;
|
||||||
|
// only keys outside the grant stay. No refresh token survives.
|
||||||
|
func ReplacedBy(local Credentials, g Grant) Credentials {
|
||||||
|
next := Credentials{}
|
||||||
|
for k, v := range local {
|
||||||
|
if k != "claudeAiOauth" {
|
||||||
|
next[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return WithGrant(next, g)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteCredentials writes atomically at 0600: a partial credentials file must never be read as a whole one.
|
||||||
|
func WriteCredentials(path string, c Credentials) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw, err := indented(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := path + ".mesh-tmp"
|
||||||
|
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// indented is JSON as the agent's own files are written: two-space indent, a trailing newline, nothing
|
||||||
|
// escaped that need not be.
|
||||||
|
func indented(v any) ([]byte, error) {
|
||||||
|
var b bytes.Buffer
|
||||||
|
enc := json.NewEncoder(&b)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
err := enc.Encode(v)
|
||||||
|
return b.Bytes(), err
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Which account the agent is logged in as (novox/hq ADR 0183): not in the token, but in the agent's own
|
||||||
|
// state file beside the home, `~/.claude.json` → `oauthAccount`. Read to report and attribute a login;
|
||||||
|
// written, three keys and nothing else, when a licence is switched, so the account Claude Code shows is
|
||||||
|
// the one whose token it now holds.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Identity is an account as the agent's state file names it.
|
||||||
|
type Identity struct {
|
||||||
|
AccountUUID string `json:"accountUuid"`
|
||||||
|
EmailAddress string `json:"emailAddress,omitempty"`
|
||||||
|
OrganizationUUID string `json:"organizationUuid,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readState(path string) (map[string]any, bool) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
dec.UseNumber()
|
||||||
|
var m map[string]any
|
||||||
|
if dec.Decode(&m) != nil || m == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return m, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadIdentity is the account the state file names, or nil — never a guess.
|
||||||
|
func ReadIdentity(path string) *Identity {
|
||||||
|
m, ok := readState(path)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
a, _ := m["oauthAccount"].(map[string]any)
|
||||||
|
uuid, _ := a["accountUuid"].(string)
|
||||||
|
if uuid == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id := &Identity{AccountUUID: uuid}
|
||||||
|
id.EmailAddress, _ = a["emailAddress"].(string)
|
||||||
|
id.OrganizationUUID, _ = a["organizationUuid"].(string)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteIdentity points the state file's account at id, keeping every other key as found; answers whether
|
||||||
|
// the file changed. A file that is there and cannot be read as an object is left alone.
|
||||||
|
func WriteIdentity(path string, id Identity) (bool, error) {
|
||||||
|
m, ok := readState(path)
|
||||||
|
if !ok {
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
m = map[string]any{}
|
||||||
|
}
|
||||||
|
current, _ := m["oauthAccount"].(map[string]any)
|
||||||
|
if current == nil {
|
||||||
|
current = map[string]any{}
|
||||||
|
}
|
||||||
|
e, _ := current["emailAddress"].(string)
|
||||||
|
o, _ := current["organizationUuid"].(string)
|
||||||
|
if current["accountUuid"] == id.AccountUUID && e == id.EmailAddress && o == id.OrganizationUUID {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
current["accountUuid"] = id.AccountUUID
|
||||||
|
current["emailAddress"] = id.EmailAddress
|
||||||
|
current["organizationUuid"] = id.OrganizationUUID
|
||||||
|
m["oauthAccount"] = current
|
||||||
|
raw, err := indented(m)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
tmp := path + ".mesh-tmp"
|
||||||
|
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, os.Rename(tmp, path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// instructionsText is the managed instruction file, generated from the TypeScript renderer it replaced so
|
||||||
|
// the file under the agent's managed directory did not change by a byte when the module moved to Go;
|
||||||
|
// a test holds it to that renderer's own output (testdata/rendered-by-typescript.json).
|
||||||
|
func instructionsText(node, role string) string {
|
||||||
|
return "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `" +
|
||||||
|
node +
|
||||||
|
"`\n- **Role:** " +
|
||||||
|
role +
|
||||||
|
"\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
// claude-code's bundle (novox/hq design 36, ADR 0183, ADR 0206): a binary the node's runtime launches over
|
||||||
|
// stdio as the operator account (ADR 0193) and is the bus for (ADR 0198). It is given its state directory
|
||||||
|
// and two files the mesh renders into it (ADR 0192), beside the runtime's own words.
|
||||||
|
//
|
||||||
|
// At start it renders the agent's managed directory, reports what this node holds as the module's
|
||||||
|
// `holdings` state and again whenever the credentials file changes, watches the licence manager's
|
||||||
|
// `bindings` state for this node and fetches the token when it says so, and watches the module's
|
||||||
|
// `servers` state — every node's MCP server registrations (ADR 0201). node.go holds the logic.
|
||||||
|
//
|
||||||
|
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func say(format string, args ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, "[claude-code] "+format+"\n", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeManaged writes one managed file as root, only when its content changed. From a staged file, never
|
||||||
|
// /dev/stdin: a child's input may be a socket, which /dev/stdin cannot open (found on the first assignment).
|
||||||
|
func writeManaged(name, content string) (string, error) {
|
||||||
|
path := filepath.Join(ManagedDir, name)
|
||||||
|
if was, err := os.ReadFile(path); err == nil && string(was) == content {
|
||||||
|
return name + ": unchanged", nil
|
||||||
|
}
|
||||||
|
staged, err := os.MkdirTemp("", "claude-code-")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(staged)
|
||||||
|
source := filepath.Join(staged, name)
|
||||||
|
if err := os.WriteFile(source, []byte(content), 0o644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
args := []string{"install", "-D", "-m", "0644", source, path}
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
args = append([]string{"sudo", "-n"}, args...)
|
||||||
|
}
|
||||||
|
if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {
|
||||||
|
return "", fmt.Errorf("%s: could not be written to %s (%s); the module writes there through the operator account's passwordless sudo",
|
||||||
|
name, ManagedDir, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return name + ": written", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask is a tool on the bus, through the runtime: its answer is the tool's value.
|
||||||
|
func ask(address string, args any) (json.RawMessage, error) { return stdio.Ask(address, args) }
|
||||||
|
|
||||||
|
// stateOf adapts the SDK's state to what node.go asks of one.
|
||||||
|
type stateOf struct{ s stdio.KeptState }
|
||||||
|
|
||||||
|
func (s stateOf) Put(key string, value any) error { _, err := s.s.Put(key, value); return err }
|
||||||
|
func (s stateOf) Delete(key string) error { return s.s.Delete(key) }
|
||||||
|
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
|
||||||
|
|
||||||
|
// nodesRunningMe is the nodes claude-code runs on, from the controller's list of modules — for the register
|
||||||
|
// tool's question.
|
||||||
|
func nodesRunningMe() ([]string, error) {
|
||||||
|
raw, err := ask("seat:mesh-controller.modules", map[string]any{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var answer struct {
|
||||||
|
Output string `json:"output"`
|
||||||
|
}
|
||||||
|
text := string(raw)
|
||||||
|
if json.Unmarshal(raw, &answer) == nil && answer.Output != "" {
|
||||||
|
text = answer.Output
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
if !strings.HasPrefix(line, "claude-code ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, on, ok := strings.Cut(line, " on ")
|
||||||
|
if !ok || strings.TrimSpace(on) == "nothing" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, n := range strings.Split(on, ",") {
|
||||||
|
if n = strings.TrimSpace(n); n != "" {
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprintOfFile(path string) any {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Fingerprint(string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
func status(p Paths) map[string]any {
|
||||||
|
creds := ReadCredentials(p.credentials())
|
||||||
|
var token any
|
||||||
|
if g := GrantOf(creds); g != nil {
|
||||||
|
token = map[string]any{"fingerprint": Fingerprint(g.AccessToken), "expiresAt": stamp(g.ExpiresAt), "loginWaiting": HoldsLogin(creds)}
|
||||||
|
}
|
||||||
|
var managed []map[string]any
|
||||||
|
for _, f := range []string{"managed-mcp.json", "managed-settings.json", "CLAUDE.md"} {
|
||||||
|
path := filepath.Join(ManagedDir, f)
|
||||||
|
managed = append(managed, map[string]any{"file": path, "fingerprint": fingerprintOfFile(path)})
|
||||||
|
}
|
||||||
|
var licence any
|
||||||
|
var b Binding
|
||||||
|
if readJSON(p.binding(), &b) {
|
||||||
|
licence = b
|
||||||
|
}
|
||||||
|
names := []string{}
|
||||||
|
for n := range Registered(p) {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
return map[string]any{"node": p.Node, "licence": licence, "token": token, "holdings": HoldingsOf(p),
|
||||||
|
"managed": managed, "registered": names}
|
||||||
|
}
|
||||||
|
|
||||||
|
func str(description string) map[string]any {
|
||||||
|
return map[string]any{"type": "string", "description": description}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nodesOf reads the tools' `nodes` argument: absent is this node, "all" every node, else a list.
|
||||||
|
func nodesOf(v any) []string {
|
||||||
|
s, _ := v.(string)
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
switch s {
|
||||||
|
case "":
|
||||||
|
return nil
|
||||||
|
case "all":
|
||||||
|
return []string{"all"}
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, n := range strings.Split(s, ",") {
|
||||||
|
if n = strings.TrimSpace(n); n != "" {
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func tools(p Paths, servers ServerState, view *ServerView) []stdio.Tool {
|
||||||
|
nodesArg := str(`more nodes: "all" for every node running claude-code, or a comma-separated list; absent is this node only`)
|
||||||
|
return []stdio.Tool{
|
||||||
|
{Name: "claude_code_status",
|
||||||
|
Description: "Claude Code on this machine as the mesh configured it: the licence it holds and when its token expires, what it reports holding, the managed files, the MCP servers registered here. Fingerprints only, never a token.",
|
||||||
|
Run: func(map[string]any) (any, error) { return status(p), nil }},
|
||||||
|
{Name: "claude_code_render",
|
||||||
|
Description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here.",
|
||||||
|
Run: func(map[string]any) (any, error) {
|
||||||
|
out, err := RenderNow(p, writeManaged)
|
||||||
|
return map[string]any{"rendered": out}, err
|
||||||
|
}},
|
||||||
|
{Name: "claude_code_pull",
|
||||||
|
Description: "Ask the licence manager for this node's current token now and apply it, rather than waiting for its binding to change.",
|
||||||
|
Run: func(map[string]any) (any, error) { return Pull(p, ask, writeManaged) }},
|
||||||
|
{Name: "claude_code_grant",
|
||||||
|
Description: "For the licence manager (ADR 0206): the full grant in this node's credentials file — a login made here — sealed to the public key given, with the account it belongs to. Nothing when no login is waiting. Never answers a token in the clear.",
|
||||||
|
Input: map[string]any{"public_key": str("the manager's public key, PEM; the grant opens only with its private half")},
|
||||||
|
Run: func(a map[string]any) (any, error) {
|
||||||
|
key, _ := a["public_key"].(string)
|
||||||
|
if !strings.Contains(key, "PUBLIC KEY") {
|
||||||
|
return nil, errors.New("claude_code_grant seals to a public key, and none was given")
|
||||||
|
}
|
||||||
|
return GrantFor(p, key)
|
||||||
|
}},
|
||||||
|
{Name: "claude_code_mcp_list",
|
||||||
|
Description: "The MCP servers registered through this module: those that apply on this node (beside the console, `mesh`, and those set in the module's settings), and every registration on the mesh, by key — `all.<server>` for every node, `<node>.<server>` for one.",
|
||||||
|
Run: func(map[string]any) (any, error) {
|
||||||
|
keys, err := servers.Keys()
|
||||||
|
return map[string]any{"here": Registered(p), "everywhere": keys}, err
|
||||||
|
}},
|
||||||
|
{Name: "claude_code_mcp_register",
|
||||||
|
Description: "Register an MCP server with Claude Code on this node, every node, or a list — an http/sse server by url, or a stdio server by command. Kept on the bus, so a node that joins later takes it too. Never put a secret in env or headers: the mesh refuses one.",
|
||||||
|
Input: map[string]any{
|
||||||
|
"name": str("the server's name: letters, digits, - and _"),
|
||||||
|
"type": str("http, sse or stdio (default stdio when a command is given, http when a url is)"),
|
||||||
|
"url": str("an http or sse server's url"),
|
||||||
|
"command": str("a stdio server's program"),
|
||||||
|
"args": map[string]any{"type": "array", "description": "a stdio server's arguments"},
|
||||||
|
"env": map[string]any{"type": "object", "description": "a stdio server's environment"},
|
||||||
|
"headers": map[string]any{"type": "object", "description": "an http server's headers"},
|
||||||
|
"nodes": nodesArg,
|
||||||
|
},
|
||||||
|
Run: func(a map[string]any) (any, error) {
|
||||||
|
entry := map[string]any{}
|
||||||
|
if t, _ := a["type"].(string); t != "" {
|
||||||
|
entry["type"] = t
|
||||||
|
} else if _, hasURL := a["url"]; hasURL {
|
||||||
|
entry["type"] = "http"
|
||||||
|
} else {
|
||||||
|
entry["type"] = "stdio"
|
||||||
|
}
|
||||||
|
for _, k := range []string{"url", "command", "args", "env", "headers"} {
|
||||||
|
if v, ok := a[k]; ok {
|
||||||
|
entry[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name, _ := a["name"].(string)
|
||||||
|
return RegisterServer(p, Registration{Name: name, Entry: entry, Nodes: nodesOf(a["nodes"])}, servers, view, writeManaged, nodesRunningMe)
|
||||||
|
}},
|
||||||
|
{Name: "claude_code_mcp_unregister",
|
||||||
|
Description: "Remove an MCP server registered through this module, on this node or more.",
|
||||||
|
Input: map[string]any{"name": str("the server's name"), "nodes": nodesArg},
|
||||||
|
Run: func(a map[string]any) (any, error) {
|
||||||
|
name, _ := a["name"].(string)
|
||||||
|
return RegisterServer(p, Registration{Name: name, Nodes: nodesOf(a["nodes"])}, servers, view, writeManaged, nodesRunningMe)
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// persist asks the state again until it answers: its bucket or the bus's grant may arrive after the module.
|
||||||
|
func persist(what string, attempt func() error, done func(refusals int)) {
|
||||||
|
waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
|
||||||
|
for n := 0; ; n++ {
|
||||||
|
err := attempt()
|
||||||
|
if err == nil {
|
||||||
|
done(n)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pause := time.Minute
|
||||||
|
if n < len(waits) {
|
||||||
|
pause = waits[n]
|
||||||
|
}
|
||||||
|
say("%s not yet (%v); asking again in %s", what, err, pause)
|
||||||
|
time.Sleep(pause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
p, launched := PathsFrom(os.Getenv)
|
||||||
|
if !launched {
|
||||||
|
// Outside a launch — a build, a check — it serves nothing and says why.
|
||||||
|
say("not launched by the runtime with this module's words; serving no tools")
|
||||||
|
if err := stdio.Serve("", nil); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := Keypair(p); err != nil {
|
||||||
|
say("this module's key: %v", err)
|
||||||
|
}
|
||||||
|
if out, err := RenderNow(p, writeManaged); err != nil {
|
||||||
|
say("%v", err)
|
||||||
|
} else {
|
||||||
|
for _, line := range out {
|
||||||
|
if !strings.HasSuffix(line, "unchanged") {
|
||||||
|
say("%s", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
servers := stateOf{stdio.State("servers")}
|
||||||
|
view := NewServerView(p)
|
||||||
|
go run(p, view)
|
||||||
|
if err := stdio.Serve("", tools(p, servers, view)); err != nil {
|
||||||
|
say("%v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run is the module's long-running half, beside the tools (ADR 0198).
|
||||||
|
func run(p Paths, view *ServerView) {
|
||||||
|
// Every node's MCP servers: the whole current set first, then each change (ADR 0201).
|
||||||
|
go persist("watching the MCP servers", func() error {
|
||||||
|
return stdio.State("servers").Watch("", func(c stdio.StateChange) error {
|
||||||
|
var value map[string]any
|
||||||
|
_ = json.Unmarshal(c.Value, &value)
|
||||||
|
if done, err := OnServerChange(view, ServerChange{Key: c.Key, Op: c.Op, Value: value}, p, writeManaged); err != nil {
|
||||||
|
say("taking %s %s: %v", c.Op, c.Key, err) // the view took it; the next render writes it
|
||||||
|
} else if done != "" {
|
||||||
|
say("%s", done)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}, func(n int) { say("watching the MCP servers%s", refusals(n)) })
|
||||||
|
|
||||||
|
// What this node holds (ADR 0206): at start — a node already logged in is reported at once — and on
|
||||||
|
// every change of the credentials file, polled, because the file is replaced by rename and a watch on
|
||||||
|
// the old inode would go quiet. Fingerprints and expiries only.
|
||||||
|
holdings := stdio.State("holdings")
|
||||||
|
reported := ""
|
||||||
|
report := func() {
|
||||||
|
now := HoldingsOf(p)
|
||||||
|
raw, _ := json.Marshal(now)
|
||||||
|
if string(raw) == reported {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
persist("reporting what this node holds", func() error { _, err := holdings.Put(p.Node, now); return err }, func(int) {
|
||||||
|
reported = string(raw)
|
||||||
|
account := "no account"
|
||||||
|
if now.Identity != nil && now.Identity.EmailAddress != "" {
|
||||||
|
account = now.Identity.EmailAddress
|
||||||
|
}
|
||||||
|
line := "reported: " + account
|
||||||
|
if now.Kind != nil {
|
||||||
|
line += ", " + *now.Kind
|
||||||
|
}
|
||||||
|
if now.Refresh.Present {
|
||||||
|
line += ", a login waiting"
|
||||||
|
}
|
||||||
|
if now.Licence != nil {
|
||||||
|
line += fmt.Sprintf(", licence %s g%d", *now.Licence, now.Generation)
|
||||||
|
}
|
||||||
|
say("%s", line)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// What this node should hold (ADR 0206): the manager's `bindings` key for this node; a newer
|
||||||
|
// generation is fetched with the seat's `current`, sealed to this module's key.
|
||||||
|
go persist("watching this node's licence binding", func() error {
|
||||||
|
return stdio.State(Manager+".bindings").Watch(p.Node, func(c stdio.StateChange) error {
|
||||||
|
if c.Key != p.Node {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var b *BindingState
|
||||||
|
if c.Op == "put" {
|
||||||
|
b = &BindingState{}
|
||||||
|
if err := json.Unmarshal(c.Value, b); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if done, err := OnBinding(p, b, ask, writeManaged); err != nil {
|
||||||
|
say("fetching this node's token failed: %v", err)
|
||||||
|
} else if done != "" {
|
||||||
|
say("%s", done)
|
||||||
|
}
|
||||||
|
go report()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}, func(n int) { say("watching this node's licence binding%s", refusals(n)) })
|
||||||
|
|
||||||
|
report()
|
||||||
|
var last string
|
||||||
|
for range time.Tick(5 * time.Second) {
|
||||||
|
info, err := os.Stat(p.credentials())
|
||||||
|
now := "absent"
|
||||||
|
if err == nil {
|
||||||
|
now = fmt.Sprintf("%d/%d", info.ModTime().UnixNano(), info.Size())
|
||||||
|
}
|
||||||
|
if now != last {
|
||||||
|
last = now
|
||||||
|
report()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refusals(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(" (after %d refusal(s))", n)
|
||||||
|
}
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// What claude-code does on a node, written against what it is handed — a way to ask a tool on the bus, its
|
||||||
|
// own state, a way to write a managed file — so every path is tested without a bus (novox/hq design 36,
|
||||||
|
// ADR 0183, ADR 0201, ADR 0206).
|
||||||
|
//
|
||||||
|
// Over NATS, and nothing an event: what is current is state, and a secret only ever travels on a request,
|
||||||
|
// sealed to its one recipient.
|
||||||
|
// - What this node holds is the module's `holdings` state, one key per node: the account, the kind,
|
||||||
|
// fingerprints and expiries — never a token. Written at start and on every change of the credentials
|
||||||
|
// file, so the licence manager learns a login, or a node already logged in, from the state alone.
|
||||||
|
// - The grant itself leaves only when the manager asks `claude_code_grant`, sealed to the key it gives.
|
||||||
|
// - What this node should hold is the manager's `bindings` state; a newer generation for this node is
|
||||||
|
// fetched with the seat's `current` verb, sealed to this module's key, and written access-token-only.
|
||||||
|
// - An MCP server registered through this module is a key in its `servers` state — `all.<server>` for
|
||||||
|
// every node, `<node>.<server>` for one — which every node watches.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Seat is the licence manager's role, and Manager the module whose `bindings` state this one reads.
|
||||||
|
const (
|
||||||
|
Seat = "anthropic-licence-manager"
|
||||||
|
Manager = "claude-licence-manager"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeatVerb is a seat's verb as the runtime addresses it: a role, not a module.
|
||||||
|
func SeatVerb(verb string) string { return "seat:" + Seat + "." + verb }
|
||||||
|
|
||||||
|
// Fingerprint names a token without being one: the first 16 hex of its SHA-256, as the manager computes it.
|
||||||
|
func Fingerprint(s string) string {
|
||||||
|
sum := sha256.Sum256([]byte(s))
|
||||||
|
return "sha256:" + hex.EncodeToString(sum[:])[:16]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paths are where this node's files are, from the module's words (ADR 0192).
|
||||||
|
type Paths struct {
|
||||||
|
State, Facts, Settings, Home, Node string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathsFrom reads them, or answers false outside a launch.
|
||||||
|
func PathsFrom(env func(string) string) (Paths, bool) {
|
||||||
|
p := Paths{State: env("MESH_CLAUDE_CODE_STATE"), Facts: env("MESH_CLAUDE_CODE_FACTS"),
|
||||||
|
Settings: env("MESH_CLAUDE_CODE_SETTINGS"), Home: env("MESH_OPERATOR_HOME"), Node: env("MESH_NODE")}
|
||||||
|
return p, p.State != "" && p.Facts != "" && p.Settings != "" && p.Home != "" && p.Node != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Paths) credentials() string { return filepath.Join(p.Home, ".claude", ".credentials.json") }
|
||||||
|
func (p Paths) account() string { return filepath.Join(p.Home, ".claude.json") }
|
||||||
|
func (p Paths) binding() string { return filepath.Join(p.State, "licence.json") }
|
||||||
|
func (p Paths) apiKey() string { return filepath.Join(p.State, "api-key") }
|
||||||
|
func (p Paths) helper() string { return filepath.Join(p.State, "api-key-helper") }
|
||||||
|
func (p Paths) registry() string { return filepath.Join(p.State, "mcp-servers.json") }
|
||||||
|
|
||||||
|
// Ask is a tool on the bus: its address and arguments in, its JSON answer out.
|
||||||
|
type Ask func(address string, args any) (json.RawMessage, error)
|
||||||
|
|
||||||
|
// WriteManaged writes one managed file and answers what happened.
|
||||||
|
type WriteManaged func(name, content string) (string, error)
|
||||||
|
|
||||||
|
func readJSON(path string, into any) bool {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
return err == nil && json.Unmarshal(raw, into) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keypair is this module's own, made once in its state; the TypeScript module's files are kept, so a node
|
||||||
|
// moving to this binary keeps the key it had.
|
||||||
|
func Keypair(p Paths) (KeyPair, error) {
|
||||||
|
priv, pub := filepath.Join(p.State, "key.pem"), filepath.Join(p.State, "key.pub.pem")
|
||||||
|
if _, err := os.Stat(priv); errors.Is(err, os.ErrNotExist) {
|
||||||
|
k, err := GenerateKeyPair()
|
||||||
|
if err != nil {
|
||||||
|
return KeyPair{}, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(priv, []byte(k.PrivateKey), 0o600); err != nil {
|
||||||
|
return KeyPair{}, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(pub, []byte(k.PublicKey), 0o644); err != nil {
|
||||||
|
return KeyPair{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a, err1 := os.ReadFile(priv)
|
||||||
|
b, err2 := os.ReadFile(pub)
|
||||||
|
return KeyPair{PrivateKey: string(a), PublicKey: string(b)}, errors.Join(err1, err2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registered is what applies here of the servers registered through this module.
|
||||||
|
func Registered(p Paths) Servers {
|
||||||
|
s := Servers{}
|
||||||
|
readJSON(p.registry(), &s)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderNow writes the managed directory from the facts, the settings, the licence held and the servers
|
||||||
|
// registered here.
|
||||||
|
func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
||||||
|
var facts Facts
|
||||||
|
if !readJSON(p.Facts, &facts) || facts.Console == "" {
|
||||||
|
return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts)
|
||||||
|
}
|
||||||
|
var settings Settings
|
||||||
|
readJSON(p.Settings, &settings)
|
||||||
|
var binding *Binding
|
||||||
|
var b Binding
|
||||||
|
if readJSON(p.binding(), &b) {
|
||||||
|
binding = &b
|
||||||
|
}
|
||||||
|
files := Render(facts, settings, binding, p.helper(), Registered(p))
|
||||||
|
names := make([]string, 0, len(files))
|
||||||
|
for n := range files {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
var out []string
|
||||||
|
for _, n := range names {
|
||||||
|
line, err := write(n, files[n])
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the licence ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// BindingState is what the manager's `bindings` state says one consumer should hold (ADR 0206).
|
||||||
|
type BindingState struct {
|
||||||
|
Licence string `json:"licence"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Generation int64 `json:"generation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed.
|
||||||
|
type Current struct {
|
||||||
|
Licence string `json:"licence"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Generation int64 `json:"generation"`
|
||||||
|
Sealed *SealedBox `json:"sealed"`
|
||||||
|
Identity *Identity `json:"identity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holdings is what this node holds, as the `holdings` state carries it (ADR 0206): enough for the manager
|
||||||
|
// to tell a login it has not adopted from one it has, and never a token — fingerprints and expiries only.
|
||||||
|
type Holdings struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Identity *Identity `json:"identity"`
|
||||||
|
Kind *string `json:"kind"`
|
||||||
|
Refresh struct {
|
||||||
|
Present bool `json:"present"`
|
||||||
|
Fingerprint *string `json:"fingerprint"`
|
||||||
|
ExpiresAt *int64 `json:"expiresAt"`
|
||||||
|
} `json:"refresh"`
|
||||||
|
Access *struct {
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
ExpiresAt int64 `json:"expiresAt"`
|
||||||
|
} `json:"access"`
|
||||||
|
Licence *string `json:"licence"`
|
||||||
|
Generation int64 `json:"generation"`
|
||||||
|
ChangedAt *string `json:"changedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HoldingsOf is what this node holds now.
|
||||||
|
func HoldingsOf(p Paths) Holdings {
|
||||||
|
h := Holdings{Node: p.Node, Identity: ReadIdentity(p.account())}
|
||||||
|
creds := ReadCredentials(p.credentials())
|
||||||
|
if info, err := os.Stat(p.credentials()); err == nil {
|
||||||
|
at := info.ModTime().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||||
|
h.ChangedAt = &at
|
||||||
|
}
|
||||||
|
if rt := RefreshTokenOf(creds); rt != "" {
|
||||||
|
fp := Fingerprint(rt)
|
||||||
|
h.Refresh.Present, h.Refresh.Fingerprint = true, &fp
|
||||||
|
}
|
||||||
|
if v, ok := number(creds.oauth()["refreshTokenExpiresAt"]); ok {
|
||||||
|
h.Refresh.ExpiresAt = &v
|
||||||
|
}
|
||||||
|
if g := GrantOf(creds); g != nil {
|
||||||
|
h.Access = &struct {
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
ExpiresAt int64 `json:"expiresAt"`
|
||||||
|
}{Fingerprint(g.AccessToken), g.ExpiresAt}
|
||||||
|
}
|
||||||
|
kind := ""
|
||||||
|
if _, err := os.Stat(p.apiKey()); err == nil {
|
||||||
|
kind = "api-key"
|
||||||
|
} else if h.Access != nil {
|
||||||
|
kind = "subscription"
|
||||||
|
}
|
||||||
|
if kind != "" {
|
||||||
|
h.Kind = &kind
|
||||||
|
}
|
||||||
|
var applied Binding
|
||||||
|
if readJSON(p.binding(), &applied) && applied.Licence != "" {
|
||||||
|
h.Licence, h.Generation = &applied.Licence, applied.Generation
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantAnswer is what `claude_code_grant` answers: a login sealed to the key given, or nothing waiting.
|
||||||
|
type GrantAnswer struct {
|
||||||
|
Sealed *SealedBox `json:"sealed,omitempty"`
|
||||||
|
Identity *Identity `json:"identity,omitempty"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
Waiting *bool `json:"waiting,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantFor is the full grant in the credentials file sealed to the manager's key — the one time a refresh
|
||||||
|
// token leaves this node, for the manager to adopt by refreshing it (ADR 0206). Nothing waiting when the
|
||||||
|
// file holds no refresh token.
|
||||||
|
func GrantFor(p Paths, managerPublicKey string) (GrantAnswer, error) {
|
||||||
|
creds := ReadCredentials(p.credentials())
|
||||||
|
rt := RefreshTokenOf(creds)
|
||||||
|
if rt == "" {
|
||||||
|
no := false
|
||||||
|
return GrantAnswer{Waiting: &no}, nil
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(creds.oauth())
|
||||||
|
if err != nil {
|
||||||
|
return GrantAnswer{}, err
|
||||||
|
}
|
||||||
|
box, err := Seal(string(raw), managerPublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return GrantAnswer{}, err
|
||||||
|
}
|
||||||
|
return GrantAnswer{Sealed: &box, Identity: ReadIdentity(p.account()), Fingerprint: Fingerprint(rt)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull asks the seat for this node's current token and applies it.
|
||||||
|
func Pull(p Paths, ask Ask, write WriteManaged) (map[string]any, error) {
|
||||||
|
keys, err := Keypair(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
raw, err := ask(SeatVerb("current"), map[string]any{"consumer": p.Node, "public_key": keys.PublicKey})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var c Current
|
||||||
|
if err := json.Unmarshal(raw, &c); err != nil || c.Sealed == nil {
|
||||||
|
return map[string]any{"applied": false, "reason": "the seat holds no licence for this node"}, nil
|
||||||
|
}
|
||||||
|
return Apply(p, c, write)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnBinding takes a change to this node's key in the manager's `bindings` state (ADR 0206): the token is
|
||||||
|
// fetched when the generation is newer than the one applied. A released binding keeps the last token,
|
||||||
|
// which lives hours, and says so.
|
||||||
|
func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
|
||||||
|
if b == nil {
|
||||||
|
return "this node's binding was released; it keeps its last token until it expires", nil
|
||||||
|
}
|
||||||
|
var applied Binding
|
||||||
|
if readJSON(p.binding(), &applied) && applied.Generation >= b.Generation {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
out, err := Pull(p, ask, write)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(out)
|
||||||
|
return string(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply applies what the seat handed over. A switch replaces the grant whole and cleans up after the old
|
||||||
|
// licence; whatever it is, the file is written without a refresh token, so the agent here never refreshes.
|
||||||
|
func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
|
||||||
|
keys, err := Keypair(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
plain, err := Open(*c.Sealed, keys.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var previous Binding
|
||||||
|
had := readJSON(p.binding(), &previous)
|
||||||
|
switched := !had || previous.Licence != c.Licence
|
||||||
|
out := map[string]any{"applied": true, "licence": c.Licence, "kind": c.Kind, "switched": switched}
|
||||||
|
if c.Kind == "api-key" {
|
||||||
|
if err := os.WriteFile(p.apiKey(), []byte(strings.TrimSpace(plain)+"\n"), 0o600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p.helper(), []byte("#!/bin/sh\nexec cat '"+p.apiKey()+"'\n"), 0o700); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = os.Chmod(p.helper(), 0o700)
|
||||||
|
} else {
|
||||||
|
var g Grant
|
||||||
|
if err := json.Unmarshal([]byte(plain), &g); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
local := ReadCredentials(p.credentials())
|
||||||
|
// A login waiting here was handed to the manager first (ADR 0206): what comes back is its successor,
|
||||||
|
// and the refresh token in the file is the one the manager just spent.
|
||||||
|
d := DecideApply(GrantOf(local), g, switched || HoldsLogin(local))
|
||||||
|
if d.Apply {
|
||||||
|
next := WithGrant(local, g)
|
||||||
|
if switched {
|
||||||
|
next = ReplacedBy(local, g)
|
||||||
|
}
|
||||||
|
if err := WriteCredentials(p.credentials(), next); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out = map[string]any{"applied": false, "licence": c.Licence, "reason": d.Reason}
|
||||||
|
}
|
||||||
|
// Away from the API key: it goes, with its helper.
|
||||||
|
_ = os.Remove(p.apiKey())
|
||||||
|
_ = os.Remove(p.helper())
|
||||||
|
}
|
||||||
|
if switched && c.Identity != nil && c.Identity.AccountUUID != "" {
|
||||||
|
changed, err := WriteIdentity(p.account(), *c.Identity)
|
||||||
|
if err == nil {
|
||||||
|
out["account"] = map[bool]string{true: "updated", false: "unchanged"}[changed]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gen := c.Generation
|
||||||
|
if gen == 0 {
|
||||||
|
gen = previous.Generation
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(Binding{Licence: c.Licence, Kind: c.Kind, Generation: gen})
|
||||||
|
if err := os.WriteFile(p.binding(), append(raw, '\n'), 0o600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// The key-helper comes or goes with the licence's kind.
|
||||||
|
if rendered, err := RenderNow(p, write); err != nil {
|
||||||
|
out["rendered"] = map[string]any{"failed": err.Error()}
|
||||||
|
} else {
|
||||||
|
out["rendered"] = rendered
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- MCP servers ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Registration is a server registered (or, with no entry, unregistered) through this module.
|
||||||
|
type Registration struct {
|
||||||
|
Name string
|
||||||
|
Entry map[string]any
|
||||||
|
// Nodes: nil for this node, ["all"] for every node running the module, or a list.
|
||||||
|
Nodes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerState is the `servers` state as this module reaches it through the runtime.
|
||||||
|
type ServerState interface {
|
||||||
|
Put(key string, value any) error
|
||||||
|
Delete(key string) error
|
||||||
|
Keys() ([]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerChange is one change to the `servers` state, as a watch hands it over.
|
||||||
|
type ServerChange struct {
|
||||||
|
Key string
|
||||||
|
Op string // put | delete
|
||||||
|
Value map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyOf is the key a registration lives at: `all.<server>` for every node, `<node>.<server>` for one.
|
||||||
|
func KeyOf(scope, name string) string { return scope + "." + name }
|
||||||
|
|
||||||
|
// ServerView is what this node takes from the `servers` state: the entries for every node and for this
|
||||||
|
// one, kept in memory from the watch and written through to the module's own file whenever what applies
|
||||||
|
// here changes, so the managed directory renders without the bus.
|
||||||
|
type ServerView struct {
|
||||||
|
p Paths
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[string]map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServerView is an empty view for this node.
|
||||||
|
func NewServerView(p Paths) *ServerView {
|
||||||
|
return &ServerView{p: p, entries: map[string]map[string]any{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take takes one change, and answers whether what applies to this node changed.
|
||||||
|
func (v *ServerView) Take(c ServerChange) bool {
|
||||||
|
scope, name, ok := strings.Cut(c.Key, ".")
|
||||||
|
if !ok || scope == "" || (scope != "all" && scope != v.p.Node) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v.mu.Lock()
|
||||||
|
if c.Op == "put" && c.Value != nil && EntryProblem(name, c.Value) == "" {
|
||||||
|
v.entries[c.Key] = c.Value
|
||||||
|
} else {
|
||||||
|
delete(v.entries, c.Key)
|
||||||
|
}
|
||||||
|
v.mu.Unlock()
|
||||||
|
return v.writeThrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effective is what applies here: every node's entries, with this node's own laid over them by name.
|
||||||
|
func (v *ServerView) Effective() Servers {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
out := Servers{}
|
||||||
|
for _, scope := range []string{"all", v.p.Node} {
|
||||||
|
for key, entry := range v.entries {
|
||||||
|
if name, ok := strings.CutPrefix(key, scope+"."); ok {
|
||||||
|
out[name] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *ServerView) writeThrough() bool {
|
||||||
|
now, _ := indented(v.Effective())
|
||||||
|
before, _ := os.ReadFile(v.p.registry())
|
||||||
|
if string(before) == string(now) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(v.p.registry(), now, 0o600)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnServerChange takes a change from the watch, and renders when what applies here changed.
|
||||||
|
func OnServerChange(v *ServerView, c ServerChange, p Paths, write WriteManaged) (string, error) {
|
||||||
|
if !v.Take(c) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if _, err := RenderNow(p, write); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
what := "registered"
|
||||||
|
if c.Op != "put" {
|
||||||
|
what = "unregistered"
|
||||||
|
}
|
||||||
|
return what + " " + c.Key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterServer registers (or, with no entry, unregisters) a server: a put (or delete) per scope in the
|
||||||
|
// `servers` state, taken into this node's view at once so the answer says what it did here; every other
|
||||||
|
// node takes it from its watch, and a node that joins later from the current state.
|
||||||
|
func RegisterServer(p Paths, r Registration, servers ServerState, v *ServerView, write WriteManaged,
|
||||||
|
others func() ([]string, error)) (map[string]any, error) {
|
||||||
|
if r.Entry != nil {
|
||||||
|
if problem := EntryProblem(r.Name, r.Entry); problem != "" {
|
||||||
|
return map[string]any{"registered": false, "reason": problem}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scopes := r.Nodes
|
||||||
|
if len(scopes) == 0 {
|
||||||
|
scopes = []string{p.Node}
|
||||||
|
}
|
||||||
|
// Compared before and after rather than read from Take: this node's own watch may hand the view the
|
||||||
|
// same change first, and then Take here finds nothing new although this call made it.
|
||||||
|
before, _ := json.Marshal(v.Effective())
|
||||||
|
for _, scope := range scopes {
|
||||||
|
key := KeyOf(scope, r.Name)
|
||||||
|
var err error
|
||||||
|
if r.Entry != nil {
|
||||||
|
err = servers.Put(key, r.Entry)
|
||||||
|
} else {
|
||||||
|
err = servers.Delete(key)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
op := "put"
|
||||||
|
if r.Entry == nil {
|
||||||
|
op = "delete"
|
||||||
|
}
|
||||||
|
v.Take(ServerChange{Key: key, Op: op, Value: r.Entry})
|
||||||
|
}
|
||||||
|
after, _ := json.Marshal(v.Effective())
|
||||||
|
changed := string(before) != string(after)
|
||||||
|
here := false
|
||||||
|
for _, s := range scopes {
|
||||||
|
here = here || s == "all" || s == p.Node
|
||||||
|
}
|
||||||
|
verb := "registered"
|
||||||
|
if r.Entry == nil {
|
||||||
|
verb = "unregistered"
|
||||||
|
}
|
||||||
|
answer := map[string]any{verb: r.Name, "on": scopes}
|
||||||
|
switch {
|
||||||
|
case !here:
|
||||||
|
answer["here"] = "not this node"
|
||||||
|
case changed:
|
||||||
|
answer["here"] = "changed"
|
||||||
|
default:
|
||||||
|
answer["here"] = "already so"
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
rendered, err := RenderNow(p, write)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
answer["rendered"] = rendered
|
||||||
|
}
|
||||||
|
if r.Entry == nil {
|
||||||
|
if _, still := v.Effective()[r.Name]; still {
|
||||||
|
answer["still"] = r.Name + " still applies here from another registration (for every node, or for this one); unregister that too"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(r.Nodes) == 0 {
|
||||||
|
// The question the operator wanted asked: here only, or more?
|
||||||
|
var elsewhere []string
|
||||||
|
if nodes, err := others(); err == nil {
|
||||||
|
for _, n := range nodes {
|
||||||
|
if n != p.Node {
|
||||||
|
elsewhere = append(elsewhere, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(elsewhere) > 0 {
|
||||||
|
answer["also"] = fmt.Sprintf("claude-code also runs on %s. To %s it there too, call again with nodes: \"all\" or a list of those nodes.",
|
||||||
|
strings.Join(elsewhere, ", "), map[bool]string{true: "register", false: "unregister"}[r.Entry != nil])
|
||||||
|
} else {
|
||||||
|
answer["also"] = "To do the same on every node running claude-code, call again with nodes: \"all\"."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return answer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stamp is a time as the status answers it.
|
||||||
|
func stamp(ms int64) string { return time.UnixMilli(ms).UTC().Format(time.RFC3339) }
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// What the module writes into the agent's machine-wide managed directory (novox/hq design 36 §1–§4).
|
||||||
|
// Pure: composed from the facts the mesh rendered, the settings the operator set and the licence the node
|
||||||
|
// holds, so what lands under /etc is tested without a machine.
|
||||||
|
//
|
||||||
|
// Three files, owned whole by this module:
|
||||||
|
//
|
||||||
|
// managed-mcp.json the tool servers every session loads: the mesh's console as `mesh`, and the
|
||||||
|
// servers the operator declared or registered through this module. Exclusive by
|
||||||
|
// the vendor's rule — a server not listed here does not load (operator's choice,
|
||||||
|
// 2026-10-03).
|
||||||
|
// managed-settings.json the mesh's keys only: the repositories' attribution convention, the claude.ai
|
||||||
|
// connectors kept beside the managed servers, and — for an API-key licence only —
|
||||||
|
// the key-helper. A person's preferences are theirs.
|
||||||
|
// CLAUDE.md how a session on this mesh works, who this node is, the conventions.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ManagedDir is the agent's machine-wide managed directory.
|
||||||
|
const ManagedDir = "/etc/claude-code"
|
||||||
|
|
||||||
|
const meshEntry = "mesh"
|
||||||
|
|
||||||
|
// Facts are what the mesh rendered for this node.
|
||||||
|
type Facts struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Console string `json:"console"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings are the operator's, for the mesh or this node.
|
||||||
|
type Settings struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
MCPServers map[string]map[string]any `json:"mcp_servers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binding is the licence this node holds, as it was last applied.
|
||||||
|
type Binding struct {
|
||||||
|
Licence string `json:"licence"`
|
||||||
|
Kind string `json:"kind"` // subscription | api-key
|
||||||
|
Generation int64 `json:"generation,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Servers are tool server entries by name, in the vendor's `.mcp.json` shape.
|
||||||
|
type Servers map[string]map[string]any
|
||||||
|
|
||||||
|
var serverName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||||
|
|
||||||
|
// EntryProblem says why the vendor's managed file would not take an entry, or "" when it would: a name of
|
||||||
|
// letters, digits, `-` and `_`, and an http/sse server with a url or a stdio server with a command.
|
||||||
|
func EntryProblem(name string, entry map[string]any) string {
|
||||||
|
if !serverName.MatchString(name) {
|
||||||
|
return fmt.Sprintf("%q is not a name the agent takes: letters, digits, - and _", name)
|
||||||
|
}
|
||||||
|
if name == meshEntry {
|
||||||
|
return fmt.Sprintf("%q is the mesh's own entry", meshEntry)
|
||||||
|
}
|
||||||
|
kind, _ := entry["type"].(string)
|
||||||
|
if kind == "" {
|
||||||
|
kind = "stdio"
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case "http", "sse", "streamable-http":
|
||||||
|
if u, _ := entry["url"].(string); u != "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("an %s server needs a url", kind)
|
||||||
|
case "stdio":
|
||||||
|
if c, _ := entry["command"].(string); c != "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "a stdio server needs a command"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%q is not a server type the agent knows (http, sse, stdio)", kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonFile is a value as the managed files are written: two-space indent, a trailing newline, nothing
|
||||||
|
// escaped that need not be.
|
||||||
|
func jsonFile(v any) string {
|
||||||
|
var b bytes.Buffer
|
||||||
|
enc := json.NewEncoder(&b)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
_ = enc.Encode(v)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render composes the three files. registered — what was registered through this module and applies
|
||||||
|
// here — is laid over the servers the operator set in its settings.
|
||||||
|
func Render(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers) map[string]string {
|
||||||
|
servers := map[string]any{}
|
||||||
|
for _, layer := range []map[string]map[string]any{settings.MCPServers, registered} {
|
||||||
|
for name, entry := range layer {
|
||||||
|
if EntryProblem(name, entry) != "" {
|
||||||
|
continue // the mesh's own entry, or one the agent would refuse
|
||||||
|
}
|
||||||
|
servers[name] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console}
|
||||||
|
|
||||||
|
managed := map[string]any{"attribution": map[string]any{"commit": "", "pr": ""}, "allowAllClaudeAiMcps": true}
|
||||||
|
if binding != nil && binding.Kind == "api-key" {
|
||||||
|
managed["apiKeyHelper"] = helperPath
|
||||||
|
}
|
||||||
|
role := strings.TrimSpace(settings.Role)
|
||||||
|
if role == "" {
|
||||||
|
role = "not stated — set it in this module's settings for the node"
|
||||||
|
}
|
||||||
|
return map[string]string{
|
||||||
|
"managed-mcp.json": jsonFile(map[string]any{"mcpServers": servers}),
|
||||||
|
"managed-settings.json": jsonFile(managed),
|
||||||
|
"CLAUDE.md": instructionsText(facts.Node, role),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Sealing to one recipient (novox/hq ADR 0183, ADR 0206): the manager seals what it hands a consumer to
|
||||||
|
// the key that consumer sent, and a node seals a waiting login to the key the manager gives. The same box
|
||||||
|
// the agent module's TypeScript makes and opens, byte for byte — X25519 for the agreement, HKDF-SHA256 for
|
||||||
|
// the key, AES-256-GCM for the box — so `testdata/sealed-by-typescript.json` is opened here, and a test
|
||||||
|
// reopens what this seals with the same derivation.
|
||||||
|
//
|
||||||
|
// A box is `{ v: 1, eph, iv, tag, ct }`, every field base64; `eph` is the one-time public key as SPKI DER,
|
||||||
|
// and the key is bound to it and to the recipient's raw public key, so a box cannot be re-addressed.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/ecdh"
|
||||||
|
"crypto/hkdf"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SealedBox is a value sealed to one recipient.
|
||||||
|
type SealedBox struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Eph string `json:"eph"`
|
||||||
|
IV string `json:"iv"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Ct string `json:"ct"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyPair is a recipient's keypair as the two PEM strings it is kept and sent as.
|
||||||
|
type KeyPair struct {
|
||||||
|
PublicKey string `json:"publicKey"`
|
||||||
|
PrivateKey string `json:"privateKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const sealInfo = "novox-mesh sealed box v1"
|
||||||
|
|
||||||
|
// GenerateKeyPair makes an X25519 keypair, PEM-encoded as the agent module's are.
|
||||||
|
func GenerateKeyPair() (KeyPair, error) {
|
||||||
|
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return KeyPair{}, err
|
||||||
|
}
|
||||||
|
pubDER, err := x509.MarshalPKIXPublicKey(priv.PublicKey())
|
||||||
|
if err != nil {
|
||||||
|
return KeyPair{}, err
|
||||||
|
}
|
||||||
|
privDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
return KeyPair{}, err
|
||||||
|
}
|
||||||
|
return KeyPair{
|
||||||
|
PublicKey: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})),
|
||||||
|
PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicFromPEM(p string) (*ecdh.PublicKey, error) {
|
||||||
|
block, _ := pem.Decode([]byte(p))
|
||||||
|
if block == nil {
|
||||||
|
return nil, errors.New("not a PEM public key")
|
||||||
|
}
|
||||||
|
k, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pub, ok := k.(*ecdh.PublicKey)
|
||||||
|
if !ok || pub.Curve() != ecdh.X25519() {
|
||||||
|
return nil, errors.New("not an X25519 public key")
|
||||||
|
}
|
||||||
|
return pub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func privateFromPEM(p string) (*ecdh.PrivateKey, error) {
|
||||||
|
block, _ := pem.Decode([]byte(p))
|
||||||
|
if block == nil {
|
||||||
|
return nil, errors.New("not a PEM private key")
|
||||||
|
}
|
||||||
|
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
priv, ok := k.(*ecdh.PrivateKey)
|
||||||
|
if !ok || priv.Curve() != ecdh.X25519() {
|
||||||
|
return nil, errors.New("not an X25519 private key")
|
||||||
|
}
|
||||||
|
return priv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boxKey(secret, ephDER, recipientRaw []byte) ([]byte, error) {
|
||||||
|
salt := append(append([]byte{}, ephDER...), recipientRaw...)
|
||||||
|
return hkdf.Key(sha256.New, secret, salt, sealInfo, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seal seals plaintext to the recipient's public key.
|
||||||
|
func Seal(plaintext, recipientPEM string) (SealedBox, error) {
|
||||||
|
recipient, err := publicFromPEM(recipientPEM)
|
||||||
|
if err != nil {
|
||||||
|
return SealedBox{}, err
|
||||||
|
}
|
||||||
|
eph, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return SealedBox{}, err
|
||||||
|
}
|
||||||
|
secret, err := eph.ECDH(recipient)
|
||||||
|
if err != nil {
|
||||||
|
return SealedBox{}, err
|
||||||
|
}
|
||||||
|
ephDER, err := x509.MarshalPKIXPublicKey(eph.PublicKey())
|
||||||
|
if err != nil {
|
||||||
|
return SealedBox{}, err
|
||||||
|
}
|
||||||
|
key, err := boxKey(secret, ephDER, recipient.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
return SealedBox{}, err
|
||||||
|
}
|
||||||
|
gcm, err := newGCM(key)
|
||||||
|
if err != nil {
|
||||||
|
return SealedBox{}, err
|
||||||
|
}
|
||||||
|
iv := make([]byte, 12)
|
||||||
|
if _, err := rand.Read(iv); err != nil {
|
||||||
|
return SealedBox{}, err
|
||||||
|
}
|
||||||
|
out := gcm.Seal(nil, iv, []byte(plaintext), nil)
|
||||||
|
ct, tag := out[:len(out)-gcm.Overhead()], out[len(out)-gcm.Overhead():]
|
||||||
|
b64 := base64.StdEncoding.EncodeToString
|
||||||
|
return SealedBox{V: 1, Eph: b64(ephDER), IV: b64(iv), Tag: b64(tag), Ct: b64(ct)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens a box with the recipient's private key; it fails for a box to another key or one tampered with.
|
||||||
|
func Open(box SealedBox, privatePEM string) (string, error) {
|
||||||
|
if box.V != 1 {
|
||||||
|
return "", errors.New("not a sealed box this module can open")
|
||||||
|
}
|
||||||
|
priv, err := privateFromPEM(privatePEM)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
d := base64.StdEncoding.DecodeString
|
||||||
|
ephDER, err := d(box.Eph)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("the box's eph: %w", err)
|
||||||
|
}
|
||||||
|
ephKey, err := x509.ParsePKIXPublicKey(ephDER)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
eph, ok := ephKey.(*ecdh.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
return "", errors.New("the box's eph is not an X25519 key")
|
||||||
|
}
|
||||||
|
secret, err := priv.ECDH(eph)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
key, err := boxKey(secret, ephDER, priv.PublicKey().Bytes())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
iv, err1 := d(box.IV)
|
||||||
|
tag, err2 := d(box.Tag)
|
||||||
|
ct, err3 := d(box.Ct)
|
||||||
|
if err := errors.Join(err1, err2, err3); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, err := newGCM(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
plain, err := gcm.Open(nil, iv, append(ct, tag...), nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("the box does not open with this key")
|
||||||
|
}
|
||||||
|
return string(plain), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cipher.NewGCM(block)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"facts": {
|
||||||
|
"node": "workstation",
|
||||||
|
"console": "http://127.0.0.1:4270/mcp"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"role": "the laptop",
|
||||||
|
"mcp_servers": {
|
||||||
|
"search": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://s.example/mcp"
|
||||||
|
},
|
||||||
|
"docs": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "docs-mcp",
|
||||||
|
"args": [
|
||||||
|
"--x"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"registered": {
|
||||||
|
"anton": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "node",
|
||||||
|
"args": [
|
||||||
|
"/a/b.js"
|
||||||
|
],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"withKey": {
|
||||||
|
"managed-mcp.json": "{\n \"mcpServers\": {\n \"anton\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\n \"/a/b.js\"\n ],\n \"env\": {}\n },\n \"docs\": {\n \"type\": \"stdio\",\n \"command\": \"docs-mcp\",\n \"args\": [\n \"--x\"\n ]\n },\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n },\n \"search\": {\n \"type\": \"http\",\n \"url\": \"https://s.example/mcp\"\n }\n }\n}\n",
|
||||||
|
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true,\n \"apiKeyHelper\": \"/state/api-key-helper\"\n}\n",
|
||||||
|
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||||
|
},
|
||||||
|
"plain": {
|
||||||
|
"managed-mcp.json": "{\n \"mcpServers\": {\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n }\n }\n}\n",
|
||||||
|
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true\n}\n",
|
||||||
|
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module claude-code
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"module": "claude-code",
|
||||||
|
"version": "1",
|
||||||
|
"slug": "agent",
|
||||||
|
"capabilities": [
|
||||||
|
"package-manager"
|
||||||
|
],
|
||||||
|
"requires": [
|
||||||
|
"mcp-endpoint"
|
||||||
|
],
|
||||||
|
"binds": {
|
||||||
|
"mcp-endpoint": "${dir:state}/mcp-endpoint.json"
|
||||||
|
},
|
||||||
|
"state": [
|
||||||
|
"servers",
|
||||||
|
"holdings"
|
||||||
|
],
|
||||||
|
"reads": [
|
||||||
|
"claude-licence-manager.bindings"
|
||||||
|
],
|
||||||
|
"tools": [
|
||||||
|
"claude_code_status",
|
||||||
|
"claude_code_render",
|
||||||
|
"claude_code_pull",
|
||||||
|
"claude_code_grant",
|
||||||
|
"claude_code_mcp_list",
|
||||||
|
"claude_code_mcp_register",
|
||||||
|
"claude_code_mcp_unregister"
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "package",
|
||||||
|
"type": "package",
|
||||||
|
"package": "claude-code"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "managed",
|
||||||
|
"type": "directory",
|
||||||
|
"path": "/etc/claude-code",
|
||||||
|
"mode": "0755"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "agent-home",
|
||||||
|
"type": "directory",
|
||||||
|
"path": "${machine:account-home}/.claude",
|
||||||
|
"mode": "0700",
|
||||||
|
"owner": "${machine:account}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "state",
|
||||||
|
"type": "directory",
|
||||||
|
"mode": "0700",
|
||||||
|
"owner": "${machine:account}",
|
||||||
|
"place": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "facts",
|
||||||
|
"type": "file",
|
||||||
|
"path": "${dir:state}/facts.json",
|
||||||
|
"mode": "0600",
|
||||||
|
"owner": "${machine:account}",
|
||||||
|
"content": "{\n \"node\": \"${machine:name}\",\n \"console\": \"http://127.0.0.1:${bound:mcp-endpoint:port}/mcp\"\n}\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "settings",
|
||||||
|
"type": "file",
|
||||||
|
"path": "${dir:state}/settings.json",
|
||||||
|
"mode": "0600",
|
||||||
|
"owner": "${machine:account}",
|
||||||
|
"merge": "json",
|
||||||
|
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {}\n}\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "tools",
|
||||||
|
"kind": "bundle",
|
||||||
|
"language": "go",
|
||||||
|
"system": "arch",
|
||||||
|
"from": "cmd/claude-code",
|
||||||
|
"binary": "claude-code",
|
||||||
|
"loads": [
|
||||||
|
"claude-code"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"MESH_CLAUDE_CODE_STATE": "${dir:state}",
|
||||||
|
"MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json",
|
||||||
|
"MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# claude-licence-manager
|
||||||
|
|
||||||
|
Holds the `anthropic-licence-manager` seat: every Anthropic licence the mesh has, kept alive by one
|
||||||
|
rotation source, and handed to each consumer sealed (novox/hq ADR 0183, ADR 0206, design 39).
|
||||||
|
|
||||||
|
## How a licence comes to exist
|
||||||
|
|
||||||
|
Nothing is configured. Every node running `claude-code` reports what it holds as that module's
|
||||||
|
`holdings` state — the account, fingerprints and expiries, never a token. This module reads every report
|
||||||
|
when it starts and watches them:
|
||||||
|
|
||||||
|
1. A report with a refresh token it does not hold is a **candidate**.
|
||||||
|
2. It asks that node's `claude_code_grant`, giving its public key, and receives the grant sealed to it.
|
||||||
|
3. **It refreshes it.** If the vendor exchanges the token, the grant is this module's — encrypted at rest
|
||||||
|
with the key the vault made for it — and from then on it is the only refresher. If not, the candidate
|
||||||
|
is recorded dead and nothing is adopted.
|
||||||
|
4. Several nodes logged in to one account: newest login first; the rest are never exchanged.
|
||||||
|
5. A node reporting that account and bound to nothing is bound to it.
|
||||||
|
|
||||||
|
Each node is then handed an access token only, so the agent there never refreshes, and a refresh token
|
||||||
|
appearing on a node later can only be a person's login — which wins if it refreshes.
|
||||||
|
|
||||||
|
An API key enters through `adopt`, from a file on this module's node.
|
||||||
|
|
||||||
|
## What each consumer holds
|
||||||
|
|
||||||
|
This module's `bindings` state: one key per consumer (a node's name) with the licence, its kind and a
|
||||||
|
generation that grows with every rotation and switch. `claude-code` watches its own key and, on a newer
|
||||||
|
generation, asks `current` with its public key.
|
||||||
|
|
||||||
|
## The seat's verbs
|
||||||
|
|
||||||
|
`licences`, `bindings`, `bind`, `switch`, `release`, `refresh`, `usage`, `adopt`, `current` — through
|
||||||
|
the console as `anthropic-licence-manager.<verb>`. No answer carries a token.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
`settings.json` in the state directory: `cadence_minutes` (240), `floor_minutes` (60),
|
||||||
|
`failures_to_notify` (3), `cooldown_hours` (24), `refresh_warn_days` (3).
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
`licence.adopted`, `licence.refused`, `licence.failing`, `usage.read` — none carries a secret.
|
||||||
|
|
||||||
|
## Code and tests
|
||||||
|
|
||||||
|
Go, one binary (`cmd/claude-licence-manager`): the seat's verbs and the daemon in one launched bundle,
|
||||||
|
`prepare` as the run-once preparation step. The sealed box is `claude-code`'s own format, byte for byte —
|
||||||
|
the two modules carry the same `seal.go` — and a test opens one sealed by the TypeScript agent module the
|
||||||
|
Go one replaced, so the format is the one already on the machines.
|
||||||
|
|
||||||
|
go test ./...
|
||||||
|
# the store against a real postgres:
|
||||||
|
docker run -d --rm --name licmgr-pg -e POSTGRES_PASSWORD=t -p 15498:5432 postgres:16-alpine
|
||||||
|
MESH_TEST_POSTGRES=postgres://postgres:t@127.0.0.1:15498/postgres go test ./...
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The grants at rest (novox/hq ADR 0183): encrypted with a key the vault made for this module, so the
|
||||||
|
// store holds ciphertext and only this module, reading its own secret, can open a row. AES-256-GCM, the
|
||||||
|
// key derived from the vault's secret by SHA-256 so a secret of any length serves.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Crypt seals and opens what the store keeps.
|
||||||
|
type Crypt struct{ key []byte }
|
||||||
|
|
||||||
|
// NewCrypt is the store's cipher from the vault's secret.
|
||||||
|
func NewCrypt(secret string) (*Crypt, error) {
|
||||||
|
secret = strings.TrimSpace(secret)
|
||||||
|
if secret == "" {
|
||||||
|
return nil, errors.New("the key the grants are encrypted with is empty: the vault has not delivered it yet")
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(secret))
|
||||||
|
return &Crypt{key: sum[:]}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CryptFromFile reads the vault's secret from the file the mesh delivered it to.
|
||||||
|
func CryptFromFile(path string) (*Crypt, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return NewCrypt(string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seal encrypts a plaintext as `v1.<iv>.<ciphertext and tag>`.
|
||||||
|
func (c *Crypt) Seal(plaintext string) string {
|
||||||
|
gcm, _ := newGCM(c.key)
|
||||||
|
iv := make([]byte, 12)
|
||||||
|
_, _ = rand.Read(iv)
|
||||||
|
b64 := base64.StdEncoding.EncodeToString
|
||||||
|
return "v1." + b64(iv) + "." + b64(gcm.Seal(nil, iv, []byte(plaintext), nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open decrypts what Seal made with the same key.
|
||||||
|
func (c *Crypt) Open(sealed string) (string, error) {
|
||||||
|
parts := strings.Split(sealed, ".")
|
||||||
|
if len(parts) != 3 || parts[0] != "v1" {
|
||||||
|
return "", errors.New("not a grant this module sealed")
|
||||||
|
}
|
||||||
|
iv, err1 := base64.StdEncoding.DecodeString(parts[1])
|
||||||
|
ct, err2 := base64.StdEncoding.DecodeString(parts[2])
|
||||||
|
if err := errors.Join(err1, err2); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, _ := newGCM(c.key)
|
||||||
|
plain, err := gcm.Open(nil, iv, ct, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("the grant does not open with this module's key")
|
||||||
|
}
|
||||||
|
return string(plain), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): one binary, launched by the control
|
||||||
|
// node's runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). It serves the
|
||||||
|
// `anthropic-licence-manager` seat's verbs and, beside them, runs long: it watches what every node reports
|
||||||
|
// holding and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage.
|
||||||
|
//
|
||||||
|
// `claude-licence-manager prepare` is the preparation step (ADR 0135): the host runs it once before the
|
||||||
|
// version that needs it, with the module's words and no bus, and it brings the store's schema to shape.
|
||||||
|
//
|
||||||
|
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Seat is the role this module holds.
|
||||||
|
const Seat = "anthropic-licence-manager"
|
||||||
|
|
||||||
|
func say(format string, args ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, "[claude-licence-manager] "+format+"\n", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "prepare" {
|
||||||
|
if err := prepare(); err != nil {
|
||||||
|
say("preparing the store failed: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
say("the store's schema is what this version needs")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go daemon()
|
||||||
|
if err := stdio.Serve("", tools()); err != nil {
|
||||||
|
say("%v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepare() error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
store, err := PgStoreFromEnv(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
return store.Migrate(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- what the binary is handed -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
built *Manager
|
||||||
|
buildMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// manager builds the rules' dependencies once, from the module's words (ADR 0192): file paths, never
|
||||||
|
// values. A failure is said and tried again on the next call, so a database that arrives late is not fatal.
|
||||||
|
func manager() (*Manager, error) {
|
||||||
|
buildMu.Lock()
|
||||||
|
defer buildMu.Unlock()
|
||||||
|
if built != nil {
|
||||||
|
return built, nil
|
||||||
|
}
|
||||||
|
dir, keyFile := os.Getenv("MESH_LICENCE_STATE"), os.Getenv("MESH_LICENCE_KEY_FILE")
|
||||||
|
if dir == "" || keyFile == "" {
|
||||||
|
return nil, errors.New("MESH_LICENCE_STATE and MESH_LICENCE_KEY_FILE are not set: the mesh renders them for this module")
|
||||||
|
}
|
||||||
|
crypt, err := CryptFromFile(keyFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keys, err := keypair(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
store, err := PgStoreFromEnv(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
node, _ := os.Hostname()
|
||||||
|
if n := os.Getenv("MESH_NODE"); n != "" {
|
||||||
|
node = n
|
||||||
|
}
|
||||||
|
bindings := stdio.State("bindings")
|
||||||
|
built = &Manager{
|
||||||
|
Store: store,
|
||||||
|
Vendor: LiveVendor(),
|
||||||
|
Crypt: crypt,
|
||||||
|
Keys: keys,
|
||||||
|
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
|
||||||
|
var a GrantAnswer
|
||||||
|
raw, err := stdio.Ask("claude-code.claude_code_grant@"+node, map[string]any{"public_key": publicKey})
|
||||||
|
if err != nil {
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
return a, json.Unmarshal(raw, &a)
|
||||||
|
},
|
||||||
|
PutBinding: func(_ context.Context, consumer string, b BindingState) error {
|
||||||
|
_, err := bindings.Put(consumer, b)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
DeleteBinding: func(_ context.Context, consumer string) error { return bindings.Delete(consumer) },
|
||||||
|
Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) },
|
||||||
|
Now: time.Now,
|
||||||
|
Log: say,
|
||||||
|
Holder: fmt.Sprintf("%s:%d", node, os.Getpid()),
|
||||||
|
Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")),
|
||||||
|
}
|
||||||
|
return built, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// keypair is this module's own, made once in its state directory; the private half never leaves it.
|
||||||
|
// Written whole, then linked into place, so a second process reads the first's key and never half of it.
|
||||||
|
func keypair(dir string) (KeyPair, error) {
|
||||||
|
file := filepath.Join(dir, "manager-key.json")
|
||||||
|
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
|
||||||
|
k, err := GenerateKeyPair()
|
||||||
|
if err != nil {
|
||||||
|
return KeyPair{}, err
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(k)
|
||||||
|
tmp := filepath.Join(dir, fmt.Sprintf(".manager-key.%d.json", os.Getpid()))
|
||||||
|
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||||
|
return KeyPair{}, err
|
||||||
|
}
|
||||||
|
_ = os.Link(tmp, file) // fails when another made it first, which is right
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return KeyPair{}, err
|
||||||
|
}
|
||||||
|
var k KeyPair
|
||||||
|
return k, json.Unmarshal(raw, &k)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the long-running half ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func daemon() {
|
||||||
|
var mu sync.Mutex
|
||||||
|
reports := map[string]Holdings{}
|
||||||
|
var passing sync.Mutex
|
||||||
|
pass := func() {
|
||||||
|
passing.Lock() // one pass at a time: each account is leased, and a pass is cheap
|
||||||
|
defer passing.Unlock()
|
||||||
|
m, err := manager()
|
||||||
|
if err != nil {
|
||||||
|
say("not ready: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
all := make([]Holdings, 0, len(reports))
|
||||||
|
for _, r := range reports {
|
||||||
|
all = append(all, r)
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
sort.Slice(all, func(i, j int) bool { return all[i].Node < all[j].Node })
|
||||||
|
adopted, err := m.Consider(context.Background(), all)
|
||||||
|
if err != nil {
|
||||||
|
say("considering the reports failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(adopted) > 0 {
|
||||||
|
say("adopted %s", strings.Join(adopted, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What every node holds (ADR 0206): the whole current set, then each change. Asked again until it
|
||||||
|
// answers — the channel to the runtime opens as the bundle starts, and the agent module's state may
|
||||||
|
// arrive after this one.
|
||||||
|
go func() {
|
||||||
|
waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
|
||||||
|
for attempt := 0; ; attempt++ {
|
||||||
|
err := stdio.State("claude-code.holdings").Watch("", func(c stdio.StateChange) error {
|
||||||
|
mu.Lock()
|
||||||
|
if c.Op == "put" {
|
||||||
|
var h Holdings
|
||||||
|
if json.Unmarshal(c.Value, &h) == nil {
|
||||||
|
reports[c.Key] = h
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delete(reports, c.Key)
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
// During the current values the pass waits for the whole set: newest login first needs all.
|
||||||
|
if !c.Current {
|
||||||
|
go pass()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
mu.Lock()
|
||||||
|
n := len(reports)
|
||||||
|
mu.Unlock()
|
||||||
|
say("watching what %d node(s) hold", n)
|
||||||
|
pass()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pause := time.Minute
|
||||||
|
if attempt < len(waits) {
|
||||||
|
pause = waits[attempt]
|
||||||
|
}
|
||||||
|
say("what the nodes hold cannot be watched yet (%v); asking again in %s", err, pause)
|
||||||
|
time.Sleep(pause)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
refresh := time.NewTicker(time.Minute)
|
||||||
|
usage := time.NewTicker(5 * time.Minute)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-refresh.C:
|
||||||
|
if m, err := manager(); err == nil {
|
||||||
|
out, err := m.RotateDue(context.Background())
|
||||||
|
for _, r := range out {
|
||||||
|
b, _ := json.Marshal(r)
|
||||||
|
say("%s", b)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
say("refreshing failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pass() // a login whose node did not answer last time is asked again
|
||||||
|
case <-usage.C:
|
||||||
|
if m, err := manager(); err == nil {
|
||||||
|
if err := m.ReadUsage(context.Background()); err != nil {
|
||||||
|
say("reading usage failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the seat's verbs --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func text(a map[string]any, k string) (string, error) {
|
||||||
|
v, _ := a[k].(string)
|
||||||
|
if strings.TrimSpace(v) == "" {
|
||||||
|
return "", fmt.Errorf("%s is required", k)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(v), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's subject.
|
||||||
|
func verb(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool {
|
||||||
|
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input,
|
||||||
|
Run: func(a map[string]any) (any, error) {
|
||||||
|
m, err := manager()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return run(context.Background(), m, a)
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func str(description string) map[string]any {
|
||||||
|
return map[string]any{"type": "string", "description": description}
|
||||||
|
}
|
||||||
|
|
||||||
|
func two(a map[string]any, k1, k2 string) (string, string, error) {
|
||||||
|
v1, err1 := text(a, k1)
|
||||||
|
v2, err2 := text(a, k2)
|
||||||
|
return v1, v2, errors.Join(err1, err2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tools() []stdio.Tool {
|
||||||
|
consumer := str("the node's name")
|
||||||
|
return []stdio.Tool{
|
||||||
|
verb("licences", "Every licence the manager holds — account, kind, when its token and its refresh token expire, failures in a row, which consumers are bound to it. Never a token.",
|
||||||
|
nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Licences(ctx) }),
|
||||||
|
verb("bindings", "Which licence each consumer (a node's agent, by the node's name) is bound to, and the generation it was last given.",
|
||||||
|
nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Store.Bindings(ctx) }),
|
||||||
|
verb("bind", "Bind a consumer — a node's agent, by the node's name — to a licence. Its node fetches the licence's token at once.",
|
||||||
|
map[string]any{"consumer": consumer, "licence": str("a licence, as `licences` names it")},
|
||||||
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
|
c, l, err := two(a, "consumer", "licence")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m.Bind(ctx, c, l, "bind")
|
||||||
|
}),
|
||||||
|
verb("switch", "Move a consumer to another licence. Its node fetches the new licence's token at once and points the agent's account at it.",
|
||||||
|
map[string]any{"consumer": consumer, "licence": str("the licence to move to")},
|
||||||
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
|
c, l, err := two(a, "consumer", "licence")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m.Bind(ctx, c, l, "switch")
|
||||||
|
}),
|
||||||
|
verb("release", "Unbind a consumer. Its node keeps its last token, which expires within hours.",
|
||||||
|
map[string]any{"consumer": consumer},
|
||||||
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
|
c, err := text(a, "consumer")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m.Release(ctx, c, "release")
|
||||||
|
}),
|
||||||
|
verb("refresh", "Refresh a licence now, or every due licence when none is named; under each licence's lease, so it never races the daemon. Answers the outcome, never a token.",
|
||||||
|
map[string]any{"licence": str("a licence; absent for every due one")},
|
||||||
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
|
if l, _ := a["licence"].(string); l != "" {
|
||||||
|
return m.Rotate(ctx, l, true)
|
||||||
|
}
|
||||||
|
return m.RotateDue(ctx)
|
||||||
|
}),
|
||||||
|
verb("usage", "Usage readings, the latest first, for every licence or one.",
|
||||||
|
map[string]any{"licence": str("a licence; absent for all"), "limit": map[string]any{"type": "number", "description": "how many readings (default 20)"}},
|
||||||
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
|
l, _ := a["licence"].(string)
|
||||||
|
limit := 20
|
||||||
|
if v, ok := a["limit"].(float64); ok && v > 0 {
|
||||||
|
limit = int(v)
|
||||||
|
}
|
||||||
|
return m.Store.Usage(ctx, l, limit)
|
||||||
|
}),
|
||||||
|
verb("adopt", "Adopt an API key from a file on the manager's node, never as an argument. Subscriptions are adopted from the nodes' logins by themselves.",
|
||||||
|
map[string]any{"name": str("the licence's name"), "file": str("a file on the manager's node holding the key")},
|
||||||
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
|
n, f, err := two(a, "name", "file")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m.AdoptKey(ctx, n, f)
|
||||||
|
}),
|
||||||
|
verb("current", "For a consumer's agent module (ADR 0206): its token, sealed to the public key it sends, with the licence, kind and generation. Null when it is bound to nothing.",
|
||||||
|
map[string]any{"consumer": consumer, "public_key": str("the consumer's public key, PEM")},
|
||||||
|
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||||
|
c, k, err := two(a, "consumer", "public_key")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m.Current(ctx, c, k)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,640 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The Anthropic licence manager's rules (novox/hq ADR 0183, ADR 0206, design 39), written against what it
|
||||||
|
// is handed — a store, the vendor, a way to ask a node, its own state, a way to emit — so every rule is
|
||||||
|
// tested without a bus, a database or the vendor.
|
||||||
|
//
|
||||||
|
// - A licence is an account, learned from what the nodes report (`holdings`, the agent module's state).
|
||||||
|
// A report with a refresh token the manager does not hold is a candidate.
|
||||||
|
// - The secret is asked for, sealed to this module's key, never published.
|
||||||
|
// - Adopting is refreshing: newest login first, once per account; a failure adopts nothing.
|
||||||
|
// - What each consumer should hold is this module's `bindings` state, with a generation that grows with
|
||||||
|
// every rotation and switch; the consumer fetches its token by asking `current`.
|
||||||
|
// - One rotation source: every exchange with the vendor runs under a lease.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fingerprint names a token without being one: the agent module's own fingerprint, so the two compare.
|
||||||
|
func Fingerprint(s string) string {
|
||||||
|
sum := sha256.Sum256([]byte(s))
|
||||||
|
return "sha256:" + hex.EncodeToString(sum[:])[:16]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity is the account a grant belongs to, as the agent's own state file names it.
|
||||||
|
type Identity struct {
|
||||||
|
AccountUUID string `json:"accountUuid"`
|
||||||
|
EmailAddress string `json:"emailAddress,omitempty"`
|
||||||
|
OrganizationUUID string `json:"organizationUuid,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holdings is one node's report, as the agent module writes it to its `holdings` state (ADR 0206).
|
||||||
|
type Holdings struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Identity *Identity `json:"identity"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Refresh struct {
|
||||||
|
Present bool `json:"present"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
} `json:"refresh"`
|
||||||
|
ChangedAt string `json:"changedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindingState is what `bindings` holds for one consumer: no secret, only what it should hold and which
|
||||||
|
// generation.
|
||||||
|
type BindingState struct {
|
||||||
|
Licence string `json:"licence"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Generation int64 `json:"generation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings are the manager's own, declared with defaults (design 39 §8).
|
||||||
|
type Settings struct {
|
||||||
|
Cadence time.Duration // rotate a grant once older than this
|
||||||
|
Floor time.Duration // refresh in any case with less than this left
|
||||||
|
FailuresToNotify int
|
||||||
|
Cooldown time.Duration // at most one notification per licence in this window
|
||||||
|
RefreshWarn time.Duration // warn this long before a refresh token itself expires
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults are the settings a fresh mesh runs with.
|
||||||
|
var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour, RefreshWarn: 72 * time.Hour}
|
||||||
|
|
||||||
|
// SettingsFrom reads the settings file, keeping a default for anything absent or not positive.
|
||||||
|
func SettingsFrom(path string) Settings {
|
||||||
|
s := Defaults
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var f map[string]float64
|
||||||
|
if json.Unmarshal(raw, &f) != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
set := func(k string, unit time.Duration, into *time.Duration) {
|
||||||
|
if v := f[k]; v > 0 {
|
||||||
|
*into = time.Duration(v * float64(unit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set("cadence_minutes", time.Minute, &s.Cadence)
|
||||||
|
set("floor_minutes", time.Minute, &s.Floor)
|
||||||
|
set("cooldown_hours", time.Hour, &s.Cooldown)
|
||||||
|
if v := f["refresh_warn_days"]; v > 0 {
|
||||||
|
s.RefreshWarn = time.Duration(v * float64(24*time.Hour))
|
||||||
|
}
|
||||||
|
if v := f["failures_to_notify"]; v > 0 {
|
||||||
|
s.FailuresToNotify = int(v)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantAnswer is what a node's `claude_code_grant` answers: a login sealed to the key given, or nothing.
|
||||||
|
type GrantAnswer struct {
|
||||||
|
Sealed *SealedBox `json:"sealed"`
|
||||||
|
Identity *Identity `json:"identity"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager is the rules and what they are handed.
|
||||||
|
type Manager struct {
|
||||||
|
Store Store
|
||||||
|
Vendor Vendor
|
||||||
|
Crypt *Crypt
|
||||||
|
Keys KeyPair
|
||||||
|
// AskGrant asks a node's agent module for the grant a login left there, sealed to publicKey. An error
|
||||||
|
// is the node not answering; a nil Sealed is no login waiting.
|
||||||
|
AskGrant func(ctx context.Context, node, publicKey string) (GrantAnswer, error)
|
||||||
|
// PutBinding and DeleteBinding are this module's `bindings` state.
|
||||||
|
PutBinding func(ctx context.Context, consumer string, b BindingState) error
|
||||||
|
DeleteBinding func(ctx context.Context, consumer string) error
|
||||||
|
Emit func(event string, body map[string]any) error
|
||||||
|
Now func() time.Time
|
||||||
|
Log func(format string, args ...any)
|
||||||
|
// Holder names this process in a lease, so a second run is told apart.
|
||||||
|
Holder string
|
||||||
|
Settings Settings
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaseFor = 2 * time.Minute
|
||||||
|
|
||||||
|
// NameFor is a licence's name: the account's address where it has one, else its id.
|
||||||
|
func NameFor(id Identity) string {
|
||||||
|
if e := strings.TrimSpace(id.EmailAddress); e != "" {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return id.AccountUUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- learning licences from what the nodes hold ------------------------------------------------------
|
||||||
|
|
||||||
|
// Candidate is a report the manager should try.
|
||||||
|
type Candidate struct {
|
||||||
|
Node string
|
||||||
|
Identity Identity
|
||||||
|
Fingerprint string
|
||||||
|
ChangedAt int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// CandidatesIn is the candidates in a set of reports by account, newest login first (ADR 0206 §2, §4). A
|
||||||
|
// report without an identity is not one: a grant is filed under its account or not at all.
|
||||||
|
func (m *Manager) CandidatesIn(ctx context.Context, reports []Holdings) (map[string][]Candidate, error) {
|
||||||
|
out := map[string][]Candidate{}
|
||||||
|
for _, r := range reports {
|
||||||
|
if !r.Refresh.Present || r.Refresh.Fingerprint == "" || r.Identity == nil || r.Identity.AccountUUID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
settled, err := m.Store.Outcome(ctx, r.Refresh.Fingerprint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if settled != "" {
|
||||||
|
continue // adopted, dead, skipped, refused or gone: settled once
|
||||||
|
}
|
||||||
|
held, err := m.Store.LicenceForAccount(ctx, r.Identity.AccountUUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if held != nil && held.RefreshFingerprint == r.Refresh.Fingerprint {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var changed int64
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, r.ChangedAt); err == nil {
|
||||||
|
changed = t.UnixMilli()
|
||||||
|
}
|
||||||
|
// The latest login wins: one no newer than the grant held is not a newer login.
|
||||||
|
if held != nil && changed <= held.AdoptedAt {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[r.Identity.AccountUUID] = append(out[r.Identity.AccountUUID],
|
||||||
|
Candidate{Node: r.Node, Identity: *r.Identity, Fingerprint: r.Refresh.Fingerprint, ChangedAt: changed})
|
||||||
|
}
|
||||||
|
for _, list := range out {
|
||||||
|
sort.SliceStable(list, func(i, j int) bool { return list[i].ChangedAt > list[j].ChangedAt })
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consider every report (ADR 0206): for each account with candidates, under that account's lease, try them
|
||||||
|
// newest first; the first that refreshes is adopted and the rest are settled as skipped without being
|
||||||
|
// exchanged. Answers what was adopted.
|
||||||
|
func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, error) {
|
||||||
|
byAccount, err := m.CandidatesIn(ctx, reports)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
accounts := make([]string, 0, len(byAccount))
|
||||||
|
for a := range byAccount {
|
||||||
|
accounts = append(accounts, a)
|
||||||
|
}
|
||||||
|
sort.Strings(accounts)
|
||||||
|
var adopted []string
|
||||||
|
for _, account := range accounts {
|
||||||
|
list := byAccount[account]
|
||||||
|
key := "account:" + account
|
||||||
|
ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
|
||||||
|
if err != nil || !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i, c := range list {
|
||||||
|
name, err := m.adoptOne(ctx, c, reports)
|
||||||
|
if err != nil {
|
||||||
|
m.Log("adopting %s's login failed: %v", c.Node, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name != "" {
|
||||||
|
adopted = append(adopted, name)
|
||||||
|
for _, rest := range list[i+1:] {
|
||||||
|
_ = m.Store.RecordOutcome(ctx, rest.Fingerprint, rest.Node, account, Skipped, c.Node+"'s newer login was adopted first")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = m.Store.Unlease(ctx, key, m.Holder)
|
||||||
|
}
|
||||||
|
return adopted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) (string, error) {
|
||||||
|
answer, err := m.AskGrant(ctx, c.Node, m.Keys.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
// Not answering is not an answer: asked again on the next pass.
|
||||||
|
m.Log("%s did not hand over its login: %v", c.Node, err)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if answer.Sealed == nil {
|
||||||
|
return "", m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Gone, "no login was waiting when asked")
|
||||||
|
}
|
||||||
|
plain, err := Open(*answer.Sealed, m.Keys.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, "the grant did not open with this module's key")
|
||||||
|
}
|
||||||
|
var offered FullGrant
|
||||||
|
if err := json.Unmarshal([]byte(plain), &offered); err != nil || offered.RefreshToken == "" {
|
||||||
|
return "", m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, "the grant holds no refresh token")
|
||||||
|
}
|
||||||
|
if Fingerprint(offered.RefreshToken) != c.Fingerprint {
|
||||||
|
m.Log("%s's login changed while it was asked for; waiting for its next report", c.Node)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adopting is refreshing (ADR 0206 §4): the exchange is the check, and from here this module is the
|
||||||
|
// only holder of a live refresh token for the account.
|
||||||
|
r := m.Vendor.Refresh(ctx, offered)
|
||||||
|
if !r.OK {
|
||||||
|
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Dead, fmt.Sprintf("%d %s", r.Status, r.Reason))
|
||||||
|
_ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason})
|
||||||
|
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity),
|
||||||
|
"reason": fmt.Sprintf("the login's refresh token did not refresh (%d)", r.Status)})
|
||||||
|
m.Log("%s's login for %s did not refresh: %d %s", c.Node, NameFor(c.Identity), r.Status, r.Reason)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
// The identity guard, on two sources (ADR 0206 §8).
|
||||||
|
if r.Account != "" && r.Account != c.Identity.AccountUUID {
|
||||||
|
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused,
|
||||||
|
"the node says "+c.Identity.AccountUUID+", the vendor says "+r.Account)
|
||||||
|
_ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account})
|
||||||
|
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity),
|
||||||
|
"reason": "the account the node reported is not the one the vendor answered for"})
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
held, err := m.Store.LicenceForAccount(ctx, c.Identity.AccountUUID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
now := m.Now().UnixMilli()
|
||||||
|
l := Licence{Name: NameFor(c.Identity), Kind: "subscription", AccountUUID: c.Identity.AccountUUID,
|
||||||
|
Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID}
|
||||||
|
if held != nil {
|
||||||
|
l.Name, l.NotifiedAt = held.Name, held.NotifiedAt
|
||||||
|
if l.Email == "" {
|
||||||
|
l.Email = held.Email
|
||||||
|
}
|
||||||
|
if l.OrganizationUUID == "" {
|
||||||
|
l.OrganizationUUID = held.OrganizationUUID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.keep(&l, r.Grant)
|
||||||
|
l.AdoptedAt = max(now, c.ChangedAt)
|
||||||
|
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
why := "a new licence"
|
||||||
|
if held != nil {
|
||||||
|
why = "a newer login"
|
||||||
|
}
|
||||||
|
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Adopted, why)
|
||||||
|
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID,
|
||||||
|
"vendorNamedAccount": r.Account != ""})
|
||||||
|
|
||||||
|
// A first binding follows the login (ADR 0206 §7): every node reporting this account and bound to nothing.
|
||||||
|
for _, rep := range reports {
|
||||||
|
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil {
|
||||||
|
_ = m.Store.Audit(ctx, "bound", map[string]any{"consumer": rep.Node, "licence": l.Name, "by": "its login"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := m.publishAdvance(ctx, l); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_ = m.Emit("licence.adopted", map[string]any{"licence": l.Name, "from": c.Node, "replaced": held != nil})
|
||||||
|
m.Log("adopted %s from %s (%s)", l.Name, c.Node, why)
|
||||||
|
return l.Name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep stores a grant on its licence: encrypted, fingerprinted, its expiries, fresh.
|
||||||
|
func (m *Manager) keep(l *Licence, g FullGrant) {
|
||||||
|
raw, _ := json.Marshal(g)
|
||||||
|
l.Sealed = m.Crypt.Seal(string(raw))
|
||||||
|
l.RefreshFingerprint = Fingerprint(g.RefreshToken)
|
||||||
|
l.AccessExpiresAt = g.ExpiresAt
|
||||||
|
if g.RefreshTokenExpiresAt != nil {
|
||||||
|
l.RefreshExpiresAt = *g.RefreshTokenExpiresAt
|
||||||
|
}
|
||||||
|
l.Failures = 0
|
||||||
|
l.RotatedAt = m.Now().UnixMilli()
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state.
|
||||||
|
func (m *Manager) publishAdvance(ctx context.Context, l Licence) error {
|
||||||
|
bindings, err := m.Store.Advance(ctx, l.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, b := range bindings {
|
||||||
|
if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- keeping grants alive ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Due says whether a licence needs a refresh now: near expiry, or older than the cadence.
|
||||||
|
func Due(l Licence, now time.Time, s Settings) bool {
|
||||||
|
if l.Kind != "subscription" || l.Sealed == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ms := now.UnixMilli()
|
||||||
|
if l.AccessExpiresAt != 0 && l.AccessExpiresAt-ms < s.Floor.Milliseconds() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return l.RotatedAt == 0 || ms-l.RotatedAt >= s.Cadence.Milliseconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate refreshes one licence under its lease (design 39 §3). A second run started together finds the
|
||||||
|
// lease live and does nothing; one started just after finds a fresh grant and is not due. force refreshes
|
||||||
|
// whatever the age — the seat's `refresh` verb.
|
||||||
|
func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[string]any, error) {
|
||||||
|
key := "licence:" + name
|
||||||
|
ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return map[string]any{"licence": name, "refreshed": false, "reason": "another run holds its lease"}, nil
|
||||||
|
}
|
||||||
|
defer func() { _ = m.Store.Unlease(ctx, key, m.Holder) }()
|
||||||
|
l, err := m.Store.Licence(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if l == nil {
|
||||||
|
return map[string]any{"licence": name, "refreshed": false, "reason": "no such licence"}, nil
|
||||||
|
}
|
||||||
|
if l.Kind != "subscription" || l.Sealed == "" {
|
||||||
|
return map[string]any{"licence": name, "refreshed": false, "reason": "an API key does not refresh"}, nil
|
||||||
|
}
|
||||||
|
now := m.Now()
|
||||||
|
if !force && !Due(*l, now, m.Settings) {
|
||||||
|
return map[string]any{"licence": name, "refreshed": false, "reason": "not due"}, nil
|
||||||
|
}
|
||||||
|
plain, err := m.Crypt.Open(l.Sealed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var g FullGrant
|
||||||
|
if err := json.Unmarshal([]byte(plain), &g); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r := m.Vendor.Refresh(ctx, g)
|
||||||
|
if !r.OK {
|
||||||
|
l.Failures++
|
||||||
|
m.Log("%s did not refresh (%d in a row): %d %s", name, l.Failures, r.Status, r.Reason)
|
||||||
|
_ = m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures})
|
||||||
|
m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.Settings.FailuresToNotify)
|
||||||
|
if err := m.Store.SaveLicence(ctx, *l); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"licence": name, "refreshed": false, "reason": fmt.Sprintf("%d %s", r.Status, r.Reason), "failures": l.Failures}, nil
|
||||||
|
}
|
||||||
|
m.keep(l, r.Grant)
|
||||||
|
if l.RefreshExpiresAt != 0 {
|
||||||
|
left := time.Duration(l.RefreshExpiresAt-now.UnixMilli()) * time.Millisecond
|
||||||
|
m.notify(l, fmt.Sprintf("its refresh token expires in %.1f day(s): a person must log in again", left.Hours()/24),
|
||||||
|
left < m.Settings.RefreshWarn)
|
||||||
|
}
|
||||||
|
if err := m.Store.SaveLicence(ctx, *l); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = m.Store.Audit(ctx, "rotated", map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt})
|
||||||
|
if err := m.publishAdvance(ctx, *l); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"licence": name, "refreshed": true, "expiresAt": time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// notify emits `licence.failing` at most once per cooldown (design 39 §3); it carries no secret.
|
||||||
|
func (m *Manager) notify(l *Licence, why string, when bool) {
|
||||||
|
if !when {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := m.Now().UnixMilli()
|
||||||
|
if l.NotifiedAt != 0 && now-l.NotifiedAt < m.Settings.Cooldown.Milliseconds() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.NotifiedAt = now
|
||||||
|
_ = m.Emit("licence.failing", map[string]any{"licence": l.Name, "why": why})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotateDue refreshes every licence that is due.
|
||||||
|
func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) {
|
||||||
|
all, err := m.Store.Licences(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []map[string]any
|
||||||
|
for _, l := range all {
|
||||||
|
if Due(l, m.Now(), m.Settings) {
|
||||||
|
r, err := m.Rotate(ctx, l.Name, false)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadUsage reads and records each subscription's usage (ADR 0054); the event names the licence and numbers.
|
||||||
|
func (m *Manager) ReadUsage(ctx context.Context) error {
|
||||||
|
all, err := m.Store.Licences(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, l := range all {
|
||||||
|
if l.Kind != "subscription" || l.Sealed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
plain, err := m.Crypt.Open(l.Sealed)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var g FullGrant
|
||||||
|
_ = json.Unmarshal([]byte(plain), &g)
|
||||||
|
raw, err := m.Vendor.Usage(ctx, g.AccessToken)
|
||||||
|
if err != nil || raw == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reading := FlattenUsage(raw)
|
||||||
|
if err := m.Store.RecordUsage(ctx, l.Name, m.Now().UnixMilli(), reading, raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct,
|
||||||
|
"weeklyPct": reading.WeeklyPct, "sonnetPct": reading.SonnetPct})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the seat's verbs --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Current is a consumer's token sealed to the key it sent (ADR 0206 §6): for a subscription the access
|
||||||
|
// token and its expiries only — never the refresh token, which no node holds. Nil when it is bound to
|
||||||
|
// nothing.
|
||||||
|
func (m *Manager) Current(ctx context.Context, consumer, publicKey string) (map[string]any, error) {
|
||||||
|
if !strings.Contains(publicKey, "PUBLIC KEY") {
|
||||||
|
return nil, errors.New("current seals to the consumer's public key, and none was given")
|
||||||
|
}
|
||||||
|
b, err := m.Store.Binding(ctx, consumer)
|
||||||
|
if err != nil || b == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
l, err := m.Store.Licence(ctx, b.Licence)
|
||||||
|
if err != nil || l == nil || l.Sealed == "" {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
plain, err := m.Crypt.Open(l.Sealed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
handed := plain
|
||||||
|
if l.Kind == "subscription" {
|
||||||
|
var g FullGrant
|
||||||
|
if err := json.Unmarshal([]byte(plain), &g); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt,
|
||||||
|
"refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes,
|
||||||
|
"subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier})
|
||||||
|
handed = string(access)
|
||||||
|
}
|
||||||
|
box, err := Seal(handed, publicKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := map[string]any{"licence": l.Name, "kind": l.Kind, "generation": b.Generation, "sealed": box}
|
||||||
|
if l.AccountUUID != "" {
|
||||||
|
out["identity"] = Identity{AccountUUID: l.AccountUUID, EmailAddress: l.Email, OrganizationUUID: l.OrganizationUUID}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation.
|
||||||
|
func (m *Manager) Bind(ctx context.Context, consumer, licence, by string) (map[string]any, error) {
|
||||||
|
l, err := m.Store.Licence(ctx, licence)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if l == nil {
|
||||||
|
return nil, fmt.Errorf("there is no licence %s; `licences` lists them", licence)
|
||||||
|
}
|
||||||
|
before, err := m.Store.Binding(ctx, consumer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b, err := m.Store.Bind(ctx, consumer, licence)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
from, what := "", "bound"
|
||||||
|
if before != nil {
|
||||||
|
from, what = before.Licence, "switched"
|
||||||
|
}
|
||||||
|
_ = m.Store.Audit(ctx, what, map[string]any{"consumer": consumer, "licence": licence, "from": from, "by": by})
|
||||||
|
if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release unbinds a consumer; its node keeps its last token, which expires within hours.
|
||||||
|
func (m *Manager) Release(ctx context.Context, consumer, by string) (map[string]any, error) {
|
||||||
|
was, err := m.Store.Binding(ctx, consumer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ok, err := m.Store.Unbind(ctx, consumer); err != nil || !ok {
|
||||||
|
return map[string]any{"consumer": consumer, "released": false, "reason": "it was bound to nothing"}, err
|
||||||
|
}
|
||||||
|
if err := m.DeleteBinding(ctx, consumer); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = m.Store.Audit(ctx, "released", map[string]any{"consumer": consumer, "licence": was.Licence, "by": by})
|
||||||
|
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var licenceName = regexp.MustCompile(`^[A-Za-z0-9@._-]+$`)
|
||||||
|
|
||||||
|
// AdoptKey adopts an API key from a file on this node — never an argument (design 39 §6).
|
||||||
|
func (m *Manager) AdoptKey(ctx context.Context, name, file string) (map[string]any, error) {
|
||||||
|
if !licenceName.MatchString(name) {
|
||||||
|
return nil, fmt.Errorf("%q is not a licence name: letters, digits and @._-", name)
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(string(raw))
|
||||||
|
if key == "" {
|
||||||
|
return nil, fmt.Errorf("%s is empty", file)
|
||||||
|
}
|
||||||
|
held, err := m.Store.Licence(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if held != nil && held.Kind != "api-key" {
|
||||||
|
return nil, fmt.Errorf("%s is a subscription; an API key needs a name of its own", name)
|
||||||
|
}
|
||||||
|
l := Licence{Name: name, Kind: "api-key", Sealed: m.Crypt.Seal(key), AdoptedAt: m.Now().UnixMilli()}
|
||||||
|
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": "a file"})
|
||||||
|
if err := m.publishAdvance(ctx, l); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": "a file", "replaced": held != nil})
|
||||||
|
return map[string]any{"licence": name, "kind": "api-key", "adopted": true, "fingerprint": Fingerprint(key)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Licences is each licence as a person reads it: health and who is bound, never a token.
|
||||||
|
func (m *Manager) Licences(ctx context.Context) ([]map[string]any, error) {
|
||||||
|
all, err := m.Store.Licences(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bindings, err := m.Store.Bindings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stamp := func(ms int64) any {
|
||||||
|
if ms == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return time.UnixMilli(ms).UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
out := []map[string]any{}
|
||||||
|
for _, l := range all {
|
||||||
|
bound := []string{}
|
||||||
|
for _, b := range bindings {
|
||||||
|
if b.Licence == l.Name {
|
||||||
|
bound = append(bound, b.Consumer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
account := l.Email
|
||||||
|
if account == "" {
|
||||||
|
account = l.AccountUUID
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{"name": l.Name, "kind": l.Kind, "account": account,
|
||||||
|
"accessExpiresAt": stamp(l.AccessExpiresAt), "refreshExpiresAt": stamp(l.RefreshExpiresAt),
|
||||||
|
"rotatedAt": stamp(l.RotatedAt), "failures": l.Failures, "bound": bound})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var t0 = time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// stubVendor rotates like the real one is presumed to: each refresh token exchanges once.
|
||||||
|
type stubVendor struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
live map[string]bool
|
||||||
|
exchanged []string
|
||||||
|
issued int
|
||||||
|
account string
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
v.exchanged = append(v.exchanged, g.RefreshToken)
|
||||||
|
if !v.live[g.RefreshToken] {
|
||||||
|
return Refreshed{Status: 400, Reason: `{"error":"invalid_grant"}`}
|
||||||
|
}
|
||||||
|
delete(v.live, g.RefreshToken)
|
||||||
|
v.issued++
|
||||||
|
next := fmt.Sprintf("rt-%d", v.issued)
|
||||||
|
v.live[next] = true
|
||||||
|
g.AccessToken = fmt.Sprintf("at-%d", v.issued)
|
||||||
|
g.RefreshToken = next
|
||||||
|
g.ExpiresAt = v.now().Add(8 * time.Hour).UnixMilli()
|
||||||
|
exp := v.now().Add(30 * 24 * time.Hour).UnixMilli()
|
||||||
|
g.RefreshTokenExpiresAt = &exp
|
||||||
|
return Refreshed{OK: true, Grant: g, Account: v.account}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *stubVendor) Usage(context.Context, string) (map[string]any, error) {
|
||||||
|
return map[string]any{"five_hour": map[string]any{"utilization": 12.0}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type miniMesh struct {
|
||||||
|
m *Manager
|
||||||
|
store *MemoryStore
|
||||||
|
vendor *stubVendor
|
||||||
|
logins map[string]FullGrant // node → what its credentials file holds
|
||||||
|
state map[string]BindingState
|
||||||
|
events []string
|
||||||
|
now time.Time
|
||||||
|
keys KeyPair
|
||||||
|
askDown bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMesh(t *testing.T) *miniMesh {
|
||||||
|
t.Helper()
|
||||||
|
mm := &miniMesh{logins: map[string]FullGrant{}, state: map[string]BindingState{}, now: t0}
|
||||||
|
now := func() time.Time { return mm.now }
|
||||||
|
mm.store = NewMemoryStore(now)
|
||||||
|
mm.vendor = &stubVendor{live: map[string]bool{}, now: now}
|
||||||
|
crypt, _ := NewCrypt("a key the vault made")
|
||||||
|
mm.keys, _ = GenerateKeyPair()
|
||||||
|
mm.m = &Manager{
|
||||||
|
Store: mm.store, Vendor: mm.vendor, Crypt: crypt, Keys: mm.keys,
|
||||||
|
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
|
||||||
|
if mm.askDown {
|
||||||
|
return GrantAnswer{}, fmt.Errorf("503 no responders")
|
||||||
|
}
|
||||||
|
g, ok := mm.logins[node]
|
||||||
|
if !ok {
|
||||||
|
return GrantAnswer{}, nil
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(g)
|
||||||
|
box, err := Seal(string(raw), publicKey)
|
||||||
|
return GrantAnswer{Sealed: &box, Fingerprint: Fingerprint(g.RefreshToken)}, err
|
||||||
|
},
|
||||||
|
PutBinding: func(_ context.Context, c string, b BindingState) error { mm.state[c] = b; return nil },
|
||||||
|
DeleteBinding: func(_ context.Context, c string) error { delete(mm.state, c); return nil },
|
||||||
|
Emit: func(event string, body map[string]any) error {
|
||||||
|
raw, _ := json.Marshal(body)
|
||||||
|
mm.events = append(mm.events, event+" "+string(raw))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Now: now, Log: func(string, ...any) {}, Holder: "test", Settings: Defaults,
|
||||||
|
}
|
||||||
|
return mm
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *miniMesh) report(node, rt string, at time.Time, account string) Holdings {
|
||||||
|
h := Holdings{Node: node, Identity: &Identity{AccountUUID: account, EmailAddress: account + "@example.org"},
|
||||||
|
Kind: "subscription", ChangedAt: at.Format(time.RFC3339Nano)}
|
||||||
|
if rt != "" {
|
||||||
|
h.Refresh.Present, h.Refresh.Fingerprint = true, Fingerprint(rt)
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *miniMesh) login(node, rt string, valid bool, at time.Time) Holdings {
|
||||||
|
mm.logins[node] = FullGrant{AccessToken: "local-" + node, RefreshToken: rt, ExpiresAt: mm.now.Add(time.Hour).UnixMilli()}
|
||||||
|
if valid {
|
||||||
|
mm.vendor.live[rt] = true
|
||||||
|
}
|
||||||
|
return mm.report(node, rt, at, "acct-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
const licence1 = "acct-1@example.org"
|
||||||
|
|
||||||
|
func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchangesTheRest(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
older := mm.login("server", "rt-server", true, t0.Add(-time.Hour))
|
||||||
|
newest := mm.login("laptop", "rt-laptop", true, t0.Add(-time.Minute))
|
||||||
|
adopted, err := mm.m.Consider(ctx, []Holdings{older, newest})
|
||||||
|
if err != nil || len(adopted) != 1 || adopted[0] != licence1 {
|
||||||
|
t.Fatalf("adopted %v, %v", adopted, err)
|
||||||
|
}
|
||||||
|
if strings.Join(mm.vendor.exchanged, ",") != "rt-laptop" {
|
||||||
|
t.Fatalf("exchanged %v: an older login was exchanged although a newer one refreshed", mm.vendor.exchanged)
|
||||||
|
}
|
||||||
|
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-server")); o != Skipped {
|
||||||
|
t.Fatalf("the older login is %q, not skipped", o)
|
||||||
|
}
|
||||||
|
// A first binding follows the login: both nodes reported this account and were bound to nothing.
|
||||||
|
bs, _ := mm.store.Bindings(ctx)
|
||||||
|
if len(bs) != 2 || mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
|
||||||
|
t.Fatalf("bindings %v, state %v", bs, mm.state)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(strings.Join(mm.events, "|"), "licence.adopted") {
|
||||||
|
t.Fatalf("events %v", mm.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestALoginThatDoesNotRefreshAdoptsNothingAndIsNotTriedAgain(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
dead := mm.login("laptop", "rt-dead", false, t0)
|
||||||
|
if adopted, _ := mm.m.Consider(ctx, []Holdings{dead}); len(adopted) != 0 {
|
||||||
|
t.Fatalf("adopted %v", adopted)
|
||||||
|
}
|
||||||
|
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-dead")); o != Dead {
|
||||||
|
t.Fatalf("outcome %q", o)
|
||||||
|
}
|
||||||
|
if ls, _ := mm.store.Licences(ctx); len(ls) != 0 {
|
||||||
|
t.Fatalf("licences %v", ls)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(mm.events, "|"), "licence.refused") {
|
||||||
|
t.Fatalf("events %v", mm.events)
|
||||||
|
}
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{dead})
|
||||||
|
if len(mm.vendor.exchanged) != 1 {
|
||||||
|
t.Fatalf("a dead refresh token was exchanged %d times", len(mm.vendor.exchanged))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestANewerLoginReplacesTheGrantHeldAndAnOlderOneDoesNot(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0.Add(-time.Minute))})
|
||||||
|
before, _ := mm.store.Licence(ctx, licence1)
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("server", "rt-old", true, t0.Add(-24*time.Hour))})
|
||||||
|
if strings.Join(mm.vendor.exchanged, ",") != "rt-a" {
|
||||||
|
t.Fatalf("an older login was exchanged: %v", mm.vendor.exchanged)
|
||||||
|
}
|
||||||
|
mm.now = t0.Add(10 * time.Minute)
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("desktop", "rt-new", true, t0.Add(10*time.Minute))})
|
||||||
|
after, _ := mm.store.Licence(ctx, licence1)
|
||||||
|
if after.RefreshFingerprint == before.RefreshFingerprint || mm.vendor.exchanged[len(mm.vendor.exchanged)-1] != "rt-new" {
|
||||||
|
t.Fatalf("a newer login did not win: %v", mm.vendor.exchanged)
|
||||||
|
}
|
||||||
|
if ls, _ := mm.store.Licences(ctx); len(ls) != 1 {
|
||||||
|
t.Fatalf("one account became %d licences", len(ls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAReportNamingAnotherAccountThanTheVendorAnsweredForIsRefused(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mm.vendor.account = "someone-else"
|
||||||
|
if adopted, _ := mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}); len(adopted) != 0 {
|
||||||
|
t.Fatalf("adopted %v", adopted)
|
||||||
|
}
|
||||||
|
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-a")); o != Refused {
|
||||||
|
t.Fatalf("outcome %q", o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTwoRefreshRunsStartedTogetherRotateAGrantOnce(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
|
mm.now = t0.Add(5 * time.Hour)
|
||||||
|
second := *mm.m
|
||||||
|
second.Holder = "another run"
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make([]map[string]any, 2)
|
||||||
|
for i, m := range []*Manager{mm.m, &second} {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() { defer wg.Done(); results[i], _ = m.Rotate(ctx, licence1, false) }()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
n := 0
|
||||||
|
for _, r := range results {
|
||||||
|
if r["refreshed"] == true {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("rotated %d times: %v", n, results)
|
||||||
|
}
|
||||||
|
if r, _ := second.Rotate(ctx, licence1, false); r["reason"] != "not due" {
|
||||||
|
t.Fatalf("a run just after was %v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
|
g0 := mm.state["laptop"].Generation
|
||||||
|
_, _ = mm.m.Rotate(ctx, licence1, true)
|
||||||
|
g1 := mm.state["laptop"].Generation
|
||||||
|
if g1 <= g0 {
|
||||||
|
t.Fatalf("a rotation went from %d to %d", g0, g1)
|
||||||
|
}
|
||||||
|
file := filepath.Join(t.TempDir(), "key")
|
||||||
|
_ = os.WriteFile(file, []byte("sk-ant-api-key\n"), 0o600)
|
||||||
|
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := mm.m.Bind(ctx, "laptop", "api", "test"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if mm.state["laptop"].Licence != "api" || mm.state["laptop"].Generation <= g1 {
|
||||||
|
t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.state["laptop"])
|
||||||
|
}
|
||||||
|
if _, err := mm.m.Release(ctx, "laptop", "test"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := mm.state["laptop"]; ok {
|
||||||
|
t.Fatal("a released consumer still has a binding in the state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentHandsAnAccessTokenOnlySealedToTheConsumersKey(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
|
node, _ := GenerateKeyPair()
|
||||||
|
answer, err := mm.m.Current(ctx, "laptop", node.PublicKey)
|
||||||
|
if err != nil || answer["kind"] != "subscription" {
|
||||||
|
t.Fatalf("%v %v", answer, err)
|
||||||
|
}
|
||||||
|
box := answer["sealed"].(SealedBox)
|
||||||
|
plain, err := Open(box, node.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var handed map[string]any
|
||||||
|
_ = json.Unmarshal([]byte(plain), &handed)
|
||||||
|
if !strings.HasPrefix(handed["accessToken"].(string), "at-") || handed["refreshToken"] != nil {
|
||||||
|
t.Fatalf("handed %v", handed)
|
||||||
|
}
|
||||||
|
other, _ := GenerateKeyPair()
|
||||||
|
if _, err := Open(box, other.PrivateKey); err == nil {
|
||||||
|
t.Fatal("the hand-over opened with another key")
|
||||||
|
}
|
||||||
|
if a, _ := mm.m.Current(ctx, "nobody", node.PublicKey); a != nil {
|
||||||
|
t.Fatalf("a consumer bound to nothing was answered %v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-secret-1", true, t0)})
|
||||||
|
_, _ = mm.m.Rotate(ctx, licence1, true)
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("server", "rt-secret-dead", false, t0.Add(time.Hour))})
|
||||||
|
_ = mm.m.ReadUsage(ctx)
|
||||||
|
ls, _ := mm.m.Licences(ctx)
|
||||||
|
bs, _ := mm.store.Bindings(ctx)
|
||||||
|
everything, _ := json.Marshal([]any{mm.events, mm.state, ls, bs})
|
||||||
|
for _, token := range []string{"rt-secret", "rt-1", "rt-2", "at-1", "at-2", "local-"} {
|
||||||
|
if strings.Contains(string(everything), token) {
|
||||||
|
t.Fatalf("%s was published: %s", token, everything)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row, _ := mm.store.Licence(ctx, licence1)
|
||||||
|
if strings.Contains(row.Sealed, "rt-") {
|
||||||
|
t.Fatal("the grant is stored in the clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestANodeThatDoesNotAnswerIsAskedAgainAndOneWithNoLoginIsSettled(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
r := mm.login("laptop", "rt-a", true, t0)
|
||||||
|
mm.askDown = true
|
||||||
|
if adopted, _ := mm.m.Consider(ctx, []Holdings{r}); len(adopted) != 0 {
|
||||||
|
t.Fatalf("adopted %v from a node that did not answer", adopted)
|
||||||
|
}
|
||||||
|
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-a")); o != "" {
|
||||||
|
t.Fatalf("a node that was down was settled %q", o)
|
||||||
|
}
|
||||||
|
mm.askDown = false
|
||||||
|
if adopted, _ := mm.m.Consider(ctx, []Holdings{r}); len(adopted) != 1 {
|
||||||
|
t.Fatalf("adopted %v on the next pass", adopted)
|
||||||
|
}
|
||||||
|
gone := mm.report("server", "rt-gone", t0.Add(time.Second), "acct-2")
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{gone})
|
||||||
|
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-gone")); o != Gone {
|
||||||
|
t.Fatalf("outcome %q", o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAReportIsReadAsTheAgentModuleWritesIt(t *testing.T) {
|
||||||
|
// The agent module's own report shape (claude-code's holdingsOf), parsed here.
|
||||||
|
raw := `{"node":"laptop","identity":{"accountUuid":"u-1","emailAddress":"a@example.org"},"kind":"subscription",
|
||||||
|
"refresh":{"present":true,"fingerprint":"sha256:0123456789abcdef","expiresAt":null},
|
||||||
|
"access":{"fingerprint":"sha256:fedcba9876543210","expiresAt":1},"licence":null,"generation":0,
|
||||||
|
"changedAt":"2026-10-04T11:00:00.000Z"}`
|
||||||
|
var h Holdings
|
||||||
|
if err := json.Unmarshal([]byte(raw), &h); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if h.Node != "laptop" || h.Identity.AccountUUID != "u-1" || !h.Refresh.Present || h.Refresh.Fingerprint != "sha256:0123456789abcdef" {
|
||||||
|
t.Fatalf("%+v", h)
|
||||||
|
}
|
||||||
|
if _, err := time.Parse(time.RFC3339Nano, h.ChangedAt); err != nil {
|
||||||
|
t.Fatalf("the report's time does not parse: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The store on the mesh's postgres (novox/hq design 39 §1), the database the mesh provisioned for this
|
||||||
|
// module. The schema is brought to this version's shape by the preparation step (ADR 0135), each
|
||||||
|
// statement idempotent.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Schema is what this version needs.
|
||||||
|
var Schema = []string{
|
||||||
|
`create table if not exists licence (
|
||||||
|
name text primary key,
|
||||||
|
kind text not null check (kind in ('subscription', 'api-key')),
|
||||||
|
account_uuid text unique,
|
||||||
|
email text,
|
||||||
|
organization_uuid text,
|
||||||
|
sealed text,
|
||||||
|
refresh_fingerprint text,
|
||||||
|
access_expires_at bigint,
|
||||||
|
refresh_expires_at bigint,
|
||||||
|
failures integer not null default 0,
|
||||||
|
notified_at bigint,
|
||||||
|
adopted_at bigint not null,
|
||||||
|
rotated_at bigint)`,
|
||||||
|
`create sequence if not exists binding_generation`,
|
||||||
|
`create table if not exists binding (
|
||||||
|
consumer text primary key,
|
||||||
|
licence text not null references licence(name),
|
||||||
|
generation bigint not null)`,
|
||||||
|
`create table if not exists lease (
|
||||||
|
key text primary key,
|
||||||
|
holder text not null,
|
||||||
|
until timestamptz not null)`,
|
||||||
|
`create table if not exists offered (
|
||||||
|
fingerprint text primary key,
|
||||||
|
node text not null,
|
||||||
|
account_uuid text,
|
||||||
|
outcome text not null,
|
||||||
|
why text not null,
|
||||||
|
at timestamptz not null default now())`,
|
||||||
|
`create table if not exists usage (
|
||||||
|
licence text not null,
|
||||||
|
at bigint not null,
|
||||||
|
reading jsonb not null,
|
||||||
|
raw jsonb not null)`,
|
||||||
|
`create index if not exists usage_by_licence on usage (licence, at desc)`,
|
||||||
|
`create table if not exists audit (
|
||||||
|
at timestamptz not null default now(),
|
||||||
|
what text not null,
|
||||||
|
detail jsonb not null)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// PgStore is the store on postgres.
|
||||||
|
type PgStore struct{ pool *pgxpool.Pool }
|
||||||
|
|
||||||
|
// PgStoreFromEnv opens the store the mesh provisioned, its URL in the file DATABASE_URL_FILE names.
|
||||||
|
func PgStoreFromEnv(ctx context.Context) (*PgStore, error) {
|
||||||
|
file := os.Getenv("DATABASE_URL_FILE")
|
||||||
|
if file == "" {
|
||||||
|
return nil, errors.New("DATABASE_URL_FILE is not set: the manager's database is a requirement the mesh resolves")
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return OpenPgStore(ctx, strings.TrimSpace(string(raw)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenPgStore opens a store at a URL.
|
||||||
|
func OpenPgStore(ctx context.Context, url string) (*PgStore, error) {
|
||||||
|
pool, err := pgxpool.New(ctx, url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &PgStore{pool: pool}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate brings the schema to this version's shape.
|
||||||
|
func (s *PgStore) Migrate(ctx context.Context) error {
|
||||||
|
for _, q := range Schema {
|
||||||
|
if _, err := s.pool.Exec(ctx, q); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", strings.Fields(q)[0:6], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const licenceColumns = `name, kind, coalesce(account_uuid,''), coalesce(email,''), coalesce(organization_uuid,''),
|
||||||
|
coalesce(sealed,''), coalesce(refresh_fingerprint,''), coalesce(access_expires_at,0), coalesce(refresh_expires_at,0),
|
||||||
|
failures, coalesce(notified_at,0), adopted_at, coalesce(rotated_at,0)`
|
||||||
|
|
||||||
|
func scanLicence(row pgx.Row) (*Licence, error) {
|
||||||
|
var l Licence
|
||||||
|
err := row.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &l.Sealed, &l.RefreshFingerprint,
|
||||||
|
&l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &l, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgStore) Licences(ctx context.Context) ([]Licence, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `select `+licenceColumns+` from licence order by name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Licence
|
||||||
|
for rows.Next() {
|
||||||
|
l, err := scanLicence(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *l)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgStore) Licence(ctx context.Context, name string) (*Licence, error) {
|
||||||
|
return scanLicence(s.pool.QueryRow(ctx, `select `+licenceColumns+` from licence where name = $1`, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgStore) LicenceForAccount(ctx context.Context, account string) (*Licence, error) {
|
||||||
|
return scanLicence(s.pool.QueryRow(ctx, `select `+licenceColumns+` from licence where account_uuid = $1`, account))
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullable(s string) any {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableInt(v int64) any {
|
||||||
|
if v == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgStore) SaveLicence(ctx context.Context, l Licence) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `insert into licence (name, kind, account_uuid, email, organization_uuid, sealed, refresh_fingerprint,
|
||||||
|
access_expires_at, refresh_expires_at, failures, notified_at, adopted_at, rotated_at)
|
||||||
|
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||||
|
on conflict (name) do update set kind = excluded.kind, account_uuid = excluded.account_uuid, email = excluded.email,
|
||||||
|
organization_uuid = excluded.organization_uuid, sealed = excluded.sealed, refresh_fingerprint = excluded.refresh_fingerprint,
|
||||||
|
access_expires_at = excluded.access_expires_at, refresh_expires_at = excluded.refresh_expires_at,
|
||||||
|
failures = excluded.failures, notified_at = excluded.notified_at, adopted_at = excluded.adopted_at,
|
||||||
|
rotated_at = excluded.rotated_at`,
|
||||||
|
l.Name, l.Kind, nullable(l.AccountUUID), nullable(l.Email), nullable(l.OrganizationUUID), nullable(l.Sealed),
|
||||||
|
nullable(l.RefreshFingerprint), nullableInt(l.AccessExpiresAt), nullableInt(l.RefreshExpiresAt), l.Failures,
|
||||||
|
nullableInt(l.NotifiedAt), l.AdoptedAt, nullableInt(l.RotatedAt))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lease is taken in the store before a row is read (design 39 §3): a second run started together finds
|
||||||
|
// it live and does nothing.
|
||||||
|
func (s *PgStore) Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error) {
|
||||||
|
tag, err := s.pool.Exec(ctx, `insert into lease (key, holder, until) values ($1, $2, now() + make_interval(secs => $3))
|
||||||
|
on conflict (key) do update set holder = excluded.holder, until = excluded.until
|
||||||
|
where lease.until < now() or lease.holder = excluded.holder`, key, holder, d.Seconds())
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgStore) Unlease(ctx context.Context, key, holder string) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `delete from lease where key = $1 and holder = $2`, key, holder)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgStore) bindingsWhere(ctx context.Context, q string, args ...any) ([]Binding, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Binding
|
||||||
|
for rows.Next() {
|
||||||
|
var b Binding
|
||||||
|
if err := rows.Scan(&b.Consumer, &b.Licence, &b.Generation); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgStore) Bindings(ctx context.Context) ([]Binding, error) {
|
||||||
|
return s.bindingsWhere(ctx, `select consumer, licence, generation from binding order by consumer`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgStore) Binding(ctx context.Context, consumer string) (*Binding, error) {
|
||||||
|
out, err := s.bindingsWhere(ctx, `select consumer, licence, generation from binding where consumer = $1`, consumer)
|
||||||
|
if err != nil || len(out) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgStore) Bind(ctx context.Context, consumer, licence string) (Binding, error) {
|
||||||
|
out, err := s.bindingsWhere(ctx, `insert into binding (consumer, licence, generation) values ($1, $2, nextval('binding_generation'))
|
||||||
|
on conflict (consumer) do update set licence = excluded.licence, generation = excluded.generation
|
||||||
|
returning consumer, licence, generation`, consumer, licence)
|
||||||
|
if err != nil {
|
||||||
|
return Binding{}, err
|
||||||
|
}
|
||||||
|
return out[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgStore) Unbind(ctx context.Context, consumer string) (bool, error) {
|
||||||
|
tag, err := s.pool.Exec(ctx, `delete from binding where consumer = $1`, consumer)
|
||||||
|
return err == nil && tag.RowsAffected() == 1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgStore) Advance(ctx context.Context, licence string) ([]Binding, error) {
|
||||||
|
return s.bindingsWhere(ctx, `update binding set generation = nextval('binding_generation') where licence = $1
|
||||||
|
returning consumer, licence, generation`, licence)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgStore) Outcome(ctx context.Context, fp string) (Outcome, error) {
|
||||||
|
var o string
|
||||||
|
err := s.pool.QueryRow(ctx, `select outcome from offered where fingerprint = $1`, fp).Scan(&o)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return Outcome(o), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgStore) RecordOutcome(ctx context.Context, fp, node, account string, o Outcome, why string) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `insert into offered (fingerprint, node, account_uuid, outcome, why) values ($1,$2,$3,$4,$5)
|
||||||
|
on conflict (fingerprint) do update set outcome = excluded.outcome, why = excluded.why, at = now()`,
|
||||||
|
fp, node, nullable(account), string(o), why)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgStore) RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error {
|
||||||
|
reading, _ := json.Marshal(r)
|
||||||
|
rawJSON, _ := json.Marshal(raw)
|
||||||
|
_, err := s.pool.Exec(ctx, `insert into usage (licence, at, reading, raw) values ($1,$2,$3,$4)`, licence, at, reading, rawJSON)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgStore) Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `select licence, at, reading from usage where ($1 = '' or licence = $1) order by at desc limit $2`, licence, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []UsageRow
|
||||||
|
for rows.Next() {
|
||||||
|
var u UsageRow
|
||||||
|
var reading []byte
|
||||||
|
if err := rows.Scan(&u.Licence, &u.At, &reading); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(reading, &u.Reading)
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgStore) Audit(ctx context.Context, what string, detail map[string]any) error {
|
||||||
|
raw, _ := json.Marshal(detail)
|
||||||
|
_, err := s.pool.Exec(ctx, `insert into audit (what, detail) values ($1, $2)`, what, raw)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PgStore) Close() { s.pool.Close() }
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Sealing to one recipient (novox/hq ADR 0183, ADR 0206): the manager seals what it hands a consumer to
|
||||||
|
// the key that consumer sent, and a node seals a waiting login to the key the manager gives. The same box
|
||||||
|
// the agent module's TypeScript makes and opens, byte for byte — X25519 for the agreement, HKDF-SHA256 for
|
||||||
|
// the key, AES-256-GCM for the box — so `testdata/sealed-by-typescript.json` is opened here, and a test
|
||||||
|
// reopens what this seals with the same derivation.
|
||||||
|
//
|
||||||
|
// A box is `{ v: 1, eph, iv, tag, ct }`, every field base64; `eph` is the one-time public key as SPKI DER,
|
||||||
|
// and the key is bound to it and to the recipient's raw public key, so a box cannot be re-addressed.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/ecdh"
|
||||||
|
"crypto/hkdf"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SealedBox is a value sealed to one recipient.
|
||||||
|
type SealedBox struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Eph string `json:"eph"`
|
||||||
|
IV string `json:"iv"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Ct string `json:"ct"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyPair is a recipient's keypair as the two PEM strings it is kept and sent as.
|
||||||
|
type KeyPair struct {
|
||||||
|
PublicKey string `json:"publicKey"`
|
||||||
|
PrivateKey string `json:"privateKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const sealInfo = "novox-mesh sealed box v1"
|
||||||
|
|
||||||
|
// GenerateKeyPair makes an X25519 keypair, PEM-encoded as the agent module's are.
|
||||||
|
func GenerateKeyPair() (KeyPair, error) {
|
||||||
|
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return KeyPair{}, err
|
||||||
|
}
|
||||||
|
pubDER, err := x509.MarshalPKIXPublicKey(priv.PublicKey())
|
||||||
|
if err != nil {
|
||||||
|
return KeyPair{}, err
|
||||||
|
}
|
||||||
|
privDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
return KeyPair{}, err
|
||||||
|
}
|
||||||
|
return KeyPair{
|
||||||
|
PublicKey: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})),
|
||||||
|
PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicFromPEM(p string) (*ecdh.PublicKey, error) {
|
||||||
|
block, _ := pem.Decode([]byte(p))
|
||||||
|
if block == nil {
|
||||||
|
return nil, errors.New("not a PEM public key")
|
||||||
|
}
|
||||||
|
k, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pub, ok := k.(*ecdh.PublicKey)
|
||||||
|
if !ok || pub.Curve() != ecdh.X25519() {
|
||||||
|
return nil, errors.New("not an X25519 public key")
|
||||||
|
}
|
||||||
|
return pub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func privateFromPEM(p string) (*ecdh.PrivateKey, error) {
|
||||||
|
block, _ := pem.Decode([]byte(p))
|
||||||
|
if block == nil {
|
||||||
|
return nil, errors.New("not a PEM private key")
|
||||||
|
}
|
||||||
|
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
priv, ok := k.(*ecdh.PrivateKey)
|
||||||
|
if !ok || priv.Curve() != ecdh.X25519() {
|
||||||
|
return nil, errors.New("not an X25519 private key")
|
||||||
|
}
|
||||||
|
return priv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boxKey(secret, ephDER, recipientRaw []byte) ([]byte, error) {
|
||||||
|
salt := append(append([]byte{}, ephDER...), recipientRaw...)
|
||||||
|
return hkdf.Key(sha256.New, secret, salt, sealInfo, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seal seals plaintext to the recipient's public key.
|
||||||
|
func Seal(plaintext, recipientPEM string) (SealedBox, error) {
|
||||||
|
recipient, err := publicFromPEM(recipientPEM)
|
||||||
|
if err != nil {
|
||||||
|
return SealedBox{}, err
|
||||||
|
}
|
||||||
|
eph, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return SealedBox{}, err
|
||||||
|
}
|
||||||
|
secret, err := eph.ECDH(recipient)
|
||||||
|
if err != nil {
|
||||||
|
return SealedBox{}, err
|
||||||
|
}
|
||||||
|
ephDER, err := x509.MarshalPKIXPublicKey(eph.PublicKey())
|
||||||
|
if err != nil {
|
||||||
|
return SealedBox{}, err
|
||||||
|
}
|
||||||
|
key, err := boxKey(secret, ephDER, recipient.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
return SealedBox{}, err
|
||||||
|
}
|
||||||
|
gcm, err := newGCM(key)
|
||||||
|
if err != nil {
|
||||||
|
return SealedBox{}, err
|
||||||
|
}
|
||||||
|
iv := make([]byte, 12)
|
||||||
|
if _, err := rand.Read(iv); err != nil {
|
||||||
|
return SealedBox{}, err
|
||||||
|
}
|
||||||
|
out := gcm.Seal(nil, iv, []byte(plaintext), nil)
|
||||||
|
ct, tag := out[:len(out)-gcm.Overhead()], out[len(out)-gcm.Overhead():]
|
||||||
|
b64 := base64.StdEncoding.EncodeToString
|
||||||
|
return SealedBox{V: 1, Eph: b64(ephDER), IV: b64(iv), Tag: b64(tag), Ct: b64(ct)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens a box with the recipient's private key; it fails for a box to another key or one tampered with.
|
||||||
|
func Open(box SealedBox, privatePEM string) (string, error) {
|
||||||
|
if box.V != 1 {
|
||||||
|
return "", errors.New("not a sealed box this module can open")
|
||||||
|
}
|
||||||
|
priv, err := privateFromPEM(privatePEM)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
d := base64.StdEncoding.DecodeString
|
||||||
|
ephDER, err := d(box.Eph)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("the box's eph: %w", err)
|
||||||
|
}
|
||||||
|
ephKey, err := x509.ParsePKIXPublicKey(ephDER)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
eph, ok := ephKey.(*ecdh.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
return "", errors.New("the box's eph is not an X25519 key")
|
||||||
|
}
|
||||||
|
secret, err := priv.ECDH(eph)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
key, err := boxKey(secret, ephDER, priv.PublicKey().Bytes())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
iv, err1 := d(box.IV)
|
||||||
|
tag, err2 := d(box.Tag)
|
||||||
|
ct, err3 := d(box.Ct)
|
||||||
|
if err := errors.Join(err1, err2, err3); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, err := newGCM(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
plain, err := gcm.Open(nil, iv, append(ct, tag...), nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("the box does not open with this key")
|
||||||
|
}
|
||||||
|
return string(plain), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cipher.NewGCM(block)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A box the agent module's TypeScript sealed opens here: the two implementations are one format.
|
||||||
|
func TestABoxSealedInTypeScriptOpensInGo(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("testdata/sealed-by-typescript.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var f struct {
|
||||||
|
PrivateKey string `json:"privateKey"`
|
||||||
|
Box SealedBox `json:"box"`
|
||||||
|
Plaintext string `json:"plaintext"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &f); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := Open(f.Box, f.PrivateKey)
|
||||||
|
if err != nil || got != f.Plaintext {
|
||||||
|
t.Fatalf("opened %q, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What Go seals opens with its own key and no other.
|
||||||
|
func TestABoxOpensOnlyForItsRecipient(t *testing.T) {
|
||||||
|
a, _ := GenerateKeyPair()
|
||||||
|
b, _ := GenerateKeyPair()
|
||||||
|
box, err := Seal("a token", a.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := Open(box, a.PrivateKey); err != nil || got != "a token" {
|
||||||
|
t.Fatalf("opened %q, %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := Open(box, b.PrivateKey); err == nil {
|
||||||
|
t.Fatal("a box opened for another key")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The manager's store (novox/hq ADR 0183, design 39 §1): licences with their grants encrypted, bindings
|
||||||
|
// with a generation, what became of each login it was offered, usage readings, and the audit. One
|
||||||
|
// interface, two implementations — postgres for the mesh (pgstore.go), memory for the tests — so every
|
||||||
|
// rule is tested without a database, and the database is asked only to keep rows.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Licence is one licence: an account, or an API key.
|
||||||
|
type Licence struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"` // subscription | api-key
|
||||||
|
AccountUUID string `json:"accountUuid,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
OrganizationUUID string `json:"organizationUuid,omitempty"`
|
||||||
|
Sealed string `json:"-"` // the grant or the key, encrypted at rest
|
||||||
|
RefreshFingerprint string `json:"-"`
|
||||||
|
AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"`
|
||||||
|
RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"`
|
||||||
|
Failures int `json:"failures"`
|
||||||
|
NotifiedAt int64 `json:"-"`
|
||||||
|
AdoptedAt int64 `json:"adoptedAt"`
|
||||||
|
RotatedAt int64 `json:"rotatedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binding is one consumer's binding and the generation it was last given (ADR 0206).
|
||||||
|
type Binding struct {
|
||||||
|
Consumer string `json:"consumer"`
|
||||||
|
Licence string `json:"licence"`
|
||||||
|
Generation int64 `json:"generation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outcome is what became of a login the manager was offered, by its refresh token's fingerprint.
|
||||||
|
type Outcome string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Adopted Outcome = "adopted"
|
||||||
|
Dead Outcome = "dead"
|
||||||
|
Skipped Outcome = "skipped"
|
||||||
|
Refused Outcome = "refused"
|
||||||
|
Gone Outcome = "gone"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UsageRow is one usage reading.
|
||||||
|
type UsageRow struct {
|
||||||
|
Licence string `json:"licence"`
|
||||||
|
At int64 `json:"at"`
|
||||||
|
Reading UsageReading `json:"reading"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store is what the manager keeps.
|
||||||
|
type Store interface {
|
||||||
|
Licences(ctx context.Context) ([]Licence, error)
|
||||||
|
Licence(ctx context.Context, name string) (*Licence, error)
|
||||||
|
LicenceForAccount(ctx context.Context, account string) (*Licence, error)
|
||||||
|
SaveLicence(ctx context.Context, l Licence) error
|
||||||
|
// Lease takes a lease for d, or answers false while another holder's is live.
|
||||||
|
Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error)
|
||||||
|
Unlease(ctx context.Context, key, holder string) error
|
||||||
|
Bindings(ctx context.Context) ([]Binding, error)
|
||||||
|
Binding(ctx context.Context, consumer string) (*Binding, error)
|
||||||
|
// Bind binds (or switches) a consumer at the next generation.
|
||||||
|
Bind(ctx context.Context, consumer, licence string) (Binding, error)
|
||||||
|
Unbind(ctx context.Context, consumer string) (bool, error)
|
||||||
|
// Advance gives every consumer of a licence a new generation: what a rotation is to them.
|
||||||
|
Advance(ctx context.Context, licence string) ([]Binding, error)
|
||||||
|
Outcome(ctx context.Context, fingerprint string) (Outcome, error)
|
||||||
|
RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error
|
||||||
|
RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error
|
||||||
|
Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error)
|
||||||
|
Audit(ctx context.Context, what string, detail map[string]any) error
|
||||||
|
Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemoryStore is the tests' store.
|
||||||
|
type MemoryStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
now func() time.Time
|
||||||
|
rows map[string]Licence
|
||||||
|
binds map[string]Binding
|
||||||
|
leases map[string]struct {
|
||||||
|
holder string
|
||||||
|
until time.Time
|
||||||
|
}
|
||||||
|
outcomes map[string]Outcome
|
||||||
|
usage []UsageRow
|
||||||
|
Audits []map[string]any
|
||||||
|
generation int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMemoryStore is an empty store whose leases age by now.
|
||||||
|
func NewMemoryStore(now func() time.Time) *MemoryStore {
|
||||||
|
return &MemoryStore{now: now, rows: map[string]Licence{}, binds: map[string]Binding{},
|
||||||
|
leases: map[string]struct {
|
||||||
|
holder string
|
||||||
|
until time.Time
|
||||||
|
}{}, outcomes: map[string]Outcome{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryStore) Licences(context.Context) ([]Licence, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
out := make([]Licence, 0, len(m.rows))
|
||||||
|
for _, l := range m.rows {
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryStore) Licence(_ context.Context, name string) (*Licence, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if l, ok := m.rows[name]; ok {
|
||||||
|
return &l, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryStore) LicenceForAccount(_ context.Context, account string) (*Licence, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for _, l := range m.rows {
|
||||||
|
if l.AccountUUID == account {
|
||||||
|
return &l, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryStore) SaveLicence(_ context.Context, l Licence) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.rows[l.Name] = l
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if held, ok := m.leases[key]; ok && held.until.After(m.now()) && held.holder != holder {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
m.leases[key] = struct {
|
||||||
|
holder string
|
||||||
|
until time.Time
|
||||||
|
}{holder, m.now().Add(d)}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryStore) Unlease(_ context.Context, key, holder string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.leases[key].holder == holder {
|
||||||
|
delete(m.leases, key)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryStore) Bindings(context.Context) ([]Binding, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
out := make([]Binding, 0, len(m.binds))
|
||||||
|
for _, b := range m.binds {
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryStore) Binding(_ context.Context, consumer string) (*Binding, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if b, ok := m.binds[consumer]; ok {
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryStore) Bind(_ context.Context, consumer, licence string) (Binding, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.generation++
|
||||||
|
b := Binding{Consumer: consumer, Licence: licence, Generation: m.generation}
|
||||||
|
m.binds[consumer] = b
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryStore) Unbind(_ context.Context, consumer string) (bool, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
_, ok := m.binds[consumer]
|
||||||
|
delete(m.binds, consumer)
|
||||||
|
return ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryStore) Advance(_ context.Context, licence string) ([]Binding, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
var out []Binding
|
||||||
|
for c, b := range m.binds {
|
||||||
|
if b.Licence != licence {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.generation++
|
||||||
|
b.Generation = m.generation
|
||||||
|
m.binds[c] = b
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryStore) Outcome(_ context.Context, fp string) (Outcome, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.outcomes[fp], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryStore) RecordOutcome(_ context.Context, fp, _, _ string, o Outcome, _ string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.outcomes[fp] = o
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, _ map[string]any) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.usage = append(m.usage, UsageRow{Licence: licence, At: at, Reading: r})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryStore) Usage(_ context.Context, licence string, limit int) ([]UsageRow, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
var out []UsageRow
|
||||||
|
for i := len(m.usage) - 1; i >= 0 && len(out) < limit; i-- {
|
||||||
|
if licence == "" || m.usage[i].Licence == licence {
|
||||||
|
out = append(out, m.usage[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryStore) Audit(_ context.Context, what string, detail map[string]any) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
d := map[string]any{"what": what}
|
||||||
|
for k, v := range detail {
|
||||||
|
d[k] = v
|
||||||
|
}
|
||||||
|
m.Audits = append(m.Audits, d)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryStore) Close() {}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Against a real postgres, because the questions are the database's: does the schema apply twice, does a
|
||||||
|
// lease refuse a second holder, does a generation only grow. Skipped unless one is named:
|
||||||
|
//
|
||||||
|
// docker run -d --rm --name licmgr-pg -e POSTGRES_PASSWORD=t -p 15498:5432 postgres:16-alpine
|
||||||
|
// MESH_TEST_POSTGRES=postgres://postgres:t@127.0.0.1:15498/postgres go test ./...
|
||||||
|
func TestTheStoreOnPostgres(t *testing.T) {
|
||||||
|
url := os.Getenv("MESH_TEST_POSTGRES")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("MESH_TEST_POSTGRES unset")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
s, err := OpenPgStore(ctx, url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
for _, table := range []string{"binding", "licence", "lease", "offered", "usage", "audit"} {
|
||||||
|
_, _ = s.pool.Exec(ctx, "drop table if exists "+table+" cascade")
|
||||||
|
}
|
||||||
|
_, _ = s.pool.Exec(ctx, "drop sequence if exists binding_generation")
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if err := s.Migrate(ctx); err != nil {
|
||||||
|
t.Fatalf("migration %d: %v", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l := Licence{Name: "a@example.org", Kind: "subscription", AccountUUID: "u-1", Email: "a@example.org", Sealed: "v1.x.y",
|
||||||
|
RefreshFingerprint: "sha256:1", AccessExpiresAt: 1, RefreshExpiresAt: 2, AdoptedAt: 3}
|
||||||
|
if err := s.SaveLicence(ctx, l); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
l.Failures = 2
|
||||||
|
_ = s.SaveLicence(ctx, l)
|
||||||
|
if got, _ := s.LicenceForAccount(ctx, "u-1"); got == nil || got.Failures != 2 || got.OrganizationUUID != "" {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
if none, err := s.Licence(ctx, "nobody"); none != nil || err != nil {
|
||||||
|
t.Fatalf("%v %v", none, err)
|
||||||
|
}
|
||||||
|
lease := func(holder string) bool {
|
||||||
|
ok, err := s.Lease(ctx, "licence:a", holder, time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
if !lease("one") || lease("two") || !lease("one") {
|
||||||
|
t.Fatal("a lease did not refuse a second holder, or its own holder could not renew it")
|
||||||
|
}
|
||||||
|
_ = s.Unlease(ctx, "licence:a", "one")
|
||||||
|
if !lease("two") {
|
||||||
|
t.Fatal("a released lease was not taken")
|
||||||
|
}
|
||||||
|
b1, _ := s.Bind(ctx, "laptop", l.Name)
|
||||||
|
adv, _ := s.Advance(ctx, l.Name)
|
||||||
|
b3, _ := s.Bind(ctx, "laptop", l.Name)
|
||||||
|
if len(adv) != 1 || !(b1.Generation < adv[0].Generation && adv[0].Generation < b3.Generation) {
|
||||||
|
t.Fatalf("generations %d %v %d", b1.Generation, adv, b3.Generation)
|
||||||
|
}
|
||||||
|
_ = s.RecordOutcome(ctx, "sha256:x", "laptop", "u-1", Dead, "400")
|
||||||
|
if o, _ := s.Outcome(ctx, "sha256:x"); o != Dead {
|
||||||
|
t.Fatalf("outcome %q", o)
|
||||||
|
}
|
||||||
|
if o, _ := s.Outcome(ctx, "sha256:none"); o != "" {
|
||||||
|
t.Fatalf("an unknown login is %q", o)
|
||||||
|
}
|
||||||
|
pct := 1.0
|
||||||
|
_ = s.RecordUsage(ctx, l.Name, 5, UsageReading{SessionPct: &pct}, map[string]any{})
|
||||||
|
if u, _ := s.Usage(ctx, "", 5); len(u) != 1 || *u[0].Reading.SessionPct != 1 {
|
||||||
|
t.Fatalf("usage %v", u)
|
||||||
|
}
|
||||||
|
if err := s.Audit(ctx, "bound", map[string]any{"consumer": "laptop"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ok, _ := s.Unbind(ctx, "laptop"); !ok {
|
||||||
|
t.Fatal("unbind")
|
||||||
|
}
|
||||||
|
all, _ := s.Licences(ctx)
|
||||||
|
if len(all) != 1 || !strings.HasPrefix(all[0].Sealed, "v1.") {
|
||||||
|
t.Fatalf("%v", all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) {
|
||||||
|
prev := FullGrant{AccessToken: "a", RefreshToken: "r", ExpiresAt: 1}
|
||||||
|
in := int64(60)
|
||||||
|
kept := NextGrant(prev, tokenResponse{AccessToken: "a2", ExpiresIn: &in}, 1000)
|
||||||
|
if !kept.OK || kept.Grant.RefreshToken != "r" || kept.Grant.ExpiresAt != 61_000 {
|
||||||
|
t.Fatalf("%+v", kept)
|
||||||
|
}
|
||||||
|
rotated := NextGrant(prev, tokenResponse{AccessToken: "a3", RefreshToken: "r2"}, 0)
|
||||||
|
if rotated.Grant.RefreshToken != "r2" {
|
||||||
|
t.Fatalf("%+v", rotated)
|
||||||
|
}
|
||||||
|
if NextGrant(prev, tokenResponse{}, 0).OK {
|
||||||
|
t.Fatal("an answer with no access token was a grant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) {
|
||||||
|
a, _ := NewCrypt("one key")
|
||||||
|
b, _ := NewCrypt("another key")
|
||||||
|
sealed := a.Seal(`{"refreshToken":"rt"}`)
|
||||||
|
if strings.Contains(sealed, "rt") {
|
||||||
|
t.Fatal("stored in the clear")
|
||||||
|
}
|
||||||
|
if got, err := a.Open(sealed); err != nil || got != `{"refreshToken":"rt"}` {
|
||||||
|
t.Fatalf("%q %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := b.Open(sealed); err == nil {
|
||||||
|
t.Fatal("opened with another key")
|
||||||
|
}
|
||||||
|
if _, err := NewCrypt(" "); err == nil {
|
||||||
|
t.Fatal("an empty key was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"privateKey": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VuBCIEIAi2NK/bN+p7cqYUwv/kz72TgLdmJUfOHCDZTrMpQzpS\n-----END PRIVATE KEY-----\n",
|
||||||
|
"publicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VuAyEARYvD/w+9ah0KWS9T9pd6Ea6CBymUE48vEVk983QPKyY=\n-----END PUBLIC KEY-----\n",
|
||||||
|
"box": {
|
||||||
|
"v": 1,
|
||||||
|
"eph": "MCowBQYDK2VuAyEAIYlSGrJvF8qSjR1aTFWbEQM/NFbZXrarglN0aRLZZ0E=",
|
||||||
|
"iv": "ABqT/hMukn6+xpjh",
|
||||||
|
"tag": "POzmsWTPHSn9xLMMJJ9Akg==",
|
||||||
|
"ct": "m2u0kuQ0PwrsGelM99Z5aBw6FCd6lFISUbwiK5TzzDw4ZrGtsHEvxytoIeFC"
|
||||||
|
},
|
||||||
|
"plaintext": "a grant sealed by the TypeScript agent module"
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The only file that talks to Anthropic (novox/hq ADR 0183): the token endpoint, which this module alone
|
||||||
|
// calls — one rotation source — and the usage endpoint. Ported from the predecessor's manager, whose
|
||||||
|
// client id and error handling were each earned by an incident.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The public Claude Code client's id: not a secret, and a hard-won constant — a metadata URL in its place
|
||||||
|
// answers 400, which the predecessor once misdiagnosed as a dead grant.
|
||||||
|
const clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||||
|
|
||||||
|
func tokenEndpoint() string {
|
||||||
|
if v := os.Getenv("MESH_ANTHROPIC_TOKEN_ENDPOINT"); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "https://platform.claude.com/v1/oauth/token"
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageEndpoint() string {
|
||||||
|
if v := os.Getenv("MESH_ANTHROPIC_USAGE_ENDPOINT"); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "https://api.anthropic.com/api/oauth/usage"
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullGrant is a subscription's grant as this module keeps it: what the agent's credentials file calls
|
||||||
|
// `claudeAiOauth`.
|
||||||
|
type FullGrant struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
RefreshToken string `json:"refreshToken"`
|
||||||
|
ExpiresAt int64 `json:"expiresAt"`
|
||||||
|
RefreshTokenExpiresAt *int64 `json:"refreshTokenExpiresAt,omitempty"`
|
||||||
|
Scopes []string `json:"scopes,omitempty"`
|
||||||
|
SubscriptionType string `json:"subscriptionType,omitempty"`
|
||||||
|
RateLimitTier string `json:"rateLimitTier,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refreshed is what a refresh came to: the next grant and the account the vendor answered for, or why not.
|
||||||
|
type Refreshed struct {
|
||||||
|
OK bool
|
||||||
|
Grant FullGrant
|
||||||
|
Account string // empty when the vendor named none
|
||||||
|
Status int
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vendor is the vendor as the manager reaches it; a test stubs it.
|
||||||
|
type Vendor interface {
|
||||||
|
Refresh(ctx context.Context, g FullGrant) Refreshed
|
||||||
|
Usage(ctx context.Context, accessToken string) (map[string]any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn *int64 `json:"expires_in"`
|
||||||
|
RefreshTokenExpiresIn *int64 `json:"refresh_token_expires_in"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
SubscriptionType string `json:"subscription_type"`
|
||||||
|
Account *struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
} `json:"account"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextGrant is the grant a refresh answered, laid over the one refreshed: a refresh token the vendor did
|
||||||
|
// not rotate is kept, so a rotating vendor and one that does not are both handled.
|
||||||
|
func NextGrant(prev FullGrant, r tokenResponse, nowMs int64) Refreshed {
|
||||||
|
if r.AccessToken == "" {
|
||||||
|
return Refreshed{Status: 200, Reason: "the vendor answered without an access token"}
|
||||||
|
}
|
||||||
|
g := prev
|
||||||
|
g.AccessToken = r.AccessToken
|
||||||
|
if r.RefreshToken != "" {
|
||||||
|
g.RefreshToken = r.RefreshToken
|
||||||
|
}
|
||||||
|
if r.ExpiresIn != nil {
|
||||||
|
g.ExpiresAt = nowMs + *r.ExpiresIn*1000
|
||||||
|
}
|
||||||
|
if r.RefreshTokenExpiresIn != nil {
|
||||||
|
v := nowMs + *r.RefreshTokenExpiresIn*1000
|
||||||
|
g.RefreshTokenExpiresAt = &v
|
||||||
|
}
|
||||||
|
if len(r.Scopes) > 0 {
|
||||||
|
g.Scopes = r.Scopes
|
||||||
|
} else if r.Scope != "" {
|
||||||
|
g.Scopes = strings.Fields(r.Scope)
|
||||||
|
}
|
||||||
|
if r.SubscriptionType != "" {
|
||||||
|
g.SubscriptionType = r.SubscriptionType
|
||||||
|
}
|
||||||
|
out := Refreshed{OK: true, Grant: g}
|
||||||
|
if r.Account != nil {
|
||||||
|
out.Account = r.Account.UUID
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type liveVendor struct{ client *http.Client }
|
||||||
|
|
||||||
|
// LiveVendor is the vendor over the network.
|
||||||
|
func LiveVendor() Vendor { return liveVendor{client: &http.Client{Timeout: 30 * time.Second}} }
|
||||||
|
|
||||||
|
func (v liveVendor) Refresh(ctx context.Context, g FullGrant) Refreshed {
|
||||||
|
form := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {g.RefreshToken}, "client_id": {clientID}}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint(), strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return Refreshed{Reason: err.Error()}
|
||||||
|
}
|
||||||
|
req.Header.Set("content-type", "application/x-www-form-urlencoded")
|
||||||
|
resp, err := v.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return Refreshed{Reason: "the token endpoint did not answer: " + err.Error()}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
// The body, never only the status: a malformed request and a revoked grant both answer 400.
|
||||||
|
reason := string(body)
|
||||||
|
if len(reason) > 400 {
|
||||||
|
reason = reason[:400]
|
||||||
|
}
|
||||||
|
return Refreshed{Status: resp.StatusCode, Reason: reason}
|
||||||
|
}
|
||||||
|
var r tokenResponse
|
||||||
|
if err := json.Unmarshal(body, &r); err != nil {
|
||||||
|
return Refreshed{Status: resp.StatusCode, Reason: "the vendor's answer is not JSON"}
|
||||||
|
}
|
||||||
|
return NextGrant(g, r, time.Now().UnixMilli())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]any, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageEndpoint(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("authorization", "Bearer "+accessToken)
|
||||||
|
resp, err := v.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return nil, fmt.Errorf("the usage endpoint answered %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
return out, json.NewDecoder(resp.Body).Decode(&out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageReading is the licence-grain reading (ADR 0054), flattened from the vendor's windows.
|
||||||
|
type UsageReading struct {
|
||||||
|
SessionPct *float64 `json:"sessionPct"`
|
||||||
|
SessionResetsAt string `json:"sessionResetsAt,omitempty"`
|
||||||
|
WeeklyPct *float64 `json:"weeklyPct"`
|
||||||
|
SonnetPct *float64 `json:"sonnetPct"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlattenUsage reads the windows the predecessor read.
|
||||||
|
func FlattenUsage(u map[string]any) UsageReading {
|
||||||
|
window := func(k string) (*float64, string) {
|
||||||
|
w, ok := u[k].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
pct, ok := w["utilization"].(float64)
|
||||||
|
resets, _ := w["resets_at"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, resets
|
||||||
|
}
|
||||||
|
return &pct, resets
|
||||||
|
}
|
||||||
|
s, resets := window("five_hour")
|
||||||
|
w, _ := window("seven_day")
|
||||||
|
so, _ := window("seven_day_sonnet")
|
||||||
|
return UsageReading{SessionPct: s, SessionResetsAt: resets, WeeklyPct: w, SonnetPct: so}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
module claude-licence-manager
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||||
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||||
|
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
{
|
||||||
|
"module": "claude-licence-manager",
|
||||||
|
"version": "1",
|
||||||
|
"slug": "licmgr",
|
||||||
|
"requires": [
|
||||||
|
"postgres-database",
|
||||||
|
"secret"
|
||||||
|
],
|
||||||
|
"contributes": {
|
||||||
|
"postgres-database": {
|
||||||
|
"name": "claude_licences"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"binds": {
|
||||||
|
"postgres-database": "${dir:state}/database.json"
|
||||||
|
},
|
||||||
|
"secrets": {
|
||||||
|
"postgres-database": "${dir:state}/database.secret",
|
||||||
|
"secret": {
|
||||||
|
"grant-key": "${dir:state}/grant.key"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"seats": [
|
||||||
|
{
|
||||||
|
"name": "anthropic-licence-manager",
|
||||||
|
"scope": "mesh",
|
||||||
|
"serves": [
|
||||||
|
"licences",
|
||||||
|
"bindings",
|
||||||
|
"bind",
|
||||||
|
"switch",
|
||||||
|
"release",
|
||||||
|
"refresh",
|
||||||
|
"usage",
|
||||||
|
"adopt",
|
||||||
|
"current"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"claims": [
|
||||||
|
{
|
||||||
|
"name": "anthropic-licence-manager",
|
||||||
|
"scope": "mesh",
|
||||||
|
"serves": [
|
||||||
|
"licences",
|
||||||
|
"bindings",
|
||||||
|
"bind",
|
||||||
|
"switch",
|
||||||
|
"release",
|
||||||
|
"refresh",
|
||||||
|
"usage",
|
||||||
|
"adopt",
|
||||||
|
"current"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"emits": [
|
||||||
|
"licence.adopted",
|
||||||
|
"licence.refused",
|
||||||
|
"licence.failing",
|
||||||
|
"usage.read"
|
||||||
|
],
|
||||||
|
"state": [
|
||||||
|
"bindings"
|
||||||
|
],
|
||||||
|
"reads": [
|
||||||
|
"claude-code.holdings"
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "state",
|
||||||
|
"type": "directory",
|
||||||
|
"mode": "0700",
|
||||||
|
"place": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "database-url",
|
||||||
|
"type": "file",
|
||||||
|
"path": "${dir:state}/database.url",
|
||||||
|
"mode": "0600",
|
||||||
|
"content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "settings",
|
||||||
|
"type": "file",
|
||||||
|
"path": "${dir:state}/settings.json",
|
||||||
|
"mode": "0600",
|
||||||
|
"merge": "json",
|
||||||
|
"content": "{\n \"cadence_minutes\": 240,\n \"floor_minutes\": 60,\n \"failures_to_notify\": 3,\n \"cooldown_hours\": 24,\n \"refresh_warn_days\": 3\n}\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "prepare",
|
||||||
|
"type": "process",
|
||||||
|
"name": "claude-licence-manager-prepare",
|
||||||
|
"artifact": "code",
|
||||||
|
"run": [
|
||||||
|
"./claude-licence-manager",
|
||||||
|
"prepare"
|
||||||
|
],
|
||||||
|
"run-once": true,
|
||||||
|
"env": {
|
||||||
|
"DATABASE_URL_FILE": "${dir:state}/database.url"
|
||||||
|
},
|
||||||
|
"restart-on": [
|
||||||
|
"database-url"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "code",
|
||||||
|
"kind": "bundle",
|
||||||
|
"language": "go",
|
||||||
|
"system": "arch",
|
||||||
|
"from": "cmd/claude-licence-manager",
|
||||||
|
"binary": "claude-licence-manager",
|
||||||
|
"loads": [
|
||||||
|
"claude-licence-manager"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"DATABASE_URL_FILE": "${dir:state}/database.url",
|
||||||
|
"MESH_LICENCE_STATE": "${dir:state}",
|
||||||
|
"MESH_LICENCE_KEY_FILE": "${dir:state}/grant.key",
|
||||||
|
"MESH_LICENCE_SETTINGS": "${dir:state}/settings.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,10 @@
|
|||||||
],
|
],
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/var/lib/mesh-registry:/var/lib/registry"
|
"/var/lib/mesh-registry:/var/lib/registry"
|
||||||
]
|
],
|
||||||
|
"env": {
|
||||||
|
"REGISTRY_STORAGE_DELETE_ENABLED": "true"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
# gitea's runtime: the tool runtime, carrying this module's compiled provisioner, tools and event
|
|
||||||
# consumer.
|
|
||||||
#
|
|
||||||
# **Built from this module's own directory and nothing else.** The sdk is in the base image, so
|
|
||||||
# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this from a
|
|
||||||
# repository and a path (novox/hq ADR 0069) rather than only on a workstation that happens to have
|
|
||||||
# the siblings.
|
|
||||||
#
|
|
||||||
# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in.
|
|
||||||
# They are different images on purpose — the first carries a compiler and the second must not, or
|
|
||||||
# every running container would carry one it never invokes. The mesh answers both with the copies it
|
|
||||||
# holds, because a fingerprint written here would name one particular copy and no other mesh has it
|
|
||||||
# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build
|
|
||||||
# nobody told stops here and says which module to build first.
|
|
||||||
ARG BUILD_BASE
|
|
||||||
ARG RUNTIME_BASE
|
|
||||||
|
|
||||||
FROM ${BUILD_BASE} AS build
|
|
||||||
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
|
|
||||||
# node_modules — the module is compiled against exactly the sdk it will run against.
|
|
||||||
WORKDIR /app/modules/gitea
|
|
||||||
COPY . .
|
|
||||||
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
|
|
||||||
# symlinks to a launcher that requires its library relatively — resolved away when the base image
|
|
||||||
# was assembled.
|
|
||||||
RUN node /app/node_modules/typescript/bin/tsc client.ts token.ts index.ts provisioner/index.ts tools/index.ts \
|
|
||||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
|
||||||
|
|
||||||
FROM ${RUNTIME_BASE}
|
|
||||||
# **No apt packages.** gitea's provisioner talks to the forge over HTTP (the gitea REST API), not
|
|
||||||
# through a CLI the way postgres drives psql — so the runtime base holds everything this needs.
|
|
||||||
COPY --from=build /app/modules/gitea/dist /app/modules/gitea/dist
|
|
||||||
# What a tool host should load from this module: its event consumer and its tools, which are
|
|
||||||
# separate entrypoints because they are loaded by different things. The provisioner is the third,
|
|
||||||
# and is not listed here — the declaration names it in the container's `args`, because it is what
|
|
||||||
# this module's own container runs. One image, because they are one module and share a client.
|
|
||||||
ENV MESH_TOOL_MODULES=/app/modules/gitea/dist/index.js,/app/modules/gitea/dist/tools/index.js,/app/modules/gitea/dist/provisioner/index.js
|
|
||||||
+21
-44
@@ -85,9 +85,6 @@
|
|||||||
"scope": "mesh"
|
"scope": "mesh"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"own-secrets": {
|
|
||||||
"broker": "${dir:mesh-state}/broker"
|
|
||||||
},
|
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "mesh-state",
|
"id": "mesh-state",
|
||||||
@@ -180,32 +177,6 @@
|
|||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"content": "{}\n",
|
"content": "{}\n",
|
||||||
"merge": "json"
|
"merge": "json"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "runtime",
|
|
||||||
"type": "container",
|
|
||||||
"name": "mesh-gitea",
|
|
||||||
"network": "host",
|
|
||||||
"volumes": [
|
|
||||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
|
||||||
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
|
|
||||||
"${dir:grants}:${dir:grants}:ro",
|
|
||||||
"${dir:state}/admin.secret:/run/secrets/admin:ro",
|
|
||||||
"${dir:runtime-state}:/run/state"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
|
||||||
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
|
|
||||||
"MESH_GITEA_CONFIG_FILE": "/run/config/config.json",
|
|
||||||
"MESH_GITEA_ADMIN_USER": "mesh-admin",
|
|
||||||
"MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin",
|
|
||||||
"MESH_GITEA_STATE_DIR": "/run/state",
|
|
||||||
"MESH_RECEIVES": "${dir:grants}/npm.json"
|
|
||||||
},
|
|
||||||
"artifact": "runtime",
|
|
||||||
"restart-on": [
|
|
||||||
"runtime-config"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"provides": [
|
"provides": [
|
||||||
@@ -219,23 +190,29 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"on": [
|
|
||||||
{
|
|
||||||
"arg": "BUILD_BASE",
|
|
||||||
"module": "mesh-tools",
|
|
||||||
"artifact": "build"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"arg": "RUNTIME_BASE",
|
|
||||||
"module": "mesh-tools",
|
|
||||||
"artifact": "runtime"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
"name": "runtime",
|
"name": "code",
|
||||||
"kind": "image",
|
"kind": "bundle",
|
||||||
"from": "Dockerfile"
|
"language": "typescript",
|
||||||
|
"entrypoints": [
|
||||||
|
"index.js",
|
||||||
|
"tools/index.js",
|
||||||
|
"provisioner/index.js"
|
||||||
|
],
|
||||||
|
"loads": [
|
||||||
|
"index.js",
|
||||||
|
"tools/index.js",
|
||||||
|
"provisioner/index.js"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
|
||||||
|
"MESH_GITEA_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||||
|
"MESH_GITEA_ADMIN_USER": "mesh-admin",
|
||||||
|
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
|
||||||
|
"MESH_GITEA_STATE_DIR": "${dir:runtime-state}",
|
||||||
|
"MESH_RECEIVES": "${dir:grants}/npm.json"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:state}/api.env",
|
"path": "${dir:state}/api.env",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_BUCKET=mesh-novox-invoice\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n"
|
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_BUCKET=${bound:s3-bucket:bucket}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "net",
|
"id": "net",
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
# lab's runtime: the tool runtime, carrying this module's code, and the toolchain the lab's suite
|
|
||||||
# builds the mesh with (novox/hq ADR 0172). It reaches the machine's virtualisation and container
|
|
||||||
# runtime through their sockets, so what it raises is what a hand run on this machine raises.
|
|
||||||
#
|
|
||||||
# Every download is pinned by its checksum: an image that builds the mesh is the last place to take
|
|
||||||
# whatever an upstream serves today.
|
|
||||||
ARG BUILD_BASE
|
|
||||||
ARG RUNTIME_BASE
|
|
||||||
|
|
||||||
FROM ${BUILD_BASE} AS build
|
|
||||||
WORKDIR /app/modules/lab
|
|
||||||
COPY . .
|
|
||||||
RUN node /app/node_modules/typescript/bin/tsc tools/index.ts tools/runs.ts --rootDir . \
|
|
||||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
|
||||||
|
|
||||||
FROM ${RUNTIME_BASE}
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends git make ca-certificates curl python3 file iproute2 sudo \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
RUN curl -fsSL -o /tmp/go.tgz https://go.dev/dl/go1.26.8.linux-amd64.tar.gz \
|
|
||||||
&& echo "d0f743b33e8d8945e6b1f432edd15785c70507121d6e2a723b21285eddf8b57b /tmp/go.tgz" | sha256sum -c - \
|
|
||||||
&& tar -C /usr/local -xzf /tmp/go.tgz && rm /tmp/go.tgz
|
|
||||||
RUN curl -fsSL -o /usr/local/bin/incus https://github.com/lxc/incus/releases/download/v7.5.1/bin.linux.incus.x86_64 \
|
|
||||||
&& echo "7bd6223b369f4d693fcde695bd8549a73b5b3d403735329212483702aa22c179 /usr/local/bin/incus" | sha256sum -c - \
|
|
||||||
&& chmod 0755 /usr/local/bin/incus
|
|
||||||
RUN curl -fsSL -o /tmp/docker.tgz https://download.docker.com/linux/static/stable/x86_64/docker-28.5.2.tgz \
|
|
||||||
&& echo "ea90cfd12e1eeb12aa1c971741adb8bd4ed88e2a574eaac13f5029a1dbc6300d /tmp/docker.tgz" | sha256sum -c - \
|
|
||||||
&& tar -C /tmp -xzf /tmp/docker.tgz docker/docker && mv /tmp/docker/docker /usr/local/bin/docker && rm -rf /tmp/docker /tmp/docker.tgz
|
|
||||||
ENV PATH=/usr/local/go/bin:$PATH
|
|
||||||
COPY --from=build /app/modules/lab/dist /app/modules/lab/dist
|
|
||||||
ENV MESH_TOOL_MODULES=/app/modules/lab/dist/tools/index.js
|
|
||||||
+56
-45
@@ -5,16 +5,7 @@
|
|||||||
"container-runtime",
|
"container-runtime",
|
||||||
"virtualisation"
|
"virtualisation"
|
||||||
],
|
],
|
||||||
"own-secrets": {
|
|
||||||
"broker": "${dir:mesh-state}/broker"
|
|
||||||
},
|
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
|
||||||
"id": "mesh-state",
|
|
||||||
"type": "directory",
|
|
||||||
"mode": "0700",
|
|
||||||
"place": "mesh"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "state",
|
"id": "state",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
@@ -35,47 +26,67 @@
|
|||||||
"content": "MESH_LAB_FORGE=${setting:forge}\n"
|
"content": "MESH_LAB_FORGE=${setting:forge}\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "runtime",
|
"id": "git",
|
||||||
"type": "container",
|
"type": "package",
|
||||||
"name": "mesh-lab",
|
"package": "git"
|
||||||
"network": "host",
|
},
|
||||||
"env-file": [
|
{
|
||||||
"${dir:state}/lab.env"
|
"id": "make",
|
||||||
],
|
"type": "package",
|
||||||
"volumes": [
|
"package": "make"
|
||||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
},
|
||||||
"${dir:work}:${dir:work}",
|
{
|
||||||
"/var/run/docker.sock:/var/run/docker.sock",
|
"id": "python",
|
||||||
"/var/lib/incus/unix.socket:/var/lib/incus/unix.socket"
|
"type": "package",
|
||||||
],
|
"package": "python"
|
||||||
"env": {
|
},
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
{
|
||||||
"MESH_LAB_WORK": "${dir:work}"
|
"id": "file",
|
||||||
},
|
"type": "package",
|
||||||
"restart-on": [
|
"package": "file"
|
||||||
"runtime-env"
|
},
|
||||||
],
|
{
|
||||||
"artifact": "runtime"
|
"id": "iproute2",
|
||||||
|
"type": "package",
|
||||||
|
"package": "iproute2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sudo",
|
||||||
|
"type": "package",
|
||||||
|
"package": "sudo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "npm",
|
||||||
|
"type": "package",
|
||||||
|
"package": "npm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "go",
|
||||||
|
"type": "package",
|
||||||
|
"package": "go"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "incus",
|
||||||
|
"type": "package",
|
||||||
|
"package": "incus"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"on": [
|
|
||||||
{
|
|
||||||
"arg": "BUILD_BASE",
|
|
||||||
"module": "mesh-tools",
|
|
||||||
"artifact": "build"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"arg": "RUNTIME_BASE",
|
|
||||||
"module": "mesh-tools",
|
|
||||||
"artifact": "runtime"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
"name": "runtime",
|
"name": "code",
|
||||||
"kind": "image",
|
"kind": "bundle",
|
||||||
"from": "Dockerfile"
|
"language": "typescript",
|
||||||
|
"entrypoints": [
|
||||||
|
"tools/index.js"
|
||||||
|
],
|
||||||
|
"loads": [
|
||||||
|
"tools/index.js"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"MESH_LAB_WORK": "${dir:work}",
|
||||||
|
"MESH_LAB_ENV_FILE": "${dir:state}/lab.env"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,23 @@
|
|||||||
// lab is assigned to, and only there: a bed raises virtual machines on that machine's virtualisation.
|
// lab is assigned to, and only there: a bed raises virtual machines on that machine's virtualisation.
|
||||||
|
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||||
import { listRuns, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js";
|
import { listRuns, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js";
|
||||||
|
|
||||||
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||||
const work = env.MESH_LAB_WORK ?? "/var/lib/mesh-lab-runs";
|
const work = env.MESH_LAB_WORK ?? "/var/lib/mesh-lab-runs";
|
||||||
const forge = (env.MESH_LAB_FORGE ?? "").replace(/\/+$/, "");
|
// The forge is an operator's setting, which reaches a file and never a bundle's words (novox/hq
|
||||||
|
// ADR 0192): read from the env-file the mesh fills, at each call, so a changed setting is used
|
||||||
|
// 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(/\/+$/, "");
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: "lab_check",
|
name: "lab_check",
|
||||||
description: "Whether this machine can run the lab's beds: the lab's own check, against the forge's main branch.",
|
description: "Whether this machine can run the lab's beds: the lab's own check, against the forge's main branch.",
|
||||||
input: {},
|
input: {},
|
||||||
run: async () => {
|
run: async () => {
|
||||||
|
const forge = forgeOf();
|
||||||
if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
||||||
const dir = `${work}/check`;
|
const dir = `${work}/check`;
|
||||||
spawnSync("rm", ["-rf", dir]);
|
spawnSync("rm", ["-rf", dir]);
|
||||||
@@ -38,6 +43,7 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
run: async (args) => {
|
run: async (args) => {
|
||||||
|
const forge = forgeOf();
|
||||||
if (!forge) return { started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
if (!forge) return { started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
||||||
const tests = String(args.tests ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
const tests = String(args.tests ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
if (tests.length === 0) return { started: false, reason: "name at least one bed test file" };
|
if (tests.length === 0) return { started: false, reason: "name at least one bed test file" };
|
||||||
@@ -83,4 +89,20 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One word from an env-file (`KEY=value` lines), or "" when the file or the word is absent. */
|
||||||
|
export function wordIn(file: string | undefined, word: string): string {
|
||||||
|
if (!file) return "";
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = readFileSync(file, "utf8");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const at = line.indexOf("=");
|
||||||
|
if (at > 0 && line.slice(0, at).trim() === word) return line.slice(at + 1).trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
registerModuleTools("lab", (env) => getLabTools(env));
|
registerModuleTools("lab", (env) => getLabTools(env));
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
# mailu's runtime: the tool runtime, carrying this module's compiled code.
|
|
||||||
#
|
|
||||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
|
||||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
|
||||||
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
|
|
||||||
# happens to have the siblings.
|
|
||||||
#
|
|
||||||
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
|
|
||||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
|
||||||
ARG BUILD_BASE
|
|
||||||
ARG RUNTIME_BASE
|
|
||||||
|
|
||||||
FROM ${BUILD_BASE} AS build
|
|
||||||
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
|
|
||||||
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
|
|
||||||
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
|
|
||||||
# resolved away.
|
|
||||||
WORKDIR /app/modules/mailu
|
|
||||||
COPY . .
|
|
||||||
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \
|
|
||||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
|
||||||
|
|
||||||
FROM ${RUNTIME_BASE}
|
|
||||||
COPY --from=build /app/modules/mailu/dist /app/modules/mailu/dist
|
|
||||||
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
|
||||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
|
||||||
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
|
|
||||||
# provisioner (`run`) served no tools and emitted no events; a container that named no command
|
|
||||||
# ran no provisioner at all.
|
|
||||||
ENV MESH_TOOL_MODULES=/app/modules/mailu/dist/index.js,/app/modules/mailu/dist/tools/index.js,/app/modules/mailu/dist/provisioner/index.js
|
|
||||||
+30
-41
@@ -135,11 +135,15 @@
|
|||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "automx: mail client autoconfiguration; the autoconfig, autodiscover and automx names are route grants reaching it here"
|
"why": "automx: mail client autoconfiguration; the autoconfig, autodiscover and automx names are route grants reaching it here"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "admin-api",
|
||||||
|
"port": 8080,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"from": "machine",
|
||||||
|
"why": "the admin API, which this module's own code reaches on loopback from the node's runtime now that it runs outside the mailu network"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"own-secrets": {
|
|
||||||
"broker": "${dir:mesh-state}/broker"
|
|
||||||
},
|
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "mesh-state",
|
"id": "mesh-state",
|
||||||
@@ -305,6 +309,9 @@
|
|||||||
"name": "mailu-admin",
|
"name": "mailu-admin",
|
||||||
"image": "ghcr.io/mailu/admin@sha256:6dbfdadc4a9590dcb7652357b505200115b689b74008653bbf369e4599a3be5a",
|
"image": "ghcr.io/mailu/admin@sha256:6dbfdadc4a9590dcb7652357b505200115b689b74008653bbf369e4599a3be5a",
|
||||||
"network": "mailu",
|
"network": "mailu",
|
||||||
|
"ports": [
|
||||||
|
"8080"
|
||||||
|
],
|
||||||
"env-file": [
|
"env-file": [
|
||||||
"${dir:state}/mailu.env",
|
"${dir:state}/mailu.env",
|
||||||
"${dir:state}/secret.env",
|
"${dir:state}/secret.env",
|
||||||
@@ -473,31 +480,6 @@
|
|||||||
"content": "{}\n",
|
"content": "{}\n",
|
||||||
"merge": "json"
|
"merge": "json"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "runtime",
|
|
||||||
"type": "container",
|
|
||||||
"name": "mesh-mailu",
|
|
||||||
"network": "mailu",
|
|
||||||
"volumes": [
|
|
||||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
|
||||||
"${dir:state}/api-token.secret:/run/secrets/api-token:ro",
|
|
||||||
"${dir:grants}:${dir:grants}:ro",
|
|
||||||
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
|
|
||||||
"/var/run/docker.sock:/var/run/docker.sock"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
|
||||||
"MESH_MAILU_URL": "http://mailu-admin:8080/api/v1",
|
|
||||||
"MESH_MAILU_API_KEY_FILE": "/run/secrets/api-token",
|
|
||||||
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
|
|
||||||
"MESH_MAILU_CONFIG_FILE": "/run/config/config.json",
|
|
||||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
|
||||||
},
|
|
||||||
"restart-on": [
|
|
||||||
"runtime-config"
|
|
||||||
],
|
|
||||||
"artifact": "runtime"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "automx",
|
"id": "automx",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
@@ -517,16 +499,6 @@
|
|||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"on": [
|
"on": [
|
||||||
{
|
|
||||||
"arg": "BUILD_BASE",
|
|
||||||
"module": "mesh-tools",
|
|
||||||
"artifact": "build"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"arg": "RUNTIME_BASE",
|
|
||||||
"module": "mesh-tools",
|
|
||||||
"artifact": "runtime"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"arg": "PYTHON_BASE",
|
"arg": "PYTHON_BASE",
|
||||||
"image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228"
|
"image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228"
|
||||||
@@ -534,9 +506,26 @@
|
|||||||
],
|
],
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
"name": "runtime",
|
"name": "code",
|
||||||
"kind": "image",
|
"kind": "bundle",
|
||||||
"from": "Dockerfile"
|
"language": "typescript",
|
||||||
|
"entrypoints": [
|
||||||
|
"index.js",
|
||||||
|
"tools/index.js",
|
||||||
|
"provisioner/index.js"
|
||||||
|
],
|
||||||
|
"loads": [
|
||||||
|
"index.js",
|
||||||
|
"tools/index.js",
|
||||||
|
"provisioner/index.js"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"MESH_MAILU_URL": "http://127.0.0.1:${port:8080}/api/v1",
|
||||||
|
"MESH_MAILU_API_KEY_FILE": "${dir:state}/api-token.secret",
|
||||||
|
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
|
||||||
|
"MESH_MAILU_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||||
|
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "automx",
|
"name": "automx",
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
# mesh-catalog's runtime: the tool runtime, carrying the catalogue's compiled graph, its consumer
|
|
||||||
# of what the builder announces, and its tools.
|
|
||||||
#
|
|
||||||
# **Built from this module's own directory and nothing else.** The sdk is in the base image, so
|
|
||||||
# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this from a
|
|
||||||
# repository and a path (novox/hq ADR 0069) rather than only on a workstation with the siblings.
|
|
||||||
#
|
|
||||||
# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in.
|
|
||||||
# They are different images on purpose — the first carries a compiler and the second must not, or
|
|
||||||
# every running container would carry one it never invokes. The mesh answers both with the copies it
|
|
||||||
# holds, because a fingerprint written here would name one particular copy and no other mesh has it
|
|
||||||
# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build
|
|
||||||
# nobody told stops here and says which module to build first.
|
|
||||||
ARG BUILD_BASE
|
|
||||||
ARG RUNTIME_BASE
|
|
||||||
|
|
||||||
FROM ${BUILD_BASE} AS build
|
|
||||||
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
|
|
||||||
# node_modules — the module is compiled against exactly the sdk it will run against.
|
|
||||||
WORKDIR /app/modules/mesh-catalog
|
|
||||||
COPY . .
|
|
||||||
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
|
|
||||||
# symlinks to a launcher that requires its library relatively — resolved away when the base image
|
|
||||||
# was assembled.
|
|
||||||
RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts prepare/index.ts \
|
|
||||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
|
||||||
|
|
||||||
# **A module may need something the base image does not carry.** The base holds what every module
|
|
||||||
# needs — the sdk, the broker client — and a postgres driver is not that: the one other module that
|
|
||||||
# reaches a database shells out to psql instead. So the catalogue brings its own.
|
|
||||||
#
|
|
||||||
# Installed into an empty directory rather than into the module's, because the module's package.json
|
|
||||||
# also names `@novox/mesh-sdk`, which is not on any registry — it is in the base image. Asking npm to
|
|
||||||
# resolve this module's dependencies would therefore fail on the one it already has.
|
|
||||||
RUN mkdir -p /deps && cd /deps && \
|
|
||||||
npm install --omit=dev --no-audit --no-fund --no-package-lock pg@8
|
|
||||||
|
|
||||||
FROM ${RUNTIME_BASE}
|
|
||||||
COPY --from=build /app/modules/mesh-catalog/dist /app/modules/mesh-catalog/dist
|
|
||||||
# Beside the compiled code, so `pg` resolves from it while `@novox/mesh-sdk` keeps walking up to the
|
|
||||||
# base image's own node_modules — the module gets its extra dependency without shadowing the sdk it
|
|
||||||
# was compiled against.
|
|
||||||
COPY --from=build /deps/node_modules /app/modules/mesh-catalog/node_modules
|
|
||||||
# Both entrypoints, loaded in serve mode.
|
|
||||||
#
|
|
||||||
# **A consumer cannot be started with `run`.** That mode imports an entrypoint without binding a
|
|
||||||
# broker — it is for a step that does its work offline and exits — and the catalogue's whole job is
|
|
||||||
# to listen for what the builder announces. Serve binds the broker first, then imports these, so
|
|
||||||
# `on()` has something to subscribe to.
|
|
||||||
ENV MESH_TOOL_MODULES=/app/modules/mesh-catalog/dist/index.js,/app/modules/mesh-catalog/dist/tools/index.js
|
|
||||||
|
|
||||||
# And what prepares this module's state, for the runtime's `prepare` mode (novox/hq ADR 0135). Named
|
|
||||||
# here, beside the entrypoints above, because the module knows which of its files prepares its state
|
|
||||||
# and nothing else could: the mesh asks one word and this says what answers it.
|
|
||||||
ENV MESH_PREPARE=/app/modules/mesh-catalog/dist/prepare/index.js
|
|
||||||
@@ -25,9 +25,6 @@
|
|||||||
"secrets": {
|
"secrets": {
|
||||||
"postgres-database": "${dir:state}/database.secret"
|
"postgres-database": "${dir:state}/database.secret"
|
||||||
},
|
},
|
||||||
"own-secrets": {
|
|
||||||
"broker": "${dir:mesh-state}/broker"
|
|
||||||
},
|
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"mesh-build-machine.built",
|
"mesh-build-machine.built",
|
||||||
"mesh-controller.built-before"
|
"mesh-controller.built-before"
|
||||||
@@ -38,14 +35,7 @@
|
|||||||
"rebuild-needed",
|
"rebuild-needed",
|
||||||
"catching-up"
|
"catching-up"
|
||||||
],
|
],
|
||||||
"prepares": true,
|
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
|
||||||
"id": "mesh-state",
|
|
||||||
"type": "directory",
|
|
||||||
"mode": "0700",
|
|
||||||
"place": "mesh"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "state",
|
"id": "state",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
@@ -60,43 +50,41 @@
|
|||||||
"content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n"
|
"content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "runtime",
|
"id": "prepare",
|
||||||
"type": "container",
|
"type": "process",
|
||||||
"name": "mesh-catalog",
|
"name": "mesh-catalog-prepare",
|
||||||
"network": "host",
|
"artifact": "code",
|
||||||
"volumes": [
|
"run": [
|
||||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
"node",
|
||||||
"${dir:state}:/run/state",
|
"prepare/index.js"
|
||||||
"${dir:state}/database.url:/run/secrets/database-url:ro"
|
|
||||||
],
|
],
|
||||||
|
"run-once": true,
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
"DATABASE_URL_FILE": "${dir:state}/database.url"
|
||||||
"DATABASE_URL_FILE": "/run/secrets/database-url"
|
|
||||||
},
|
},
|
||||||
"artifact": "runtime",
|
|
||||||
"restart-on": [
|
"restart-on": [
|
||||||
"database-url"
|
"database-url"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"on": [
|
|
||||||
{
|
|
||||||
"arg": "BUILD_BASE",
|
|
||||||
"module": "mesh-tools",
|
|
||||||
"artifact": "build"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"arg": "RUNTIME_BASE",
|
|
||||||
"module": "mesh-tools",
|
|
||||||
"artifact": "runtime"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
"name": "runtime",
|
"name": "code",
|
||||||
"kind": "image",
|
"kind": "bundle",
|
||||||
"from": "Dockerfile"
|
"language": "typescript",
|
||||||
|
"entrypoints": [
|
||||||
|
"index.js",
|
||||||
|
"tools/index.js",
|
||||||
|
"prepare/index.js"
|
||||||
|
],
|
||||||
|
"loads": [
|
||||||
|
"index.js",
|
||||||
|
"tools/index.js"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"DATABASE_URL_FILE": "${dir:state}/database.url"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
// Ambient types for `pg` (node-postgres), which ships its types only via the separate `@types/pg`
|
// Ambient types for `pg` (node-postgres), which ships its types only via the separate `@types/pg`
|
||||||
// package. Rather than pull that in at tsc time, this declares the exact slice model-usage uses —
|
// package. Rather than pull that in at tsc time, this declares the exact slice model-usage uses —
|
||||||
// the same precedent anthropic-manager sets for `tweetnacl-sealedbox-js` (a local ambient .d.ts,
|
// the same precedent anthropic-manager sets for `tweetnacl-sealedbox-js` (a local ambient .d.ts,
|
||||||
// listed in tsconfig `include`, default-imported). The real `pg` is installed into the module's
|
// listed in tsconfig `include`, default-imported). The real `pg` is the package.json dependency the
|
||||||
// runtime image (package.json `dependencies`; novox/hq ADR 0052), so this types the code without
|
// builder installs and inlines into the module's bundle (novox/hq ADR 0198 §4), so this types the
|
||||||
// deciding what runs.
|
// code without deciding what runs.
|
||||||
declare module "pg" {
|
declare module "pg" {
|
||||||
/** One checked-out connection. Needed because registering a module-version and its edges is one
|
/** One checked-out connection. Needed because registering a module-version and its edges is one
|
||||||
* act: a half-written registration is a graph that lies about what something was built against. */
|
* act: a half-written registration is a graph that lies about what something was built against. */
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
// nothing anywhere said so.
|
// nothing anywhere said so.
|
||||||
//
|
//
|
||||||
// Nothing here connects to the broker. Preparation runs before the version that would use it, so
|
// Nothing here connects to the broker. Preparation runs before the version that would use it, so
|
||||||
// there is nothing yet to talk to; the runtime's `prepare` mode imports this and awaits it, and this
|
// there is nothing yet to talk to: the host runs this file as a run-once process, with the module's
|
||||||
// process exiting non-zero is how the host knows not to start the runtime.
|
// words and no bus (novox/hq ADR 0198 §3), before the node's runtime is started with the version
|
||||||
|
// that needs it, and this process exiting non-zero is how the host knows the step did not complete.
|
||||||
import { Graph } from "../store.js";
|
import { Graph } from "../store.js";
|
||||||
|
|
||||||
const graph = Graph.fromEnv();
|
const graph = Graph.fromEnv();
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
# mesh-vault's runtime: the tool runtime, carrying this module's compiled provisioner, tools and event
|
|
||||||
# consumer. The same shape as postgres's, minus the client the database needs: mesh-vault reaches no
|
|
||||||
# server, because what it provides is a value the mesh already delivered to its node.
|
|
||||||
#
|
|
||||||
# **Built from this module's own directory and nothing else.** The sdk is in the base image, so
|
|
||||||
# nothing is copied out of a neighbouring checkout (novox/hq ADR 0069). Two bases, named rather than
|
|
||||||
# pinned — the image this is COMPILED in and the image it RUNS in — answered by the mesh from
|
|
||||||
# `build.on` in module.json (novox/hq issue 044).
|
|
||||||
ARG BUILD_BASE
|
|
||||||
ARG RUNTIME_BASE
|
|
||||||
|
|
||||||
FROM ${BUILD_BASE} AS build
|
|
||||||
WORKDIR /app/modules/vault
|
|
||||||
COPY . .
|
|
||||||
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts provisioner/index.ts tools/index.ts \
|
|
||||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
|
||||||
|
|
||||||
FROM ${RUNTIME_BASE}
|
|
||||||
COPY --from=build /app/modules/vault/dist /app/modules/vault/dist
|
|
||||||
# The entrypoints a tool host loads from this module: its event consumer, its tools and its
|
|
||||||
# provisioner — one image, one process, one broker account (novox/hq ADR 0052).
|
|
||||||
ENV MESH_TOOL_MODULES=/app/modules/vault/dist/index.js,/app/modules/vault/dist/tools/index.js,/app/modules/vault/dist/provisioner/index.js
|
|
||||||
@@ -27,16 +27,7 @@
|
|||||||
"secret": "${dir:grants}"
|
"secret": "${dir:grants}"
|
||||||
},
|
},
|
||||||
"keeps": "/var/lib/mesh-vault/root",
|
"keeps": "/var/lib/mesh-vault/root",
|
||||||
"own-secrets": {
|
|
||||||
"broker": "${dir:mesh-state}/broker"
|
|
||||||
},
|
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
|
||||||
"id": "mesh-state",
|
|
||||||
"type": "directory",
|
|
||||||
"mode": "0700",
|
|
||||||
"place": "mesh"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "state",
|
"id": "state",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
@@ -57,45 +48,29 @@
|
|||||||
"id": "root",
|
"id": "root",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"mode": "0700"
|
"mode": "0700"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "runtime",
|
|
||||||
"type": "container",
|
|
||||||
"name": "mesh-vault",
|
|
||||||
"network": "host",
|
|
||||||
"volumes": [
|
|
||||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
|
||||||
"${dir:grants}:${dir:grants}:ro",
|
|
||||||
"${dir:ledger}:${dir:ledger}",
|
|
||||||
"${dir:root}:${dir:root}:ro"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
|
||||||
"MESH_RECEIVES": "${dir:grants}/mesh.json",
|
|
||||||
"MESH_VAULT_LEDGER": "${dir:ledger}",
|
|
||||||
"MESH_VAULT_ROOT": "${dir:root}"
|
|
||||||
},
|
|
||||||
"artifact": "runtime"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"on": [
|
|
||||||
{
|
|
||||||
"arg": "BUILD_BASE",
|
|
||||||
"module": "mesh-tools",
|
|
||||||
"artifact": "build"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"arg": "RUNTIME_BASE",
|
|
||||||
"module": "mesh-tools",
|
|
||||||
"artifact": "runtime"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
"name": "runtime",
|
"name": "code",
|
||||||
"kind": "image",
|
"kind": "bundle",
|
||||||
"from": "Dockerfile"
|
"language": "typescript",
|
||||||
|
"entrypoints": [
|
||||||
|
"index.js",
|
||||||
|
"tools/index.js",
|
||||||
|
"provisioner/index.js"
|
||||||
|
],
|
||||||
|
"loads": [
|
||||||
|
"index.js",
|
||||||
|
"tools/index.js",
|
||||||
|
"provisioner/index.js"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"MESH_RECEIVES": "${dir:grants}/mesh.json",
|
||||||
|
"MESH_VAULT_LEDGER": "${dir:ledger}",
|
||||||
|
"MESH_VAULT_ROOT": "${dir:root}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
+5
-15
@@ -303,21 +303,11 @@ export class MinioClient {
|
|||||||
|
|
||||||
// --- module-scoped helpers -------------------------------------------------
|
// --- module-scoped helpers -------------------------------------------------
|
||||||
|
|
||||||
/** A deterministic 20-char access key id from a consumer name, so removal needs no stored state:
|
// **Neither the access key nor the bucket is derived here any more.** `accessKeyFor` minted an id
|
||||||
* the provisioner recomputes the same id at teardown that it minted at creation. */
|
// of its own until the mesh took that over (ADR 0048: the login is the mesh's, handed to both
|
||||||
export function accessKeyFor(consumer: string): string {
|
// ends), and `bucketFor` derived the bucket until the mesh took that over too (ADR 0201: the rule
|
||||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
// is a line of this module's manifest, filled per consumer and delivered to both ends). Both
|
||||||
const digest = createHash("sha256").update(consumer).digest();
|
// survived with no callers, which is the state a rule comes back from; they are gone.
|
||||||
let out = "";
|
|
||||||
for (let i = 0; i < 20; i++) out += chars[digest[i] % chars.length];
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A DNS-safe bucket name derived from a consumer — the removable identity of its storage. */
|
|
||||||
export function bucketFor(consumer: string): string {
|
|
||||||
const name = consumer.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
|
|
||||||
return name.length >= 3 ? name : `mesh-${name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bucketPolicy(bucket: string): string {
|
function bucketPolicy(bucket: string): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
|
|||||||
@@ -49,7 +49,8 @@
|
|||||||
"s3-bucket": {
|
"s3-bucket": {
|
||||||
"scheme": "http",
|
"scheme": "http",
|
||||||
"region": "eu-west",
|
"region": "eu-west",
|
||||||
"port": 9000
|
"port": 9000,
|
||||||
|
"bucket": "${consumer:as:dns}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"receives": {
|
"receives": {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@novox/mesh-sdk": "^0.1.1"
|
"@novox/mesh-sdk": "^0.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -10,18 +10,24 @@
|
|||||||
// **The access key and its secret are the mesh's, not the provisioner's (ADR 0048).** The mesh
|
// **The access key and its secret are the mesh's, not the provisioner's (ADR 0048).** The mesh
|
||||||
// derives the login (the access-key id) and hands it to both ends, and mints the secret key. minio
|
// derives the login (the access-key id) and hands it to both ends, and mints the secret key. minio
|
||||||
// creates the service account under exactly that access key with exactly that secret — a credential
|
// creates the service account under exactly that access key with exactly that secret — a credential
|
||||||
// the provisioner invented is one the consumer could never present. The bucket is derived from the
|
// the provisioner invented is one the consumer could never present.
|
||||||
// login, so teardown recomputes it with nothing to persist.
|
//
|
||||||
|
// **The bucket name is the mesh's too (ADR 0201).** It used to be computed here, from the login,
|
||||||
|
// and every consumer transcribed the same rule into its own definition by hand — two copies of
|
||||||
|
// one rule with nothing comparing them, and one of three was wrong for months. Now the rule is a
|
||||||
|
// line of this module's manifest (`serves.s3-bucket.bucket: ${consumer:as:dns}`), the mesh fills
|
||||||
|
// it per consumer, and the same filled value reaches this provisioner and the consumer's own
|
||||||
|
// configuration. There is no second computation to disagree with.
|
||||||
|
|
||||||
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
||||||
import { emit } from "@novox/mesh-sdk/events";
|
import { emit } from "@novox/mesh-sdk/events";
|
||||||
import { MinioClient, bucketFor } from "../client.js";
|
import { MinioClient } from "../client.js";
|
||||||
|
|
||||||
const minio = MinioClient.fromEnv();
|
const minio = MinioClient.fromEnv();
|
||||||
|
|
||||||
runProvisioner("s3-bucket", {
|
runProvisioner("s3-bucket", {
|
||||||
async create(p: Provision): Promise<void> {
|
async create(p: Provision): Promise<void> {
|
||||||
const bucket = bucketFor(p.as);
|
const bucket = bucketNamed(p.derived);
|
||||||
const accessKeyId = p.as;
|
const accessKeyId = p.as;
|
||||||
|
|
||||||
if (!(await minio.bucketExists(bucket))) await minio.createBucket(bucket);
|
if (!(await minio.bucketExists(bucket))) await minio.createBucket(bucket);
|
||||||
@@ -38,8 +44,8 @@ runProvisioner("s3-bucket", {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async remove(p: { as: string }): Promise<void> {
|
async remove(p: { as: string; derived: Readonly<Record<string, unknown>> }): Promise<void> {
|
||||||
const bucket = bucketFor(p.as);
|
const bucket = bucketNamed(p.derived);
|
||||||
|
|
||||||
// Revoking the key is what cuts the consumer's access. The bucket is emptied-then-dropped only if
|
// Revoking the key is what cuts the consumer's access. The bucket is emptied-then-dropped only if
|
||||||
// empty; a bucket that still holds objects is left for an operator rather than erroring on every
|
// empty; a bucket that still holds objects is left for an operator rather than erroring on every
|
||||||
@@ -57,10 +63,28 @@ runProvisioner("s3-bucket", {
|
|||||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly 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).
|
// 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> {
|
async holds(p: Provision): Promise<boolean> {
|
||||||
return minio.canReachAs(bucketFor(p.as), p.as, p.password);
|
return minio.canReachAs(bucketNamed(p.derived), p.as, p.password);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The bucket the mesh derived for this consumer.
|
||||||
|
*
|
||||||
|
* Absent means this module is running against a control plane that does not fill `${consumer:…}`
|
||||||
|
* yet, or a manifest whose `serves` block lost the line. Both are the same mistake from here —
|
||||||
|
* nobody said which bucket — and both are said rather than guessed: a provisioner that fell back
|
||||||
|
* to deriving one would restore the second rule and hide the fault behind a bucket that happens
|
||||||
|
* to be right. */
|
||||||
|
function bucketNamed(derived: Readonly<Record<string, unknown>>): string {
|
||||||
|
const bucket = derived.bucket;
|
||||||
|
if (typeof bucket !== "string" || bucket === "") {
|
||||||
|
throw new Error(
|
||||||
|
"the mesh did not say which bucket this consumer gets: minio's manifest must serve " +
|
||||||
|
"`bucket` under s3-bucket (novox/hq ADR 0201)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return bucket;
|
||||||
|
}
|
||||||
|
|
||||||
/** Emit best-effort: a broker hiccup is logged and dropped, never allowed to throw back and fail a
|
/** Emit best-effort: a broker hiccup is logged and dropped, never allowed to throw back and fail a
|
||||||
* bucket that was made. */
|
* bucket that was made. */
|
||||||
async function announce(type: string, body: unknown): Promise<void> {
|
async function announce(type: string, body: unknown): Promise<void> {
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
# mongodb's runtime: the tool runtime, carrying this module's compiled code.
|
|
||||||
#
|
|
||||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
|
||||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
|
||||||
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
|
|
||||||
# happens to have the siblings.
|
|
||||||
#
|
|
||||||
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
|
|
||||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
|
||||||
ARG BUILD_BASE
|
|
||||||
ARG RUNTIME_BASE
|
|
||||||
|
|
||||||
FROM ${BUILD_BASE} AS build
|
|
||||||
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
|
|
||||||
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
|
|
||||||
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
|
|
||||||
# resolved away.
|
|
||||||
WORKDIR /app/modules/mongodb
|
|
||||||
COPY . .
|
|
||||||
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \
|
|
||||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
|
||||||
|
|
||||||
FROM ${RUNTIME_BASE}
|
|
||||||
# mongodb's client shells out to `mongosh`, installed from MongoDB's own apt repo so its shared
|
|
||||||
# libraries come with it — copying the bare binary out of the mongo image leaves it unable to load.
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends gnupg curl ca-certificates \
|
|
||||||
&& curl -fsSL https://pgp.mongodb.com/server-7.0.asc | gpg --dearmor -o /usr/share/keyrings/mongodb.gpg \
|
|
||||||
&& echo "deb [signed-by=/usr/share/keyrings/mongodb.gpg] https://repo.mongodb.org/apt/debian bookworm/mongodb-org/7.0 main" > /etc/apt/sources.list.d/mongodb.list \
|
|
||||||
&& apt-get update && apt-get install -y --no-install-recommends mongodb-mongosh \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
COPY --from=build /app/modules/mongodb/dist /app/modules/mongodb/dist
|
|
||||||
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
|
||||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
|
||||||
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
|
|
||||||
# provisioner (`run`) served no tools and emitted no events; a container that named no command
|
|
||||||
# ran no provisioner at all.
|
|
||||||
ENV MESH_TOOL_MODULES=/app/modules/mongodb/dist/index.js,/app/modules/mongodb/dist/tools/index.js,/app/modules/mongodb/dist/provisioner/index.js
|
|
||||||
+70
-79
@@ -1,19 +1,16 @@
|
|||||||
// mongodb's admin client — mongodb's own code, living in the module (novox/hq ADR 0039). Both this
|
// mongodb's admin client — mongodb's own code, living in the module (novox/hq ADR 0039). Both this
|
||||||
// module's tools and its provisioner import it, and nothing outside mongodb does.
|
// module's tools and its provisioner import it, and nothing outside mongodb does.
|
||||||
//
|
//
|
||||||
// Commands run through `mongosh`, not a wire-protocol driver: the module may take NO npm dependency
|
// **The backend's own driver, inside the bundle** (novox/hq ADR 0198 §4). This used to shell out to
|
||||||
// beyond @novox/mesh-sdk, and hand-rolling the MongoDB wire protocol + SCRAM auth is more surface
|
// `mongosh`, which the module's container installed from MongoDB's apt repository; the module's code
|
||||||
// than this should carry — so it shells out to the shell the mongodb image ships, the same way
|
// now runs in the node's runtime, on machines whose system carries no mongosh, so it speaks to the
|
||||||
// postgres drives itself through `psql`, minio through `mc` and mailu through doveadm. One boundary,
|
// server through the official `mongodb` driver its package.json names — installed and inlined into
|
||||||
// `evalJs()`, and every method is built on it: a snippet of JavaScript is evaluated server-side and
|
// the bundle by the builder. One connection per call, as one mongosh invocation was: the module is
|
||||||
// its result comes back as EJSON on stdout.
|
// called rarely, and a pool held open across calls would hold a credential the mesh may rotate.
|
||||||
|
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { execFile } from "node:child_process";
|
import { MongoClient as Driver, MongoServerError, BSON, type Document } from "mongodb";
|
||||||
import { promisify } from "node:util";
|
|
||||||
|
|
||||||
const run = promisify(execFile);
|
|
||||||
|
|
||||||
export interface DatabaseInfo {
|
export interface DatabaseInfo {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
@@ -59,32 +56,26 @@ export class MongoClient {
|
|||||||
return this.conn.port;
|
return this.conn.port;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The admin connection URI mongosh authenticates with, credentials percent-encoded. */
|
/** The admin connection URI, credentials percent-encoded. */
|
||||||
private uri(): string {
|
private uri(): string {
|
||||||
const u = encodeURIComponent(this.conn.user);
|
const u = encodeURIComponent(this.conn.user);
|
||||||
const p = encodeURIComponent(this.conn.password);
|
const p = encodeURIComponent(this.conn.password);
|
||||||
const a = encodeURIComponent(this.conn.authSource);
|
const a = encodeURIComponent(this.conn.authSource);
|
||||||
return `mongodb://${u}:${p}@${this.conn.host}:${this.conn.port}/?authSource=${a}`;
|
return `mongodb://${u}:${p}@${this.conn.host}:${this.conn.port}/?authSource=${a}&directConnection=true`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evaluate a JavaScript snippet server-side through `mongosh` and parse the JSON it prints (see
|
* The one execution boundary: connect as the administrator, do `work`, and close — a failure to
|
||||||
* header). The snippet MUST `print()` exactly one JSON document as its only stdout — every method
|
* connect or to authenticate rejects here rather than returning a partial success.
|
||||||
* below ends in `print(EJSON.stringify(...))`. `--quiet` suppresses the shell banner so stdout is
|
|
||||||
* the JSON alone; a non-zero exit (auth failure, bad command) rejects here rather than returning
|
|
||||||
* a partial success.
|
|
||||||
*/
|
*/
|
||||||
async evalJs<T>(js: string): Promise<T> {
|
private async admin<T>(work: (client: Driver) => Promise<T>): Promise<T> {
|
||||||
const { stdout } = await run(
|
const client = new Driver(this.uri(), { serverSelectionTimeoutMS: 10_000 });
|
||||||
"mongosh",
|
try {
|
||||||
[this.uri(), "--quiet", "--eval", js],
|
await client.connect();
|
||||||
{ maxBuffer: 16 << 20 },
|
return await work(client);
|
||||||
);
|
} finally {
|
||||||
const text = stdout.trim();
|
await client.close();
|
||||||
if (text.length === 0) {
|
|
||||||
throw new Error("mongosh returned no output — the eval printed nothing");
|
|
||||||
}
|
}
|
||||||
return JSON.parse(text) as T;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,19 +85,16 @@ export class MongoClient {
|
|||||||
* password and roles, so a rotated credential converges.
|
* password and roles, so a rotated credential converges.
|
||||||
*/
|
*/
|
||||||
async createDatabaseAndUser(database: string, user: string, password: string): Promise<void> {
|
async createDatabaseAndUser(database: string, user: string, password: string): Promise<void> {
|
||||||
const js = `
|
await this.admin(async (client) => {
|
||||||
const target = db.getSiblingDB(${lit(database)});
|
const target = client.db(database);
|
||||||
let existing = null;
|
const roles = [{ role: "dbOwner", db: database }];
|
||||||
try { existing = target.getUser(${lit(user)}); } catch (e) { existing = null; }
|
const found = await target.command({ usersInfo: user });
|
||||||
const roles = [{ role: "dbOwner", db: ${lit(database)} }];
|
if (Array.isArray(found.users) && found.users.length > 0) {
|
||||||
if (existing) {
|
await target.command({ updateUser: user, pwd: password, roles });
|
||||||
target.updateUser(${lit(user)}, { pwd: ${lit(password)}, roles: roles });
|
} else {
|
||||||
} else {
|
await target.command({ createUser: user, pwd: password, roles });
|
||||||
target.createUser({ user: ${lit(user)}, pwd: ${lit(password)}, roles: roles });
|
}
|
||||||
}
|
});
|
||||||
print(EJSON.stringify({ ok: 1 }));
|
|
||||||
`;
|
|
||||||
await this.evalJs<{ ok: number }>(js);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,45 +103,43 @@ print(EJSON.stringify({ ok: 1 }));
|
|||||||
* authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120).
|
* authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120).
|
||||||
*/
|
*/
|
||||||
async canAuthenticateAs(database: string, user: string, password: string): Promise<boolean> {
|
async canAuthenticateAs(database: string, user: string, password: string): Promise<boolean> {
|
||||||
// Connected without credentials, then authenticated inside the eval from the environment, so
|
// Credentials as options, never in a URI, so the consumer's password is in no message a failed
|
||||||
// the consumer's password is neither on argv nor in the message of a failed command.
|
// connection prints.
|
||||||
const uri = `mongodb://${this.conn.host}:${this.conn.port}/?serverSelectionTimeoutMS=10000`;
|
const client = new Driver(`mongodb://${this.conn.host}:${this.conn.port}/?directConnection=true`, {
|
||||||
const js =
|
auth: { username: user, password },
|
||||||
"const t = db.getSiblingDB(process.env.MESH_HOLDS_DB);" +
|
authSource: database,
|
||||||
"t.auth(process.env.MESH_HOLDS_USER, process.env.MESH_HOLDS_PW);" +
|
serverSelectionTimeoutMS: 10_000,
|
||||||
"print(EJSON.stringify(t.runCommand({ connectionStatus: 1 }).authInfo.authenticatedUserRoles))";
|
});
|
||||||
let stdout: string;
|
|
||||||
try {
|
try {
|
||||||
({ stdout } = await run("mongosh", [uri, "--quiet", "--eval", js], {
|
await client.connect();
|
||||||
env: { ...process.env, MESH_HOLDS_DB: database, MESH_HOLDS_USER: user, MESH_HOLDS_PW: password },
|
const status = await client.db(database).command({ connectionStatus: 1 });
|
||||||
timeout: 30_000,
|
const roles = (status.authInfo?.authenticatedUserRoles ?? []) as { role: string; db: string }[];
|
||||||
}));
|
return roles.some((r) => r.role === "dbOwner" && r.db === database);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const text = `${(err as { stderr?: string }).stderr ?? ""}${(err as { stdout?: string }).stdout ?? ""}`;
|
if (isAuthFailure(err)) return false;
|
||||||
if (/Authentication failed|AuthenticationFailed/i.test(text)) return false;
|
throw new Error(`mongodb could not check ${user}: ${String((err as Error).message).split("\n")[0]}`);
|
||||||
throw new Error(`mongosh could not check ${user}: ${text.trim().slice(0, 500) || String((err as Error).message).split("\n")[0]}`);
|
} finally {
|
||||||
|
await client.close();
|
||||||
}
|
}
|
||||||
const roles = JSON.parse(stdout.trim()) as { role: string; db: string }[];
|
|
||||||
return roles.some((r) => r.role === "dbOwner" && r.db === database);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the
|
/** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the
|
||||||
* user is removed first so a re-grant of the same login starts clean. */
|
* user is removed first so a re-grant of the same login starts clean. */
|
||||||
async dropDatabaseAndUser(database: string, user: string): Promise<void> {
|
async dropDatabaseAndUser(database: string, user: string): Promise<void> {
|
||||||
const js = `
|
await this.admin(async (client) => {
|
||||||
const target = db.getSiblingDB(${lit(database)});
|
const target = client.db(database);
|
||||||
try { target.dropUser(${lit(user)}); } catch (e) {}
|
try {
|
||||||
target.dropDatabase();
|
await target.command({ dropUser: user });
|
||||||
print(EJSON.stringify({ ok: 1 }));
|
} catch (err) {
|
||||||
`;
|
if (!(err instanceof MongoServerError && err.code === 11)) throw err; // 11: UserNotFound
|
||||||
await this.evalJs<{ ok: number }>(js);
|
}
|
||||||
|
await target.dropDatabase();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List the databases on the server, with on-disk size, for the mongodb_list_databases tool. */
|
/** List the databases on the server, with on-disk size, for the mongodb_list_databases tool. */
|
||||||
async listDatabases(): Promise<DatabaseInfo[]> {
|
async listDatabases(): Promise<DatabaseInfo[]> {
|
||||||
const res = await this.evalJs<{ databases: { name: string; sizeOnDisk?: number }[] }>(
|
const res = await this.admin((client) => client.db("admin").admin().listDatabases());
|
||||||
`print(EJSON.stringify(db.adminCommand({ listDatabases: 1 })));`,
|
|
||||||
);
|
|
||||||
return (res.databases ?? [])
|
return (res.databases ?? [])
|
||||||
.map((d) => ({ name: String(d.name), sizeBytes: Number(d.sizeOnDisk ?? 0) }))
|
.map((d) => ({ name: String(d.name), sizeBytes: Number(d.sizeOnDisk ?? 0) }))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
@@ -162,6 +148,8 @@ print(EJSON.stringify({ ok: 1 }));
|
|||||||
/**
|
/**
|
||||||
* Run a read-only `find` against a collection in a named database, for the mongodb_query tool.
|
* Run a read-only `find` against a collection in a named database, for the mongodb_query tool.
|
||||||
* `find` mutates nothing; the limit is capped so a tool call cannot stream an unbounded result.
|
* `find` mutates nothing; the limit is capped so a tool call cannot stream an unbounded result.
|
||||||
|
* Documents come back as relaxed Extended JSON — an ObjectId as `{"$oid": …}` — exactly as the
|
||||||
|
* shell's `EJSON.stringify` rendered them before.
|
||||||
*/
|
*/
|
||||||
async find(
|
async find(
|
||||||
database: string,
|
database: string,
|
||||||
@@ -170,26 +158,29 @@ print(EJSON.stringify({ ok: 1 }));
|
|||||||
limit: number,
|
limit: number,
|
||||||
): Promise<Record<string, unknown>[]> {
|
): Promise<Record<string, unknown>[]> {
|
||||||
const capped = Math.max(1, Math.min(limit, 1000));
|
const capped = Math.max(1, Math.min(limit, 1000));
|
||||||
const js =
|
const docs = await this.admin((client) =>
|
||||||
`print(EJSON.stringify(` +
|
client
|
||||||
`db.getSiblingDB(${lit(database)}).getCollection(${lit(collection)})` +
|
.db(database)
|
||||||
`.find(${JSON.stringify(filter)}).limit(${capped}).toArray()` +
|
.collection(collection)
|
||||||
`));`;
|
.find(BSON.EJSON.deserialize(filter as Document, { relaxed: true }) as Document)
|
||||||
return this.evalJs<Record<string, unknown>[]>(js);
|
.limit(capped)
|
||||||
|
.toArray(),
|
||||||
|
);
|
||||||
|
return BSON.EJSON.serialize(docs, { relaxed: true }) as Record<string, unknown>[];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An authentication failure, as the server or the driver reports it. */
|
||||||
|
function isAuthFailure(err: unknown): boolean {
|
||||||
|
if (err instanceof MongoServerError && err.code === 18) return true; // 18: AuthenticationFailed
|
||||||
|
return /Authentication failed|AuthenticationFailed/i.test(String((err as Error)?.message ?? ""));
|
||||||
|
}
|
||||||
|
|
||||||
/** Generate a URL-safe password. */
|
/** Generate a URL-safe password. */
|
||||||
export function generatePassword(): string {
|
export function generatePassword(): string {
|
||||||
return randomBytes(24).toString("base64url");
|
return randomBytes(24).toString("base64url");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Embed a value as a JavaScript literal inside a mongosh snippet — JSON.stringify escapes quotes,
|
|
||||||
* backslashes and control characters, so a string cannot break out of the snippet. */
|
|
||||||
function lit(val: unknown): string {
|
|
||||||
return JSON.stringify(val);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSecretFile(path: string | undefined): string | undefined {
|
function readSecretFile(path: string | undefined): string | undefined {
|
||||||
if (!path) return undefined;
|
if (!path) return undefined;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// mongodb's events entrypoint, loaded by the per-node tool host (the provisioner container runs
|
// mongodb's events entrypoint, launched by the node's runtime beside its tools and provisioner
|
||||||
// ./provisioner separately). The database lifecycle events are EMITTED from the provisioner, where
|
// (novox/hq ADR 0198). The database lifecycle events are EMITTED from the provisioner, where
|
||||||
// the lifecycle actually happens (novox/hq ADR 0041/0042):
|
// the lifecycle actually happens (novox/hq ADR 0041/0042):
|
||||||
// module.mongodb.database.provisioned — a consumer's database + owning user was created
|
// module.mongodb.database.provisioned — a consumer's database + owning user was created
|
||||||
// module.mongodb.database.deprovisioned — that database was removed
|
// module.mongodb.database.deprovisioned — that database was removed
|
||||||
// Here in the tool host we react to them, keeping a lightweight audit trail of who was granted a
|
// Here in the runtime we react to them, keeping a lightweight audit trail of who was granted a
|
||||||
// database and who lost one — observability the provider itself is best placed to log.
|
// database and who lost one — observability the provider itself is best placed to log.
|
||||||
|
|
||||||
import { on } from "@novox/mesh-sdk/events";
|
import { on } from "@novox/mesh-sdk/events";
|
||||||
|
|||||||
+28
-43
@@ -39,17 +39,9 @@
|
|||||||
"mongodb-database": "${dir:grants}"
|
"mongodb-database": "${dir:grants}"
|
||||||
},
|
},
|
||||||
"own-secrets": {
|
"own-secrets": {
|
||||||
"root": "${dir:state}/root.secret",
|
"root": "${dir:state}/root.secret"
|
||||||
"broker": "${dir:mesh-state}/broker"
|
|
||||||
},
|
},
|
||||||
"secrets-owner": "999:999",
|
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
|
||||||
"id": "mesh-state",
|
|
||||||
"type": "directory",
|
|
||||||
"mode": "0700",
|
|
||||||
"place": "mesh"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "state",
|
"id": "state",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
@@ -71,6 +63,14 @@
|
|||||||
"type": "network",
|
"type": "network",
|
||||||
"name": "mongodb"
|
"name": "mongodb"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "server-root",
|
||||||
|
"type": "file",
|
||||||
|
"path": "${dir:state}/server-root.secret",
|
||||||
|
"mode": "0400",
|
||||||
|
"owner": "999:999",
|
||||||
|
"content": "${secret:root}"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "server",
|
"id": "server",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
@@ -86,46 +86,31 @@
|
|||||||
],
|
],
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"${dir:data}:/data/db",
|
"${dir:data}:/data/db",
|
||||||
"${dir:state}/root.secret:/run/secrets/root:ro"
|
"${dir:state}/server-root.secret:/run/secrets/root:ro"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "runtime",
|
|
||||||
"type": "container",
|
|
||||||
"name": "mesh-mongodb",
|
|
||||||
"network": "mongodb",
|
|
||||||
"volumes": [
|
|
||||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
|
||||||
"${dir:grants}:${dir:grants}:ro",
|
|
||||||
"${dir:state}/root.secret:/run/secrets/root:ro"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"MESH_PROVISION_MONGODB": "mongodb://root@mongodb-server:27017/admin?authSource=admin",
|
|
||||||
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/root",
|
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
|
||||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
|
||||||
},
|
|
||||||
"artifact": "runtime"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"on": [
|
|
||||||
{
|
|
||||||
"arg": "BUILD_BASE",
|
|
||||||
"module": "mesh-tools",
|
|
||||||
"artifact": "build"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"arg": "RUNTIME_BASE",
|
|
||||||
"module": "mesh-tools",
|
|
||||||
"artifact": "runtime"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
"name": "runtime",
|
"name": "code",
|
||||||
"kind": "image",
|
"kind": "bundle",
|
||||||
"from": "Dockerfile"
|
"language": "typescript",
|
||||||
|
"entrypoints": [
|
||||||
|
"index.js",
|
||||||
|
"tools/index.js",
|
||||||
|
"provisioner/index.js"
|
||||||
|
],
|
||||||
|
"loads": [
|
||||||
|
"index.js",
|
||||||
|
"tools/index.js",
|
||||||
|
"provisioner/index.js"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"MESH_PROVISION_MONGODB": "mongodb://root@127.0.0.1:${port:27017}/admin?authSource=admin",
|
||||||
|
"MESH_PROVISION_PASSWORD_FILE": "${dir:state}/root.secret",
|
||||||
|
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@novox/mesh-sdk": "^0.1.1"
|
"@novox/mesh-sdk": "^0.1.1",
|
||||||
|
"mongodb": "^6.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -11,8 +11,7 @@
|
|||||||
// same-named database under exactly that login — a name the consumer cannot learn is a database it
|
// same-named database under exactly that login — a name the consumer cannot learn is a database it
|
||||||
// cannot reach.
|
// cannot reach.
|
||||||
//
|
//
|
||||||
// The commands run through MongoClient.evalJs(), which is the module's one execution boundary (see
|
// The commands run through MongoClient, the official driver inside this bundle (see client.ts).
|
||||||
// client.ts).
|
|
||||||
|
|
||||||
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
||||||
import { emit } from "@novox/mesh-sdk/events";
|
import { emit } from "@novox/mesh-sdk/events";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// mongodb's tools — mongodb's own code (novox/hq ADR 0039), importing mongodb's own client. They
|
// mongodb's tools — mongodb's own code (novox/hq ADR 0039), importing mongodb's own client. They
|
||||||
// return structured data; the mesh serves them through the sdk's tool harness. Both call through
|
// return structured data; the mesh serves them through the sdk's tool harness. Both call the server
|
||||||
// MongoClient.evalJs(), the module's one execution boundary (see client.ts).
|
// through MongoClient, the driver inside this bundle (see client.ts).
|
||||||
|
|
||||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||||
import { MongoClient } from "../client.js";
|
import { MongoClient } from "../client.js";
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { randomBytes } from "node:crypto";
|
|||||||
import { connect as tcpConnect } from "node:net";
|
import { connect as tcpConnect } from "node:net";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
|
import { basename, dirname } from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
import { missingAcls, parseRoleAcls, staleAcls, wantedAcls } from "./topics.js";
|
import { missingAcls, parseRoleAcls, staleAcls, wantedAcls } from "./topics.js";
|
||||||
@@ -30,6 +31,14 @@ export interface MqttConn {
|
|||||||
/** The Dynamic Security admin client the runtime authenticates as. */
|
/** The Dynamic Security admin client the runtime authenticates as. */
|
||||||
readonly adminUser: string;
|
readonly adminUser: string;
|
||||||
readonly adminPassword: string;
|
readonly adminPassword: string;
|
||||||
|
/**
|
||||||
|
* The broker's own container, when `mosquitto_ctrl` is run inside it rather than from this
|
||||||
|
* machine's packages. The broker's image carries the tool at the broker's version, and inside it
|
||||||
|
* the broker listens on 127.0.0.1:1883 whatever port the machine publishes.
|
||||||
|
*/
|
||||||
|
readonly container?: string;
|
||||||
|
/** The broker's image, to seed the security file before the broker has ever started. */
|
||||||
|
readonly image?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MosquittoClient {
|
export class MosquittoClient {
|
||||||
@@ -53,7 +62,11 @@ export class MosquittoClient {
|
|||||||
"mosquitto host or admin password is not set — mosquitto's own code cannot reach the broker",
|
"mosquitto host or admin password is not set — mosquitto's own code cannot reach the broker",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return new MosquittoClient({ host, port, adminUser, adminPassword: adminPassword ?? "" });
|
return new MosquittoClient({
|
||||||
|
host, port, adminUser, adminPassword: adminPassword ?? "",
|
||||||
|
container: env.MESH_MQTT_CTRL_CONTAINER || undefined,
|
||||||
|
image: env.MESH_MQTT_CTRL_IMAGE || undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get host(): string {
|
get host(): string {
|
||||||
@@ -84,16 +97,18 @@ export class MosquittoClient {
|
|||||||
* to this single-purpose runtime container; see the module README.
|
* to this single-purpose runtime container; see the module README.
|
||||||
*/
|
*/
|
||||||
async ctl(...args: string[]): Promise<string> {
|
async ctl(...args: string[]): Promise<string> {
|
||||||
|
const inside = this.conn.container !== undefined;
|
||||||
const base = [
|
const base = [
|
||||||
"-h", this.conn.host,
|
"-h", inside ? "127.0.0.1" : this.conn.host,
|
||||||
"-p", String(this.conn.port),
|
"-p", inside ? "1883" : String(this.conn.port),
|
||||||
"-u", this.conn.adminUser,
|
"-u", this.conn.adminUser,
|
||||||
"-P", this.conn.adminPassword,
|
"-P", this.conn.adminPassword,
|
||||||
];
|
];
|
||||||
let stdout: string;
|
let stdout: string;
|
||||||
let stderr: string;
|
let stderr: string;
|
||||||
try {
|
try {
|
||||||
({ stdout, stderr } = await run("mosquitto_ctrl", [...base, "dynsec", ...args], {
|
const [command, argv] = this.ctrl([...base, "dynsec", ...args]);
|
||||||
|
({ stdout, stderr } = await run(command, argv, {
|
||||||
maxBuffer: 16 << 20,
|
maxBuffer: 16 << 20,
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
}));
|
}));
|
||||||
@@ -240,10 +255,27 @@ export class MosquittoClient {
|
|||||||
async initBootstrapFile(configFile: string): Promise<void> {
|
async initBootstrapFile(configFile: string): Promise<void> {
|
||||||
// `dynsec init <file> <admin-username> [admin-password]` is an offline file operation — it does
|
// `dynsec init <file> <admin-username> [admin-password]` is an offline file operation — it does
|
||||||
// not connect to the broker. The password is a positional argument (omitting it prompts).
|
// not connect to the broker. The password is a positional argument (omitting it prompts).
|
||||||
|
if (this.conn.image) {
|
||||||
|
// Before the broker has ever started there is no container to enter: a throwaway one from the
|
||||||
|
// broker's own image writes the file into the directory the broker will mount.
|
||||||
|
await run("docker", [
|
||||||
|
"run", "--rm", "--entrypoint", "mosquitto_ctrl",
|
||||||
|
"-v", `${dirname(configFile)}:/mosquitto/data`,
|
||||||
|
this.conn.image,
|
||||||
|
"dynsec", "init", `/mosquitto/data/${basename(configFile)}`, this.conn.adminUser, this.conn.adminPassword,
|
||||||
|
], { maxBuffer: 16 << 20 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
await run("mosquitto_ctrl", ["dynsec", "init", configFile, this.conn.adminUser, this.conn.adminPassword], {
|
await run("mosquitto_ctrl", ["dynsec", "init", configFile, this.conn.adminUser, this.conn.adminPassword], {
|
||||||
maxBuffer: 16 << 20,
|
maxBuffer: 16 << 20,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How `mosquitto_ctrl` is run here: inside the broker's container when one is named. */
|
||||||
|
ctrl(argv: string[]): [string, string[]] {
|
||||||
|
if (this.conn.container) return ["docker", ["exec", this.conn.container, "mosquitto_ctrl", ...argv]];
|
||||||
|
return ["mosquitto_ctrl", argv];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generate a URL-safe password with no argv- or MQTT-hostile characters. */
|
/** Generate a URL-safe password with no argv- or MQTT-hostile characters. */
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:state}/bootstrap.env",
|
"path": "${dir:state}/bootstrap.env",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"content": "MESH_PROVISION_MQTT=127.0.0.1:${port:1883}\nMESH_PROVISION_ADMIN_USER=mesh-admin\nMESH_PROVISION_PASSWORD_FILE=${dir:mesh-state}/admin\nMESH_DYNSEC_FILE=${dir:data}/dynamic-security.json\n"
|
"content": "MESH_PROVISION_MQTT=127.0.0.1:${port:1883}\nMESH_PROVISION_ADMIN_USER=mesh-admin\nMESH_PROVISION_PASSWORD_FILE=${dir:mesh-state}/admin\nMESH_DYNSEC_FILE=${dir:data}/dynamic-security.json\nMESH_MQTT_CTRL_IMAGE=eclipse-mosquitto@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "bootstrap",
|
"id": "bootstrap",
|
||||||
@@ -125,11 +125,6 @@
|
|||||||
"${dir:data}:/mosquitto/data",
|
"${dir:data}:/mosquitto/data",
|
||||||
"${dir:state}/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro"
|
"${dir:state}/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "client",
|
|
||||||
"type": "package",
|
|
||||||
"package": "mosquitto"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
@@ -153,7 +148,8 @@
|
|||||||
"MESH_RECEIVES": "${dir:grants}/mesh.json",
|
"MESH_RECEIVES": "${dir:grants}/mesh.json",
|
||||||
"MESH_PROVISION_MQTT": "127.0.0.1:${port:1883}",
|
"MESH_PROVISION_MQTT": "127.0.0.1:${port:1883}",
|
||||||
"MESH_PROVISION_ADMIN_USER": "mesh-admin",
|
"MESH_PROVISION_ADMIN_USER": "mesh-admin",
|
||||||
"MESH_PROVISION_PASSWORD_FILE": "${dir:mesh-state}/admin"
|
"MESH_PROVISION_PASSWORD_FILE": "${dir:mesh-state}/admin",
|
||||||
|
"MESH_MQTT_CTRL_CONTAINER": "mosquitto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Run after `npm run build`.
|
||||||
|
// mosquitto_ctrl runs inside the broker's own container when the manifest names it, so a machine
|
||||||
|
// needs no mosquitto package (whose index may be too stale to install from) and the tool always
|
||||||
|
// matches the broker's version.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { MosquittoClient } from "../dist/client.js"; // compiled: client.ts uses parameter properties, which type stripping cannot run
|
||||||
|
|
||||||
|
const env = { MESH_PROVISION_MQTT: "127.0.0.1:21883", MESH_MQTT_PASSWORD: "pw" };
|
||||||
|
|
||||||
|
test("named, the broker's container runs mosquitto_ctrl", () => {
|
||||||
|
const c = MosquittoClient.fromEnv({ ...env, MESH_MQTT_CTRL_CONTAINER: "mosquitto" });
|
||||||
|
assert.deepEqual(c.ctrl(["dynsec", "listClients"]), ["docker", ["exec", "mosquitto", "mosquitto_ctrl", "dynsec", "listClients"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unnamed, this machine's mosquitto_ctrl runs", () => {
|
||||||
|
const c = MosquittoClient.fromEnv(env);
|
||||||
|
assert.deepEqual(c.ctrl(["dynsec", "listClients"]), ["mosquitto_ctrl", ["dynsec", "listClients"]]);
|
||||||
|
});
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# mssql's runtime: the tool runtime, carrying this module's compiled code.
|
|
||||||
#
|
|
||||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
|
||||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
|
||||||
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
|
|
||||||
# happens to have the siblings.
|
|
||||||
#
|
|
||||||
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
|
|
||||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
|
||||||
ARG BUILD_BASE
|
|
||||||
ARG RUNTIME_BASE
|
|
||||||
|
|
||||||
FROM ${BUILD_BASE} AS build
|
|
||||||
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
|
|
||||||
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
|
|
||||||
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
|
|
||||||
# resolved away.
|
|
||||||
WORKDIR /app/modules/mssql
|
|
||||||
COPY . .
|
|
||||||
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \
|
|
||||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
|
||||||
|
|
||||||
# **sqlcmd, which this module's client drives, has to be here** — it never was, so every tool failed
|
|
||||||
# with `spawn sqlcmd ENOENT`. go-sqlcmd is one static binary; fetched at a pinned release and checked
|
|
||||||
# against its digest, so a build that receives anything else stops here.
|
|
||||||
FROM ${BUILD_BASE} AS sqlcmd
|
|
||||||
ARG SQLCMD_VERSION=v1.10.0
|
|
||||||
ARG SQLCMD_SHA256=92516d98c63d99b0994de5b61350c91f6915f9b76f139a59039fbcb225c2e987
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates bzip2 \
|
|
||||||
&& curl -fsSL -o /tmp/sqlcmd.tar.bz2 \
|
|
||||||
"https://github.com/microsoft/go-sqlcmd/releases/download/${SQLCMD_VERSION}/sqlcmd-linux-amd64.tar.bz2" \
|
|
||||||
&& echo "${SQLCMD_SHA256} /tmp/sqlcmd.tar.bz2" | sha256sum -c - \
|
|
||||||
&& tar -xjf /tmp/sqlcmd.tar.bz2 -C /usr/local/bin sqlcmd
|
|
||||||
|
|
||||||
FROM ${RUNTIME_BASE}
|
|
||||||
COPY --from=sqlcmd /usr/local/bin/sqlcmd /usr/local/bin/sqlcmd
|
|
||||||
COPY --from=build /app/modules/mssql/dist /app/modules/mssql/dist
|
|
||||||
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
|
||||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
|
||||||
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
|
|
||||||
# provisioner (`run`) served no tools and emitted no events; a container that named no command
|
|
||||||
# ran no provisioner at all.
|
|
||||||
ENV MESH_TOOL_MODULES=/app/modules/mssql/dist/index.js,/app/modules/mssql/dist/tools/index.js,/app/modules/mssql/dist/provisioner/index.js
|
|
||||||
+111
-98
@@ -1,22 +1,74 @@
|
|||||||
// mssql's admin client — mssql's own code, living in the module (novox/hq ADR 0039). Both this
|
// mssql's admin client — mssql's own code, living in the module (novox/hq ADR 0039). Both this
|
||||||
// module's tools and its provisioner import it, and nothing outside mssql does.
|
// module's tools and its provisioner import it, and nothing outside mssql does.
|
||||||
//
|
//
|
||||||
// SQL is executed through `sqlcmd`, not a wire-protocol driver: the module may take NO npm
|
// **The backend's own driver, inside the bundle** (novox/hq ADR 0198 §4). This used to shell out to
|
||||||
// dependency beyond @novox/mesh-sdk, and hand-rolling the TDS handshake, pre-login and query
|
// `sqlcmd`, a binary the module's container fetched; the module's code now runs in the node's
|
||||||
// protocol is more surface than this should carry — so it shells out to the client the mssql
|
// runtime, on machines whose system carries no SQL Server client, so it speaks TDS through the
|
||||||
// tools ship, the same way postgres drives itself through `psql`, minio through `mc`, and mailu
|
// `mssql` driver its package.json names — installed and inlined into the bundle by the builder. One
|
||||||
// through doveadm. One boundary, `run()`, and every method is built on it.
|
// boundary, `session()`, and every method is built on it: a connection as one login to one database,
|
||||||
|
// opened for one call and closed after, as one sqlcmd invocation was.
|
||||||
//
|
//
|
||||||
// Structured rows come back as JSON: SQL Server itself renders the result with `FOR JSON`, and
|
// Structured rows still come back as JSON rendered by SQL Server itself (`FOR JSON`), so a tool's
|
||||||
// this parses the single JSON document sqlcmd prints — far more robust than parsing sqlcmd's
|
// answer is shaped exactly as it was: SQL Server owns the quoting and typing.
|
||||||
// column-aligned text, since SQL Server owns the quoting and typing.
|
|
||||||
|
|
||||||
|
import { isIP } from "node:net";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { execFile } from "node:child_process";
|
import sql from "mssql";
|
||||||
import { promisify } from "node:util";
|
|
||||||
|
|
||||||
const run = promisify(execFile);
|
/** Where a session connects, and as whom. */
|
||||||
|
export interface Target {
|
||||||
|
readonly host: string;
|
||||||
|
readonly port: number;
|
||||||
|
readonly user: string;
|
||||||
|
readonly password: string;
|
||||||
|
readonly database: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One login's connection to one database: run a batch, answer the rows of its last result set. */
|
||||||
|
export interface Session {
|
||||||
|
/** `params` are bound as NVARCHAR parameters (`@name`), never written into the text. */
|
||||||
|
run(text: string, params?: Record<string, string>): Promise<Record<string, unknown>[]>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a session is opened — the driver, or a test's fake. */
|
||||||
|
export type Connect = (to: Target) => Promise<Session>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The driver's session: TLS, trusting the self-signed certificate the mssql image ships with (what
|
||||||
|
* sqlcmd's `-C` did), one connection, closed with the session.
|
||||||
|
*/
|
||||||
|
export const connectWithDriver: Connect = async (to) => {
|
||||||
|
const pool = new sql.ConnectionPool({
|
||||||
|
server: to.host,
|
||||||
|
port: to.port,
|
||||||
|
user: to.user,
|
||||||
|
password: to.password,
|
||||||
|
database: to.database,
|
||||||
|
// TLS names a host, never an address: Node refuses an IP as the server name (DEP0123, an error
|
||||||
|
// since Node 25), and the module reaches its server on loopback. The certificate is trusted
|
||||||
|
// either way, so the name only has to be one TLS accepts.
|
||||||
|
options: { encrypt: true, trustServerCertificate: true, ...(isIP(to.host) ? { serverName: "localhost" } : {}) },
|
||||||
|
pool: { min: 0, max: 1 },
|
||||||
|
connectionTimeout: 15_000,
|
||||||
|
requestTimeout: 60_000,
|
||||||
|
});
|
||||||
|
await pool.connect();
|
||||||
|
return {
|
||||||
|
async run(text, params = {}) {
|
||||||
|
const request = pool.request();
|
||||||
|
const names = Object.keys(params);
|
||||||
|
for (const name of names) request.input(name, sql.NVarChar, params[name]);
|
||||||
|
// A batch when nothing is bound — CREATE DATABASE must stand alone in its batch, which a
|
||||||
|
// parameterised query (sp_executesql) is not.
|
||||||
|
const result = names.length > 0 ? await request.query(text) : await request.batch(text);
|
||||||
|
const sets = (result.recordsets ?? []) as Record<string, unknown>[][];
|
||||||
|
return sets.length > 0 ? sets[sets.length - 1] : [];
|
||||||
|
},
|
||||||
|
close: () => pool.close(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export interface QueryResult {
|
export interface QueryResult {
|
||||||
/** The leading keyword of the statement, e.g. "SELECT", "CREATE". */
|
/** The leading keyword of the statement, e.g. "SELECT", "CREATE". */
|
||||||
@@ -45,20 +97,17 @@ export interface MssqlConn {
|
|||||||
*/
|
*/
|
||||||
export const READER = "mesh_mssql_reader";
|
export const READER = "mesh_mssql_reader";
|
||||||
|
|
||||||
/** Who a sqlcmd invocation logs in as, and whether the text is a caller's rather than the module's. */
|
/** Who a session logs in as. */
|
||||||
interface Invocation {
|
interface Invocation {
|
||||||
readonly user: string;
|
readonly user: string;
|
||||||
readonly password: string;
|
readonly password: string;
|
||||||
/**
|
|
||||||
* A caller's text: sqlcmd substitutes no `$(NAME)` in it, which would read this process's
|
|
||||||
* environment — the administrator's password among it. (Its own commands are kept out by the
|
|
||||||
* caller's text never beginning a line; see readOnlyQuery.)
|
|
||||||
*/
|
|
||||||
readonly caller: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MssqlClient {
|
export class MssqlClient {
|
||||||
constructor(private readonly conn: MssqlConn) {}
|
constructor(
|
||||||
|
private readonly conn: MssqlConn,
|
||||||
|
private readonly connect: Connect = connectWithDriver,
|
||||||
|
) {}
|
||||||
|
|
||||||
/** The reader is made once per process: idempotent, and repeating it re-sets a rotated password. */
|
/** The reader is made once per process: idempotent, and repeating it re-sets a rotated password. */
|
||||||
private readerReady?: Promise<void>;
|
private readerReady?: Promise<void>;
|
||||||
@@ -90,63 +139,41 @@ export class MssqlClient {
|
|||||||
return this.conn.port;
|
return this.conn.port;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Execute a batch that returns no rows (DDL and the like). A failed statement rejects. */
|
||||||
* Execute a batch that returns no rows (DDL and the like), through `sqlcmd`. The password is
|
async exec(text: string, database = "master"): Promise<void> {
|
||||||
* passed by SQLCMDPASSWORD, never on argv, the way postgres passes PGPASSWORD; `-b` makes a
|
await this.session(text, database);
|
||||||
* failed statement an error here rather than a success with a warning, and `-C` trusts the
|
|
||||||
* server's self-signed certificate the mssql image ships with.
|
|
||||||
*/
|
|
||||||
async exec(sql: string, database = "master"): Promise<void> {
|
|
||||||
await this.sqlcmd(sql, database);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run a SELECT and return its rows as objects. The caller's SQL must be a single SELECT; it is
|
* Run a SELECT and return its rows as objects. The caller's SQL must be a single SELECT; it is
|
||||||
* wrapped so SQL Server renders the result with `FOR JSON PATH`, and the JSON document sqlcmd
|
* wrapped so SQL Server renders the result with `FOR JSON PATH`, and the JSON document it answers
|
||||||
* prints (split across output lines for a large result, and reassembled here) is parsed. An
|
* (split across rows for a large result, and reassembled here) is parsed. An empty result yields
|
||||||
* empty result yields no output at all — an empty array.
|
* no rows — an empty array. `params` are bound as `@name`, never written into the text.
|
||||||
*/
|
*/
|
||||||
async query(
|
async query(
|
||||||
select: string,
|
select: string,
|
||||||
database = "master",
|
database = "master",
|
||||||
variables: Record<string, string> = {},
|
params: Record<string, string> = {},
|
||||||
): Promise<Record<string, unknown>[]> {
|
): Promise<Record<string, unknown>[]> {
|
||||||
const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`;
|
const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`;
|
||||||
const stdout = await this.sqlcmd(wrapped, database, variables);
|
return parseJsonRows(await this.session(wrapped, database, params));
|
||||||
return parseJsonRows(stdout);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */
|
/** The one execution boundary: open a session as `as`, run `text`, close it. */
|
||||||
private async sqlcmd(
|
private async session(
|
||||||
sql: string,
|
text: string,
|
||||||
database: string,
|
database: string,
|
||||||
variables: Record<string, string> = {},
|
params: Record<string, string> = {},
|
||||||
as: Invocation = { user: this.conn.user, password: this.conn.password, caller: false },
|
as: Invocation = { user: this.conn.user, password: this.conn.password },
|
||||||
): Promise<string> {
|
): Promise<Record<string, unknown>[]> {
|
||||||
// `-h -1` drops the column-header rule; `-y 0`/`-Y 0` lift the display-width cap so a long
|
const session = await this.connect({
|
||||||
// JSON document is not truncated; `-W` trims trailing whitespace so the JSON chunks rejoin
|
host: this.conn.host, port: this.conn.port, user: as.user, password: as.password, database,
|
||||||
// cleanly. sqlcmd from the mssql-tools ships in the runtime container, the way `psql` ships
|
});
|
||||||
// with postgres's — the module owns its own code (ADR 0039) and shells out to it.
|
try {
|
||||||
const { stdout } = await run(
|
return await session.run(text, params);
|
||||||
"sqlcmd",
|
} finally {
|
||||||
[
|
await session.close();
|
||||||
"-S", `${this.conn.host},${this.conn.port}`,
|
}
|
||||||
"-U", as.user,
|
|
||||||
"-d", database,
|
|
||||||
...(as.caller ? ["-x"] : []),
|
|
||||||
"-C",
|
|
||||||
"-b",
|
|
||||||
"-h", "-1",
|
|
||||||
"-y", "0",
|
|
||||||
"-Y", "0",
|
|
||||||
"-W",
|
|
||||||
"-Q", sql,
|
|
||||||
],
|
|
||||||
// `variables` reach sqlcmd as environment variables, which it substitutes as `$(NAME)` scripting
|
|
||||||
// variables: a value that must not appear on argv, or in the message of a failed command.
|
|
||||||
{ env: { ...process.env, ...variables, SQLCMDPASSWORD: as.password }, maxBuffer: 16 << 20 },
|
|
||||||
);
|
|
||||||
return stdout;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,7 +200,7 @@ export class MssqlClient {
|
|||||||
`SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`,
|
`SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`,
|
||||||
);
|
);
|
||||||
if (dbs.length === 0) {
|
if (dbs.length === 0) {
|
||||||
// CREATE DATABASE must stand alone in its batch; it runs as its own sqlcmd invocation.
|
// CREATE DATABASE must stand alone in its batch; it runs as its own session.
|
||||||
await this.exec(`CREATE DATABASE ${ident(database)}`);
|
await this.exec(`CREATE DATABASE ${ident(database)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,15 +233,14 @@ export class MssqlClient {
|
|||||||
* nothing logs in and no failed-login is recorded (novox/hq issue 120).
|
* nothing logs in and no failed-login is recorded (novox/hq issue 120).
|
||||||
*/
|
*/
|
||||||
async holdsLogin(database: string, login: string, password: string): Promise<boolean> {
|
async holdsLogin(database: string, login: string, password: string): Promise<boolean> {
|
||||||
// The password reaches sqlcmd as a scripting variable from the environment, never inside the
|
// The password is a bound parameter, never inside the query text, so it is in no message of a
|
||||||
// query text, so it is neither on argv nor in the message of a failed command. It is the mesh's
|
// failed statement.
|
||||||
// minted value, which carries no quote.
|
|
||||||
const server = await this.query(
|
const server = await this.query(
|
||||||
`SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = ${literal(login)} ` +
|
`SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = ${literal(login)} ` +
|
||||||
`AND is_disabled = 0 AND PWDCOMPARE(N'$(MESHHOLDSPW)', password_hash) = 1) ` +
|
`AND is_disabled = 0 AND PWDCOMPARE(@meshholdspw, password_hash) = 1) ` +
|
||||||
`AND DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`,
|
`AND DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`,
|
||||||
"master",
|
"master",
|
||||||
{ MESHHOLDSPW: password },
|
{ meshholdspw: password },
|
||||||
);
|
);
|
||||||
if (Number(server[0]?.ok) !== 1) return false;
|
if (Number(server[0]?.ok) !== 1) return false;
|
||||||
// The user must be this login's, by SID, and a db_owner. A user orphaned by a restore has the
|
// The user must be this login's, by SID, and a db_owner. A user orphaned by a restore has the
|
||||||
@@ -294,35 +320,27 @@ export class MssqlClient {
|
|||||||
* (novox/hq issue 193). Read-only by the login, not by a transaction wrapped around the text; the
|
* (novox/hq issue 193). Read-only by the login, not by a transaction wrapped around the text; the
|
||||||
* rows are rendered by FOR JSON. Never as the administrator: without the reader's password the call
|
* rows are rendered by FOR JSON. Never as the administrator: without the reader's password the call
|
||||||
* is refused.
|
* is refused.
|
||||||
|
*
|
||||||
|
* The text goes to the server as it is, over the driver: there is no client between that reads a
|
||||||
|
* line of its own (sqlcmd's `:!!`, which could start a program) or substitutes `$(NAME)` from this
|
||||||
|
* process's environment, so neither the one-line rule nor `-x` has anything left to guard.
|
||||||
*/
|
*/
|
||||||
async readOnlyQuery(database: string, sql: string): Promise<QueryResult> {
|
async readOnlyQuery(database: string, text: string): Promise<QueryResult> {
|
||||||
const password = this.conn.readerPassword;
|
const password = this.conn.readerPassword;
|
||||||
if (!password) throw readerMissing();
|
if (!password) throw readerMissing();
|
||||||
// **One line, refused otherwise.** sqlcmd reads a line that BEGINS with `:` or `!!` as its own
|
|
||||||
// command rather than SQL, and `:!!` starts a program in this container, which holds the
|
|
||||||
// administrator's password. Its switch for refusing those (-X) makes it ignore -Q in the
|
|
||||||
// version shipped here, so instead no line of a caller's text can begin one: the text follows
|
|
||||||
// this module's own on the first line, and a line break in it is refused. Proven on a throwaway
|
|
||||||
// server: the same text at the start of a line ran a program; mid-line it is a syntax error.
|
|
||||||
if (/[\r\n]/.test(sql)) {
|
|
||||||
throw new Error(
|
|
||||||
"mssql_query: the statement must be one line — sqlcmd takes a line beginning with ':' or " +
|
|
||||||
"'!!' as a command of its own, which can start a program (novox/hq issue 193)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.readerReady ??= this.ensureReader().catch((err) => {
|
this.readerReady ??= this.ensureReader().catch((err) => {
|
||||||
this.readerReady = undefined; // asked again next call, not failed for the process's life
|
this.readerReady = undefined; // asked again next call, not failed for the process's life
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
await this.readerReady;
|
await this.readerReady;
|
||||||
const stdout = await this.sqlcmd(
|
const rows = await this.session(
|
||||||
`SET NOCOUNT ON; ${stripTrailingSemis(sql)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`,
|
`SET NOCOUNT ON; ${stripTrailingSemis(text)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`,
|
||||||
database,
|
database,
|
||||||
{},
|
{},
|
||||||
{ user: READER, password, caller: true },
|
{ user: READER, password },
|
||||||
);
|
);
|
||||||
const command = /^\s*([A-Za-z]+)/.exec(sql)?.[1]?.toUpperCase() ?? "";
|
const command = /^\s*([A-Za-z]+)/.exec(text)?.[1]?.toUpperCase() ?? "";
|
||||||
return { command, rows: parseJsonRows(stdout) };
|
return { command, rows: parseJsonRows(rows) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,18 +390,13 @@ function safeUrl(raw: string): URL | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the JSON a FOR JSON query prints through sqlcmd. SQL Server splits a large FOR JSON result
|
* Parse the JSON a FOR JSON query answers. SQL Server splits a large FOR JSON result into
|
||||||
* into ~2033-character chunks, one per output row; with `-h -1 -W` each lands on its own line, so
|
* ~2033-character chunks, one per row of a single column, so the document is reassembled by
|
||||||
* the document is reassembled by concatenating the non-empty lines. No output (an empty result, or
|
* concatenating that column in order. No rows (an empty result, or a pure DDL batch) means none.
|
||||||
* a pure DDL batch) means no rows.
|
|
||||||
*/
|
*/
|
||||||
function parseJsonRows(stdout: string): Record<string, unknown>[] {
|
function parseJsonRows(rows: Record<string, unknown>[]): Record<string, unknown>[] {
|
||||||
const joined = stdout
|
const joined = rows.map((row) => String(Object.values(row)[0] ?? "")).join("");
|
||||||
.split(/\r?\n/)
|
if (joined.trim().length === 0) return [];
|
||||||
.map((l) => l.trimEnd())
|
|
||||||
.filter((l) => l.length > 0)
|
|
||||||
.join("");
|
|
||||||
if (joined.length === 0) return [];
|
|
||||||
const parsed = JSON.parse(joined);
|
const parsed = JSON.parse(joined);
|
||||||
return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [parsed as Record<string, unknown>];
|
return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [parsed as Record<string, unknown>];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// mssql's events entrypoint, loaded by the per-node tool host (the provisioner container runs
|
// mssql's events entrypoint, launched by the node's runtime beside its tools and provisioner
|
||||||
// ./provisioner separately). The database lifecycle events are EMITTED from the provisioner, where
|
// (novox/hq ADR 0198). The database lifecycle events are EMITTED from the provisioner, where
|
||||||
// the lifecycle actually happens (novox/hq ADR 0041/0042):
|
// the lifecycle actually happens (novox/hq ADR 0041/0042):
|
||||||
// module.mssql.database.provisioned — a consumer's database + login/user was created
|
// module.mssql.database.provisioned — a consumer's database + login/user was created
|
||||||
// module.mssql.database.deprovisioned — that database was removed
|
// module.mssql.database.deprovisioned — that database was removed
|
||||||
// Here in the tool host we react to them, keeping a lightweight audit trail of who was granted a
|
// Here in the runtime we react to them, keeping a lightweight audit trail of who was granted a
|
||||||
// database and who lost one — observability the provider itself is best placed to log.
|
// database and who lost one — observability the provider itself is best placed to log.
|
||||||
|
|
||||||
import { on } from "@novox/mesh-sdk/events";
|
import { on } from "@novox/mesh-sdk/events";
|
||||||
|
|||||||
+19
-42
@@ -38,16 +38,9 @@
|
|||||||
},
|
},
|
||||||
"own-secrets": {
|
"own-secrets": {
|
||||||
"sa": "${dir:state}/sa.secret",
|
"sa": "${dir:state}/sa.secret",
|
||||||
"broker": "${dir:mesh-state}/broker",
|
|
||||||
"reader": "${dir:state}/reader.secret"
|
"reader": "${dir:state}/reader.secret"
|
||||||
},
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
|
||||||
"id": "mesh-state",
|
|
||||||
"type": "directory",
|
|
||||||
"mode": "0700",
|
|
||||||
"place": "mesh"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "state",
|
"id": "state",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
@@ -93,46 +86,30 @@
|
|||||||
"${dir:data}:/var/opt/mssql"
|
"${dir:data}:/var/opt/mssql"
|
||||||
],
|
],
|
||||||
"secrets-in-environment": "the image documents only MSSQL_SA_PASSWORD, no _FILE and no configuration field; not convertible without a wrapper entrypoint"
|
"secrets-in-environment": "the image documents only MSSQL_SA_PASSWORD, no _FILE and no configuration field; not convertible without a wrapper entrypoint"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "runtime",
|
|
||||||
"type": "container",
|
|
||||||
"name": "mesh-mssql",
|
|
||||||
"network": "mssql",
|
|
||||||
"volumes": [
|
|
||||||
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
|
|
||||||
"${dir:grants}:/var/lib/mssql/grants:ro",
|
|
||||||
"${dir:state}/sa.secret:/run/secrets/sa:ro",
|
|
||||||
"${dir:state}/reader.secret:/run/secrets/reader:ro"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"MESH_PROVISION_MSSQL": "mssql://sa@mssql:1433/master",
|
|
||||||
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/sa",
|
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
|
||||||
"MESH_RECEIVES": "/var/lib/mssql/grants/mesh.json",
|
|
||||||
"MESH_MSSQL_READER_PASSWORD_FILE": "/run/secrets/reader"
|
|
||||||
},
|
|
||||||
"artifact": "runtime"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"on": [
|
|
||||||
{
|
|
||||||
"arg": "BUILD_BASE",
|
|
||||||
"module": "mesh-tools",
|
|
||||||
"artifact": "build"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"arg": "RUNTIME_BASE",
|
|
||||||
"module": "mesh-tools",
|
|
||||||
"artifact": "runtime"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
"name": "runtime",
|
"name": "code",
|
||||||
"kind": "image",
|
"kind": "bundle",
|
||||||
"from": "Dockerfile"
|
"language": "typescript",
|
||||||
|
"entrypoints": [
|
||||||
|
"index.js",
|
||||||
|
"tools/index.js",
|
||||||
|
"provisioner/index.js"
|
||||||
|
],
|
||||||
|
"loads": [
|
||||||
|
"index.js",
|
||||||
|
"tools/index.js",
|
||||||
|
"provisioner/index.js"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"MESH_PROVISION_MSSQL": "mssql://sa@127.0.0.1:${port:1433}/master",
|
||||||
|
"MESH_PROVISION_PASSWORD_FILE": "${dir:state}/sa.secret",
|
||||||
|
"MESH_RECEIVES": "${dir:grants}/mesh.json",
|
||||||
|
"MESH_MSSQL_READER_PASSWORD_FILE": "${dir:state}/reader.secret"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+32
@@ -0,0 +1,32 @@
|
|||||||
|
// Ambient types for `mssql`, which ships its types only in the separate `@types/mssql` package. This
|
||||||
|
// declares the slice client.ts uses — the precedent mesh-catalog's pg.d.ts sets — so the module
|
||||||
|
// type-checks without deciding what runs: the real `mssql` is the package.json dependency the
|
||||||
|
// builder installs and inlines into the bundle (novox/hq ADR 0198 §4).
|
||||||
|
declare module "mssql" {
|
||||||
|
interface Result {
|
||||||
|
recordsets: unknown;
|
||||||
|
}
|
||||||
|
interface Request {
|
||||||
|
input(name: string, type: unknown, value: unknown): Request;
|
||||||
|
query(text: string): Promise<Result>;
|
||||||
|
batch(text: string): Promise<Result>;
|
||||||
|
}
|
||||||
|
class ConnectionPool {
|
||||||
|
constructor(config: {
|
||||||
|
server: string;
|
||||||
|
port?: number;
|
||||||
|
user?: string;
|
||||||
|
password?: string;
|
||||||
|
database?: string;
|
||||||
|
options?: { encrypt?: boolean; trustServerCertificate?: boolean; serverName?: string };
|
||||||
|
pool?: { min?: number; max?: number };
|
||||||
|
connectionTimeout?: number;
|
||||||
|
requestTimeout?: number;
|
||||||
|
});
|
||||||
|
connect(): Promise<ConnectionPool>;
|
||||||
|
request(): Request;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
const sql: { ConnectionPool: typeof ConnectionPool; NVarChar: unknown };
|
||||||
|
export default sql;
|
||||||
|
}
|
||||||
@@ -5,11 +5,12 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc client.ts index.ts tools/index.ts provisioner/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
"build": "tsc mssql.d.ts client.ts index.ts tools/index.ts provisioner/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
||||||
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
|
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@novox/mesh-sdk": "^0.1.1"
|
"@novox/mesh-sdk": "^0.1.1",
|
||||||
|
"mssql": "^11.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -1,96 +1,83 @@
|
|||||||
// What holds mssql_query to being read-only (novox/hq issue 193): a caller's statement runs as the
|
// What holds mssql_query to being read-only (novox/hq issue 193): a caller's statement runs as the
|
||||||
// reader login and never as the administrator, with sqlcmd's variable substitution off, on one line
|
// reader login and never as the administrator, with no transaction wrapped around it as text, and
|
||||||
// that follows the module's own — a line break is refused before sqlcmd starts — and with no
|
// without the reader's password the statement is refused.
|
||||||
// transaction wrapped around it as text. Without the reader's password the statement is refused.
|
|
||||||
//
|
//
|
||||||
// sqlcmd is a fake on PATH that records each call's login, flags and text. That the reader cannot
|
// The driver is a fake session that records each call's login, database, text and bound
|
||||||
// write is the server's to enforce and was proven against a real server; this holds the module to
|
// parameters. That the reader cannot write is the server's to enforce and was proven against a real
|
||||||
// asking for it. Run against the compiled module (npm test builds first), the way the runtime loads it.
|
// server; this holds the module to asking for it. Run against the compiled module (npm test builds
|
||||||
|
// first), the way the runtime loads it.
|
||||||
|
|
||||||
import { test, before, after } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
import { MssqlClient, READER } from "../dist/client.js";
|
import { MssqlClient, READER, type Connect, type Target } from "../dist/client.js";
|
||||||
|
|
||||||
let dir: string;
|
interface Call extends Target {
|
||||||
let log: string;
|
text: string;
|
||||||
const originalPath = process.env.PATH;
|
params: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
before(async () => {
|
function recording(): { connect: Connect; calls: Call[] } {
|
||||||
dir = await mkdtemp(join(tmpdir(), "mssql-reader-"));
|
const calls: Call[] = [];
|
||||||
log = join(dir, "calls.jsonl");
|
const connect: Connect = async (to) => ({
|
||||||
await writeFile(join(dir, "sqlcmd"), `#!/usr/bin/env node
|
async run(text, params = {}) {
|
||||||
const fs = require("node:fs");
|
calls.push({ ...to, text, params });
|
||||||
const args = process.argv.slice(2);
|
if (/FROM sys.server_principals/.test(text)) return [];
|
||||||
const at = (flag) => args[args.indexOf(flag) + 1];
|
// FOR JSON answers its document split across rows of one column.
|
||||||
fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify({
|
if (/FOR JSON/.test(text)) return [{ JSON_F52E: '[{"name":"al' }, { JSON_F52E: 'pha","n":1}]' }];
|
||||||
user: at("-U"), database: at("-d"), sql: at("-Q"), noVariables: args.includes("-x"),
|
return [];
|
||||||
password: process.env.SQLCMDPASSWORD,
|
},
|
||||||
}) + "\\n");
|
async close() {},
|
||||||
const sql = at("-Q");
|
});
|
||||||
if (/FROM sys.server_principals/.test(sql)) process.stdout.write("");
|
return { connect, calls };
|
||||||
else if (/FOR JSON/.test(sql)) process.stdout.write('[{"name":"alpha","n":1}]\\n');
|
|
||||||
`);
|
|
||||||
await chmod(join(dir, "sqlcmd"), 0o755);
|
|
||||||
process.env.PATH = `${dir}:${originalPath}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
process.env.PATH = originalPath;
|
|
||||||
await rm(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
async function calls(): Promise<Record<string, unknown>[]> {
|
|
||||||
const text = await readFile(log, "utf8").catch(() => "");
|
|
||||||
await writeFile(log, "");
|
|
||||||
return text.split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const conn = { host: "127.0.0.1", port: 1433, user: "sa", password: "admin-secret" };
|
const conn = { host: "127.0.0.1", port: 1433, user: "sa", password: "admin-secret" };
|
||||||
|
|
||||||
test("a caller's statement runs as the reader, without variables, on the module's first line", async () => {
|
test("a caller's statement runs as the reader, as it was written, on the database it names", async () => {
|
||||||
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" });
|
const { connect, calls } = recording();
|
||||||
|
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }, connect);
|
||||||
const result = await client.readOnlyQuery("inventory", "SELECT '$(SQLCMDPASSWORD)' AS p");
|
const result = await client.readOnlyQuery("inventory", "SELECT '$(SQLCMDPASSWORD)' AS p");
|
||||||
|
|
||||||
const asked = (await calls()).at(-1)!;
|
const asked = calls.at(-1)!;
|
||||||
assert.equal(asked.user, READER, "the statement never runs as the administrator");
|
assert.equal(asked.user, READER, "the statement never runs as the administrator");
|
||||||
assert.equal(asked.password, "reader-secret");
|
assert.equal(asked.password, "reader-secret");
|
||||||
assert.equal(asked.noVariables, true, "no $(NAME) is substituted in a caller's text");
|
assert.equal(asked.database, "inventory");
|
||||||
const [first] = String(asked.sql).split("\n");
|
assert.ok(asked.text.startsWith("SET NOCOUNT ON; SELECT '$(SQLCMDPASSWORD)' AS p\nFOR JSON PATH"),
|
||||||
assert.ok(first.startsWith("SET NOCOUNT ON; SELECT '$(SQLCMDPASSWORD)'"), "the caller's text never begins a line");
|
"the caller's text reaches the server unaltered");
|
||||||
assert.doesNotMatch(String(asked.sql), /BEGIN TRANSACTION|ROLLBACK/, "no transaction wrapped around it as text");
|
assert.doesNotMatch(asked.text, /BEGIN TRANSACTION|ROLLBACK/, "no transaction wrapped around it as text");
|
||||||
assert.deepEqual(result.rows, [{ name: "alpha", n: 1 }]);
|
assert.deepEqual(result.rows, [{ name: "alpha", n: 1 }], "a FOR JSON document split across rows is reassembled");
|
||||||
assert.equal(result.command, "SELECT");
|
assert.equal(result.command, "SELECT");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a line break in a caller's statement is refused before sqlcmd starts", async () => {
|
|
||||||
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" });
|
|
||||||
for (const sql of ["SELECT 1\n:!! id", "SELECT 1\r\n:!! id", "SELECT 1\r:!! id"]) {
|
|
||||||
await assert.rejects(client.readOnlyQuery("inventory", sql), /must be one line/);
|
|
||||||
}
|
|
||||||
assert.deepEqual(await calls(), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the reader is made as the administrator, kept out of sysadmin, and granted only reading", async () => {
|
test("the reader is made as the administrator, kept out of sysadmin, and granted only reading", async () => {
|
||||||
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" });
|
const { connect, calls } = recording();
|
||||||
|
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }, connect);
|
||||||
await client.readOnlyQuery("inventory", "SELECT 1 AS x");
|
await client.readOnlyQuery("inventory", "SELECT 1 AS x");
|
||||||
await client.readOnlyQuery("inventory", "SELECT 2 AS x");
|
await client.readOnlyQuery("inventory", "SELECT 2 AS x");
|
||||||
|
|
||||||
const made = await calls();
|
const asAdmin = calls.filter((c) => c.user === "sa").map((c) => c.text);
|
||||||
const asAdmin = made.filter((c) => c.user === "sa").map((c) => String(c.sql));
|
|
||||||
assert.ok(asAdmin.some((s) => s.startsWith(`CREATE LOGIN [${READER}]`)));
|
assert.ok(asAdmin.some((s) => s.startsWith(`CREATE LOGIN [${READER}]`)));
|
||||||
assert.ok(asAdmin.some((s) => /ALTER SERVER ROLE sysadmin DROP MEMBER/.test(s)));
|
assert.ok(asAdmin.some((s) => /ALTER SERVER ROLE sysadmin DROP MEMBER/.test(s)));
|
||||||
assert.ok(asAdmin.includes(`GRANT CONNECT ANY DATABASE TO [${READER}]`));
|
assert.ok(asAdmin.includes(`GRANT CONNECT ANY DATABASE TO [${READER}]`));
|
||||||
assert.ok(asAdmin.includes(`GRANT SELECT ALL USER SECURABLES TO [${READER}]`));
|
assert.ok(asAdmin.includes(`GRANT SELECT ALL USER SECURABLES TO [${READER}]`));
|
||||||
assert.equal(asAdmin.filter((s) => s.startsWith("CREATE LOGIN")).length, 1, "made once, not per call");
|
assert.equal(asAdmin.filter((s) => s.startsWith("CREATE LOGIN")).length, 1, "made once, not per call");
|
||||||
assert.equal(made.filter((c) => c.user === READER).length, 2);
|
assert.equal(calls.filter((c) => c.user === READER).length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("without the reader's password the statement is refused, and nothing runs as the administrator", async () => {
|
test("without the reader's password the statement is refused, and nothing runs as the administrator", async () => {
|
||||||
const client = new MssqlClient(conn);
|
const { connect, calls } = recording();
|
||||||
|
const client = new MssqlClient(conn, connect);
|
||||||
await assert.rejects(client.readOnlyQuery("inventory", "SELECT 1"), /refused rather than run as the administrator/);
|
await assert.rejects(client.readOnlyQuery("inventory", "SELECT 1"), /refused rather than run as the administrator/);
|
||||||
assert.deepEqual(await calls(), []);
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a consumer's password is checked as a bound parameter, never in the text", async () => {
|
||||||
|
const { connect, calls } = recording();
|
||||||
|
const client = new MssqlClient(conn, connect);
|
||||||
|
await client.holdsLogin("shop", "shop_login", "minted-secret");
|
||||||
|
const asked = calls[0];
|
||||||
|
assert.equal(asked.params.meshholdspw, "minted-secret");
|
||||||
|
assert.doesNotMatch(asked.text, /minted-secret/);
|
||||||
|
assert.match(asked.text, /PWDCOMPARE\(@meshholdspw, password_hash\)/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
|
"include": ["mssql.d.ts", "client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bounds on what a caller may ask: a check is a probe, never a wait anyone can make long.
|
||||||
|
const (
|
||||||
|
DefaultTimeout = 3 * time.Second
|
||||||
|
MostTimeout = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// TCPResult is what netcheck_tcp answers.
|
||||||
|
type TCPResult struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
Reachable bool `json:"reachable"`
|
||||||
|
ElapsedMS int64 `json:"elapsed_ms"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckTCP opens one TCP connection and closes it, sending nothing. A port that refuses or a host
|
||||||
|
// that does not answer is a result, not a failure of the tool; only a malformed question is.
|
||||||
|
func CheckTCP(host string, port int, timeout time.Duration) (TCPResult, error) {
|
||||||
|
if port < 1 || port > 65535 {
|
||||||
|
return TCPResult{}, fmt.Errorf("port %d is not a TCP port (1-65535)", port)
|
||||||
|
}
|
||||||
|
out := TCPResult{Host: host, Port: port}
|
||||||
|
start := time.Now()
|
||||||
|
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), timeout)
|
||||||
|
out.ElapsedMS = time.Since(start).Milliseconds()
|
||||||
|
if err != nil {
|
||||||
|
out.Error = err.Error()
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
out.Address = conn.RemoteAddr().String()
|
||||||
|
out.Reachable = true
|
||||||
|
_ = conn.Close()
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSResult is what netcheck_dns answers.
|
||||||
|
type DNSResult struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Answers []string `json:"answers"`
|
||||||
|
ElapsedMS int64 `json:"elapsed_ms"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSTypes are the record types netcheck_dns looks up.
|
||||||
|
var DNSTypes = []string{"A", "AAAA", "CNAME", "TXT", "MX"}
|
||||||
|
|
||||||
|
// CheckDNS looks a name up with the machine's resolver. Built without cgo, Go's own resolver reads
|
||||||
|
// the machine's /etc/resolv.conf and /etc/hosts, which is the resolver this machine's programs use.
|
||||||
|
// A name that does not resolve is a result with its error; an unknown type is refused.
|
||||||
|
func CheckDNS(name, kind string, timeout time.Duration) (DNSResult, error) {
|
||||||
|
kind = strings.ToUpper(strings.TrimSpace(kind))
|
||||||
|
if kind == "" {
|
||||||
|
kind = "A"
|
||||||
|
}
|
||||||
|
known := false
|
||||||
|
for _, t := range DNSTypes {
|
||||||
|
known = known || t == kind
|
||||||
|
}
|
||||||
|
if !known {
|
||||||
|
return DNSResult{}, fmt.Errorf("type %q is not one netcheck_dns looks up (%s)", kind, strings.Join(DNSTypes, ", "))
|
||||||
|
}
|
||||||
|
out := DNSResult{Name: name, Type: kind, Answers: []string{}}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
r := net.DefaultResolver
|
||||||
|
start := time.Now()
|
||||||
|
var err error
|
||||||
|
switch kind {
|
||||||
|
case "A", "AAAA":
|
||||||
|
network := "ip4"
|
||||||
|
if kind == "AAAA" {
|
||||||
|
network = "ip6"
|
||||||
|
}
|
||||||
|
var ips []net.IP
|
||||||
|
if ips, err = r.LookupIP(ctx, network, name); err == nil {
|
||||||
|
for _, ip := range ips {
|
||||||
|
out.Answers = append(out.Answers, ip.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "CNAME":
|
||||||
|
var cname string
|
||||||
|
if cname, err = r.LookupCNAME(ctx, name); err == nil {
|
||||||
|
out.Answers = append(out.Answers, cname)
|
||||||
|
}
|
||||||
|
case "TXT":
|
||||||
|
var txts []string
|
||||||
|
if txts, err = r.LookupTXT(ctx, name); err == nil {
|
||||||
|
out.Answers = append(out.Answers, txts...)
|
||||||
|
}
|
||||||
|
case "MX":
|
||||||
|
var mxs []*net.MX
|
||||||
|
if mxs, err = r.LookupMX(ctx, name); err == nil {
|
||||||
|
for _, mx := range mxs {
|
||||||
|
out.Answers = append(out.Answers, fmt.Sprintf("%d %s", mx.Pref, mx.Host))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.ElapsedMS = time.Since(start).Milliseconds()
|
||||||
|
if err != nil {
|
||||||
|
out.Error = err.Error()
|
||||||
|
}
|
||||||
|
if kind != "MX" {
|
||||||
|
sort.Strings(out.Answers)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// text is a required string argument.
|
||||||
|
func text(args map[string]any, key string) (string, error) {
|
||||||
|
s, _ := args[key].(string)
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return "", fmt.Errorf("%s is required", key)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// whole is an integer argument, given as a JSON number or a numeric string; fallback when absent.
|
||||||
|
func whole(args map[string]any, key string, fallback int) (int, error) {
|
||||||
|
v, given := args[key]
|
||||||
|
if !given || v == nil {
|
||||||
|
if fallback == 0 {
|
||||||
|
return 0, fmt.Errorf("%s is required", key)
|
||||||
|
}
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
switch n := v.(type) {
|
||||||
|
case float64:
|
||||||
|
if n != math.Trunc(n) {
|
||||||
|
return 0, fmt.Errorf("%s must be a whole number, not %v", key, n)
|
||||||
|
}
|
||||||
|
return int(n), nil
|
||||||
|
case string:
|
||||||
|
i, err := strconv.Atoi(strings.TrimSpace(n))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%s must be a whole number, not %q", key, n)
|
||||||
|
}
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
return 0, errors.New(key + " must be a whole number")
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeoutOf is timeout_ms, defaulted and bounded.
|
||||||
|
func timeoutOf(args map[string]any) (time.Duration, error) {
|
||||||
|
ms, err := whole(args, "timeout_ms", int(DefaultTimeout/time.Millisecond))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if ms < 1 {
|
||||||
|
return 0, fmt.Errorf("timeout_ms must be at least 1, not %d", ms)
|
||||||
|
}
|
||||||
|
d := time.Duration(ms) * time.Millisecond
|
||||||
|
if d > MostTimeout {
|
||||||
|
d = MostTimeout
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestATCPPortThatListensIsReachableAndOneThatDoesNotIsNot(t *testing.T) {
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
port := l.Addr().(*net.TCPAddr).Port
|
||||||
|
got, err := CheckTCP("127.0.0.1", port, time.Second)
|
||||||
|
if err != nil || !got.Reachable || got.Error != "" {
|
||||||
|
t.Fatalf("a listening port: %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
l.Close()
|
||||||
|
got, err = CheckTCP("127.0.0.1", port, time.Second)
|
||||||
|
if err != nil || got.Reachable || got.Error == "" {
|
||||||
|
t.Fatalf("a closed port is reported as a result with its error, not a failure: %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPortOutsideTheRangeIsRefused(t *testing.T) {
|
||||||
|
for _, p := range []int{0, -1, 65536} {
|
||||||
|
if _, err := CheckTCP("127.0.0.1", p, time.Second); err == nil {
|
||||||
|
t.Errorf("port %d was accepted", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDNSAnswersFromTheMachinesResolverAndRefusesAnUnknownType(t *testing.T) {
|
||||||
|
got, err := CheckDNS("localhost", "a", time.Second)
|
||||||
|
if err != nil || got.Type != "A" || len(got.Answers) == 0 {
|
||||||
|
t.Fatalf("localhost A: %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := CheckDNS("localhost", "SRV", time.Second); err == nil {
|
||||||
|
t.Fatal("an unknown record type was accepted")
|
||||||
|
}
|
||||||
|
got, err = CheckDNS("no-such-name.invalid", "A", time.Second)
|
||||||
|
if err != nil || got.Error == "" || len(got.Answers) != 0 {
|
||||||
|
t.Fatalf("a name that does not resolve is a result with its error: %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeoutIsDefaultedAndBounded(t *testing.T) {
|
||||||
|
if d, _ := timeoutOf(map[string]any{}); d != DefaultTimeout {
|
||||||
|
t.Errorf("default: %v", d)
|
||||||
|
}
|
||||||
|
if d, _ := timeoutOf(map[string]any{"timeout_ms": float64(10 * 60 * 1000)}); d != MostTimeout {
|
||||||
|
t.Errorf("bounded: %v", d)
|
||||||
|
}
|
||||||
|
if _, err := timeoutOf(map[string]any{"timeout_ms": float64(0)}); err == nil {
|
||||||
|
t.Error("a zero timeout was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBothToolsAreListedUnprefixed(t *testing.T) {
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, tool := range tools() {
|
||||||
|
names[tool.Name] = true
|
||||||
|
}
|
||||||
|
if !names["netcheck_tcp"] || !names["netcheck_dns"] || len(names) != 2 {
|
||||||
|
t.Fatalf("tools: %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// netcheck's Go tools bundle (novox/hq ADR 0188, ADR 0193): a process the node's runtime launches
|
||||||
|
// and speaks MCP over stdio to, through the Go SDK. It serves the two checks that are the machine's
|
||||||
|
// own sockets and resolver — a TCP connect and a DNS lookup — and nothing that changes anything.
|
||||||
|
// The module's HTTP check is its TypeScript bundle; the runtime serves both under one module.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): netcheck.
|
||||||
|
if err := stdio.Serve("", tools()); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tools() []stdio.Tool {
|
||||||
|
return []stdio.Tool{
|
||||||
|
{
|
||||||
|
Name: "netcheck_tcp",
|
||||||
|
Description: "Check whether a TCP port is reachable from this machine: opens one connection " +
|
||||||
|
"and closes it at once, sending nothing. Answers reachable, elapsed_ms and the error when not.",
|
||||||
|
Input: map[string]any{
|
||||||
|
"host": map[string]any{"type": "string", "description": "host name or IP address"},
|
||||||
|
"port": map[string]any{"type": "integer", "description": "TCP port, 1-65535"},
|
||||||
|
"timeout_ms": map[string]any{"type": "integer", "description": "give up after this long (default 3000, at most 30000)"},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
host, err := text(args, "host")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
port, err := whole(args, "port", 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
timeout, err := timeoutOf(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return CheckTCP(host, port, timeout)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "netcheck_dns",
|
||||||
|
Description: "Look a name up with this machine's resolver (its /etc/resolv.conf and /etc/hosts). " +
|
||||||
|
"type is A, AAAA, CNAME, TXT or MX; answers the records found, or the error.",
|
||||||
|
Input: map[string]any{
|
||||||
|
"name": map[string]any{"type": "string", "description": "the name to look up"},
|
||||||
|
"type": map[string]any{"type": "string", "enum": []string{"A", "AAAA", "CNAME", "TXT", "MX"}, "description": "record type (default A)"},
|
||||||
|
"timeout_ms": map[string]any{"type": "integer", "description": "give up after this long (default 3000, at most 30000)"},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
name, err := text(args, "name")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
kind, _ := args["type"].(string)
|
||||||
|
timeout, err := timeoutOf(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return CheckDNS(name, kind, timeout)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module netcheck
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// netcheck's HTTP check — the module's own code, in TypeScript (novox/hq ADR 0039, ADR 0188). One
|
||||||
|
// request, HEAD or GET, never a body sent and never a body read: the status, how long it took and
|
||||||
|
// a few headers that say what answered. Redirects are reported, not followed, so a check reaches
|
||||||
|
// exactly the address it was given.
|
||||||
|
|
||||||
|
export const METHODS = ["HEAD", "GET"] as const;
|
||||||
|
export type Method = (typeof METHODS)[number];
|
||||||
|
|
||||||
|
/** The headers worth reporting: what answered and what it says it is, nothing it set for a client. */
|
||||||
|
export const REPORTED_HEADERS = [
|
||||||
|
"content-type", "content-length", "server", "location", "date",
|
||||||
|
"cache-control", "last-modified", "etag",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const DEFAULT_TIMEOUT_MS = 5000;
|
||||||
|
export const MOST_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
export interface HttpResult {
|
||||||
|
url: string;
|
||||||
|
method: Method;
|
||||||
|
status?: number;
|
||||||
|
statusText?: string;
|
||||||
|
elapsed_ms: number;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only http and https are checked; anything else — file:, data:, ftp: — is refused by name. */
|
||||||
|
export function checkedUrl(raw: unknown): URL {
|
||||||
|
const text = typeof raw === "string" ? raw.trim() : "";
|
||||||
|
if (!text) throw new Error("url is required");
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${JSON.stringify(text)} is not a URL`);
|
||||||
|
}
|
||||||
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||||
|
throw new Error(`netcheck_http checks http and https URLs only, not ${url.protocol}`);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkedMethod(raw: unknown): Method {
|
||||||
|
const m = (typeof raw === "string" && raw.trim() ? raw.trim() : "HEAD").toUpperCase();
|
||||||
|
if (!(METHODS as readonly string[]).includes(m)) {
|
||||||
|
throw new Error(`method ${m} is not one netcheck_http uses (${METHODS.join(", ")}): a check never changes anything`);
|
||||||
|
}
|
||||||
|
return m as Method;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkedTimeout(raw: unknown): number {
|
||||||
|
if (raw === undefined || raw === null || raw === "") return DEFAULT_TIMEOUT_MS;
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isInteger(n) || n < 1) throw new Error(`timeout_ms must be a whole number of at least 1, not ${String(raw)}`);
|
||||||
|
return Math.min(n, MOST_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Make one request and report how it went. A refused connection or a timeout is a result with its
|
||||||
|
* error; only a malformed question throws. */
|
||||||
|
export async function checkHttp(args: Readonly<Record<string, unknown>>, fetcher: typeof fetch = fetch): Promise<HttpResult> {
|
||||||
|
const url = checkedUrl(args.url);
|
||||||
|
const method = checkedMethod(args.method);
|
||||||
|
const timeout = checkedTimeout(args.timeout_ms);
|
||||||
|
const started = performance.now();
|
||||||
|
const out: HttpResult = { url: url.toString(), method, elapsed_ms: 0, headers: {} };
|
||||||
|
try {
|
||||||
|
const res = await fetcher(url, { method, redirect: "manual", signal: AbortSignal.timeout(timeout) });
|
||||||
|
out.elapsed_ms = Math.round(performance.now() - started);
|
||||||
|
out.status = res.status;
|
||||||
|
out.statusText = res.statusText;
|
||||||
|
for (const h of REPORTED_HEADERS) {
|
||||||
|
const v = res.headers.get(h);
|
||||||
|
if (v !== null) out.headers[h] = v;
|
||||||
|
}
|
||||||
|
// The body is not read: a check asks whether something answers, not what it says.
|
||||||
|
await res.body?.cancel().catch(() => {});
|
||||||
|
} catch (err) {
|
||||||
|
out.elapsed_ms = Math.round(performance.now() - started);
|
||||||
|
const e = err as Error & { cause?: { message?: string; code?: string } };
|
||||||
|
out.error = e.name === "TimeoutError" ? `no answer within ${timeout} ms` : (e.cause?.code ?? e.cause?.message ?? e.message);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"module": "netcheck",
|
||||||
|
"version": "1",
|
||||||
|
"tools": [
|
||||||
|
"netcheck_tcp",
|
||||||
|
"netcheck_dns",
|
||||||
|
"netcheck_http"
|
||||||
|
],
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "tools-go",
|
||||||
|
"kind": "bundle",
|
||||||
|
"language": "go",
|
||||||
|
"system": "arch",
|
||||||
|
"from": "cmd/netcheck",
|
||||||
|
"binary": "netcheck",
|
||||||
|
"loads": [
|
||||||
|
"netcheck"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tools-typescript",
|
||||||
|
"kind": "bundle",
|
||||||
|
"language": "typescript",
|
||||||
|
"entrypoints": [
|
||||||
|
"tools/index.js"
|
||||||
|
],
|
||||||
|
"loads": [
|
||||||
|
"tools/index.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "@novox/module-netcheck",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "netcheck — read-only network checks from a machine, as one module carrying a Go tools bundle (TCP, DNS) and a TypeScript one (HTTP) (novox/hq ADR 0188, ADR 0193).",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@novox/mesh-sdk": "^0.1.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// The HTTP check refuses what is not http(s) and what would change something, and reports a status,
|
||||||
|
// a refusal and a timeout as results (novox/hq ADR 0188: a tools bundle is read-only and harmless).
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { checkHttp, checkedMethod, checkedUrl } from "../http.ts";
|
||||||
|
|
||||||
|
test("only http and https URLs are checked", () => {
|
||||||
|
for (const bad of ["file:///etc/passwd", "ftp://example.org/", "data:text/plain,hi", "javascript:1", "", "not a url"]) {
|
||||||
|
assert.throws(() => checkedUrl(bad), `${bad} was accepted`);
|
||||||
|
}
|
||||||
|
assert.equal(checkedUrl("https://example.org/x").protocol, "https:");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only HEAD and GET are used", () => {
|
||||||
|
assert.equal(checkedMethod(undefined), "HEAD");
|
||||||
|
assert.equal(checkedMethod("get"), "GET");
|
||||||
|
for (const bad of ["POST", "PUT", "DELETE", "PATCH"]) assert.throws(() => checkedMethod(bad));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a status, its headers and a redirect not followed", async () => {
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
if (req.url === "/moved") { res.writeHead(302, { location: "/elsewhere" }); res.end(); return; }
|
||||||
|
res.writeHead(200, { "content-type": "text/plain", "x-secret": "not reported" });
|
||||||
|
res.end(req.method === "GET" ? "body" : undefined);
|
||||||
|
});
|
||||||
|
await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok));
|
||||||
|
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||||
|
try {
|
||||||
|
const head = await checkHttp({ url: base + "/" });
|
||||||
|
assert.equal(head.status, 200);
|
||||||
|
assert.equal(head.method, "HEAD");
|
||||||
|
assert.equal(head.headers["content-type"], "text/plain");
|
||||||
|
assert.equal(head.headers["x-secret"], undefined);
|
||||||
|
const moved = await checkHttp({ url: base + "/moved", method: "GET" });
|
||||||
|
assert.equal(moved.status, 302);
|
||||||
|
assert.equal(moved.headers.location, "/elsewhere");
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a refused connection is a result with its error", async () => {
|
||||||
|
const server = createServer();
|
||||||
|
await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok));
|
||||||
|
const port = (server.address() as AddressInfo).port;
|
||||||
|
await new Promise<void>((ok) => server.close(() => ok()));
|
||||||
|
const got = await checkHttp({ url: `http://127.0.0.1:${port}/`, timeout_ms: 2000 });
|
||||||
|
assert.equal(got.status, undefined);
|
||||||
|
assert.ok(got.error, "no error reported");
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// netcheck's TypeScript tools bundle (novox/hq ADR 0188, ADR 0193): what the builder's launcher
|
||||||
|
// imports and serves over MCP on stdio. Its Go bundle serves the TCP and DNS checks; this one the
|
||||||
|
// HTTP check — one module, two languages, one runtime that knows neither.
|
||||||
|
|
||||||
|
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||||
|
import { checkHttp, DEFAULT_TIMEOUT_MS, METHODS, MOST_TIMEOUT_MS } from "../http.js";
|
||||||
|
|
||||||
|
export function getNetcheckHttpTools(): ToolDefinition[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "netcheck_http",
|
||||||
|
description:
|
||||||
|
"Check whether an http(s) URL answers from this machine: one HEAD or GET, no body sent or read, " +
|
||||||
|
"redirects reported and not followed. Answers status, elapsed_ms and a few headers.",
|
||||||
|
input: {
|
||||||
|
url: { type: "string", description: "an http:// or https:// URL" },
|
||||||
|
method: { type: "string", enum: [...METHODS], description: "HEAD (default) or GET" },
|
||||||
|
timeout_ms: {
|
||||||
|
type: "integer",
|
||||||
|
description: `give up after this long (default ${DEFAULT_TIMEOUT_MS}, at most ${MOST_TIMEOUT_MS})`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
run: async (args) => checkHttp(args),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
registerModuleTools("netcheck", () => getNetcheckHttpTools());
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["http.ts", "tools/index.ts"]
|
||||||
|
}
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:state}/server.env",
|
"path": "${dir:state}/server.env",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"content": "POSTGRES_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nPOSTGRES_DB=${bound:postgres-database:as}\nPOSTGRES_USER=${bound:postgres-database:as}\nPOSTGRES_PASSWORD=${secret:postgres-database}\nNEXTCLOUD_ADMIN_USER=mesh-admin\nNEXTCLOUD_ADMIN_PASSWORD=${secret:admin}\nOBJECTSTORE_S3_HOST=${bound:s3-bucket:at}\nOBJECTSTORE_S3_PORT=${bound:s3-bucket:port}\nOBJECTSTORE_S3_BUCKET=mesh-novox-ncloud\nOBJECTSTORE_S3_KEY=${bound:s3-bucket:as}\nOBJECTSTORE_S3_SECRET=${secret:s3-bucket}\nOBJECTSTORE_S3_SSL=false\nOBJECTSTORE_S3_USEPATH_STYLE=true\nOBJECTSTORE_S3_REGION=${bound:s3-bucket:region}\n"
|
"content": "POSTGRES_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nPOSTGRES_DB=${bound:postgres-database:as}\nPOSTGRES_USER=${bound:postgres-database:as}\nPOSTGRES_PASSWORD=${secret:postgres-database}\nNEXTCLOUD_ADMIN_USER=mesh-admin\nNEXTCLOUD_ADMIN_PASSWORD=${secret:admin}\nOBJECTSTORE_S3_HOST=${bound:s3-bucket:at}\nOBJECTSTORE_S3_PORT=${bound:s3-bucket:port}\nOBJECTSTORE_S3_BUCKET=${bound:s3-bucket:bucket}\nOBJECTSTORE_S3_KEY=${bound:s3-bucket:as}\nOBJECTSTORE_S3_SECRET=${secret:s3-bucket}\nOBJECTSTORE_S3_SSL=false\nOBJECTSTORE_S3_USEPATH_STYLE=true\nOBJECTSTORE_S3_REGION=${bound:s3-bucket:region}\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "html",
|
"id": "html",
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"module": "node-env",
|
||||||
|
"version": "1",
|
||||||
|
"claims": [
|
||||||
|
{
|
||||||
|
"name": "node-environment",
|
||||||
|
"scope": "node"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "mesh-config-dir",
|
||||||
|
"type": "directory",
|
||||||
|
"path": "${machine:account-home}/.config/mesh",
|
||||||
|
"owner": "${machine:account}",
|
||||||
|
"mode": "0755"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "environment-d",
|
||||||
|
"type": "directory",
|
||||||
|
"path": "${machine:account-home}/.config/environment.d",
|
||||||
|
"owner": "${machine:account}",
|
||||||
|
"mode": "0755"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "posix",
|
||||||
|
"type": "file",
|
||||||
|
"path": "${machine:account-home}/.config/mesh/environment.sh",
|
||||||
|
"owner": "${machine:account}",
|
||||||
|
"mode": "0644",
|
||||||
|
"content": "# The operator account's environment, generated by the mesh (module node-env, novox/hq ADR 0203).\n# Do not edit: this file is replaced at every push. Every line names the module that contributed it.\n# Sourced by the login shell from its always-read startup file (for zsh, ~/.zshenv), so a script, a\n# login and the shell's execute verb all see it. Your own variables belong in your shell's own lines.\n${environment:posix}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "systemd",
|
||||||
|
"type": "file",
|
||||||
|
"path": "${machine:account-home}/.config/environment.d/50-mesh.conf",
|
||||||
|
"owner": "${machine:account}",
|
||||||
|
"mode": "0644",
|
||||||
|
"content": "# The operator account's environment for its service manager and graphical session, generated by\n# the mesh (module node-env, novox/hq ADR 0203). Do not edit: this file is replaced at every push.\n# The same facts as ~/.config/mesh/environment.sh, in environment.d(5) syntax.\n${environment:systemd}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# openai-consumer's runtime: the tool runtime, carrying this module's compiled code.
|
|
||||||
#
|
|
||||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
|
||||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
|
||||||
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
|
|
||||||
# happens to have the siblings.
|
|
||||||
#
|
|
||||||
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
|
|
||||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
|
||||||
ARG BUILD_BASE
|
|
||||||
ARG RUNTIME_BASE
|
|
||||||
|
|
||||||
FROM ${BUILD_BASE} AS build
|
|
||||||
WORKDIR /app/modules/openai-consumer
|
|
||||||
COPY . .
|
|
||||||
RUN node /app/node_modules/typescript/bin/tsc apply/index.ts \
|
|
||||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
|
||||||
|
|
||||||
FROM ${RUNTIME_BASE}
|
|
||||||
COPY --from=build /app/modules/openai-consumer/dist /app/modules/openai-consumer/dist
|
|
||||||
# No serve-time entrypoints: every container of this module names its command (`run` on a
|
|
||||||
# schedule), so nothing here serves — deliberately no MESH_TOOL_MODULES.
|
|
||||||
@@ -28,44 +28,31 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "apply",
|
"id": "apply",
|
||||||
"type": "container",
|
"type": "process",
|
||||||
"name": "mesh-openai-consumer-apply",
|
"name": "openai-consumer-apply",
|
||||||
"network": "host",
|
"artifact": "code",
|
||||||
|
"run": [
|
||||||
|
"node",
|
||||||
|
"apply/index.js"
|
||||||
|
],
|
||||||
"schedule": "*/5 * * * *",
|
"schedule": "*/5 * * * *",
|
||||||
"args": [
|
|
||||||
"run",
|
|
||||||
"/app/modules/openai-consumer/dist/apply/index.js"
|
|
||||||
],
|
|
||||||
"volumes": [
|
|
||||||
"${dir:state}:/run/state"
|
|
||||||
],
|
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/api-key",
|
"MESH_MODEL_ACCESS_SECRET_FILE": "${dir:state}/api-key",
|
||||||
"MESH_MODEL_ACCESS_BIND_FILE": "/run/state/model.json",
|
"MESH_MODEL_ACCESS_BIND_FILE": "${dir:state}/model.json",
|
||||||
"MESH_OPENAI_ENV_FILE": "/run/state/config/openai.env",
|
"MESH_OPENAI_ENV_FILE": "${dir:state}/config/openai.env",
|
||||||
"MESH_OPENAI_CREDENTIALS_FILE": "/run/state/config/auth.json"
|
"MESH_OPENAI_CREDENTIALS_FILE": "${dir:state}/config/auth.json"
|
||||||
},
|
}
|
||||||
"artifact": "runtime"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"on": [
|
|
||||||
{
|
|
||||||
"arg": "BUILD_BASE",
|
|
||||||
"module": "mesh-tools",
|
|
||||||
"artifact": "build"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"arg": "RUNTIME_BASE",
|
|
||||||
"module": "mesh-tools",
|
|
||||||
"artifact": "runtime"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
"name": "runtime",
|
"name": "code",
|
||||||
"kind": "image",
|
"kind": "bundle",
|
||||||
"from": "Dockerfile"
|
"language": "typescript",
|
||||||
|
"entrypoints": [
|
||||||
|
"apply/index.js"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
"image": "registry-api.novox.be/novox/photos-client@sha256:f87d63ee7bfb44c9f9748b99be6ba6dc0daf955a1d463699e9e7e3009693c0ab",
|
"image": "registry-api.novox.be/novox/photos-client@sha256:f87d63ee7bfb44c9f9748b99be6ba6dc0daf955a1d463699e9e7e3009693c0ab",
|
||||||
"network": "photos-eef",
|
"network": "photos-eef",
|
||||||
"ports": [
|
"ports": [
|
||||||
"80"
|
"4012:80"
|
||||||
],
|
],
|
||||||
"names-on-purpose": {
|
"names-on-purpose": {
|
||||||
"registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)"
|
"registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)"
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
"image": "registry-api.novox.be/novox/photos-client@sha256:f87d63ee7bfb44c9f9748b99be6ba6dc0daf955a1d463699e9e7e3009693c0ab",
|
"image": "registry-api.novox.be/novox/photos-client@sha256:f87d63ee7bfb44c9f9748b99be6ba6dc0daf955a1d463699e9e7e3009693c0ab",
|
||||||
"network": "photos-filip",
|
"network": "photos-filip",
|
||||||
"ports": [
|
"ports": [
|
||||||
"80"
|
"4013:80"
|
||||||
],
|
],
|
||||||
"names-on-purpose": {
|
"names-on-purpose": {
|
||||||
"registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)"
|
"registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)"
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:state}/server.env",
|
"path": "${dir:state}/server.env",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=admin\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_BUCKET=mesh-novox-photos\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\nMINIO_USE_SSL=false\n"
|
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_BUCKET=${bound:s3-bucket:bucket}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\nMINIO_USE_SSL=false\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "net",
|
"id": "net",
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
"image": "registry-api.novox.be/novox/photos-admin-client@sha256:f437fa9ed28b29a012f715fb8d9b809a15cff4a672794c620d5d400f57695580",
|
"image": "registry-api.novox.be/novox/photos-admin-client@sha256:f437fa9ed28b29a012f715fb8d9b809a15cff4a672794c620d5d400f57695580",
|
||||||
"network": "photos",
|
"network": "photos",
|
||||||
"ports": [
|
"ports": [
|
||||||
"80"
|
"4001:80"
|
||||||
],
|
],
|
||||||
"names-on-purpose": {
|
"names-on-purpose": {
|
||||||
"registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)"
|
"registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)"
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# powerlevel10k
|
||||||
|
|
||||||
|
The zsh prompt as a module (novox/hq ADR 0204, ADR 0205, to-be 41).
|
||||||
|
|
||||||
|
- **Upstream:** https://github.com/romkatv/powerlevel10k
|
||||||
|
- **Version:** v1.20.0, vendored verbatim from the release archive
|
||||||
|
(`archive/refs/tags/v1.20.0.tar.gz`, sha256
|
||||||
|
`d8187d44b697b3a37a8c4896678b4380e717cbf2850179529358348780a2d3d7`) into `theme/`. It is 86
|
||||||
|
files and 1,427,736 bytes.
|
||||||
|
- **Licence:** MIT, in `theme/LICENSE`, which travels in the archive. gitstatus's own licence is
|
||||||
|
`theme/gitstatus/LICENSE`.
|
||||||
|
|
||||||
|
The distribution does not package the theme, so the module carries a pinned release (ADR 0205). An
|
||||||
|
upgrade is a change to this directory, reviewed like any other: replace `theme/` with the new
|
||||||
|
release's contents, and update the version here and in the shell code's comment.
|
||||||
|
|
||||||
|
## What it places
|
||||||
|
|
||||||
|
| path under the account's home | what | class (ADR 0182) |
|
||||||
|
|---|---|---|
|
||||||
|
| `~/.local/share/powerlevel10k/` | the theme, unpacked from the `theme` archive | owned, whole |
|
||||||
|
| `~/.config/powerlevel10k/p10k.zsh` | the prompt's configuration, unpacked from the `configuration` archive | owned, whole |
|
||||||
|
|
||||||
|
The configuration is today's `~/.p10k.zsh`, byte for byte. It is the predecessor's file, and was
|
||||||
|
identical on every machine. It ships as an archive of one file rather than as an inline file. At
|
||||||
|
86 KB, inline content would ride in every declaration the node receives, and would be unreadable
|
||||||
|
JSON in review. As its own file it is reviewed as a diff, and pinned by digest like the theme. The
|
||||||
|
directory is the module's, so `p10k configure` writing `~/.p10k.zsh` does not touch it: to change
|
||||||
|
the prompt, change `config/p10k.zsh` here.
|
||||||
|
|
||||||
|
The module contributes zsh code to the `normal` slot of the login shell's block. That code sources
|
||||||
|
the theme, then the configuration, each only if present. Instant prompt is not turned on: the
|
||||||
|
operator's `.zshrc` has its cache line commented out today, and the configuration's own
|
||||||
|
`POWERLEVEL9K_INSTANT_PROMPT` setting does nothing without that line.
|
||||||
|
|
||||||
|
## What it does not do
|
||||||
|
|
||||||
|
- **gitstatus downloads its binary on first use.** The theme's git status helper fetches
|
||||||
|
`gitstatusd` from upstream's releases into `~/.cache/gitstatus` the first time a prompt runs in a
|
||||||
|
git repository. ADR 0205 pins what the mesh ships, not what the software fetches for itself. A
|
||||||
|
machine without a route to upstream shows the prompt without git status.
|
||||||
|
- **Fonts are not this module's.** The configuration uses Nerd Font icons. The terminal's font is the
|
||||||
|
desktop's concern.
|
||||||
|
- **Moving from the predecessor:** once this module is assigned, `~/.zsh/themes/powerlevel10k` and
|
||||||
|
`~/.p10k.zsh` are no longer read, and the operator removes them, once (ADR 0182; the zsh module's
|
||||||
|
README lists the lines to delete from `.zshrc`).
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"module": "powerlevel10k",
|
||||||
|
"version": "1",
|
||||||
|
"shell": [
|
||||||
|
{
|
||||||
|
"for": "zsh",
|
||||||
|
"slot": "normal",
|
||||||
|
"code": "# The prompt: powerlevel10k v1.20.0, pinned in this module (novox/hq ADR 0205), and its configuration.\n[[ ! -f ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme ]] || source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme\n[[ ! -f ~/.config/powerlevel10k/p10k.zsh ]] || source ~/.config/powerlevel10k/p10k.zsh\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "theme",
|
||||||
|
"type": "archive",
|
||||||
|
"path": "${machine:account-home}/.local/share/powerlevel10k",
|
||||||
|
"owner": "${machine:account}",
|
||||||
|
"artifact": "theme"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "configuration",
|
||||||
|
"type": "archive",
|
||||||
|
"path": "${machine:account-home}/.config/powerlevel10k",
|
||||||
|
"owner": "${machine:account}",
|
||||||
|
"artifact": "configuration"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "theme",
|
||||||
|
"kind": "archive",
|
||||||
|
"from": "theme"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "configuration",
|
||||||
|
"kind": "archive",
|
||||||
|
"from": "config"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "@novox/module-powerlevel10k",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "powerlevel10k \u2014 the zsh prompt as a module: upstream v1.20.0 vendored and shipped as a pinned archive, its configuration as a second, and the zsh code that loads both in the normal slot (novox/hq ADR 0204, ADR 0205).",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// The prompt module's shape (novox/hq ADR 0204, ADR 0205): the vendored release is pinned, carries
|
||||||
|
// its licence in the archive, and is loaded with its configuration from the normal slot.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||||
|
|
||||||
|
const at = (p: string) => new URL(`../${p}`, import.meta.url);
|
||||||
|
const m = JSON.parse(readFileSync(at("module.json"), "utf8"));
|
||||||
|
const artifact = (name: string) => m.build.artifacts.find((a: { name: string }) => a.name === name);
|
||||||
|
|
||||||
|
test("the theme archive is built from the vendored release and carries its licence", () => {
|
||||||
|
assert.deepEqual(artifact("theme"), { name: "theme", kind: "archive", from: "theme" });
|
||||||
|
assert.match(readFileSync(at("theme/LICENSE"), "utf8"), /Permission is hereby granted, free of charge/);
|
||||||
|
assert.ok(existsSync(at("theme/powerlevel10k.zsh-theme")));
|
||||||
|
assert.ok(existsSync(at("theme/gitstatus/LICENSE")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the configuration archive holds the prompt's configuration and nothing else", () => {
|
||||||
|
assert.deepEqual(artifact("configuration"), { name: "configuration", kind: "archive", from: "config" });
|
||||||
|
assert.ok(statSync(at("config/p10k.zsh")).size > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("both are unpacked under the account's home, owned by the account", () => {
|
||||||
|
const byId = Object.fromEntries(m.resources.map((r: { id: string }) => [r.id, r]));
|
||||||
|
assert.deepEqual(byId.theme, { id: "theme", type: "archive", path: "${machine:account-home}/.local/share/powerlevel10k", owner: "${machine:account}", artifact: "theme" });
|
||||||
|
assert.deepEqual(byId.configuration, { id: "configuration", type: "archive", path: "${machine:account-home}/.config/powerlevel10k", owner: "${machine:account}", artifact: "configuration" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the zsh code in the normal slot sources the theme, then the configuration, and turns on no instant prompt", () => {
|
||||||
|
assert.equal(m.shell.length, 1);
|
||||||
|
const [c] = m.shell;
|
||||||
|
assert.equal(c.for, "zsh");
|
||||||
|
assert.equal(c.slot, "normal");
|
||||||
|
const sourced = [...(c.code as string).matchAll(/\|\| source (\S+)/g)].map((x) => x[1]);
|
||||||
|
assert.deepEqual(sourced, ["~/.local/share/powerlevel10k/powerlevel10k.zsh-theme", "~/.config/powerlevel10k/p10k.zsh"]);
|
||||||
|
assert.doesNotMatch(c.code, /instant/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the README names the upstream, the version and the licence", () => {
|
||||||
|
const readme = readFileSync(at("README.md"), "utf8");
|
||||||
|
assert.match(readme, /github\.com\/romkatv\/powerlevel10k/);
|
||||||
|
assert.match(readme, /v1\.20\.0/);
|
||||||
|
assert.match(readme, /MIT/);
|
||||||
|
assert.match(m.shell[0].code, /v1\.20\.0/, "the version in the shell code's comment matches");
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
* text=auto
|
||||||
|
*.zsh text eol=lf
|
||||||
|
*.zsh-theme text eol=lf
|
||||||
|
/prompt_powerlevel9k_setup text eol=lf
|
||||||
|
/prompt_powerlevel10k_setup text eol=lf
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
*.zwc
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
Copyright (c) 2009-2014 Robby Russell and contributors (see https://github.com/robbyrussell/oh-my-zsh/contributors)
|
||||||
|
Copyright (c) 2014-2017 Ben Hilburn <bhilburn@gmail.com>
|
||||||
|
Copyright (c) 2019 Roman Perepelitsa <roman.perepelitsa@gmail.com> and contributors (see https://github.com/romkatv/powerlevel10k/contributors)
|
||||||
|
|
||||||
|
MIT LICENSE
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
|
the Software without restriction, including without limitation the rights to
|
||||||
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||||
|
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||||
|
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
ZSH := $(shell command -v zsh 2> /dev/null)
|
||||||
|
|
||||||
|
all:
|
||||||
|
|
||||||
|
zwc:
|
||||||
|
$(MAKE) -C gitstatus zwc
|
||||||
|
$(or $(ZSH),:) -fc 'for f in *.zsh-theme internal/*.zsh; do zcompile -R -- $$f.zwc $$f || exit; done'
|
||||||
|
|
||||||
|
minify:
|
||||||
|
$(MAKE) -C gitstatus minify
|
||||||
|
rm -rf -- .git .gitattributes .gitignore LICENSE Makefile README.md font.md powerlevel10k.png
|
||||||
|
|
||||||
|
pkg: zwc
|
||||||
|
$(MAKE) -C gitstatus pkg
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user