@mmmbuto/nexuscrew 0.9.57 → 0.9.59

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.
@@ -14,12 +14,62 @@
14
14
  // refused locally until verification resolves the attempt.
15
15
 
16
16
  const crypto = require('node:crypto');
17
+ const { NODE_NAME_RE } = require('../nodes/store.js');
17
18
  const { probeReverseSlot } = require('../nodes/reverse-slot-proof.js');
18
- const IDLE_MS = 15000;
19
+ const { askSlotVerifier, binding } = require('../nodes/ask-slot-verifier.js');
20
+ const RELAY_DEADLINE_MS = 8000;
21
+ const OWNER_REASONS = new Set([
22
+ 'reverse-slot-unverified', 'peer-unknown', 'events-disabled', 'grant-required:ask-action',
23
+ 'peer-visibility', 'origin-visibility', 'federated-origin-required', 'hop-chain',
24
+ 'store-unavailable', 'unknown-peer', 'unknown-trust', 'no-delivering-peer',
25
+ 'no-node-identity', 'bad-visited', 'bad-hop', 'self-hop', 'bad-attested-cell',
26
+ 'origin-mismatch', 'fleet-unavailable', 'cell-not-active', 'answer-rate',
27
+ 'unknown', 'dismissed', 'answering', 'answered', 'store-unreadable',
28
+ 'delivery-unknown-block', 'already-delivered', 'paste-failed', 'bad-request-id',
29
+ 'no-receipts', 'request-conflict', 'receipt-cap', 'federation-peer-unreachable',
30
+ 'federation-peer-timeout', 'federation-body-consumed', 'generation-mismatch', 'invalid-generation', 'unsupported',
31
+ ]);
32
+ function safeReason(reason) { return OWNER_REASONS.has(reason) ? reason : undefined; }
19
33
 
20
- function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = () => {}, probeReverseSlotImpl = probeReverseSlot } = {}) {
34
+ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = () => {}, probeReverseSlotImpl = probeReverseSlot, peers = null, localPort = () => 0, localToken = () => '', nodesPath = null, availabilityBinding = () => null, slotVerifier = askSlotVerifier } = {}) {
21
35
  // (ownerId|askId|requestId) -> {state: 'sent'|'committed'|'failed'|'uncertain', requestId}
36
+ const channelObservations = new Map();
22
37
  const attempts = new Map();
38
+ const answerFlights = new Map();
39
+
40
+ const proofScope = nodesPath || loadStore;
41
+ function deadlineResult(op, args, capability) {
42
+ if (capability) return { ownerId: args.ownerId, askId: args.askId, canReply: false, status: 'unreachable', reason: 'relay-deadline' };
43
+ if (op.answer && op.delivered && args.rid) {
44
+ return { ok: false, code: 200, uncertain: true, requestId: args.rid, reason: 'delivery-unknown', error: 'esito incerto: verifica lo stato prima di rispondere' };
45
+ }
46
+ return { ok: false, code: 504, reason: 'relay-deadline', error: 'relay deadline' };
47
+ }
48
+ async function operation(args, work, capability = false, answer = false) {
49
+ const controller = new AbortController();
50
+ const op = { controller, answer, delivered: false, expired: false, proofKey: null, proofBinding: null };
51
+ let rejectAbort;
52
+ const aborted = new Promise((_resolve, reject) => { rejectAbort = () => reject(new Error('relay deadline')); });
53
+ controller.signal.addEventListener('abort', rejectAbort, { once: true });
54
+ op.active = () => !controller.signal.aborted;
55
+ op.check = () => { if (!op.active()) throw new Error('relay deadline'); };
56
+ op.wait = promise => Promise.race([promise, aborted]);
57
+ const timer = setTimeout(() => { op.expired = true; controller.abort(); }, RELAY_DEADLINE_MS);
58
+ try { return await Promise.race([Promise.resolve().then(() => work(op)), aborted]); }
59
+ catch (_) {
60
+ slotVerifier.invalidate(op.proofKey);
61
+ if (op.answer && op.delivered && args.rid) {
62
+ const entry = attempts.get(attemptKey(args.ownerId, args.askId, args.rid));
63
+ if (entry) entry.state = 'uncertain';
64
+ }
65
+ if (op.expired) return deadlineResult(op, args, capability);
66
+ return capability
67
+ ? { ownerId: args.ownerId, askId: args.askId, canReply: false, status: 'unreachable' }
68
+ : { ok: false, code: 502, error: 'owner unreachable' };
69
+ } finally {
70
+ clearTimeout(timer); controller.signal.removeEventListener('abort', rejectAbort); controller.abort();
71
+ }
72
+ }
23
73
 
24
74
  function requestId() { return crypto.randomUUID(); }
25
75
 
@@ -28,52 +78,103 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
28
78
  // attivo. Per gli inbound la porta dello slot DEVE essere verificata con la
29
79
  // stessa sonda dei terminali (secret del peer + instanceId atteso): nessun
30
80
  // fallback sulla localPort senza proof.
31
- function candidateOwner(ownerId) {
32
- const store = loadStore();
81
+ async function candidateOwner(ownerId, op) {
82
+ let store = loadStore();
33
83
  if (!store) return null;
84
+ let routed = null;
85
+ if (typeof peers === 'function') {
86
+ let inventory;
87
+ try { inventory = await op.wait(peers()); } catch (_) { op.check(); return null; }
88
+ op.check();
89
+ const matches = (Array.isArray(inventory) ? inventory : [])
90
+ .filter((p) => p && (p.nodeId === ownerId || p.instanceId === ownerId));
91
+ if (matches.length !== 1 || matches[0].stale === true) return null;
92
+ const route = matches[0].route;
93
+ if (!Array.isArray(route) || !route.length || route.length > 4
94
+ || route.some((name) => typeof name !== 'string' || !NODE_NAME_RE.test(name))
95
+ || new Set(route).size !== route.length) return null;
96
+ routed = [...route];
97
+ }
98
+ op.check();
99
+ store = loadStore();
100
+ if (!store) return null;
101
+ slotVerifier.sync(store, proofScope);
34
102
  const nodes = store.nodes || [];
35
- const out = nodes.find((n) => n && n.direction === 'outbound'
36
- && n.nodeId === ownerId && n.token && n.localPort);
37
- if (out) return { node: out, forwardPort: out.localPort, forwardToken: out.token, kind: 'outbound' };
103
+ const direct = nodes.filter((n) => n && n.nodeId === ownerId);
104
+ if (direct.length > 1) return null;
105
+ const out = direct.find((n) => n.direction === 'outbound' && n.token && n.localPort);
106
+ if (out) return { node: out, forwardPort: out.localPort, forwardToken: out.token, kind: 'outbound', route: routed };
107
+ if (!direct.length && routed) return { node: null, kind: 'routed', route: routed };
38
108
  const inbound = nodes.find((n) => n && n.direction === 'inbound'
39
109
  && n.shared === true && n.nodeId === ownerId
40
- && (n.token || n.acceptToken)
110
+ && n.token
41
111
  && n.askReplyAccess === true && n.eventsAccess === true
42
112
  && n.cellVisibility !== 'none');
43
113
  if (!inbound) return null;
44
- const forwardToken = inbound.token || inbound.acceptToken;
114
+ const forwardToken = inbound.token;
45
115
  const pool = inbound.reversePool;
46
116
  const slot = pool && Array.isArray(pool.slots) ? pool.slots[pool.activeSlot] : null;
47
117
  if (!slot || !Number.isInteger(slot.port)) {
48
- return { node: inbound, forwardPort: null, forwardToken, kind: 'inbound-unverified' };
118
+ return { node: inbound, forwardPort: null, forwardToken, kind: 'inbound-unverified', route: routed };
49
119
  }
50
- return { node: inbound, forwardPort: slot.port, forwardToken, kind: 'inbound' };
120
+ return { node: inbound, forwardPort: slot.port, forwardToken, kind: 'inbound', route: routed };
51
121
  }
52
122
 
53
123
  function ownerUrl(port, path) {
54
124
  return `http://127.0.0.1:${port}/federation/route/_/${path.replace(/^\//, '')}`;
55
125
  }
56
126
 
127
+ async function forward(candidate, path, { method = 'GET', body, expectedTs, op } = {}) {
128
+ op.check();
129
+ const st = loadStore();
130
+ slotVerifier.sync(st, proofScope);
131
+ if (candidate.kind === 'inbound' && !candidate.route) {
132
+ const current = st && (st.nodes || []).find(node => node.name === candidate.node.name);
133
+ const selected = binding(st, current);
134
+ if (!selected || selected.fingerprint !== op.proofBinding) {
135
+ slotVerifier.invalidate(op.proofKey);
136
+ const error = new Error('reverse slot changed'); error.reason = 'reverse-slot-unverified'; throw error;
137
+ }
138
+ }
139
+ const routed = candidate.route;
140
+ const url = routed
141
+ ? `http://127.0.0.1:${localPort()}/api/route/${routed.map(encodeURIComponent).join('/')}/_${path}`
142
+ : ownerUrl(candidate.forwardPort, path);
143
+ const headers = routed ? { authorization: `Bearer ${localToken()}` }
144
+ : { authorization: `Bearer ${candidate.forwardToken}`, 'x-nexuscrew-visited': st && st.nodeId || '' };
145
+ try {
146
+ op.check(); op.delivered = true;
147
+ const r = await op.wait(fetchImpl(url, { method, headers: { ...headers, 'content-type': 'application/json',
148
+ ...(expectedTs === undefined ? {} : { 'x-nexuscrew-ask-ts': String(expectedTs) }) },
149
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: op.controller.signal }));
150
+ op.check();
151
+ const payload = await op.wait(r.json().catch(error => {
152
+ if ([403, 404, 501].includes(r.status)) return {};
153
+ throw error;
154
+ }));
155
+ op.check();
156
+ if (!r.ok) slotVerifier.invalidate(op.proofKey);
157
+ return { r, body: { ...payload, reason: safeReason(payload && payload.reason) } };
158
+ } catch (error) { slotVerifier.invalidate(op.proofKey); throw error; }
159
+ }
160
+
57
161
  // Preflight del canale reverse per un peer inbound: rifiuto NETTO se lo slot
58
162
  // non e' verificato (non e' un esito incerto: il forward non e' mai partito).
59
- async function verifyInboundChannel(candidate) {
60
- const { node, forwardPort } = candidate;
61
- const pool = node.reversePool;
62
- const slot = pool && Array.isArray(pool.slots) ? pool.slots[pool.activeSlot] : null;
63
- const proof = await probeReverseSlotImpl({
64
- port: forwardPort, secret: node.token,
65
- expected: {
66
- remotePort: forwardPort,
67
- generation: pool && Number.isInteger(pool.activeGeneration) ? pool.activeGeneration : undefined,
68
- instanceId: node.nodeId,
69
- },
70
- });
71
- if (proof && proof.owned === true) return { ok: true };
72
- return {
73
- ok: false, code: 404,
74
- error: 'canale reverse non verificato: risposta non inoltrata',
75
- reason: 'reverse-slot-unverified',
76
- };
163
+ async function verifyInboundChannel(candidate, op) {
164
+ // REST routes prove the slot at their inbound proxy hop, never twice.
165
+ if (candidate.route) return { ok: true };
166
+ op.check();
167
+ const st = loadStore();
168
+ const proof = await op.wait(slotVerifier.verify({ store: st, peer: candidate.node, loadStore,
169
+ scope: proofScope, signal: op.controller.signal, probeImpl: probeReverseSlotImpl }));
170
+ op.check();
171
+ if (proof && proof.owned === true) {
172
+ op.proofKey = proof.key;
173
+ const selected = binding(st, candidate.node);
174
+ op.proofBinding = selected && selected.fingerprint;
175
+ return { ok: true };
176
+ }
177
+ return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
77
178
  }
78
179
 
79
180
  function attemptKey(ownerId, askId, rid) { return `${ownerId}|${askId}|${rid}`; }
@@ -99,17 +200,17 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
99
200
  return sent.length ? sent[sent.length - 1].requestId : null;
100
201
  }
101
202
 
102
- async function relayAnswer({ ownerId, askId, text, rid: providedRid } = {}) {
203
+ async function relayAnswerOnce({ ownerId, askId, text, rid: providedRid } = {}, op) {
103
204
  if (!ownerId || !askId || typeof text !== 'string' || !text.trim()) {
104
205
  return { ok: false, code: 400, error: 'ownerId, askId e text richiesti' };
105
206
  }
106
- const candidate = candidateOwner(ownerId);
207
+ const candidate = await candidateOwner(ownerId, op);
107
208
  if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
108
209
  if (candidate.kind === 'inbound-unverified') {
109
210
  return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
110
211
  }
111
212
  if (candidate.kind === 'inbound') {
112
- const gate = await verifyInboundChannel(candidate);
213
+ const gate = await verifyInboundChannel(candidate, op);
113
214
  if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
114
215
  }
115
216
  const peer = candidate.node;
@@ -123,25 +224,22 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
123
224
  const rid = providedRid || requestId();
124
225
  const key = attemptKey(ownerId, askId, rid);
125
226
  const store = loadStore();
126
- attempts.set(key, { requestId: rid, state: 'sent', askId, ownerId });
127
- const ctrl = new AbortController();
128
- const timer = setTimeout(() => ctrl.abort(), IDLE_MS);
227
+ attempts.set(key, { requestId: rid, state: 'sent', askId, ownerId, digest: crypto.createHash('sha256').update(text).digest('hex') });
129
228
  try {
130
- const r = await fetchImpl(ownerUrl(candidate.forwardPort, `/event-feed/asks/${askId}/answer`), {
131
- method: 'POST',
132
- headers: {
133
- authorization: `Bearer ${candidate.forwardToken}`,
134
- 'content-type': 'application/json',
135
- 'x-nexuscrew-visited': store.nodeId,
136
- },
137
- body: JSON.stringify({ text, requestId: rid }),
138
- signal: ctrl.signal,
229
+ const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}/answer`, {
230
+ method: 'POST', body: { text, requestId: rid }, op,
139
231
  });
140
- const body = await r.json().catch(() => ({}));
232
+ op.check();
141
233
  if (r.status === 200) {
142
- const state = body.status === 'answered' ? 'committed' : body.status || 'committed';
234
+ const state = body.status === 'answered' ? 'committed' : body.status;
235
+ if (!['committed', 'failed'].includes(state)) {
236
+ attempts.get(key).state = 'uncertain';
237
+ return { ok: false, code: 200, uncertain: true, requestId: rid, reason: 'delivery-unknown' };
238
+ }
143
239
  attempts.get(key).state = body.replay ? (body.state || state) : state;
144
- return { ok: true, status: attempts.get(key).state, requestId: rid };
240
+ return { ok: true, status: attempts.get(key).state, requestId: rid,
241
+ ...(body.ownerId ? { ownerId: body.ownerId } : {}), ...(body.askId ? { askId: body.askId } : {}),
242
+ ...(body.actor ? { actor: body.actor } : {}), ...(body.receipt ? { receipt: body.receipt } : {}) };
145
243
  }
146
244
  const entry = attempts.get(key);
147
245
  // 409 request-conflict / answering are definitive; anything else that
@@ -155,7 +253,7 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
155
253
  originalRequestId: originalSentRequestId(ownerId, askId, rid), requestId: rid,
156
254
  error: 'esito incerto: bloccato da un invio precedente' };
157
255
  }
158
- return { ok: false, code: 409, error: body.error || 'conflict', reason: body.reason };
256
+ return { ok: false, code: 409, error: 'conflict', reason: body.reason };
159
257
  }
160
258
  if (r.status === 502) {
161
259
  if (entry) entry.state = 'uncertain';
@@ -163,75 +261,164 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
163
261
  error: 'esito incerto: verifica lo stato prima di rispondere' };
164
262
  }
165
263
  if (entry) entry.state = 'failed';
166
- return { ok: false, code: r.status, error: body.error || `HTTP ${r.status}`, reason: body.reason };
167
- } catch (_) {
264
+ return { ok: false, code: r.status, error: `HTTP ${r.status}`, reason: body.reason };
265
+ } catch (error) {
266
+ if (!op.active()) return deadlineResult(op, { ownerId, askId, rid }, false);
267
+ if (!op.delivered) {
268
+ const entry = attempts.get(key); if (entry) entry.state = 'failed';
269
+ return { ok: false, code: 404, reason: error.reason || 'reverse-slot-unverified', error: 'risposta non inoltrata' };
270
+ }
168
271
  // The request may or may not have pasted: uncertain, never retried.
169
272
  const entry = attempts.get(key);
170
273
  if (entry) entry.state = 'uncertain';
171
274
  return { ok: false, code: 200, uncertain: true, reason: 'delivery-unknown', requestId: rid,
172
275
  error: 'esito incerto: verifica lo stato prima di rispondere' };
173
- } finally {
174
- clearTimeout(timer);
175
276
  }
176
277
  }
177
278
 
279
+ async function relayAnswer(args = {}) {
280
+ const rid = args.rid || requestId();
281
+ const key = attemptKey(args.ownerId, args.askId, rid);
282
+ const old = attempts.get(key);
283
+ const digest = crypto.createHash('sha256').update(String(args.text || '')).digest('hex');
284
+ if (old && old.digest !== digest) return { ok: false, code: 409, reason: 'request-conflict', error: 'stesso requestId con testo diverso' };
285
+ const pending = answerFlights.get(key);
286
+ if (pending && pending.digest !== digest) return { ok: false, code: 409, reason: 'request-conflict', error: 'stesso requestId con testo diverso' };
287
+ if (pending) return pending.promise;
288
+ const flight = operation({ ...args, rid }, op => relayAnswerOnce({ ...args, rid }, op), false, true);
289
+ const slot = { promise: flight, digest };
290
+ answerFlights.set(key, slot);
291
+ try { return await flight; } finally { if (answerFlights.get(key) === slot) answerFlights.delete(key); }
292
+ }
293
+
178
294
  // Remote dismiss: same resolution rules as the answer (DELETE on the owner).
179
- async function relayDismiss({ ownerId, askId } = {}) {
180
- const candidate = candidateOwner(ownerId);
295
+ function retryAfterMs(response) {
296
+ const value = response.headers?.get?.('retry-after');
297
+ if (typeof value !== 'string') return 0;
298
+ const seconds = /^\d+(?:\.\d+)?$/.test(value.trim()) ? Number(value) : null;
299
+ const delay = seconds === null ? Date.parse(value) - now() : seconds * 1000;
300
+ return Number.isFinite(delay) && delay > 0 ? delay : 0;
301
+ }
302
+
303
+ async function relayDismissOnce({ ownerId, askId, expectedTs } = {}, op) {
304
+ const candidate = await candidateOwner(ownerId, op);
181
305
  if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
182
306
  if (candidate.kind === 'inbound-unverified') {
183
307
  return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
184
308
  }
185
309
  if (candidate.kind === 'inbound') {
186
- const gate = await verifyInboundChannel(candidate);
310
+ const gate = await verifyInboundChannel(candidate, op);
187
311
  if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
188
312
  }
189
313
  const peer = candidate.node;
190
314
  const store = loadStore();
191
315
  try {
192
- const r = await fetchImpl(ownerUrl(candidate.forwardPort, `/event-feed/asks/${askId}`), {
193
- method: 'DELETE',
194
- headers: { authorization: `Bearer ${candidate.forwardToken}`, 'x-nexuscrew-visited': store ? store.nodeId : '' },
195
- });
196
- const body = await r.json().catch(() => ({}));
197
- if (!r.ok) return { ok: false, code: r.status, error: body.error || `HTTP ${r.status}` };
198
- return { ok: true };
199
- } catch (e) { return { ok: false, error: String((e && e.message) || e) }; }
316
+ const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}`, { method: 'DELETE', expectedTs, op });
317
+ if (!r.ok) return { ok: false, code: r.status, error: 'owner refused', reason: body.reason, retryAfterMs: retryAfterMs(r) };
318
+ return { ok: true, ...(body.outcome ? { outcome: body.outcome } : {}) };
319
+ } catch (e) { if (!op.active()) return deadlineResult(op, { ownerId, askId }, false); return { ok: false, code: 502, reason: safeReason(e.reason), error: 'owner unreachable' }; }
200
320
  }
201
321
 
202
- async function verifyStatus({ ownerId, askId, requestId: rid }) {
203
- const candidate = candidateOwner(ownerId);
322
+ async function verifyStatusOnce({ ownerId, askId, requestId: rid }, op) {
323
+ const candidate = await candidateOwner(ownerId, op);
204
324
  if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
205
325
  if (candidate.kind === 'inbound-unverified') {
206
326
  return { ok: false, code: 404, error: 'canale reverse non verificato: risposta non inoltrata', reason: 'reverse-slot-unverified' };
207
327
  }
208
328
  if (candidate.kind === 'inbound') {
209
- const gate = await verifyInboundChannel(candidate);
329
+ const gate = await verifyInboundChannel(candidate, op);
210
330
  if (!gate.ok) return { ok: false, code: gate.code, error: gate.error, reason: gate.reason };
211
331
  }
212
332
  const peer = candidate.node;
213
333
  const store = loadStore();
214
334
  try {
215
- const r = await fetchImpl(ownerUrl(candidate.forwardPort, `/event-feed/asks/${askId}/requests/${rid}`), {
216
- headers: { authorization: `Bearer ${candidate.forwardToken}`, 'x-nexuscrew-visited': store ? store.nodeId : '' },
217
- });
218
- if (!r.ok) return { ok: false, code: r.status };
219
- const body = await r.json();
335
+ const { r, body } = await forward(candidate,
336
+ `/event-feed/asks/${encodeURIComponent(askId)}/requests/${encodeURIComponent(rid)}`, { op });
337
+ if (!r.ok) return { ok: false, code: r.status, reason: body.reason, error: 'owner refused' };
338
+ op.check();
220
339
  const key = attemptKey(ownerId, askId, rid);
221
340
  if (attempts.has(key) && (body.state === 'committed' || body.state === 'failed')) {
222
341
  attempts.get(key).state = body.state;
223
342
  }
224
- return { ok: true, state: body.state || 'unknown' };
343
+ return { ok: true, state: body.state || 'unknown',
344
+ ...(body.ownerId ? { ownerId: body.ownerId } : {}), ...(body.askId ? { askId: body.askId } : {}),
345
+ ...(body.actor ? { actor: body.actor } : {}), ...(body.receipt ? { receipt: body.receipt } : {}) };
225
346
  } catch (e) {
226
- return { ok: false, error: String((e && e.message) || e) };
347
+ if (!op.active()) return deadlineResult(op, { ownerId, askId }, false);
348
+ return { ok: false, code: 502, reason: safeReason(e.reason), error: 'owner unreachable' };
349
+ }
350
+ }
351
+
352
+ async function replyCapabilityOnce({ ownerId, askId } = {}, op) {
353
+ const denied = (status, reason) => ({ ownerId, askId, canReply: false, status, ...(reason ? { reason } : {}) });
354
+ const candidate = await candidateOwner(ownerId, op);
355
+ if (!candidate) return denied('unreachable', 'owner-unknown');
356
+ if (candidate.kind === 'inbound-unverified') { op.channelUnavailable = true; return denied('unreachable', 'reverse-slot-unverified'); }
357
+ if (candidate.kind === 'inbound') {
358
+ const gate = await verifyInboundChannel(candidate, op);
359
+ if (!gate.ok) { op.channelUnavailable = true; return denied('unreachable', gate.reason); }
360
+ }
361
+ try {
362
+ const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}/capability`, { op });
363
+ if (['reverse-slot-unverified', 'federation-peer-unreachable', 'federation-peer-timeout'].includes(body.reason)) { op.channelUnavailable = true; return denied('unreachable', body.reason); }
364
+ if (r.status === 404 || r.status === 501) return denied('unsupported', body.reason);
365
+ if (r.status === 403) return denied('denied', body.reason);
366
+ if (!r.ok || body.ownerId !== ownerId || body.askId !== askId || typeof body.canReply !== 'boolean'
367
+ || !['open', 'denied', 'answered', 'dismissed'].includes(body.status)
368
+ || (body.canReply && body.status !== 'open')) return denied('unreachable');
369
+ return { ownerId, askId, canReply: body.canReply, status: body.status };
370
+ } catch (_) { op.channelUnavailable = true; return denied('unreachable', op.active() ? undefined : 'relay-deadline'); }
371
+ }
372
+
373
+ async function inspectDismissalOnce({ ownerId, askId } = {}, op) {
374
+ const candidate = await candidateOwner(ownerId, op);
375
+ if (!candidate) return { ok: false, code: 404, reason: 'owner-unknown' };
376
+ if (candidate.kind === 'inbound-unverified') return { ok: false, code: 404, reason: 'reverse-slot-unverified' };
377
+ if (candidate.kind === 'inbound') {
378
+ const proof = await verifyInboundChannel(candidate, op);
379
+ if (!proof.ok) return proof;
380
+ }
381
+ const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}/capability`, { op });
382
+ if (!r.ok) return { ok: false, code: r.status, reason: body.reason || 'unknown', retryAfterMs: retryAfterMs(r) };
383
+ if (body.ownerId !== ownerId || body.askId !== askId || !['open', 'answered', 'dismissed'].includes(body.status)) {
384
+ return { ok: false, code: 403, reason: 'unknown' };
385
+ }
386
+ if (body.generationPrecondition !== true) return { ok: false, code: 501, reason: 'unsupported' };
387
+ const ask = body.ask;
388
+ if (!ask || ask.id !== askId || !Number.isSafeInteger(ask.ts) || ask.ts <= 0
389
+ || typeof ask.question !== 'string' || typeof ask.session !== 'string'
390
+ || (ask.options !== undefined && (!Array.isArray(ask.options) || ask.options.some(option => typeof option !== 'string')))) {
391
+ return { ok: false, code: 409, reason: 'generation-mismatch' };
227
392
  }
393
+ return { ok: true, status: body.status, ask, generationPrecondition: true };
394
+ }
395
+ function inspectDismissal(args = {}) { return operation(args, op => inspectDismissalOnce(args, op)); }
396
+
397
+ function relayDismiss(args = {}) { return operation(args, op => relayDismissOnce(args, op)); }
398
+ function verifyStatus(args = {}) { return operation(args, op => verifyStatusOnce(args, op)); }
399
+ function channelBinding(ownerId) {
400
+ const store = loadStore();
401
+ // Kept only in process memory; credentials never enter the dismissal store.
402
+ return crypto.createHash('sha256').update(JSON.stringify([store, availabilityBinding(ownerId)])).digest('hex');
403
+ }
404
+ function channelState(ownerId) {
405
+ const observation = channelObservations.get(ownerId);
406
+ return { unavailable: !!(observation && now() - observation.at < 30000 && observation.binding === channelBinding(ownerId) && observation.unavailable) };
407
+ }
408
+ function replyCapability(args = {}) {
409
+ const initial = channelBinding(args.ownerId);
410
+ return operation(args, async op => {
411
+ const result = await replyCapabilityOnce(args, op);
412
+ channelObservations.set(args.ownerId, { at: now(), binding: initial, unavailable: op.channelUnavailable === true });
413
+ return result;
414
+ }, true);
228
415
  }
229
416
 
230
417
  function state() {
231
418
  return [...attempts.entries()].map(([key, a]) => ({ key, ...a }));
232
419
  }
233
420
 
234
- return { relayAnswer, relayDismiss, verifyStatus, state, isUncertain, requestId };
421
+ return { channelState, inspectDismissal, relayAnswer, relayDismiss, verifyStatus, replyCapability, state, isUncertain, requestId };
235
422
  }
236
423
 
237
424
  module.exports = { createAskRelay };