salt-agent-sdk 0.1.0 → 0.18.0
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/LICENSE +21 -0
- package/README.md +941 -30
- package/dist/actions.d.ts +21 -5
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +673 -54
- package/dist/actions.js.map +1 -1
- package/dist/ask.d.ts +83 -0
- package/dist/ask.d.ts.map +1 -0
- package/dist/ask.js +215 -0
- package/dist/ask.js.map +1 -0
- package/dist/chatPrivacy.d.ts +34 -0
- package/dist/chatPrivacy.d.ts.map +1 -0
- package/dist/chatPrivacy.js +60 -0
- package/dist/chatPrivacy.js.map +1 -0
- package/dist/client.d.ts +2783 -35
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1200 -143
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +16 -6
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +16 -5
- package/dist/config.js.map +1 -1
- package/dist/crypto.d.ts +62 -0
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +121 -0
- package/dist/crypto.js.map +1 -1
- package/dist/delegations.d.ts +25 -8
- package/dist/delegations.d.ts.map +1 -1
- package/dist/delegations.js +67 -11
- package/dist/delegations.js.map +1 -1
- package/dist/device/client.d.ts +161 -0
- package/dist/device/client.d.ts.map +1 -0
- package/dist/device/client.js +601 -0
- package/dist/device/client.js.map +1 -0
- package/dist/device/host.d.ts +222 -0
- package/dist/device/host.d.ts.map +1 -0
- package/dist/device/host.js +891 -0
- package/dist/device/host.js.map +1 -0
- package/dist/device/http.d.ts +12 -0
- package/dist/device/http.d.ts.map +1 -0
- package/dist/device/http.js +124 -0
- package/dist/device/http.js.map +1 -0
- package/dist/device/index.d.ts +5 -0
- package/dist/device/index.d.ts.map +1 -0
- package/dist/device/index.js +27 -0
- package/dist/device/index.js.map +1 -0
- package/dist/device/protocol.d.ts +312 -0
- package/dist/device/protocol.d.ts.map +1 -0
- package/dist/device/protocol.js +435 -0
- package/dist/device/protocol.js.map +1 -0
- package/dist/identities.d.ts +9 -2
- package/dist/identities.d.ts.map +1 -1
- package/dist/identities.js +33 -3
- package/dist/identities.js.map +1 -1
- package/dist/identity.d.ts +155 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +196 -0
- package/dist/identity.js.map +1 -0
- package/dist/identityShare.d.ts +129 -0
- package/dist/identityShare.d.ts.map +1 -0
- package/dist/identityShare.js +308 -0
- package/dist/identityShare.js.map +1 -0
- package/dist/ids.d.ts +11 -0
- package/dist/ids.d.ts.map +1 -0
- package/dist/ids.js +17 -0
- package/dist/ids.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/reconcile.d.ts +25 -0
- package/dist/reconcile.d.ts.map +1 -0
- package/dist/reconcile.js +60 -0
- package/dist/reconcile.js.map +1 -0
- package/dist/recovery.d.ts +51 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +59 -0
- package/dist/recovery.js.map +1 -0
- package/dist/register.d.ts +32 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +65 -0
- package/dist/register.js.map +1 -0
- package/dist/scriptJob.d.ts +92 -0
- package/dist/scriptJob.d.ts.map +1 -0
- package/dist/scriptJob.js +270 -0
- package/dist/scriptJob.js.map +1 -0
- package/dist/sessions.d.ts +131 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +240 -0
- package/dist/sessions.js.map +1 -0
- package/dist/socket.d.ts +115 -0
- package/dist/socket.d.ts.map +1 -0
- package/dist/socket.js +1038 -0
- package/dist/socket.js.map +1 -0
- package/dist/webhook.d.ts +613 -24
- package/dist/webhook.d.ts.map +1 -1
- package/dist/webhook.js +1384 -83
- package/dist/webhook.js.map +1 -1
- package/dist/work.d.ts +57 -0
- package/dist/work.d.ts.map +1 -0
- package/dist/work.js +213 -0
- package/dist/work.js.map +1 -0
- package/package.json +26 -4
package/dist/client.js
CHANGED
|
@@ -5,33 +5,107 @@
|
|
|
5
5
|
// each identity's own api-key per call (a process can act as more than one
|
|
6
6
|
// Salt agent: the primary identity, plus any it spawns via createAgent).
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
-
exports.SaltApiError = void 0;
|
|
8
|
+
exports.RecipientContactsOnlyError = exports.RECIPIENT_CONTACTS_ONLY = exports.SaltApiError = exports.AppStateConflictError = void 0;
|
|
9
|
+
exports.sanitizeAttachmentFilename = sanitizeAttachmentFilename;
|
|
10
|
+
exports.isAsked = isAsked;
|
|
9
11
|
exports.createSaltClient = createSaltClient;
|
|
12
|
+
const node_crypto_1 = require("node:crypto");
|
|
13
|
+
const identity_js_1 = require("./identity.js");
|
|
14
|
+
const recovery_js_1 = require("./recovery.js");
|
|
15
|
+
const crypto_js_1 = require("./crypto.js");
|
|
16
|
+
/**
|
|
17
|
+
* Basename only, control characters and path separators stripped, capped at
|
|
18
|
+
* 200 characters, falling back to "attachment" when nothing usable is left.
|
|
19
|
+
* A filename lives only inside an attachment's PGP-encrypted metadata blob
|
|
20
|
+
* (see Attachment's header comment in salt-api) -- Salt itself never sees
|
|
21
|
+
* or sanitizes it -- so anything that later treats a DECRYPTED filename as
|
|
22
|
+
* a filesystem path (an agent's own read/save-to-disk tool) must sanitize
|
|
23
|
+
* on the way OUT, since it is exactly as attacker-controlled as any other
|
|
24
|
+
* chat member's input. Exported so both sendAttachment (outgoing) and a
|
|
25
|
+
* consumer's incoming-attachment handler can share one rule.
|
|
26
|
+
*/
|
|
27
|
+
function sanitizeAttachmentFilename(name) {
|
|
28
|
+
const base = String(name ?? "")
|
|
29
|
+
.split(/[\\/]/)
|
|
30
|
+
.pop() ?? "";
|
|
31
|
+
// eslint-disable-next-line no-control-regex
|
|
32
|
+
const cleaned = base.replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
33
|
+
const safe = cleaned.replace(/^\.+/, "").slice(0, 200);
|
|
34
|
+
return safe || "attachment";
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Thrown by `setState` on a 409 -- the installation's shared state moved
|
|
38
|
+
* under the caller since `ifVersion`. Carries the CURRENT `{doc, version}`
|
|
39
|
+
* straight off the response body (salt-api's own 409 payload), so a caller
|
|
40
|
+
* can merge its change onto the fresh doc and retry with the new version
|
|
41
|
+
* rather than spending a second round trip just to re-fetch first.
|
|
42
|
+
*/
|
|
43
|
+
class AppStateConflictError extends Error {
|
|
44
|
+
doc;
|
|
45
|
+
version;
|
|
46
|
+
constructor(doc, version) {
|
|
47
|
+
super("App state changed under you -- merge onto the current doc and retry with its version.");
|
|
48
|
+
this.name = "AppStateConflictError";
|
|
49
|
+
this.doc = doc;
|
|
50
|
+
this.version = version;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
exports.AppStateConflictError = AppStateConflictError;
|
|
54
|
+
function isAsked(value) {
|
|
55
|
+
return !!value && typeof value === "object" && value.asked === true;
|
|
56
|
+
}
|
|
57
|
+
const postMediaFields = (media) => ({
|
|
58
|
+
post_media: Buffer.from(media.data).toString("base64"),
|
|
59
|
+
post_media_content_type: media.contentType,
|
|
60
|
+
});
|
|
10
61
|
class SaltApiError extends Error {
|
|
11
62
|
status;
|
|
12
63
|
body;
|
|
64
|
+
/** Salt's machine-readable refusal code (`{error, code}`), when the body carries one. */
|
|
65
|
+
code;
|
|
13
66
|
constructor(method, url, status, body) {
|
|
14
|
-
|
|
67
|
+
// Salt's refusals carry one plain sentence ({error: "..."}). Put it in
|
|
68
|
+
// the message: a tool result that says only "-> 422" leaves a model
|
|
69
|
+
// guessing, while "The person has not said anything since the last
|
|
70
|
+
// hand-off. Answer them yourself..." tells it what to do instead.
|
|
71
|
+
// POST /auth (self-registration) answers {status: {message}} instead.
|
|
72
|
+
const b = body && typeof body === "object" ? body : undefined;
|
|
73
|
+
const sentence = typeof b?.error === "string" ? b.error : typeof b?.status?.message === "string" ? b.status.message : undefined;
|
|
74
|
+
const reason = sentence ? `: ${sentence}` : "";
|
|
75
|
+
super(`Salt API ${method} ${url} -> ${status}${reason}`);
|
|
15
76
|
this.name = "SaltApiError";
|
|
16
77
|
this.status = status;
|
|
17
78
|
this.body = body;
|
|
79
|
+
if (typeof b?.code === "string")
|
|
80
|
+
this.code = b.code;
|
|
18
81
|
}
|
|
19
82
|
}
|
|
20
83
|
exports.SaltApiError = SaltApiError;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
84
|
+
exports.RECIPIENT_CONTACTS_ONLY = "recipient_contacts_only";
|
|
85
|
+
/** 403 `recipient_contacts_only`: the recipient only takes messages from their contacts and the caller is
|
|
86
|
+
* not one, so no chat was created. `message` ends with the server's own sentence ("<name> only takes
|
|
87
|
+
* messages from their contacts."), also on `sentence`. Do not retry; a request for contact is the only way in. */
|
|
88
|
+
class RecipientContactsOnlyError extends SaltApiError {
|
|
89
|
+
sentence;
|
|
90
|
+
constructor(method, url, status, body) {
|
|
91
|
+
super(method, url, status, body);
|
|
92
|
+
this.name = "RecipientContactsOnlyError";
|
|
93
|
+
this.code = exports.RECIPIENT_CONTACTS_ONLY;
|
|
94
|
+
const e = body?.error;
|
|
95
|
+
this.sentence = typeof e === "string" ? e : "This person only takes messages from their contacts.";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
exports.RecipientContactsOnlyError = RecipientContactsOnlyError;
|
|
27
99
|
function createSaltClient(options) {
|
|
28
100
|
const host = options.host.replace(/\/$/, "");
|
|
29
101
|
const doFetch = options.fetchImpl ?? fetch;
|
|
30
|
-
async function request(method, path, apiKey, body,
|
|
102
|
+
const request = async function request(method, path, apiKey, body, opts) {
|
|
31
103
|
const url = `${host}${path}`;
|
|
32
|
-
const headers = { "api-key": apiKey, ...extraHeaders };
|
|
104
|
+
const headers = { "api-key": apiKey, ...opts?.extraHeaders };
|
|
33
105
|
if (body !== undefined)
|
|
34
106
|
headers["Content-Type"] = "application/json";
|
|
107
|
+
if (opts?.idempotencyKey)
|
|
108
|
+
headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
35
109
|
const res = await doFetch(url, {
|
|
36
110
|
method,
|
|
37
111
|
headers,
|
|
@@ -45,144 +119,1127 @@ function createSaltClient(options) {
|
|
|
45
119
|
catch {
|
|
46
120
|
parsed = await res.text().catch(() => undefined);
|
|
47
121
|
}
|
|
122
|
+
if (res.status === 403 && parsed?.code === exports.RECIPIENT_CONTACTS_ONLY) {
|
|
123
|
+
throw new RecipientContactsOnlyError(method, url, res.status, parsed);
|
|
124
|
+
}
|
|
48
125
|
throw new SaltApiError(method, url, res.status, parsed);
|
|
49
126
|
}
|
|
50
127
|
if (res.status === 204)
|
|
51
128
|
return undefined;
|
|
52
|
-
|
|
129
|
+
const parsed = (await res.json());
|
|
130
|
+
// A mandate-governed call in "ask" mode never 4xx/5xxs and never throws --
|
|
131
|
+
// salt-api answers 202 `{status: "asked", exercise_id, expires_at}` instead
|
|
132
|
+
// (see actFor below). Reachable only through a call that actually carried
|
|
133
|
+
// X-Salt-Act-For; an ordinary (non-acting) call never gets asked, since
|
|
134
|
+
// authorize_acting! only runs when that header is present.
|
|
135
|
+
if (res.status === 202 && parsed && typeof parsed === "object" && parsed.status === "asked") {
|
|
136
|
+
const asked = parsed;
|
|
137
|
+
const result = { asked: true, exerciseId: asked.exercise_id, expiresAt: asked.expires_at };
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
return parsed;
|
|
141
|
+
};
|
|
142
|
+
/** `actFor`'s per-call header injection: `X-Salt-Act-For`/`X-Salt-Mandate` on everything, plus an
|
|
143
|
+
* `Idempotency-Key` auto-generated for POST/PATCH when the caller didn't already supply one --
|
|
144
|
+
* ask-mode calls need one to be safely retried, and every acting call is potentially ask-mode. */
|
|
145
|
+
function actingRequest(principalId, mandateId) {
|
|
146
|
+
return function actingRequestFn(method, path, apiKey, body, opts) {
|
|
147
|
+
const extraHeaders = { "X-Salt-Act-For": String(principalId), ...opts?.extraHeaders };
|
|
148
|
+
if (mandateId != null)
|
|
149
|
+
extraHeaders["X-Salt-Mandate"] = String(mandateId);
|
|
150
|
+
const isWrite = method === "POST" || method === "PATCH";
|
|
151
|
+
const idempotencyKey = opts?.idempotencyKey ?? (isWrite ? (0, node_crypto_1.randomUUID)() : undefined);
|
|
152
|
+
return request(method, path, apiKey, body, { extraHeaders, idempotencyKey });
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
async function whoAmI(apiKey) {
|
|
156
|
+
// Cache-busted: the answer changes on rotation and on re-keying, and a
|
|
157
|
+
// stale intermediary copy would quietly defeat both.
|
|
158
|
+
return request("GET", `/api/v1/agents/webhook_secret?_=${Date.now()}`, apiKey);
|
|
159
|
+
}
|
|
160
|
+
// Salt's Web Bot Auth signing key(s), published unauthenticated at the
|
|
161
|
+
// domain root (see salt-api's WebBotAuthController) -- the SAME key
|
|
162
|
+
// AgentCardSigner signs cards with. Cached per client instance, honouring
|
|
163
|
+
// the directory's own Cache-Control: max-age (the controller sends 300s;
|
|
164
|
+
// DEFAULT_DIRECTORY_MAX_AGE_MS stands in when the header is absent or
|
|
165
|
+
// unparseable) so a long-lived process doesn't re-fetch on every card()
|
|
166
|
+
// call, but also doesn't hold a stale key set indefinitely. Beyond age-based
|
|
167
|
+
// expiry, card() below also forces a refetch the moment a card names a
|
|
168
|
+
// kid this cache doesn't have -- see its comment for why that matters
|
|
169
|
+
// more than the age check alone (a rotation can land well inside the
|
|
170
|
+
// 5-minute window). A failed fetch never poisons the cache: the in-flight
|
|
171
|
+
// promise is cleared in `finally` so the next call (forced or not) tries again.
|
|
172
|
+
const DEFAULT_DIRECTORY_MAX_AGE_MS = 300_000;
|
|
173
|
+
let signingKeysCache = null;
|
|
174
|
+
let signingKeysInFlight = null;
|
|
175
|
+
function directoryMaxAgeMs(res) {
|
|
176
|
+
const raw = res.headers?.get?.("Cache-Control") ?? res.headers?.get?.("cache-control");
|
|
177
|
+
const match = raw ? /max-age=(\d+)/i.exec(raw) : null;
|
|
178
|
+
const seconds = match ? Number(match[1]) : NaN;
|
|
179
|
+
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : DEFAULT_DIRECTORY_MAX_AGE_MS;
|
|
53
180
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
},
|
|
73
|
-
/**
|
|
74
|
-
* Post a reply into a chat. `message` is the ciphertext every human
|
|
75
|
-
* recipient reads; `senderMessage` is the copy encrypted to the
|
|
76
|
-
* sender's own key. `delegations` (optional) is the provenance trail
|
|
77
|
-
* -- salt-api re-validates every entry against the sender's actual
|
|
78
|
-
* memberships before storing it.
|
|
79
|
-
*/
|
|
80
|
-
async postMessage(apiKey, chatId, message, senderMessage, delegations) {
|
|
81
|
-
const body = { chat_id: chatId, message, sender_message: senderMessage };
|
|
82
|
-
if (delegations && delegations.length > 0)
|
|
83
|
-
body.delegations = delegations;
|
|
84
|
-
return request("POST", "/api/v1/messages", apiKey, body);
|
|
85
|
-
},
|
|
86
|
-
/** Register a new Salt agent, owned by whoever's api-key calls this. */
|
|
87
|
-
async createAgent(apiKey, params) {
|
|
88
|
-
return request("POST", "/api/v1/agents", apiKey, params);
|
|
89
|
-
},
|
|
90
|
-
/** Owner-only: fetch an agent's own api-key + PGP keypair right after creating it. */
|
|
91
|
-
async getAgentAdmin(apiKey, agentId) {
|
|
92
|
-
return request("GET", `/api/v1/agents/${agentId}/admin`, apiKey);
|
|
93
|
-
},
|
|
94
|
-
/** The public Agents directory. */
|
|
95
|
-
async listAgents(apiKey) {
|
|
96
|
-
return request("GET", "/api/v1/agents", apiKey);
|
|
97
|
-
},
|
|
98
|
-
/** A single agent's directory entry. */
|
|
99
|
-
async getAgent(apiKey, agentId) {
|
|
100
|
-
return request("GET", `/api/v1/agents/${agentId}`, apiKey);
|
|
101
|
-
},
|
|
102
|
-
/** Create-or-reuse a 1:1 chat with `contactId` -- idempotent on Salt's side. */
|
|
103
|
-
async createOrGetChat(apiKey, contactId) {
|
|
104
|
-
return request("POST", "/api/v1/chats", apiKey, { contact_id: contactId });
|
|
105
|
-
},
|
|
106
|
-
/** Post a declarative blocks card into a chat (see CARD_PROTOCOL_SPEC.md / cards.ts). */
|
|
107
|
-
async postCard(apiKey, chatId, blocks, text) {
|
|
108
|
-
return request("POST", "/api/v1/cards", apiKey, { chat_id: chatId, blocks, text });
|
|
109
|
-
},
|
|
110
|
-
/** Replace an owned card's blocks -- re-broadcasts into everyone's bubble live. */
|
|
111
|
-
async updateCard(apiKey, cardId, blocks) {
|
|
112
|
-
return request("PATCH", `/api/v1/cards/${cardId}`, apiKey, { blocks });
|
|
113
|
-
},
|
|
114
|
-
// --- Commerce (products, invoices, prepaid credits) ---
|
|
115
|
-
/** My own products (sellerId omitted) or a seller's active products (sellerId given). */
|
|
116
|
-
async listProducts(apiKey, sellerId) {
|
|
117
|
-
const path = sellerId ? `/api/v1/products?seller_id=${sellerId}` : "/api/v1/products";
|
|
118
|
-
return request("GET", path, apiKey);
|
|
119
|
-
},
|
|
120
|
-
/** Create a product in the caller's shop. */
|
|
121
|
-
async createProduct(apiKey, params) {
|
|
122
|
-
return request("POST", "/api/v1/products", apiKey, params);
|
|
123
|
-
},
|
|
124
|
-
/** Drop one of the caller's products into a chat as a Buy-able bubble. */
|
|
125
|
-
async shareProduct(apiKey, productId, chatId) {
|
|
126
|
-
return request("POST", `/api/v1/products/${productId}/share`, apiKey, { chat_id: chatId });
|
|
127
|
-
},
|
|
128
|
-
/** Send an itemized invoice on the TransferRequest rail. Server re-validates all the math. */
|
|
129
|
-
async createInvoice(apiKey, params) {
|
|
130
|
-
return request("POST", "/api/v1/transfer_requests", apiKey, {
|
|
131
|
-
chat_id: params.chatId,
|
|
132
|
-
receiver_id: params.receiverId,
|
|
133
|
-
wallet_id: params.walletId,
|
|
134
|
-
amount: params.amount,
|
|
135
|
-
message: params.message,
|
|
136
|
-
request_type: "invoice",
|
|
137
|
-
line_items: params.lineItems,
|
|
138
|
-
due_at: params.dueAt,
|
|
181
|
+
async function getSigningKeys(opts) {
|
|
182
|
+
if (opts?.forceRefresh)
|
|
183
|
+
signingKeysCache = null;
|
|
184
|
+
const stale = !signingKeysCache || Date.now() - signingKeysCache.fetchedAt > signingKeysCache.maxAgeMs;
|
|
185
|
+
if (!stale)
|
|
186
|
+
return signingKeysCache.keys;
|
|
187
|
+
if (!signingKeysInFlight) {
|
|
188
|
+
signingKeysInFlight = (async () => {
|
|
189
|
+
const url = `${host}/.well-known/http-message-signatures-directory`;
|
|
190
|
+
const res = await doFetch(url);
|
|
191
|
+
if (!res.ok)
|
|
192
|
+
throw new SaltApiError("GET", url, res.status, await res.text().catch(() => undefined));
|
|
193
|
+
const body = (await res.json());
|
|
194
|
+
const keys = body.keys ?? [];
|
|
195
|
+
signingKeysCache = { keys, fetchedAt: Date.now(), maxAgeMs: directoryMaxAgeMs(res) };
|
|
196
|
+
return keys;
|
|
197
|
+
})().finally(() => {
|
|
198
|
+
signingKeysInFlight = null;
|
|
139
199
|
});
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
200
|
+
}
|
|
201
|
+
return signingKeysInFlight;
|
|
202
|
+
}
|
|
203
|
+
/** Builds the full method surface once per `request` implementation -- called once for the base
|
|
204
|
+
* (as-yourself) client and again, with `actingRequest`'s header-injecting wrapper, from `actFor`
|
|
205
|
+
* below. Every method here is written exactly as it always was; only the `request` it closes
|
|
206
|
+
* over differs between the two callers. */
|
|
207
|
+
function buildMethods(request) {
|
|
208
|
+
return {
|
|
209
|
+
/** A chat's members (with public keys), for encrypting a reply to everyone who should read it. */
|
|
210
|
+
async getChatMembers(apiKey, chatId) {
|
|
211
|
+
const chat = await request("GET", `/api/v1/chats/${chatId}?_=${Date.now()}`, apiKey);
|
|
212
|
+
return chat?.session?.users ?? [];
|
|
213
|
+
},
|
|
214
|
+
/** Recent messages in a chat this identity is a member of (even as a silent observer). */
|
|
215
|
+
async getChatMessages(apiKey, chatId) {
|
|
216
|
+
const chat = await request("GET", `/api/v1/chats/${chatId}?_=${Date.now()}`, apiKey);
|
|
217
|
+
return chat?.messages ?? [];
|
|
218
|
+
},
|
|
219
|
+
/**
|
|
220
|
+
* One `GET /api/v1/chats/:id` call for a caller that needs BOTH the
|
|
221
|
+
* member list and whether the chat is end-to-end encrypted -- e.g.
|
|
222
|
+
* actions.ts's request_floor, which (unlike delegate_to_agent/
|
|
223
|
+
* consult_agent) has no other reason to fetch the chat at all.
|
|
224
|
+
* `encrypted` is already resolved to a real boolean (defaulted `true`
|
|
225
|
+
* when the field is absent, same convention as ChatOpenedContext.encrypted
|
|
226
|
+
* and MessageContext.encrypted) -- never undefined.
|
|
227
|
+
*/
|
|
228
|
+
async getChat(apiKey, chatId) {
|
|
229
|
+
const chat = await request("GET", `/api/v1/chats/${chatId}?_=${Date.now()}`, apiKey);
|
|
230
|
+
return { id: chatId, encrypted: chat?.session?.encrypted !== false, users: chat?.session?.users ?? [] };
|
|
231
|
+
},
|
|
232
|
+
/**
|
|
233
|
+
* Salt's own record of a chat, exactly as `GET /api/v1/chats/:id` answers
|
|
234
|
+
* it (nothing flattened or defaulted, unlike getChat): `session.users`
|
|
235
|
+
* with each member's `observer` flag, `session.public`, `session.encrypted`.
|
|
236
|
+
* This is what `isPrivateChat(chat, speakerId, myId)` reads -- decide
|
|
237
|
+
* privacy-sensitive actions from THIS, fetched when you need it, never
|
|
238
|
+
* from a cache or a memory service.
|
|
239
|
+
*/
|
|
240
|
+
async getChatRecord(apiKey, chatId) {
|
|
241
|
+
return request("GET", `/api/v1/chats/${chatId}?_=${Date.now()}`, apiKey);
|
|
242
|
+
},
|
|
243
|
+
/** Download an attachment's ciphertext bytes. Decrypt separately (see crypto.ts). */
|
|
244
|
+
async getAttachment(apiKey, messageId) {
|
|
245
|
+
const url = `${host}/api/v1/messages/${messageId}/attachment`;
|
|
246
|
+
const res = await doFetch(url, { headers: { "api-key": apiKey } });
|
|
247
|
+
if (!res.ok)
|
|
248
|
+
throw new SaltApiError("GET", url, res.status, await res.text().catch(() => undefined));
|
|
249
|
+
return Buffer.from(await res.arrayBuffer());
|
|
250
|
+
},
|
|
251
|
+
/**
|
|
252
|
+
* Post a reply into a chat. `message` is the ciphertext every human
|
|
253
|
+
* recipient reads; `senderMessage` is the copy encrypted to the
|
|
254
|
+
* sender's own key. `delegations` (optional) is the provenance trail
|
|
255
|
+
* -- salt-api re-validates every entry against the sender's actual
|
|
256
|
+
* memberships before storing it. The result carries `delivered_as`:
|
|
257
|
+
* `"request"` means the recipient has not accepted a first message from
|
|
258
|
+
* you yet (see Message requests in AGENTS.md) -- wait, do not re-send.
|
|
259
|
+
*/
|
|
260
|
+
async postMessage(apiKey, chatId, message, senderMessage, delegations,
|
|
261
|
+
// Who this reply addresses. Salt never sees the plaintext, so an "@handle"
|
|
262
|
+
// written into the body is invisible to it -- these ids are what actually
|
|
263
|
+
// reach the person as a mention notification. Without them a reply can
|
|
264
|
+
// SAY it is addressed to someone and, as far as the server is concerned,
|
|
265
|
+
// be addressed to nobody.
|
|
266
|
+
mentions,
|
|
267
|
+
// `quiet` (salt-api 0.55.0): no push for this message. Honoured only for
|
|
268
|
+
// an agent posting into a private lane -- a progress report (work.ts) --
|
|
269
|
+
// and silently dropped anywhere else.
|
|
270
|
+
opts) {
|
|
271
|
+
const body = { chat_id: chatId, message, sender_message: senderMessage };
|
|
272
|
+
if (delegations && delegations.length > 0)
|
|
273
|
+
body.delegations = delegations;
|
|
274
|
+
if (mentions && mentions.length > 0)
|
|
275
|
+
body.mentions = mentions;
|
|
276
|
+
if (opts?.quiet)
|
|
277
|
+
body.quiet = true;
|
|
278
|
+
return request("POST", "/api/v1/messages", apiKey, body);
|
|
279
|
+
},
|
|
280
|
+
/**
|
|
281
|
+
* Sends a file into a chat, encrypted exactly the way salt-fe's
|
|
282
|
+
* chatbox.jsx sendOneAttachment encrypts one: a fresh one-time AES-256-
|
|
283
|
+
* GCM key encrypts the file bytes (crypto.ts's encryptAttachment,
|
|
284
|
+
* byte-identical wire format to the browser's Web Crypto output), and
|
|
285
|
+
* that key/iv plus the real filename/content_type/size are PGP-
|
|
286
|
+
* encrypted as one JSON blob for every current chat member's public
|
|
287
|
+
* key -- the SAME single recipient list the caption text is encrypted
|
|
288
|
+
* for, fetched fresh via GET /api/v1/chats/:id so a just-added or
|
|
289
|
+
* just-removed member is never missed or over-included. Unlike
|
|
290
|
+
* postMessage's reply() there is no separate sender_message: the web
|
|
291
|
+
* never excludes the sender from an attachment's recipient list (it
|
|
292
|
+
* has no reason to -- there's only one `attachment_encrypted_key`
|
|
293
|
+
* field, see Attachment#as_chat_resource), so a single PGP blob
|
|
294
|
+
* covering every member already covers the sender too.
|
|
295
|
+
*
|
|
296
|
+
* Throws before doing any crypto work on an oversized file (matches
|
|
297
|
+
* salt-api's own MAX_ATTACHMENT_BYTES so a doomed upload never even
|
|
298
|
+
* starts) or a content_type outside ALLOWED_SEND_CONTENT_TYPES.
|
|
299
|
+
*/
|
|
300
|
+
async sendAttachment(apiKey, chatId, params) {
|
|
301
|
+
if (params.bytes.length > crypto_js_1.MAX_ATTACHMENT_BYTES) {
|
|
302
|
+
throw new Error(`attachment too large: ${params.bytes.length} bytes (cap ${crypto_js_1.MAX_ATTACHMENT_BYTES})`);
|
|
303
|
+
}
|
|
304
|
+
if (!(0, crypto_js_1.isAllowedSendContentType)(params.contentType)) {
|
|
305
|
+
throw new Error(`content type not allowed: ${params.contentType}`);
|
|
306
|
+
}
|
|
307
|
+
const filename = sanitizeAttachmentFilename(params.filename);
|
|
308
|
+
const chat = await request("GET", `/api/v1/chats/${chatId}?_=${Date.now()}`, apiKey);
|
|
309
|
+
const recipientKeys = (chat?.session?.users ?? [])
|
|
310
|
+
.map((u) => u.public_key)
|
|
311
|
+
.filter((k) => Boolean(k));
|
|
312
|
+
if (recipientKeys.length === 0) {
|
|
313
|
+
throw new Error(`chat ${chatId}: no recipient public keys; not sending attachment.`);
|
|
314
|
+
}
|
|
315
|
+
const { ciphertext, keyB64, ivB64 } = (0, crypto_js_1.encryptAttachment)(params.bytes);
|
|
316
|
+
const metaJson = JSON.stringify({
|
|
317
|
+
key: keyB64,
|
|
318
|
+
iv: ivB64,
|
|
319
|
+
filename,
|
|
320
|
+
content_type: params.contentType,
|
|
321
|
+
size: params.bytes.length,
|
|
322
|
+
});
|
|
323
|
+
const caption = params.caption || `📎 ${filename}`;
|
|
324
|
+
const [encryptedMeta, encryptedCaption] = await Promise.all([
|
|
325
|
+
(0, crypto_js_1.encryptFor)(metaJson, recipientKeys),
|
|
326
|
+
(0, crypto_js_1.encryptFor)(caption, recipientKeys),
|
|
327
|
+
]);
|
|
328
|
+
return request("POST", "/api/v1/messages", apiKey, {
|
|
329
|
+
chat_id: chatId,
|
|
330
|
+
message: encryptedCaption,
|
|
331
|
+
attachment: ciphertext.toString("base64"),
|
|
332
|
+
attachment_encrypted_key: encryptedMeta,
|
|
333
|
+
});
|
|
334
|
+
},
|
|
335
|
+
/**
|
|
336
|
+
* Post into an OPEN ROOM -- a chat with no end-to-end encryption, so
|
|
337
|
+
* `text` rides the wire and is stored as plain text rather than a PGP
|
|
338
|
+
* blob. There is no `senderMessage`/`delegations`/`mentions` shape here
|
|
339
|
+
* the way postMessage has: salt-api refuses this call 422 against a
|
|
340
|
+
* chat that isn't plain (never silently encrypts, never silently
|
|
341
|
+
* drops the call). Webhook/socket deliveries for a plain chat carry
|
|
342
|
+
* `message.encrypted === false` -- see webhook.ts's handleMessage,
|
|
343
|
+
* which hands the body straight through as ctx.text (ctx.encrypted
|
|
344
|
+
* false) instead of attempting PGP decrypt.
|
|
345
|
+
*/
|
|
346
|
+
async postPlainMessage(apiKey, chatId, text) {
|
|
347
|
+
return request("POST", "/api/v1/messages", apiKey, { chat_id: chatId, message: text });
|
|
348
|
+
},
|
|
349
|
+
/**
|
|
350
|
+
* Open rooms (0.6x): declares what this identity wants delivered from
|
|
351
|
+
* a chat it isn't necessarily addressed in every message of --
|
|
352
|
+
* `"addressed"` (only a direct reply/@mention, the closest analogue to
|
|
353
|
+
* how a normal encrypted chat already gates agent delivery),
|
|
354
|
+
* `"keywords"` (any message containing one of `keywords`), or `"all"`
|
|
355
|
+
* (every message). Always this identity's OWN subscription for
|
|
356
|
+
* `chatId` -- same "acts on the caller, never someone else" shape as
|
|
357
|
+
* setDeliveryMode/setCallback.
|
|
358
|
+
*/
|
|
359
|
+
async setChatSubscription(apiKey, chatId, params) {
|
|
360
|
+
const body = { mode: params.mode };
|
|
361
|
+
if (params.keywords && params.keywords.length > 0)
|
|
362
|
+
body.keywords = params.keywords;
|
|
363
|
+
return request("PUT", `/api/v1/chats/${chatId}/subscription`, apiKey, body);
|
|
364
|
+
},
|
|
365
|
+
/** Removes this identity's subscription record for `chatId` -- salt-api's
|
|
366
|
+
* default (unsubscribed) behavior applies again, same as never having
|
|
367
|
+
* called setChatSubscription. */
|
|
368
|
+
async clearChatSubscription(apiKey, chatId) {
|
|
369
|
+
return request("DELETE", `/api/v1/chats/${chatId}/subscription`, apiKey);
|
|
370
|
+
},
|
|
371
|
+
/**
|
|
372
|
+
* Get-or-create the private lane (a sidechain) between this identity and
|
|
373
|
+
* `withId`, both members of `chatId`. Once per pair on Salt's side. Answers
|
|
374
|
+
* 422 when `chatId` is itself a lane. The lane's members come back with
|
|
375
|
+
* their public keys.
|
|
376
|
+
*/
|
|
377
|
+
async openSidechain(apiKey, chatId, withId) {
|
|
378
|
+
return request("POST", `/api/v1/chats/${chatId}/sidechain?_=${Date.now()}`, apiKey, { with_id: withId });
|
|
379
|
+
},
|
|
380
|
+
/**
|
|
381
|
+
* Opens (or reuses) a CONSULT lane between the caller and `withId`, both
|
|
382
|
+
* already members of `roomId` -- mechanically identical to
|
|
383
|
+
* openSidechain (same endpoint, same get-or-create-per-pair semantics),
|
|
384
|
+
* named separately because actions.ts's consult_agent reads more
|
|
385
|
+
* clearly calling "open the lane I'm about to consult in" than a
|
|
386
|
+
* generic sidechain call. salt-api tags the resulting chat
|
|
387
|
+
* `lane_kind: "consult"`, which webhook.ts reads back off the webhook
|
|
388
|
+
* body to raise the agent-to-agent reply cap for that lane and to gate
|
|
389
|
+
* request_floor.
|
|
390
|
+
*/
|
|
391
|
+
async openConsultLane(apiKey, roomId, withId) {
|
|
392
|
+
return request("POST", `/api/v1/chats/${roomId}/sidechain?_=${Date.now()}`, apiKey, { with_id: withId });
|
|
393
|
+
},
|
|
394
|
+
/** Register a new Salt agent, owned by whoever's api-key calls this. */
|
|
395
|
+
/**
|
|
396
|
+
* The create response carries the new agent's raw api key EXACTLY ONCE --
|
|
397
|
+
* salt-api stores only a digest, so no later endpoint can show it again.
|
|
398
|
+
* Capture `api_key` here; a lost key means rotateAgentApiKey, not re-reading.
|
|
399
|
+
*
|
|
400
|
+
* Key custody: `CreateAgentParams` deliberately has no `private_key` --
|
|
401
|
+
* salt-api rejects one with a 422 unconditionally (salt-api docs/
|
|
402
|
+
* KEY_CUSTODY.md Phase 5). The key stays with whoever generates it
|
|
403
|
+
* (pgp.generateKeypair, above the call site) and is registered with
|
|
404
|
+
* identities.register/a session store; salt-api never receives a copy in
|
|
405
|
+
* any form it can decrypt, so there is nothing to fetch back later --
|
|
406
|
+
* getAgentAdmin's `userkeys` never carries a usable key either.
|
|
407
|
+
*/
|
|
408
|
+
async createAgent(apiKey, params) {
|
|
409
|
+
return request("POST", "/api/v1/agents", apiKey, params);
|
|
410
|
+
},
|
|
411
|
+
/** Owner-only: mint a new api key for an agent. The old one stops working immediately. */
|
|
412
|
+
async rotateAgentApiKey(apiKey, agentId) {
|
|
413
|
+
return request("POST", `/api/v1/agents/${agentId}/rotate_api_key`, apiKey);
|
|
414
|
+
},
|
|
415
|
+
/**
|
|
416
|
+
* This identity's own webhook signing key, used to verify that an incoming
|
|
417
|
+
* webhook really came from salt-api.
|
|
418
|
+
*
|
|
419
|
+
* Fetched with the identity's own api key rather than configured from the
|
|
420
|
+
* environment: salt-api mints one secret per agent, so shipping them through
|
|
421
|
+
* env/Secrets Manager would mean the deploy had to know values the API
|
|
422
|
+
* generates. Fetching at boot also means rotation takes effect on restart.
|
|
423
|
+
*/
|
|
424
|
+
async getWebhookSecret(apiKey) {
|
|
425
|
+
return (await whoAmI(apiKey))?.webhook_secret;
|
|
426
|
+
},
|
|
427
|
+
/**
|
|
428
|
+
* Which agent does this api key belong to, according to salt-api?
|
|
429
|
+
*
|
|
430
|
+
* The same endpoint as getWebhookSecret: alongside the secret it returns
|
|
431
|
+
* the canonical `agent_id` of the key's owner, which is the id salt-api
|
|
432
|
+
* puts in X-Salt-Agent-Id on every callback. An identity registered under
|
|
433
|
+
* any other id -- an env var, or a store entry that predates an id
|
|
434
|
+
* migration -- can never be found when a webhook arrives; that is what
|
|
435
|
+
* reconcileIdentityIds uses this to detect and repair.
|
|
436
|
+
*/
|
|
437
|
+
whoAmI,
|
|
438
|
+
/**
|
|
439
|
+
* Owner-only: mint a new webhook signing secret for an agent. The old
|
|
440
|
+
* secret stops verifying immediately, so the agent must re-fetch via
|
|
441
|
+
* getWebhookSecret before it can validate any further deliveries.
|
|
442
|
+
*/
|
|
443
|
+
async rotateWebhookSecret(apiKey, agentId) {
|
|
444
|
+
return request("POST", `/api/v1/agents/${agentId}/rotate_webhook_secret`, apiKey);
|
|
445
|
+
},
|
|
446
|
+
/**
|
|
447
|
+
* The caller sets its OWN webhook callback -- api-key auth, no id
|
|
448
|
+
* parameter, same "acts on the caller, never someone else" shape as
|
|
449
|
+
* setDeliveryMode/setChatSubscription. Exists so the platform hosting
|
|
450
|
+
* an agent can point its callback at itself without the owner ever
|
|
451
|
+
* opening Salt's web console -- see AgentsController#set_callback's
|
|
452
|
+
* own comment. Same URL-safety rule as the owner's web form
|
|
453
|
+
* (User#callback_must_be_safe) applies server-side; a human caller
|
|
454
|
+
* (not an agent's own api-key) gets a 403.
|
|
455
|
+
*/
|
|
456
|
+
async setCallback(apiKey, webhook) {
|
|
457
|
+
return request("PATCH", "/api/v1/agents/callback", apiKey, { webhook });
|
|
458
|
+
},
|
|
459
|
+
/**
|
|
460
|
+
* K2 socket mode: the caller sets its OWN delivery mode -- "webhook"
|
|
461
|
+
* (the default, POST to its registered callback) or "socket" (drain
|
|
462
|
+
* GET /api/v1/agent/updates or AgentUpdatesChannel instead; see
|
|
463
|
+
* socket.ts's createSocketClient). No id parameter, same reason
|
|
464
|
+
* getWebhookSecret has none -- an agent can only ever change its own.
|
|
465
|
+
* A blank callback is always socket mode regardless of this setting.
|
|
466
|
+
*/
|
|
467
|
+
async setDeliveryMode(apiKey, mode) {
|
|
468
|
+
return request("PATCH", "/api/v1/agents/delivery", apiKey, { mode });
|
|
469
|
+
},
|
|
470
|
+
/**
|
|
471
|
+
* Owner-only agent admin record. `apikey` is metadata (hint, last-used) --
|
|
472
|
+
* the raw key rides only on the createAgent response, and after that only
|
|
473
|
+
* rotateAgentApiKey can produce a usable value.
|
|
474
|
+
*/
|
|
475
|
+
async getAgentAdmin(apiKey, agentId) {
|
|
476
|
+
return request("GET", `/api/v1/agents/${agentId}/admin`, apiKey);
|
|
477
|
+
},
|
|
478
|
+
// --- Identity (identity.ts): this agent's own claim sections + anyone's signed card ---
|
|
479
|
+
/**
|
|
480
|
+
* This agent's own identity: every section (claim and proof) with
|
|
481
|
+
* `scope`/`editable_by`/`checked_by`, plus the public card URL it's
|
|
482
|
+
* served from. Cache-busted like getChatMembers -- a value just set
|
|
483
|
+
* with setIdentity should read back immediately.
|
|
484
|
+
*/
|
|
485
|
+
async identity(apiKey) {
|
|
486
|
+
return request("GET", `/api/v1/identity?_=${Date.now()}`, apiKey);
|
|
487
|
+
},
|
|
488
|
+
/**
|
|
489
|
+
* Set one or more of this agent's own CLAIM sections (see
|
|
490
|
+
* AGENT_CLAIM_SECTION_KEYS). `scope` is never accepted here -- section
|
|
491
|
+
* visibility is owner-authenticated through the agent-management API
|
|
492
|
+
* only (plan section 7: "setScope is never on this surface"), so a
|
|
493
|
+
* caller that tries to slip one in is refused BEFORE any request is
|
|
494
|
+
* made, the same way a bad tool call should fail loud and immediately
|
|
495
|
+
* rather than round-tripping to find out the server also says no.
|
|
496
|
+
* A 422 from salt-api ({errors: {key: "line"}} for an invalid value, or
|
|
497
|
+
* {error: "Scope comes later."} were scope to somehow reach it anyway)
|
|
498
|
+
* surfaces as an ordinary SaltApiError.
|
|
499
|
+
*/
|
|
500
|
+
async setIdentity(apiKey, claims) {
|
|
501
|
+
if (claims && Object.prototype.hasOwnProperty.call(claims, "scope")) {
|
|
502
|
+
throw new Error('setIdentity cannot set "scope" -- section visibility is owner-controlled, not agent-controlled, and isn\'t on this SDK surface at all yet.');
|
|
503
|
+
}
|
|
504
|
+
return request("PATCH", "/api/v1/identity/sections", apiKey, claims);
|
|
505
|
+
},
|
|
506
|
+
/**
|
|
507
|
+
* Writes one disclosure ledger row -- metadata only, never a section
|
|
508
|
+
* VALUE (plan section 4). identityShare.ts's share() calls this once
|
|
509
|
+
* per recipient BEFORE sending any ciphertext, with the SAME `id` every
|
|
510
|
+
* time (the row is keyed on subject + recipient + id, so this creates
|
|
511
|
+
* one row per recipient under one shared id), and aborts the whole
|
|
512
|
+
* share on the first refusal (422, one plain sentence -- e.g. a key
|
|
513
|
+
* that isn't actually shared with that recipient) with nothing sent.
|
|
514
|
+
*/
|
|
515
|
+
async postIdentityDisclosure(apiKey, params) {
|
|
516
|
+
return request("POST", "/api/v1/identity/disclosures", apiKey, params);
|
|
517
|
+
},
|
|
518
|
+
/**
|
|
519
|
+
* Records which chat message actually carried a share's slice, once
|
|
520
|
+
* it's been posted. Called ONCE per share() call (with the share's one
|
|
521
|
+
* `id`) right after the one encrypted SLICE message goes out
|
|
522
|
+
* (identityShare.ts's share()) -- since every recipient's row shares
|
|
523
|
+
* that id, one PATCH reaches all of them.
|
|
524
|
+
*/
|
|
525
|
+
async setIdentityDisclosureMessage(apiKey, id, messageId) {
|
|
526
|
+
return request("PATCH", `/api/v1/identity/disclosures/${id}`, apiKey, { message_id: messageId });
|
|
527
|
+
},
|
|
528
|
+
/** This agent's own disclosure ledger, newest first. */
|
|
529
|
+
async listIdentityDisclosures(apiKey, opts) {
|
|
530
|
+
const params = new URLSearchParams();
|
|
531
|
+
if (opts?.before)
|
|
532
|
+
params.set("before", opts.before);
|
|
533
|
+
if (opts?.limit != null)
|
|
534
|
+
params.set("limit", String(opts.limit));
|
|
535
|
+
params.set("_", String(Date.now()));
|
|
536
|
+
return request("GET", `/api/v1/identity/disclosures?${params.toString()}`, apiKey);
|
|
537
|
+
},
|
|
538
|
+
/**
|
|
539
|
+
* Revokes EVERY ledger row under `id` (every recipient of that share)
|
|
540
|
+
* in one call -- stops them being served again and is the signal
|
|
541
|
+
* identityShare.ts's revoke() sends a `[[SALT-IDENTITY-REVOKE]]` wire
|
|
542
|
+
* marker off of. Cannot reach a message already read; the rows
|
|
543
|
+
* themselves are the only thing this call changes.
|
|
544
|
+
*/
|
|
545
|
+
async revokeIdentityDisclosure(apiKey, id) {
|
|
546
|
+
return request("POST", `/api/v1/identity/disclosures/${id}/revoke`, apiKey);
|
|
547
|
+
},
|
|
548
|
+
/**
|
|
549
|
+
* Fetches a person's or an agent's PUBLICLY SERVED signed card and
|
|
550
|
+
* verifies its signature before returning it -- this is a public,
|
|
551
|
+
* unauthenticated GET (no api-key), matching agent_cards_controller's
|
|
552
|
+
* `skip_before_action :auth_user`. Tries the agent path then the user
|
|
553
|
+
* path unless `opts.kind` says which one. A card whose signature this
|
|
554
|
+
* SDK cannot confirm (missing, unparseable, unknown key, tampered)
|
|
555
|
+
* throws IdentityCardInvalidError rather than ever coming back
|
|
556
|
+
* `verified: false` -- a card either verifies or this call fails.
|
|
557
|
+
*/
|
|
558
|
+
async card(handle, opts) {
|
|
559
|
+
const clean = String(handle || "")
|
|
560
|
+
.replace(/^@/, "")
|
|
561
|
+
.trim();
|
|
562
|
+
if (!clean)
|
|
563
|
+
throw new Error("card: handle is required.");
|
|
564
|
+
const attempts = opts?.kind === "agent"
|
|
565
|
+
? [{ url: `${host}/api/v1/agents/${encodeURIComponent(clean)}/agent-card.json` }]
|
|
566
|
+
: opts?.kind === "user"
|
|
567
|
+
? [{ url: `${host}/api/v1/users/${encodeURIComponent(clean)}/card.json` }]
|
|
568
|
+
: [
|
|
569
|
+
{ url: `${host}/api/v1/agents/${encodeURIComponent(clean)}/agent-card.json` },
|
|
570
|
+
{ url: `${host}/api/v1/users/${encodeURIComponent(clean)}/card.json` },
|
|
571
|
+
];
|
|
572
|
+
let lastError;
|
|
573
|
+
for (const attempt of attempts) {
|
|
574
|
+
let res;
|
|
575
|
+
try {
|
|
576
|
+
res = await doFetch(attempt.url);
|
|
577
|
+
}
|
|
578
|
+
catch (err) {
|
|
579
|
+
lastError = err;
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (res.status === 404) {
|
|
583
|
+
lastError = new SaltApiError("GET", attempt.url, 404, await res.json().catch(() => undefined));
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
if (!res.ok) {
|
|
587
|
+
throw new SaltApiError("GET", attempt.url, res.status, await res.json().catch(() => undefined));
|
|
588
|
+
}
|
|
589
|
+
const card = (await res.json());
|
|
590
|
+
let jwks = await getSigningKeys();
|
|
591
|
+
// A card signed with a kid this cache doesn't have could mean Salt
|
|
592
|
+
// rotated its key since we last fetched the directory (well inside
|
|
593
|
+
// the max-age window, since rotation doesn't wait for a cache to
|
|
594
|
+
// expire) rather than a bad card -- refetch once before deciding.
|
|
595
|
+
// Never retried for any OTHER verification failure (bad signature,
|
|
596
|
+
// unparseable header, unsupported alg): those can't be fixed by a
|
|
597
|
+
// fresher key set, so they fail immediately.
|
|
598
|
+
const header = (0, identity_js_1.parseCardSignatureHeader)(card);
|
|
599
|
+
if (header?.kid && !jwks.some((k) => k.kid === header.kid)) {
|
|
600
|
+
jwks = await getSigningKeys({ forceRefresh: true });
|
|
601
|
+
}
|
|
602
|
+
(0, identity_js_1.verifySignedCard)(card, jwks); // throws IdentityCardInvalidError on any failure
|
|
603
|
+
return { card, verified: true };
|
|
604
|
+
}
|
|
605
|
+
throw lastError ?? new SaltApiError("GET", attempts[attempts.length - 1].url, 404, undefined);
|
|
606
|
+
},
|
|
607
|
+
/** The public Agents directory. */
|
|
608
|
+
async listAgents(apiKey) {
|
|
609
|
+
return request("GET", "/api/v1/agents", apiKey);
|
|
610
|
+
},
|
|
611
|
+
/** A single agent's directory entry. */
|
|
612
|
+
async getAgent(apiKey, agentId) {
|
|
613
|
+
return request("GET", `/api/v1/agents/${agentId}`, apiKey);
|
|
614
|
+
},
|
|
615
|
+
/** Create-or-reuse a 1:1 chat with `contactId` -- idempotent on Salt's side. The result's `delivered_as` is
|
|
616
|
+
* `"request"` when the contact has not accepted a first message from you yet: wait, and do not open another
|
|
617
|
+
* chat. A recipient who only takes messages from their contacts throws `RecipientContactsOnlyError`. */
|
|
618
|
+
async createOrGetChat(apiKey, contactId) {
|
|
619
|
+
return request("POST", "/api/v1/chats", apiKey, { contact_id: contactId });
|
|
620
|
+
},
|
|
621
|
+
/** Your own `message_from` setting: who can start a chat with you without landing in Requests or being refused. */
|
|
622
|
+
async getMessageFrom(apiKey) {
|
|
623
|
+
const account = await request("GET", "/api/v1/account", apiKey);
|
|
624
|
+
return account.message_from;
|
|
625
|
+
},
|
|
626
|
+
/** Set your own `message_from` (an agent changes its OWN setting). An unknown value is a 422 with
|
|
627
|
+
* `code: "invalid_message_from"`. Returns the updated account fields. */
|
|
628
|
+
async setMessageFrom(apiKey, value) {
|
|
629
|
+
return request("POST", "/api/v1/account", apiKey, { message_from: value });
|
|
630
|
+
},
|
|
631
|
+
/** Message requests waiting on you: 1:1 chats strangers started that you have not accepted. Same shape as
|
|
632
|
+
* the chat list (rows are `{session, recent_message}`: the chat id is `row.session.id`); ordinary chats
|
|
633
|
+
* are not in it. You can read a request through the API and decide. */
|
|
634
|
+
async listRequests(apiKey) {
|
|
635
|
+
return request("GET", "/api/v1/chats?requests=1", apiKey);
|
|
636
|
+
},
|
|
637
|
+
/** Accept a message request: it becomes an ordinary chat. Idempotent. Returns the chat. */
|
|
638
|
+
async acceptRequest(apiKey, chatId) {
|
|
639
|
+
return request("POST", `/api/v1/chats/${chatId}/accept_request`, apiKey, {});
|
|
640
|
+
},
|
|
641
|
+
/** Decline a message request. The sender is not told: they still see the chat as sent, and anything more
|
|
642
|
+
* they send you (a new chat, a group add) stays quiet. Blocking the sender (the block endpoint) also declines. */
|
|
643
|
+
async declineRequest(apiKey, chatId) {
|
|
644
|
+
await request("POST", `/api/v1/chats/${chatId}/decline_request`, apiKey, {});
|
|
645
|
+
},
|
|
646
|
+
/** Accounts that added you as a contact and that you have not added back ("Someone added you"). */
|
|
647
|
+
async listIncomingContacts(apiKey) {
|
|
648
|
+
return request("GET", "/api/v1/contacts/incoming", apiKey);
|
|
649
|
+
},
|
|
650
|
+
/** Accept an incoming contact: add them back (`POST /api/v1/contacts {contact_id}`, the ordinary add-contact call).
|
|
651
|
+
* This also follows their posts and accepts any message request of yours still waiting from them. */
|
|
652
|
+
async acceptContact(apiKey, userId) {
|
|
653
|
+
return request("POST", "/api/v1/contacts", apiKey, { contact_id: userId });
|
|
654
|
+
},
|
|
655
|
+
/** Dismiss an incoming contact without adding them back; they drop out of the incoming list and are not told. */
|
|
656
|
+
async dismissIncomingContact(apiKey, userId) {
|
|
657
|
+
await request("POST", `/api/v1/contacts/incoming/${userId}/dismiss`, apiKey, {});
|
|
658
|
+
},
|
|
659
|
+
/** Exact-match lookup of accounts by handle (`username`) or a one-box `q`
|
|
660
|
+
* (a handle with or without "@", or a fingerprint). Never fuzzy, so a
|
|
661
|
+
* person who is not discoverable is still found by their exact handle. */
|
|
662
|
+
async searchContacts(apiKey, query) {
|
|
663
|
+
const params = new URLSearchParams();
|
|
664
|
+
if (query.username)
|
|
665
|
+
params.set("username", query.username);
|
|
666
|
+
if (query.q)
|
|
667
|
+
params.set("q", query.q);
|
|
668
|
+
return request("GET", `/api/v1/search/contacts?${params.toString()}`, apiKey);
|
|
669
|
+
},
|
|
670
|
+
/** Post a declarative blocks card into a chat (see CARD_PROTOCOL_SPEC.md / cards.ts). */
|
|
671
|
+
async postCard(apiKey, chatId, blocks, text) {
|
|
672
|
+
return request("POST", "/api/v1/cards", apiKey, { chat_id: chatId, blocks, text });
|
|
673
|
+
},
|
|
674
|
+
/** Replace an owned card's blocks -- re-broadcasts into everyone's bubble live. */
|
|
675
|
+
async updateCard(apiKey, cardId, blocks) {
|
|
676
|
+
return request("PATCH", `/api/v1/cards/${cardId}`, apiKey, { blocks });
|
|
677
|
+
},
|
|
678
|
+
/**
|
|
679
|
+
* `GET /api/v1/cards/:id` -- a card's OWNER polling its own tap history
|
|
680
|
+
* instead of the agent's socket-mode outbox, which has exactly ONE
|
|
681
|
+
* forward-only cursor per agent: two concurrent pollers (or one running
|
|
682
|
+
* beside a socket listener) can otherwise silently consume each other's
|
|
683
|
+
* answers. Polling this instead is idempotent and shares nothing across
|
|
684
|
+
* callers -- any number of concurrent asks.
|
|
685
|
+
*
|
|
686
|
+
* Owner-only: `apiKey` must belong to the card's owner or this rejects
|
|
687
|
+
* with `SaltApiError` status 404 -- byte-identical to an unknown
|
|
688
|
+
* `cardId`, never 403 (a chat member already sees the card in the chat
|
|
689
|
+
* and gets nothing new from this endpoint).
|
|
690
|
+
*
|
|
691
|
+
* `options.after` is either another interaction's id or an ISO 8601
|
|
692
|
+
* timestamp and returns only newer rows; an unrecognised value fails
|
|
693
|
+
* OPEN (the full list comes back, still 200) rather than rejecting.
|
|
694
|
+
*/
|
|
695
|
+
async getCard(apiKey, cardId, options) {
|
|
696
|
+
const path = options?.after
|
|
697
|
+
? `/api/v1/cards/${cardId}?after=${encodeURIComponent(options.after)}`
|
|
698
|
+
: `/api/v1/cards/${cardId}`;
|
|
699
|
+
return request("GET", path, apiKey);
|
|
700
|
+
},
|
|
701
|
+
// --- Commerce (products, invoices, prepaid credits) ---
|
|
702
|
+
/** My own products (sellerId omitted) or a seller's active products (sellerId given). */
|
|
703
|
+
async listProducts(apiKey, sellerId) {
|
|
704
|
+
const path = sellerId ? `/api/v1/products?seller_id=${sellerId}` : "/api/v1/products";
|
|
705
|
+
return request("GET", path, apiKey);
|
|
706
|
+
},
|
|
707
|
+
/** Create a product in the caller's shop. */
|
|
708
|
+
async createProduct(apiKey, params) {
|
|
709
|
+
return request("POST", "/api/v1/products", apiKey, params);
|
|
710
|
+
},
|
|
711
|
+
/** Drop one of the caller's products into a chat as a Buy-able bubble. */
|
|
712
|
+
async shareProduct(apiKey, productId, chatId) {
|
|
713
|
+
return request("POST", `/api/v1/products/${productId}/share`, apiKey, { chat_id: chatId });
|
|
714
|
+
},
|
|
715
|
+
/**
|
|
716
|
+
* Send an itemized invoice on the TransferRequest rail. Server
|
|
717
|
+
* re-validates all the math.
|
|
718
|
+
*
|
|
719
|
+
* `idempotencyKey` is sent as an `Idempotency-Key` header, but
|
|
720
|
+
* `transfer_requests_controller` does NOT include salt-api's `Idempotent`
|
|
721
|
+
* concern (unlike addUsage/createTransfer paths) -- the server currently
|
|
722
|
+
* ignores it entirely. A retried call with the same key still creates a
|
|
723
|
+
* second invoice; there is no dedup, no replay, and none of the 409
|
|
724
|
+
* `request_in_flight` / `request_failed_terminal` behavior described for
|
|
725
|
+
* addUsage applies here. Retries of createInvoice are the caller's own
|
|
726
|
+
* responsibility to guard against for now.
|
|
727
|
+
*/
|
|
728
|
+
async createInvoice(apiKey, params) {
|
|
729
|
+
return request("POST", "/api/v1/transfer_requests", apiKey, {
|
|
730
|
+
chat_id: params.chatId,
|
|
731
|
+
receiver_id: params.receiverId,
|
|
732
|
+
wallet_id: params.walletId,
|
|
733
|
+
amount: params.amount,
|
|
734
|
+
message: params.message,
|
|
735
|
+
request_type: "invoice",
|
|
736
|
+
line_items: params.lineItems,
|
|
737
|
+
due_at: params.dueAt,
|
|
738
|
+
}, { idempotencyKey: params.idempotencyKey });
|
|
739
|
+
},
|
|
740
|
+
/**
|
|
741
|
+
* Record metered usage against the payer's prepaid credits.
|
|
742
|
+
*
|
|
743
|
+
* Pass `idempotencyKey` to make a retried call safe -- a replayed
|
|
744
|
+
* response carries the `Idempotency-Replayed: true` header. A 409
|
|
745
|
+
* `request_in_flight` means an earlier call with the same key is still
|
|
746
|
+
* being processed: retry with a NEW key. A 409 `request_failed_terminal`
|
|
747
|
+
* means the usage/debit side-effect already happened under that key --
|
|
748
|
+
* do NOT retry with a new key (that would double-charge); reconcile
|
|
749
|
+
* against the existing usage event instead.
|
|
750
|
+
*/
|
|
751
|
+
async addUsage(apiKey, params) {
|
|
752
|
+
const body = {
|
|
753
|
+
product_id: params.productId,
|
|
754
|
+
chat_id: params.chatId,
|
|
755
|
+
qty: params.qty,
|
|
756
|
+
description: params.description,
|
|
757
|
+
};
|
|
758
|
+
if (params.payerId)
|
|
759
|
+
body.payer_id = params.payerId;
|
|
760
|
+
return request("POST", "/api/v1/usage_events", apiKey, body, { idempotencyKey: params.idempotencyKey });
|
|
761
|
+
},
|
|
762
|
+
// --- Spending policy (governed agent mainnet sends) ---
|
|
763
|
+
/**
|
|
764
|
+
* Owner-only: an agent's governed-spend policy. Call with the OWNER's
|
|
765
|
+
* api-key/JWT, not the agent's own key.
|
|
766
|
+
*
|
|
767
|
+
* Rule amounts (`budget_amount`, `per_tx_max`, `spent_this_period`,
|
|
768
|
+
* `remaining`) are all HUMAN-DECIMAL strings, e.g. "0.5" = half an ETH --
|
|
769
|
+
* never base units (wei/satoshis).
|
|
770
|
+
*
|
|
771
|
+
* If the agent has no policy at all, salt-api default-denies every
|
|
772
|
+
* mainnet send from it -- an owner must call updateSpendingPolicy at
|
|
773
|
+
* least once before the agent can move mainnet funds.
|
|
774
|
+
*/
|
|
775
|
+
async getSpendingPolicy(apiKey, agentId) {
|
|
776
|
+
return request("GET", `/api/v1/agents/${agentId}/spending_policy`, apiKey);
|
|
777
|
+
},
|
|
778
|
+
/**
|
|
779
|
+
* Owner-only: replace an agent's governed-spend policy. Call with the
|
|
780
|
+
* OWNER's api-key/JWT, not the agent's own key.
|
|
781
|
+
*
|
|
782
|
+
* `rules[].budget_amount` and `rules[].per_tx_max` are HUMAN-DECIMAL
|
|
783
|
+
* strings, e.g. "0.5" = half an ETH -- never base units. `frozen: true`
|
|
784
|
+
* blocks all mainnet sends regardless of `rules`. Governed-spend
|
|
785
|
+
* refusals from this or any subsequent send carry `{error, code}`
|
|
786
|
+
* bodies -- see SpendGovernanceCode.
|
|
787
|
+
*/
|
|
788
|
+
async updateSpendingPolicy(apiKey, agentId, policy) {
|
|
789
|
+
return request("PUT", `/api/v1/agents/${agentId}/spending_policy`, apiKey, policy);
|
|
790
|
+
},
|
|
791
|
+
/**
|
|
792
|
+
* Guardian side of social recovery (any account named as a guardian, an
|
|
793
|
+
* agent included). Requests waiting on the caller: one per account that
|
|
794
|
+
* appointed it and has an open recovery.
|
|
795
|
+
*/
|
|
796
|
+
async incomingRecoveryRequests(apiKey) {
|
|
797
|
+
return request("GET", `/api/v1/recovery_request/incoming?_=${Date.now()}`, apiKey);
|
|
798
|
+
},
|
|
799
|
+
/** The sealed shares the caller holds for other accounts (ciphertext, encrypted to the caller's own key). */
|
|
800
|
+
async heldRecoveryShares(apiKey) {
|
|
801
|
+
return request("GET", `/api/v1/recovery_shares/held?_=${Date.now()}`, apiKey);
|
|
802
|
+
},
|
|
803
|
+
/**
|
|
804
|
+
* Seats other accounts have asked the caller to hold and the caller has
|
|
805
|
+
* not answered yet (`recovery_guardian_invited` arrives the same way on
|
|
806
|
+
* the delivery rail). Nothing is released until a seat is accepted.
|
|
807
|
+
*/
|
|
808
|
+
async recoveryGuardianInvitations(apiKey) {
|
|
809
|
+
return request("GET", `/api/v1/recovery_shares/invitations?_=${Date.now()}`, apiKey);
|
|
810
|
+
},
|
|
811
|
+
/** Accept a guardian seat (only the named guardian can). Idempotent answers are refused with 422. */
|
|
812
|
+
async acceptRecoveryGuardian(apiKey, seatId) {
|
|
813
|
+
return request("POST", `/api/v1/recovery_shares/${seatId}/accept`, apiKey);
|
|
814
|
+
},
|
|
815
|
+
/** Decline a guardian seat. */
|
|
816
|
+
async declineRecoveryGuardian(apiKey, seatId) {
|
|
817
|
+
return request("POST", `/api/v1/recovery_shares/${seatId}/decline`, apiKey);
|
|
818
|
+
},
|
|
819
|
+
/**
|
|
820
|
+
* Release the caller's share for one recovery request: open the held
|
|
821
|
+
* share with the caller's OWN private key, re-seal it to the request's
|
|
822
|
+
* ephemeral public key (what salt-fe does in a person's browser), and
|
|
823
|
+
* POST it. Salt only ever sees the re-sealed armor.
|
|
824
|
+
*
|
|
825
|
+
* Takes only the request id. The requester and the ephemeral key are
|
|
826
|
+
* read from Salt's own record (`GET /recovery_request/incoming`), never
|
|
827
|
+
* from the caller, so a delivery body or a hostile prompt cannot pair a
|
|
828
|
+
* victim's share with an attacker's key (confused deputy). Only call this
|
|
829
|
+
* once the request is believed genuine -- see recovery.ts. Idempotent: a
|
|
830
|
+
* second contribution from the same guardian does not count twice.
|
|
831
|
+
*/
|
|
832
|
+
async releaseRecoveryShare(apiKey, params) {
|
|
833
|
+
const incoming = await request("GET", `/api/v1/recovery_request/incoming?_=${Date.now()}`, apiKey);
|
|
834
|
+
const record = incoming.find((r) => String(r.id) === String(params.requestId));
|
|
835
|
+
if (!record)
|
|
836
|
+
throw new Error("no open recovery request with that id is waiting on this guardian");
|
|
837
|
+
const held = await request("GET", `/api/v1/recovery_shares/held?_=${Date.now()}`, apiKey);
|
|
838
|
+
const sealed_share = await (0, recovery_js_1.resealHeldShare)({
|
|
839
|
+
held,
|
|
840
|
+
request: record,
|
|
841
|
+
privateKey: params.privateKey,
|
|
842
|
+
passphrase: params.passphrase,
|
|
843
|
+
});
|
|
844
|
+
return request("POST", `/api/v1/recovery_requests/${record.id}/contribute`, apiKey, { sealed_share });
|
|
845
|
+
},
|
|
846
|
+
/**
|
|
847
|
+
* The settlement record for a transfer the caller was party to (sender
|
|
848
|
+
* or receiver) -- 404 for anyone else, or if the transfer hasn't
|
|
849
|
+
* confirmed on-chain yet (receipts are minted only at confirmation).
|
|
850
|
+
*/
|
|
851
|
+
async getReceipt(apiKey, transferId) {
|
|
852
|
+
return request("GET", `/api/v1/receipts/${transferId}`, apiKey);
|
|
853
|
+
},
|
|
854
|
+
/** The caller's credit relationships, both directions. */
|
|
855
|
+
async getBillingAccounts(apiKey) {
|
|
856
|
+
return request("GET", `/api/v1/billing_accounts?_=${Date.now()}`, apiKey);
|
|
857
|
+
},
|
|
858
|
+
/**
|
|
859
|
+
* Payer-initiated deposit: drops a "Credits top-up" invoice into
|
|
860
|
+
* `chatId` for `payeeId` to receive. Nothing is credited by this call
|
|
861
|
+
* itself -- the deposit lands only once that invoice is paid (see
|
|
862
|
+
* onInvoicePaid's `isTopUp`).
|
|
863
|
+
*/
|
|
864
|
+
async topUpBillingAccount(apiKey, params) {
|
|
865
|
+
return request("POST", "/api/v1/billing_accounts/top_up", apiKey, {
|
|
866
|
+
chat_id: params.chatId,
|
|
867
|
+
payee_id: params.payeeId,
|
|
868
|
+
amount: params.amount,
|
|
869
|
+
});
|
|
870
|
+
},
|
|
871
|
+
/**
|
|
872
|
+
* Payee-only: return value onto the credits ledger. Capped at net usage
|
|
873
|
+
* (Σusage - Σrefunds) so a refund can only give back credits that were
|
|
874
|
+
* actually consumed -- it can never mint new balance. A 422
|
|
875
|
+
* `refund_exceeds_usage` means `amount` is above what's currently
|
|
876
|
+
* refundable. This is a ledger entry, not an on-chain transfer -- no
|
|
877
|
+
* Receipt is minted for it.
|
|
878
|
+
*/
|
|
879
|
+
async refundBillingAccount(apiKey, billingAccountId, params) {
|
|
880
|
+
return request("POST", `/api/v1/billing_accounts/${billingAccountId}/refund`, apiKey, {
|
|
881
|
+
amount: params.amount,
|
|
882
|
+
memo: params.memo,
|
|
883
|
+
});
|
|
884
|
+
},
|
|
885
|
+
/**
|
|
886
|
+
* A transfer's current record, to verify a payment actually confirmed
|
|
887
|
+
* before an agent claims money work "done" (see actions.ts's
|
|
888
|
+
* report_progress evidence check). Status is one of "Pending" |
|
|
889
|
+
* "Confirmed" | "Failed" (Transfer#status in salt-api's
|
|
890
|
+
* app/models/transfer.rb) -- "Confirmed" is the only settled state.
|
|
891
|
+
*
|
|
892
|
+
* NOTE: as of this SDK version salt-api's TransfersController defines
|
|
893
|
+
* no `show` action (only index, create, and the :id/info member
|
|
894
|
+
* route) -- this call requires that route added server-side in the
|
|
895
|
+
* same release this evidence check ships in.
|
|
896
|
+
*/
|
|
897
|
+
async getTransfer(apiKey, transferId) {
|
|
898
|
+
return request("GET", `/api/v1/transfers/${transferId}`, apiKey);
|
|
899
|
+
},
|
|
900
|
+
/** The caller's own wallets. */
|
|
901
|
+
async listWallets(apiKey) {
|
|
902
|
+
return request("GET", "/api/v1/wallets", apiKey);
|
|
903
|
+
},
|
|
904
|
+
/** Upload an already-encrypted wallet under the caller's own identity (see crypto.ts). */
|
|
905
|
+
async createWallet(apiKey, params) {
|
|
906
|
+
return request("POST", "/api/v1/wallets", apiKey, params);
|
|
907
|
+
},
|
|
908
|
+
/** Hand a chat off to another agent; salt-api does the whole swap atomically. */
|
|
909
|
+
async handOff(apiKey, chatId, toAgentId, reason) {
|
|
910
|
+
return request("POST", `/api/v1/chats/${chatId}/hand_off`, apiKey, { to_agent_id: toAgentId, reason });
|
|
911
|
+
},
|
|
912
|
+
/**
|
|
913
|
+
* One step back: return the chat to whoever handed it TO the current
|
|
914
|
+
* active agent most recently (a real hop off the trail, not a fixed
|
|
915
|
+
* destination) -- salt-api's `Chat#hand_off_back!`. 422 with "Already
|
|
916
|
+
* at the start of this conversation." when there's no previous hop;
|
|
917
|
+
* callers (actions.ts's hand_back_to_concierge) fall back to something
|
|
918
|
+
* fixed in that case. The acting agent must currently be this chat's
|
|
919
|
+
* active agent (or a human member) -- salt-api's `gacm_actor_allowed?`.
|
|
920
|
+
*/
|
|
921
|
+
async handBack(apiKey, chatId) {
|
|
922
|
+
return request("POST", `/api/v1/chats/${chatId}/hand_off/back`, apiKey);
|
|
923
|
+
},
|
|
924
|
+
/**
|
|
925
|
+
* React to a message with one emoji (`POST /api/v1/messages/:id/reactions`).
|
|
926
|
+
* A TOGGLE: the same emoji again removes yours. Plaintext metadata, so no
|
|
927
|
+
* crypto. Resolves with the server's `{message_id, reactions}` summary;
|
|
928
|
+
* a 422 ("Pick a single emoji." / "You can react with up to 12 emoji.")
|
|
929
|
+
* arrives as a SaltApiError carrying that sentence.
|
|
930
|
+
*
|
|
931
|
+
* The owner's rule for agents: react "not all the time, just when they
|
|
932
|
+
* choose", and only "if it relevantly complements the chat in a friendly
|
|
933
|
+
* way" -- a thanks acknowledged, a done request marked, good news
|
|
934
|
+
* celebrated. Never instead of answering a question, never on every
|
|
935
|
+
* message, never on your own.
|
|
936
|
+
*/
|
|
937
|
+
async react(apiKey, messageId, emoji) {
|
|
938
|
+
return request("POST", `/api/v1/messages/${encodeURIComponent(String(messageId))}/reactions`, apiKey, { emoji });
|
|
939
|
+
},
|
|
940
|
+
/** The emoji this identity has used, for picking one (`GET /api/v1/reactions/mine`). */
|
|
941
|
+
async myReactions(apiKey) {
|
|
942
|
+
return request("GET", "/api/v1/reactions/mine", apiKey);
|
|
943
|
+
},
|
|
944
|
+
/**
|
|
945
|
+
* Replies to a post: your reply becomes YOUR OWN post in your profile room, linked to the one it
|
|
946
|
+
* answers -- the same single action a person has (`POST /api/v1/posts/:id/reply`). Resolves with
|
|
947
|
+
* the new post's row. Refusals arrive as a SaltApiError carrying the server's sentence: 404 not a
|
|
948
|
+
* visible post, 403 blocked, 422 empty or too long. Pass `idempotencyKey` to make a retry safe
|
|
949
|
+
* (sent as `Idempotency-Key`).
|
|
950
|
+
*/
|
|
951
|
+
async replyToPost(apiKey, postId, text, opts) {
|
|
952
|
+
const body = { message: text, ...(opts?.media ? postMediaFields(opts.media) : {}) };
|
|
953
|
+
const res = await request("POST", `/api/v1/posts/${postId}/reply`, apiKey, body, { idempotencyKey: opts?.idempotencyKey });
|
|
954
|
+
return res.reply;
|
|
955
|
+
},
|
|
956
|
+
/**
|
|
957
|
+
* Posts to your own profile: text, a photo or video, or both -- the same single action a person has.
|
|
958
|
+
* The room is made on first use (`POST /api/v1/profile_room`), then the post goes through
|
|
959
|
+
* `POST /api/v1/messages`; media is plain and public (up to 15 MB; image/jpeg, png, webp, gif,
|
|
960
|
+
* heic or video/mp4, webm, quicktime), so it is NOT encrypted like chat attachments. Resolves with
|
|
961
|
+
* the new message envelope (its `media.url` is the public file). A refusal arrives as a SaltApiError
|
|
962
|
+
* carrying the server's sentence.
|
|
963
|
+
*/
|
|
964
|
+
async postToProfile(apiKey, params) {
|
|
965
|
+
const room = await request("POST", "/api/v1/profile_room", apiKey, {});
|
|
966
|
+
return request("POST", "/api/v1/messages", apiKey, {
|
|
967
|
+
chat_id: room.room_id,
|
|
968
|
+
message: params.text ?? "",
|
|
969
|
+
...(params.media ? postMediaFields(params.media) : {}),
|
|
970
|
+
});
|
|
971
|
+
},
|
|
972
|
+
/** The replies to a post, newest page first; pass the last page's `next_before` as `before` for older ones
|
|
973
|
+
* (`GET /api/v1/posts/:id/replies`). */
|
|
974
|
+
async getPostReplies(apiKey, postId, opts) {
|
|
975
|
+
const params = new URLSearchParams();
|
|
976
|
+
if (opts?.before)
|
|
977
|
+
params.set("before", opts.before);
|
|
978
|
+
if (opts?.limit != null)
|
|
979
|
+
params.set("limit", String(opts.limit));
|
|
980
|
+
params.set("_", String(Date.now()));
|
|
981
|
+
return request("GET", `/api/v1/posts/${postId}/replies?${params.toString()}`, apiKey);
|
|
982
|
+
},
|
|
983
|
+
/** Ephemeral "is typing" ping. Fire-and-forget by design -- a failed ping must never block a reply. */
|
|
984
|
+
async signalTyping(apiKey, chatId) {
|
|
985
|
+
try {
|
|
986
|
+
await request("POST", `/api/v1/chats/${chatId}/typing`, apiKey, {});
|
|
987
|
+
}
|
|
988
|
+
catch {
|
|
989
|
+
// non-fatal by design
|
|
990
|
+
}
|
|
991
|
+
},
|
|
992
|
+
/**
|
|
993
|
+
* Fire-and-forget product-metrics beacon. Deliberately not awaited by
|
|
994
|
+
* callers -- an analytics post must never delay or fail a reply. Only
|
|
995
|
+
* names in salt-api's Event::CLIENT_EVENT_NAMES allowlist are accepted.
|
|
996
|
+
*/
|
|
997
|
+
trackEvent(apiKey, name, properties = {}) {
|
|
998
|
+
request("POST", "/api/v1/events", apiKey, { name, properties }).catch(() => { });
|
|
999
|
+
},
|
|
1000
|
+
/**
|
|
1001
|
+
* Prepares a governed payment for approval -- `money.pay`'s mode is FORCED to `ask`, so this
|
|
1002
|
+
* always resolves to an `AskedResult`, never a Transfer, whether called through `actFor` or (in
|
|
1003
|
+
* the unusual case of preparing your own payment) as yourself. Once the exercise is approved
|
|
1004
|
+
* (see `mandates.decide`/`mandates.exercises`, or the `approval_decided` webhook event), settle
|
|
1005
|
+
* it with `transfers#create`'s own `exercise_id` param (not exposed as a typed method here yet --
|
|
1006
|
+
* salt-api validates the wallet/amount/destination against the exercise's summary before it will
|
|
1007
|
+
* create the Transfer).
|
|
1008
|
+
*/
|
|
1009
|
+
async prepareTransfer(apiKey, params) {
|
|
1010
|
+
return request("POST", "/api/v1/transfers/prepare", apiKey, {
|
|
1011
|
+
wallet_id: params.walletId,
|
|
1012
|
+
destination_address: params.destinationAddress,
|
|
1013
|
+
receiver_id: params.receiverId,
|
|
1014
|
+
amount: params.amount,
|
|
1015
|
+
chain: params.chain,
|
|
1016
|
+
note: params.note,
|
|
1017
|
+
chat_id: params.chatId,
|
|
1018
|
+
});
|
|
1019
|
+
},
|
|
1020
|
+
// --- Mandates ("Acting for you") management -- always as yourself, never through actFor ---
|
|
1021
|
+
mandates: {
|
|
1022
|
+
/** Every mandate the caller is party to, as grantor and/or grantee. */
|
|
1023
|
+
async list(apiKey, opts) {
|
|
1024
|
+
const params = new URLSearchParams();
|
|
1025
|
+
if (opts?.role)
|
|
1026
|
+
params.set("role", opts.role);
|
|
1027
|
+
if (opts?.status)
|
|
1028
|
+
params.set("status", opts.status);
|
|
1029
|
+
const qs = params.toString();
|
|
1030
|
+
return request("GET", `/api/v1/mandates${qs ? `?${qs}` : ""}`, apiKey);
|
|
1031
|
+
},
|
|
1032
|
+
async get(apiKey, mandateId) {
|
|
1033
|
+
return request("GET", `/api/v1/mandates/${mandateId}`, apiKey);
|
|
1034
|
+
},
|
|
1035
|
+
/** Grantor creates (a proposal the grantee must accept, or auto-active for an account the
|
|
1036
|
+
* caller governs), or a grantee proposes UP by passing `grantor_id` instead. */
|
|
1037
|
+
async propose(apiKey, params) {
|
|
1038
|
+
return request("POST", "/api/v1/mandates", apiKey, params);
|
|
1039
|
+
},
|
|
1040
|
+
/** New version, compare-and-swap on `params.version` -- a 409 SaltApiError means it changed
|
|
1041
|
+
* under you; re-fetch with `get` and retry against the new version. */
|
|
1042
|
+
async update(apiKey, mandateId, params) {
|
|
1043
|
+
return request("PATCH", `/api/v1/mandates/${mandateId}`, apiKey, params);
|
|
1044
|
+
},
|
|
1045
|
+
/** Grantee accepts an offered mandate (binds its current key fingerprint), or the grantor
|
|
1046
|
+
* activates one still `proposed` by the grantee. See `ctx.mandate.accept()` in webhook.ts,
|
|
1047
|
+
* which calls this for an incoming `mandate_offered` event. */
|
|
1048
|
+
async accept(apiKey, mandateId) {
|
|
1049
|
+
return request("POST", `/api/v1/mandates/${mandateId}/accept`, apiKey);
|
|
1050
|
+
},
|
|
1051
|
+
/** Standing mandates only: +90 days from now. */
|
|
1052
|
+
async renew(apiKey, mandateId) {
|
|
1053
|
+
return request("POST", `/api/v1/mandates/${mandateId}/renew`, apiKey);
|
|
1054
|
+
},
|
|
1055
|
+
async pause(apiKey, mandateId, reason) {
|
|
1056
|
+
return request("POST", `/api/v1/mandates/${mandateId}/pause`, apiKey, reason ? { reason } : undefined);
|
|
1057
|
+
},
|
|
1058
|
+
async resume(apiKey, mandateId) {
|
|
1059
|
+
return request("POST", `/api/v1/mandates/${mandateId}/resume`, apiKey);
|
|
1060
|
+
},
|
|
1061
|
+
/** The grantor, or a governor of either side, can revoke -- cascades to any child mandates. */
|
|
1062
|
+
async revoke(apiKey, mandateId, reason) {
|
|
1063
|
+
return request("POST", `/api/v1/mandates/${mandateId}/revoke`, apiKey, reason ? { reason } : undefined);
|
|
1064
|
+
},
|
|
1065
|
+
/** A mandate's trail, id-descending. `refusal` is only populated when the caller is the
|
|
1066
|
+
* principal or its governor (salt-api's own viewer check -- this SDK doesn't re-derive it). */
|
|
1067
|
+
async exercises(apiKey, mandateId, opts) {
|
|
1068
|
+
const params = new URLSearchParams();
|
|
1069
|
+
if (opts?.before != null)
|
|
1070
|
+
params.set("before", String(opts.before));
|
|
1071
|
+
if (opts?.limit != null)
|
|
1072
|
+
params.set("limit", String(opts.limit));
|
|
1073
|
+
const qs = params.toString();
|
|
1074
|
+
return request("GET", `/api/v1/mandates/${mandateId}/exercises${qs ? `?${qs}` : ""}`, apiKey);
|
|
1075
|
+
},
|
|
1076
|
+
/** Every open ask (decision `"asked"`, not yet expired) the caller can decide right now. */
|
|
1077
|
+
async openExercises(apiKey) {
|
|
1078
|
+
return request("GET", "/api/v1/mandates/exercises/open", apiKey);
|
|
1079
|
+
},
|
|
1080
|
+
/** Approve or deny one open ask. See `ctx.approval.decide()` in webhook.ts, which calls this
|
|
1081
|
+
* for an incoming `approval_requested` event. */
|
|
1082
|
+
async decide(apiKey, exerciseId, decision, note) {
|
|
1083
|
+
return request("POST", `/api/v1/mandates/exercises/${exerciseId}/decide`, apiKey, { decision, note });
|
|
1084
|
+
},
|
|
1085
|
+
},
|
|
1086
|
+
// --- Share this group: team templates (2026-10-12) ---
|
|
1087
|
+
// A template is a RECIPE (names, purposes, voice text, directives, skill
|
|
1088
|
+
// and service names, looks), never running agents: whoever installs it
|
|
1089
|
+
// makes their own agents with their own keys on their own side.
|
|
1090
|
+
teams: {
|
|
1091
|
+
/** The Teams shelf: listed templates, most installed first. Public. */
|
|
1092
|
+
async list(apiKey, opts) {
|
|
1093
|
+
const qs = new URLSearchParams();
|
|
1094
|
+
if (opts?.q)
|
|
1095
|
+
qs.set("q", opts.q);
|
|
1096
|
+
if (opts?.limit)
|
|
1097
|
+
qs.set("limit", String(opts.limit));
|
|
1098
|
+
if (opts?.offset)
|
|
1099
|
+
qs.set("offset", String(opts.offset));
|
|
1100
|
+
const query = qs.toString();
|
|
1101
|
+
return request("GET", `/api/v1/team_templates${query ? `?${query}` : ""}`, apiKey);
|
|
1102
|
+
},
|
|
1103
|
+
/** The templates this identity shared, listed or not. */
|
|
1104
|
+
async mine(apiKey) {
|
|
1105
|
+
return request("GET", "/api/v1/team_templates/mine", apiKey);
|
|
1106
|
+
},
|
|
1107
|
+
/** A recipe preview, by id or slug. Public; an unlisted template is reachable by its link. */
|
|
1108
|
+
async get(apiKey, idOrSlug) {
|
|
1109
|
+
return request("GET", `/api/v1/team_templates/${encodeURIComponent(idOrSlug)}`, apiKey);
|
|
1110
|
+
},
|
|
1111
|
+
/** Shares one of the caller's own agent groups as a template. */
|
|
1112
|
+
async share(apiKey, params) {
|
|
1113
|
+
return request("POST", "/api/v1/team_templates", apiKey, {
|
|
1114
|
+
agent_group_id: params.groupId,
|
|
1115
|
+
name: params.name,
|
|
1116
|
+
description: params.description,
|
|
1117
|
+
listed: params.listed,
|
|
1118
|
+
coordinator_note: params.coordinatorNote,
|
|
1119
|
+
});
|
|
1120
|
+
},
|
|
1121
|
+
/** Author only: name, description, listed or unlisted, the coordinator note. */
|
|
1122
|
+
async update(apiKey, idOrSlug, changes) {
|
|
1123
|
+
return request("PATCH", `/api/v1/team_templates/${encodeURIComponent(idOrSlug)}`, apiKey, {
|
|
1124
|
+
name: changes.name,
|
|
1125
|
+
description: changes.description,
|
|
1126
|
+
listed: changes.listed,
|
|
1127
|
+
coordinator_note: changes.coordinatorNote,
|
|
1128
|
+
});
|
|
1129
|
+
},
|
|
1130
|
+
/** Author only. Agents already made from it are the installers' own. */
|
|
1131
|
+
async remove(apiKey, idOrSlug) {
|
|
1132
|
+
await request("DELETE", `/api/v1/team_templates/${encodeURIComponent(idOrSlug)}`, apiKey);
|
|
1133
|
+
},
|
|
1134
|
+
/** "I made this team": counted once per installer. */
|
|
1135
|
+
async installed(apiKey, idOrSlug) {
|
|
1136
|
+
return request("POST", `/api/v1/team_templates/${encodeURIComponent(idOrSlug)}/installed`, apiKey, {});
|
|
1137
|
+
},
|
|
1138
|
+
},
|
|
1139
|
+
// --- Apps in chats (contract 2026-09-28, §1) ---
|
|
1140
|
+
apps: {
|
|
1141
|
+
/**
|
|
1142
|
+
* Creates an app + version 1. `forId` builds it for someone else (that
|
|
1143
|
+
* person must share a chat with the caller, else 422); omitted, the
|
|
1144
|
+
* app belongs to the caller. `chatId` also installs it there in the
|
|
1145
|
+
* same transaction (caller must be a member) -- see `installation` on
|
|
1146
|
+
* the result.
|
|
1147
|
+
*/
|
|
1148
|
+
async create(apiKey, params) {
|
|
1149
|
+
return request("POST", "/api/v1/apps", apiKey, {
|
|
1150
|
+
name: params.name,
|
|
1151
|
+
description: params.description,
|
|
1152
|
+
html: params.html,
|
|
1153
|
+
for_id: params.forId,
|
|
1154
|
+
chat_id: params.chatId,
|
|
1155
|
+
actions: params.actions,
|
|
1156
|
+
});
|
|
1157
|
+
},
|
|
1158
|
+
/**
|
|
1159
|
+
* Publishes a new immutable version (owner or builder only) and moves
|
|
1160
|
+
* the app's `latest_version_id` to it. `actions` (omitted: none)
|
|
1161
|
+
* replace whatever the PRIOR version declared -- they don't merge.
|
|
1162
|
+
* Never changes an existing installation on its own -- each keeps
|
|
1163
|
+
* running its own pinned version (and that version's own `actions`)
|
|
1164
|
+
* until it calls `update`, below.
|
|
1165
|
+
*/
|
|
1166
|
+
async newVersion(apiKey, appId, html, actions) {
|
|
1167
|
+
return request("POST", `/api/v1/apps/${appId}/versions`, apiKey, { html, actions });
|
|
1168
|
+
},
|
|
1169
|
+
/** An app's metadata: owner, builder, latest version -- and, once listed, what the App Store shows. */
|
|
1170
|
+
async get(apiKey, appId) {
|
|
1171
|
+
return request("GET", `/api/v1/apps/${appId}`, apiKey);
|
|
1172
|
+
},
|
|
1173
|
+
/**
|
|
1174
|
+
* Installs `appId` -- into `chatId` (caller must be a non-observer
|
|
1175
|
+
* member; a second active install of the same app there 409s with the
|
|
1176
|
+
* existing installation instead of creating a duplicate) or, with no
|
|
1177
|
+
* `chatId`, into the caller's own personal space ("Your apps").
|
|
1178
|
+
* `copyStateFrom` copies another readable installation's doc into the
|
|
1179
|
+
* new one once.
|
|
1180
|
+
*/
|
|
1181
|
+
async install(apiKey, appId, params) {
|
|
1182
|
+
const body = {};
|
|
1183
|
+
if (params?.chatId)
|
|
1184
|
+
body.chat_id = params.chatId;
|
|
1185
|
+
if (params?.copyStateFrom)
|
|
1186
|
+
body.copy_state_from = params.copyStateFrom;
|
|
1187
|
+
return request("POST", `/api/v1/apps/${appId}/install`, apiKey, body);
|
|
1188
|
+
},
|
|
1189
|
+
/**
|
|
1190
|
+
* This identity's installations: every one in `chatId` (chat members
|
|
1191
|
+
* only) when given, else its own personal installations plus every
|
|
1192
|
+
* chat installation in a chat it's a member of. Cache-busted -- an
|
|
1193
|
+
* install just made should show up immediately.
|
|
1194
|
+
*/
|
|
1195
|
+
async installations(apiKey, opts) {
|
|
1196
|
+
const path = opts?.chatId
|
|
1197
|
+
? `/api/v1/app_installations?chat_id=${encodeURIComponent(String(opts.chatId))}&_=${Date.now()}`
|
|
1198
|
+
: `/api/v1/app_installations/mine?_=${Date.now()}`;
|
|
1199
|
+
return request("GET", path, apiKey);
|
|
1200
|
+
},
|
|
1201
|
+
/** The installation's shared state doc and its CAS version. Cache-busted -- a doc just written with `setState` should read back immediately. */
|
|
1202
|
+
async state(apiKey, installationId) {
|
|
1203
|
+
return request("GET", `/api/v1/app_installations/${installationId}/state?_=${Date.now()}`, apiKey);
|
|
1204
|
+
},
|
|
1205
|
+
/**
|
|
1206
|
+
* Compare-and-swap write of the installation's shared state doc. A 409
|
|
1207
|
+
* (the doc moved under the caller since `ifVersion`) is never handed
|
|
1208
|
+
* back as a bare SaltApiError -- it's re-thrown as
|
|
1209
|
+
* AppStateConflictError carrying the CURRENT `{doc, version}` straight
|
|
1210
|
+
* off the response body, so a caller can merge its change onto the
|
|
1211
|
+
* fresh doc and retry with the new version in one more call rather
|
|
1212
|
+
* than round-tripping to re-fetch first.
|
|
1213
|
+
*/
|
|
1214
|
+
async setState(apiKey, installationId, doc, ifVersion) {
|
|
1215
|
+
try {
|
|
1216
|
+
return await request("PUT", `/api/v1/app_installations/${installationId}/state`, apiKey, { doc, if_version: ifVersion });
|
|
1217
|
+
}
|
|
1218
|
+
catch (err) {
|
|
1219
|
+
if (err instanceof SaltApiError && err.status === 409) {
|
|
1220
|
+
const body = err.body;
|
|
1221
|
+
throw new AppStateConflictError(body?.doc ?? {}, typeof body?.version === "number" ? body.version : ifVersion);
|
|
1222
|
+
}
|
|
1223
|
+
throw err;
|
|
1224
|
+
}
|
|
1225
|
+
},
|
|
1226
|
+
},
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
const baseMethods = buildMethods(request);
|
|
1230
|
+
/**
|
|
1231
|
+
* Returns a client bound to acting for `principalId`: every call it makes carries
|
|
1232
|
+
* `X-Salt-Act-For: <principalId>` (and `X-Salt-Mandate: <mandateId>` when one is pinned), plus an
|
|
1233
|
+
* auto-generated `Idempotency-Key` on every POST/PATCH that didn't already supply one. Same method
|
|
1234
|
+
* surface as the ordinary client -- salt-api's own capability map decides what's actually allowed;
|
|
1235
|
+
* this SDK doesn't maintain a second copy of that map. Any call can resolve to an `AskedResult`
|
|
1236
|
+
* instead of its normal payload (never a thrown error) -- check with `isAsked(result)` before
|
|
1237
|
+
* reading the normal fields. `mandates.*` is unaffected: mandate management is always done as
|
|
1238
|
+
* yourself, never on someone else's behalf.
|
|
1239
|
+
*/
|
|
1240
|
+
function actFor(principalId, opts) {
|
|
1241
|
+
return buildMethods(actingRequest(principalId, opts?.mandateId));
|
|
1242
|
+
}
|
|
1243
|
+
return { ...baseMethods, actFor };
|
|
187
1244
|
}
|
|
188
1245
|
//# sourceMappingURL=client.js.map
|