@mmmbuto/nexuscrew 0.9.63 → 0.9.65
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/CHANGELOG.md +14 -0
- package/README.md +9 -21
- package/docs/NOTIFICATIONS.md +34 -0
- package/docs/SECURITY.md +13 -12
- package/frontend/dist/assets/index-DkYRC1vV.js +169 -0
- package/frontend/dist/assets/{index-D1cTtNED.css → index-Dwd37Z_R.css} +2 -2
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cells/routes.js +37 -3
- package/lib/cli/commands.js +1 -1
- package/lib/config.js +1 -1
- package/lib/fleet/builtin.js +3 -0
- package/lib/fleet/cell-lease-server.js +1 -1
- package/lib/fleet/cell-lease.js +1 -1
- package/lib/fleet/lease-client.js +1 -1
- package/lib/fleet/lease-verifier.js +1 -1
- package/lib/live-host/attest.js +39 -0
- package/lib/live-host/bridge.js +81 -21
- package/lib/live-host/entry.js +49 -0
- package/lib/live-host/registry.js +111 -0
- package/lib/live-host/reserved.js +12 -0
- package/lib/mcp/cells.js +37 -0
- package/lib/mcp/server.js +7 -1
- package/lib/mcp/tools.js +7 -1
- package/lib/notify/ask-relay.js +36 -166
- package/lib/notify/event-feed-asks-routes.js +18 -3
- package/lib/notify/event-feed-client.js +85 -10
- package/lib/notify/event-feed-notices-routes.js +135 -0
- package/lib/notify/event-feed-producers.js +21 -0
- package/lib/notify/event-feed-routes.js +30 -11
- package/lib/notify/notice-dismissal-queue.js +336 -0
- package/lib/notify/notice-dismissals.js +178 -0
- package/lib/notify/notice-relay.js +128 -0
- package/lib/notify/owner-channel.js +172 -0
- package/lib/proxy/federation.js +9 -0
- package/lib/proxy/resource-acl.js +6 -0
- package/lib/server.js +229 -12
- package/lib/tmux/actions.js +1 -1
- package/package.json +2 -2
- package/frontend/dist/assets/index-maPH0v_e.js +0 -169
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/notify/notice-dismissals.js — durable set of the notices THIS node cleared.
|
|
3
|
+
//
|
|
4
|
+
// The owner is the source of truth for its own notices, so the dismissal lives
|
|
5
|
+
// here too: the snapshot it serves stops carrying the entry, and every device
|
|
6
|
+
// that reads that snapshot stops seeing it. The file holds ids and timestamps
|
|
7
|
+
// and nothing else — never a title, never a body — and it expires exactly with
|
|
8
|
+
// the history it filters (MAX_MS == event-feed-history.js MAX_MS): dismissing an
|
|
9
|
+
// entry that is no longer anywhere means nothing.
|
|
10
|
+
//
|
|
11
|
+
// Bounds and failure policy mirror the history: schema/size validation on load,
|
|
12
|
+
// prune of expired entries on load and on every write, caps on count AND bytes,
|
|
13
|
+
// atomic tmp+rename 0600. A disk failure degrades the store and is DECLARED in
|
|
14
|
+
// status(); it never throws into the caller, because a local dismissal is an
|
|
15
|
+
// operator intent and losing it silently would resurrect the card.
|
|
16
|
+
//
|
|
17
|
+
// The flush is SYNCHRONOUS on purpose. event-feed-history.js serializes its
|
|
18
|
+
// writes on a promise chain because its flush is async; here the write is
|
|
19
|
+
// writeFileSync+renameSync inside the mutation, so there is no interleaving to
|
|
20
|
+
// serialize and no window in which a 200 has answered before the entry is
|
|
21
|
+
// durable — which is the whole point of answering 200 to the peer.
|
|
22
|
+
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
const MAX_ENTRIES = 200;
|
|
27
|
+
const MAX_BYTES = 64 * 1024;
|
|
28
|
+
const MAX_MS = 15 * 60 * 1000;
|
|
29
|
+
const SCHEMA = 'nexuscrew-notice-dismissals-v1';
|
|
30
|
+
|
|
31
|
+
function createNoticeDismissals({ filePath, now = Date.now, limits = {} }) {
|
|
32
|
+
const maxEntries = limits.maxEntries || MAX_ENTRIES;
|
|
33
|
+
const maxBytes = limits.maxBytes || MAX_BYTES;
|
|
34
|
+
const maxMs = limits.maxMs || MAX_MS;
|
|
35
|
+
|
|
36
|
+
let cache = null; // validated entries, oldest first
|
|
37
|
+
let degraded = null; // null | reason string
|
|
38
|
+
|
|
39
|
+
function validateList(raw) {
|
|
40
|
+
if (!Array.isArray(raw)) return null;
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const entry of raw) {
|
|
43
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
44
|
+
if (typeof entry.eventId !== 'string' || entry.eventId.length === 0) return null;
|
|
45
|
+
if (typeof entry.at !== 'number' || !Number.isFinite(entry.at)) return null;
|
|
46
|
+
out.push({ eventId: entry.eventId, at: entry.at });
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function prune(entries, t) {
|
|
52
|
+
const cutoff = t - maxMs;
|
|
53
|
+
let out = entries.filter((e) => e.at >= cutoff);
|
|
54
|
+
if (out.length > maxEntries) out = out.slice(out.length - maxEntries);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function load() {
|
|
59
|
+
if (cache) return cache;
|
|
60
|
+
let raw;
|
|
61
|
+
try {
|
|
62
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
63
|
+
} catch (e) {
|
|
64
|
+
// A missing file is a fresh install, not a degradation; any other read
|
|
65
|
+
// failure is declared, because an unreadable store must not look empty.
|
|
66
|
+
degraded = e && e.code === 'ENOENT' ? degraded : 'dismissals-read-failed';
|
|
67
|
+
cache = [];
|
|
68
|
+
return cache;
|
|
69
|
+
}
|
|
70
|
+
// A file wildly larger than the cap is not parsed at all: it cannot be
|
|
71
|
+
// trusted and reading it is the only cost that grows without bound.
|
|
72
|
+
if (Buffer.byteLength(raw, 'utf8') > maxBytes * 4) {
|
|
73
|
+
degraded = 'dismissals-oversize';
|
|
74
|
+
cache = [];
|
|
75
|
+
return cache;
|
|
76
|
+
}
|
|
77
|
+
let parsed = null;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(raw);
|
|
80
|
+
} catch (_) {
|
|
81
|
+
degraded = 'dismissals-schema';
|
|
82
|
+
cache = [];
|
|
83
|
+
return cache;
|
|
84
|
+
}
|
|
85
|
+
const entries = parsed && parsed.schema === SCHEMA ? validateList(parsed.entries) : null;
|
|
86
|
+
if (!entries) { degraded = 'dismissals-schema'; cache = []; return cache; }
|
|
87
|
+
cache = prune(entries, now());
|
|
88
|
+
return cache;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function serialize(entries) {
|
|
92
|
+
return JSON.stringify({ schema: SCHEMA, savedAt: now(), entries });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Returns { ok: true } when the candidate list reached the disk, or
|
|
96
|
+
// { ok: false, reason } when it could not — in which case the previous state
|
|
97
|
+
// stays authoritative in memory and on disk.
|
|
98
|
+
function persist(entries) {
|
|
99
|
+
let body;
|
|
100
|
+
try {
|
|
101
|
+
body = serialize(entries);
|
|
102
|
+
} catch (_) {
|
|
103
|
+
degraded = 'dismissals-write-failed';
|
|
104
|
+
return { ok: false, reason: 'write-failed' };
|
|
105
|
+
}
|
|
106
|
+
if (Buffer.byteLength(body, 'utf8') > maxBytes) {
|
|
107
|
+
degraded = 'dismissals-oversize';
|
|
108
|
+
return { ok: false, reason: 'oversize' };
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const dir = path.dirname(filePath);
|
|
112
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
113
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
114
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
115
|
+
fs.renameSync(tmp, filePath);
|
|
116
|
+
} catch (_) {
|
|
117
|
+
degraded = 'dismissals-write-failed';
|
|
118
|
+
return { ok: false, reason: 'write-failed' };
|
|
119
|
+
}
|
|
120
|
+
return { ok: true };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isDismissed(eventId) {
|
|
124
|
+
if (typeof eventId !== 'string' || eventId.length === 0) return false;
|
|
125
|
+
const entries = load();
|
|
126
|
+
pruneInPlace(entries);
|
|
127
|
+
return entries.some((e) => e.eventId === eventId);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Expiry is applied on every read too: a long-lived process must not answer
|
|
131
|
+
// from a snapshot of the file taken before the entries expired.
|
|
132
|
+
function pruneInPlace(entries) {
|
|
133
|
+
const live = prune(entries, now());
|
|
134
|
+
if (live.length === entries.length && live.every((e, i) => e === entries[i])) return entries;
|
|
135
|
+
cache = live;
|
|
136
|
+
return cache;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function dismiss(eventId) {
|
|
140
|
+
const entries = load();
|
|
141
|
+
const current = pruneInPlace(entries);
|
|
142
|
+
if (current.some((e) => e.eventId === eventId)) {
|
|
143
|
+
return { ok: true, idempotent: true };
|
|
144
|
+
}
|
|
145
|
+
const candidate = prune([...current, { eventId, at: now() }], now());
|
|
146
|
+
// Byte cap: drop the oldest until it fits, so a full store keeps accepting
|
|
147
|
+
// new dismissals instead of refusing them for a stale tail.
|
|
148
|
+
let fitted = candidate;
|
|
149
|
+
while (fitted.length > 1 && Buffer.byteLength(serialize(fitted), 'utf8') > maxBytes) {
|
|
150
|
+
fitted = fitted.slice(1);
|
|
151
|
+
}
|
|
152
|
+
const written = persist(fitted);
|
|
153
|
+
if (!written.ok && written.reason === 'oversize') {
|
|
154
|
+
// The list cannot be represented at all: no partial truth, the caller is
|
|
155
|
+
// told and the previous state stays authoritative on both sides.
|
|
156
|
+
return written;
|
|
157
|
+
}
|
|
158
|
+
// A failed WRITE is different from an unrepresentable list: the operator
|
|
159
|
+
// asked for this notice to be cleared, so the owner honours it in every view
|
|
160
|
+
// it serves from now on (memory is authoritative in-process, exactly like
|
|
161
|
+
// the history) and declares the lost durability in status(). The window is
|
|
162
|
+
// bounded by the retention TTL: the entry and the notice it filters expire
|
|
163
|
+
// together.
|
|
164
|
+
cache = fitted;
|
|
165
|
+
return { ok: true, idempotent: false };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function list() { return pruneInPlace(load()).map((e) => ({ ...e })); }
|
|
169
|
+
|
|
170
|
+
function status() {
|
|
171
|
+
const entries = pruneInPlace(load());
|
|
172
|
+
return { degraded: degraded || null, count: entries.length };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { isDismissed, dismiss, list, status };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = { createNoticeDismissals, MAX_ENTRIES, MAX_BYTES, MAX_MS, SCHEMA };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/notify/notice-relay.js — the client-side relay that carries a notice
|
|
3
|
+
// dismissal to the node that OWNS the notice.
|
|
4
|
+
//
|
|
5
|
+
// The channel (resolution from the authorized store, deadline, hop proof) is the
|
|
6
|
+
// shared one the ask answers use (./owner-channel.js): this module adds only what
|
|
7
|
+
// is specific to notices — the two routes it calls and the outcome contract:
|
|
8
|
+
//
|
|
9
|
+
// DELETE /event-feed/notices/<eventId> one notice
|
|
10
|
+
// POST /event-feed/notices/dismiss-all the whole visible set
|
|
11
|
+
//
|
|
12
|
+
// 2xx → cleared (idempotent when the owner says so)
|
|
13
|
+
// 404 (no route) → the owner does not have this surface at all: `unsupported`,
|
|
14
|
+
// declared, never retried in a loop
|
|
15
|
+
// 404 otherwise → the notice is already gone (expired): the GOAL is reached,
|
|
16
|
+
// so it counts as confirmed — never as a failure to retry
|
|
17
|
+
|
|
18
|
+
const { createOwnerChannel } = require('./owner-channel.js');
|
|
19
|
+
|
|
20
|
+
const OWNER_ID_RE = /^[a-f0-9]{32}$/;
|
|
21
|
+
const EVENT_ID_RE = /^[0-9a-f-]{36}$/;
|
|
22
|
+
const RELAY_DEADLINE_MS = 8000;
|
|
23
|
+
|
|
24
|
+
const OWNER_REASONS = new Set([
|
|
25
|
+
'reverse-slot-unverified', 'peer-unknown', 'events-disabled', 'grant-required:ask-action',
|
|
26
|
+
'peer-visibility', 'origin-visibility', 'federated-origin-required', 'hop-chain',
|
|
27
|
+
'store-unavailable', 'unknown-peer', 'unknown-trust', 'no-delivering-peer',
|
|
28
|
+
'no-node-identity', 'bad-visited', 'bad-hop', 'self-hop', 'origin-mismatch',
|
|
29
|
+
'notify-unknown', 'dismissals-unavailable', 'unsupported', 'dismiss-rate',
|
|
30
|
+
'resource-not-classified', 'owner-unknown', 'federation-peer-unreachable',
|
|
31
|
+
'federation-peer-timeout', 'federation-body-consumed', 'owner-unreachable',
|
|
32
|
+
]);
|
|
33
|
+
function safeReason(reason) { return OWNER_REASONS.has(reason) ? reason : undefined; }
|
|
34
|
+
|
|
35
|
+
// Reasons that mean "the owner is not reachable NOW": the caller queues the
|
|
36
|
+
// intent and its drainer retries. Everything else is a decision.
|
|
37
|
+
const TRANSIENT_REASONS = new Set([
|
|
38
|
+
'relay-deadline', 'owner-unreachable', 'unknown', 'reverse-slot-unverified',
|
|
39
|
+
'federation-peer-unreachable', 'federation-peer-timeout', 'federation-body-consumed',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
function createNoticeRelay({
|
|
43
|
+
loadStore, now = Date.now, fetchImpl = fetch, log = () => {},
|
|
44
|
+
probeReverseSlotImpl = null, peers = null, localPort = () => 0, localToken = () => '',
|
|
45
|
+
nodesPath = null, slotVerifier = undefined, deadlineMs = RELAY_DEADLINE_MS,
|
|
46
|
+
} = {}) {
|
|
47
|
+
const channel = createOwnerChannel({
|
|
48
|
+
loadStore, now, fetchImpl, peers, localPort, localToken, nodesPath, deadlineMs,
|
|
49
|
+
sanitizeReason: safeReason,
|
|
50
|
+
...(probeReverseSlotImpl ? { probeReverseSlotImpl } : {}),
|
|
51
|
+
...(slotVerifier ? { slotVerifier } : {}),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Same resolution as an ask answer: an outbound peer, a shared inbound peer
|
|
55
|
+
// with a proven reverse slot, or a route of the authorized inventory.
|
|
56
|
+
async function resolve(ownerId, op) {
|
|
57
|
+
const candidate = await channel.candidateOwner(ownerId, op);
|
|
58
|
+
if (!candidate) return { error: { ok: false, code: 404, reason: 'owner-unknown', error: 'owner non tra i peer autorizzati' } };
|
|
59
|
+
if (candidate.kind === 'inbound-unverified') {
|
|
60
|
+
return { error: { ok: false, code: 404, reason: 'reverse-slot-unverified', error: 'canale reverse non verificato: scarto non inoltrato' } };
|
|
61
|
+
}
|
|
62
|
+
if (candidate.kind === 'inbound') {
|
|
63
|
+
const gate = await channel.verifyInboundChannel(candidate, op);
|
|
64
|
+
if (!gate.ok) return { error: { ok: false, code: gate.code, reason: gate.reason, error: gate.error } };
|
|
65
|
+
}
|
|
66
|
+
return { candidate };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function transient(reason, code) {
|
|
70
|
+
return TRANSIENT_REASONS.has(reason) || code === 408 || code === 429 || (typeof code === 'number' && code >= 500);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function dismissOnce({ ownerId, eventId }, op) {
|
|
74
|
+
if (!OWNER_ID_RE.test(String(ownerId || ''))) return { ok: false, code: 400, error: 'ownerId non valido' };
|
|
75
|
+
if (!EVENT_ID_RE.test(String(eventId || ''))) return { ok: false, code: 400, error: 'eventId non valido' };
|
|
76
|
+
const resolved = await resolve(ownerId, op);
|
|
77
|
+
if (resolved.error) return resolved.error;
|
|
78
|
+
const { r, body } = await channel.forward(resolved.candidate, `/event-feed/notices/${encodeURIComponent(eventId)}`, { method: 'DELETE', op });
|
|
79
|
+
if (r.status === 200) return { ok: true, code: 200, ownerId, idempotent: body.idempotent === true };
|
|
80
|
+
if (r.status === 404 && body.reason === 'resource-not-classified') return { ok: false, code: 404, reason: 'unsupported', error: 'owner senza la superficie di scarto' };
|
|
81
|
+
// The entry expired on the owner: the dismissal is ALREADY true there.
|
|
82
|
+
if (r.status === 404) return { ok: true, code: 404, ownerId, gone: true };
|
|
83
|
+
const reason = body.reason || (r.status === 403 ? 'grant-required:ask-action' : 'unknown');
|
|
84
|
+
return { ok: false, code: r.status, ownerId, reason: safeReason(reason) || 'unknown', error: body.error || `HTTP ${r.status}`,
|
|
85
|
+
retryAfterMs: retryAfterMs(r, now), transient: transient(reason, r.status) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function dismissAllOnce({ ownerId }, op) {
|
|
89
|
+
if (!OWNER_ID_RE.test(String(ownerId || ''))) return { ok: false, code: 400, error: 'ownerId non valido' };
|
|
90
|
+
const resolved = await resolve(ownerId, op);
|
|
91
|
+
if (resolved.error) return resolved.error;
|
|
92
|
+
const { r, body } = await channel.forward(resolved.candidate, '/event-feed/notices/dismiss-all', { method: 'POST', body: {}, op });
|
|
93
|
+
if (r.status === 200) {
|
|
94
|
+
return { ok: true, code: 200, ownerId, dismissed: Number.isSafeInteger(body.dismissed) ? body.dismissed : 0 };
|
|
95
|
+
}
|
|
96
|
+
if (r.status === 404 && body.reason === 'resource-not-classified') return { ok: false, code: 404, reason: 'unsupported', error: 'owner senza la superficie di scarto' };
|
|
97
|
+
if (r.status === 404) return { ok: true, code: 404, ownerId, gone: true, dismissed: 0 };
|
|
98
|
+
const reason = body.reason || (r.status === 403 ? 'grant-required:ask-action' : 'unknown');
|
|
99
|
+
return { ok: false, code: r.status, ownerId, reason: safeReason(reason) || 'unknown', error: body.error || `HTTP ${r.status}`,
|
|
100
|
+
retryAfterMs: retryAfterMs(r, now), transient: transient(reason, r.status) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function retryAfterMs(response, clock) {
|
|
104
|
+
const value = response.headers?.get?.('retry-after');
|
|
105
|
+
if (typeof value !== 'string') return 0;
|
|
106
|
+
const seconds = /^\d+(?:\.\d+)?$/.test(value.trim()) ? Number(value) : null;
|
|
107
|
+
const delay = seconds === null ? Date.parse(value) - clock() : seconds * 1000;
|
|
108
|
+
return Number.isFinite(delay) && delay > 0 ? delay : 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Every failure carries the classification ONCE, here: `transient` is what the
|
|
112
|
+
// queue reads to decide between "retry later" and "declare it and stop"
|
|
113
|
+
// (a refusal or an owner without the surface is a decision, not an outage).
|
|
114
|
+
function classified(out) {
|
|
115
|
+
if (!out || out.ok === true || out.transient !== undefined) return out;
|
|
116
|
+
return { ...out, transient: transient(out.reason, out.code) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function relayNoticeDismiss(args = {}) {
|
|
120
|
+
return classified(await channel.operation(args, (op) => dismissOnce(args, op)));
|
|
121
|
+
}
|
|
122
|
+
async function relayNoticeDismissAll(args = {}) {
|
|
123
|
+
return classified(await channel.operation(args, (op) => dismissAllOnce(args, op)));
|
|
124
|
+
}
|
|
125
|
+
return { relayNoticeDismiss, relayNoticeDismissAll };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { createNoticeRelay, TRANSIENT_REASONS, OWNER_ID_RE, EVENT_ID_RE };
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/notify/owner-channel.js — the channel to an OWNER node, resolved from the
|
|
3
|
+
// authorized store and never from anything an event said.
|
|
4
|
+
//
|
|
5
|
+
// Extracted from ask-relay.js so the ask answers and the notice dismissals share
|
|
6
|
+
// ONE implementation of the same three things: which peer carries the request
|
|
7
|
+
// (direct outbound, shared inbound with a proven reverse slot, or a route from
|
|
8
|
+
// the authorized inventory), the forwarding with its deadline and typed
|
|
9
|
+
// outcomes, and the hop proof. Two copies of this would drift, and a drifted
|
|
10
|
+
// chain rule is a security bug, not a duplication smell.
|
|
11
|
+
|
|
12
|
+
const { NODE_NAME_RE } = require('../nodes/store.js');
|
|
13
|
+
const { probeReverseSlot } = require('../nodes/reverse-slot-proof.js');
|
|
14
|
+
const { askSlotVerifier, binding } = require('../nodes/ask-slot-verifier.js');
|
|
15
|
+
|
|
16
|
+
const RELAY_DEADLINE_MS = 8000;
|
|
17
|
+
|
|
18
|
+
function createOwnerChannel({
|
|
19
|
+
loadStore, now = Date.now, fetchImpl = fetch,
|
|
20
|
+
probeReverseSlotImpl = probeReverseSlot, peers = null,
|
|
21
|
+
localPort = () => 0, localToken = () => '', nodesPath = null,
|
|
22
|
+
slotVerifier = askSlotVerifier, deadlineMs = RELAY_DEADLINE_MS,
|
|
23
|
+
// Caller-owned hook: what to do when a request that may have travelled dies
|
|
24
|
+
// with the deadline (the ask relay parks the attempt as uncertain).
|
|
25
|
+
onUncertain = null,
|
|
26
|
+
// Callers surface `body.reason` to their own API: each one keeps its own
|
|
27
|
+
// allowlist, so an arbitrary string from the owner never travels upward.
|
|
28
|
+
sanitizeReason = (reason) => reason,
|
|
29
|
+
} = {}) {
|
|
30
|
+
const proofScope = nodesPath || loadStore;
|
|
31
|
+
|
|
32
|
+
function deadlineResult(op, args, capability) {
|
|
33
|
+
if (capability) return { ownerId: args.ownerId, askId: args.askId, canReply: false, status: 'unreachable', reason: 'relay-deadline' };
|
|
34
|
+
if (op.answer && op.delivered && args.rid) {
|
|
35
|
+
return { ok: false, code: 200, uncertain: true, requestId: args.rid, reason: 'delivery-unknown', error: 'esito incerto: verifica lo stato prima di rispondere' };
|
|
36
|
+
}
|
|
37
|
+
return { ok: false, code: 504, reason: 'relay-deadline', error: 'relay deadline' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// One request, one deadline. The work function receives `op` and must call
|
|
41
|
+
// op.check()/op.wait() so an expired deadline aborts the travel instead of
|
|
42
|
+
// leaving a request in flight with nobody to collect it.
|
|
43
|
+
async function operation(args, work, capability = false, answer = false) {
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const op = { controller, answer, delivered: false, expired: false, proofKey: null, proofBinding: null };
|
|
46
|
+
let rejectAbort;
|
|
47
|
+
const aborted = new Promise((_resolve, reject) => { rejectAbort = () => reject(new Error('relay deadline')); });
|
|
48
|
+
controller.signal.addEventListener('abort', rejectAbort, { once: true });
|
|
49
|
+
op.active = () => !controller.signal.aborted;
|
|
50
|
+
op.check = () => { if (!op.active()) throw new Error('relay deadline'); };
|
|
51
|
+
op.wait = promise => Promise.race([promise, aborted]);
|
|
52
|
+
const timer = setTimeout(() => { op.expired = true; controller.abort(); }, deadlineMs);
|
|
53
|
+
try { return await Promise.race([Promise.resolve().then(() => work(op)), aborted]); }
|
|
54
|
+
catch (_) {
|
|
55
|
+
slotVerifier.invalidate(op.proofKey);
|
|
56
|
+
if (op.answer && op.delivered && args.rid && typeof onUncertain === 'function') onUncertain(args);
|
|
57
|
+
if (op.expired) return deadlineResult(op, args, capability);
|
|
58
|
+
return capability
|
|
59
|
+
? { ownerId: args.ownerId, askId: args.askId, canReply: false, status: 'unreachable' }
|
|
60
|
+
: { ok: false, code: 502, error: 'owner unreachable' };
|
|
61
|
+
} finally {
|
|
62
|
+
clearTimeout(timer); controller.signal.removeEventListener('abort', rejectAbort); controller.abort();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Owner resolution: an OUTBOUND peer (its own channel, as always) or a shared
|
|
67
|
+
// INBOUND peer with the events+reply grants and an active reverse slot. For
|
|
68
|
+
// inbound peers the slot port MUST be proven with the same probe the terminals
|
|
69
|
+
// use: no fallback on localPort without proof.
|
|
70
|
+
async function candidateOwner(ownerId, op) {
|
|
71
|
+
let store = loadStore();
|
|
72
|
+
if (!store) return null;
|
|
73
|
+
let routed = null;
|
|
74
|
+
if (typeof peers === 'function') {
|
|
75
|
+
let inventory;
|
|
76
|
+
try { inventory = await op.wait(peers()); } catch (_) { op.check(); return null; }
|
|
77
|
+
op.check();
|
|
78
|
+
const matches = (Array.isArray(inventory) ? inventory : [])
|
|
79
|
+
.filter((p) => p && (p.nodeId === ownerId || p.instanceId === ownerId));
|
|
80
|
+
if (matches.length !== 1 || matches[0].stale === true) return null;
|
|
81
|
+
const route = matches[0].route;
|
|
82
|
+
if (!Array.isArray(route) || !route.length || route.length > 4
|
|
83
|
+
|| route.some((name) => typeof name !== 'string' || !NODE_NAME_RE.test(name))
|
|
84
|
+
|| new Set(route).size !== route.length) return null;
|
|
85
|
+
routed = [...route];
|
|
86
|
+
}
|
|
87
|
+
op.check();
|
|
88
|
+
store = loadStore();
|
|
89
|
+
if (!store) return null;
|
|
90
|
+
slotVerifier.sync(store, proofScope);
|
|
91
|
+
const nodes = store.nodes || [];
|
|
92
|
+
const direct = nodes.filter((n) => n && n.nodeId === ownerId);
|
|
93
|
+
if (direct.length > 1) return null;
|
|
94
|
+
const out = direct.find((n) => n.direction === 'outbound' && n.token && n.localPort);
|
|
95
|
+
if (out) return { node: out, forwardPort: out.localPort, forwardToken: out.token, kind: 'outbound', route: routed };
|
|
96
|
+
if (!direct.length && routed) return { node: null, kind: 'routed', route: routed };
|
|
97
|
+
const inbound = nodes.find((n) => n && n.direction === 'inbound'
|
|
98
|
+
&& n.shared === true && n.nodeId === ownerId
|
|
99
|
+
&& n.token
|
|
100
|
+
&& n.askReplyAccess === true && n.eventsAccess === true
|
|
101
|
+
&& n.cellVisibility !== 'none');
|
|
102
|
+
if (!inbound) return null;
|
|
103
|
+
const forwardToken = inbound.token;
|
|
104
|
+
const pool = inbound.reversePool;
|
|
105
|
+
const slot = pool && Array.isArray(pool.slots) ? pool.slots[pool.activeSlot] : null;
|
|
106
|
+
if (!slot || !Number.isInteger(slot.port)) {
|
|
107
|
+
return { node: inbound, forwardPort: null, forwardToken, kind: 'inbound-unverified', route: routed };
|
|
108
|
+
}
|
|
109
|
+
return { node: inbound, forwardPort: slot.port, forwardToken, kind: 'inbound', route: routed };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function ownerUrl(port, path) {
|
|
113
|
+
return `http://127.0.0.1:${port}/federation/route/_/${path.replace(/^\//, '')}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function forward(candidate, path, { method = 'GET', body, expectedTs, op } = {}) {
|
|
117
|
+
op.check();
|
|
118
|
+
const st = loadStore();
|
|
119
|
+
slotVerifier.sync(st, proofScope);
|
|
120
|
+
if (candidate.kind === 'inbound' && !candidate.route) {
|
|
121
|
+
const current = st && (st.nodes || []).find(node => node.name === candidate.node.name);
|
|
122
|
+
const selected = binding(st, current);
|
|
123
|
+
if (!selected || selected.fingerprint !== op.proofBinding) {
|
|
124
|
+
slotVerifier.invalidate(op.proofKey);
|
|
125
|
+
const error = new Error('reverse slot changed'); error.reason = 'reverse-slot-unverified'; throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const routed = candidate.route;
|
|
129
|
+
const url = routed
|
|
130
|
+
? `http://127.0.0.1:${localPort()}/api/route/${routed.map(encodeURIComponent).join('/')}/_${path}`
|
|
131
|
+
: ownerUrl(candidate.forwardPort, path);
|
|
132
|
+
const headers = routed ? { authorization: `Bearer ${localToken()}` }
|
|
133
|
+
: { authorization: `Bearer ${candidate.forwardToken}`, 'x-nexuscrew-visited': st && st.nodeId || '' };
|
|
134
|
+
try {
|
|
135
|
+
op.check(); op.delivered = true;
|
|
136
|
+
const r = await op.wait(fetchImpl(url, { method, headers: { ...headers, 'content-type': 'application/json',
|
|
137
|
+
...(expectedTs === undefined ? {} : { 'x-nexuscrew-ask-ts': String(expectedTs) }) },
|
|
138
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: op.controller.signal }));
|
|
139
|
+
op.check();
|
|
140
|
+
const payload = await op.wait(r.json().catch(error => {
|
|
141
|
+
if ([403, 404, 501].includes(r.status)) return {};
|
|
142
|
+
throw error;
|
|
143
|
+
}));
|
|
144
|
+
op.check();
|
|
145
|
+
if (!r.ok) slotVerifier.invalidate(op.proofKey);
|
|
146
|
+
return { r, body: { ...payload, reason: sanitizeReason(payload && payload.reason) } };
|
|
147
|
+
} catch (error) { slotVerifier.invalidate(op.proofKey); throw error; }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Preflight of the reverse channel for an inbound peer: a NET refusal when the
|
|
151
|
+
// slot is not proven (not an uncertain outcome: the forward never left).
|
|
152
|
+
async function verifyInboundChannel(candidate, op) {
|
|
153
|
+
// REST routes prove the slot at their inbound proxy hop, never twice.
|
|
154
|
+
if (candidate.route) return { ok: true };
|
|
155
|
+
op.check();
|
|
156
|
+
const st = loadStore();
|
|
157
|
+
const proof = await op.wait(slotVerifier.verify({ store: st, peer: candidate.node, loadStore,
|
|
158
|
+
scope: proofScope, signal: op.controller.signal, probeImpl: probeReverseSlotImpl }));
|
|
159
|
+
op.check();
|
|
160
|
+
if (proof && proof.owned === true) {
|
|
161
|
+
op.proofKey = proof.key;
|
|
162
|
+
const selected = binding(st, candidate.node);
|
|
163
|
+
op.proofBinding = selected && selected.fingerprint;
|
|
164
|
+
return { ok: true };
|
|
165
|
+
}
|
|
166
|
+
return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { operation, candidateOwner, ownerUrl, forward, verifyInboundChannel, deadlineResult, RELAY_DEADLINE_MS: deadlineMs };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = { createOwnerChannel, RELAY_DEADLINE_MS };
|
package/lib/proxy/federation.js
CHANGED
|
@@ -228,6 +228,11 @@ function knownResource(resource) {
|
|
|
228
228
|
|| /^\/event-feed\/asks\/[a-f0-9]{8}(?:\/answer|\/capability|\/requests\/[0-9a-f-]{16,64})?$/.test(resource)
|
|
229
229
|
|| /^\/event-feed\/[A-Za-z0-9._-]{1,32}$/.test(resource)
|
|
230
230
|
|| /^\/event-feed\/node\/[a-f0-9]{32}$/.test(resource)
|
|
231
|
+
// Owner-side notice dismissals: the write-back path of the feed for the
|
|
232
|
+
// notices the owner cleared. It travels like the ask write-back — a peer
|
|
233
|
+
// that may see the notice (and holds the ask-action grants) may clear it.
|
|
234
|
+
|| /^\/event-feed\/notices\/[0-9a-f-]{36}$/.test(resource)
|
|
235
|
+
|| resource === '/event-feed/notices/dismiss-all'
|
|
231
236
|
|| isPanelResource(resource)
|
|
232
237
|
|| /^\/fleet\/(status|schema|definitions|credentials\/status|credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|edit-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource);
|
|
233
238
|
}
|
|
@@ -335,6 +340,10 @@ function allowedResource(resource, method = 'GET') {
|
|
|
335
340
|
if (/^\/event-feed\/asks\/[a-f0-9]{8}\/capability$/.test(resource)) return method === 'GET';
|
|
336
341
|
if (/^\/event-feed\/asks\/[a-f0-9]{8}$/.test(resource)) return method === 'DELETE';
|
|
337
342
|
if (/^\/event-feed\/asks\/[a-f0-9]{8}\/requests\/[0-9a-f-]{16,64}$/.test(resource)) return method === 'GET';
|
|
343
|
+
// Notice dismissals: a single DELETE per notice, or one POST that clears the
|
|
344
|
+
// whole visible set (N DELETEs through two hops would die in the 6/min budget).
|
|
345
|
+
if (/^\/event-feed\/notices\/[0-9a-f-]{36}$/.test(resource)) return method === 'DELETE';
|
|
346
|
+
if (resource === '/event-feed/notices/dismiss-all') return method === 'POST';
|
|
338
347
|
if (resource === '/asks') return method === 'POST';
|
|
339
348
|
if (resource === '/notify') return method === 'POST';
|
|
340
349
|
// Audio: only capability read, speak and stop are exposed through Hydra.
|
|
@@ -58,6 +58,12 @@ function classifyResource(resource, method = 'GET') {
|
|
|
58
58
|
if (/^\/event-feed\/asks\/[a-f0-9]{8}\/capability$/.test(resource)) return m === 'GET' ? 'ask-action' : null;
|
|
59
59
|
if (/^\/event-feed\/asks\/[a-f0-9]{8}$/.test(resource)) return m === 'DELETE' ? 'ask-action' : null;
|
|
60
60
|
if (/^\/event-feed\/asks\/[a-f0-9]{8}\/requests\/[0-9a-f-]{16,64}$/.test(resource)) return m === 'GET' ? 'ask-action' : null;
|
|
61
|
+
// Notice dismissals: the same class as the ask write-back — an ACTION on the
|
|
62
|
+
// feed, not a read of it. The full preset carries it, the viewer does not,
|
|
63
|
+
// and seeing the notice is not what grants clearing it. No new grant name is
|
|
64
|
+
// invented: `askReplyAccess` is already "may act on what the feed shows".
|
|
65
|
+
if (/^\/event-feed\/notices\/[0-9a-f-]{36}$/.test(resource)) return m === 'DELETE' ? 'ask-action' : null;
|
|
66
|
+
if (resource === '/event-feed/notices/dismiss-all') return m === 'POST' ? 'ask-action' : null;
|
|
61
67
|
|
|
62
68
|
if (resource === '/cells') return m === 'GET' ? 'inventory' : null;
|
|
63
69
|
if (resource === '/sessions') return m === 'GET' ? 'inventory' : (m === 'POST' ? 'operator' : null);
|