@mcsherrylabs/pi-meerkat 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +234 -54
  2. package/dist/installer/committee-init.sh +117 -0
  3. package/dist/installer/committee.conf +102 -0
  4. package/dist/installer/node-join.sh +228 -0
  5. package/dist/installer/node.conf +235 -0
  6. package/dist/installer/runtime.json +1 -0
  7. package/dist/installer-runtime/dist/scripts/recover-owner.js +15 -0
  8. package/dist/installer-runtime/dist/scripts/subscriptions-init.js +35 -0
  9. package/dist/installer-runtime/dist/scripts/versions.mjs +86 -0
  10. package/dist/installer-runtime/dist/scripts/wait-for-chain.js +90 -0
  11. package/dist/installer-runtime/dist/src/announcements.js +448 -0
  12. package/dist/installer-runtime/dist/src/app.js +539 -0
  13. package/dist/installer-runtime/dist/src/chainfetch.js +87 -0
  14. package/dist/installer-runtime/dist/src/env.js +425 -0
  15. package/dist/installer-runtime/dist/src/registry/announce.js +77 -0
  16. package/dist/installer-runtime/dist/src/registry/announcement.js +141 -0
  17. package/dist/installer-runtime/dist/src/registry/dependencies.js +28 -0
  18. package/dist/installer-runtime/dist/src/registry/detail.js +87 -0
  19. package/dist/installer-runtime/dist/src/registry/drainer.js +131 -0
  20. package/dist/installer-runtime/dist/src/registry/http.js +194 -0
  21. package/dist/installer-runtime/dist/src/registry/index.js +52 -0
  22. package/dist/installer-runtime/dist/src/registry/lease.js +35 -0
  23. package/dist/installer-runtime/dist/src/registry/ownership.js +31 -0
  24. package/dist/installer-runtime/dist/src/registry/provision.js +180 -0
  25. package/dist/installer-runtime/dist/src/registry/store.js +242 -0
  26. package/dist/installer-runtime/dist/src/registry/sweep.js +9 -0
  27. package/dist/installer-runtime/dist/src/registry/worker-module.js +87 -0
  28. package/dist/installer-runtime/dist/src/runtime.js +164 -0
  29. package/dist/installer-runtime/dist/src/store.js +138 -0
  30. package/dist/installer-runtime/package.json +1 -0
  31. package/dist/installer.d.ts +224 -0
  32. package/dist/installer.d.ts.map +1 -0
  33. package/dist/installer.js +336 -0
  34. package/dist/installer.js.map +1 -0
  35. package/dist/mcp-announce.d.ts +42 -0
  36. package/dist/mcp-announce.d.ts.map +1 -0
  37. package/dist/mcp-announce.js +231 -0
  38. package/dist/mcp-announce.js.map +1 -0
  39. package/dist/mcp-cli.d.ts +3 -0
  40. package/dist/mcp-cli.d.ts.map +1 -0
  41. package/dist/mcp-cli.js +137 -0
  42. package/dist/mcp-cli.js.map +1 -0
  43. package/dist/mcp-config.d.ts +28 -0
  44. package/dist/mcp-config.d.ts.map +1 -0
  45. package/dist/mcp-config.js +104 -0
  46. package/dist/mcp-config.js.map +1 -0
  47. package/dist/mcp-facade.d.ts +20 -0
  48. package/dist/mcp-facade.d.ts.map +1 -0
  49. package/dist/mcp-facade.js +200 -0
  50. package/dist/mcp-facade.js.map +1 -0
  51. package/dist/mcp-upstream.d.ts +42 -0
  52. package/dist/mcp-upstream.d.ts.map +1 -0
  53. package/dist/mcp-upstream.js +151 -0
  54. package/dist/mcp-upstream.js.map +1 -0
  55. package/dist/registry.d.ts.map +1 -1
  56. package/dist/registry.js +11 -5
  57. package/dist/registry.js.map +1 -1
  58. package/dist/tools.d.ts +6 -2
  59. package/dist/tools.d.ts.map +1 -1
  60. package/dist/tools.js +123 -119
  61. package/dist/tools.js.map +1 -1
  62. package/installer/Committee.Dockerfile +6 -0
  63. package/installer/Node.Dockerfile +6 -0
  64. package/installer/Registry.Dockerfile +13 -0
  65. package/installer/runtime-control.mjs +172 -0
  66. package/installer/session-files.mjs +16 -0
  67. package/package.json +14 -5
@@ -0,0 +1,425 @@
1
+ // ── THE FOUR STATES, and the one place that knows about them ────────────────
2
+ //
3
+ // An evolver app has TWO independent pieces of state, and each exists PER CHAIN:
4
+ //
5
+ // │ chain state │ application state
6
+ // ─────────────────────┼──────────────────────────────┼───────────────────────────
7
+ // local (ephemeral) │ the docker volume node-data │ state/local/
8
+ // devnet (persistent) │ NOT OURS — shared and remote │ state/devnet/
9
+ // <yours> (persistent) │ NOT OURS — wherever it runs │ state/<name>/
10
+ //
11
+ // Conflating any two of them is the bug this module exists to prevent. Concretely:
12
+ //
13
+ // · Resetting the LOCAL CHAIN (`npm run reset`) throws away every identity that
14
+ // ever existed on it — so the local application state is worthless afterwards and must
15
+ // go too. The reset does both, together, because doing one is always a mistake.
16
+ // · Resetting a REMOTE chain is not possible: it is shared, with other people's data on
17
+ // it. Only the application state can be reset, and doing so ABANDONS the identities it
18
+ // held — they stay on the chain forever, they are simply no longer ours.
19
+ // · Two chains must never share a state directory. If they did, one of them would be
20
+ // handed keys for identities that only exist on the other, and every write would fail in
21
+ // a way that looks like a bug in the app. That is why the directory is `state/<name>`
22
+ // and the chain's NAME is part of its identity — not just its URL. Pointing a second
23
+ // chain at `devnet`'s name was how this used to be done, and it put two chains'
24
+ // identities in one directory.
25
+ //
26
+ // ── HOW THE CHAIN IS CHOSEN, in order, with no silent step ─────────────────
27
+ //
28
+ // 1. `EVOLVER_CHAIN` (+ `EVOLVER_CHAIN_URL`) — a chain DEFINED by the environment. This is
29
+ // how a deployment reaches a chain this file has never heard of. The name is free-form
30
+ // because it becomes a directory name; the URL is REQUIRED for a name that is not
31
+ // built in, because a chain nobody can locate is not a chain.
32
+ // 2. `EVOLVER_TARGET` — SELECTS a built-in chain by name. An unknown value is an ERROR,
33
+ // never a fallback: the variable is a deliberate instruction, and quietly running on a
34
+ // different chain than the one named is the worst outcome available. (It used to fall
35
+ // through to the default, so `EVOLVER_TARGET=stagng` ran against the throwaway chain
36
+ // while the operator believed it was on the deploy chain.)
37
+ // 3. `.target` — the file, for interactive work. A file is right there and an environment
38
+ // variable is not: a variable lives for one command and lies to the next one, while the
39
+ // whole point is that a session — human or agent — can ask "where am I pointed?" and
40
+ // get a durable answer. A file holding nonsense is TOLERATED (it falls back to the
41
+ // default and `npm run status` reports it), because status is the command you are told
42
+ // to run first when something is wrong.
43
+ // 4. The default, `local` — EXCEPT in a container, where there is no default at all.
44
+ // A container is a single process whose whole configuration is its environment; if
45
+ // nothing in it names a chain, that is a deployment that forgot to say, and guessing
46
+ // means claiming permanent identities on a chain nobody chose.
47
+ //
48
+ // Every rung is reported: `preflight`, `npm run target` and `npm run status` all print the
49
+ // resolved chain AND where the choice came from.
50
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
51
+ import { fileURLToPath } from "node:url";
52
+ import { basename, dirname, join, resolve } from "node:path";
53
+ /** A chain that cannot be resolved from the environment: an unknown name, a name with no
54
+ * URL, or — in a container — nothing naming a chain at all. A distinct type because the
55
+ * CALLERS want different things from it: the app must refuse and say why, `status` must
56
+ * report it on the line that names it. The same split as `store.StateError`. */
57
+ export class TargetError extends Error {
58
+ }
59
+ // fileURLToPath, NOT url.pathname: pathname is percent-ENCODED, so a checkout under a path
60
+ // containing a space resolves to a "%20" sibling directory — state, including private keys,
61
+ // written outside the project and invisible to `ls`. On Windows pathname also carries a
62
+ // leading slash before the drive letter.
63
+ const moduleRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
64
+ // The supported emitted layout is dist/src, while source-only operator scripts use src.
65
+ // Both must resolve the SAME application state, never dist/state on a production upgrade.
66
+ export const ROOT = basename(moduleRoot) === "dist" ? dirname(moduleRoot) : moduleRoot;
67
+ const TARGET_FILE = join(ROOT, ".target");
68
+ // DEVNET, not local — this app's default. A fresh clone with no `.target` file (it is
69
+ // gitignored, so that is what a clone has) and no Docker running still does something real.
70
+ //
71
+ // It applies to INTERACTIVE runs only: a container has no default at all (see the ladder
72
+ // above), so this is not what points a deployment. Two consequences, neither undoable by a
73
+ // switch:
74
+ // · `npm start` on a clean checkout claims identities on a chain nobody can reset. They
75
+ // are suffixed per state directory so they never collide, and they are permanent.
76
+ // · anything that drives the LOCAL chain must NAME it rather than trusting the active
77
+ // chain — `npm run chain:wait` passes `local`, and docker-compose.yml states
78
+ // `EVOLVER_TARGET: local`. Before both, the local chain's own readiness check polled
79
+ // DEVNET and passed while nothing local was up.
80
+ export const DEFAULT_TARGET = "devnet";
81
+ /** A chain name becomes a directory name (`state/<name>`) and appears in every message, so
82
+ * it is bounded here rather than trusted. Refusing `..` and separators is the point. */
83
+ export const CHAIN_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,31}$/;
84
+ /** An environment variable, with EMPTY meaning UNSET.
85
+ *
86
+ * `??` is the wrong operator for these. Compose renders a pass-through variable as
87
+ * PRESENT-AND-EMPTY rather than omitting it (`EVOLVER_DEVNET_URL: ${EVOLVER_DEVNET_URL:-}`),
88
+ * and `process.env.X ?? default` only falls back on undefined — so an empty value WINS and
89
+ * the default never applies. That is not hypothetical: `docker-compose.remote.yml` was the
90
+ * first caller to pass these through, devnet's url resolved to the empty string, and the
91
+ * deployment failed with `chain not ready at (target 'devnet')`. Every optional variable
92
+ * in this project goes through here. */
93
+ export function optionalEnv(k) {
94
+ const v = process.env[k]?.trim();
95
+ return v ? v : undefined;
96
+ }
97
+ const env = optionalEnv;
98
+ const BUILTINS = {
99
+ local: {
100
+ // The one-node chain from docker-compose.yml. In-container the app reaches it by service
101
+ // name; on the host, by published port — which is `LOCAL_CHAIN_PORT` if the compose file
102
+ // was told to publish elsewhere, so ONE variable moves both halves. EVOLVER_NODE
103
+ // overrides the whole url.
104
+ nodeUrl: env("EVOLVER_NODE") ?? `http://localhost:${env("LOCAL_CHAIN_PORT") ?? "8440"}`,
105
+ chainIsEphemeral: true,
106
+ describe: "ephemeral one-node chain from docker-compose.yml (resettable, plain HTTP)",
107
+ },
108
+ devnet: {
109
+ nodeUrl: env("EVOLVER_DEVNET_URL") ?? "https://devnet.mcsherrylabs.com",
110
+ chainIsEphemeral: false,
111
+ describe: "shared remote devnet (chain NOT resettable — other people's data)",
112
+ },
113
+ };
114
+ const inContainer = () => process.env.EVOLVER_IN_CONTAINER === "1";
115
+ /**
116
+ * Where a faucet claim goes — `EVOLVER_FAUCET_URL` if set, otherwise the node we talk to.
117
+ *
118
+ * SCOPE, and it is deliberately narrow: **claims only, and only at bootstrap.** Every other
119
+ * request — submits, commit waits, reads — goes to `nodeUrl`, always. An app that routes its
120
+ * WRITES to a remote endpoint has a permanent non-local dependency, and then owning a node
121
+ * buys nothing: the chain is still only as reachable as somebody else's API.
122
+ *
123
+ * The one structural reason this exists: a faucet claim is SPONSORED BY THE NODE THAT
124
+ * RECEIVES IT, out of that node's own balance (`sponsorshipCost` + the new identity's 5000),
125
+ * and only committee members earn block rewards. A follower is given 5000 once and never more,
126
+ * so it answers
127
+ * node_underfunded — Node balance insufficient (need 5100 tokens) to sponsor identity
128
+ * to every claim, forever. That is a BOOTSTRAP problem — it happens once per identity, before
129
+ * the app has done anything — and it disappears entirely if somebody funds the follower, after
130
+ * which this may be left unset and even claims are local.
131
+ *
132
+ * WHAT THIS IS NOT FOR, having been measured and then corrected: a freshly synced follower
133
+ * cannot yet FORWARD a submit — its routing loop was still replaying per-block events at round
134
+ * 3750 while its chain store was at 4500, so a transfer expired unrouted. That is a TRANSIENT
135
+ * startup condition and the answer is to WAIT for the node (see `waitUntilNodeCanWrite` in
136
+ * src/app.ts), not to post somewhere else. Sending writes elsewhere turns one slow minute at
137
+ * boot into a dependency that never goes away.
138
+ */
139
+ function faucetFor(nodeUrl) {
140
+ return env("EVOLVER_FAUCET_URL") ?? nodeUrl;
141
+ }
142
+ function build(name, def, source) {
143
+ // The guard that would have turned the empty-url failure into a sentence instead of
144
+ // `chain not ready after 0s at ` — an unreachable-looking message with nothing in it.
145
+ if (!def.nodeUrl)
146
+ throw new TargetError(`chain '${name}' has no url. ${name === "devnet" ? "EVOLVER_DEVNET_URL" : name === "local" ? "EVOLVER_NODE" : "EVOLVER_CHAIN_URL"} ` +
147
+ `is set to an empty value — unset it to use the default, or give it a url.`);
148
+ return { name, ...def, faucetUrl: faucetFor(def.nodeUrl), stateDir: join(ROOT, "state", name), source };
149
+ }
150
+ /** The chain named by the ENVIRONMENT, or null if the environment names none.
151
+ * Throws [[TargetError]] for an environment that names one badly — a deployment's only
152
+ * instruction being wrong is not a thing to paper over. */
153
+ function fromEnvironment() {
154
+ const chain = env("EVOLVER_CHAIN");
155
+ const url = env("EVOLVER_CHAIN_URL");
156
+ const pick = env("EVOLVER_TARGET");
157
+ if (chain) {
158
+ if (!CHAIN_NAME_RE.test(chain))
159
+ throw new TargetError(`EVOLVER_CHAIN='${chain}' is not a usable chain name — lower-case letters, digits, ` +
160
+ `'-', '_' and '.', starting with a letter or digit, at most 32 characters ` +
161
+ `(it becomes the directory state/<name>)`);
162
+ const builtin = BUILTINS[chain];
163
+ if (!builtin && !url)
164
+ throw new TargetError(`EVOLVER_CHAIN='${chain}' is not a chain this app knows, so EVOLVER_CHAIN_URL must ` +
165
+ `say where it is. Built-in chains: ${Object.keys(BUILTINS).join(", ")}`);
166
+ const def = builtin
167
+ ? { ...builtin, nodeUrl: url ?? builtin.nodeUrl }
168
+ : {
169
+ nodeUrl: url,
170
+ // NOT ephemeral: a chain we were merely pointed at is not ours to throw away, and
171
+ // that flag decides whether identity names carry a uniqueness suffix and whether
172
+ // `state:reset` warns that they are abandoned forever. Guessing "ephemeral" would
173
+ // be the dangerous guess in both places.
174
+ chainIsEphemeral: false,
175
+ describe: `chain '${chain}' from EVOLVER_CHAIN_URL (assumed shared — NOT resettable)`,
176
+ };
177
+ return build(chain, def, "environment");
178
+ }
179
+ if (url)
180
+ throw new TargetError(`EVOLVER_CHAIN_URL is set but EVOLVER_CHAIN is not — a URL with no chain name has no ` +
181
+ `state directory to belong to. Set both, or use EVOLVER_TARGET for a built-in chain.`);
182
+ if (pick) {
183
+ const builtin = BUILTINS[pick];
184
+ if (!builtin)
185
+ throw new TargetError(`EVOLVER_TARGET='${pick}' is not a chain this app knows. Built-in chains: ` +
186
+ `${Object.keys(BUILTINS).join(", ")}. For any other chain set EVOLVER_CHAIN and ` +
187
+ `EVOLVER_CHAIN_URL.`);
188
+ return build(pick, builtin, "environment");
189
+ }
190
+ return null;
191
+ }
192
+ /** True for a chain this app can resolve BY NAME ALONE — a built-in, or the one the
193
+ * environment defines. Anything else needs `EVOLVER_CHAIN_URL` to say where it is. */
194
+ export function isTargetName(s) {
195
+ return s in BUILTINS || knownTargets().some((t) => t.name === s);
196
+ }
197
+ /** Every chain this app can name right now: the built-ins, plus the environment's own if it
198
+ * defined one that is not built in. What `status` lists and what error messages offer. */
199
+ export function knownTargets() {
200
+ const builtins = Object.keys(BUILTINS).map((n) => build(n, BUILTINS[n], "default"));
201
+ let extra = [];
202
+ try {
203
+ const e = fromEnvironment();
204
+ if (e && !(e.name in BUILTINS))
205
+ extra = [e];
206
+ }
207
+ catch { /* a broken environment is reported by currentTarget/inspectTarget, not here */ }
208
+ return [...builtins, ...extra];
209
+ }
210
+ /** @deprecated name kept for readability at call sites that mean "every chain". */
211
+ export const allTargets = knownTargets;
212
+ /**
213
+ * THE chain this command is talking to, resolved by the ladder in the header.
214
+ *
215
+ * THROWS [[TargetError]] when the ENVIRONMENT is wrong (an unknown name, a missing URL, or
216
+ * a container with nothing naming a chain). Does NOT throw for an unreadable or nonsense
217
+ * `.target` FILE — that falls back to the default and is reported by [[inspectTarget]].
218
+ */
219
+ export function currentTarget() {
220
+ const fromEnv = fromEnvironment();
221
+ if (fromEnv)
222
+ return fromEnv;
223
+ const fromFile = readTargetFile();
224
+ if (fromFile.name)
225
+ return build(fromFile.name, BUILTINS[fromFile.name], "file");
226
+ if (inContainer())
227
+ throw new TargetError(`nothing tells this container which chain to use. Set EVOLVER_TARGET to one of ` +
228
+ `${Object.keys(BUILTINS).join(", ")}, or EVOLVER_CHAIN + EVOLVER_CHAIN_URL for another ` +
229
+ `chain. There is deliberately no default here: guessing would claim permanent ` +
230
+ `identities on a chain nobody chose.`);
231
+ return build(DEFAULT_TARGET, BUILTINS[DEFAULT_TARGET], "default");
232
+ }
233
+ /** The `.target` file, tolerantly: a built-in name, or nothing plus the reason. */
234
+ function readTargetFile() {
235
+ let raw;
236
+ try {
237
+ raw = readFileSync(TARGET_FILE, "utf8").trim();
238
+ }
239
+ catch {
240
+ return {}; // no file yet — the ordinary case
241
+ }
242
+ if (!raw)
243
+ return { problem: `.target is empty` };
244
+ if (raw in BUILTINS)
245
+ return { name: raw };
246
+ return {
247
+ problem: `.target names '${raw}', which is not a built-in chain ` +
248
+ `(${Object.keys(BUILTINS).join(", ")}) — using '${DEFAULT_TARGET}'. ` +
249
+ `Fix it with: npm run target:${DEFAULT_TARGET}`,
250
+ };
251
+ }
252
+ /** What `status` uses. NEVER throws.
253
+ *
254
+ * `currentTarget` is deliberately strict — a deployment pointed at nothing, or at a chain
255
+ * that cannot be located, must stop rather than run somewhere else. But `status` is the
256
+ * command you are told to run FIRST when something is wrong, and a first-aid command that
257
+ * dies on exactly the misconfiguration it exists to describe is worse than useless. Same
258
+ * split as `store.inspect` vs `store.summary`. */
259
+ export function inspectTarget() {
260
+ try {
261
+ const t = currentTarget();
262
+ const f = t.source === "default" ? readTargetFile() : { problem: undefined };
263
+ return { target: t, problem: f.problem };
264
+ }
265
+ catch (e) {
266
+ if (e instanceof TargetError)
267
+ return { problem: e.message };
268
+ throw e;
269
+ }
270
+ }
271
+ /** Switch the interactive chain. Built-ins only: an arbitrary chain is defined by the
272
+ * ENVIRONMENT of the process that talks to it, so writing its name into a file would leave
273
+ * a name here with no URL anywhere — a target that resolves to nothing next time. */
274
+ export function setTarget(name) {
275
+ if (!(name in BUILTINS))
276
+ throw new TargetError(`'${name}' is not a built-in chain (${Object.keys(BUILTINS).join(", ")}). Another chain ` +
277
+ `is selected per command with EVOLVER_CHAIN + EVOLVER_CHAIN_URL, not written to .target.`);
278
+ writeFileSync(TARGET_FILE, name + "\n");
279
+ return build(name, BUILTINS[name], "file");
280
+ }
281
+ /** A chain BY NAME — a built-in, or the one the environment defined. Throws otherwise: a
282
+ * name with no URL cannot be resolved, and inventing one would point the app at a chain
283
+ * that does not exist. */
284
+ export function targetByName(name) {
285
+ if (name in BUILTINS)
286
+ return build(name, BUILTINS[name], "default");
287
+ const known = knownTargets().find((t) => t.name === name);
288
+ if (known)
289
+ return known;
290
+ throw new TargetError(`'${name}' is not a chain this app knows (${knownTargets().map((t) => t.name).join(", ")}). ` +
291
+ `For another chain set EVOLVER_CHAIN=${name} and EVOLVER_CHAIN_URL=<url>.`);
292
+ }
293
+ export function ensureStateDir(t) {
294
+ mkdirSync(t.stateDir, { recursive: true });
295
+ return t.stateDir;
296
+ }
297
+ export function stateExists(t) {
298
+ return existsSync(join(t.stateDir, "identities.json"));
299
+ }
300
+ /** Every state directory that exists on disk, whether or not its chain is nameable now — so
301
+ * `status` can show state left behind by a chain the environment is no longer defining, and
302
+ * `state:reset` can be pointed at it. Without this, state for `staging` becomes invisible
303
+ * the moment EVOLVER_CHAIN is unset, which is exactly when someone goes looking for it. */
304
+ export function stateDirsOnDisk() {
305
+ const root = join(ROOT, "state");
306
+ if (!existsSync(root))
307
+ return [];
308
+ return readdirSync(root, { withFileTypes: true })
309
+ .filter((d) => d.isDirectory())
310
+ .map((d) => d.name)
311
+ .sort();
312
+ }
313
+ /** The state directory of a chain named on disk but not resolvable by name — enough to
314
+ * REPORT it and to DELETE it, which is all either caller needs. Deliberately carries a
315
+ * nodeUrl nothing can reach: reading state needs no chain, and a plausible-looking URL here
316
+ * would be an invitation to talk to it. */
317
+ export function orphanTarget(name) {
318
+ return {
319
+ name,
320
+ nodeUrl: "",
321
+ faucetUrl: "",
322
+ stateDir: join(ROOT, "state", name),
323
+ chainIsEphemeral: false,
324
+ describe: "state on disk for a chain the environment does not currently define",
325
+ source: "default",
326
+ };
327
+ }
328
+ // ── 001-announcements-registry ────────────────────────────────────────────────
329
+ //
330
+ // `optionalEnv` and not `??` on purpose: a compose `${X:-}` renders PRESENT-AND-EMPTY, so a
331
+ // default applied with `??` never fires (it cost this app devnet's url once, and a
332
+ // `CHAIN_WAIT_MS` of 0).
333
+ /** The channel the registry drains. Its own knob, NOT `EVOLVER_CHANNEL`: the demo channel and
334
+ * the registry channel are different concerns, and pointing the registry at a channel someone
335
+ * else's code also drains is the one thing FR-029 forbids. */
336
+ export function registryChannel() {
337
+ return optionalEnv("REGISTRY_CHANNEL") ?? "ce.meerkat.announcements";
338
+ }
339
+ /** WHO OWNS THE REGISTRY CHANNEL — the identity name, and the only deployment that may CLAIM
340
+ * the channel when it is unclaimed.
341
+ *
342
+ * ⚠️ NO DEFAULT, and deliberately not derived from this deployment's own identity. Defaulting
343
+ * it to self is exactly the first-boot-wins behaviour this key exists to remove: every
344
+ * pi-agent ships a registry, so on a fresh chain the first laptop to start would take
345
+ * `ce.meerkat.announcements` permanently. Unset means "this deployment acquires nothing",
346
+ * which is the right answer for every deployment but one.
347
+ *
348
+ * Unset does NOT stop a deployment reading a channel somebody else owns — that is the ordinary
349
+ * case and needs no configuration. See `decideRegistryRole`. */
350
+ export function registryChannelOperator() {
351
+ return optionalEnv("REGISTRY_CHANNEL_OPERATOR");
352
+ }
353
+ // ── 004-event-processor-inbox ─────────────────────────────────────────────────
354
+ /** A setting 004 renamed. It is REFUSED, not read: see `processorHttpPort`. */
355
+ export class RenamedSettingError extends Error {
356
+ }
357
+ /**
358
+ * THE SWITCH BETWEEN THE APP'S TWO SHAPES, and the event processor's read-server port.
359
+ *
360
+ * `> 0` is the SERVICE shape: the event processor with the registry module, serving its routes
361
+ * on this port, until stopped. Unset or `0` is the DEMO shape: the event processor with the demo
362
+ * module, for as long as the template's steps take. Both shapes have exactly one consumer of
363
+ * the node's stream — the processor — and both keep what nobody handles in the inbox.
364
+ *
365
+ * ⚠️ IT STAYS ONE SWITCH BECAUSE THE NODE READS IT TOO. `node-join.sh` decides from the same
366
+ * value whether the node subscribes to `REGISTRY_CHANNEL`, and phase M of the command suite
367
+ * fails the build if the two are keyed on anything else. A second "which modules" setting could
368
+ * disagree with the node's channel list, and the registry would then index nothing (research R1).
369
+ *
370
+ * `0` = no read server, mirroring `https.port`'s convention in the node — the port IS the switch,
371
+ * so there is no second `enabled` flag.
372
+ *
373
+ * ⚠️ `REGISTRY_HTTP_PORT`, its name before 004, is REFUSED whenever it is set — not read, not
374
+ * aliased. Ignoring it would be worse than either: a registry deployment whose environment still
375
+ * says `REGISTRY_HTTP_PORT=8090` would start as the template DEMO, drain its node with the wrong
376
+ * module and serve nothing, and say so nowhere. The refusal names the fix.
377
+ */
378
+ export function processorHttpPort() {
379
+ const old = optionalEnv("REGISTRY_HTTP_PORT");
380
+ if (old !== undefined)
381
+ throw new RenamedSettingError(`REGISTRY_HTTP_PORT is set (${old}), but it was renamed PROCESSOR_HTTP_PORT and is no longer read.\n` +
382
+ ` Set PROCESSOR_HTTP_PORT=${old} and remove REGISTRY_HTTP_PORT. Left as it is, this deployment\n` +
383
+ ` would start as the template demo instead of the registry.`);
384
+ const port = Number(optionalEnv("PROCESSOR_HTTP_PORT") ?? 0);
385
+ if (!Number.isSafeInteger(port) || port < 0 || port > 65535)
386
+ throw new TargetError("invalid PROCESSOR_HTTP_PORT");
387
+ return port;
388
+ }
389
+ /** The lease ceiling, in ROUNDS, measured from the round an announcement is ordered.
390
+ *
391
+ * ⚠️ Rounds, never wall-clock: devnet commits on a ~600 ms floor when busy and a 300-second
392
+ * idle heartbeat when quiescent, so the same number of rounds is a wildly different amount of
393
+ * time in each regime. The default is deliberately conservative and T038 replaces it with a
394
+ * measured value. */
395
+ export function registryMaxLeaseRounds() {
396
+ return Number(optionalEnv("REGISTRY_MAX_LEASE_ROUNDS") ?? 20_000);
397
+ }
398
+ /**
399
+ * Where a detail document is fetched from — REQUIRED for `detail` support, and with NO default.
400
+ *
401
+ * ⚠️ IT USED TO DEFAULT TO THE NODE'S OWN API AND THAT COULD NEVER WORK. `detail.key` is a
402
+ * content-cache key, and the content cache is off unless `ce.evolver.content.enabled = true`
403
+ * (reference.conf:112, default FALSE) — and even when it is on, its route rides either the TLS
404
+ * peer listener (which presents the node's ~13-minute identity certificate) or a listener bound
405
+ * to `127.0.0.1` INSIDE the node's container (NodeBootstrap.scala:2081), which a sibling
406
+ * container cannot reach. So every detail fetch was an HTTP 404 recorded as `unfetchable`, and
407
+ * the entry told a reader the announcer's documentation was broken when the registry simply had
408
+ * nowhere to ask.
409
+ *
410
+ * Required-with-no-default rather than a guess: the endpoint only has to serve
411
+ * `GET /content/<key>`, and whatever serves it is UNTRUSTED — the announced sha256 is verified
412
+ * over the assembled bytes before anything is indexed, which is what makes the source
413
+ * interchangeable. Unset, a `detail` announcement is still indexed, marked `unfetchable` with
414
+ * that reason (FR-016).
415
+ */
416
+ export function registryContentUrl() {
417
+ const raw = optionalEnv("REGISTRY_CONTENT_URL");
418
+ return raw && raw.trim().length > 0 ? raw.trim().replace(/\/+$/, "") : null;
419
+ }
420
+ /** Announcer identities whose entries are suppressed (FR-024). Comma-separated. Applied at
421
+ * ingest AND at query time, so adding a name takes effect without waiting for a refresh. */
422
+ export function registryDenyList() {
423
+ const raw = optionalEnv("REGISTRY_DENY_LIST");
424
+ return new Set((raw ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0));
425
+ }
@@ -0,0 +1,77 @@
1
+ // Writing an announcement — used by an operator for its own entries, and by the CLI for
2
+ // testing. An ordinary announcer does not need this file; they write to the channel however
3
+ // they like, and `contracts/announcement.md` is the contract.
4
+ //
5
+ // ⚠️ THE OPERATOR MUST ANNOUNCE THROUGH ITS OWN NODE. This file said the exact opposite, and
6
+ // the correction is the difference between "the base instruction document has an availability
7
+ // guarantee" and "it has none".
8
+ //
9
+ // The claim was that the sender can never satisfy the threshold locally, so the operator had to
10
+ // use a foreign gateway. What the node actually does:
11
+ //
12
+ // `daRequirement` builds the member set with `membersAndOwnerOfTx` — the DA group's members
13
+ // ∪ ITS OWNER — and this app creates the group, so the app's identity IS in that set. The
14
+ // gateway therefore computes `required = m - 1 = 0` for the operator, and
15
+ // `onStartDaCollection` returns the transaction unchanged: nothing stored, nothing flooded,
16
+ // and `daSatisfied` later passes on zero attestations.
17
+ //
18
+ // Whichever gateway it went through then stored the body via `channelOriginate` and flooded
19
+ // it ONCE, best-effort, to `channelDaMembers ∩ connected`. Through a foreign gateway that is
20
+ // the registry's node — and if the flood is dropped, nothing recovers it: pull targets for
21
+ // this channel are `{the app's own identity}`, an application and never a connected peer, so
22
+ // there is no one to ask.
23
+ //
24
+ // Submitted through the registry's OWN node, `channelOriginate` runs there and stores the body
25
+ // LOCALLY before the id-only form is ordered. The registry then holds the body by construction
26
+ // — strictly stronger than collecting an attestation, and with no dependence on a single
27
+ // unacknowledged flood. A THIRD PARTY still uses any node it can reach; that path is unchanged
28
+ // and is the one the threshold protects.
29
+ import { buildSignedTx, channelBody, NodeClient } from "@mcsherrylabs/evolver-client";
30
+ import { deadlineFetch } from "../chainfetch.js";
31
+ export { announcementBody } from "@mcsherrylabs/pi-meerkat/contract";
32
+ import { announcementBody } from "@mcsherrylabs/pi-meerkat/contract";
33
+ /**
34
+ * Submit an announcement THROUGH A NAMED GATEWAY.
35
+ *
36
+ * `gatewayUrl` is required and deliberately has no default, because the right answer differs by
37
+ * WHO is announcing and neither party should inherit the other's:
38
+ *
39
+ * THE OPERATOR passes its own node. `required` is 0 for it (it owns the DA group), so no
40
+ * attestation is collected and the only thing that makes the body available is
41
+ * `channelOriginate` storing it on the node it was submitted to. That has to be the node the
42
+ * registry reads.
43
+ *
44
+ * A THIRD PARTY passes any node it can reach, and must NOT pass the registry's — that node's
45
+ * applied state is paced by the registry's own polling, so a freshly claimed identity's key
46
+ * is not yet visible there and the write is refused `unknown_key`.
47
+ */
48
+ export async function submitAnnouncement(args) {
49
+ const { channel, gatewayUrl, sender, signer, input, say } = args;
50
+ // ⚠️ A LONGER DEADLINE THAN AN ORDINARY CHAIN CALL, and for a specific reason: this submit
51
+ // does not return until the gateway has COLLECTED a DA attestation over the overlay, which
52
+ // means a round trip to another node plus that node storing the body. The default 30s client
53
+ // deadline aborted first and reported "internal: The operation was aborted due to timeout" —
54
+ // a client-side abort dressed as a node failure, which is exactly the wrong thing to tell an
55
+ // announcer whose transaction may yet commit.
56
+ const gateway = new NodeClient({
57
+ baseUrl: gatewayUrl.replace(/\/+$/, ""),
58
+ fetch: deadlineFetch(Number(process.env.ANNOUNCE_TIMEOUT_MS ?? 120_000)),
59
+ });
60
+ // ⚠️ TWO LEDGER IDS, AND THEY ARE DIFFERENT THINGS. The transaction is written to the
61
+ // announcements CHANNEL; the id inside the body is the channel being DESCRIBED. Getting this
62
+ // wrong writes the announcement onto the described channel itself, where the registry never
63
+ // looks — a silence with no error anywhere. Named apart for that reason.
64
+ const stx = buildSignedTx({
65
+ ledgerId: channel,
66
+ senderNodeId: sender.nodeId, body: channelBody(announcementBody(input)),
67
+ ttlRound: BigInt((await gateway.currentRound()) + 50), signers: [signer],
68
+ });
69
+ say(` announcing '${input.ledgerId}' onto '${channel}' via ${gatewayUrl} (seq ${input.seq})`);
70
+ const r = await gateway.submitTxWithDa(stx);
71
+ if (r.kind === "failed" && r.reason === "da_unavailable")
72
+ say(` ⚠ da_unavailable — the registry's node did not attest within the gateway's window. ` +
73
+ `It must hold a live connection to ${gatewayUrl} (research D2: that is what publishing ` +
74
+ `the p2p port is for). Nothing was ordered. NB: an OPERATOR never sees this — it owns ` +
75
+ `the DA group, so its own requirement is zero and it submits through its own node.`);
76
+ return r;
77
+ }
@@ -0,0 +1,141 @@
1
+ // The wire shape an announcer writes, and the only place it is trusted enough to become an
2
+ // entry. Pure: no chain, no clock, no I/O — so `test/announcement-checks.ts` can drive it.
3
+ //
4
+ // Bounds are enforced HERE, at ingest, and that is what makes "hundreds of services" a small
5
+ // payload later: the read surface cannot return more than what was allowed in.
6
+ import { registryMaxLeaseRounds } from "../env.js";
7
+ /**
8
+ * THE BOUNDS COME FROM `@mcsherrylabs/pi-meerkat`, which owns the wire contract.
9
+ *
10
+ * ⚠️ RE-EXPORTED, NOT RE-DECLARED. This app is the PARSER and the package is the BUILDER of the
11
+ * same format, so a bound that differed between them would be a client that cannot announce
12
+ * something this registry would happily have indexed — or worse, one that announces something
13
+ * this registry then refuses, which the announcer sees as a successful submit and an entry that
14
+ * never appears. They are one set of numbers, in the package a client installs.
15
+ *
16
+ * Through the `/contract` SUBPATH export, which carries the wire format and nothing else. The
17
+ * package root would pull in the tools — and with them the vendored chain plumbing — to read a
18
+ * module that has no imports at all. An external consumer imports exactly the same subpath.
19
+ */
20
+ export { MAX_BODY_BYTES, MAX_TITLE, MAX_SUMMARY, MAX_KEYWORDS, MAX_KEYWORD, MAX_KIND, MAX_LEDGER_ID, } from "@mcsherrylabs/pi-meerkat/contract";
21
+ import { MAX_BODY_BYTES, MAX_TITLE, MAX_SUMMARY, MAX_KEYWORDS, MAX_KEYWORD, MAX_KIND, MAX_LEDGER_ID, } from "@mcsherrylabs/pi-meerkat/contract";
22
+ const bad = (reason, detail) => ({ ok: false, reason, detail });
23
+ /**
24
+ * Parse and bound one channel body.
25
+ *
26
+ * ⚠️ THE RESULT IS BUILT FIELD BY FIELD, never spread from the input. Whatever else the body
27
+ * carries is dropped rather than filtered: an `owner` field has no authority (FR-003 — that is
28
+ * the authenticated sender's alone) and an endpoint has no place in the index (FR-017), and
29
+ * "dropped by construction" is the only version of that which cannot be forgotten when a
30
+ * field is added.
31
+ */
32
+ export function parseAnnouncement(body) {
33
+ if (body.byteLength > MAX_BODY_BYTES)
34
+ return bad("oversize", `body is ${body.byteLength} bytes, the limit is ${MAX_BODY_BYTES}`);
35
+ let raw;
36
+ try {
37
+ raw = JSON.parse(new TextDecoder().decode(body));
38
+ }
39
+ catch (e) {
40
+ return bad("malformed", `not JSON: ${e instanceof Error ? e.message : String(e)}`);
41
+ }
42
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
43
+ return bad("malformed", "not a JSON object");
44
+ const o = raw;
45
+ // Version first: an unrecognised version means every rule below might be wrong, so it must
46
+ // not be judged by them.
47
+ if (o.v !== 1)
48
+ return bad("unknown-version", `v is ${JSON.stringify(o.v)}, this registry speaks v1`);
49
+ const str = (k, max, required = true) => {
50
+ const v = o[k];
51
+ if (v === undefined || v === null) {
52
+ if (!required)
53
+ return "";
54
+ return bad("malformed", `${k} is missing`);
55
+ }
56
+ if (typeof v !== "string")
57
+ return bad("malformed", `${k} is not a string`);
58
+ const t = v.trim();
59
+ if (required && t.length === 0)
60
+ return bad("malformed", `${k} is empty`);
61
+ if (t.length > max)
62
+ return bad("malformed", `${k} is ${t.length} characters, the limit is ${max}`);
63
+ return t;
64
+ };
65
+ const isResult = (x) => typeof x === "object" && x !== null && "ok" in x;
66
+ const ledgerId = str("ledgerId", MAX_LEDGER_ID);
67
+ if (isResult(ledgerId))
68
+ return ledgerId;
69
+ const title = str("title", MAX_TITLE);
70
+ if (isResult(title))
71
+ return title;
72
+ const summary = str("summary", MAX_SUMMARY);
73
+ if (isResult(summary))
74
+ return summary;
75
+ const kindRaw = str("kind", MAX_KIND);
76
+ if (isResult(kindRaw))
77
+ return kindRaw;
78
+ // `seq` is REQUIRED, and the reason is not tidiness: txId folds the body, so a byte-identical
79
+ // refresh is refused as a duplicate by the node's replay guard and never reaches the chain
80
+ // (research A10). Without a changing field an announcer cannot refresh a lease at all.
81
+ if (typeof o.seq !== "number" || !Number.isSafeInteger(o.seq) || o.seq < 0)
82
+ return bad("malformed", "seq is missing or not a non-negative integer — a refresh must differ in some byte");
83
+ const expiry = o.expiresAtRound;
84
+ if (typeof expiry !== "string" && typeof expiry !== "number")
85
+ return bad("malformed", "expiresAtRound is missing");
86
+ let requestedExpiryRound;
87
+ try {
88
+ requestedExpiryRound = BigInt(expiry);
89
+ }
90
+ catch {
91
+ return bad("malformed", `expiresAtRound ${JSON.stringify(expiry)} is not an integer`);
92
+ }
93
+ if (requestedExpiryRound < 0n)
94
+ return bad("malformed", "expiresAtRound is negative");
95
+ const kwRaw = o.keywords ?? [];
96
+ if (!Array.isArray(kwRaw))
97
+ return bad("malformed", "keywords is not an array");
98
+ if (kwRaw.length > MAX_KEYWORDS)
99
+ return bad("malformed", `keywords has ${kwRaw.length} entries, the limit is ${MAX_KEYWORDS}`);
100
+ const keywords = [];
101
+ for (const k of kwRaw) {
102
+ if (typeof k !== "string")
103
+ return bad("malformed", "a keyword is not a string");
104
+ const t = k.trim().toLowerCase();
105
+ if (t.length === 0)
106
+ continue;
107
+ if (t.length > MAX_KEYWORD)
108
+ return bad("malformed", `a keyword is ${t.length} characters, the limit is ${MAX_KEYWORD}`);
109
+ if (!keywords.includes(t))
110
+ keywords.push(t); // de-duplicated, first occurrence wins
111
+ }
112
+ // The detail triple is all-or-nothing: the digest is what makes the key checkable, and a key
113
+ // without one is an unverifiable fetch (research D8).
114
+ let detail;
115
+ if (o.detail !== undefined && o.detail !== null) {
116
+ const d = o.detail;
117
+ if (typeof d !== "object" || Array.isArray(d))
118
+ return bad("malformed", "detail is not an object");
119
+ const dd = d;
120
+ const key = typeof dd.key === "string" ? dd.key.trim() : "";
121
+ const sha256 = typeof dd.sha256 === "string" ? dd.sha256.trim().toLowerCase() : "";
122
+ const bytes = typeof dd.bytes === "string" ? Number(dd.bytes) : dd.bytes;
123
+ if (key.length === 0 || sha256.length === 0)
124
+ return bad("malformed", "detail needs both key and sha256 — a key with no digest cannot be verified");
125
+ if (!/^[0-9a-f]{64}$/.test(sha256))
126
+ return bad("malformed", "detail.sha256 is not a 64-character hex digest");
127
+ if (typeof bytes !== "number" || !Number.isSafeInteger(bytes) || bytes <= 0)
128
+ return bad("malformed", "detail.bytes is missing or not a positive integer");
129
+ detail = { key, sha256, bytes };
130
+ }
131
+ return {
132
+ ok: true,
133
+ value: {
134
+ v: 1, ledgerId, seq: o.seq, requestedExpiryRound,
135
+ kind: kindRaw.toLowerCase(), title, summary, keywords, ...(detail ? { detail } : {}),
136
+ },
137
+ };
138
+ }
139
+ /** The ceiling, read once per process. Here rather than in `lease.ts` so the pure rules take
140
+ * their bound as an argument and stay testable without the environment. */
141
+ export const maxLeaseRounds = registryMaxLeaseRounds;