@atlaso-labs/opencode 0.1.1 → 0.2.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/CHANGELOG.md +31 -0
- package/lib/atlaso.ts +269 -14
- package/lib/capture_stats.ts +318 -0
- package/lib/connect.ts +43 -11
- package/lib/credential.ts +148 -0
- package/lib/drain.ts +186 -0
- package/lib/entitlement.ts +4 -2
- package/lib/lock.ts +217 -0
- package/lib/mcp.ts +300 -0
- package/lib/outbox.ts +348 -0
- package/lib/project.ts +268 -32
- package/package.json +7 -3
- package/src/index.ts +68 -13
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
**Your memories now survive a bad connection.** Capture used to send a memory once
|
|
6
|
+
and, if anything went wrong — a timeout, a server hiccup, a dropped wifi
|
|
7
|
+
connection — it was gone, with nothing kept locally and nothing to tell you. Every
|
|
8
|
+
memory is now written to disk *before* it is sent and retried automatically on a
|
|
9
|
+
later turn. A memory is never silently lost; if one truly cannot be delivered it is
|
|
10
|
+
set aside with a reason rather than discarded.
|
|
11
|
+
|
|
12
|
+
**Removing the plugin now really stops it, on every platform.** On Windows the
|
|
13
|
+
plugin could not obtain its own credential, which meant it never learned it had been
|
|
14
|
+
removed and kept syncing. Fixed.
|
|
15
|
+
|
|
16
|
+
**Secrets stay on your machine.** Explicit `remember` calls and recall queries are
|
|
17
|
+
now scrubbed on-device, matching what automatic capture already did. Previously a
|
|
18
|
+
key pasted into an explicit save or a search left your machine (it was redacted
|
|
19
|
+
before storage, but it should never have been sent at all).
|
|
20
|
+
|
|
21
|
+
**Memory stays in the right project.** Per-project filtering now applies to the
|
|
22
|
+
`recall` and `recent` tools, so one repository's notes no longer surface in another.
|
|
23
|
+
Repositories cloned over SSH with a custom port no longer split into two separate
|
|
24
|
+
projects. An explicit save can now be scoped to a project instead of always being
|
|
25
|
+
personal.
|
|
26
|
+
|
|
27
|
+
**Fixes**
|
|
28
|
+
- `status` no longer reports "connected" when the server is unreachable.
|
|
29
|
+
- Concurrent tool calls can no longer interleave and corrupt the MCP transport.
|
|
30
|
+
- A preference that merely mentions a file path is no longer trapped in one repo.
|
|
31
|
+
- A valid credential is no longer discarded and re-minted on every run.
|
package/lib/atlaso.ts
CHANGED
|
@@ -6,21 +6,35 @@
|
|
|
6
6
|
* endpoints over the global `fetch`. The engine stays on the server; this only
|
|
7
7
|
* knows the URLs — the IP thin-client rule, in TypeScript.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* NO local RECALL cache — reads always go to the brain, keeping the ranking
|
|
10
|
+
* engine server-side (the IP thin-client rule). WRITES are durable: deposits go
|
|
11
|
+
* through lib/outbox.ts write-ahead and are retried by lib/drain.ts, so a
|
|
12
|
+
* timeout, 5xx, 429, WAF block or offline laptop can no longer silently lose a
|
|
13
|
+
* user's memory the way v1 did.
|
|
10
14
|
* Every call is FAIL-OPEN (memory must never break a Cursor turn): callers get
|
|
11
15
|
* `[]` / `false` on any error — never a throw. A REACHED-but-rejected token
|
|
12
16
|
* (HTTP 401/403) is the one authoritative signal: we retire auth.json so the next
|
|
13
17
|
* session re-authorizes (mirrors the Python client's AuthRejected handling).
|
|
14
18
|
*/
|
|
15
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync,
|
|
21
|
+
} from "node:fs";
|
|
22
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
16
23
|
import { homedir } from "node:os";
|
|
17
24
|
import { join } from "node:path";
|
|
25
|
+
import { scrub } from "./capture";
|
|
18
26
|
|
|
19
27
|
export interface Auth {
|
|
20
28
|
server: string;
|
|
21
29
|
token: string;
|
|
22
30
|
user_id?: string;
|
|
23
31
|
device_id?: string;
|
|
32
|
+
// Where this credential came from — set by resolveCredential (lib/credential.ts).
|
|
33
|
+
// "own" = the tool's OWN ~/.atlaso/tools/<tool>.json; "shared" = the shared bearer.
|
|
34
|
+
// undefined = a bare loadAuth() result (treated as shared for retirement). This is
|
|
35
|
+
// what lets a rejected per-tool token retire ONLY that file, never the shared one.
|
|
36
|
+
source?: "own" | "shared";
|
|
37
|
+
tool?: string; // the tool slug, when source === "own"
|
|
24
38
|
}
|
|
25
39
|
|
|
26
40
|
export interface RecallResult {
|
|
@@ -89,8 +103,91 @@ export function markRevoked(): void {
|
|
|
89
103
|
}
|
|
90
104
|
}
|
|
91
105
|
|
|
92
|
-
|
|
93
|
-
|
|
106
|
+
// ── per-tool credentials (~/.atlaso/tools/<tool>.json) ───────────────────────────
|
|
107
|
+
//
|
|
108
|
+
// Each Atlaso integration on a machine holds its OWN credential, minted from the
|
|
109
|
+
// shared bearer (see lib/credential.ts). That's what lets the brain tell two tools
|
|
110
|
+
// on one device apart, so removing one truly stops it. ONE FILE PER TOOL (not a map
|
|
111
|
+
// inside auth.json): two hooks can fire concurrently, and separate files + a kernel
|
|
112
|
+
// lock avoid a read-modify-write clobber. This module owns the file I/O; the mint
|
|
113
|
+
// state machine lives in lib/credential.ts.
|
|
114
|
+
|
|
115
|
+
export function toolsDir(): string {
|
|
116
|
+
return join(atlasoDir(), "tools");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function toolAuthPath(tool: string): string {
|
|
120
|
+
return join(toolsDir(), `${tool}.json`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function toolLockPath(tool: string): string {
|
|
124
|
+
return join(toolsDir(), `${tool}.lock`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** {server, token, user_id, device_id, tool} from tools/<tool>.json, or null. */
|
|
128
|
+
export function loadToolAuth(tool: string): Auth | null {
|
|
129
|
+
try {
|
|
130
|
+
const o = JSON.parse(readFileSync(toolAuthPath(tool), "utf-8"));
|
|
131
|
+
if (o && typeof o === "object" && typeof o.token === "string" && o.token) {
|
|
132
|
+
return {
|
|
133
|
+
server: typeof o.server === "string" && o.server ? o.server : defaultServer(),
|
|
134
|
+
token: o.token,
|
|
135
|
+
user_id: o.user_id,
|
|
136
|
+
device_id: o.device_id,
|
|
137
|
+
tool,
|
|
138
|
+
source: "own",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
/* missing / unreadable → no per-tool credential yet */
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Atomically + durably write tools/<tool>.json at 0600 (O_EXCL temp + fsync +
|
|
148
|
+
* atomic rename) — a torn credential file would brick the integration. */
|
|
149
|
+
export function saveToolAuth(tool: string, cred: Record<string, unknown>): void {
|
|
150
|
+
const dir = toolsDir();
|
|
151
|
+
mkdirSync(dir, { recursive: true });
|
|
152
|
+
const p = toolAuthPath(tool);
|
|
153
|
+
const tmp = join(dir, `.${tool}.${process.pid}.${randomUUID()}.tmp`);
|
|
154
|
+
const fd = openSync(tmp, "wx", 0o600); // O_CREAT|O_EXCL|O_WRONLY, owner-only
|
|
155
|
+
try {
|
|
156
|
+
writeFileSync(fd, JSON.stringify(cred, null, 2));
|
|
157
|
+
fsyncSync(fd);
|
|
158
|
+
} finally {
|
|
159
|
+
closeSync(fd);
|
|
160
|
+
}
|
|
161
|
+
renameSync(tmp, p);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Remove a per-tool credential (a rejected token, or a foreign leftover). The
|
|
165
|
+
* shared auth.json and the lock file are untouched. */
|
|
166
|
+
export function clearToolAuth(tool: string): void {
|
|
167
|
+
try {
|
|
168
|
+
unlinkSync(toolAuthPath(tool));
|
|
169
|
+
} catch {
|
|
170
|
+
/* already gone */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Retire the credential a rejected call was made with — the ONE thing that takes a
|
|
175
|
+
* client offline, so it is source-aware and never over-reaches:
|
|
176
|
+
* - source "own" → drop ONLY tools/<tool>.json; the shared auth.json (and any
|
|
177
|
+
* other tool riding it) is untouched. resolveCredential re-mints next run, or the
|
|
178
|
+
* server refuses (tombstoned tool) and it goes local-only.
|
|
179
|
+
* - source "shared"/undefined → retire the shared bearer, as before.
|
|
180
|
+
* Called only for a VERIFIED verdict (see call()). */
|
|
181
|
+
function retireForAuth(auth: Auth): void {
|
|
182
|
+
if (auth.source === "own" && auth.tool) clearToolAuth(auth.tool);
|
|
183
|
+
else markRevoked();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** One bearer-authed JSON call, hard-bounded by a timeout. null on ANY non-2xx or
|
|
187
|
+
* transport/parse error. A 401/403 retires the token ONLY when the response is a
|
|
188
|
+
* VERIFIED verdict from our own brain — `x-atlaso-response: 1`, stamped by a global
|
|
189
|
+
* middleware on every app response including errors. An edge/WAF 403 lacks it and
|
|
190
|
+
* must NOT take us offline (never-brick; the WAF sync-brick incident). */
|
|
94
191
|
async function call(
|
|
95
192
|
auth: Auth,
|
|
96
193
|
method: string,
|
|
@@ -98,6 +195,32 @@ async function call(
|
|
|
98
195
|
body: unknown,
|
|
99
196
|
timeoutMs: number,
|
|
100
197
|
): Promise<any | null> {
|
|
198
|
+
return (await callDetailed(auth, method, path, body, timeoutMs)).data;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Outcome of one call, with enough detail for the outbox to classify a failure.
|
|
202
|
+
* `call()` above collapses this to data-or-null for the many call sites that only
|
|
203
|
+
* need "did it work"; the deposit path needs the WHY, because "retry forever",
|
|
204
|
+
* "stop retrying", and "this will never succeed" are three different answers and
|
|
205
|
+
* guessing wrong either loses a memory or wedges the queue. */
|
|
206
|
+
export interface CallOutcome {
|
|
207
|
+
data: any | null;
|
|
208
|
+
/** HTTP status, or 0 when the request never produced a response (timeout,
|
|
209
|
+
* DNS failure, connection reset, unparseable body). */
|
|
210
|
+
status: number;
|
|
211
|
+
/** True when the response positively identifies as OUR brain rather than an
|
|
212
|
+
* edge/WAF page — the same marker that gates credential retirement. */
|
|
213
|
+
ours: boolean;
|
|
214
|
+
error?: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function callDetailed(
|
|
218
|
+
auth: Auth,
|
|
219
|
+
method: string,
|
|
220
|
+
path: string,
|
|
221
|
+
body: unknown,
|
|
222
|
+
timeoutMs: number,
|
|
223
|
+
): Promise<CallOutcome> {
|
|
101
224
|
const ctrl = new AbortController();
|
|
102
225
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
103
226
|
try {
|
|
@@ -110,14 +233,24 @@ async function call(
|
|
|
110
233
|
body: body ? JSON.stringify(body) : undefined,
|
|
111
234
|
signal: ctrl.signal,
|
|
112
235
|
});
|
|
236
|
+
const ours = res.headers?.get("x-atlaso-response") === "1";
|
|
113
237
|
if (res.status === 401 || res.status === 403) {
|
|
114
|
-
|
|
115
|
-
|
|
238
|
+
// Only OUR server's verdict may retire a credential — an edge/WAF block is not one.
|
|
239
|
+
if (ours) retireForAuth(auth);
|
|
240
|
+
return { data: null, status: res.status, ours };
|
|
116
241
|
}
|
|
117
|
-
if (!res.ok) return null;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
242
|
+
if (!res.ok) return { data: null, status: res.status, ours };
|
|
243
|
+
try {
|
|
244
|
+
return { data: await res.json(), status: res.status, ours };
|
|
245
|
+
} catch {
|
|
246
|
+
// 2xx with an unreadable body: the write may well have landed. Report it as
|
|
247
|
+
// a transport-class failure so the caller RETRIES — the deposit is
|
|
248
|
+
// idempotent on client_id, so a retry cannot duplicate.
|
|
249
|
+
return { data: null, status: 0, ours, error: "unparseable body" };
|
|
250
|
+
}
|
|
251
|
+
} catch (e) {
|
|
252
|
+
// transport/timeout/abort — transient, leave auth.json intact
|
|
253
|
+
return { data: null, status: 0, ours: false, error: String(e).slice(0, 200) };
|
|
121
254
|
} finally {
|
|
122
255
|
clearTimeout(timer);
|
|
123
256
|
}
|
|
@@ -134,7 +267,11 @@ export async function recall(
|
|
|
134
267
|
project?: string,
|
|
135
268
|
session?: string,
|
|
136
269
|
): Promise<RecallResult[]> {
|
|
137
|
-
|
|
270
|
+
// Explicit MCP queries are user/agent-controlled too. Scrub BEFORE building the
|
|
271
|
+
// URL so a pasted token cannot escape through the READ path while searching
|
|
272
|
+
// memory — the write path is not the only way a secret leaves the machine.
|
|
273
|
+
const safeQuery = scrub(query || "")[0];
|
|
274
|
+
const params = new URLSearchParams({ q: safeQuery, limit: String(limit) });
|
|
138
275
|
if (project) params.set("project", project);
|
|
139
276
|
if (session) params.set("session", session);
|
|
140
277
|
const data = await call(auth, "GET", `/v1/recall?${params.toString()}`, null, RECALL_TIMEOUT_MS);
|
|
@@ -150,12 +287,58 @@ export async function recent(auth: Auth, limit = 8): Promise<RecallResult[]> {
|
|
|
150
287
|
return Array.isArray(deposits) ? deposits : [];
|
|
151
288
|
}
|
|
152
289
|
|
|
290
|
+
export interface DepositOutcome {
|
|
291
|
+
ok: boolean;
|
|
292
|
+
results: Array<{ client_id?: string; status?: string }>;
|
|
293
|
+
/** Transport/HTTP detail so the outbox can decide retry vs quarantine. */
|
|
294
|
+
status: number;
|
|
295
|
+
ours: boolean;
|
|
296
|
+
error?: string;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Batch deposit exposing the full outcome. The outbox drain uses this; everything
|
|
300
|
+
* that only cares "did it work" uses `depositWithResults` below. */
|
|
301
|
+
export async function depositDetailed(
|
|
302
|
+
auth: Auth,
|
|
303
|
+
items: DepositItem[],
|
|
304
|
+
captureStats?: unknown[],
|
|
305
|
+
): Promise<DepositOutcome> {
|
|
306
|
+
if (!items.length && !(captureStats && captureStats.length)) {
|
|
307
|
+
return { ok: false, results: [], status: 0, ours: false, error: "empty" };
|
|
308
|
+
}
|
|
309
|
+
const body: Record<string, unknown> = { items };
|
|
310
|
+
if (captureStats && captureStats.length) body.capture_stats = captureStats;
|
|
311
|
+
const out = await callDetailed(auth, "POST", "/v1/memories/batch", body, DEPOSIT_TIMEOUT_MS);
|
|
312
|
+
return {
|
|
313
|
+
ok: !!out.data,
|
|
314
|
+
results: Array.isArray(out.data?.results) ? out.data.results : [],
|
|
315
|
+
status: out.status,
|
|
316
|
+
ours: out.ours,
|
|
317
|
+
error: out.error,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Batch deposit returning the server's per-item verdicts (added/duplicate) —
|
|
322
|
+
* the capture counters need them. `captureStats` is the ADDITIVE content-free
|
|
323
|
+
* counter payload (old servers ignore it; items may be [] for a stats-only
|
|
324
|
+
* flush, though the connectors currently only piggyback). */
|
|
325
|
+
/** Batch deposit returning the server's per-item verdicts (added/duplicate) —
|
|
326
|
+
* the capture counters need them. `captureStats` is the ADDITIVE content-free
|
|
327
|
+
* counter payload (old servers ignore it; items may be [] for a stats-only
|
|
328
|
+
* flush, though the connectors currently only piggyback). */
|
|
329
|
+
export async function depositWithResults(
|
|
330
|
+
auth: Auth,
|
|
331
|
+
items: DepositItem[],
|
|
332
|
+
captureStats?: unknown[],
|
|
333
|
+
): Promise<{ ok: boolean; results: Array<{ client_id?: string; status?: string }> }> {
|
|
334
|
+
const r = await depositDetailed(auth, items, captureStats);
|
|
335
|
+
return { ok: r.ok, results: r.results };
|
|
336
|
+
}
|
|
337
|
+
|
|
153
338
|
/** Batch deposit (the server re-scrubs + runs the worth-keeping gate). The
|
|
154
339
|
* client_id is the server idempotency key, so a retry never duplicates. */
|
|
155
340
|
export async function deposit(auth: Auth, items: DepositItem[]): Promise<boolean> {
|
|
156
|
-
|
|
157
|
-
const data = await call(auth, "POST", "/v1/memories/batch", { items }, DEPOSIT_TIMEOUT_MS);
|
|
158
|
-
return !!data;
|
|
341
|
+
return (await depositWithResults(auth, items)).ok;
|
|
159
342
|
}
|
|
160
343
|
|
|
161
344
|
/** POST /v1/entitlement — this device's tool policy {active_tool, multi_tool,
|
|
@@ -170,3 +353,75 @@ export async function entitlementCall(auth: Auth): Promise<any | null> {
|
|
|
170
353
|
export async function claimToolCall(auth: Auth, tool: string): Promise<any | null> {
|
|
171
354
|
return call(auth, "POST", "/v1/devices/claim-tool", { tool }, RECALL_TIMEOUT_MS);
|
|
172
355
|
}
|
|
356
|
+
|
|
357
|
+
// ── the MCP surface: explicit remember / forget / status ─────────────────────────
|
|
358
|
+
// These back the plugin's MCP tools (recall/recent already exist above). An explicit
|
|
359
|
+
// user "remember" is tagged `manual` — the server enricher treats manual memories as
|
|
360
|
+
// untouchable — plus the tool id for attribution. Mirrors the Cursor connector.
|
|
361
|
+
|
|
362
|
+
/** Deposit ONE memory the user explicitly asked to keep. Returns the server id
|
|
363
|
+
* (so a later `forget` can target it), or null on failure. */
|
|
364
|
+
export interface RememberOptions {
|
|
365
|
+
text: string;
|
|
366
|
+
/** Extra tags (e.g. scope:project + project:<key>) so an explicit save is
|
|
367
|
+
* scoped like an automatic capture instead of defaulting to personal —
|
|
368
|
+
* otherwise repo-specific facts saved via MCP follow the user everywhere. */
|
|
369
|
+
tags?: string[];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function remember(auth: Auth, opts: RememberOptions): Promise<string | null> {
|
|
373
|
+
// SCRUB BEFORE SENDING. Auto-capture scrubs secrets on-device; this explicit path
|
|
374
|
+
// did not, so "remember my key is sk-..." shipped the key to the brain in clear —
|
|
375
|
+
// a hole in the on-device scrubbing guarantee, reachable from the MCP `remember`
|
|
376
|
+
// tool with arbitrary agent-supplied text. (Bugbot, cursor/plugins#157, HIGH.)
|
|
377
|
+
const t = scrub(opts.text || "")[0].trim();
|
|
378
|
+
if (!t) return null;
|
|
379
|
+
// CONTENT-DERIVED idempotency key, not a random UUID. A random key meant a
|
|
380
|
+
// timeout AFTER the server committed looked like failure, and the retry minted a
|
|
381
|
+
// NEW key — so the same fact could be stored twice, or reported unsaved when it
|
|
382
|
+
// had landed. Auto-capture already derives its key from content; the explicit
|
|
383
|
+
// path is the higher-intent one and deserves it more, not less.
|
|
384
|
+
// (Bugbot #157, "Remember lacks durable idempotency".)
|
|
385
|
+
const client_id = createHash("sha256")
|
|
386
|
+
.update(`remember\u0000${t}\u0000${(opts.tags || []).slice().sort().join(",")}`)
|
|
387
|
+
.digest("hex")
|
|
388
|
+
.slice(0, 32);
|
|
389
|
+
const tags = [...new Set(["opencode", "manual", ...(opts.tags || [])])];
|
|
390
|
+
const item: DepositItem = {
|
|
391
|
+
client_id, text: t, polarity: "open", evidence_grade: "anecdotal",
|
|
392
|
+
scope_note: null, tags,
|
|
393
|
+
};
|
|
394
|
+
const data = await call(auth, "POST", "/v1/memories/batch", { items: [item] }, DEPOSIT_TIMEOUT_MS);
|
|
395
|
+
if (!data) return null;
|
|
396
|
+
const results = Array.isArray(data.results) ? data.results : [];
|
|
397
|
+
const row = results.find((r: any) => r?.client_id === client_id) ?? results[0];
|
|
398
|
+
return row?.id ?? client_id; // durable server id when settled, else the idempotency key
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** DELETE /v1/memories/<id>. Success = REACHED and 2xx (the body may be empty, so we
|
|
402
|
+
* don't route it through call()'s JSON parse). A verified 401/403 retires the token. */
|
|
403
|
+
export async function forget(auth: Auth, id: string): Promise<boolean> {
|
|
404
|
+
const ctrl = new AbortController();
|
|
405
|
+
const timer = setTimeout(() => ctrl.abort(), RECALL_TIMEOUT_MS);
|
|
406
|
+
try {
|
|
407
|
+
const res = await fetch(auth.server.replace(/\/+$/, "") + `/v1/memories/${encodeURIComponent(id)}`, {
|
|
408
|
+
method: "DELETE",
|
|
409
|
+
headers: { Authorization: `Bearer ${auth.token}` },
|
|
410
|
+
signal: ctrl.signal,
|
|
411
|
+
});
|
|
412
|
+
if (res.status === 401 || res.status === 403) {
|
|
413
|
+
if (res.headers?.get("x-atlaso-response") === "1") retireForAuth(auth);
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
return res.ok;
|
|
417
|
+
} catch {
|
|
418
|
+
return false; // offline / transient — the memory stays; caller says "try again"
|
|
419
|
+
} finally {
|
|
420
|
+
clearTimeout(timer);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** GET /v1/health — {fmi, deposit_count} for the status tool. null on any error. */
|
|
425
|
+
export async function health(auth: Auth): Promise<any | null> {
|
|
426
|
+
return call(auth, "GET", "/v1/health", null, RECALL_TIMEOUT_MS);
|
|
427
|
+
}
|