@mmmbuto/nexuscrew 0.9.64 → 0.9.66
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 +18 -0
- package/docs/NODES.md +44 -0
- package/docs/NOTIFICATIONS.md +34 -0
- package/docs/SECURITY.md +40 -0
- package/frontend/dist/assets/index-DhZOap1-.js +169 -0
- package/frontend/dist/assets/{index-BC6vO7Zd.css → index-Dwd37Z_R.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +31 -0
- package/lib/nodes/access-presets.js +27 -7
- package/lib/nodes/commands.js +63 -4
- package/lib/nodes/store.js +197 -12
- package/lib/notify/ask-cell.js +53 -0
- 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 +125 -12
- 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 +37 -12
- 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 +52 -4
- package/lib/proxy/resource-acl.js +6 -0
- package/lib/server.js +230 -7
- package/lib/settings/routes.js +6 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-oUd5O8W8.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
|
@@ -113,8 +113,31 @@ function peerAllows(peer, otherId) {
|
|
|
113
113
|
return Array.isArray(peer.selected) && peer.selected.includes(otherId);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// A host-only peer (publishRemote, schema 4) is a REMOTE node this installation
|
|
117
|
+
// publishes to an explicit audience of its own admin nodes. The boundary is
|
|
118
|
+
// one-way and does not depend on the peer's own claims or grants:
|
|
119
|
+
// audience -> peer : yes, while the reader is in the audience AND still holds
|
|
120
|
+
// the operator grant (re-read on every call);
|
|
121
|
+
// peer -> anyone : never (not another peer, not a topology, not a route);
|
|
122
|
+
// everyone else : no, whatever `visibility` or `shared` say.
|
|
123
|
+
function isHostOnlyPeer(peer) {
|
|
124
|
+
return !!peer && peer.publishRemote === true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function audienceAllows(reader, hostOnly) {
|
|
128
|
+
return Array.isArray(hostOnly.publishRemoteAudience)
|
|
129
|
+
&& typeof reader.nodeId === 'string'
|
|
130
|
+
&& hostOnly.publishRemoteAudience.includes(reader.nodeId)
|
|
131
|
+
&& !isHostOnlyPeer(reader)
|
|
132
|
+
&& allowedByClass('operator', reader);
|
|
133
|
+
}
|
|
134
|
+
|
|
116
135
|
function canTransit(ingress, egress) {
|
|
117
136
|
if (!ingress || !egress || ingress.name === egress.name) return !ingress;
|
|
137
|
+
// The ingress is the AUTHENTICATED peer record (looked up by its token), never
|
|
138
|
+
// something read from the query, a header or the visited chain.
|
|
139
|
+
if (isHostOnlyPeer(ingress)) return false;
|
|
140
|
+
if (isHostOnlyPeer(egress)) return audienceAllows(ingress, egress);
|
|
118
141
|
// `shared` is the explicit publication gate. Visibility remains the hub ACL,
|
|
119
142
|
// but it cannot make a private peer routable on its own.
|
|
120
143
|
return egress.shared === true
|
|
@@ -228,6 +251,11 @@ function knownResource(resource) {
|
|
|
228
251
|
|| /^\/event-feed\/asks\/[a-f0-9]{8}(?:\/answer|\/capability|\/requests\/[0-9a-f-]{16,64})?$/.test(resource)
|
|
229
252
|
|| /^\/event-feed\/[A-Za-z0-9._-]{1,32}$/.test(resource)
|
|
230
253
|
|| /^\/event-feed\/node\/[a-f0-9]{32}$/.test(resource)
|
|
254
|
+
// Owner-side notice dismissals: the write-back path of the feed for the
|
|
255
|
+
// notices the owner cleared. It travels like the ask write-back — a peer
|
|
256
|
+
// that may see the notice (and holds the ask-action grants) may clear it.
|
|
257
|
+
|| /^\/event-feed\/notices\/[0-9a-f-]{36}$/.test(resource)
|
|
258
|
+
|| resource === '/event-feed/notices/dismiss-all'
|
|
231
259
|
|| isPanelResource(resource)
|
|
232
260
|
|| /^\/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
261
|
}
|
|
@@ -269,6 +297,9 @@ function liveHostAllowedFor(ingress) {
|
|
|
269
297
|
// WebSocket upgrade. A resource whose class is unknown, or whose grants the
|
|
270
298
|
// owner has not conceded, is refused before anything is dispatched.
|
|
271
299
|
function classGateCheck(resource, method, ingress) {
|
|
300
|
+
// A host-only peer reaches nothing of ours, whatever grants its record carries
|
|
301
|
+
// or gains: the zeroed vector is the first lock, this is the second.
|
|
302
|
+
if (isHostOnlyPeer(ingress)) return { ok: false, status: 403, reason: 'host-only-peer' };
|
|
272
303
|
const cls = classifyResource(resource, method);
|
|
273
304
|
if (!cls) return { ok: false, status: 404, reason: 'resource-not-classified' };
|
|
274
305
|
if (!allowedByClass(cls, ingress)) return { ok: false, status: 403, reason: `grant-required:${cls}` };
|
|
@@ -335,6 +366,10 @@ function allowedResource(resource, method = 'GET') {
|
|
|
335
366
|
if (/^\/event-feed\/asks\/[a-f0-9]{8}\/capability$/.test(resource)) return method === 'GET';
|
|
336
367
|
if (/^\/event-feed\/asks\/[a-f0-9]{8}$/.test(resource)) return method === 'DELETE';
|
|
337
368
|
if (/^\/event-feed\/asks\/[a-f0-9]{8}\/requests\/[0-9a-f-]{16,64}$/.test(resource)) return method === 'GET';
|
|
369
|
+
// Notice dismissals: a single DELETE per notice, or one POST that clears the
|
|
370
|
+
// whole visible set (N DELETEs through two hops would die in the 6/min budget).
|
|
371
|
+
if (/^\/event-feed\/notices\/[0-9a-f-]{36}$/.test(resource)) return method === 'DELETE';
|
|
372
|
+
if (resource === '/event-feed/notices/dismiss-all') return method === 'POST';
|
|
338
373
|
if (resource === '/asks') return method === 'POST';
|
|
339
374
|
if (resource === '/notify') return method === 'POST';
|
|
340
375
|
// Audio: only capability read, speak and stop are exposed through Hydra.
|
|
@@ -1200,7 +1235,9 @@ async function collectTopologyDetailed({
|
|
|
1200
1235
|
instanceId: n.nodeId, name: n.name, route: [n.name], direct: true,
|
|
1201
1236
|
label: store.validLabel(n.label) ? n.label.trim() : '',
|
|
1202
1237
|
});
|
|
1203
|
-
|
|
1238
|
+
// A host-only peer is published as a LEAF: whatever it says about its own
|
|
1239
|
+
// children is never asked for, so it cannot announce nodes to our readers.
|
|
1240
|
+
if (ttl <= 1 || !n.token || isHostOnlyPeer(n)) continue;
|
|
1204
1241
|
probes.push({ n, pending: fetchPeerTopology({ node: n, ttl, seen, fetchImpl, timeoutMs: childBudget }) });
|
|
1205
1242
|
}
|
|
1206
1243
|
const results = await Promise.all(probes.map(async ({ n, pending }) => {
|
|
@@ -1277,7 +1314,9 @@ async function collectLocalTopology({
|
|
|
1277
1314
|
const live = await collectTopologyDetailed({ nodesPath, fetchImpl, timeoutMs, childSnapshots: false });
|
|
1278
1315
|
const st = store.loadStore(nodesPath);
|
|
1279
1316
|
const directNames = new Set(((st && st.nodes) || [])
|
|
1280
|
-
|
|
1317
|
+
// A host-only peer is a leaf: nothing cached "behind" it survives (entries
|
|
1318
|
+
// written before the mode was turned on included).
|
|
1319
|
+
.filter((n) => (n.direction !== 'inbound' || n.shared === true) && !isHostOnlyPeer(n))
|
|
1281
1320
|
.map((n) => n.name));
|
|
1282
1321
|
const authoritative = new Set(live.authoritative);
|
|
1283
1322
|
const liveIds = new Set(live.nodes.map((n) => n.instanceId));
|
|
@@ -1353,6 +1392,15 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
1353
1392
|
if (!peer) return res.status(401).json({ error: 'unauthorized peer' });
|
|
1354
1393
|
req.peer = peer; next();
|
|
1355
1394
|
});
|
|
1395
|
+
// A host-only peer (publish-remote) reaches NOTHING of ours over the protocol:
|
|
1396
|
+
// the only thing it may do is the authenticated health probe. Share, topology,
|
|
1397
|
+
// reverse-pool and routes are all refused here, before their handlers run.
|
|
1398
|
+
r.use((req, res, next) => {
|
|
1399
|
+
if (isHostOnlyPeer(req.peer) && !(req.method === 'GET' && req.path === '/health')) {
|
|
1400
|
+
return res.status(403).json({ error: 'non concesso a questo nodo', reason: 'host-only-peer' });
|
|
1401
|
+
}
|
|
1402
|
+
return next();
|
|
1403
|
+
});
|
|
1356
1404
|
// Health federato: il peer autenticato (acceptToken matchato sopra) ottiene un
|
|
1357
1405
|
// 200 esplicito con instanceId/version. Serve da target di probeHealth() lato
|
|
1358
1406
|
// Initiator: distingue transport (porta aperta) da auth (200 vs 401) da
|
|
@@ -2063,9 +2111,9 @@ function acceptWsThenClose(socket, req, code, reason) {
|
|
|
2063
2111
|
|
|
2064
2112
|
|
|
2065
2113
|
module.exports = {
|
|
2066
|
-
isPanelResource, panelAllowedFor, isLiveHostResource, liveHostAllowedFor, routeHandler,
|
|
2114
|
+
isPanelResource, panelAllowedFor, isLiveHostResource, liveHostAllowedFor, classGateCheck, routeHandler,
|
|
2067
2115
|
MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure, activeReversePort,
|
|
2068
|
-
peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
|
|
2116
|
+
peerFromToken, peerAllows, canTransit, isHostOnlyPeer, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
|
|
2069
2117
|
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade, createHubAttachStore,
|
|
2070
2118
|
probeHealth, waitForHealthyPeer, canListenLoopback, preflightHubReverse, notifyHubShare, reconcilePeerShare, runShareRevokeBoot, probeReverseOwner,
|
|
2071
2119
|
hubPoolRequest, verifyHubPoolSlot, reserveHubPoolSlot, commitHubPoolSlot, settleHubPoolSlot, abortHubPoolSlot, getHubPoolStatus,
|
|
@@ -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);
|