File size: 3,184 Bytes
e94832e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
/** The graph endpoints. Same base-URL rule as the rest of the console. */
import { API_BASE } from "../../api/client";
import type { GraphPayload } from "./model";

async function get<T>(path: string, signal?: AbortSignal): Promise<T> {
  const response = await fetch(`${API_BASE}${path}`, { signal });
  if (!response.ok) {
    let detail = response.statusText;
    try {
      const body = await response.json();
      if (typeof body?.detail === "string") detail = body.detail;
    } catch {
      /* the status line is all we have */
    }
    throw new Error(detail);
  }
  return (await response.json()) as T;
}

export interface NeighborsPage extends GraphPayload {
  node_id: string;
  total: number;
  returned: number;
  offset: number;
  has_more: boolean;
  remaining: number;
}

export interface TracePayload extends GraphPayload {
  root: string;
  direction: "forward" | "backward";
  pruned_edges: number;
  wallets: number;
}

export interface PathPayload extends GraphPayload {
  from: string;
  to: string;
  hops: number;
  path: string[];
}

export interface ClusterPayload extends GraphPayload {
  cluster_id: string;
  size: number;
  risk: number;
  flag: string | null;
}

const q = (value: string) => encodeURIComponent(value);

export const graphApi = {
  neighbors: (
    id: string,
    { direction = "both", limit = 20, offset = 0 }: {
      direction?: "in" | "out" | "both";
      limit?: number;
      offset?: number;
    } = {},
    signal?: AbortSignal,
  ) =>
    get<NeighborsPage>(
      `/graph/nodes/${q(id)}/neighbors?direction=${direction}&limit=${limit}&offset=${offset}`,
      signal,
    ),

  trace: (
    from: string,
    {
      direction = "forward",
      maxHops = 4,
      minAmount = 0,
    }: { direction?: "forward" | "backward"; maxHops?: number; minAmount?: number } = {},
    signal?: AbortSignal,
  ) =>
    get<TracePayload>(
      `/graph/trace?from=${q(from)}&direction=${direction}&max_hops=${maxHops}&min_amount=${minAmount}`,
      signal,
    ),

  taint: (seed: string, maxHops = 4, signal?: AbortSignal) =>
    get<{
      seed: string;
      seed_entity: string;
      tainted: { entity_id: string; score: number; hops: number; path: string[] }[];
      total: number;
      caveat: string;
    }>(`/graph/taint?seed=${q(seed)}&max_hops=${maxHops}`, signal),

  path: (from: string, to: string, signal?: AbortSignal) =>
    get<PathPayload>(`/graph/path?from=${q(from)}&to=${q(to)}`, signal),

  cluster: (id: string, signal?: AbortSignal) =>
    get<ClusterPayload>(`/graph/clusters/${q(id)}`, signal),

  async save(name: string, state: unknown) {
    const response = await fetch(`${API_BASE}/investigations`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ name, state }),
    });
    if (!response.ok) throw new Error(`could not save: ${response.statusText}`);
    return (await response.json()) as { id: string; name: string; saved_at: string };
  },

  load: (id: string, signal?: AbortSignal) =>
    get<{ id: string; name: string; saved_at: string; state: Record<string, unknown> }>(
      `/investigations/${q(id)}`,
      signal,
    ),
};