@secondlayer/sentinel 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,29 @@
1
+ export declare const CREDITS_API_URL = "https://api.secondlayer.tools";
2
+ export type Session = {
3
+ token: string;
4
+ email: string;
5
+ accountId: string;
6
+ expiresAt: string;
7
+ };
8
+ export type TokenFile = {
9
+ token: string;
10
+ email?: string;
11
+ };
12
+ export type CredentialSource = "env" | "flag" | "token file" | "secondlayer session";
13
+ export type Credential = {
14
+ token: string;
15
+ source: CredentialSource;
16
+ };
17
+ /** The secondlayer server whose session Sentinel shares. Same override env as the secondlayer CLI. */
18
+ export declare const creditsUrl: () => string;
19
+ export declare const sessionPath: () => string;
20
+ export declare const tokenFilePath: () => string;
21
+ export declare function readSession(url?: string): Promise<Session | null>;
22
+ export declare function writeSession(session: Session, url?: string): Promise<void>;
23
+ export declare function clearSession(url?: string): Promise<void>;
24
+ export declare function readTokenFile(): Promise<TokenFile | null>;
25
+ export declare const writeTokenFile: (file: TokenFile) => Promise<void>;
26
+ export declare const clearTokenFile: () => Promise<void>;
27
+ /** The credential to use, or null when there is none anywhere. `flag` is the CLI's --token. */
28
+ export declare function resolveToken(flag?: string): Promise<Credential | null>;
29
+ export declare const NOT_LOGGED_IN = "Not logged in. Run: sentinel login";
@@ -0,0 +1,3 @@
1
+ /** The package's SDK entry (`@secondlayer/sentinel`): the API client and the credential helpers. */
2
+ export * from "./credentials";
3
+ export * from "./sentinel-client";
package/dist/index.js ADDED
@@ -0,0 +1,306 @@
1
+ // client/credentials.ts
2
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ var CREDITS_API_URL = "https://api.secondlayer.tools";
6
+ var creditsUrl = () => (process.env.SL_CREDITS_API_URL ?? CREDITS_API_URL).replace(/\/+$/, "");
7
+ var dir = () => join(process.env.HOME || homedir(), ".secondlayer");
8
+ var sessionPath = () => join(dir(), "session.json");
9
+ var tokenFilePath = () => join(dir(), "sentinel.json");
10
+ async function readJson(path) {
11
+ try {
12
+ return JSON.parse(await readFile(path, "utf8"));
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+ async function writePrivate(path, data) {
18
+ await mkdir(dirname(path), { recursive: true });
19
+ const tmp = join(dirname(path), `.${process.pid}.tmp`);
20
+ await writeFile(tmp, `${JSON.stringify(data, null, 2)}
21
+ `, { mode: 384, encoding: "utf8" });
22
+ await chmod(tmp, 384);
23
+ await rename(tmp, path);
24
+ }
25
+ var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
26
+ var isSession = (v) => isRecord(v) && typeof v.token === "string" && v.token !== "" && typeof v.email === "string";
27
+ async function readSessionFile() {
28
+ const raw = await readJson(sessionPath());
29
+ if (isRecord(raw) && isRecord(raw.sessions))
30
+ return { sessions: raw.sessions };
31
+ if (isSession(raw))
32
+ return { sessions: { [CREDITS_API_URL]: raw } };
33
+ return { sessions: {} };
34
+ }
35
+ async function readSession(url = creditsUrl()) {
36
+ const s = (await readSessionFile()).sessions[url.replace(/\/+$/, "")];
37
+ return isSession(s) ? s : null;
38
+ }
39
+ async function writeSession(session, url = creditsUrl()) {
40
+ const file = await readSessionFile();
41
+ file.sessions[url.replace(/\/+$/, "")] = session;
42
+ await writePrivate(sessionPath(), file);
43
+ }
44
+ async function clearSession(url = creditsUrl()) {
45
+ const file = await readSessionFile();
46
+ delete file.sessions[url.replace(/\/+$/, "")];
47
+ if (Object.keys(file.sessions).length === 0)
48
+ await rm(sessionPath(), { force: true });
49
+ else
50
+ await writePrivate(sessionPath(), file);
51
+ }
52
+ async function readTokenFile() {
53
+ const raw = await readJson(tokenFilePath());
54
+ return isRecord(raw) && typeof raw.token === "string" && raw.token !== "" ? { token: raw.token, email: typeof raw.email === "string" ? raw.email : undefined } : null;
55
+ }
56
+ var writeTokenFile = (file) => writePrivate(tokenFilePath(), file);
57
+ var clearTokenFile = () => rm(tokenFilePath(), { force: true });
58
+ async function resolveToken(flag) {
59
+ const env = process.env.SENTINEL_API_KEY;
60
+ if (env)
61
+ return { token: env, source: "env" };
62
+ if (flag)
63
+ return { token: flag, source: "flag" };
64
+ const file = await readTokenFile();
65
+ if (file)
66
+ return { token: file.token, source: "token file" };
67
+ const session = await readSession();
68
+ return session ? { token: session.token, source: "secondlayer session" } : null;
69
+ }
70
+ var NOT_LOGGED_IN = "Not logged in. Run: sentinel login";
71
+ // client/sentinel-client.ts
72
+ var DEFAULT_BASE_URL = "https://api.runsentinel.app";
73
+
74
+ class SentinelError extends Error {
75
+ status;
76
+ constructor(status, message) {
77
+ super(message);
78
+ this.status = status;
79
+ this.name = "SentinelError";
80
+ }
81
+ }
82
+ var subjectKey = (s) => s.kind === "fn" ? `fn:${s.fn}` : `asset:${s.asset}`;
83
+ function subjectName(s, units) {
84
+ if (s.kind === "fn")
85
+ return s.fn;
86
+ if (units?.symbol)
87
+ return units.symbol;
88
+ if (s.asset.toLowerCase() === "stx")
89
+ return "STX";
90
+ return s.asset.split("::")[1] ?? s.asset;
91
+ }
92
+
93
+ class SubjectError extends Error {
94
+ candidates;
95
+ constructor(message, candidates) {
96
+ super(message);
97
+ this.candidates = candidates;
98
+ this.name = "SubjectError";
99
+ }
100
+ }
101
+ function groupSubjects(watches) {
102
+ const out = new Map;
103
+ for (const w of watches) {
104
+ const key = subjectKey(w.subject);
105
+ const g = out.get(key) ?? { key, name: subjectName(w.subject, w.units), watches: [] };
106
+ g.watches.push(w);
107
+ out.set(key, g);
108
+ }
109
+ return [...out.values()];
110
+ }
111
+ function resolveSubject(watches, name) {
112
+ const groups = groupSubjects(watches);
113
+ const q = name.trim().toLowerCase();
114
+ const hits = groups.filter((g) => {
115
+ const s = g.watches[0].subject;
116
+ const asset = s.kind === "asset" ? s.asset.toLowerCase() : null;
117
+ return g.name.toLowerCase() === q || g.key.toLowerCase() === q || asset === q || asset !== null && asset.split("::")[1] === q;
118
+ });
119
+ if (hits.length === 1)
120
+ return hits[0];
121
+ if (hits.length === 0) {
122
+ const names = groups.map((g) => g.name);
123
+ throw new SubjectError(`No watched subject called "${name}". Watched: ${names.join(", ") || "nothing yet"}`, names);
124
+ }
125
+ const names = hits.map((g) => g.key);
126
+ throw new SubjectError(`"${name}" matches more than one subject: ${names.join(", ")}`, names);
127
+ }
128
+ var group = (digits) => digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
129
+ function toUnits(base, decimals) {
130
+ if (!/^\d+$/.test(base))
131
+ return base;
132
+ const digits = base.replace(/^0+(?=\d)/, "");
133
+ if (decimals === 0)
134
+ return group(digits);
135
+ const padded = digits.padStart(decimals + 1, "0");
136
+ const whole = padded.slice(0, padded.length - decimals);
137
+ const frac = padded.slice(padded.length - decimals).replace(/0+$/, "");
138
+ return frac ? `${group(whole)}.${frac}` : group(whole);
139
+ }
140
+ function amountText(base, units) {
141
+ return units ? `${toUnits(base, units.decimals)} ${units.symbol}` : `${group(base)} base units`;
142
+ }
143
+ var WINDOWS = { 720: "1 hour", 4320: "6 hours", 17280: "1 day" };
144
+ var windowText = (blocks) => WINDOWS[blocks] ?? `${blocks.toLocaleString("en-US")} blocks`;
145
+ function ruleSummary(rule, units) {
146
+ switch (rule.kind) {
147
+ case "signature":
148
+ return `verified finding: ${rule.findingTitle}`;
149
+ case "audit":
150
+ return rule.triggerClass === "governance.proposal_submitted" ? "reviews each governance proposal" : "reviews each upgrade";
151
+ case "callerAllowlist":
152
+ return rule.allow.length ? `any call from outside ${rule.allow.length} allowed principal(s)` : "any call";
153
+ case "outflowSingle":
154
+ return rule.amount ? `a single outflow of ${amountText(rule.amount, units)} or more` : "every outflow";
155
+ case "outflowWindow":
156
+ return `${amountText(rule.amount, units)} or more out within ${windowText(rule.blocks)}`;
157
+ case "valueNewActor":
158
+ return `${amountText(rule.amount, units)} or more to or from a new actor`;
159
+ case "actor": {
160
+ const names = {
161
+ newCaller: "a new caller",
162
+ firstCall: "a first call",
163
+ roleChange: "a role change"
164
+ };
165
+ return `${rule.signals.map((x) => names[x]).join(", ")}${rule.allow.length ? ` (outside ${rule.allow.length} allowed)` : ""}`;
166
+ }
167
+ }
168
+ }
169
+ function kindTag(rule) {
170
+ switch (rule.kind) {
171
+ case "signature":
172
+ return "finding";
173
+ case "outflowSingle":
174
+ case "outflowWindow":
175
+ case "valueNewActor":
176
+ return "outflow";
177
+ case "callerAllowlist":
178
+ case "actor":
179
+ return "who-acts";
180
+ case "audit":
181
+ return rule.triggerClass === "governance.proposal_submitted" ? "governance" : "upgrade";
182
+ }
183
+ }
184
+ function subjectStatus(ws) {
185
+ const st = ws.map((w) => w.status);
186
+ if (st.includes("live"))
187
+ return "live";
188
+ return st.length > 0 && st.every((x) => x === "paused") ? "paused" : "suggested";
189
+ }
190
+ var isMutableRule = (rule) => rule.kind !== "signature" && rule.kind !== "audit";
191
+ function mutedUntilOf(w, now = Date.now()) {
192
+ const at = w.delivery.mutedUntil;
193
+ return at && Date.parse(at) > now ? at : null;
194
+ }
195
+ function createClient(opts) {
196
+ const baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
197
+ const doFetch = opts.fetch ?? globalThis.fetch;
198
+ const authHeader = () => ({ authorization: `Bearer ${opts.token}` });
199
+ async function call(method, path, body) {
200
+ const res = await doFetch(`${baseUrl}${path}`, {
201
+ method,
202
+ headers: {
203
+ ...authHeader(),
204
+ ...body === undefined ? {} : { "content-type": "application/json" }
205
+ },
206
+ body: body === undefined ? undefined : JSON.stringify(body)
207
+ });
208
+ const data = await res.json().catch(() => null);
209
+ if (!res.ok)
210
+ throw new SentinelError(res.status, data?.error ?? `HTTP ${res.status}`);
211
+ return data;
212
+ }
213
+ const plan = (id) => `/v1/plans/${encodeURIComponent(id)}`;
214
+ const appPlan = (id) => `/plan/${encodeURIComponent(id)}`;
215
+ return {
216
+ me: () => call("GET", "/v1/me"),
217
+ plans: async () => (await call("GET", "/v1/plans")).plans,
218
+ plan: (id) => call("GET", plan(id)),
219
+ finding: (id, n) => call("GET", `${plan(id)}/findings/${n}`),
220
+ runs: async (id) => (await call("GET", `${plan(id)}/runs`)).runs,
221
+ alerts: async (contract) => (await call("GET", contract ? `/v1/alerts?contract=${encodeURIComponent(contract)}` : "/v1/alerts")).alerts,
222
+ reverify: (id) => call("POST", `/plan/${encodeURIComponent(id)}/reverify`),
223
+ reproduce: (id, finding) => call("POST", `/plan/${encodeURIComponent(id)}/reproduce`, { finding }),
224
+ challenge: (id, finding, reason) => call("POST", `${appPlan(id)}/challenge`, { finding, reason }),
225
+ replay: (id, finding) => call("POST", `${appPlan(id)}/replay`, { finding }),
226
+ directed: (id, prompt) => call("POST", `${appPlan(id)}/directed`, { prompt }),
227
+ testsFromRepo: (id) => call("POST", `${appPlan(id)}/tests`, { fromRepo: true }),
228
+ previewPlan: (contractId) => call("POST", "/plan/preview", { contractId }),
229
+ createPlan: (contractId) => call("POST", "/plan", { contractId }),
230
+ billing: () => call("GET", "/billing"),
231
+ watches: (planId) => call("GET", `${appPlan(planId)}/watches`),
232
+ setRule: (planId, watchId, rule) => call("POST", `${appPlan(planId)}/watches/${encodeURIComponent(watchId)}/rule`, { rule }),
233
+ addRule: (planId, subject, rule) => call("POST", `${appPlan(planId)}/watches`, { subject, rule }),
234
+ setWatches: (planId, watchIds, on) => call("POST", `${appPlan(planId)}/watches/state`, {
235
+ watchIds,
236
+ on
237
+ }),
238
+ mute: (planId, watchId, mutedUntil) => call("POST", `${appPlan(planId)}/watches/${encodeURIComponent(watchId)}/delivery`, { mutedUntil }),
239
+ muteAlert: (alertId, hours) => call("POST", `/alerts/${encodeURIComponent(alertId)}/mute`, { hours }),
240
+ parseRule: (planId, subject, text) => call("POST", `${appPlan(planId)}/rules/parse`, { subject, text }),
241
+ monitorSettings: (planId, settings) => call("POST", `${appPlan(planId)}/monitor`, settings),
242
+ testAlert: (planId, dest = {}) => call("POST", `${appPlan(planId)}/testalert`, dest),
243
+ watchContract: (contractId) => call("POST", "/contracts/watch", {
244
+ contractId
245
+ }),
246
+ stopMonitoring: (planId) => call("DELETE", `${appPlan(planId)}/monitor`)
247
+ };
248
+ }
249
+ var HOUR_MS = 3600000;
250
+ var muteEnd = (hours) => new Date(Date.now() + Math.min(hours * HOUR_MS, 7 * 24 * HOUR_MS - 60000)).toISOString();
251
+ async function watchContext(client, planId) {
252
+ const plan = await client.plan(planId);
253
+ return {
254
+ planId,
255
+ contractId: plan.contractId,
256
+ access: plan.access,
257
+ watches: (await client.watches(planId)).watches
258
+ };
259
+ }
260
+ async function savedMonitor(client, planId) {
261
+ const m = (await client.plan(planId)).monitor;
262
+ if (!m?.status)
263
+ throw new SentinelError(409, "Turn monitoring on first.");
264
+ return m;
265
+ }
266
+ var savedChoices = async (client, planId) => (await savedMonitor(client, planId)).watches;
267
+ async function planOfContract(client, contractId) {
268
+ const me = await client.me();
269
+ const hit = [...me.watching ?? [], ...me.claims ?? []].find((c) => c.contractId === contractId);
270
+ if (!hit)
271
+ throw new SentinelError(404, `You aren't monitoring ${contractId}.`);
272
+ return hit.planId;
273
+ }
274
+ export {
275
+ CREDITS_API_URL,
276
+ DEFAULT_BASE_URL,
277
+ NOT_LOGGED_IN,
278
+ SentinelError,
279
+ SubjectError,
280
+ clearSession,
281
+ clearTokenFile,
282
+ createClient,
283
+ creditsUrl,
284
+ groupSubjects,
285
+ isMutableRule,
286
+ kindTag,
287
+ muteEnd,
288
+ mutedUntilOf,
289
+ planOfContract,
290
+ readSession,
291
+ readTokenFile,
292
+ resolveSubject,
293
+ resolveToken,
294
+ ruleSummary,
295
+ savedChoices,
296
+ savedMonitor,
297
+ sessionPath,
298
+ subjectKey,
299
+ subjectName,
300
+ subjectStatus,
301
+ toUnits,
302
+ tokenFilePath,
303
+ watchContext,
304
+ writeSession,
305
+ writeTokenFile
306
+ };