@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.
- package/README.md +234 -54
- package/dist/installer/committee-init.sh +117 -0
- package/dist/installer/committee.conf +102 -0
- package/dist/installer/node-join.sh +228 -0
- package/dist/installer/node.conf +235 -0
- package/dist/installer/runtime.json +1 -0
- package/dist/installer-runtime/dist/scripts/recover-owner.js +15 -0
- package/dist/installer-runtime/dist/scripts/subscriptions-init.js +35 -0
- package/dist/installer-runtime/dist/scripts/versions.mjs +86 -0
- package/dist/installer-runtime/dist/scripts/wait-for-chain.js +90 -0
- package/dist/installer-runtime/dist/src/announcements.js +448 -0
- package/dist/installer-runtime/dist/src/app.js +539 -0
- package/dist/installer-runtime/dist/src/chainfetch.js +87 -0
- package/dist/installer-runtime/dist/src/env.js +425 -0
- package/dist/installer-runtime/dist/src/registry/announce.js +77 -0
- package/dist/installer-runtime/dist/src/registry/announcement.js +141 -0
- package/dist/installer-runtime/dist/src/registry/dependencies.js +28 -0
- package/dist/installer-runtime/dist/src/registry/detail.js +87 -0
- package/dist/installer-runtime/dist/src/registry/drainer.js +131 -0
- package/dist/installer-runtime/dist/src/registry/http.js +194 -0
- package/dist/installer-runtime/dist/src/registry/index.js +52 -0
- package/dist/installer-runtime/dist/src/registry/lease.js +35 -0
- package/dist/installer-runtime/dist/src/registry/ownership.js +31 -0
- package/dist/installer-runtime/dist/src/registry/provision.js +180 -0
- package/dist/installer-runtime/dist/src/registry/store.js +242 -0
- package/dist/installer-runtime/dist/src/registry/sweep.js +9 -0
- package/dist/installer-runtime/dist/src/registry/worker-module.js +87 -0
- package/dist/installer-runtime/dist/src/runtime.js +164 -0
- package/dist/installer-runtime/dist/src/store.js +138 -0
- package/dist/installer-runtime/package.json +1 -0
- package/dist/installer.d.ts +224 -0
- package/dist/installer.d.ts.map +1 -0
- package/dist/installer.js +336 -0
- package/dist/installer.js.map +1 -0
- package/dist/mcp-announce.d.ts +42 -0
- package/dist/mcp-announce.d.ts.map +1 -0
- package/dist/mcp-announce.js +231 -0
- package/dist/mcp-announce.js.map +1 -0
- package/dist/mcp-cli.d.ts +3 -0
- package/dist/mcp-cli.d.ts.map +1 -0
- package/dist/mcp-cli.js +137 -0
- package/dist/mcp-cli.js.map +1 -0
- package/dist/mcp-config.d.ts +28 -0
- package/dist/mcp-config.d.ts.map +1 -0
- package/dist/mcp-config.js +104 -0
- package/dist/mcp-config.js.map +1 -0
- package/dist/mcp-facade.d.ts +20 -0
- package/dist/mcp-facade.d.ts.map +1 -0
- package/dist/mcp-facade.js +200 -0
- package/dist/mcp-facade.js.map +1 -0
- package/dist/mcp-upstream.d.ts +42 -0
- package/dist/mcp-upstream.d.ts.map +1 -0
- package/dist/mcp-upstream.js +151 -0
- package/dist/mcp-upstream.js.map +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +11 -5
- package/dist/registry.js.map +1 -1
- package/dist/tools.d.ts +6 -2
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +123 -119
- package/dist/tools.js.map +1 -1
- package/installer/Committee.Dockerfile +6 -0
- package/installer/Node.Dockerfile +6 -0
- package/installer/Registry.Dockerfile +13 -0
- package/installer/runtime-control.mjs +172 -0
- package/installer/session-files.mjs +16 -0
- 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;
|