@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/docs/LIVE_PROMPT.md +1 -1
  3. package/docs/NOTIFICATIONS.md +37 -0
  4. package/docs/OPERATIONS.md +20 -0
  5. package/frontend/dist/assets/index-BbcjoZS0.js +167 -0
  6. package/frontend/dist/assets/index-DqCfZFGi.css +32 -0
  7. package/frontend/dist/index.html +2 -2
  8. package/frontend/dist/sw.js +91 -10
  9. package/frontend/dist/version.json +1 -1
  10. package/lib/audio/groups.js +1 -1
  11. package/lib/auth/token.js +4 -4
  12. package/lib/cli/commands.js +70 -15
  13. package/lib/cli/doctor.js +5 -5
  14. package/lib/cli/fleet-service.js +7 -7
  15. package/lib/cli/init.js +55 -31
  16. package/lib/cli/pidfile.js +1 -1
  17. package/lib/cli/service.js +124 -8
  18. package/lib/cli/stable-alias.js +4 -4
  19. package/lib/cli/url.js +1 -1
  20. package/lib/config.js +36 -6
  21. package/lib/files/routes.js +1 -1
  22. package/lib/fleet/boot.js +2 -2
  23. package/lib/fleet/builtin.js +15 -15
  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 +10 -10
  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 +35 -35
  37. package/lib/fleet/prompt-delivery.js +13 -13
  38. package/lib/fleet/routes.js +15 -10
  39. package/lib/fleet/runtime.js +24 -24
  40. package/lib/live-host/bridge.js +53 -43
  41. package/lib/live-host/federation.js +97 -0
  42. package/lib/live-host/routes.js +166 -8
  43. package/lib/live-host/store.js +44 -6
  44. package/lib/mcp/server.js +14 -14
  45. package/lib/mcp/tools.js +5 -5
  46. package/lib/nodes/access-presets.js +258 -0
  47. package/lib/nodes/commands.js +198 -4
  48. package/lib/nodes/store.js +152 -9
  49. package/lib/nodes/tunnel.js +7 -7
  50. package/lib/notify/ask-answer-service.js +184 -0
  51. package/lib/notify/ask-receipts.js +197 -0
  52. package/lib/notify/ask-relay.js +152 -0
  53. package/lib/notify/asks.js +28 -3
  54. package/lib/notify/event-feed-acl.js +95 -0
  55. package/lib/notify/event-feed-asks-routes.js +144 -0
  56. package/lib/notify/event-feed-client.js +434 -0
  57. package/lib/notify/event-feed-history.js +115 -0
  58. package/lib/notify/event-feed-producers.js +175 -0
  59. package/lib/notify/event-feed-routes.js +254 -0
  60. package/lib/notify/event-feed.js +328 -0
  61. package/lib/notify/events.js +1 -1
  62. package/lib/notify/notifier.js +3 -3
  63. package/lib/notify/push-relay.js +240 -0
  64. package/lib/notify/push.js +8 -8
  65. package/lib/notify/routes.js +62 -23
  66. package/lib/proxy/federation.js +132 -12
  67. package/lib/proxy/node-proxy.js +13 -13
  68. package/lib/proxy/panel-auth.js +4 -4
  69. package/lib/proxy/panel-proxy.js +2 -2
  70. package/lib/proxy/resource-acl.js +145 -0
  71. package/lib/server.js +396 -40
  72. package/lib/settings/pairing-coordinator.js +1 -1
  73. package/lib/settings/routes.js +45 -32
  74. package/lib/update/manager.js +1 -1
  75. package/lib/update/runner.js +1 -1
  76. package/lib/voice/transcribe.js +1 -1
  77. package/lib/ws/bridge.js +3 -3
  78. package/package.json +1 -1
  79. package/skills/live/SKILL.md +12 -0
  80. package/frontend/dist/assets/index-CWra6B79.js +0 -167
  81. 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 };
@@ -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 };
@@ -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 };