@decentrys/dri-sdk 0.1.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.
@@ -0,0 +1,224 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { DecentrysDri, DriError, type FetchLike } from './client';
3
+ import { MissingDisclosureError, type RecoveryIndex } from './model';
4
+
5
+ function ok(body: unknown): ReturnType<FetchLike> {
6
+ return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve(JSON.stringify(body)) });
7
+ }
8
+
9
+ function client(fetchImpl: FetchLike) {
10
+ return new DecentrysDri({ apiKey: 'dk_live_test', fetch: fetchImpl });
11
+ }
12
+
13
+ const INDEX: RecoveryIndex = {
14
+ recoveryCaseId: 'case-1',
15
+ caseRef: 'REC-2026-0001',
16
+ incidentRef: 'INC-2026-0007',
17
+ score: 58,
18
+ band: 'MODERATE',
19
+ bandMeaning: 'Value is traceable and attributed, but sits in non-custodial wallets.',
20
+ factors: [{ factor: 'baseline', contribution: 30, note: 'Neutral starting position.' }],
21
+ modelVersion: 'recovery-outlook-1.0.0',
22
+ inputsHash: 'abc123',
23
+ computedAt: '2026-02-01T00:00:00.000Z',
24
+ funnel: {
25
+ totalLossUsd: 1_000_000, byState: {}, locatedUsd: 400_000, atInterventionPointUsd: 250_000,
26
+ freezeRequestedUsd: 0, frozenUsd: 0, recoveredUsd: 0, obfuscatedUsd: 0, lostUsd: 0,
27
+ untracedUsd: 0, probabilisticUsd: 0, accountedUsd: 1_000_000, unaccountedUsd: 0, reconciles: true,
28
+ },
29
+ analyticalOnly: true,
30
+ disclaimer: 'The Recovery Outlook is an analytical indicator. It is not a probability and not a forecast.',
31
+ notes: [],
32
+ };
33
+
34
+ describe('construction', () => {
35
+ it('requires a key', () => {
36
+ expect(() => new DecentrysDri({ apiKey: '' })).toThrow(/apiKey is required/);
37
+ });
38
+
39
+ /**
40
+ * The single most important line in this file.
41
+ *
42
+ * An investigation names third parties who are, so far as the record shows,
43
+ * uninvolved businesses. A publishable key is readable by anyone who
44
+ * downloads the artifact carrying it, so accepting one here would mean
45
+ * anybody who unzipped an app could start opening cases that name people.
46
+ * The server refuses it too — `dri:investigate` is not a publishable scope —
47
+ * but a clear failure at construction beats a 403 three weeks in.
48
+ */
49
+ it('refuses a publishable key outright', () => {
50
+ expect(() => new DecentrysDri({ apiKey: 'dk_pub_live_abc' }))
51
+ .toThrow(/publishable key/);
52
+ expect(() => new DecentrysDri({ apiKey: 'dk_pub_live_abc' }))
53
+ .toThrow(/names third parties/);
54
+ });
55
+ });
56
+
57
+ /**
58
+ * The contract, and why it is the opposite of Protect's.
59
+ *
60
+ * Protect never throws: a wallet must stay usable when Decentrys is down.
61
+ * Nothing here is on a signing path. A trace that silently returned an empty
62
+ * graph when the service was unreachable would tell a responder the value had
63
+ * vanished at exactly the moment it was still moving — and nothing about that
64
+ * failure would be visible.
65
+ */
66
+ describe('failure behaviour', () => {
67
+ it('throws rather than returning an empty graph when unreachable', async () => {
68
+ const dri = client(() => Promise.reject(new Error('connection refused')));
69
+ await expect(dri.getFlowGraph('inv-1')).rejects.toBeInstanceOf(DriError);
70
+ });
71
+
72
+ it('surfaces the API’s own message and code', async () => {
73
+ const dri = client(() => Promise.resolve({
74
+ ok: false, status: 403,
75
+ text: () => Promise.resolve(JSON.stringify({ code: 'FORBIDDEN', message: 'API key scope: dri:investigate' })),
76
+ }));
77
+
78
+ const error = await dri.getFlowGraph('inv-1').catch((e: unknown) => e);
79
+ expect((error as DriError).message).toBe('API key scope: dri:investigate');
80
+ expect((error as DriError).code).toBe('FORBIDDEN');
81
+ expect((error as DriError).status).toBe(403);
82
+ });
83
+
84
+ it('gives up at the deadline instead of hanging a caller’s process', async () => {
85
+ const hang: FetchLike = (_u, init) => new Promise((_res, rej) => {
86
+ init.signal?.addEventListener('abort', () => rej(Object.assign(new Error('aborted'), { name: 'AbortError' })));
87
+ });
88
+ const dri = new DecentrysDri({ apiKey: 'dk_live_test', timeoutMs: 20, fetch: hang });
89
+
90
+ const error = await dri.traceFunds({ investigationId: 'inv-1' }).catch((e: unknown) => e);
91
+ expect((error as DriError).message).toContain('No response within');
92
+ });
93
+ });
94
+
95
+ describe('Recovery Index', () => {
96
+ /**
97
+ * The requirement Stage 8 exists to satisfy, enforced at the boundary.
98
+ *
99
+ * The server already checks this. Duplicating it here is the point: an
100
+ * integrator's dashboard renders whatever arrives, and "58 — MODERATE"
101
+ * beside the word recovery is read as a rate by everyone who sees it. A
102
+ * proxy stripping fields, an older deployment or a hand-rolled response must
103
+ * not be able to turn an analytical estimate into a bare number.
104
+ */
105
+ it('refuses a score that arrives without its disclaimer', async () => {
106
+ const dri = client(() => ok({ data: { ...INDEX, disclaimer: '' } }));
107
+
108
+ await expect(dri.getRecoveryIndex('case-1')).rejects.toBeInstanceOf(MissingDisclosureError);
109
+ });
110
+
111
+ it('refuses a score that is not marked analytical-only', async () => {
112
+ const dri = client(() => ok({ data: { ...INDEX, analyticalOnly: false } }));
113
+
114
+ await expect(dri.getRecoveryIndex('case-1')).rejects.toBeInstanceOf(MissingDisclosureError);
115
+ });
116
+
117
+ /**
118
+ * A disclosure failure is not a transport failure. A caller that wraps DRI
119
+ * calls in a retry-on-DriError loop would otherwise treat a stripped
120
+ * disclaimer as a blip and retry it forever, never surfacing the one problem
121
+ * that actually needed a human.
122
+ */
123
+ it('does not disguise a missing disclaimer as a network error', async () => {
124
+ const dri = client(() => ok({ data: { ...INDEX, disclaimer: ' ' } }));
125
+
126
+ const error = await dri.getRecoveryIndex('case-1').catch((e: unknown) => e);
127
+ expect(error).not.toBeInstanceOf(DriError);
128
+ expect((error as Error).name).toBe('MissingDisclosureError');
129
+ });
130
+
131
+ it('returns the score with its model version and inputs hash, so a figure is reproducible', async () => {
132
+ const dri = client(() => ok({ data: INDEX }));
133
+
134
+ const index = await dri.getRecoveryIndex('case-1');
135
+ expect(index.score).toBe(58);
136
+ expect(index.modelVersion).toBe('recovery-outlook-1.0.0');
137
+ expect(index.inputsHash).toBe('abc123');
138
+ expect(index.disclaimer).toContain('not a probability');
139
+ });
140
+ });
141
+
142
+ describe('request shape', () => {
143
+ it('keeps the key in a header, never in the URL', async () => {
144
+ const fetchImpl = vi.fn<FetchLike>().mockImplementation(() => ok({ data: {} }));
145
+ await client(fetchImpl).getFlowGraph('inv-1');
146
+
147
+ const [url, init] = fetchImpl.mock.calls[0]!;
148
+ expect(url).not.toContain('dk_live_test');
149
+ expect(init.headers['x-api-key']).toBe('dk_live_test');
150
+ });
151
+
152
+ /**
153
+ * A case reference or an identifier goes into a path segment. Left unencoded
154
+ * a stray slash would silently address a different route — on this API,
155
+ * plausibly a different customer's.
156
+ */
157
+ it('encodes identifiers into the path', async () => {
158
+ const fetchImpl = vi.fn<FetchLike>().mockImplementation(() => ok({ data: INDEX }));
159
+ await client(fetchImpl).getRecoveryIndex('case/../other');
160
+
161
+ expect(fetchImpl.mock.calls[0]![0]).toBe(
162
+ 'https://api.decentrys.com/v1/dri/cases/case%2F..%2Fother/recovery-index',
163
+ );
164
+ });
165
+
166
+ it('omits fromNodeId entirely when none is given, so the server picks the frontier', async () => {
167
+ const fetchImpl = vi.fn<FetchLike>().mockImplementation(() => ok({ data: {} }));
168
+ await client(fetchImpl).traceFunds({ investigationId: 'inv-1' });
169
+
170
+ const [url, init] = fetchImpl.mock.calls[0]!;
171
+ expect(url).toContain('/v1/dri/investigations/inv-1/trace');
172
+ expect(JSON.parse(init.body!)).toEqual({});
173
+ });
174
+
175
+ it('sends the seed as chain and address on creation', async () => {
176
+ const fetchImpl = vi.fn<FetchLike>().mockImplementation(() => ok({ data: {} }));
177
+ await client(fetchImpl).createInvestigation({
178
+ title: 'Bridge drain', chain: 'ethereum', address: '0xseed', maxHops: 3,
179
+ });
180
+
181
+ const body = JSON.parse(fetchImpl.mock.calls[0]![1].body!) as Record<string, unknown>;
182
+ expect(body).toEqual({ title: 'Bridge drain', chain: 'ethereum', address: '0xseed', maxHops: 3 });
183
+ });
184
+
185
+ /**
186
+ * The list endpoints wrap their rows, and a caller wants the rows. Unwrapping
187
+ * here rather than in every call site is what keeps `listCases()` usable as
188
+ * the way to find a `recoveryCaseId`.
189
+ */
190
+ it('unwraps list responses to the rows themselves', async () => {
191
+ const dri = client(() => ok({ data: { cases: [{ caseRef: 'REC-2026-0001' }] } }));
192
+ const cases = await dri.listCases();
193
+ expect(cases).toHaveLength(1);
194
+ expect(cases[0]!.caseRef).toBe('REC-2026-0001');
195
+ });
196
+ });
197
+
198
+ /**
199
+ * There is no method here that freezes, seizes, holds, transmits or returns
200
+ * anything, and there must never be one. Decentrys never takes custody:
201
+ * recovered value moves between the parties entitled to it and never through
202
+ * us, so a method that appeared to do otherwise would be a claim the company
203
+ * cannot make — and an SDK surface is exactly where somebody would add one for
204
+ * convenience.
205
+ */
206
+ describe('what this client cannot do', () => {
207
+ it('exposes no method that acts on assets', () => {
208
+ const methods = Object.getOwnPropertyNames(DecentrysDri.prototype);
209
+ for (const name of methods) {
210
+ expect(name, name).not.toMatch(/freeze|seize|recover(?!yIndex)|refund|return|withdraw|custody|transmit/i);
211
+ }
212
+ });
213
+
214
+ it('exposes exactly the seven documented capabilities plus the two lookups', () => {
215
+ const methods = Object.getOwnPropertyNames(DecentrysDri.prototype)
216
+ .filter((name) => name !== 'constructor' && !name.startsWith('request'));
217
+
218
+ expect(new Set(methods)).toEqual(new Set([
219
+ 'createInvestigation', 'traceFunds', 'getFlowGraph', 'identifyInterventionPoints',
220
+ 'getRecoveryIndex', 'generateEvidencePackage', 'getInvestigationTimeline',
221
+ 'listInvestigations', 'listCases',
222
+ ]));
223
+ });
224
+ });
package/src/client.ts ADDED
@@ -0,0 +1,343 @@
1
+ /**
2
+ * The DRI client — Digital Recovery Intelligence.
3
+ *
4
+ * Fund tracing, attribution, intervention-point identification and evidence
5
+ * packages, for the organisation whose value was taken.
6
+ *
7
+ * **This client throws**, like Sentinel and Risk and unlike Protect. Protect
8
+ * sits between a user and a signing screen, so a bad minute must never cost
9
+ * someone their transaction. Nothing here is on a signing path: a trace that
10
+ * quietly returned an empty graph when the service was unreachable would tell
11
+ * a responder the value had vanished at exactly the moment it was still
12
+ * moving, and that failure is invisible by construction. Better a loud error.
13
+ *
14
+ * **A publishable key is refused at construction.** An investigation names
15
+ * third parties who are, so far as the record shows, uninvolved businesses.
16
+ * That must never run behind a credential shipped inside a client artifact,
17
+ * where anyone who downloads it can read it and start naming people. The
18
+ * server refuses one too — `dri:investigate` is not a publishable scope and
19
+ * `/v1/dri/` is not a publishable path — but a clear error here beats a 403
20
+ * three weeks into an integration.
21
+ *
22
+ * What this client cannot do, at all, and by design:
23
+ *
24
+ * - It cannot freeze, seize, hold, transmit or return anything. Decentrys
25
+ * never takes custody of recovered value; it moves between the parties
26
+ * entitled to it and never through us.
27
+ * - It cannot record that a custodian, an issuer, a court or an agency acted.
28
+ * Those states name the party who acted and are written by staff, because
29
+ * an audit trail that says Decentrys did something it did not is worse than
30
+ * no trail.
31
+ * - It cannot give legal advice. Decentrys is not a law firm, a
32
+ * law-enforcement agency or a licensed recovery agent.
33
+ */
34
+
35
+ import {
36
+ assertAnalyticalDisclosure,
37
+ type EvidencePackage, type EvidenceRecipientType, type FlowGraph, type InterventionAnalysis,
38
+ type InvestigationSummary, type InvestigationTimeline, type RecoveryCaseSummary,
39
+ type RecoveryIndex, type TraceResult,
40
+ } from './model';
41
+
42
+ export const SDK_VERSION = '0.1.0';
43
+
44
+ const DEFAULT_BASE_URL = 'https://api.decentrys.com';
45
+
46
+ /**
47
+ * Ten seconds, as elsewhere. A trace expands one address against rate-limited
48
+ * public endpoints, so it is the slowest call here by some margin — but a
49
+ * client that waits indefinitely is a client that hangs, and the graph is
50
+ * persisted server-side either way, so a timed-out trace is re-readable rather
51
+ * than lost.
52
+ */
53
+ const DEFAULT_TIMEOUT_MS = 30_000;
54
+
55
+ export type FetchLike = (
56
+ url: string,
57
+ init: { method: string; headers: Record<string, string>; body?: string; signal?: AbortSignal },
58
+ ) => Promise<{ ok: boolean; status: number; text: () => Promise<string> }>;
59
+
60
+ export class DriError extends Error {
61
+ constructor(readonly status: number, message: string, readonly code?: string) {
62
+ super(message);
63
+ this.name = 'DriError';
64
+ }
65
+ }
66
+
67
+ export interface DriConfig {
68
+ apiKey: string;
69
+ baseUrl?: string;
70
+ timeoutMs?: number;
71
+ fetch?: FetchLike;
72
+ }
73
+
74
+ export interface CreateInvestigationInput {
75
+ /** What this case is, in a form a reader will recognise months later. */
76
+ title: string;
77
+ chain: string;
78
+ /** The seed: the victim contract, or the first attacker wallet. */
79
+ address: string;
80
+ /** Links the case to a declared incident of your own organisation. */
81
+ incidentId?: string;
82
+ /** 1-4. The graph will not be expanded past this distance from the seed. */
83
+ maxHops?: number;
84
+ }
85
+
86
+ export interface TraceFundsInput {
87
+ investigationId: string;
88
+ /**
89
+ * Which address to follow. Omit to take the shallowest one nobody has looked
90
+ * past yet, which is the ordering that keeps a graph anchored in what was
91
+ * directly traced.
92
+ */
93
+ fromNodeId?: string;
94
+ }
95
+
96
+ export interface EvidencePackageInput {
97
+ recoveryCaseId: string;
98
+ recipientType: EvidenceRecipientType;
99
+ recipientName: string;
100
+ /** Narrows the package to one identified destination, when there is one. */
101
+ recoveryLeadId?: string;
102
+ }
103
+
104
+ export class DecentrysDri {
105
+ private readonly baseUrl: string;
106
+ private readonly apiKey: string;
107
+ private readonly timeoutMs: number;
108
+ private readonly fetchImpl: FetchLike;
109
+
110
+ constructor(config: DriConfig) {
111
+ if (!config.apiKey?.trim()) {
112
+ throw new Error('DRI: an apiKey is required. Create one at https://decentrys.com/developers.');
113
+ }
114
+ if (config.apiKey.startsWith('dk_pub_')) {
115
+ throw new Error(
116
+ 'DRI: that is a publishable key. An investigation names third parties, and that must never run behind a '
117
+ + 'credential shipped inside a client where anyone who downloads it can read it. Use a secret key, '
118
+ + 'server-side.',
119
+ );
120
+ }
121
+
122
+ this.apiKey = config.apiKey;
123
+ this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
124
+ this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
125
+ this.fetchImpl = config.fetch ?? resolveFetch();
126
+ }
127
+
128
+ // --- Investigations ------------------------------------------------------
129
+
130
+ /**
131
+ * Open an investigation seeded on one address.
132
+ *
133
+ * Nothing is traced yet. The seed node exists so the graph has a root before
134
+ * any chain call is made, which means a case can be opened and worked on
135
+ * even while a provider is unreachable.
136
+ */
137
+ createInvestigation(input: CreateInvestigationInput): Promise<FlowGraph> {
138
+ return this.request<FlowGraph>('POST', '/v1/dri/investigations', {
139
+ title: input.title,
140
+ chain: input.chain,
141
+ address: input.address,
142
+ ...(input.incidentId ? { incidentId: input.incidentId } : {}),
143
+ ...(input.maxHops === undefined ? {} : { maxHops: input.maxHops }),
144
+ });
145
+ }
146
+
147
+ listInvestigations(limit?: number): Promise<InvestigationSummary[]> {
148
+ const query = limit === undefined ? '' : `?limit=${encodeURIComponent(String(limit))}`;
149
+ return this.request<{ investigations: InvestigationSummary[] }>(
150
+ 'GET', `/v1/dri/investigations${query}`,
151
+ ).then((body) => body.investigations);
152
+ }
153
+
154
+ /**
155
+ * Follow the value one address further.
156
+ *
157
+ * One address, one hop, per call — and that is a deliberate limit rather
158
+ * than an unfinished feature. The public endpoints a trace reads are rate
159
+ * limited, and an unattended crawl to four hops exhausts them; the result
160
+ * then reads as "the funds vanished" rather than "we ran out of quota",
161
+ * which on this product is the worst possible way to be wrong. Call it in a
162
+ * loop if you want depth, and read `coverage.truncated` on each result.
163
+ */
164
+ traceFunds(input: TraceFundsInput): Promise<TraceResult> {
165
+ return this.request<TraceResult>('POST', `/v1/dri/investigations/${encode(input.investigationId)}/trace`, {
166
+ ...(input.fromNodeId ? { fromNodeId: input.fromNodeId } : {}),
167
+ });
168
+ }
169
+
170
+ /** The graph as it stands: every address reached, and how it was reached. */
171
+ getFlowGraph(investigationId: string): Promise<FlowGraph> {
172
+ return this.request<FlowGraph>('GET', `/v1/dri/investigations/${encode(investigationId)}`);
173
+ }
174
+
175
+ /**
176
+ * Where a third party could act, and where attribution stops.
177
+ *
178
+ * Read both halves. An intervention point is a party who holds value and can
179
+ * be contacted, served or subpoenaed — whether it does anything is a matter
180
+ * for it, its regulators and any legal process, never for Decentrys. An
181
+ * attribution limit is where the trail stops being provable, and a case that
182
+ * reaches one has established an answer rather than failed.
183
+ *
184
+ * Every point carries `observedPath`. False means the connection to this
185
+ * case runs through mixing or privacy infrastructure and is therefore
186
+ * inference — an observation about a counterparty, never an accusation about
187
+ * a person. `inferredPoints()` isolates them.
188
+ */
189
+ identifyInterventionPoints(investigationId: string): Promise<InterventionAnalysis> {
190
+ return this.request<InterventionAnalysis>(
191
+ 'GET', `/v1/dri/investigations/${encode(investigationId)}/intervention-points`,
192
+ );
193
+ }
194
+
195
+ /**
196
+ * What happened on this case, oldest first, with the acting party on every
197
+ * entry.
198
+ *
199
+ * Merges the recovery record in when the incident behind the investigation
200
+ * has a case. Entries attributed to anyone other than Decentrys are recorded
201
+ * as reported by that party — Decentrys did not perform them and does not
202
+ * verify them.
203
+ */
204
+ getInvestigationTimeline(investigationId: string, limit?: number): Promise<InvestigationTimeline> {
205
+ const query = limit === undefined ? '' : `?limit=${encodeURIComponent(String(limit))}`;
206
+ return this.request<InvestigationTimeline>(
207
+ 'GET', `/v1/dri/investigations/${encode(investigationId)}/timeline${query}`,
208
+ );
209
+ }
210
+
211
+ // --- Recovery cases ------------------------------------------------------
212
+
213
+ /**
214
+ * The recovery cases of your organisation.
215
+ *
216
+ * A case is opened as part of an engagement, against a declared incident and
217
+ * a stated loss; the tranche ledger behind it is analyst work, so it is not
218
+ * something an API call brings into existence. This is how you find the
219
+ * `recoveryCaseId` that `getRecoveryIndex` and `generateEvidencePackage`
220
+ * take — and `getFlowGraph` reports it too, for an investigation attached to
221
+ * one.
222
+ */
223
+ listCases(limit?: number): Promise<RecoveryCaseSummary[]> {
224
+ const query = limit === undefined ? '' : `?limit=${encodeURIComponent(String(limit))}`;
225
+ return this.request<{ cases: RecoveryCaseSummary[] }>('GET', `/v1/dri/cases${query}`)
226
+ .then((body) => body.cases);
227
+ }
228
+
229
+ /**
230
+ * The Recovery Index for a case.
231
+ *
232
+ * An **analytical estimate** of the current evidentiary and custodial
233
+ * position, produced by a versioned model from the evidence recorded in the
234
+ * case. It is not a probability, not a forecast, and not a representation
235
+ * that any value will be recovered. `modelVersion` and `inputsHash` come
236
+ * back with it so any figure shown to a client can be reproduced exactly
237
+ * from the record it was computed from.
238
+ *
239
+ * The disclaimer travels in the payload, and this method **refuses a
240
+ * response that does not carry it** rather than returning a bare number.
241
+ * That check is deliberately duplicated from the server: an integrator's
242
+ * dashboard renders whatever arrives, and "58 — MODERATE" beside the word
243
+ * recovery is read as a rate by everyone who sees it. If the caveat ever
244
+ * stops arriving, failing here is the only outcome that keeps the score
245
+ * honest.
246
+ */
247
+ getRecoveryIndex(recoveryCaseId: string): Promise<RecoveryIndex> {
248
+ return this.request<RecoveryIndex>('GET', `/v1/dri/cases/${encode(recoveryCaseId)}/recovery-index`)
249
+ .then(assertAnalyticalDisclosure);
250
+ }
251
+
252
+ /**
253
+ * Prepare an evidence package for a recipient.
254
+ *
255
+ * **Prepared, not sent.** The package goes to you and, at your direction, to
256
+ * your counsel, your insurer or a law-enforcement agency. Decentrys does not
257
+ * contact counterparties on your behalf unless instructed, and recording
258
+ * that a package was transmitted is a separate act that names who sent it.
259
+ *
260
+ * The document is frozen and hashed at generation, because third parties
261
+ * make freezing decisions on the strength of these and one that could be
262
+ * regenerated differently afterwards would be worth nothing. Quote
263
+ * `contentHash` back to a recipient so they can verify what they hold.
264
+ */
265
+ generateEvidencePackage(input: EvidencePackageInput): Promise<EvidencePackage> {
266
+ return this.request<EvidencePackage>(
267
+ 'POST', `/v1/dri/cases/${encode(input.recoveryCaseId)}/evidence-package`,
268
+ {
269
+ recipientType: input.recipientType,
270
+ recipientName: input.recipientName,
271
+ ...(input.recoveryLeadId ? { recoveryLeadId: input.recoveryLeadId } : {}),
272
+ },
273
+ );
274
+ }
275
+
276
+ // --- transport -----------------------------------------------------------
277
+
278
+ private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
279
+ const controller = new AbortController();
280
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
281
+
282
+ try {
283
+ const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
284
+ method,
285
+ headers: {
286
+ 'content-type': 'application/json',
287
+ 'x-api-key': this.apiKey,
288
+ 'user-agent': `decentrys-dri/${SDK_VERSION}`,
289
+ },
290
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
291
+ signal: controller.signal,
292
+ });
293
+
294
+ const text = await response.text();
295
+
296
+ if (!response.ok) {
297
+ const parsed = safeParse(text);
298
+ throw new DriError(
299
+ response.status,
300
+ typeof parsed?.message === 'string' ? parsed.message : `Decentrys returned HTTP ${response.status}.`,
301
+ typeof parsed?.code === 'string' ? parsed.code : undefined,
302
+ );
303
+ }
304
+
305
+ if (!text) return undefined as T;
306
+
307
+ const parsed = safeParse(text);
308
+ if (parsed === null) throw new DriError(response.status, 'The response was not valid JSON.');
309
+ return ('data' in parsed ? parsed.data : parsed) as T;
310
+ } catch (error) {
311
+ if (error instanceof DriError) throw error;
312
+ // A disclosure failure is not a transport failure and must not be
313
+ // rewritten as one — it is thrown by `assertAnalyticalDisclosure`
314
+ // downstream of this promise, but a caller wrapping the whole call in a
315
+ // retry would otherwise treat it as a blip and try again forever.
316
+ if (controller.signal.aborted) throw new DriError(0, `No response within ${this.timeoutMs}ms.`);
317
+ throw new DriError(0, error instanceof Error ? error.message : 'Request failed.');
318
+ } finally {
319
+ clearTimeout(timer);
320
+ }
321
+ }
322
+ }
323
+
324
+ function encode(segment: string): string {
325
+ return encodeURIComponent(segment);
326
+ }
327
+
328
+ function safeParse(text: string): Record<string, unknown> | null {
329
+ try {
330
+ const parsed = JSON.parse(text) as unknown;
331
+ return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
332
+ } catch {
333
+ return null;
334
+ }
335
+ }
336
+
337
+ function resolveFetch(): FetchLike {
338
+ const candidate = (globalThis as { fetch?: unknown }).fetch;
339
+ if (typeof candidate !== 'function') {
340
+ throw new Error('DRI: no global fetch was found. Pass one via `new DecentrysDri({ fetch })`.');
341
+ }
342
+ return candidate.bind(globalThis) as FetchLike;
343
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './model';
2
+ export * from './client';
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ assertAnalyticalDisclosure, custodialPoints, inferredPoints, MissingDisclosureError,
4
+ type InterventionAnalysis, type InterventionPoint, type RecoveryIndex,
5
+ } from './model';
6
+
7
+ function index(overrides: Partial<RecoveryIndex> = {}): RecoveryIndex {
8
+ return {
9
+ recoveryCaseId: 'case-1', caseRef: 'REC-2026-0001', incidentRef: 'INC-2026-0007',
10
+ score: 58, band: 'MODERATE', bandMeaning: 'Value is traceable but sits in non-custodial wallets.',
11
+ factors: [], modelVersion: 'recovery-outlook-1.0.0', inputsHash: 'abc',
12
+ computedAt: '2026-02-01T00:00:00.000Z',
13
+ funnel: {
14
+ totalLossUsd: 0, byState: {}, locatedUsd: 0, atInterventionPointUsd: 0, freezeRequestedUsd: 0,
15
+ frozenUsd: 0, recoveredUsd: 0, obfuscatedUsd: 0, lostUsd: 0, untracedUsd: 0,
16
+ probabilisticUsd: 0, accountedUsd: 0, unaccountedUsd: 0, reconciles: true,
17
+ },
18
+ analyticalOnly: true,
19
+ disclaimer: 'An analytical indicator. Not a probability and not a forecast.',
20
+ notes: [],
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ /**
26
+ * The Recovery Index is the one number in this product that can be
27
+ * misunderstood into a promise. Everything else describes what was observed;
28
+ * this one describes a position, and a position stated without its caveat is
29
+ * heard as a forecast — by a board, by an insurer, and eventually by whoever
30
+ * is deciding whether Decentrys said something it should not have.
31
+ */
32
+ describe('assertAnalyticalDisclosure', () => {
33
+ it('passes a score that carries both the flag and the text', () => {
34
+ expect(assertAnalyticalDisclosure(index()).score).toBe(58);
35
+ });
36
+
37
+ it('refuses a score with an empty disclaimer', () => {
38
+ expect(() => assertAnalyticalDisclosure(index({ disclaimer: ' ' })))
39
+ .toThrow(MissingDisclosureError);
40
+ });
41
+
42
+ /**
43
+ * The flag exists because an integrator's code cannot branch on a paragraph.
44
+ * A payload that carries the prose but not the flag has lost the half that
45
+ * software can act on, so it fails the same way.
46
+ */
47
+ it('refuses a score that is not machine-readably analytical-only', () => {
48
+ const forged = index({ analyticalOnly: false as unknown as true });
49
+ expect(() => assertAnalyticalDisclosure(forged)).toThrow(/not marked analytical-only/);
50
+ });
51
+
52
+ it('explains why, rather than just failing', () => {
53
+ const error = (() => {
54
+ try { assertAnalyticalDisclosure(index({ disclaimer: '' })); return null; } catch (e) { return e as Error; }
55
+ })();
56
+ expect(error!.message).toMatch(/read as a promise of recovery/);
57
+ });
58
+ });
59
+
60
+ function point(overrides: Partial<InterventionPoint>): InterventionPoint {
61
+ return {
62
+ nodeId: 'n', chain: 'ethereum', address: '0x1', nodeType: 'EXCHANGE', kind: 'CUSTODIAL',
63
+ label: null, entityName: null, hop: 1, observedPath: true, expanded: false, basis: 'x',
64
+ ...overrides,
65
+ };
66
+ }
67
+
68
+ function analysis(points: InterventionPoint[]): InterventionAnalysis {
69
+ return {
70
+ investigationId: 'inv-1', caseRef: 'INV-2026-0001',
71
+ interventionPoints: points, attributionLimits: [], unexplored: [], notes: [],
72
+ };
73
+ }
74
+
75
+ describe('reading an intervention analysis', () => {
76
+ /**
77
+ * A custodian holds the value; a bridge operator holds records of value that
78
+ * has already moved on. Writing this filter by hand at each call site is
79
+ * exactly where a bridge quietly gets served to counsel as somewhere to send
80
+ * a freeze request.
81
+ */
82
+ it('separates parties that hold value from parties that only have records', () => {
83
+ const result = custodialPoints(analysis([
84
+ point({ nodeId: 'cex', kind: 'CUSTODIAL' }),
85
+ point({ nodeId: 'bridge', kind: 'CROSS_CHAIN', nodeType: 'BRIDGE' }),
86
+ ]));
87
+
88
+ expect(result.map((p) => p.nodeId)).toEqual(['cex']);
89
+ });
90
+
91
+ /**
92
+ * A list forwarded to counsel must not silently mix destinations that were
93
+ * traced with destinations that were reasoned to. `inferredPoints` makes the
94
+ * second set something a caller has to decide about.
95
+ */
96
+ it('isolates the points whose connection to the case is inference', () => {
97
+ const result = inferredPoints(analysis([
98
+ point({ nodeId: 'traced', observedPath: true }),
99
+ point({ nodeId: 'post-mixer', observedPath: false }),
100
+ ]));
101
+
102
+ expect(result.map((p) => p.nodeId)).toEqual(['post-mixer']);
103
+ });
104
+
105
+ it('returns nothing rather than everything when a graph has no intervention points', () => {
106
+ expect(custodialPoints(analysis([]))).toEqual([]);
107
+ expect(inferredPoints(analysis([]))).toEqual([]);
108
+ });
109
+ });