@mmmbuto/nexuscrew 0.9.29 → 0.9.31

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/docs/LIVE_PROMPT.md +1 -1
  3. package/docs/NOTIFICATIONS.md +37 -0
  4. package/frontend/dist/assets/index-DaAL7P-m.js +167 -0
  5. package/frontend/dist/assets/{index-CywSos2e.css → index-DqCfZFGi.css} +1 -1
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/dist/sw.js +91 -10
  8. package/frontend/dist/version.json +1 -1
  9. package/lib/audio/groups.js +1 -1
  10. package/lib/auth/token.js +4 -4
  11. package/lib/cli/commands.js +59 -12
  12. package/lib/cli/doctor.js +36 -7
  13. package/lib/cli/fleet-service.js +7 -7
  14. package/lib/cli/init.js +27 -27
  15. package/lib/cli/pidfile.js +1 -1
  16. package/lib/cli/service.js +6 -6
  17. package/lib/cli/stable-alias.js +4 -4
  18. package/lib/cli/url.js +1 -1
  19. package/lib/config.js +36 -6
  20. package/lib/decks/routes.js +11 -1
  21. package/lib/files/routes.js +1 -1
  22. package/lib/fleet/boot.js +2 -2
  23. package/lib/fleet/builtin.js +28 -19
  24. package/lib/fleet/causes.js +1 -1
  25. package/lib/fleet/cell-exec.js +23 -23
  26. package/lib/fleet/cell-hold.js +6 -6
  27. package/lib/fleet/cell-lease-server.js +106 -106
  28. package/lib/fleet/cell-lease.js +21 -21
  29. package/lib/fleet/definitions.js +21 -13
  30. package/lib/fleet/identity-authority.js +1 -1
  31. package/lib/fleet/launch-broker.js +13 -13
  32. package/lib/fleet/launch.js +15 -15
  33. package/lib/fleet/lease-client.js +23 -23
  34. package/lib/fleet/lease-routes.js +3 -3
  35. package/lib/fleet/lease-verifier.js +22 -22
  36. package/lib/fleet/managed.js +46 -36
  37. package/lib/fleet/prompt-delivery.js +13 -13
  38. package/lib/fleet/routes.js +15 -10
  39. package/lib/fleet/runtime.js +26 -26
  40. package/lib/live-host/bridge.js +36 -36
  41. package/lib/live-host/routes.js +2 -2
  42. package/lib/live-host/store.js +4 -4
  43. package/lib/mcp/server.js +14 -14
  44. package/lib/mcp/tools.js +5 -5
  45. package/lib/nodes/access-presets.js +258 -0
  46. package/lib/nodes/commands.js +198 -4
  47. package/lib/nodes/store.js +152 -9
  48. package/lib/nodes/tunnel.js +7 -7
  49. package/lib/notify/ask-answer-service.js +184 -0
  50. package/lib/notify/ask-receipts.js +197 -0
  51. package/lib/notify/ask-relay.js +152 -0
  52. package/lib/notify/asks.js +28 -3
  53. package/lib/notify/event-feed-acl.js +95 -0
  54. package/lib/notify/event-feed-asks-routes.js +144 -0
  55. package/lib/notify/event-feed-client.js +434 -0
  56. package/lib/notify/event-feed-history.js +115 -0
  57. package/lib/notify/event-feed-producers.js +175 -0
  58. package/lib/notify/event-feed-routes.js +254 -0
  59. package/lib/notify/event-feed.js +328 -0
  60. package/lib/notify/events.js +1 -1
  61. package/lib/notify/notifier.js +3 -3
  62. package/lib/notify/push-relay.js +240 -0
  63. package/lib/notify/push.js +8 -8
  64. package/lib/notify/routes.js +62 -23
  65. package/lib/proxy/federation.js +74 -3
  66. package/lib/proxy/node-proxy.js +13 -13
  67. package/lib/proxy/panel-auth.js +4 -4
  68. package/lib/proxy/panel-proxy.js +2 -2
  69. package/lib/proxy/resource-acl.js +145 -0
  70. package/lib/server.js +372 -39
  71. package/lib/settings/pairing-coordinator.js +1 -1
  72. package/lib/settings/routes.js +45 -32
  73. package/lib/update/manager.js +1 -1
  74. package/lib/update/runner.js +1 -1
  75. package/lib/voice/transcribe.js +1 -1
  76. package/lib/ws/bridge.js +3 -3
  77. package/package.json +1 -1
  78. package/frontend/dist/assets/index-B_THfiL5.js +0 -167
@@ -0,0 +1,184 @@
1
+ 'use strict';
2
+ // lib/notify/ask-answer-service.js — ONE answer cycle for BOTH the local route
3
+ // and the federated one. There is no second path: claim (synchronous) ->
4
+ // paste to the session FROM THE STORE (never from a body) -> commit or a
5
+ // declared failure -> a qualified closure event (askId, revision, cell).
6
+ //
7
+ // Federated attempts additionally carry a durable receipt (written BEFORE the
8
+ // paste) and an idempotency contract: the same requestId with the same text
9
+ // after a timeout returns the stored state WITHOUT pasting again; the same id
10
+ // with a different text is a 409. A locked ask (delivery-unknown from a
11
+ // previous crash) refuses new pastes on EVERY path until the operator
12
+ // reconciles explicitly: no silent retries, no TTL.
13
+
14
+ const crypto = require('node:crypto');
15
+
16
+ const REQUEST_ID_RE = /^[0-9a-f-]{16,64}$/i;
17
+
18
+ function createAskAnswerService({ asks, paste, receipts, onClosure, labelPrefix = 'reply' } = {}) {
19
+ if (!asks || !paste) throw new Error('createAskAnswerService: asks e paste richiesti');
20
+ const emitClosure = typeof onClosure === 'function' ? onClosure : () => {};
21
+
22
+ function pasteText(askId, text, label) {
23
+ return `[${label} reply · ask#${askId}] ${text}`;
24
+ }
25
+
26
+ // The shared core. Returns {ok:true} or {ok:false, code, error, reason}.
27
+ async function runAnswer({ askId, text, request = null }) {
28
+ // A locked ask refuses new pastes on every path until reconciliation.
29
+ if (receipts && receipts.isBlocked(askId)) {
30
+ return { ok: false, code: 409, error: 'ask con esito incerto: richiede riconciliazione dell\'operatore', reason: 'delivery-unknown-block' };
31
+ }
32
+ // The receipt is authoritative AT THE POINT OF PASTE: an ask whose durable
33
+ // answer state is already terminal cannot produce a second paste, whichever
34
+ // path got here — the ask store alone is not enough, because a reconciliation
35
+ // can land on the receipt without closing the ask.
36
+ const answerState = receipts && typeof receipts.answerStateFor === 'function'
37
+ ? receipts.answerStateFor(askId) : null;
38
+ if (answerState === 'committed' || answerState === 'delivered') {
39
+ return { ok: false, code: 409, error: 'ask gia\' consegnato: nessun nuovo paste', reason: 'already-delivered' };
40
+ }
41
+ const claim = asks.claim(askId);
42
+ if (!claim.ok) {
43
+ // Refused BEFORE any paste: the attempt is closed here. Left `pending`, it
44
+ // would come back as delivery-unknown after a restart and block the ask
45
+ // for a paste that never happened.
46
+ if (request && receipts) receipts.finalize(request.peerId, askId, request.requestId, 'failed', `refused:${claim.reason}`);
47
+ if (claim.reason === 'unknown') return { ok: false, code: 404, error: 'ask inesistente', reason: 'unknown' };
48
+ if (claim.reason === 'dismissed') return { ok: false, code: 409, error: 'ask gia\' scartato (dismissed)', reason: 'dismissed' };
49
+ if (claim.reason === 'answering') return { ok: false, code: 409, error: 'risposta gia\' in corso da un\'altra richiesta', reason: 'answering' };
50
+ return { ok: false, code: 409, error: 'ask gia\' risposto', reason: 'answered' };
51
+ }
52
+ let pasted = false;
53
+ try {
54
+ pasted = await paste(claim.ask.session, pasteText(askId, text, labelPrefix));
55
+ } catch (_) { pasted = false; }
56
+ if (!pasted) {
57
+ asks.release(askId); // rollback: the ask stays open and contestable
58
+ if (request && receipts) receipts.finalize(request.peerId, askId, request.requestId, 'failed', 'paste-failed');
59
+ return { ok: false, code: 502, error: `paste fallito: sessione "${claim.ask.session}" non raggiungibile`, reason: 'paste-failed' };
60
+ }
61
+ const committed = asks.commit(askId, text);
62
+ if (request && receipts) receipts.finalize(request.peerId, askId, request.requestId, 'committed', null);
63
+ const finalAsk = asks.get(askId);
64
+ emitClosure('ask-answered', { askId, revision: (finalAsk && finalAsk.revision) || 1, cellSession: finalAsk && finalAsk.session });
65
+ return { ok: true, committed };
66
+ }
67
+
68
+ // LOCAL answer: validation and binding already happened in the route.
69
+ async function answerLocal({ askId, text }) {
70
+ return runAnswer({ askId, text });
71
+ }
72
+
73
+ // FEDERATED answer: the receipt lifecycle wraps the same core.
74
+ async function answerFederated({ askId, text, peerId, requestId }) {
75
+ if (!requestId || !REQUEST_ID_RE.test(String(requestId))) {
76
+ return { ok: false, code: 400, error: 'requestId non valido', reason: 'bad-request-id' };
77
+ }
78
+ if (!receipts) return { ok: false, code: 503, error: 'receipt store non disponibile', reason: 'no-receipts' };
79
+ const existing = receipts.get(peerId, askId, requestId);
80
+ const digest = receipts.digestOf(text);
81
+ if (existing) {
82
+ // Same id, same text: the stored state, never a second paste.
83
+ if (existing.digest === digest) {
84
+ return { ok: true, replay: true, state: existing.state };
85
+ }
86
+ // Same id, different text: never answer twice with diverging payloads.
87
+ return { ok: false, code: 409, error: 'stesso requestId con testo diverso', reason: 'request-conflict' };
88
+ }
89
+ // Receipt BEFORE the paste: a failed receipt write means no paste at all.
90
+ const receipt = receipts.attempt({ peerId, askId, requestId, text });
91
+ if (!receipt.ok) {
92
+ if (receipt.reason === 'blocked') {
93
+ return { ok: false, code: 409, error: 'ask con esito incerto: richiede riconciliazione dell\'operatore', reason: 'delivery-unknown-block' };
94
+ }
95
+ if (receipt.reason === 'digest-conflict') {
96
+ return { ok: false, code: 409, error: 'stesso requestId con testo diverso', reason: 'request-conflict' };
97
+ }
98
+ return { ok: false, code: 429, error: 'receipt store al cap', reason: 'receipt-cap' };
99
+ }
100
+ const out = await runAnswer({ askId, text, request: { peerId, requestId } });
101
+ if (out.ok) return { ok: true, state: 'committed' };
102
+ // Every refusal is answered as it is: a rejection BEFORE the paste (claim
103
+ // lost, ask closed, locked) finalizes the attempt as failed, and a transport
104
+ // failure does the same — the peer re-checks the status instead of blindly
105
+ // retrying.
106
+ return out;
107
+ }
108
+
109
+ // Dismiss: idempotent, refused while an answer is in flight, and refused on an
110
+ // ask whose delivery is still unknown — dismissing the card would hide an
111
+ // outcome the operator still has to reconcile.
112
+ function dismiss(askId) {
113
+ if (receipts && receipts.isBlocked(askId)) {
114
+ return { ok: false, code: 409, error: 'ask con esito incerto: richiede riconciliazione dell\'operatore', reason: 'delivery-unknown-block' };
115
+ }
116
+ const out = asks.dismiss(askId);
117
+ if (!out.ok) return out;
118
+ emitClosure('ask-dismissed', { askId, revision: (out.ask && out.ask.revision) || 1, cellSession: out.ask && out.ask.session });
119
+ return out;
120
+ }
121
+
122
+ // Operator reconciliation of a delivery-unknown attempt. The revision is a
123
+ // MANDATORY compare-and-set: a reconciliation decided on a generation that is
124
+ // already gone must not unlock anything, and the transition itself advances
125
+ // the revision so the same token cannot be replayed.
126
+ function reconcile({ askId, decision, expectedRevision }) {
127
+ if (decision !== 'mark-delivered' && decision !== 'allow-new-attempt') {
128
+ return { ok: false, code: 400, error: 'decision deve essere mark-delivered|allow-new-attempt', reason: 'bad-decision' };
129
+ }
130
+ if (!receipts) return { ok: false, code: 503, error: 'receipt store non disponibile', reason: 'no-receipts' };
131
+ const ask = asks.get(askId);
132
+ if (!ask) return { ok: false, code: 404, error: 'ask inesistente', reason: 'unknown' };
133
+ if (expectedRevision === undefined || expectedRevision === null || expectedRevision === '') {
134
+ return { ok: false, code: 400, error: 'expectedRevision obbligatoria', reason: 'revision-required' };
135
+ }
136
+ if (Number(expectedRevision) !== (ask.revision || 0)) {
137
+ return { ok: false, code: 409, error: 'revision stantia', reason: 'revision-conflict' };
138
+ }
139
+ // A paste that is still in flight is not an outcome to reconcile: its claim
140
+ // is HERS, and deciding here would release a claim mid-paste and let a
141
+ // second paste through. Refused without touching the claim.
142
+ // The window between this check and markReconciled is SYNCHRONOUS (there is
143
+ // no await in between), so no new claim can appear once the refusal is not
144
+ // taken: that is what makes the guard sufficient, not the receipt alone.
145
+ const live = receipts.reconcilableFor(askId);
146
+ if ((typeof asks.isAnswering === 'function' && asks.isAnswering(askId)) || live.live > 0) {
147
+ return {
148
+ ok: false, code: 409, reason: 'answering',
149
+ error: 'paste in corso su questa ask: riconciliazione rifiutata, il claim non si tocca',
150
+ };
151
+ }
152
+ // Nothing unresolved: `ok:true, changed:0` would be a false success.
153
+ if (live.count === 0) {
154
+ return { ok: false, code: 409, reason: 'nothing-to-reconcile', error: 'nessun esito incerto da riconciliare' };
155
+ }
156
+ const expect = { count: live.count, generation: live.generation };
157
+
158
+ // Restrictive write FIRST, permissive one last: the ask-store transition
159
+ // closes the ask (mark-delivered) or just advances the revision
160
+ // (allow-new-attempt), while the receipt keeps blocking the paste until its
161
+ // own durable write lands. A partial failure therefore never leaves a state
162
+ // where a paste is possible: the residue is "ask closed" or "receipt still
163
+ // blocked", never more permissive than the decision.
164
+ const moved = asks.markReconciled(askId, decision);
165
+ if (!moved.ok) {
166
+ return { ok: false, code: 500, error: 'transizione non scritta: stato invariato', reason: 'ask-transition-failed' };
167
+ }
168
+ const out = receipts.reconcile(askId, decision, expect);
169
+ if (!out.ok) {
170
+ return {
171
+ ok: false, code: out.reason === 'nothing-to-reconcile' || out.reason === 'generation-changed' ? 409 : 500,
172
+ reason: out.reason,
173
+ error: decision === 'mark-delivered'
174
+ ? 'ricevuta non riconciliata: ask comunque chiuso (nessun paste possibile)'
175
+ : 'ricevuta non riconciliata: blocco ancora attivo (nessun paste possibile)',
176
+ };
177
+ }
178
+ return { ok: true, changed: out.changed, revision: (moved.ask && moved.ask.revision) || 0 };
179
+ }
180
+
181
+ return { answerLocal, answerFederated, dismiss, reconcile, pasteText };
182
+ }
183
+
184
+ module.exports = { createAskAnswerService, REQUEST_ID_RE };
@@ -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 };
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- // Store degli "ask" (domande cella→operatore) — MCP bridge, design §2c.
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` (F-02).
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 };