@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,28 @@
|
|
|
1
|
+
// Registry-specific bounded network collaborators, independent of processor lifecycle and env.
|
|
2
|
+
// The production shared worker uses this one composition; there is no transitional collector.
|
|
3
|
+
import { channelTerms, groupMembers } from "../announcements.js";
|
|
4
|
+
import { fetchDetail } from "./detail.js";
|
|
5
|
+
import { decideControl } from "./ownership.js";
|
|
6
|
+
export function registryDependencies(options, say) {
|
|
7
|
+
return {
|
|
8
|
+
channel: options.channel, operator: options.operator, maxLeaseRounds: options.maxLeaseRounds,
|
|
9
|
+
deny: new Set(options.deny), say,
|
|
10
|
+
decideControl: (sender, ledgerId) => decideControl({
|
|
11
|
+
sender, ledgerId,
|
|
12
|
+
readTerms: (id) => channelTerms(options.chainUrl, id),
|
|
13
|
+
readGroupMembers: async (group) => {
|
|
14
|
+
const read = await groupMembers(options.chainUrl, group);
|
|
15
|
+
if (!read.known)
|
|
16
|
+
return { known: false, why: "group membership unreadable" };
|
|
17
|
+
return "absent" in read ? { known: true, members: [] } : { known: true, members: read.members };
|
|
18
|
+
},
|
|
19
|
+
}),
|
|
20
|
+
fetchDetail: async (detail) => {
|
|
21
|
+
if (options.contentUrl === null)
|
|
22
|
+
return { ok: false, reason: "this registry has no content endpoint configured (REGISTRY_CONTENT_URL), so the " +
|
|
23
|
+
"document was not fetched — the description is indexed, the document is not" };
|
|
24
|
+
const read = await fetchDetail({ contentUrl: options.contentUrl, detail });
|
|
25
|
+
return read.ok ? { ok: true, bytes: read.bytes } : { ok: false, reason: read.reason };
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The detail document: fetched by the registry, read by an agent.
|
|
2
|
+
//
|
|
3
|
+
// Two bounds, for two different reasons. The DIGEST, because the announcement supplies it and
|
|
4
|
+
// without checking it the registry would trust the announcer twice — once for the description,
|
|
5
|
+
// once for whatever the content store happens to return under that key. The SIZE, because an
|
|
6
|
+
// agent reads this document, and because it is the guard on the registry's own fetcher.
|
|
7
|
+
//
|
|
8
|
+
// ⚠️ Two hashes exist deliberately (144): above the node's chunk threshold the content key is a
|
|
9
|
+
// MANIFEST hash, so a single `sha256(bytes)` could not address a chunked object at all. The
|
|
10
|
+
// announcement carries both; this verifies the ASSEMBLED bytes against `sha256`.
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { CHAIN_TIMEOUT_MS } from "../chainfetch.js";
|
|
13
|
+
/** 64 KiB. An agent reads this document, so the bound protects a context as much as a disk. */
|
|
14
|
+
export const MAX_DETAIL_BYTES = 64 * 1024;
|
|
15
|
+
export async function fetchDetail(args) {
|
|
16
|
+
const { detail } = args;
|
|
17
|
+
// The ANNOUNCED size is checked first, so an over-size document costs nothing to refuse. It
|
|
18
|
+
// is only a claim, which is why the real bytes are checked as well below.
|
|
19
|
+
if (detail.bytes > MAX_DETAIL_BYTES)
|
|
20
|
+
return { ok: false, reason: `announced ${detail.bytes} bytes, the limit is ${MAX_DETAIL_BYTES}` };
|
|
21
|
+
const f = args.fetchImpl ?? fetch;
|
|
22
|
+
let res;
|
|
23
|
+
try {
|
|
24
|
+
res = await f(`${args.contentUrl}/content/${encodeURIComponent(detail.key)}`, { signal: AbortSignal.timeout(args.timeoutMs ?? CHAIN_TIMEOUT_MS) });
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
// A reason, never a throw: the drainer must be able to mark the entry and carry on, and an
|
|
28
|
+
// exception here would take the whole walk down over one broken document.
|
|
29
|
+
return { ok: false, reason: e instanceof Error ? e.message : String(e) };
|
|
30
|
+
}
|
|
31
|
+
// A 403 is the read-ACL case (142): the document exists and is not readable, which makes it
|
|
32
|
+
// undiscoverable rather than fatal — the entry stands, marked.
|
|
33
|
+
if (!res.ok)
|
|
34
|
+
return { ok: false, reason: `HTTP ${res.status}`.trim() };
|
|
35
|
+
let bytes;
|
|
36
|
+
try {
|
|
37
|
+
bytes = new Uint8Array(await res.arrayBuffer());
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
return { ok: false, reason: `unreadable body: ${e instanceof Error ? e.message : String(e)}` };
|
|
41
|
+
}
|
|
42
|
+
if (bytes.byteLength > MAX_DETAIL_BYTES)
|
|
43
|
+
return { ok: false, reason: `body is ${bytes.byteLength} bytes, the limit is ${MAX_DETAIL_BYTES} (announced ${detail.bytes})` };
|
|
44
|
+
const got = createHash("sha256").update(bytes).digest("hex");
|
|
45
|
+
if (got !== detail.sha256)
|
|
46
|
+
return { ok: false, reason: `digest mismatch: announced ${detail.sha256.slice(0, 12)}…, got ${got.slice(0, 12)}…` };
|
|
47
|
+
return { ok: true, bytes };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the `detail` reference an announcement will carry, uploading the document unless the
|
|
51
|
+
* caller named a key for one already published elsewhere.
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ THE KEY COMES FROM THE NODE, NOT FROM US. Above the node's chunk threshold it is a MANIFEST
|
|
54
|
+
* hash, so `sha256(bytes)` could not address the object at all — which is why an announcement
|
|
55
|
+
* carries both, and why the old guess was only ever right for small documents.
|
|
56
|
+
*
|
|
57
|
+
* `sha256` stays OUR computation over the bytes we read. It is the announcer's commitment, and a
|
|
58
|
+
* registry verifies the assembled bytes against it before indexing; deriving it from anything the
|
|
59
|
+
* node said would turn that check into a conversation with itself.
|
|
60
|
+
*/
|
|
61
|
+
export async function publishDetail(args) {
|
|
62
|
+
const { bytes, contentUrl, explicitKey, publisher, say } = args;
|
|
63
|
+
if (bytes.byteLength > MAX_DETAIL_BYTES)
|
|
64
|
+
throw new Error(`the document is ${bytes.byteLength} bytes; the registry refuses a detail ` +
|
|
65
|
+
`document over ${MAX_DETAIL_BYTES}. Shorten it, or link to it from the summary.`);
|
|
66
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
67
|
+
// An explicit key means "already published, elsewhere". Honoured WITHOUT uploading — uploading
|
|
68
|
+
// as well would leave two objects and announce the wrong one.
|
|
69
|
+
if (explicitKey && explicitKey.length > 0) {
|
|
70
|
+
say(` --detail-key given: NOT uploading. The announcement points at '${explicitKey}', ` +
|
|
71
|
+
`which must already be fetchable from the registry's content endpoint.`);
|
|
72
|
+
return { key: explicitKey, sha256, bytes: bytes.byteLength };
|
|
73
|
+
}
|
|
74
|
+
if (contentUrl === null)
|
|
75
|
+
throw new Error(`a detail document needs REGISTRY_CONTENT_URL: it is the node content endpoint this ` +
|
|
76
|
+
`command uploads the document to, and the same one the registry later reads it back ` +
|
|
77
|
+
`from. Without it the announcement would commit to a document that exists nowhere — ` +
|
|
78
|
+
`which is what this command used to do. Point it at a content-enabled node you can ` +
|
|
79
|
+
`reach (e.g. http://127.0.0.1:8450), or announce without a detail document.`);
|
|
80
|
+
const published = await publisher().postContent(bytes);
|
|
81
|
+
if (published.size !== bytes.byteLength)
|
|
82
|
+
throw new Error(`the content endpoint stored ${published.size} bytes of a ` +
|
|
83
|
+
`${bytes.byteLength}-byte document — refusing to announce a key over bytes ` +
|
|
84
|
+
`that are not the ones read`);
|
|
85
|
+
say(` uploaded ${bytes.byteLength} bytes to ${contentUrl} → key ${published.key.slice(0, 12)}…`);
|
|
86
|
+
return { key: published.key, sha256, bytes: bytes.byteLength };
|
|
87
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Registry indexing policy, invoked by the processor's ONE ordered walk (FR-029).
|
|
2
|
+
// This code never polls the cursor; the supplied store stays on that processor's database owner.
|
|
3
|
+
//
|
|
4
|
+
// ⚠️ Two platform facts shape every decision here, and both were verified in the node source:
|
|
5
|
+
//
|
|
6
|
+
// The cursor is SINGLE-CONSUMER and FORWARD-ONLY, and no route fetches a body by txId
|
|
7
|
+
// (evolver#87). So a position walked past is unrecoverable — which makes a REJECTION
|
|
8
|
+
// terminal in the strongest sense, and makes "reject on a failed read" a way of losing
|
|
9
|
+
// announcements permanently for a transient 503.
|
|
10
|
+
//
|
|
11
|
+
// One cursor serves EVERY channel the node declares, and consumers filter by ledger id
|
|
12
|
+
// afterwards. So this drainer sees other channels' traffic and must ignore it, and no other
|
|
13
|
+
// component in this process may poll (app.ts chooses between the registry and the demo).
|
|
14
|
+
import { parseAnnouncement } from "./announcement.js";
|
|
15
|
+
import { grantLease } from "./lease.js";
|
|
16
|
+
import { entryFrom } from "./store.js";
|
|
17
|
+
/**
|
|
18
|
+
* Process one delivery. Pure of transport: the caller owns the cursor and hands deliveries in,
|
|
19
|
+
* which is what lets `test/drainer-checks.ts` arrange a missing body or an unreadable ownership
|
|
20
|
+
* check without a chain.
|
|
21
|
+
*/
|
|
22
|
+
export async function processDelivery(store, d, deps) {
|
|
23
|
+
if (d.ledgerId !== deps.channel)
|
|
24
|
+
return { outcome: "ignored" };
|
|
25
|
+
const reject = (reason, detail) => {
|
|
26
|
+
store.reject({ txId: d.txId, round: d.round, sender: d.sender, ledgerId: d.ledgerId, reason, detail });
|
|
27
|
+
deps.say(` refused ${d.txId.slice(0, 12)}… from ${d.sender}: ${reason} — ${detail}`);
|
|
28
|
+
return { outcome: "rejected", reason };
|
|
29
|
+
};
|
|
30
|
+
// Cheapest and most terminal first, so nothing expensive runs for an announcement that
|
|
31
|
+
// cannot be accepted (data-model.md's validation order).
|
|
32
|
+
const parsed = parseAnnouncement(d.body);
|
|
33
|
+
if (!parsed.ok)
|
|
34
|
+
return reject(parsed.reason, parsed.detail);
|
|
35
|
+
const a = parsed.value;
|
|
36
|
+
if (deps.deny.has(d.sender))
|
|
37
|
+
return reject("deny-listed", `announcer '${d.sender}' is suppressed by the operator`);
|
|
38
|
+
// THE CHECK THE REGISTRY EXISTS FOR. Three answers, and the third is neither of the others.
|
|
39
|
+
const control = await deps.decideControl(d.sender, a.ledgerId);
|
|
40
|
+
if (control.kind === "undetermined") {
|
|
41
|
+
// Not recorded, because it is not a decision. Recording it as a rejection would make a
|
|
42
|
+
// transient failure look like a verdict, and the announcer would be told something untrue.
|
|
43
|
+
deps.say(` ${d.txId.slice(0, 12)}… from ${d.sender}: control undetermined (${control.why}) — will retry`);
|
|
44
|
+
return { outcome: "retry", why: control.why };
|
|
45
|
+
}
|
|
46
|
+
if (control.kind === "does-not-control")
|
|
47
|
+
return reject("not-owner", `'${d.sender}' does not control '${a.ledgerId}'` +
|
|
48
|
+
(control.owners.length ? ` (owners: ${control.owners.join(", ")})` : " (nobody does)"));
|
|
49
|
+
const lease = grantLease({
|
|
50
|
+
requestedExpiryRound: a.requestedExpiryRound,
|
|
51
|
+
// From the round the announcement was ORDERED, not the round it was read: a slow reader
|
|
52
|
+
// must not shorten somebody's lease.
|
|
53
|
+
receivedRound: d.round,
|
|
54
|
+
maxLeaseRounds: deps.maxLeaseRounds,
|
|
55
|
+
});
|
|
56
|
+
// The document is fetched AFTER control is settled — it is the expensive step, and there is
|
|
57
|
+
// no point paying for it on behalf of somebody who cannot announce this channel.
|
|
58
|
+
let detailState = "ok";
|
|
59
|
+
let detailReason;
|
|
60
|
+
if (a.detail) {
|
|
61
|
+
const got = await deps.fetchDetail(a.detail);
|
|
62
|
+
if (got.ok) {
|
|
63
|
+
// KEPT HERE, because this is the only endpoint an agent can reach. The node's content
|
|
64
|
+
// cache is off by default and its listener is loopback-INSIDE-the-node's-container when
|
|
65
|
+
// it is on, so "fetch the document yourself" was advice no reader could follow.
|
|
66
|
+
store.putDetailDoc(a.detail.key, a.detail.sha256, got.bytes);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
detailState = "unfetchable";
|
|
70
|
+
detailReason = got.reason;
|
|
71
|
+
// The entry STILL STANDS. "This service exists and its documentation is broken" is more
|
|
72
|
+
// useful to a reader than silence, and the reason travels with it (FR-016).
|
|
73
|
+
deps.say(` ${a.ledgerId}: detail document unusable (${got.reason}) — indexing without it`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const provenance = d.sender === deps.operator ? "operator" : "third-party";
|
|
77
|
+
store.put(entryFrom({
|
|
78
|
+
a, announcer: d.sender, provenance, lease,
|
|
79
|
+
announcedAtRound: d.round, verifiedAtRound: d.round, txId: d.txId,
|
|
80
|
+
detailState, ...(detailReason ? { detailReason } : {}),
|
|
81
|
+
}));
|
|
82
|
+
deps.say(` indexed '${a.ledgerId}' from ${d.sender} (${provenance}, lease to round ${lease.grantedExpiryRound}` +
|
|
83
|
+
`${lease.clamped ? `, clamped from ${lease.requestedExpiryRound}` : ""})`);
|
|
84
|
+
return { outcome: "accepted", ledgerId: a.ledgerId };
|
|
85
|
+
}
|
|
86
|
+
// ── registry-specific walk constants ───────────────────────────────────────
|
|
87
|
+
/** How long an undetermined control check is retried INLINE before the delivery is written to
|
|
88
|
+
* the deferred table and the walk moves on. */
|
|
89
|
+
export const CONTROL_RETRY_MS = 60_000;
|
|
90
|
+
/** Registry-specific retry/defer policy, shared by the current composition and the shared SDK
|
|
91
|
+
* worker adapter. This is NOT a collector: it never polls or paces the node and never owns a
|
|
92
|
+
* database. Its store and every callback are run by the calling owner. */
|
|
93
|
+
export function createRegistryHandler(store, deps, clock = {}) {
|
|
94
|
+
const now = clock.now ?? (() => Date.now());
|
|
95
|
+
const sleep = clock.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
96
|
+
let accepted = 0;
|
|
97
|
+
return {
|
|
98
|
+
async handle(d) {
|
|
99
|
+
for (const held of store.deferredAll()) {
|
|
100
|
+
// Only a typed undetermined control outcome is retryable. Unexpected collaborator or
|
|
101
|
+
// SQLite errors must reject THIS handler too: the held body remains durable and the
|
|
102
|
+
// shared owner retains the current original without advancing to the next position.
|
|
103
|
+
const out = await processDelivery(store, held, deps);
|
|
104
|
+
if (out.outcome === "retry") {
|
|
105
|
+
store.defer({ ...held, why: out.why });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
store.undefer(held.txId);
|
|
109
|
+
if (out.outcome === "accepted")
|
|
110
|
+
accepted += 1;
|
|
111
|
+
deps.say(` held ${held.txId.slice(0, 12)}… settled after ${held.attempts} attempt(s): ${out.outcome}`);
|
|
112
|
+
}
|
|
113
|
+
const until = now() + CONTROL_RETRY_MS;
|
|
114
|
+
let out = await processDelivery(store, d, deps);
|
|
115
|
+
while (out.outcome === "retry" && now() < until) {
|
|
116
|
+
await sleep(1_000);
|
|
117
|
+
out = await processDelivery(store, d, deps);
|
|
118
|
+
}
|
|
119
|
+
if (out.outcome === "retry") {
|
|
120
|
+
store.defer({
|
|
121
|
+
txId: d.txId || `${d.round}:${d.index}`, round: d.round, index: d.index,
|
|
122
|
+
sender: d.sender, ledgerId: d.ledgerId, body: d.body, why: out.why,
|
|
123
|
+
});
|
|
124
|
+
deps.say(` … control of '${d.txId.slice(0, 12)}…' still undetermined after ${CONTROL_RETRY_MS}ms (${out.why}) — HELD with its body, retried on the next walk`);
|
|
125
|
+
}
|
|
126
|
+
if (out.outcome === "accepted")
|
|
127
|
+
accepted += 1;
|
|
128
|
+
},
|
|
129
|
+
takeAccepted() { const n = accepted; accepted = 0; return n; },
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/** Truncated deliberately — the histogram is the one part of the summary that would otherwise
|
|
2
|
+
* grow with the registry, and SC-001's budget is what it must not exceed. */
|
|
3
|
+
export const SUMMARY_KEYWORDS = 40;
|
|
4
|
+
/**
|
|
5
|
+
* TEN, and DERIVED from SC-001's budget rather than chosen.
|
|
6
|
+
*
|
|
7
|
+
* A worst-case list row — a 60-character title, a 200-character summary, eight keywords and a
|
|
8
|
+
* detail key with its 64-hex digest — measures about 750 bytes. Twenty of those came to
|
|
9
|
+
* ~15 KB against an 8 KB budget, so the DEFAULT moved rather than the budget: the budget IS
|
|
10
|
+
* the requirement (do not flood an agent's context) and the limit is only a knob. `total`
|
|
11
|
+
* tells a reader what it did not see, and a caller who wants more asks for it.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_LIMIT = 10;
|
|
14
|
+
export const MAX_LIMIT = 100;
|
|
15
|
+
const DISCLAIMER = "inclusion is not endorsement";
|
|
16
|
+
/** The full record — what `/services/<ledgerId>` returns. */
|
|
17
|
+
const wireFull = (e) => ({
|
|
18
|
+
ledgerId: e.ledgerId,
|
|
19
|
+
kind: e.kind,
|
|
20
|
+
title: e.title,
|
|
21
|
+
summary: e.summary,
|
|
22
|
+
keywords: e.keywords,
|
|
23
|
+
announcer: e.announcer,
|
|
24
|
+
provenance: e.provenance,
|
|
25
|
+
state: e.state,
|
|
26
|
+
...(e.detail ? { detail: {
|
|
27
|
+
key: e.detail.key, sha256: e.detail.sha256, bytes: String(e.detail.bytes),
|
|
28
|
+
state: e.detail.state, ...(e.detail.reason ? { reason: e.detail.reason } : {}),
|
|
29
|
+
} } : {}),
|
|
30
|
+
announcedAtRound: String(e.announcedAtRound),
|
|
31
|
+
verifiedAtRound: String(e.verifiedAtRound),
|
|
32
|
+
grantedExpiryRound: String(e.grantedExpiryRound),
|
|
33
|
+
requestedExpiryRound: String(e.requestedExpiryRound),
|
|
34
|
+
txId: e.txId,
|
|
35
|
+
});
|
|
36
|
+
/**
|
|
37
|
+
* The LIST projection — leaner, because a list is for CHOOSING.
|
|
38
|
+
*
|
|
39
|
+
* `txId`, `requestedExpiryRound` and `verifiedAtRound` are provenance a reader wants about a
|
|
40
|
+
* service it has already picked, and paying for them 20 times to answer "which of these?" is
|
|
41
|
+
* exactly the context flooding this feature exists to prevent. The detail key AND its digest
|
|
42
|
+
* stay: an agent must be able to fetch and verify the document without a fourth request
|
|
43
|
+
* (SC-002).
|
|
44
|
+
*/
|
|
45
|
+
const wireList = (e) => ({
|
|
46
|
+
ledgerId: e.ledgerId,
|
|
47
|
+
kind: e.kind,
|
|
48
|
+
title: e.title,
|
|
49
|
+
summary: e.summary,
|
|
50
|
+
keywords: e.keywords,
|
|
51
|
+
announcer: e.announcer,
|
|
52
|
+
provenance: e.provenance,
|
|
53
|
+
state: e.state,
|
|
54
|
+
...(e.detail ? { detail: {
|
|
55
|
+
key: e.detail.key, sha256: e.detail.sha256, bytes: String(e.detail.bytes), state: e.detail.state,
|
|
56
|
+
} } : {}),
|
|
57
|
+
announcedAtRound: String(e.announcedAtRound),
|
|
58
|
+
grantedExpiryRound: String(e.grantedExpiryRound),
|
|
59
|
+
});
|
|
60
|
+
export function registryRoutes(args) {
|
|
61
|
+
const { store, chain, channel, tipRound, walkedRound, deny } = args;
|
|
62
|
+
const json = (status, body) => ({ status, json: body });
|
|
63
|
+
// ⚠️ `/summary`, NOT `/services/summary`, and the move is the fix for a real ambiguity: a
|
|
64
|
+
// ledger id may legitimately be the single word `summary` (the node's id grammar allows
|
|
65
|
+
// it), and a route matched before `/services/<id>` made that channel's entry unreachable
|
|
66
|
+
// while quietly answering a different question. A sibling of `/rejections` has no such
|
|
67
|
+
// overlap. The old path is GONE rather than aliased — an alias would shadow the same name.
|
|
68
|
+
const summary = {
|
|
69
|
+
path: "/summary",
|
|
70
|
+
handle: () => {
|
|
71
|
+
const s = store.summary(deny());
|
|
72
|
+
return json(200, {
|
|
73
|
+
entries: { live: String(s.entries.live), expired: String(s.entries.expired) },
|
|
74
|
+
kinds: Object.fromEntries(Object.entries(s.kinds).map(([k, v]) => [k, String(v)])),
|
|
75
|
+
keywords: s.keywords.slice(0, SUMMARY_KEYWORDS).map((k) => ({ keyword: k.keyword, live: String(k.live) })),
|
|
76
|
+
keywordsTruncated: s.keywords.length > SUMMARY_KEYWORDS,
|
|
77
|
+
// Under research D1 this is always zero. A non-zero value means an announcement was
|
|
78
|
+
// ordered and its body never reached this registry — the readability guarantee
|
|
79
|
+
// violated — so it is published here rather than left in a log (SC-004).
|
|
80
|
+
misses: { total: String(s.misses.total), lastRound: s.misses.lastRound === null ? null : String(s.misses.lastRound) },
|
|
81
|
+
// Not a loss — a delivery whose body this registry still holds while it retries an
|
|
82
|
+
// ownership check it could not complete. Zero in steady state; a number that persists
|
|
83
|
+
// means the chain reads behind `decideControl` are failing.
|
|
84
|
+
deferred: String(s.deferred),
|
|
85
|
+
chain,
|
|
86
|
+
channel,
|
|
87
|
+
// ⚠️ AS OF THE WALK, not the tip. An index answers only for what has been walked past:
|
|
88
|
+
// stalled below an announcement, it holds zero entries and zero misses, and stamping that
|
|
89
|
+
// with the node's tip made "not walked yet" read exactly like "walked, and empty".
|
|
90
|
+
// `tipRound` beside it makes the gap a number a reader can see.
|
|
91
|
+
asOfRound: String(walkedRound()),
|
|
92
|
+
tipRound: String(tipRound()),
|
|
93
|
+
disclaimer: DISCLAIMER,
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
const services = {
|
|
98
|
+
path: "/services",
|
|
99
|
+
handle: (url) => {
|
|
100
|
+
const rawLimit = url.searchParams.get("limit");
|
|
101
|
+
// REFUSED, not coerced. A caller that asked for something impossible has a bug, and
|
|
102
|
+
// silently answering a different question hides it.
|
|
103
|
+
// CLAMPED ABOVE, REFUSED BELOW — and the asymmetry is deliberate, matching the lease
|
|
104
|
+
// decision (research D4): asking for more than the operator allows is a reasonable
|
|
105
|
+
// request answered with what is allowed, whereas `limit=0` or `limit=abc` cannot be
|
|
106
|
+
// answered at all and silently substituting 20 would hide the caller's bug.
|
|
107
|
+
let limit = DEFAULT_LIMIT;
|
|
108
|
+
if (rawLimit !== null) {
|
|
109
|
+
const n = Number(rawLimit);
|
|
110
|
+
if (!Number.isInteger(n) || n < 1)
|
|
111
|
+
return json(400, { error: "bad_request", message: `limit must be a positive integer (it is clamped to ${MAX_LIMIT})` });
|
|
112
|
+
limit = Math.min(n, MAX_LIMIT);
|
|
113
|
+
}
|
|
114
|
+
const rawOffset = url.searchParams.get("offset");
|
|
115
|
+
let offset = 0;
|
|
116
|
+
if (rawOffset !== null) {
|
|
117
|
+
const n = Number(rawOffset);
|
|
118
|
+
if (!Number.isInteger(n) || n < 0)
|
|
119
|
+
return json(400, { error: "bad_request", message: "offset must be a non-negative integer" });
|
|
120
|
+
offset = n;
|
|
121
|
+
}
|
|
122
|
+
const q = url.searchParams.get("q");
|
|
123
|
+
const kind = url.searchParams.get("kind") ?? undefined;
|
|
124
|
+
const r = store.search({
|
|
125
|
+
terms: q ? q.split(/[\s,]+/) : [], ...(kind ? { kind: kind.trim().toLowerCase() } : {}),
|
|
126
|
+
limit, offset,
|
|
127
|
+
}, deny());
|
|
128
|
+
return json(200, {
|
|
129
|
+
total: String(r.total), limit: String(limit), offset: String(offset),
|
|
130
|
+
services: r.entries.map(wireList), disclaimer: DISCLAIMER,
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
const service = {
|
|
135
|
+
path: "/services/",
|
|
136
|
+
prefix: true,
|
|
137
|
+
handle: (url) => {
|
|
138
|
+
// THE DETAIL DOCUMENT, served by the registry itself.
|
|
139
|
+
//
|
|
140
|
+
// Because there is nowhere else to get it. `detail.key` is a content-cache key and the
|
|
141
|
+
// node's content cache is off by default; when it is on, its route is bound to 127.0.0.1
|
|
142
|
+
// INSIDE the node's container. So "fetch it and verify it yourself" was advice no agent
|
|
143
|
+
// could act on. The registry has already fetched the bytes and checked the announced
|
|
144
|
+
// digest over them, so what it serves is what the announcer committed to — and the digest
|
|
145
|
+
// travels in a header so a reader can check the same thing without trusting this hop.
|
|
146
|
+
const detailMatch = /^\/services\/(.+)\/detail$/.exec(url.pathname);
|
|
147
|
+
if (detailMatch) {
|
|
148
|
+
const id = decodeURIComponent(detailMatch[1]);
|
|
149
|
+
const e = store.get(id, deny());
|
|
150
|
+
if (!e || !e.detail)
|
|
151
|
+
return json(404, { error: "not_found", message: "no detail document is indexed for that channel" });
|
|
152
|
+
if (e.detail.state !== "ok")
|
|
153
|
+
return json(409, { error: "unfetchable", message: e.detail.reason ?? "the document could not be fetched",
|
|
154
|
+
key: e.detail.key, sha256: e.detail.sha256 });
|
|
155
|
+
const doc = store.detailDoc(e.detail.key);
|
|
156
|
+
if (!doc)
|
|
157
|
+
return json(404, { error: "not_found", message: "the document was verified but is no longer held" });
|
|
158
|
+
// Served as bytes, with the digest stated. NOT as JSON: an agent asked to verify a
|
|
159
|
+
// sha256 must receive exactly the bytes that were hashed.
|
|
160
|
+
return {
|
|
161
|
+
status: 200,
|
|
162
|
+
bytes: doc.bytes,
|
|
163
|
+
headers: {
|
|
164
|
+
"content-type": "text/plain; charset=utf-8",
|
|
165
|
+
"content-length": doc.bytes.byteLength,
|
|
166
|
+
"x-evolver-sha256": doc.sha256,
|
|
167
|
+
"x-evolver-announcer": e.announcer,
|
|
168
|
+
"x-evolver-provenance": e.provenance,
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const id = decodeURIComponent(url.pathname.slice("/services/".length));
|
|
173
|
+
const e = store.get(id, deny());
|
|
174
|
+
// 404 means "nobody has announced it", NOT "the channel does not exist" — the registry
|
|
175
|
+
// knows nothing about channels nobody described, and must not imply otherwise.
|
|
176
|
+
if (!e)
|
|
177
|
+
return json(404, { error: "not_found", message: "no entry is indexed for that channel" });
|
|
178
|
+
return json(200, { ...wireFull(e), disclaimer: DISCLAIMER });
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
const rejections = {
|
|
182
|
+
path: "/rejections",
|
|
183
|
+
// Deliberately NOT on the agent's search path: a different reader (the announcer, the
|
|
184
|
+
// operator) with a different need (FR-005).
|
|
185
|
+
handle: () => json(200, {
|
|
186
|
+
rejections: store.rejections().map((r) => ({
|
|
187
|
+
txId: r.txId, round: String(r.round), sender: r.sender,
|
|
188
|
+
ledgerId: r.ledgerId, reason: r.reason, detail: r.detail,
|
|
189
|
+
})),
|
|
190
|
+
disclaimer: DISCLAIMER,
|
|
191
|
+
}),
|
|
192
|
+
};
|
|
193
|
+
return [summary, services, service, rejections];
|
|
194
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Application composition only. All generic cursor/storage/config/control ownership is in
|
|
2
|
+
// the SDK. Signing keys and chain provisioning remain in this parent, never worker options.
|
|
3
|
+
import { isValidNodeId } from "@mcsherrylabs/evolver-client";
|
|
4
|
+
import { registryChannel, registryChannelOperator, registryContentUrl, registryDenyList, registryMaxLeaseRounds, } from "../env.js";
|
|
5
|
+
import { provisionRegistryChannel } from "./provision.js";
|
|
6
|
+
import { REGISTRY_MODULE_NAME } from "./worker-module.js";
|
|
7
|
+
export function registryModule(args) {
|
|
8
|
+
if (!isValidNodeId(args.operator))
|
|
9
|
+
throw new Error("invalid fixed registry operator identity");
|
|
10
|
+
const channel = registryChannel();
|
|
11
|
+
const options = {
|
|
12
|
+
chain: args.chain, channel, operator: args.operator,
|
|
13
|
+
maxLeaseRounds: registryMaxLeaseRounds(), deny: [...registryDenyList()],
|
|
14
|
+
chainUrl: args.chainUrl, contentUrl: registryContentUrl(),
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
// Production starts emitted JS. No tsx worker remapping, test factory or remote discovery.
|
|
18
|
+
worker: { url: new URL("./worker-module.js", import.meta.url).href, options },
|
|
19
|
+
lifecycle: {
|
|
20
|
+
name: REGISTRY_MODULE_NAME, channels: [channel],
|
|
21
|
+
async setup(context) {
|
|
22
|
+
await args.beforeProvision?.();
|
|
23
|
+
const { sender, signer } = await args.identity();
|
|
24
|
+
if (sender.nodeId !== args.operator)
|
|
25
|
+
throw new Error("registry signing identity differs from fixed worker operator");
|
|
26
|
+
const provisioned = await provisionRegistryChannel({
|
|
27
|
+
node: args.node, chainUrl: args.chainUrl, chainAgreedUrl: args.chainAgreedUrl,
|
|
28
|
+
channel, owner: sender, signer, nodeIdentity: args.nodeIdentity,
|
|
29
|
+
say: args.say, operator: registryChannelOperator(),
|
|
30
|
+
pace: async () => { await context.pace(); },
|
|
31
|
+
});
|
|
32
|
+
args.say(` registry ${provisioned.role}: ${channel}`);
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** HTTP is a parent-side bridge; the actual routes/SQLite reads run serialized in the worker.
|
|
38
|
+
* A failed tip/cursor/module read propagates to HTTP 500, never a fabricated empty index. */
|
|
39
|
+
export function registryReadRoutes(runtime, tip) {
|
|
40
|
+
const handle = async (url) => {
|
|
41
|
+
const currentTip = await tip();
|
|
42
|
+
const cursor = await runtime.store.cursor();
|
|
43
|
+
const walked = BigInt(cursor.round) > 0n ? String(BigInt(cursor.round) - 1n) : "0";
|
|
44
|
+
return await runtime.store.readModule(REGISTRY_MODULE_NAME, {
|
|
45
|
+
path: `${url.pathname}${url.search}`, tip: currentTip, walked, deny: [...registryDenyList()],
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
return [
|
|
49
|
+
{ path: "/summary", handle }, { path: "/services", handle },
|
|
50
|
+
{ path: "/services/", prefix: true, handle }, { path: "/rejections", handle },
|
|
51
|
+
];
|
|
52
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Leases, in ROUNDS.
|
|
2
|
+
//
|
|
3
|
+
// ⚠️ Never wall-clock. Devnet commits on a ~600 ms floor when busy and a 300-second idle
|
|
4
|
+
// heartbeat when quiescent, so "an hour" is somewhere between 6,000 and 12 rounds on the SAME
|
|
5
|
+
// chain. A lease that means one thing under load and another at rest is not a lease.
|
|
6
|
+
//
|
|
7
|
+
// Pure: the ceiling arrives as an argument, so the rules are driveable without the environment.
|
|
8
|
+
/**
|
|
9
|
+
* Grant a lease: DHCP semantics.
|
|
10
|
+
*
|
|
11
|
+
* Clamped rather than refused, deliberately (research D4). Refusing punishes a caller for
|
|
12
|
+
* asking for more than the operator allows, and it makes the operation non-idempotent — an
|
|
13
|
+
* announcer would have to discover the ceiling by trial. Clamping answers the question.
|
|
14
|
+
*/
|
|
15
|
+
export function grantLease(req) {
|
|
16
|
+
const ceiling = req.receivedRound + BigInt(req.maxLeaseRounds);
|
|
17
|
+
// A request in the PAST is floored at the round of receipt rather than granted as asked: an
|
|
18
|
+
// entry that is expired before it is written is not something an announcer can have meant,
|
|
19
|
+
// and it would make `state` flip on the very first sweep.
|
|
20
|
+
const floor = req.receivedRound;
|
|
21
|
+
const granted = req.requestedExpiryRound > ceiling ? ceiling
|
|
22
|
+
: req.requestedExpiryRound < floor ? floor
|
|
23
|
+
: req.requestedExpiryRound;
|
|
24
|
+
return {
|
|
25
|
+
requestedExpiryRound: req.requestedExpiryRound,
|
|
26
|
+
grantedExpiryRound: granted,
|
|
27
|
+
clamped: req.requestedExpiryRound > ceiling,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** A lease INCLUDES its granted round; it is expired only once the tip has passed it. The
|
|
31
|
+
* boundary is stated here rather than at each call site because an off-by-one would silently
|
|
32
|
+
* retire entries a round early, which reads as a chain problem rather than an arithmetic one. */
|
|
33
|
+
export function isExpired(grantedExpiryRound, tipRound) {
|
|
34
|
+
return tipRound > grantedExpiryRound;
|
|
35
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve control, asking for as little as possible: the group read happens only when an
|
|
3
|
+
* owner is actually a group, so the ordinary individually-owned case costs one request.
|
|
4
|
+
*/
|
|
5
|
+
export async function decideControl(q) {
|
|
6
|
+
const read = await q.readTerms(q.ledgerId);
|
|
7
|
+
if (!read.ok)
|
|
8
|
+
return { kind: "undetermined", why: `terms unreadable: ${read.why}` };
|
|
9
|
+
const owners = read.terms.owners ?? [];
|
|
10
|
+
// An identity owner is decided without a second read.
|
|
11
|
+
for (const o of owners) {
|
|
12
|
+
if (o.kind === "I" && o.name === q.sender)
|
|
13
|
+
return { kind: "controls", via: "identity", owner: o.name };
|
|
14
|
+
}
|
|
15
|
+
// A group owner needs membership. ⚠️ An unreadable membership read is undetermined for the
|
|
16
|
+
// same reason an unreadable terms read is: rejecting is TERMINAL, and the cursor has already
|
|
17
|
+
// moved, so a blip would discard an announcement permanently (research A9).
|
|
18
|
+
for (const o of owners) {
|
|
19
|
+
if (o.kind !== "G")
|
|
20
|
+
continue;
|
|
21
|
+
const m = await q.readGroupMembers(o.name);
|
|
22
|
+
if (!m.known)
|
|
23
|
+
return { kind: "undetermined", why: `members of group '${o.name}' unreadable: ${m.why}` };
|
|
24
|
+
if (m.members.includes(q.sender))
|
|
25
|
+
return { kind: "controls", via: "group", owner: o.name };
|
|
26
|
+
}
|
|
27
|
+
// Nobody owns it, or somebody else does. Both are the same terminal answer — and note an
|
|
28
|
+
// UNCLAIMED channel lands here, because the route answers 200 with `owners: []` for a
|
|
29
|
+
// channel that has never existed (research A2), so "unclaimed" is not a special case.
|
|
30
|
+
return { kind: "does-not-control", owners: owners.map((o) => o.name) };
|
|
31
|
+
}
|