@mmmbuto/nexuscrew 0.9.28 → 0.9.30
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 +16 -0
- package/docs/LIVE_PROMPT.md +1 -1
- package/docs/NOTIFICATIONS.md +37 -0
- package/docs/OPERATIONS.md +20 -0
- package/frontend/dist/assets/index-BbcjoZS0.js +167 -0
- package/frontend/dist/assets/index-DqCfZFGi.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +91 -10
- package/frontend/dist/version.json +1 -1
- package/lib/audio/groups.js +1 -1
- package/lib/auth/token.js +4 -4
- package/lib/cli/commands.js +70 -15
- package/lib/cli/doctor.js +5 -5
- package/lib/cli/fleet-service.js +7 -7
- package/lib/cli/init.js +55 -31
- package/lib/cli/pidfile.js +1 -1
- package/lib/cli/service.js +124 -8
- package/lib/cli/stable-alias.js +4 -4
- package/lib/cli/url.js +1 -1
- package/lib/config.js +36 -6
- package/lib/files/routes.js +1 -1
- package/lib/fleet/boot.js +2 -2
- package/lib/fleet/builtin.js +15 -15
- package/lib/fleet/causes.js +1 -1
- package/lib/fleet/cell-exec.js +23 -23
- package/lib/fleet/cell-hold.js +6 -6
- package/lib/fleet/cell-lease-server.js +106 -106
- package/lib/fleet/cell-lease.js +21 -21
- package/lib/fleet/definitions.js +10 -10
- package/lib/fleet/identity-authority.js +1 -1
- package/lib/fleet/launch-broker.js +13 -13
- package/lib/fleet/launch.js +15 -15
- package/lib/fleet/lease-client.js +23 -23
- package/lib/fleet/lease-routes.js +3 -3
- package/lib/fleet/lease-verifier.js +22 -22
- package/lib/fleet/managed.js +35 -35
- package/lib/fleet/prompt-delivery.js +13 -13
- package/lib/fleet/routes.js +15 -10
- package/lib/fleet/runtime.js +24 -24
- package/lib/live-host/bridge.js +53 -43
- package/lib/live-host/federation.js +97 -0
- package/lib/live-host/routes.js +166 -8
- package/lib/live-host/store.js +44 -6
- package/lib/mcp/server.js +14 -14
- package/lib/mcp/tools.js +5 -5
- package/lib/nodes/access-presets.js +258 -0
- package/lib/nodes/commands.js +198 -4
- package/lib/nodes/store.js +152 -9
- package/lib/nodes/tunnel.js +7 -7
- package/lib/notify/ask-answer-service.js +184 -0
- package/lib/notify/ask-receipts.js +197 -0
- package/lib/notify/ask-relay.js +152 -0
- package/lib/notify/asks.js +28 -3
- package/lib/notify/event-feed-acl.js +95 -0
- package/lib/notify/event-feed-asks-routes.js +144 -0
- package/lib/notify/event-feed-client.js +434 -0
- package/lib/notify/event-feed-history.js +115 -0
- package/lib/notify/event-feed-producers.js +175 -0
- package/lib/notify/event-feed-routes.js +254 -0
- package/lib/notify/event-feed.js +328 -0
- package/lib/notify/events.js +1 -1
- package/lib/notify/notifier.js +3 -3
- package/lib/notify/push-relay.js +240 -0
- package/lib/notify/push.js +8 -8
- package/lib/notify/routes.js +62 -23
- package/lib/proxy/federation.js +132 -12
- package/lib/proxy/node-proxy.js +13 -13
- package/lib/proxy/panel-auth.js +4 -4
- package/lib/proxy/panel-proxy.js +2 -2
- package/lib/proxy/resource-acl.js +145 -0
- package/lib/server.js +396 -40
- package/lib/settings/pairing-coordinator.js +1 -1
- package/lib/settings/routes.js +45 -32
- package/lib/update/manager.js +1 -1
- package/lib/update/runner.js +1 -1
- package/lib/voice/transcribe.js +1 -1
- package/lib/ws/bridge.js +3 -3
- package/package.json +1 -1
- package/skills/live/SKILL.md +12 -0
- package/frontend/dist/assets/index-CWra6B79.js +0 -167
- package/frontend/dist/assets/index-D6CIfCjv.css +0 -32
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/notify/ask-receipts.js — durable receipts for FEDERATED answer attempts.
|
|
3
|
+
//
|
|
4
|
+
// The receipt is the answer's paper trail: a federated attempt gets a UUID
|
|
5
|
+
// (requestId), a digest of the text and a state that is WRITTEN BEFORE the
|
|
6
|
+
// paste happens (a failed receipt write means no paste — the owner never fires
|
|
7
|
+
// blind). Terminal states are final: replaying the same requestId with the
|
|
8
|
+
// same digest returns the stored state without pasting again; the same id with
|
|
9
|
+
// a different digest is a conflict. A process crash leaves `pending` behind:
|
|
10
|
+
// at recovery it becomes `delivery-unknown` and BLOCKS every new paste for
|
|
11
|
+
// that ask — local ones too — until the operator reconciles explicitly (
|
|
12
|
+
// no silent retries, no TTL).
|
|
13
|
+
//
|
|
14
|
+
// Cap 1000 attempts; terminal receipts age out after 24 h; at cap, NEW
|
|
15
|
+
// attempts are refused rather than evicting pending/unknown ones.
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const crypto = require('node:crypto');
|
|
20
|
+
const { atomicWriteJson } = require('./persist.js');
|
|
21
|
+
|
|
22
|
+
const SCHEMA = 'nexuscrew-ask-receipts-v1';
|
|
23
|
+
const CAP_ATTEMPTS = 1000;
|
|
24
|
+
const TERMINAL_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
function keyOf(peerId, askId, requestId) { return `${peerId}|${askId}|${requestId}`; }
|
|
27
|
+
function digestOf(text) { return crypto.createHash('sha256').update(String(text), 'utf8').digest('hex'); }
|
|
28
|
+
|
|
29
|
+
function createAskReceipts({ filePath, now = Date.now } = {}) {
|
|
30
|
+
if (!filePath) throw new Error('createAskReceipts: filePath richiesta');
|
|
31
|
+
|
|
32
|
+
function loadRaw() {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
35
|
+
if (parsed && parsed.schema === SCHEMA && parsed.attempts && typeof parsed.attempts === 'object') return parsed;
|
|
36
|
+
} catch (_) { /* fresh */ }
|
|
37
|
+
return { schema: SCHEMA, attempts: {}, blockedAsks: {} };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function persist(state) {
|
|
41
|
+
const dir = path.dirname(filePath);
|
|
42
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
43
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
44
|
+
fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
|
|
45
|
+
fs.renameSync(tmp, filePath);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Recovery at construction: a `pending` attempt did not observe its own
|
|
49
|
+
// paste, so its outcome is unknown and the ask is locked.
|
|
50
|
+
const state = loadRaw();
|
|
51
|
+
for (const entry of Object.values(state.attempts)) {
|
|
52
|
+
if (entry.state === 'pending') {
|
|
53
|
+
entry.state = 'delivery-unknown';
|
|
54
|
+
entry.updatedAt = now();
|
|
55
|
+
state.blockedAsks[entry.askId] = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Terminal retention: old terminal receipts age out (pending/unknown NEVER).
|
|
59
|
+
const cutoff = now() - TERMINAL_RETENTION_MS;
|
|
60
|
+
for (const [key, entry] of Object.entries(state.attempts)) {
|
|
61
|
+
const terminal = entry.state === 'committed' || entry.state === 'failed';
|
|
62
|
+
if (terminal && (entry.updatedAt || 0) < cutoff) delete state.attempts[key];
|
|
63
|
+
}
|
|
64
|
+
if (Object.keys(state.attempts).length > 0) persist(state);
|
|
65
|
+
|
|
66
|
+
function isBlocked(askId) { return state.blockedAsks[askId] === true; }
|
|
67
|
+
|
|
68
|
+
function get(peerId, askId, requestId) {
|
|
69
|
+
return state.attempts[keyOf(peerId, askId, requestId)] || null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Create the pending receipt. Refuses when the ask is blocked, or at cap —
|
|
73
|
+
// NEVER evicts a pending/unknown attempt to make room.
|
|
74
|
+
function attempt({ peerId, askId, requestId, text }) {
|
|
75
|
+
if (isBlocked(askId)) return { ok: false, reason: 'blocked' };
|
|
76
|
+
const key = keyOf(peerId, askId, requestId);
|
|
77
|
+
const existing = state.attempts[key];
|
|
78
|
+
if (existing) {
|
|
79
|
+
if (existing.digest !== digestOf(text)) return { ok: false, reason: 'digest-conflict', entry: existing };
|
|
80
|
+
return { ok: true, replay: true, entry: existing };
|
|
81
|
+
}
|
|
82
|
+
const terminalCount = Object.values(state.attempts)
|
|
83
|
+
.filter((e) => e.state !== 'pending' && e.state !== 'delivery-unknown').length;
|
|
84
|
+
if (Object.keys(state.attempts).length >= CAP_ATTEMPTS && terminalCount === 0) {
|
|
85
|
+
return { ok: false, reason: 'cap' };
|
|
86
|
+
}
|
|
87
|
+
const entry = {
|
|
88
|
+
peerId, askId, requestId,
|
|
89
|
+
digest: digestOf(text),
|
|
90
|
+
state: 'pending',
|
|
91
|
+
createdAt: now(), updatedAt: now(),
|
|
92
|
+
};
|
|
93
|
+
state.attempts[key] = entry;
|
|
94
|
+
persist(state);
|
|
95
|
+
return { ok: true, replay: false, entry };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function finalize(peerId, askId, requestId, finalState, receipt) {
|
|
99
|
+
const entry = state.attempts[keyOf(peerId, askId, requestId)];
|
|
100
|
+
if (!entry) return false;
|
|
101
|
+
if (entry.state !== 'pending') { entry.replayedAt = now(); return true; }
|
|
102
|
+
entry.state = finalState;
|
|
103
|
+
entry.receipt = receipt === undefined ? null : receipt;
|
|
104
|
+
entry.updatedAt = now();
|
|
105
|
+
persist(state);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Explicit operator reconciliation (local, authenticated, never federated):
|
|
110
|
+
// every delivery-unknown attempt of the ask gets the decided outcome and the
|
|
111
|
+
// ask is unlocked for new pastes.
|
|
112
|
+
function reconcile(askId, decision, expect = null) {
|
|
113
|
+
const finalState = decision === 'mark-delivered' ? 'committed'
|
|
114
|
+
: decision === 'allow-new-attempt' ? 'failed' : null;
|
|
115
|
+
if (!finalState) return { ok: false, reason: 'bad-decision' };
|
|
116
|
+
// The decision is bound to the GENERATION it was taken on: the exact set of
|
|
117
|
+
// unresolved outcomes that was visible when the operator decided. If that
|
|
118
|
+
// set moved (or is empty), this reconciliation is refused instead of
|
|
119
|
+
// unlocking a generation nobody decided about.
|
|
120
|
+
const current = reconcilableFor(askId);
|
|
121
|
+
if (current.count === 0) return { ok: false, reason: 'nothing-to-reconcile' };
|
|
122
|
+
if (expect && (expect.count !== current.count || expect.generation !== current.generation)) {
|
|
123
|
+
return { ok: false, reason: 'generation-changed' };
|
|
124
|
+
}
|
|
125
|
+
// The unlock is exposed ONLY after the durable write succeeded: the fields
|
|
126
|
+
// this decision touches are snapshotted first, so a failed write leaves the
|
|
127
|
+
// ask exactly as blocked as it was (no half-reconciled state in memory).
|
|
128
|
+
const touched = [];
|
|
129
|
+
let changed = 0;
|
|
130
|
+
for (const [key, entry] of Object.entries(state.attempts)) {
|
|
131
|
+
if (entry.askId === askId && entry.state === 'delivery-unknown') {
|
|
132
|
+
touched.push([key, { ...entry }]);
|
|
133
|
+
entry.state = finalState;
|
|
134
|
+
entry.receipt = 'reconciled';
|
|
135
|
+
entry.updatedAt = now();
|
|
136
|
+
changed += 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const wasBlocked = state.blockedAsks[askId] === true;
|
|
140
|
+
delete state.blockedAsks[askId];
|
|
141
|
+
try {
|
|
142
|
+
persist(state);
|
|
143
|
+
} catch (_) {
|
|
144
|
+
for (const [key, prev] of touched) state.attempts[key] = prev;
|
|
145
|
+
if (wasBlocked) state.blockedAsks[askId] = true;
|
|
146
|
+
return { ok: false, reason: 'persist-failed' };
|
|
147
|
+
}
|
|
148
|
+
return { ok: true, changed };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The authoritative answer state of an ask, as the durable receipts know it:
|
|
152
|
+
// a terminal write (`committed` after a paste, `delivered` after an operator
|
|
153
|
+
// reconciliation) means the ask already produced its answer — whatever store
|
|
154
|
+
// the caller went through. `unknown` marks an outcome that is still unresolved.
|
|
155
|
+
function answerStateFor(askId) {
|
|
156
|
+
let unfinished = false;
|
|
157
|
+
for (const entry of Object.values(state.attempts)) {
|
|
158
|
+
if (entry.askId !== askId) continue;
|
|
159
|
+
if (entry.state === 'committed' || entry.state === 'delivered') return entry.state;
|
|
160
|
+
if (entry.state === 'pending' || entry.state === 'delivery-unknown') unfinished = true;
|
|
161
|
+
}
|
|
162
|
+
return unfinished ? 'unknown' : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The outcomes an operator can reconcile right now: the delivery-unknown
|
|
166
|
+
// attempts of this ask, with a short digest of their identity set. `pending`
|
|
167
|
+
// attempts are a paste still in flight — they are NOT reconcilable and are
|
|
168
|
+
// reported separately, so nobody can decide on an outcome that is still open.
|
|
169
|
+
function reconcilableFor(askId) {
|
|
170
|
+
const ids = [];
|
|
171
|
+
let live = 0;
|
|
172
|
+
for (const entry of Object.values(state.attempts)) {
|
|
173
|
+
if (entry.askId !== askId) continue;
|
|
174
|
+
if (entry.state === 'delivery-unknown') ids.push(String(entry.requestId));
|
|
175
|
+
else if (entry.state === 'pending') live += 1;
|
|
176
|
+
}
|
|
177
|
+
ids.sort();
|
|
178
|
+
return {
|
|
179
|
+
count: ids.length,
|
|
180
|
+
live,
|
|
181
|
+
generation: crypto.createHash('sha256').update(ids.join('|'), 'utf8').digest('hex').slice(0, 16),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function statusFor(askId) {
|
|
186
|
+
const attempts = Object.values(state.attempts).filter((e) => e.askId === askId);
|
|
187
|
+
return {
|
|
188
|
+
blocked: isBlocked(askId),
|
|
189
|
+
attempts: attempts.length,
|
|
190
|
+
unknown: attempts.filter((e) => e.state === 'delivery-unknown').length,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { attempt, get, finalize, isBlocked, reconcile, reconcilableFor, statusFor, answerStateFor, digestOf };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = { createAskReceipts, digestOf, keyOf, CAP_ATTEMPTS };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/notify/ask-relay.js — the client-side relay for federated ask answers.
|
|
3
|
+
//
|
|
4
|
+
// The local endpoint receives {ownerId, askId, text, requestId} from the PWA.
|
|
5
|
+
// The route to the owner is resolved from the AUTHORIZED node store (the
|
|
6
|
+
// outbound peer whose nodeId is the owner) — never from anything an event
|
|
7
|
+
// said. The forward uses the peer's own pair token and the visited header;
|
|
8
|
+
// the hop proof is minted by the owner on the last hop.
|
|
9
|
+
//
|
|
10
|
+
// Outcome contract: a committed/failed answer is final. An uncertain
|
|
11
|
+
// outcome (timeout, network error after the request left) is NOT retried: the
|
|
12
|
+
// attempt is parked as "uncertain" and the only allowed action is "verify
|
|
13
|
+
// status" (GET .../requests/:requestId). A new paste for the same ask is
|
|
14
|
+
// refused locally until verification resolves the attempt.
|
|
15
|
+
|
|
16
|
+
const crypto = require('node:crypto');
|
|
17
|
+
const IDLE_MS = 15000;
|
|
18
|
+
|
|
19
|
+
function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = () => {} } = {}) {
|
|
20
|
+
// (ownerId|askId|requestId) -> {state: 'sent'|'committed'|'failed'|'uncertain', requestId}
|
|
21
|
+
const attempts = new Map();
|
|
22
|
+
|
|
23
|
+
function requestId() { return crypto.randomUUID(); }
|
|
24
|
+
|
|
25
|
+
function resolveOwner(ownerId) {
|
|
26
|
+
const store = loadStore();
|
|
27
|
+
if (!store) return null;
|
|
28
|
+
return (store.nodes || []).find((n) => n && n.direction === 'outbound'
|
|
29
|
+
&& n.nodeId === ownerId && n.token && n.localPort) || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ownerUrl(peer, path) {
|
|
33
|
+
return `http://127.0.0.1:${peer.localPort}/federation/route/_/${path.replace(/^\//, '')}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function attemptKey(ownerId, askId, rid) { return `${ownerId}|${askId}|${rid}`; }
|
|
37
|
+
|
|
38
|
+
function statusFor(ownerId, askId) {
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const [key, a] of attempts) {
|
|
41
|
+
if (key.startsWith(`${ownerId}|${askId}|`)) out.push(a);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isUncertain(ownerId, askId) {
|
|
47
|
+
return statusFor(ownerId, askId).some((a) => a.state === 'uncertain');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function relayAnswer({ ownerId, askId, text, rid: providedRid } = {}) {
|
|
51
|
+
if (!ownerId || !askId || typeof text !== 'string' || !text.trim()) {
|
|
52
|
+
return { ok: false, code: 400, error: 'ownerId, askId e text richiesti' };
|
|
53
|
+
}
|
|
54
|
+
const peer = resolveOwner(ownerId);
|
|
55
|
+
if (!peer) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
|
|
56
|
+
// Local reconciliation FIRST: an uncertain attempt on this ask must be
|
|
57
|
+
// verified before any new paste leaves this node.
|
|
58
|
+
if (isUncertain(ownerId, askId)) {
|
|
59
|
+
return { ok: false, code: 409, error: 'esito incerto: verifica lo stato prima di rispondere', reason: 'uncertain', uncertain: true };
|
|
60
|
+
}
|
|
61
|
+
const rid = providedRid || requestId();
|
|
62
|
+
const key = attemptKey(ownerId, askId, rid);
|
|
63
|
+
const store = loadStore();
|
|
64
|
+
attempts.set(key, { requestId: rid, state: 'sent', askId, ownerId });
|
|
65
|
+
const ctrl = new AbortController();
|
|
66
|
+
const timer = setTimeout(() => ctrl.abort(), IDLE_MS);
|
|
67
|
+
try {
|
|
68
|
+
const r = await fetchImpl(ownerUrl(peer, `/event-feed/asks/${askId}/answer`), {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: {
|
|
71
|
+
authorization: `Bearer ${peer.token}`,
|
|
72
|
+
'content-type': 'application/json',
|
|
73
|
+
'x-nexuscrew-visited': store.nodeId,
|
|
74
|
+
},
|
|
75
|
+
body: JSON.stringify({ text, requestId: rid }),
|
|
76
|
+
signal: ctrl.signal,
|
|
77
|
+
});
|
|
78
|
+
const body = await r.json().catch(() => ({}));
|
|
79
|
+
if (r.status === 200) {
|
|
80
|
+
const state = body.status === 'answered' ? 'committed' : body.status || 'committed';
|
|
81
|
+
attempts.get(key).state = body.replay ? (body.state || state) : state;
|
|
82
|
+
return { ok: true, status: attempts.get(key).state, requestId: rid };
|
|
83
|
+
}
|
|
84
|
+
const entry = attempts.get(key);
|
|
85
|
+
// 409 request-conflict / answering are definitive; anything else that
|
|
86
|
+
// could have pasted (timeout-ish) parks as uncertain, never retried.
|
|
87
|
+
if (r.status === 409) {
|
|
88
|
+
if (entry) entry.state = body.reason === 'delivery-unknown-block' ? 'uncertain' : 'failed';
|
|
89
|
+
return { ok: false, code: 409, error: body.error || 'conflict', reason: body.reason };
|
|
90
|
+
}
|
|
91
|
+
if (r.status === 502) {
|
|
92
|
+
if (entry) entry.state = 'uncertain';
|
|
93
|
+
return { ok: false, code: 200, uncertain: true, reason: 'delivery-unknown', requestId: rid,
|
|
94
|
+
error: 'esito incerto: verifica lo stato prima di rispondere' };
|
|
95
|
+
}
|
|
96
|
+
if (entry) entry.state = 'failed';
|
|
97
|
+
return { ok: false, code: r.status, error: body.error || `HTTP ${r.status}`, reason: body.reason };
|
|
98
|
+
} catch (_) {
|
|
99
|
+
// The request may or may not have pasted: uncertain, never retried.
|
|
100
|
+
const entry = attempts.get(key);
|
|
101
|
+
if (entry) entry.state = 'uncertain';
|
|
102
|
+
return { ok: false, code: 200, uncertain: true, reason: 'delivery-unknown', requestId: rid,
|
|
103
|
+
error: 'esito incerto: verifica lo stato prima di rispondere' };
|
|
104
|
+
} finally {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Remote dismiss: same resolution rules as the answer (DELETE on the owner).
|
|
110
|
+
async function relayDismiss({ ownerId, askId } = {}) {
|
|
111
|
+
const peer = resolveOwner(ownerId);
|
|
112
|
+
if (!peer) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati' };
|
|
113
|
+
const store = loadStore();
|
|
114
|
+
try {
|
|
115
|
+
const r = await fetchImpl(ownerUrl(peer, `/event-feed/asks/${askId}`), {
|
|
116
|
+
method: 'DELETE',
|
|
117
|
+
headers: { authorization: `Bearer ${peer.token}`, 'x-nexuscrew-visited': store ? store.nodeId : '' },
|
|
118
|
+
});
|
|
119
|
+
const body = await r.json().catch(() => ({}));
|
|
120
|
+
if (!r.ok) return { ok: false, code: r.status, error: body.error || `HTTP ${r.status}` };
|
|
121
|
+
return { ok: true };
|
|
122
|
+
} catch (e) { return { ok: false, error: String((e && e.message) || e) }; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function verifyStatus({ ownerId, askId, requestId: rid }) {
|
|
126
|
+
const peer = resolveOwner(ownerId);
|
|
127
|
+
if (!peer) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati' };
|
|
128
|
+
const store = loadStore();
|
|
129
|
+
try {
|
|
130
|
+
const r = await fetchImpl(ownerUrl(peer, `/event-feed/asks/${askId}/requests/${rid}`), {
|
|
131
|
+
headers: { authorization: `Bearer ${peer.token}`, 'x-nexuscrew-visited': store ? store.nodeId : '' },
|
|
132
|
+
});
|
|
133
|
+
if (!r.ok) return { ok: false, code: r.status };
|
|
134
|
+
const body = await r.json();
|
|
135
|
+
const key = attemptKey(ownerId, askId, rid);
|
|
136
|
+
if (attempts.has(key) && (body.state === 'committed' || body.state === 'failed')) {
|
|
137
|
+
attempts.get(key).state = body.state;
|
|
138
|
+
}
|
|
139
|
+
return { ok: true, state: body.state || 'unknown' };
|
|
140
|
+
} catch (e) {
|
|
141
|
+
return { ok: false, error: String((e && e.message) || e) };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function state() {
|
|
146
|
+
return [...attempts.entries()].map(([key, a]) => ({ key, ...a }));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { relayAnswer, relayDismiss, verifyStatus, state, isUncertain, requestId };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { createAskRelay };
|
package/lib/notify/asks.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// Store
|
|
2
|
+
// Store of "asks" (cell->operator questions) — MCP bridge.
|
|
3
3
|
// Stato in-memory + persistenza <dir>/asks.json 0600 (sopravvive al restart:
|
|
4
4
|
// un ask aperto resta risponibile). Lo store e' PURO stato: il paste tmux e la
|
|
5
5
|
// notify li orchestra la route (lib/notify/routes.js), che qui fa il ciclo
|
|
@@ -86,6 +86,7 @@ function createAsksStore(opts = {}) {
|
|
|
86
86
|
...(v.value.options ? { options: v.value.options } : {}),
|
|
87
87
|
session: String(session),
|
|
88
88
|
ts: now(),
|
|
89
|
+
revision: 0,
|
|
89
90
|
answered: false,
|
|
90
91
|
dismissed: false,
|
|
91
92
|
};
|
|
@@ -117,7 +118,7 @@ function createAsksStore(opts = {}) {
|
|
|
117
118
|
if (!ask) return { ok: false, reason: 'unknown' };
|
|
118
119
|
// Un ask dismissato non e' piu' risponibile: senza questa guardia la
|
|
119
120
|
// sequenza dismiss -> claim -> commit lascia lo stato ibrido
|
|
120
|
-
// `dismissed && answered`
|
|
121
|
+
// `dismissed && answered` state.
|
|
121
122
|
if (ask.dismissed) return { ok: false, reason: 'dismissed' };
|
|
122
123
|
if (ask.answered) return { ok: false, reason: 'answered' };
|
|
123
124
|
if (answering.has(id)) return { ok: false, reason: 'answering' };
|
|
@@ -125,6 +126,10 @@ function createAsksStore(opts = {}) {
|
|
|
125
126
|
return { ok: true, ask: { ...ask } };
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
// Read-only: a claim is being held right now. Reconciliation uses this to
|
|
130
|
+
// refuse WITHOUT touching the claim of a paste that is still in flight.
|
|
131
|
+
function isAnswering(id) { return answering.has(id); }
|
|
132
|
+
|
|
128
133
|
// Rollback: il paste e' fallito, l'ask torna contendibile.
|
|
129
134
|
function release(id) {
|
|
130
135
|
answering.delete(id);
|
|
@@ -138,6 +143,7 @@ function createAsksStore(opts = {}) {
|
|
|
138
143
|
ask.answered = true;
|
|
139
144
|
ask.answer = String(text);
|
|
140
145
|
ask.answeredTs = now();
|
|
146
|
+
ask.revision = (ask.revision || 0) + 1;
|
|
141
147
|
save();
|
|
142
148
|
return true;
|
|
143
149
|
}
|
|
@@ -154,6 +160,25 @@ function createAsksStore(opts = {}) {
|
|
|
154
160
|
if (ask.dismissed) return { ok: true, ask: { ...ask }, idempotent: true };
|
|
155
161
|
ask.dismissed = true;
|
|
156
162
|
ask.dismissedTs = now();
|
|
163
|
+
ask.revision = (ask.revision || 0) + 1;
|
|
164
|
+
save();
|
|
165
|
+
return { ok: true, ask: { ...ask } };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Operator reconciliation of a federated attempt: `mark-delivered` closes the
|
|
169
|
+
// ask (the paste happened on the peer's side, there is no local answer text),
|
|
170
|
+
// `allow-new-attempt` leaves it open. Both advance the revision, so the token
|
|
171
|
+
// the operator reconciled with can never be reused on the same generation.
|
|
172
|
+
function markReconciled(id, decision) {
|
|
173
|
+
const ask = get(id);
|
|
174
|
+
if (!ask) return { ok: false, reason: 'unknown' };
|
|
175
|
+
answering.delete(id);
|
|
176
|
+
if (decision === 'mark-delivered' && !ask.answered) {
|
|
177
|
+
ask.answered = true;
|
|
178
|
+
ask.answeredTs = now();
|
|
179
|
+
ask.answeredReconciled = true;
|
|
180
|
+
}
|
|
181
|
+
ask.revision = (ask.revision || 0) + 1;
|
|
157
182
|
save();
|
|
158
183
|
return { ok: true, ask: { ...ask } };
|
|
159
184
|
}
|
|
@@ -165,7 +190,7 @@ function createAsksStore(opts = {}) {
|
|
|
165
190
|
return commit(id, text);
|
|
166
191
|
}
|
|
167
192
|
|
|
168
|
-
return { create, get, list, openCount, claim, release, commit, markAnswered, dismiss, validate, filePath, MAX_OPEN };
|
|
193
|
+
return { create, get, list, openCount, claim, release, commit, markAnswered, markReconciled, isAnswering, dismiss, validate, filePath, MAX_OPEN };
|
|
169
194
|
}
|
|
170
195
|
|
|
171
196
|
module.exports = { createAsksStore };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/notify/event-feed-acl.js — per-peer projection of the event feed.
|
|
3
|
+
//
|
|
4
|
+
// The grants live in the node store and are the ONLY source of truth, read
|
|
5
|
+
// fresh before every batch (a cached grant is a stale grant). Scope rules:
|
|
6
|
+
// - scope 'cell': eventsAccess AND the cell must be visible under
|
|
7
|
+
// cellVisibility. A hidden cell stays hidden through events.
|
|
8
|
+
// - scope 'node': eventsAccess AND nodeEventsAccess. nodeEventsAccess NEVER
|
|
9
|
+
// bypasses cellVisibility: cell lists inside node frames are projected by
|
|
10
|
+
// the same visibility rule, and hidden cells contribute neither names nor
|
|
11
|
+
// counts.
|
|
12
|
+
|
|
13
|
+
const nodesStore = require('../nodes/store.js');
|
|
14
|
+
const accessPresets = require('../nodes/access-presets.js');
|
|
15
|
+
|
|
16
|
+
const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
|
|
17
|
+
|
|
18
|
+
function visibilityAllows(grants, cellId) {
|
|
19
|
+
const mode = grants.cellVisibility;
|
|
20
|
+
if (mode === 'all') return true;
|
|
21
|
+
if (mode === 'none') return false;
|
|
22
|
+
if (mode === 'selected') {
|
|
23
|
+
return Array.isArray(grants.cells) && typeof cellId === 'string' && grants.cells.includes(cellId);
|
|
24
|
+
}
|
|
25
|
+
return false; // malformed: denied, never guessed
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Effective grants of ONE peer, read fresh from the store. Unconfigured or
|
|
29
|
+
// malformed records are denied everywhere (a paired peer is not, by itself,
|
|
30
|
+
// allowed to read events). `revision` is the store-root access revision: the
|
|
31
|
+
// signature lets the publisher detect grant changes cheaply.
|
|
32
|
+
function peerGrants(store, peerNodeId) {
|
|
33
|
+
const node = (store && Array.isArray(store.nodes) ? store.nodes : [])
|
|
34
|
+
.find((n) => n && n.nodeId === peerNodeId && n.direction === 'inbound');
|
|
35
|
+
if (!node) return null;
|
|
36
|
+
const view = accessPresets.grantsOf(node);
|
|
37
|
+
const grants = {
|
|
38
|
+
eventsAccess: view.configured === true && node.eventsAccess === true,
|
|
39
|
+
nodeEventsAccess: view.configured === true && node.nodeEventsAccess === true,
|
|
40
|
+
askReplyAccess: view.configured === true && node.askReplyAccess === true,
|
|
41
|
+
cellVisibility: view.configured === true ? node.cellVisibility : 'none',
|
|
42
|
+
cells: Array.isArray(node.cells) ? [...node.cells] : [],
|
|
43
|
+
configured: view.configured === true,
|
|
44
|
+
label: view.label,
|
|
45
|
+
};
|
|
46
|
+
grants.signature = JSON.stringify({
|
|
47
|
+
e: grants.eventsAccess, n: grants.nodeEventsAccess, a: grants.askReplyAccess,
|
|
48
|
+
v: grants.cellVisibility, c: grants.cells, r: nodesStore.accessRevisionOf(store),
|
|
49
|
+
});
|
|
50
|
+
return grants;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// May this peer receive this envelope at all? The envelope's cellId is trusted
|
|
54
|
+
// here because it was resolved by the OWNER (binding/roster), never taken from
|
|
55
|
+
// a request body.
|
|
56
|
+
function allowsEnvelope(grants, envelope) {
|
|
57
|
+
if (!grants || grants.configured !== true) return false;
|
|
58
|
+
if (grants.eventsAccess !== true) return false;
|
|
59
|
+
if (!envelope || typeof envelope !== 'object') return false;
|
|
60
|
+
if (envelope.scope === 'cell') {
|
|
61
|
+
if (!(typeof envelope.cellId === 'string' && CELL_ID_RE.test(envelope.cellId))) return false;
|
|
62
|
+
return visibilityAllows(grants, envelope.cellId);
|
|
63
|
+
}
|
|
64
|
+
if (envelope.scope === 'node') return grants.nodeEventsAccess === true;
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Project a list of cells (fleet frames, snapshot state) to what the peer may
|
|
69
|
+
// see. Hidden cells contribute NOTHING: no names, no counts, no "there were N
|
|
70
|
+
// more" hints.
|
|
71
|
+
function projectCells(grants, cells) {
|
|
72
|
+
if (!Array.isArray(cells)) return [];
|
|
73
|
+
if (!grants || grants.eventsAccess !== true) return [];
|
|
74
|
+
if (grants.cellVisibility === 'all') return cells.slice(0, 1000);
|
|
75
|
+
if (grants.cellVisibility === 'selected') {
|
|
76
|
+
const allowed = new Set(Array.isArray(grants.cells) ? grants.cells : []);
|
|
77
|
+
return cells.filter((c) => c && allowed.has(c.cell));
|
|
78
|
+
}
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Full per-request decision + projection context. Returns null when the peer
|
|
83
|
+
// record does not exist (unknown origin): the caller refuses.
|
|
84
|
+
function resolvePeer(store, peerNodeId) {
|
|
85
|
+
const grants = peerGrants(store, peerNodeId);
|
|
86
|
+
if (!grants) return null;
|
|
87
|
+
return {
|
|
88
|
+
nodeId: peerNodeId,
|
|
89
|
+
grants,
|
|
90
|
+
allows: (envelope) => allowsEnvelope(grants, envelope),
|
|
91
|
+
projectCells: (cells) => projectCells(grants, cells),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { peerGrants, allowsEnvelope, projectCells, visibilityAllows, resolvePeer, CELL_ID_RE };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/notify/event-feed-asks-routes.js — federated ask ANSWER surface.
|
|
3
|
+
//
|
|
4
|
+
// POST /event-feed/asks/:id/answer {text, requestId} (closed body)
|
|
5
|
+
// DELETE /event-feed/asks/:id (dismiss)
|
|
6
|
+
// GET /event-feed/asks/:id/requests/:requestId (attempt status)
|
|
7
|
+
//
|
|
8
|
+
// The gate is the SAME as the stream/snapshot gate (kill-switch -> proven
|
|
9
|
+
// federated origin -> exact [clientId, ownerId] -> known peer -> eventsAccess)
|
|
10
|
+
// PLUS askReplyAccess plus the ask's own cell scope read from the ask store.
|
|
11
|
+
// Unknown and out-of-scope ids get the SAME opaque 404: an id that does not
|
|
12
|
+
// exist and an id the peer may not see must be indistinguishable. Answer and
|
|
13
|
+
// dismiss run on a DEDICATED budget (6/min/peer, 30/min global) that shares
|
|
14
|
+
// nothing with notify/audio/local ask creation.
|
|
15
|
+
|
|
16
|
+
const express = require('express');
|
|
17
|
+
const { createFeedGate } = require('./event-feed-routes.js');
|
|
18
|
+
|
|
19
|
+
const ANSWER_RATE_PER_MIN = 6;
|
|
20
|
+
const ANSWER_GLOBAL_PER_MIN = 30;
|
|
21
|
+
|
|
22
|
+
function createAskRateLimiter({ now = Date.now } = {}) {
|
|
23
|
+
const perPeer = new Map();
|
|
24
|
+
const global = [];
|
|
25
|
+
function prune(list, t) {
|
|
26
|
+
const cutoff = t - 60000;
|
|
27
|
+
while (list.length > 0 && list[0] <= cutoff) list.shift();
|
|
28
|
+
}
|
|
29
|
+
function check(peerId) {
|
|
30
|
+
const t = now();
|
|
31
|
+
const list = perPeer.get(peerId) || [];
|
|
32
|
+
prune(list, t); prune(global, t);
|
|
33
|
+
if (list.length >= ANSWER_RATE_PER_MIN || global.length >= ANSWER_GLOBAL_PER_MIN) {
|
|
34
|
+
return { allowed: false, reason: 'answer-rate' };
|
|
35
|
+
}
|
|
36
|
+
list.push(t); perPeer.set(peerId, list); global.push(t);
|
|
37
|
+
return { allowed: true };
|
|
38
|
+
}
|
|
39
|
+
return { check };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createEventFeedAsksRoutes(deps) {
|
|
43
|
+
const r = express.Router();
|
|
44
|
+
const json = express.json({ limit: '16kb' });
|
|
45
|
+
// deps: { nodesPath, localNodeId, originResolver, eventsEnabled, asks,
|
|
46
|
+
// answerService, cellForSession, log }
|
|
47
|
+
const gate = createFeedGate(deps);
|
|
48
|
+
const rate = createAskRateLimiter({ now: deps.now });
|
|
49
|
+
const log = typeof deps.log === 'function' ? deps.log : () => {};
|
|
50
|
+
|
|
51
|
+
// Resolve the ask for THIS peer: the cell comes from the ask store (never
|
|
52
|
+
// from the request), then the peer's cell scope decides. Unknown and
|
|
53
|
+
// out-of-scope are the same opaque 404.
|
|
54
|
+
function resolveAskForPeer(peer, askId) {
|
|
55
|
+
const ask = deps.asks.get(askId);
|
|
56
|
+
if (!ask) return null;
|
|
57
|
+
const cellId = deps.cellForSession(ask.session);
|
|
58
|
+
if (!cellId || !peer.allows({ scope: 'cell', cellId })) return null;
|
|
59
|
+
return { ask, cellId };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
r.post('/:id/answer', json, async (req, res) => {
|
|
63
|
+
const g = await gate(req, res);
|
|
64
|
+
if (!g) return;
|
|
65
|
+
const verdict = rate.check(g.peer.nodeId);
|
|
66
|
+
if (!verdict.allowed) return res.status(429).json({ error: 'answer rate', reason: verdict.reason });
|
|
67
|
+
if (deps.readonly && deps.readonly()) {
|
|
68
|
+
return res.status(403).json({ error: 'READONLY: mutazione bloccata' });
|
|
69
|
+
}
|
|
70
|
+
// askReplyAccess on top of the feed gate: seeing the card does not
|
|
71
|
+
// grant the action. It is re-read with the same fresh store the gate used.
|
|
72
|
+
if (g.peer.grants.askReplyAccess !== true) {
|
|
73
|
+
return res.status(403).json({ error: 'risorsa non concessa a questo nodo', reason: 'grant-required:ask-action' });
|
|
74
|
+
}
|
|
75
|
+
const askId = String(req.params.id || '');
|
|
76
|
+
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
77
|
+
// CLOSED body: {text, requestId}. Anything that tries to choose a session,
|
|
78
|
+
// a target or an origin is a 400 before any other consideration.
|
|
79
|
+
for (const key of Object.keys(body)) {
|
|
80
|
+
if (!['text', 'requestId'].includes(key)) {
|
|
81
|
+
return res.status(400).json({ error: `campo non ammesso: "${key}"` });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (typeof body.text !== 'string') return res.status(400).json({ error: 'text deve essere una stringa' });
|
|
85
|
+
const resolved = resolveAskForPeer(g.peer, askId);
|
|
86
|
+
if (!resolved) return res.status(404).json({ error: 'ask inesistente' });
|
|
87
|
+
const out = await deps.answerService.answerFederated({
|
|
88
|
+
askId, text: body.text, peerId: g.peer.nodeId, requestId: body.requestId,
|
|
89
|
+
});
|
|
90
|
+
if (out.ok) {
|
|
91
|
+
return res.json({ status: out.replay ? out.state : 'committed', requestId: body.requestId });
|
|
92
|
+
}
|
|
93
|
+
return res.status(out.code || 500).json({ error: out.error, reason: out.reason });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
r.delete('/:id', async (req, res) => {
|
|
97
|
+
const g = await gate(req, res);
|
|
98
|
+
if (!g) return;
|
|
99
|
+
const verdict = rate.check(g.peer.nodeId);
|
|
100
|
+
if (!verdict.allowed) return res.status(429).json({ error: 'answer rate', reason: verdict.reason });
|
|
101
|
+
if (deps.readonly && deps.readonly()) {
|
|
102
|
+
return res.status(403).json({ error: 'READONLY: mutazione bloccata' });
|
|
103
|
+
}
|
|
104
|
+
if (g.peer.grants.askReplyAccess !== true) {
|
|
105
|
+
return res.status(403).json({ error: 'risorsa non concessa a questo nodo', reason: 'grant-required:ask-action' });
|
|
106
|
+
}
|
|
107
|
+
const askId = String(req.params.id || '');
|
|
108
|
+
const resolved = resolveAskForPeer(g.peer, askId);
|
|
109
|
+
if (!resolved) return res.status(404).json({ error: 'ask inesistente' });
|
|
110
|
+
// Local answer in flight wins: 409, and a dismissed ask is not answerable.
|
|
111
|
+
const out = deps.answerService.dismiss(askId);
|
|
112
|
+
if (!out.ok) {
|
|
113
|
+
if (out.reason === 'unknown') return res.status(404).json({ error: 'ask inesistente' });
|
|
114
|
+
if (out.reason === 'answering') return res.status(409).json({ error: 'risposta in corso: non si scarta un ask in answering' });
|
|
115
|
+
return res.status(500).json({ error: 'dismiss non riuscito' });
|
|
116
|
+
}
|
|
117
|
+
return res.json({ dismissed: true, id: askId, idempotent: out.idempotent === true });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
r.get('/:id/requests/:requestId', async (req, res) => {
|
|
121
|
+
const g = await gate(req, res);
|
|
122
|
+
if (!g) return;
|
|
123
|
+
if (g.peer.grants.askReplyAccess !== true) {
|
|
124
|
+
return res.status(403).json({ error: 'risorsa non concessa a questo nodo', reason: 'grant-required:ask-action' });
|
|
125
|
+
}
|
|
126
|
+
const askId = String(req.params.id || '');
|
|
127
|
+
const requestId = String(req.params.requestId || '');
|
|
128
|
+
const resolved = resolveAskForPeer(g.peer, askId);
|
|
129
|
+
if (!resolved) return res.status(404).json({ error: 'ask inesistente' });
|
|
130
|
+
// Scoped: a peer sees ONLY its own attempts, and never the answer text of
|
|
131
|
+
// anyone — the receipt carries a digest, not a payload.
|
|
132
|
+
const entry = deps.receipts.get(g.peer.nodeId, askId, requestId);
|
|
133
|
+
if (!entry) return res.status(404).json({ error: 'ask inesistente' });
|
|
134
|
+
return res.json({
|
|
135
|
+
requestId, askId, state: entry.state,
|
|
136
|
+
...(entry.receipt ? { receipt: entry.receipt } : {}),
|
|
137
|
+
...(entry.updatedAt ? { updatedAt: entry.updatedAt } : {}),
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return r;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { createEventFeedAsksRoutes, createAskRateLimiter };
|