@cello-protocol/daemon 0.0.83 → 0.0.85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +219 -80
- package/dist/daemon.js.map +1 -1
- package/dist/outbound-sessions.d.ts.map +1 -1
- package/dist/outbound-sessions.js +20 -2
- package/dist/outbound-sessions.js.map +1 -1
- package/dist/session-node-manager.d.ts +24 -0
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +70 -0
- package/dist/session-node-manager.js.map +1 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +2 -0
- package/dist/vocabulary.js.map +1 -1
- package/package.json +5 -5
package/dist/daemon.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH,OAAO,KAAK,EACV,YAAY,EACZ,oBAAoB,EAKrB,MAAM,YAAY,CAAC;AAMpB,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAM/D,OAAO,EAAoD,KAAK,SAAS,EAAE,MAAM,2BAA2B,CAAC;
|
|
1
|
+
{"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH,OAAO,KAAK,EACV,YAAY,EACZ,oBAAoB,EAKrB,MAAM,YAAY,CAAC;AAMpB,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAM/D,OAAO,EAAoD,KAAK,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAiB7G,OAAO,KAAK,EAAE,mBAAmB,EAAE,iBAAiB,EAAsB,MAAM,2BAA2B,CAAC;AAO5G,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAIlE,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAkBnF,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAQlD,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,SAAS,IAAI,oBAAoB,CAAC;IAClC;;;;OAIG;IACH,qBAAqB,IAAI,kBAAkB,CAAC;IAC5C;;;;OAIG;IACH,oBAAoB,IAAI,kBAAkB,CAAC;IAC3C;;;OAGG;IACH,iBAAiB,IAAI,eAAe,CAAC;IACrC;;OAEG;IACH,eAAe,IAAI,YAAY,CAAC;CACjC;AAYD,qBAAa,4BAA6B,YAAW,mBAAmB;IAChE,UAAU,CAAC,MAAM,EAAE,iBAAiB;CA8B3C;AAID,eAAO,MAAM,sBAAsB,QAAsB,CAAC;AAE1D;;;;;;;;;;GAUG;AACH,wBAAsB,WAAW,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAS7E"}
|
package/dist/daemon.js
CHANGED
|
@@ -49,7 +49,7 @@ import { composeSealedSubmission, sendSealedSubmission } from "./signal-submissi
|
|
|
49
49
|
* operator writes, it is that an UNBOUNDED string reaches a signer, a sealer and a transport, and
|
|
50
50
|
* fails in the transport where the error names the wrong subsystem.
|
|
51
51
|
*/
|
|
52
|
-
const
|
|
52
|
+
const MAX_SUBMISSION_BODY_CHARS = 4000;
|
|
53
53
|
// CELLO-M7-MSG-001 (AC-013/AC-018): the single application content-size cap, enforced
|
|
54
54
|
// at the send point here (the receive point lives in the transport content decode).
|
|
55
55
|
import { sealParkEnvelope } from "./park-envelope.js";
|
|
@@ -1782,6 +1782,200 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1782
1782
|
}
|
|
1783
1783
|
return { ok: true, name, pubkey: rec.pubkey };
|
|
1784
1784
|
};
|
|
1785
|
+
/**
|
|
1786
|
+
* Compose → seal → send ONE submission on behalf of the selected agent, applying every guard that
|
|
1787
|
+
* must hold for any of them.
|
|
1788
|
+
*
|
|
1789
|
+
* Extracted because `refuse` and `issue` are the same journey with a different `op`, and a second
|
|
1790
|
+
* hand-written copy is how two paths that must agree stop agreeing. The guards are the point: an
|
|
1791
|
+
* agent that is not started must not be brought online by a side effect, an unbounded body must
|
|
1792
|
+
* not reach the transport, and the CAUSE of a refusal must survive to the operator. Duplicating
|
|
1793
|
+
* those means the next verb gets whichever subset its author remembered.
|
|
1794
|
+
*
|
|
1795
|
+
* INV-ATTRIBUTION holds BY CONSTRUCTION, and it did not before: this used to take the resolved
|
|
1796
|
+
* `sel` as a parameter, which is exactly a parameter through which a caller could name a different
|
|
1797
|
+
* identity. Both call sites happened to pass the right one, so the invariant held by CONVENTION
|
|
1798
|
+
* while the comment claimed structure — and the test that "pinned" it asserted the absence of two
|
|
1799
|
+
* identifiers that had never existed, so it could not fail. It now takes `connectionId` and
|
|
1800
|
+
* resolves the selection itself. There is no identity input left to get wrong.
|
|
1801
|
+
*/
|
|
1802
|
+
async function submitForAgent(opts) {
|
|
1803
|
+
const { context } = opts;
|
|
1804
|
+
const resolved = resolveSelectedAgent(opts.connectionId);
|
|
1805
|
+
if (!resolved.ok)
|
|
1806
|
+
return { queued: false, reason: resolved.reason, guidance: `${context} ${resolved.guidance}` };
|
|
1807
|
+
const sel = resolved;
|
|
1808
|
+
if (opts.body.length > MAX_SUBMISSION_BODY_CHARS) {
|
|
1809
|
+
return { queued: false, reason: "message_too_long",
|
|
1810
|
+
guidance: `${context} it is ${opts.body.length} characters and the limit is ${MAX_SUBMISSION_BODY_CHARS}.` };
|
|
1811
|
+
}
|
|
1812
|
+
// `getAgentSignaling` is NOT a getter — for an agent with no manager it CONSTRUCTS one, which
|
|
1813
|
+
// dials and authenticates to the directory immediately and installs an unbounded reconnect loop.
|
|
1814
|
+
// Calling it on a stopped agent would silently bring it online: the directory would route
|
|
1815
|
+
// sessions to it while no standing receiver exists (`standing_receiver_unavailable`), and
|
|
1816
|
+
// `cello status` would still report it offline.
|
|
1817
|
+
if (!onlineAgents.has(sel.name)) {
|
|
1818
|
+
return { queued: false, reason: "agent_offline",
|
|
1819
|
+
guidance: `${context} agent '${sel.name}' is not started. Run cello_start_agent and try again — re-sending is safe, the submission id is derived from the content.` };
|
|
1820
|
+
}
|
|
1821
|
+
const kp = keyProviders.get(sel.name);
|
|
1822
|
+
if (!kp) {
|
|
1823
|
+
logger.warn("signal.submission.refused", { agentName: sel.name, reason: "key_provider_absent", op: opts.op });
|
|
1824
|
+
return { queued: false, reason: "key_provider_absent",
|
|
1825
|
+
guidance: `${context} no signing key is loaded for '${sel.name}'.` };
|
|
1826
|
+
}
|
|
1827
|
+
try {
|
|
1828
|
+
const composed = await composeSealedSubmission({
|
|
1829
|
+
manifest: verifiedManifest, keyProvider: kp, op: opts.op,
|
|
1830
|
+
subjectKind: opts.subjectKind, subject: opts.subject, body: opts.body,
|
|
1831
|
+
issuedAt: Math.floor(Date.now() / 1000), logger,
|
|
1832
|
+
});
|
|
1833
|
+
if (!composed.ok) {
|
|
1834
|
+
// ERRORS NAME THEIR CAUSE: manifest_unavailable / manifest_expired / intake_key_absent /
|
|
1835
|
+
// intake_key_malformed each say WHICH check refused, and that survives rather than
|
|
1836
|
+
// collapsing into a generic send failure that points at the network.
|
|
1837
|
+
return { queued: false, reason: composed.reason, guidance: `${context} ${composed.guidance}` };
|
|
1838
|
+
}
|
|
1839
|
+
const sent = await sendSealedSubmission({
|
|
1840
|
+
signaling: getAgentSignaling(sel.name, kp, sel.pubkey).signaling,
|
|
1841
|
+
submissionId: composed.submissionId, intakeKeyId: composed.intakeKeyId,
|
|
1842
|
+
ciphertext: composed.ciphertext, logger,
|
|
1843
|
+
});
|
|
1844
|
+
if (!sent.ok) {
|
|
1845
|
+
return { queued: false, reason: sent.reason, guidance: `${context} ${sent.guidance ?? sent.reason}` };
|
|
1846
|
+
}
|
|
1847
|
+
// F4: `sendSealedSubmission` ALREADY logs `signal.submission.queued` / `.duplicate`. Logging
|
|
1848
|
+
// `queued` again here doubled every count-based alarm and, worse, emitted `queued` right after
|
|
1849
|
+
// `duplicate` — partially erasing the very distinction the directory's queue repository exists
|
|
1850
|
+
// to preserve. A distinct name, only for what the send layer does not know (which agent, which
|
|
1851
|
+
// op).
|
|
1852
|
+
logger.info("signal.submission.attributed", {
|
|
1853
|
+
agentName: sel.name, op: opts.op, submissionId: composed.submissionId, stored: sent.stored,
|
|
1854
|
+
});
|
|
1855
|
+
// F1: `stored: false` means a node reports it ALREADY HELD this submission id. That is either
|
|
1856
|
+
// a benign retry or single-node censorship — an operator pre-inserting garbage under a
|
|
1857
|
+
// clear-text id — and they are indistinguishable from here. Reporting it as unqualified
|
|
1858
|
+
// success is what makes the attack silent. The warning lives in the SHARED path, not at one
|
|
1859
|
+
// call site, because the refuse verb had it and the issue verb did not: the same omission
|
|
1860
|
+
// would otherwise be available to every verb added after this one.
|
|
1861
|
+
return {
|
|
1862
|
+
queued: true, stored: sent.stored, submissionId: composed.submissionId,
|
|
1863
|
+
...(sent.stored ? {} : {
|
|
1864
|
+
storedWarning: "A directory node accepted it but reports it already held this submission id. If nothing arrives for the recipient, send it again — re-sending is safe, the submission id is derived from the content.",
|
|
1865
|
+
}),
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
catch (err) {
|
|
1869
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1870
|
+
logger.warn("signal.submission.refused", { agentName: sel.name, op: opts.op, reason });
|
|
1871
|
+
return { queued: false, reason, guidance: `${context} ${reason}` };
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
/**
|
|
1875
|
+
* M10B / DOD-END-SURFACE-1 — issue a trust signal ABOUT a counterparty.
|
|
1876
|
+
*
|
|
1877
|
+
* NOTE WHAT IS NOT HERE: a `type` parameter, and the word "endorsement" anywhere in the path. The
|
|
1878
|
+
* submission wire carries no type field — the PORTAL decides what it mints from a submission — so
|
|
1879
|
+
* a second client-sourced type needs no new verb, no new parameter and no client change. That is
|
|
1880
|
+
* INV-ZEROBUMP holding by construction rather than by discipline, and it is what
|
|
1881
|
+
* `DOD-END-PLAYBOOK-1` has to prove with an empty diff.
|
|
1882
|
+
*
|
|
1883
|
+
* The subject is the counterparty's K_local pubkey: the only identifier a contact actually holds.
|
|
1884
|
+
* No account identifier crosses the wire — the portal resolves agent → account at intake, and the
|
|
1885
|
+
* directory is hash-only by design.
|
|
1886
|
+
*/
|
|
1887
|
+
handlers.set("cello_trust_signals_issue", async (params, connectionId) => {
|
|
1888
|
+
const sel = resolveSelectedAgent(connectionId);
|
|
1889
|
+
if (!sel.ok)
|
|
1890
|
+
return sel;
|
|
1891
|
+
const subject = typeof params?.subject_pubkey === "string" ? params.subject_pubkey.toLowerCase() : "";
|
|
1892
|
+
if (!/^[0-9a-f]{64}$/.test(subject)) {
|
|
1893
|
+
return { ok: false, reason: "invalid_subject",
|
|
1894
|
+
guidance: "subject_pubkey must be the counterparty's 32-byte public key as 64 hex characters — run cello_contacts to see the peers you know." };
|
|
1895
|
+
}
|
|
1896
|
+
const body = typeof params?.body === "string" ? params.body.trim() : "";
|
|
1897
|
+
if (body.length === 0) {
|
|
1898
|
+
return { ok: false, reason: "empty_body",
|
|
1899
|
+
guidance: "An issued signal needs text — it is the claim you are making about them, in your own words." };
|
|
1900
|
+
}
|
|
1901
|
+
// SELF-ISSUANCE IS REFUSED AT THE SOURCE, and across EVERY agent on this daemon — not just the
|
|
1902
|
+
// selected one. The check used to compare against `sel.pubkey` alone, which let an operator
|
|
1903
|
+
// running two of their own agents issue from one about the other and sail through a guard whose
|
|
1904
|
+
// comment claimed certainty. That configuration is not exotic: solo multi-agent is CELLO's first
|
|
1905
|
+
// wedge, so it is the most likely way to hit this, not the least.
|
|
1906
|
+
//
|
|
1907
|
+
// The portal remains the real enforcer of INV-NO-SELF-STANDING — only it can see account
|
|
1908
|
+
// linkage, and only it can catch two agents under one account on different machines. But the
|
|
1909
|
+
// daemon knows its OWN agents with certainty, and refusing here gives the operator a real answer
|
|
1910
|
+
// now instead of a silent rejection at intake minutes later.
|
|
1911
|
+
const localSelf = loadedAgents.find((a) => a.pubkey.toLowerCase() === subject);
|
|
1912
|
+
if (localSelf) {
|
|
1913
|
+
return { ok: false, reason: "self_subject",
|
|
1914
|
+
guidance: localSelf.name === sel.name
|
|
1915
|
+
? "An agent cannot issue a trust signal about itself — standing has to come from somebody else."
|
|
1916
|
+
: `'${sel.name}' and '${localSelf.name}' are both your agents on this machine, so a signal from one about the other would be you vouching for yourself. Standing has to come from somebody else.` };
|
|
1917
|
+
}
|
|
1918
|
+
const res = await submitForAgent({
|
|
1919
|
+
connectionId,
|
|
1920
|
+
op: "submit", subjectKind: "agent", subject, body,
|
|
1921
|
+
context: "The signal was NOT submitted:",
|
|
1922
|
+
});
|
|
1923
|
+
if (!res.queued)
|
|
1924
|
+
return { ok: false, reason: res.reason, guidance: res.guidance };
|
|
1925
|
+
return {
|
|
1926
|
+
ok: true, queued: true, stored: res.stored, submission_id: res.submissionId,
|
|
1927
|
+
// Deliberately NOT "issued". Nothing is minted yet: the portal must still drain, authenticate,
|
|
1928
|
+
// scan and mint, and the subject must then ACCEPT it before anyone else can see it. Reporting
|
|
1929
|
+
// this as a completed endorsement would promise three steps that have not happened.
|
|
1930
|
+
guidance: res.storedWarning
|
|
1931
|
+
? `Submitted for '${sel.name}'. ${res.storedWarning}`
|
|
1932
|
+
: `Submitted for '${sel.name}'. The portal will scan and mint it, then the subject must ACCEPT it before it is visible to anyone — a signal they have not accepted is inert. Nothing here is final until they decide.`,
|
|
1933
|
+
};
|
|
1934
|
+
});
|
|
1935
|
+
/**
|
|
1936
|
+
* M10B / DOD-END-SURFACE-1 — per-counterparty presentation choice.
|
|
1937
|
+
*
|
|
1938
|
+
* `present: null` CLEARS the choice rather than setting it false. Those are different states and
|
|
1939
|
+
* the surface must keep them apart: cleared means "no opinion, use the signal's default", false
|
|
1940
|
+
* means "specifically not this person". An operator who could only toggle true/false would be
|
|
1941
|
+
* unable to undo an omission without first knowing what the default had been.
|
|
1942
|
+
*/
|
|
1943
|
+
handlers.set("cello_contact_set_signal", async (params, connectionId) => {
|
|
1944
|
+
const sel = resolveSelectedAgent(connectionId);
|
|
1945
|
+
if (!sel.ok)
|
|
1946
|
+
return sel;
|
|
1947
|
+
const pubkey = typeof params?.pubkey === "string" ? params.pubkey.toLowerCase() : "";
|
|
1948
|
+
if (!/^[0-9a-f]{64}$/.test(pubkey)) {
|
|
1949
|
+
return { ok: false, reason: "invalid_pubkey", guidance: "pubkey must be the counterparty's 32-byte public key as 64 hex characters." };
|
|
1950
|
+
}
|
|
1951
|
+
const prefix = typeof params?.hash_prefix === "string" ? params.hash_prefix : "";
|
|
1952
|
+
if (prefix.length < 8) {
|
|
1953
|
+
return { ok: false, reason: "invalid_prefix", guidance: "hash_prefix must be at least 8 hex characters — see cello_trust_signals_list." };
|
|
1954
|
+
}
|
|
1955
|
+
const present = params?.present === null ? null : typeof params?.present === "boolean" ? params.present : undefined;
|
|
1956
|
+
if (present === undefined) {
|
|
1957
|
+
return { ok: false, reason: "invalid_present", guidance: "present must be true (show it to them), false (never show it to them), or null (clear the choice and fall back to the signal's default)." };
|
|
1958
|
+
}
|
|
1959
|
+
// Resolve the prefix against signals this agent actually holds, so a typo cannot silently write
|
|
1960
|
+
// a preference about a hash that does not exist and sit there doing nothing forever.
|
|
1961
|
+
const store = new TrustSignalStore(sessionNodeManager.getDb(), logger);
|
|
1962
|
+
const match = store.listAllWalletSignals().filter((r) => r.signalHash.startsWith(prefix));
|
|
1963
|
+
if (match.length === 0) {
|
|
1964
|
+
return { ok: false, reason: "signal_not_found", guidance: `No signal in this wallet starts with '${prefix}'.` };
|
|
1965
|
+
}
|
|
1966
|
+
if (match.length > 1) {
|
|
1967
|
+
return { ok: false, reason: "ambiguous_prefix", guidance: `'${prefix}' matches ${match.length} signals — use more characters.` };
|
|
1968
|
+
}
|
|
1969
|
+
sessionNodeManager.setContactSignalPref(sel.name, pubkey, match[0].signalHash, present);
|
|
1970
|
+
return {
|
|
1971
|
+
ok: true, signal_hash: match[0].signalHash, pubkey, present,
|
|
1972
|
+
guidance: present === null
|
|
1973
|
+
? "Choice cleared — this signal now follows its own default for this contact."
|
|
1974
|
+
: present
|
|
1975
|
+
? "This signal will be presented to this contact when a session forms, if you have accepted it."
|
|
1976
|
+
: "This signal will NOT be presented to this contact, whatever its default.",
|
|
1977
|
+
};
|
|
1978
|
+
});
|
|
1785
1979
|
handlers.set("cello_consent_list", async (_params, connectionId) => {
|
|
1786
1980
|
const sel = resolveSelectedAgent(connectionId);
|
|
1787
1981
|
if (!sel.ok)
|
|
@@ -1878,15 +2072,6 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1878
2072
|
const message = typeof params?.message === "string" ? params.message.trim() : "";
|
|
1879
2073
|
if (message.length === 0)
|
|
1880
2074
|
return { ...refused, message_queued: false };
|
|
1881
|
-
// A CAP, because the CLI joins every trailing positional into this string and a paste is
|
|
1882
|
-
// unbounded. Without it a multi-megabyte body is CBOR-encoded, signed, sealed, and pushed at the
|
|
1883
|
-
// signaling stream, where it dies as `signaling_lost` — a TRANSPORT label for a payload-size
|
|
1884
|
-
// bug, which is exactly the error-substitution failure that sends an operator to the wrong
|
|
1885
|
-
// subsystem for a day.
|
|
1886
|
-
if (message.length > MAX_REFUSAL_MESSAGE_CHARS) {
|
|
1887
|
-
return { ...refused, message_queued: false, message_error: "message_too_long",
|
|
1888
|
-
guidance: `The refusal is recorded. Your message was NOT sent: it is ${message.length} characters and the limit is ${MAX_REFUSAL_MESSAGE_CHARS}.` };
|
|
1889
|
-
}
|
|
1890
2075
|
// ACCOUNT-SUBJECT ITEMS DO NOT GET A MESSAGE YET, and this is a refusal, not an oversight.
|
|
1891
2076
|
//
|
|
1892
2077
|
// `listPendingConsent` scopes with `(subject_kind <> 'agent' OR lower(subject) = ?)`, so EVERY
|
|
@@ -1894,81 +2079,35 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1894
2079
|
// itself is defensible (it is the account's own decision, and any of its agents speaks for it),
|
|
1895
2080
|
// but the MESSAGE is signed with THIS agent's K_local, so the issuer would receive a signed
|
|
1896
2081
|
// statement from an agent that was not the subject of anything. Which agent may speak for an
|
|
1897
|
-
// account is an open question this milestone has not answered
|
|
1898
|
-
//
|
|
1899
|
-
//
|
|
1900
|
-
// So the decision stands and the courtesy is withheld, with the reason named.
|
|
2082
|
+
// account is an open question this milestone has not answered, and signing is not the place to
|
|
2083
|
+
// guess at it. So the decision stands and the courtesy is withheld, with the reason named.
|
|
1901
2084
|
if (item.subjectKind !== "agent") {
|
|
1902
2085
|
return { ...refused, message_queued: false, message_error: "account_subject_message_unsupported",
|
|
1903
2086
|
guidance: `The refusal is recorded. Your message was NOT sent: this signal is about the ACCOUNT rather than about '${sel.name}', and a message would be signed by this agent alone — which agent may speak for an account is not yet settled.` };
|
|
1904
2087
|
}
|
|
1905
|
-
//
|
|
1906
|
-
//
|
|
1907
|
-
//
|
|
1908
|
-
//
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
}
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
// The subject of a refusal is the TARGET SIGNAL HASH, as it is for a withdrawal.
|
|
1917
|
-
const kp = keyProviders.get(sel.name);
|
|
1918
|
-
if (!kp) {
|
|
1919
|
-
logger.warn("signal.refusal.refused", { agentName: sel.name, reason: "key_provider_absent" });
|
|
1920
|
-
return { ...refused, message_queued: false, message_error: "key_provider_absent",
|
|
1921
|
-
guidance: `The refusal is recorded. Your message was NOT sent: no signing key is loaded for '${sel.name}'.` };
|
|
1922
|
-
}
|
|
1923
|
-
try {
|
|
1924
|
-
const composed = await composeSealedSubmission({
|
|
1925
|
-
manifest: verifiedManifest, keyProvider: kp, op: "refuse",
|
|
1926
|
-
// The subject of a refusal is the TARGET SIGNAL HASH, and `subject_kind` describes what the
|
|
1927
|
-
// REFUSED SIGNAL was about — it is carried through from the row rather than hardcoded. A
|
|
1928
|
-
// hardcoded "agent" would be a SIGNED field asserting something false for every
|
|
1929
|
-
// account-subject refusal, and `subject_kind` is inside the TBS, so the lie would be signed.
|
|
1930
|
-
subjectKind: item.subjectKind, subject: item.signalHash, body: message,
|
|
1931
|
-
issuedAt: Math.floor(Date.now() / 1000), logger,
|
|
1932
|
-
});
|
|
1933
|
-
if (!composed.ok) {
|
|
1934
|
-
// ERRORS NAME THEIR CAUSE: composeSealedSubmission's refusals (manifest_unavailable,
|
|
1935
|
-
// manifest_expired, intake_key_absent, intake_key_malformed) say WHICH check refused, and
|
|
1936
|
-
// that reason survives here rather than collapsing into a generic send failure.
|
|
1937
|
-
return { ...refused, message_queued: false, message_error: composed.reason,
|
|
1938
|
-
guidance: `The refusal is recorded. Your message was NOT sent: ${composed.guidance}` };
|
|
1939
|
-
}
|
|
1940
|
-
const sent = await sendSealedSubmission({
|
|
1941
|
-
signaling: getAgentSignaling(sel.name, kp, sel.pubkey).signaling,
|
|
1942
|
-
submissionId: composed.submissionId, intakeKeyId: composed.intakeKeyId,
|
|
1943
|
-
ciphertext: composed.ciphertext, logger,
|
|
1944
|
-
});
|
|
1945
|
-
if (!sent.ok) {
|
|
1946
|
-
return { ...refused, message_queued: false, message_error: sent.reason,
|
|
1947
|
-
guidance: `The refusal is recorded. Your message was NOT sent: ${sent.guidance ?? sent.reason}. Retrying is safe — the submission id is derived from the content.` };
|
|
1948
|
-
}
|
|
1949
|
-
logger.info("signal.refusal.queued", {
|
|
1950
|
-
agentName: sel.name, submissionId: composed.submissionId, stored: sent.stored,
|
|
1951
|
-
});
|
|
1952
|
-
// `message_queued`, NOT `issuer_notified`. A directory node acked a sealed blob; the portal has
|
|
1953
|
-
// not drained it, scanned it, minted it, or delivered anything to the issuer. Claiming the
|
|
1954
|
-
// issuer was notified asserts four steps that have not happened.
|
|
1955
|
-
//
|
|
1956
|
-
// `stored` is carried through rather than collapsed into plain success: it is the ONE signal
|
|
1957
|
-
// that separates a benign duplicate from single-node censorship (an operator pre-inserting
|
|
1958
|
-
// garbage under a clear-text submission_id), and folding the two together destroys the only
|
|
1959
|
-
// information that could ever tell them apart.
|
|
1960
|
-
return {
|
|
1961
|
-
...refused, message_queued: true, stored: sent.stored, submission_id: composed.submissionId,
|
|
1962
|
-
...(sent.stored ? {} : { guidance: "The refusal is recorded and your message was accepted by a directory node, but that node reports it already held this submission id. If the issuer never receives it, re-run the refusal — re-sending is safe." }),
|
|
1963
|
-
};
|
|
1964
|
-
}
|
|
1965
|
-
catch (err) {
|
|
1966
|
-
// A throw here must not un-refuse anything, and must not read as a clean refusal either.
|
|
1967
|
-
const reason = err instanceof Error ? err.message : String(err);
|
|
1968
|
-
logger.warn("signal.refusal.refused", { agentName: sel.name, reason });
|
|
1969
|
-
return { ...refused, message_queued: false, message_error: reason,
|
|
1970
|
-
guidance: `The refusal is recorded. Your message was NOT sent: ${reason}` };
|
|
2088
|
+
// Rides the submission queue as the `refuse` op. The SUBJECT is the target signal hash (as it is
|
|
2089
|
+
// for a withdrawal — both verbs act on an existing signal), and `subject_kind` is carried from
|
|
2090
|
+
// the row rather than hardcoded: it is inside the TBS, so a hardcoded value would be a SIGNED
|
|
2091
|
+
// field asserting something false.
|
|
2092
|
+
const res = await submitForAgent({
|
|
2093
|
+
connectionId,
|
|
2094
|
+
op: "refuse", subjectKind: item.subjectKind, subject: item.signalHash, body: message,
|
|
2095
|
+
context: "The refusal is recorded. Your message was NOT sent:",
|
|
2096
|
+
});
|
|
2097
|
+
if (!res.queued) {
|
|
2098
|
+
return { ...refused, message_queued: false, message_error: res.reason, guidance: res.guidance };
|
|
1971
2099
|
}
|
|
2100
|
+
// `message_queued`, NOT `issuer_notified`. A directory node acked a sealed blob; the portal has
|
|
2101
|
+
// not drained it, scanned it, minted it, or delivered anything to the issuer.
|
|
2102
|
+
//
|
|
2103
|
+
// `stored` is carried through rather than collapsed into plain success: it is the ONE signal
|
|
2104
|
+
// separating a benign duplicate from single-node censorship (an operator pre-inserting garbage
|
|
2105
|
+
// under a clear-text submission_id), and folding them together destroys the only information
|
|
2106
|
+
// that could ever tell them apart.
|
|
2107
|
+
return {
|
|
2108
|
+
...refused, message_queued: true, stored: res.stored, submission_id: res.submissionId,
|
|
2109
|
+
...(res.storedWarning ? { guidance: `The refusal is recorded. ${res.storedWarning}` } : {}),
|
|
2110
|
+
};
|
|
1972
2111
|
});
|
|
1973
2112
|
handlers.set("wallet_revoke_signal", async (params, _connectionId) => {
|
|
1974
2113
|
const hashPrefix = typeof params?.hash_prefix === "string" ? params.hash_prefix : null;
|