@vestiarion/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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vestiarion contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # @vestiarion/sdk
2
+
3
+ A typed client for the [Vestiarion](https://www.vestiarion.xyz) API and its signed webhooks:
4
+
5
+ - every `/api/v1` endpoint, with its types;
6
+ - pagination that reads every page;
7
+ - retries that never add a record twice;
8
+ - checks for webhook signatures and ledger entries.
9
+
10
+ It has no dependencies, and runs on Node 20 or later, Deno, Bun and edge runtimes such as Cloudflare Workers.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install https://www.vestiarion.xyz/sdk/vestiarion-sdk-0.1.0.tgz
16
+ ```
17
+
18
+ pnpm, Yarn and Bun take the same URL. The package is ESM only.
19
+
20
+ ## Read
21
+
22
+ ```ts
23
+ import { Vestiarion } from "@vestiarion/sdk";
24
+
25
+ const vestiarion = new Vestiarion({ apiKey: process.env.VESTIARION_API_KEY! });
26
+
27
+ const status = await vestiarion.status.get();
28
+ const { data: held, page } = await vestiarion.invoices.list({ status: "held" });
29
+ const verification = await vestiarion.ledger.verify();
30
+
31
+ for await (const invoice of vestiarion.invoices.listAll({ direction: "payable" })) {
32
+ console.log(invoice.id, invoice.status, invoice.agentReasoning);
33
+ }
34
+ ```
35
+
36
+ An owner or admin creates a workspace API key on the workspace's Settings page. Keep it on a server: it reads the whole
37
+ workspace.
38
+
39
+ ## Add records
40
+
41
+ A key with write access ("Can also add counterparties and invoices") adds counterparties and invoices.
42
+ - The agent decides each invoice as it decides one typed into the console, with every guardrail.
43
+ - An address added this way waits for a person in the workspace to confirm it.
44
+
45
+ ```ts
46
+ const counterparty = await vestiarion.counterparties.create(
47
+ { name: "Quill Studio", role: "vendor", address: "0x5b2d8c1f0e7a4936b8d1c0e2f3a4b5c6d7e8f901", paymentLimit: "500" },
48
+ { idempotencyKey: "crm-vendor-1042" }
49
+ );
50
+ const invoice = await vestiarion.invoices.create(
51
+ { counterpartyId: counterparty.id, amount: "420.00", dueDate: "2026-10-31", poReference: "PO-4012", goodsReceived: true },
52
+ { idempotencyKey: "billing-inv-2026-0042" }
53
+ );
54
+ ```
55
+
56
+ Pass your own record's id as `idempotencyKey`, so that your own retries cannot add it twice either.
57
+
58
+ ## Errors and retries
59
+
60
+ Every failure is a `VestiarionError`. It carries:
61
+ - `status`;
62
+ - `code`: the API's error code, or `network_error`, `timeout` or `invalid_response`;
63
+ - `message`;
64
+ - `retryAfter`.
65
+
66
+ A request is retried twice by default after:
67
+ - a `429`, waiting as long as `Retry-After` says, up to 60 seconds;
68
+ - a `500`, `502`, `503` or `504`;
69
+ - a timeout or a network failure.
70
+
71
+ A write always carries an `Idempotency-Key`, so retrying it never adds the record twice.
72
+
73
+ ## Webhooks
74
+
75
+ ```ts
76
+ import { verifyLedgerEntry, verifyWebhook } from "@vestiarion/sdk/webhooks";
77
+
78
+ export async function POST(request: Request) {
79
+ const event = await verifyWebhook({
80
+ secret: process.env.VESTIARION_WEBHOOK_SECRET!,
81
+ payload: await request.text(),
82
+ signature: request.headers.get("vestiarion-signature"),
83
+ });
84
+ if (event.entry) {
85
+ const check = await verifyLedgerEntry(event.entry, process.env.VESTIARION_LEDGER_PUBLIC_KEY!);
86
+ if (check.ok === false) return new Response(check.reason, { status: 400 });
87
+ }
88
+ return new Response(null, { status: 204 });
89
+ }
90
+ ```
91
+
92
+ `verifyWebhook` throws a `WebhookVerificationError` unless the signature matches the raw body, signed less than 5
93
+ minutes ago. `verifyLedgerEntry` checks the entry's own Ed25519 signature and chain hash, with the public key from the
94
+ workspace's Audit page.
95
+
96
+ The full guide: https://www.vestiarion.xyz/docs/get-started/sdk
97
+
98
+ ## Licence
99
+
100
+ MIT
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The ledger's canonical JSON: keys sorted at every level, `undefined` left out, nothing else changed. An entry's
3
+ * `bodyHash` is SHA-256 of this over `{ actor, domain, action, summary, detail }`. tests/sdk-webhooks.test.ts holds it
4
+ * to the server's own (src/lib/canonical-json.ts).
5
+ */
6
+ export declare function canonicalJson(value: unknown): string;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The ledger's canonical JSON: keys sorted at every level, `undefined` left out, nothing else changed. An entry's
3
+ * `bodyHash` is SHA-256 of this over `{ actor, domain, action, summary, detail }`. tests/sdk-webhooks.test.ts holds it
4
+ * to the server's own (src/lib/canonical-json.ts).
5
+ */
6
+ export function canonicalJson(value) {
7
+ if (value === null || typeof value !== "object")
8
+ return JSON.stringify(value) ?? "null";
9
+ if (Array.isArray(value))
10
+ return `[${value.map(canonicalJson).join(",")}]`;
11
+ const entries = Object.entries(value)
12
+ .filter(([, v]) => v !== undefined)
13
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
14
+ .map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`);
15
+ return `{${entries.join(",")}}`;
16
+ }
@@ -0,0 +1,60 @@
1
+ import { type FetchLike } from "./http.js";
2
+ import type { Counterparty, CounterpartyDetail, CreateCounterpartyInput, CreateInvoiceInput, Insights, Invoice, LedgerEntry, LedgerVerification, ListCounterpartiesParams, ListInvoicesParams, ListLedgerEntriesParams, ListMilestonesParams, Milestone, Page, Status, Treasury } from "./types.js";
3
+ /** Where the API is, unless `baseUrl` says otherwise. */
4
+ export declare const DEFAULT_BASE_URL = "https://www.vestiarion.xyz";
5
+ export interface VestiarionOptions {
6
+ /** A workspace API key, `vxk_<prefix>_<secret>`. Keep it on a server: it reads the whole workspace. */
7
+ apiKey: string;
8
+ /** Where the API is. Defaults to `https://www.vestiarion.xyz`. Plain `http` is refused, except to localhost. */
9
+ baseUrl?: string;
10
+ /** Defaults to the runtime's `fetch`. */
11
+ fetch?: FetchLike;
12
+ /** How many times a request is retried after a 429, a 5xx, a timeout or a network failure. Defaults to 2. */
13
+ maxRetries?: number;
14
+ /** How long one attempt may take, in milliseconds. Defaults to 30 000. */
15
+ timeoutMs?: number;
16
+ }
17
+ /** One page of a collection, as the API answers it. */
18
+ export interface List<T> {
19
+ data: T[];
20
+ page: Page;
21
+ }
22
+ /** A write's options: the `Idempotency-Key` that makes retrying it safe. Best: the record's id in your own system. */
23
+ export interface WriteOptions {
24
+ idempotencyKey?: string;
25
+ }
26
+ export interface Collection<T, P> {
27
+ /** One page, `{ data, page }`. Pass `page.nextCursor` back as `cursor` for the next. */
28
+ list(params?: P): Promise<List<T>>;
29
+ /** Every page in turn, from `params.cursor` on. Store each `page.nextCursor` once its page is processed, to resume from it. */
30
+ pages(params?: P): AsyncIterable<List<T>>;
31
+ /** Every item, page after page, until the collection ends. */
32
+ listAll(params?: P): AsyncIterable<T>;
33
+ }
34
+ /**
35
+ * A client for one workspace's `/api/v1` (docs/superpowers/specs/2026-10-03-typescript-sdk-design.md R4): one method
36
+ * per operation, a resource's `data` returned as it is, and a collection paged by `list`, `pages` or `listAll`.
37
+ */
38
+ export declare class Vestiarion {
39
+ readonly status: {
40
+ get(): Promise<Status>;
41
+ };
42
+ readonly ledger: Collection<LedgerEntry, ListLedgerEntriesParams> & {
43
+ verify(): Promise<LedgerVerification>;
44
+ };
45
+ readonly invoices: Collection<Invoice, ListInvoicesParams> & {
46
+ create(input: CreateInvoiceInput, options?: WriteOptions): Promise<Invoice>;
47
+ };
48
+ readonly counterparties: Collection<Counterparty, ListCounterpartiesParams> & {
49
+ get(id: string): Promise<CounterpartyDetail>;
50
+ create(input: CreateCounterpartyInput, options?: WriteOptions): Promise<Counterparty>;
51
+ };
52
+ readonly milestones: Collection<Milestone, ListMilestonesParams>;
53
+ readonly treasury: {
54
+ get(): Promise<Treasury>;
55
+ };
56
+ readonly insights: {
57
+ get(): Promise<Insights>;
58
+ };
59
+ constructor(options: VestiarionOptions);
60
+ }
package/dist/client.js ADDED
@@ -0,0 +1,91 @@
1
+ import { VestiarionError } from "./errors.js";
2
+ import { createTransport } from "./http.js";
3
+ /** Where the API is, unless `baseUrl` says otherwise. */
4
+ export const DEFAULT_BASE_URL = "https://www.vestiarion.xyz";
5
+ const KEY_FORMAT = /^vxk_[a-z2-7]{8}_[A-Za-z0-9_-]{43}$/;
6
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
7
+ /** An https URL, or plain http to this machine for local testing: the key must never travel in the clear. */
8
+ function isSafeBaseUrl(value) {
9
+ if (typeof value !== "string")
10
+ return false;
11
+ let url;
12
+ try {
13
+ url = new URL(value);
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ return url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname));
19
+ }
20
+ function collection(transport, path) {
21
+ const list = (params) => transport.request({ method: "GET", path, query: params });
22
+ async function* pages(params) {
23
+ let cursor = params?.cursor;
24
+ for (;;) {
25
+ const answer = await list({ ...params, cursor });
26
+ yield answer;
27
+ const next = answer.page.nextCursor;
28
+ if (!answer.page.hasMore || next === null)
29
+ return;
30
+ if (next === cursor)
31
+ throw new VestiarionError(200, "invalid_response", "The API answered the same cursor twice.");
32
+ cursor = next;
33
+ }
34
+ }
35
+ async function* listAll(params) {
36
+ for await (const answer of pages(params))
37
+ yield* answer.data;
38
+ }
39
+ return { list, pages, listAll };
40
+ }
41
+ /**
42
+ * A client for one workspace's `/api/v1` (docs/superpowers/specs/2026-10-03-typescript-sdk-design.md R4): one method
43
+ * per operation, a resource's `data` returned as it is, and a collection paged by `list`, `pages` or `listAll`.
44
+ */
45
+ export class Vestiarion {
46
+ status;
47
+ ledger;
48
+ invoices;
49
+ counterparties;
50
+ milestones;
51
+ treasury;
52
+ insights;
53
+ constructor(options) {
54
+ if (typeof options?.apiKey !== "string" || !KEY_FORMAT.test(options.apiKey)) {
55
+ throw new TypeError("apiKey must be a workspace API key, shaped vxk_<prefix>_<secret>.");
56
+ }
57
+ const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
58
+ if (!isSafeBaseUrl(baseUrl))
59
+ throw new TypeError("baseUrl must be an https URL, or http to localhost: the key must not travel in the clear.");
60
+ // NaN here would retry a failing request forever, or time out every attempt.
61
+ const maxRetries = options.maxRetries ?? 2;
62
+ if (!Number.isInteger(maxRetries) || maxRetries < 0)
63
+ throw new TypeError("maxRetries must be a whole number, 0 or more.");
64
+ const timeoutMs = options.timeoutMs ?? 30_000;
65
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
66
+ throw new TypeError("timeoutMs must be a positive number of milliseconds.");
67
+ const fetchImpl = options.fetch ?? (typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : undefined);
68
+ if (!fetchImpl)
69
+ throw new TypeError("This runtime has no fetch: pass one as options.fetch.");
70
+ const transport = createTransport({ apiKey: options.apiKey, baseUrl, fetch: fetchImpl, maxRetries, timeoutMs });
71
+ const one = (path) => transport.request({ method: "GET", path }).then((answer) => answer.data);
72
+ const add = (path, body, write) => transport.request({ method: "POST", path, body, idempotencyKey: write?.idempotencyKey }).then((answer) => answer.data);
73
+ this.status = { get: () => one("/api/v1/status") };
74
+ this.ledger = {
75
+ ...collection(transport, "/api/v1/ledger"),
76
+ verify: () => one("/api/v1/ledger/verify"),
77
+ };
78
+ this.invoices = {
79
+ ...collection(transport, "/api/v1/invoices"),
80
+ create: (input, write) => add("/api/v1/invoices", input, write),
81
+ };
82
+ this.counterparties = {
83
+ ...collection(transport, "/api/v1/counterparties"),
84
+ get: (id) => one(`/api/v1/counterparties/${encodeURIComponent(id)}`),
85
+ create: (input, write) => add("/api/v1/counterparties", input, write),
86
+ };
87
+ this.milestones = collection(transport, "/api/v1/milestones");
88
+ this.treasury = { get: () => one("/api/v1/treasury") };
89
+ this.insights = { get: () => one("/api/v1/insights") };
90
+ }
91
+ }
@@ -0,0 +1,14 @@
1
+ import type { ApiErrorCode } from "./types.js";
2
+ /** What went wrong: one of the API's own codes, or what happened before an answer arrived. */
3
+ export type VestiarionErrorCode = ApiErrorCode | "network_error" | "timeout" | "invalid_response";
4
+ /**
5
+ * Every failed request (TypeScript SDK design R5): the HTTP status (0 when no answer arrived), a code to branch on,
6
+ * the API's own message, and how long the API's `Retry-After` asked to wait, in seconds.
7
+ */
8
+ export declare class VestiarionError extends Error {
9
+ readonly status: number;
10
+ readonly code: VestiarionErrorCode;
11
+ readonly retryAfter: number | null;
12
+ readonly name = "VestiarionError";
13
+ constructor(status: number, code: VestiarionErrorCode, message: string, retryAfter?: number | null);
14
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Every failed request (TypeScript SDK design R5): the HTTP status (0 when no answer arrived), a code to branch on,
3
+ * the API's own message, and how long the API's `Retry-After` asked to wait, in seconds.
4
+ */
5
+ export class VestiarionError extends Error {
6
+ status;
7
+ code;
8
+ retryAfter;
9
+ name = "VestiarionError";
10
+ constructor(status, code, message, retryAfter = null) {
11
+ super(message);
12
+ this.status = status;
13
+ this.code = code;
14
+ this.retryAfter = retryAfter;
15
+ }
16
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,42 @@
1
+ /** The part of `fetch` the SDK uses. Pass your own to route, record or proxy its requests. */
2
+ export type FetchLike = (url: string, init: {
3
+ method: string;
4
+ headers: Record<string, string>;
5
+ body?: string;
6
+ signal?: AbortSignal;
7
+ }) => Promise<{
8
+ status: number;
9
+ headers: {
10
+ get(name: string): string | null;
11
+ };
12
+ text(): Promise<string>;
13
+ }>;
14
+ export interface TransportOptions {
15
+ apiKey: string;
16
+ baseUrl: string;
17
+ fetch: FetchLike;
18
+ maxRetries: number;
19
+ timeoutMs: number;
20
+ /** Waits that many milliseconds. Replaced in tests. */
21
+ sleep?: (ms: number) => Promise<void>;
22
+ /** A number in [0, 1), for the backoff's jitter. Replaced in tests. */
23
+ random?: () => number;
24
+ /** A fresh Idempotency-Key. Replaced in tests. */
25
+ uuid?: () => string;
26
+ }
27
+ export interface RequestSpec {
28
+ method: "GET" | "POST";
29
+ path: string;
30
+ query?: object;
31
+ body?: unknown;
32
+ idempotencyKey?: string;
33
+ }
34
+ export interface Transport {
35
+ request<T>(spec: RequestSpec): Promise<T>;
36
+ }
37
+ /**
38
+ * Sends one request to the API and reads its answer (TypeScript SDK design R4–R6): the key as a bearer token, a write's
39
+ * body with its `Idempotency-Key`, retries after a 429, a 5xx, a timeout or a network failure, and every failure as a
40
+ * `VestiarionError`.
41
+ */
42
+ export declare function createTransport(options: TransportOptions): Transport;
package/dist/http.js ADDED
@@ -0,0 +1,138 @@
1
+ import { VestiarionError } from "./errors.js";
2
+ import { API_ERROR_CODES } from "./types.js";
3
+ import { VERSION } from "./version.js";
4
+ /** The longest `Retry-After` the SDK waits out; a longer one is thrown, so a call never blocks for minutes (R6). */
5
+ const RETRY_AFTER_CAP_S = 60;
6
+ const BACKOFF_START_MS = 500;
7
+ const BACKOFF_CAP_MS = 8_000;
8
+ const RETRIED_STATUSES = new Set([500, 502, 503, 504]);
9
+ const API_CODES = new Set(API_ERROR_CODES);
10
+ function queryString(query) {
11
+ if (!query)
12
+ return "";
13
+ const params = new URLSearchParams();
14
+ for (const [name, value] of Object.entries(query)) {
15
+ if (value === undefined || value === null)
16
+ continue;
17
+ params.append(name, String(value));
18
+ }
19
+ const text = params.toString();
20
+ return text ? `?${text}` : "";
21
+ }
22
+ function retryAfterSeconds(header) {
23
+ return header !== null && /^\d+$/.test(header.trim()) ? Number(header.trim()) : null;
24
+ }
25
+ /** The API's own `{ error: { code, message } }`, or null for any other body. */
26
+ function apiErrorOf(text) {
27
+ try {
28
+ const error = JSON.parse(text).error;
29
+ if (typeof error?.code === "string" && API_CODES.has(error.code) && typeof error.message === "string") {
30
+ return { code: error.code, message: error.message };
31
+ }
32
+ }
33
+ catch {
34
+ // Not JSON: a proxy's page, or nothing.
35
+ }
36
+ return null;
37
+ }
38
+ /** What a status means when its body is not the API's own error. */
39
+ function codeForStatus(status) {
40
+ const named = {
41
+ 400: "invalid_request",
42
+ 401: "unauthorized",
43
+ 403: "forbidden",
44
+ 404: "not_found",
45
+ 409: "conflict",
46
+ 429: "rate_limited",
47
+ 503: "unavailable",
48
+ };
49
+ return named[status] ?? (status >= 500 ? "internal" : "invalid_response");
50
+ }
51
+ /**
52
+ * Sends one request to the API and reads its answer (TypeScript SDK design R4–R6): the key as a bearer token, a write's
53
+ * body with its `Idempotency-Key`, retries after a 429, a 5xx, a timeout or a network failure, and every failure as a
54
+ * `VestiarionError`.
55
+ */
56
+ export function createTransport(options) {
57
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
58
+ const random = options.random ?? Math.random;
59
+ const uuid = options.uuid ?? (() => globalThis.crypto.randomUUID());
60
+ // Called as a plain function: a runtime's own fetch throws "Illegal invocation" when called on another object.
61
+ const fetchImpl = options.fetch;
62
+ const base = options.baseUrl.replace(/\/+$/, "");
63
+ function backoff(attempt) {
64
+ const ceiling = Math.min(BACKOFF_CAP_MS, BACKOFF_START_MS * 2 ** attempt);
65
+ return Math.round(ceiling / 2 + (random() * ceiling) / 2);
66
+ }
67
+ function retryDelay(status, retryAfter, attempt, method) {
68
+ const waitable = retryAfter !== null && retryAfter <= RETRY_AFTER_CAP_S;
69
+ if (status === 429)
70
+ return retryAfter === null ? backoff(attempt) : waitable ? retryAfter * 1000 : null;
71
+ if (RETRIED_STATUSES.has(status))
72
+ return waitable ? retryAfter * 1000 : backoff(attempt);
73
+ // The same key and body again: a conflict now means the first attempt is still being handled (R6).
74
+ if (status === 409 && method === "POST" && attempt > 0)
75
+ return backoff(attempt);
76
+ return null;
77
+ }
78
+ async function attemptOnce(url, init, attempt, method) {
79
+ const controller = new AbortController();
80
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs);
81
+ try {
82
+ let status;
83
+ let headers;
84
+ let text;
85
+ try {
86
+ const response = await fetchImpl(url, { ...init, signal: controller.signal });
87
+ status = response.status;
88
+ headers = response.headers;
89
+ text = await response.text();
90
+ }
91
+ catch {
92
+ const error = controller.signal.aborted
93
+ ? new VestiarionError(0, "timeout", `No answer within ${options.timeoutMs} ms.`)
94
+ : new VestiarionError(0, "network_error", "The request did not reach the API.");
95
+ return { error, retryInMs: backoff(attempt) };
96
+ }
97
+ if (status >= 200 && status < 300) {
98
+ try {
99
+ return { value: JSON.parse(text) };
100
+ }
101
+ catch {
102
+ return { error: new VestiarionError(status, "invalid_response", "The API's answer was not JSON."), retryInMs: null };
103
+ }
104
+ }
105
+ const retryAfter = retryAfterSeconds(headers.get("retry-after"));
106
+ const own = apiErrorOf(text);
107
+ const error = new VestiarionError(status, own?.code ?? codeForStatus(status), own?.message ?? `The API answered HTTP ${status}.`, retryAfter);
108
+ return { error, retryInMs: retryDelay(status, retryAfter, attempt, method) };
109
+ }
110
+ finally {
111
+ clearTimeout(timer);
112
+ }
113
+ }
114
+ async function request(spec) {
115
+ const url = `${base}${spec.path}${queryString(spec.query)}`;
116
+ const headers = {
117
+ authorization: `Bearer ${options.apiKey}`,
118
+ accept: "application/json",
119
+ "user-agent": `vestiarion-sdk-js/${VERSION}`,
120
+ };
121
+ let body;
122
+ if (spec.method === "POST") {
123
+ headers["content-type"] = "application/json";
124
+ // Every write carries a key, so retrying it can never add the record twice (R6).
125
+ headers["idempotency-key"] = spec.idempotencyKey ?? uuid();
126
+ body = JSON.stringify(spec.body ?? {});
127
+ }
128
+ for (let attempt = 0;; attempt += 1) {
129
+ const outcome = await attemptOnce(url, { method: spec.method, headers, body }, attempt, spec.method);
130
+ if ("value" in outcome)
131
+ return outcome.value;
132
+ if (outcome.retryInMs === null || attempt >= options.maxRetries)
133
+ throw outcome.error;
134
+ await sleep(outcome.retryInMs);
135
+ }
136
+ }
137
+ return { request };
138
+ }
@@ -0,0 +1,6 @@
1
+ export { Vestiarion, DEFAULT_BASE_URL, type Collection, type List, type VestiarionOptions, type WriteOptions } from "./client.js";
2
+ export { VestiarionError, type VestiarionErrorCode } from "./errors.js";
3
+ export type { FetchLike } from "./http.js";
4
+ export * from "./types.js";
5
+ export { VERSION } from "./version.js";
6
+ export { verifyLedgerEntry, verifyWebhook, WEBHOOK_TOLERANCE_SECONDS, WebhookVerificationError, type EntryCheck, type WebhookEvent, type WebhookLedgerEntry, type WebhookVerificationReason, } from "./webhooks.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { Vestiarion, DEFAULT_BASE_URL } from "./client.js";
2
+ export { VestiarionError } from "./errors.js";
3
+ export * from "./types.js";
4
+ export { VERSION } from "./version.js";
5
+ export { verifyLedgerEntry, verifyWebhook, WEBHOOK_TOLERANCE_SECONDS, WebhookVerificationError, } from "./webhooks.js";
@@ -0,0 +1,383 @@
1
+ /** One of a closed set, so a client can branch on it without parsing the message. */
2
+ export declare const API_ERROR_CODES: readonly ["unauthorized", "forbidden", "not_found", "invalid_request", "conflict", "rate_limited", "unavailable", "internal"];
3
+ export type ApiErrorCode = (typeof API_ERROR_CODES)[number];
4
+ /** What this workspace is, and what it can actually do. */
5
+ export interface Status {
6
+ /** The workspace's name. */
7
+ businessName: string;
8
+ /** Payments and yield differ and are reported separately, as in the UI. `unavailable` means the workspace's Circle credentials are stored but could not be read: cycles refuse to pay then rather than simulate, so neither leg is live or simulated. */
9
+ provenance: {
10
+ payments: "live" | "simulate" | "unavailable";
11
+ yield: "live" | "simulate" | "unavailable";
12
+ screening: "live" | "simulate";
13
+ };
14
+ /** The workspace's clock, and when its last cycle ran. */
15
+ clock: {
16
+ mode: "real" | "simulate";
17
+ day: number;
18
+ lastCycleAt: string | null;
19
+ };
20
+ /** Running totals across the workspace. */
21
+ totals: {
22
+ decisionsLogged: number;
23
+ totalPaidOut: number;
24
+ flagged: number;
25
+ };
26
+ /** What is configured for this workspace, as flags and modes. Never carries a secret. */
27
+ configuration: Record<string, unknown>;
28
+ apiVersion: "v1";
29
+ }
30
+ /** One entry of the audit chain, which the API returns oldest first. */
31
+ export interface LedgerEntry {
32
+ /** Monotonic within a workspace, but not gap-free; the hash chain proves continuity. */
33
+ seq: number;
34
+ id: string;
35
+ ts: string;
36
+ actor: string;
37
+ domain: string;
38
+ action: string;
39
+ summary: string;
40
+ detail: Record<string, unknown>;
41
+ /** Present so a consumer can verify the chain itself rather than trust us. */
42
+ bodyHash: string;
43
+ signature: string;
44
+ prevHash: string;
45
+ hash: string;
46
+ /** Which key signed the entry; `null` for entries written before key identity existed. A consumer verifying for itself needs this to pick the right key. */
47
+ signingKeyId: string | null;
48
+ }
49
+ /** The verdict of replaying the workspace's ledger: signatures, body hashes and hash continuity. */
50
+ export interface LedgerVerification {
51
+ /** `true` verified, `false` broken, and `null` not checked, which is a third answer, not a soft failure. A workspace holding no public key has produced no evidence either way. */
52
+ valid: boolean | null;
53
+ checkedEntries: number;
54
+ /** The `seq` of the first entry that failed. Absent unless `valid` is `false`. */
55
+ brokenAt?: number;
56
+ /** Why the verdict is what it is, when it is not a plain `true`. */
57
+ reason?: string;
58
+ /** Configuration problems found on the way to this verdict; not about the chain. */
59
+ warnings?: Array<string>;
60
+ }
61
+ /** An invoice in the payable or receivable book, with the agent's reasoning. */
62
+ export interface Invoice {
63
+ id: string;
64
+ direction: "payable" | "receivable";
65
+ status: string;
66
+ amount: number;
67
+ /** USDC or EURC: what `amount` is in, and what a payable is paid in. A EURC payable is checked against the counterparty's USDC limit at a quoted rate. */
68
+ currency: string;
69
+ memo: string | null;
70
+ poReference: string | null;
71
+ goodsReceived: boolean;
72
+ dueDate: string;
73
+ /** ISO timestamp the agent has committed to pay this on, once scheduled; else null. */
74
+ scheduledFor: string | null;
75
+ /** The early-payment discount this invoice carries, if any: the percent off and the deadline's ISO timestamp. */
76
+ earlyPayDiscount: {
77
+ percent: number;
78
+ deadline: string;
79
+ } | null;
80
+ decidedAt: string | null;
81
+ settledAt: string | null;
82
+ escalatedAt: string | null;
83
+ /** Why the agent ruled as it did, verbatim from the decision. */
84
+ agentReasoning: string | null;
85
+ /** An on-chain hash once the payment settled, else null: on Arc testnet, or for a payout from a Gateway balance the mint on the payee's chain. */
86
+ txHash: string | null;
87
+ /** What actually left once this invoice was paid; null otherwise, even while a submitted transfer already carries an amount. */
88
+ paidAmount: number | null;
89
+ counterparty: {
90
+ id: string;
91
+ name: string;
92
+ riskLevel: string;
93
+ } | null;
94
+ createdAt: string;
95
+ }
96
+ /** A vendor, client or contractor, with its risk tier and payment limits. */
97
+ export interface Counterparty {
98
+ id: string;
99
+ name: string;
100
+ role: "vendor" | "client" | "contractor";
101
+ address: string | null;
102
+ chain: string | null;
103
+ jurisdiction: string | null;
104
+ riskLevel: "unscreened" | "clear" | "medium" | "high";
105
+ riskNotes: string | null;
106
+ /** The business's baseline payment limit for this counterparty. */
107
+ baselinePaymentLimit: number | null;
108
+ /** The current payment limit, derived from the risk tier. */
109
+ paymentLimit: number | null;
110
+ lastScreenedAt: string | null;
111
+ /** No history is different from a zero score and remains null. */
112
+ performanceScore: number | null;
113
+ performanceInputs: Record<string, unknown> | null;
114
+ createdAt: string;
115
+ }
116
+ /** A counterparty, with its recent screening history. */
117
+ export interface CounterpartyDetail {
118
+ id: string;
119
+ name: string;
120
+ role: "vendor" | "client" | "contractor";
121
+ address: string | null;
122
+ chain: string | null;
123
+ jurisdiction: string | null;
124
+ riskLevel: "unscreened" | "clear" | "medium" | "high";
125
+ riskNotes: string | null;
126
+ /** The business's baseline payment limit for this counterparty. */
127
+ baselinePaymentLimit: number | null;
128
+ /** The current payment limit, derived from the risk tier. */
129
+ paymentLimit: number | null;
130
+ lastScreenedAt: string | null;
131
+ /** No history is different from a zero score and remains null. */
132
+ performanceScore: number | null;
133
+ performanceInputs: Record<string, unknown> | null;
134
+ createdAt: string;
135
+ /** Up to 20 recent screenings, newest first. */
136
+ screeningHistory: Array<{
137
+ id: string;
138
+ riskLevel: string;
139
+ source: string;
140
+ notes: string | null;
141
+ rawScore: number | null;
142
+ matchedEntityId: string | null;
143
+ screeningMode: "live" | "simulate";
144
+ status: "complete" | "failed";
145
+ createdAt: string;
146
+ }>;
147
+ }
148
+ /** A contractor milestone, how it was verified, and whether it was paid. */
149
+ export interface Milestone {
150
+ id: string;
151
+ title: string;
152
+ amount: number;
153
+ status: "pending" | "verified" | "paid" | "held" | "closed";
154
+ verificationSource: string | null;
155
+ verificationMethod: "unverified" | "github" | "manual" | "seed";
156
+ verificationStatus: "unverified" | "verified" | "not_merged" | "unavailable" | "failed";
157
+ verificationCheckedAt: string | null;
158
+ verifiedAt: string | null;
159
+ verificationDetail: Record<string, unknown>;
160
+ verified: boolean;
161
+ decidedAt: string | null;
162
+ settledAt: string | null;
163
+ /** When a person closed the milestone without paying it (status closed), else null. */
164
+ closedAt: string | null;
165
+ /** The reason the person gave for closing it without paying, else null. */
166
+ closeReason: string | null;
167
+ agentReasoning: string | null;
168
+ /** An on-chain hash when the payment settled on Arc, else null. */
169
+ txHash: string | null;
170
+ contractor: {
171
+ id: string;
172
+ name: string;
173
+ riskLevel: string;
174
+ } | null;
175
+ createdAt: string;
176
+ }
177
+ /** The workspace's accounts, reserve, obligations, latest forecast and recent treasury moves. */
178
+ export interface Treasury {
179
+ accounts: Array<{
180
+ id: string;
181
+ name: string;
182
+ kind: "operating" | "reserve" | "chain";
183
+ chain: string;
184
+ token: string;
185
+ address: string | null;
186
+ balance: number;
187
+ apy: number;
188
+ }>;
189
+ /** The sum of the reserve accounts' balances. */
190
+ reservePosition: number;
191
+ /** From the latest cycle snapshot; null before any snapshot exists, never an invented zero. */
192
+ obligations: {
193
+ asOf: string | null;
194
+ dueWithin7Days: number | null;
195
+ dueWithin14Days: number | null;
196
+ };
197
+ latestForecast: {
198
+ id: string;
199
+ asOf: string;
200
+ horizonDays: number;
201
+ projectedInflow: number;
202
+ projectedOutflow: number;
203
+ liquidBalance: number;
204
+ recommendation: string | null;
205
+ } | null;
206
+ /** Up to 20 treasury moves, newest first. */
207
+ recentActions: Array<{
208
+ id: string;
209
+ action: "sweep_to_usyc" | "redeem_from_usyc" | "rebalance";
210
+ amount: number;
211
+ fromAccountId: string | null;
212
+ toAccountId: string | null;
213
+ reasoning: string | null;
214
+ createdAt: string;
215
+ }>;
216
+ }
217
+ /** The telemetry behind the Insights charts: the most recent transfers, cycle runs, snapshots, treasury moves and screenings, each oldest first. */
218
+ export interface Insights {
219
+ transfers: Array<{
220
+ id: string;
221
+ targetType: "invoice" | "milestone";
222
+ targetId: string;
223
+ txRef: string;
224
+ feeUsd: number;
225
+ feeSource: "chain_reported" | "provider_estimate" | "simulated_profile";
226
+ settledInMs: number | null;
227
+ chain: string;
228
+ providerMode: "live" | "simulate";
229
+ executedAt: string;
230
+ status: string;
231
+ }>;
232
+ runs: Array<{
233
+ id: string;
234
+ startedAt: string;
235
+ finishedAt: string;
236
+ durationMs: number;
237
+ decisionCount: number;
238
+ paidCount: number;
239
+ heldCount: number;
240
+ flaggedCount: number;
241
+ awaitingInfoCount: number;
242
+ releasedCount: number;
243
+ modelDecisionCount: number;
244
+ heuristicDecisionCount: number;
245
+ guardrailOverrideCount: number;
246
+ /** Model verdicts that chose a different action from the rule-based policy. Null for cycles run before the comparison existed, never zero, which would claim perfect agreement over decisions never compared. */
247
+ referenceDisagreementCount: number | null;
248
+ /** A cycle that failed partway is not a quiet cycle, and must not read as one. Its counts are real but partial: they cover the stages that ran before it stopped, and nothing after. */
249
+ status: "running" | "completed" | "partial" | "failed";
250
+ failedStage: string | null;
251
+ errorMessage: string | null;
252
+ chainMode: "live" | "simulate";
253
+ screeningMode: "live" | "simulate";
254
+ }>;
255
+ snapshots: Array<{
256
+ id: string;
257
+ cycleRunId: string;
258
+ capturedAt: string;
259
+ totalLiquid: number;
260
+ openPayables: number;
261
+ openReceivables: number;
262
+ obligationsDue7d: number;
263
+ obligationsDue14d: number;
264
+ reservePosition: number;
265
+ chainMode: "live" | "simulate";
266
+ }>;
267
+ treasuryMoves: Array<{
268
+ id: string;
269
+ action: "sweep_to_usyc" | "redeem_from_usyc" | "rebalance";
270
+ amount: number;
271
+ createdAt: string;
272
+ }>;
273
+ screenings: Array<{
274
+ id: string;
275
+ counterpartyId: string;
276
+ counterpartyName: string;
277
+ riskLevel: string;
278
+ previousRiskLevel: string | null;
279
+ tierChanged: boolean;
280
+ mode: "live" | "simulate";
281
+ source: string;
282
+ status: "complete" | "failed";
283
+ createdAt: string;
284
+ }>;
285
+ }
286
+ /** Where this page sits in the collection. */
287
+ export interface Page {
288
+ /** Pass back as `?cursor=` to continue. Null when the end is reached. */
289
+ nextCursor: string | null;
290
+ /** Whether another page follows this one. */
291
+ hasMore: boolean;
292
+ /** How many items this response carries. */
293
+ count: number;
294
+ }
295
+ /** An invoice to add. The agent decides a payable as one typed in, with every guardrail, usually within a minute. */
296
+ export interface CreateInvoiceInput {
297
+ /** `payable`, a bill the business pays, which is the default, or `receivable`, one it is owed. */
298
+ direction?: "payable" | "receivable";
299
+ /** The counterparty's `id`, from `GET /api/v1/counterparties` or from the answer that added it. */
300
+ counterpartyId: string;
301
+ /** What it bills, in `currency`, with at most 6 decimal places. A decimal string such as `"1250.50"` keeps it exact; a number is read the same way. */
302
+ amount: string | number;
303
+ /** `USDC`, the default, or `EURC`. */
304
+ currency?: "USDC" | "EURC";
305
+ /** The day it is due, as `YYYY-MM-DD`. */
306
+ dueDate: string;
307
+ /** What it is for, up to 280 characters. */
308
+ memo?: string;
309
+ /** The purchase order it bills against, up to 100 characters. */
310
+ poReference?: string;
311
+ /** Whether what it bills for has arrived. Defaults to `false`. Without it, or without `poReference`, the agent asks for the missing detail instead of paying. */
312
+ goodsReceived?: boolean;
313
+ /** A discount for paying by `deadline`. The agent weighs it against what the cash would earn in the reserve until `dueDate`. */
314
+ earlyPayDiscount?: {
315
+ /** The percent off, greater than 0 and less than 100, with at most 2 decimal places. */
316
+ percent: string | number;
317
+ /** The last day it applies, as `YYYY-MM-DD`, on or before `dueDate`. */
318
+ deadline: string;
319
+ };
320
+ }
321
+ /** A counterparty to add. It is screened as one added in the console. */
322
+ export interface CreateCounterpartyInput {
323
+ /** 2 to 160 characters. */
324
+ name: string;
325
+ /** `vendor` or `contractor`, whom the business pays, or `client`, who pays the business. */
326
+ role: "vendor" | "client" | "contractor";
327
+ /** Where the agent pays it. An address added through the API waits for a person in the workspace to confirm it on Counterparties; until then the agent pays nothing to it. */
328
+ address?: string;
329
+ /** The chain the address receives on. Defaults to `ARC-TESTNET`; only a vendor can be paid on another chain. */
330
+ chain?: "ARC-TESTNET" | "BASE-SEPOLIA" | "ARB-SEPOLIA" | "ETH-SEPOLIA";
331
+ /** Where it is based, up to 80 characters; screening uses it. */
332
+ jurisdiction?: string;
333
+ /** The most the agent pays it in one payment, in USDC, with up to 6 decimal places. Required for a vendor or a contractor. */
334
+ paymentLimit?: string | number;
335
+ /** Where it is emailed once a payment to it is confirmed. */
336
+ noticeEmail?: string;
337
+ }
338
+ /** The query parameters of `list-ledger-entries`. */
339
+ export interface ListLedgerEntriesParams {
340
+ /** How many items to return. Defaults to 50; a larger value is capped at 200. */
341
+ limit?: number;
342
+ /** The previous response's `page.nextCursor`, passed back unchanged to continue. Opaque: never decode or construct one. A cursor this endpoint could not have issued is refused with `400`. */
343
+ cursor?: string;
344
+ /** Only entries in this domain. */
345
+ domain?: string;
346
+ /** Only entries written by this actor. */
347
+ actor?: string;
348
+ }
349
+ /** The query parameters of `list-invoices`. */
350
+ export interface ListInvoicesParams {
351
+ /** How many items to return. Defaults to 50; a larger value is capped at 200. */
352
+ limit?: number;
353
+ /** The previous response's `page.nextCursor`, passed back unchanged to continue. Opaque: never decode or construct one. A cursor this endpoint could not have issued is refused with `400`. */
354
+ cursor?: string;
355
+ /** Only payables, or only receivables. */
356
+ direction?: "payable" | "receivable";
357
+ /** Only invoices in this status. */
358
+ status?: "pending" | "matched" | "scheduled" | "paid" | "held" | "flagged" | "awaiting_info" | "received" | "rejected";
359
+ /** Only invoices from or to this counterparty. */
360
+ counterpartyId?: string;
361
+ }
362
+ /** The query parameters of `list-counterparties`. */
363
+ export interface ListCounterpartiesParams {
364
+ /** How many items to return. Defaults to 50; a larger value is capped at 200. */
365
+ limit?: number;
366
+ /** The previous response's `page.nextCursor`, passed back unchanged to continue. Opaque: never decode or construct one. A cursor this endpoint could not have issued is refused with `400`. */
367
+ cursor?: string;
368
+ /** Only counterparties with this role. */
369
+ role?: "vendor" | "client" | "contractor";
370
+ /** Only counterparties at this risk tier. */
371
+ riskLevel?: "unscreened" | "clear" | "medium" | "high";
372
+ }
373
+ /** The query parameters of `list-milestones`. */
374
+ export interface ListMilestonesParams {
375
+ /** How many items to return. Defaults to 50; a larger value is capped at 200. */
376
+ limit?: number;
377
+ /** The previous response's `page.nextCursor`, passed back unchanged to continue. Opaque: never decode or construct one. A cursor this endpoint could not have issued is refused with `400`. */
378
+ cursor?: string;
379
+ /** Only milestones in this status. */
380
+ status?: "pending" | "verified" | "paid" | "held" | "closed";
381
+ /** Only milestones for this contractor. */
382
+ contractorId?: string;
383
+ }
package/dist/types.js ADDED
@@ -0,0 +1,5 @@
1
+ // Generated from the API's OpenAPI document (/api/v1/openapi.json) by `npm run sdk:types`, with
2
+ // scripts/lib/sdk-types.ts. Do not edit it by hand: tests/sdk-types.test.ts fails when it differs from what the
3
+ // generator renders now.
4
+ /** One of a closed set, so a client can branch on it without parsing the message. */
5
+ export const API_ERROR_CODES = ["unauthorized", "forbidden", "not_found", "invalid_request", "conflict", "rate_limited", "unavailable", "internal"];
@@ -0,0 +1,2 @@
1
+ /** This package's version, sent in every request's `User-Agent`. tests/sdk-package.test.ts holds it to package.json. */
2
+ export declare const VERSION = "0.1.0";
@@ -0,0 +1,2 @@
1
+ /** This package's version, sent in every request's `User-Agent`. tests/sdk-package.test.ts holds it to package.json. */
2
+ export const VERSION = "0.1.0";
@@ -0,0 +1,61 @@
1
+ import type { LedgerEntry } from "./types.js";
2
+ /**
3
+ * Checking what Vestiarion sends (docs/superpowers/specs/2026-10-03-typescript-sdk-design.md R7), with Web Crypto
4
+ * alone:
5
+ * - a webhook delivery's `Vestiarion-Signature`, as src/lib/webhooks/sign.ts signs it;
6
+ * - a ledger entry's own Ed25519 signature and chain hash, as the receipt page checks them
7
+ * (src/lib/receipts/verify.ts).
8
+ */
9
+ /** How far a delivery's timestamp may be from now, in seconds, as the server's own check allows. */
10
+ export declare const WEBHOOK_TOLERANCE_SECONDS = 300;
11
+ /** A ledger entry as a delivery carries it: `GET /api/v1/ledger`'s fields, without the row `id`. */
12
+ export type WebhookLedgerEntry = Omit<LedgerEntry, "id">;
13
+ export interface WebhookEvent {
14
+ /** The delivery's id, the same on every retry of it: de-duplicate on it. */
15
+ id: string;
16
+ type: "ledger.appended" | "webhook.test";
17
+ createdAt: string;
18
+ workspace: {
19
+ slug: string;
20
+ };
21
+ /** The entry appended. `ledger.appended` only. */
22
+ entry?: WebhookLedgerEntry;
23
+ }
24
+ export type WebhookVerificationReason = "missing" | "malformed" | "expired" | "mismatch";
25
+ export declare class WebhookVerificationError extends Error {
26
+ readonly reason: WebhookVerificationReason;
27
+ readonly name = "WebhookVerificationError";
28
+ constructor(reason: WebhookVerificationReason, message: string);
29
+ }
30
+ /** `ok: null` is not a failure: no key known here could check the entry. */
31
+ export type EntryCheck = {
32
+ ok: true;
33
+ } | {
34
+ ok: false;
35
+ reason: string;
36
+ } | {
37
+ ok: null;
38
+ reason: string;
39
+ };
40
+ /**
41
+ * The event a delivery carries, once its `Vestiarion-Signature` proves Vestiarion sent this exact body, within
42
+ * `toleranceSeconds` of `now`. Pass the raw request body as it arrived: a re-serialized copy differs byte for byte.
43
+ * Throws a `WebhookVerificationError` otherwise; nothing is parsed before the signature checks out.
44
+ */
45
+ export declare function verifyWebhook(input: {
46
+ secret: string;
47
+ payload: string;
48
+ signature: string | null | undefined;
49
+ toleranceSeconds?: number;
50
+ now?: Date;
51
+ }): Promise<WebhookEvent>;
52
+ /**
53
+ * Whether a ledger entry is authentic, as the receipt page checks one:
54
+ * - its content matches `bodyHash`, SHA-256 of the canonical JSON of `{ actor, domain, action, summary, detail }`;
55
+ * - `signature` is the workspace key's Ed25519 signature over the 32 bytes of `bodyHash`;
56
+ * - `hash` is SHA-256 of `prevHash + bodyHash + signature`.
57
+ *
58
+ * `keys` is the PEM from the workspace's Audit page, or a map of key id to PEM. A key counts only under its true id, the
59
+ * first 16 hex characters of SHA-256 of its SPKI encoding.
60
+ */
61
+ export declare function verifyLedgerEntry(entry: WebhookLedgerEntry, keys: string | Record<string, string>): Promise<EntryCheck>;
@@ -0,0 +1,154 @@
1
+ import { canonicalJson } from "./canonical-json.js";
2
+ /**
3
+ * Checking what Vestiarion sends (docs/superpowers/specs/2026-10-03-typescript-sdk-design.md R7), with Web Crypto
4
+ * alone:
5
+ * - a webhook delivery's `Vestiarion-Signature`, as src/lib/webhooks/sign.ts signs it;
6
+ * - a ledger entry's own Ed25519 signature and chain hash, as the receipt page checks them
7
+ * (src/lib/receipts/verify.ts).
8
+ */
9
+ /** How far a delivery's timestamp may be from now, in seconds, as the server's own check allows. */
10
+ export const WEBHOOK_TOLERANCE_SECONDS = 300;
11
+ export class WebhookVerificationError extends Error {
12
+ reason;
13
+ name = "WebhookVerificationError";
14
+ constructor(reason, message) {
15
+ super(message);
16
+ this.reason = reason;
17
+ }
18
+ }
19
+ const TIMESTAMP = /^\d{1,15}$/;
20
+ const HEX_SHA256 = /^[0-9a-f]{64}$/;
21
+ function subtle() {
22
+ return globalThis.crypto.subtle;
23
+ }
24
+ function utf8(text) {
25
+ return new TextEncoder().encode(text);
26
+ }
27
+ function hexToBytes(hex) {
28
+ const clean = hex.length % 2 === 0 && /^[0-9a-fA-F]*$/.test(hex) ? hex : "";
29
+ const bytes = new Uint8Array(clean.length / 2);
30
+ for (let i = 0; i < bytes.length; i += 1)
31
+ bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
32
+ return bytes;
33
+ }
34
+ async function sha256Hex(data) {
35
+ return Array.from(new Uint8Array(await subtle().digest("SHA-256", data)), (byte) => byte.toString(16).padStart(2, "0")).join("");
36
+ }
37
+ function pemToDer(pem) {
38
+ const binary = atob(pem.replace(/-----(BEGIN|END) PUBLIC KEY-----/g, "").replace(/\s+/g, ""));
39
+ const bytes = new Uint8Array(binary.length);
40
+ for (let i = 0; i < binary.length; i += 1)
41
+ bytes[i] = binary.charCodeAt(i);
42
+ return bytes;
43
+ }
44
+ /**
45
+ * The event a delivery carries, once its `Vestiarion-Signature` proves Vestiarion sent this exact body, within
46
+ * `toleranceSeconds` of `now`. Pass the raw request body as it arrived: a re-serialized copy differs byte for byte.
47
+ * Throws a `WebhookVerificationError` otherwise; nothing is parsed before the signature checks out.
48
+ */
49
+ export async function verifyWebhook(input) {
50
+ const tolerance = input.toleranceSeconds ?? WEBHOOK_TOLERANCE_SECONDS;
51
+ // NaN in either would make every timestamp "within" the window, and turn replay protection off.
52
+ if (!Number.isFinite(tolerance) || tolerance < 0)
53
+ throw new TypeError("toleranceSeconds must be a number of seconds, 0 or more.");
54
+ const nowMs = (input.now ?? new Date()).getTime();
55
+ if (!Number.isFinite(nowMs))
56
+ throw new TypeError("now must be a valid Date.");
57
+ if (typeof input.payload !== "string") {
58
+ throw new WebhookVerificationError("malformed", "payload must be the raw request body as a string, not a parsed object.");
59
+ }
60
+ if (!input.signature)
61
+ throw new WebhookVerificationError("missing", "The delivery has no Vestiarion-Signature header.");
62
+ let t = null;
63
+ const candidates = [];
64
+ for (const part of input.signature.split(",")) {
65
+ const eq = part.indexOf("=");
66
+ if (eq <= 0)
67
+ throw new WebhookVerificationError("malformed", "Vestiarion-Signature is not t=<seconds>,v1=<hex>.");
68
+ const name = part.slice(0, eq);
69
+ const value = part.slice(eq + 1);
70
+ if (name === "t") {
71
+ if (t !== null || !TIMESTAMP.test(value))
72
+ throw new WebhookVerificationError("malformed", "Vestiarion-Signature's timestamp is not valid.");
73
+ t = Number(value);
74
+ }
75
+ else if (name === "v1") {
76
+ if (!HEX_SHA256.test(value))
77
+ throw new WebhookVerificationError("malformed", "Vestiarion-Signature's v1 is not a hex HMAC-SHA256.");
78
+ candidates.push(value);
79
+ }
80
+ }
81
+ if (t === null || candidates.length === 0)
82
+ throw new WebhookVerificationError("malformed", "Vestiarion-Signature needs a t and a v1.");
83
+ const nowSeconds = Math.floor(nowMs / 1000);
84
+ if (Math.abs(nowSeconds - t) > tolerance)
85
+ throw new WebhookVerificationError("expired", `The delivery was signed more than ${tolerance} s from now.`);
86
+ const key = await subtle().importKey("raw", utf8(input.secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
87
+ const signed = utf8(`${t}.${input.payload}`);
88
+ let match = false;
89
+ for (const candidate of candidates) {
90
+ // Every candidate is checked, so the time taken does not tell which one matched.
91
+ if (await subtle().verify("HMAC", key, hexToBytes(candidate), signed))
92
+ match = true;
93
+ }
94
+ if (!match)
95
+ throw new WebhookVerificationError("mismatch", "The signature does not match this body and secret.");
96
+ let event;
97
+ try {
98
+ event = JSON.parse(input.payload);
99
+ }
100
+ catch {
101
+ throw new WebhookVerificationError("malformed", "The delivery's body is not JSON.");
102
+ }
103
+ if (typeof event !== "object" || event === null || typeof event.type !== "string") {
104
+ throw new WebhookVerificationError("malformed", "The delivery's body is not a Vestiarion event.");
105
+ }
106
+ return event;
107
+ }
108
+ /** The keys an entry may be checked against: each filed under its true id, and only the one the entry names, if it names one. */
109
+ async function candidateKeys(entry, keys) {
110
+ const pems = typeof keys === "string" ? [[null, keys]] : Object.entries(keys);
111
+ const filed = await Promise.all(pems.map(async ([id, pem]) => {
112
+ const der = pemToDer(pem);
113
+ const trueId = (await sha256Hex(der)).slice(0, 16);
114
+ return { der, trueId, genuine: id === null || id === trueId };
115
+ }));
116
+ const genuine = filed.filter((key) => key.genuine);
117
+ return (entry.signingKeyId ? genuine.filter((key) => key.trueId === entry.signingKeyId) : genuine).map((key) => key.der);
118
+ }
119
+ /**
120
+ * Whether a ledger entry is authentic, as the receipt page checks one:
121
+ * - its content matches `bodyHash`, SHA-256 of the canonical JSON of `{ actor, domain, action, summary, detail }`;
122
+ * - `signature` is the workspace key's Ed25519 signature over the 32 bytes of `bodyHash`;
123
+ * - `hash` is SHA-256 of `prevHash + bodyHash + signature`.
124
+ *
125
+ * `keys` is the PEM from the workspace's Audit page, or a map of key id to PEM. A key counts only under its true id, the
126
+ * first 16 hex characters of SHA-256 of its SPKI encoding.
127
+ */
128
+ export async function verifyLedgerEntry(entry, keys) {
129
+ const body = canonicalJson({ actor: entry.actor, domain: entry.domain, action: entry.action, summary: entry.summary, detail: entry.detail });
130
+ if ((await sha256Hex(utf8(body))) !== entry.bodyHash)
131
+ return { ok: false, reason: "The entry's content does not match its body hash." };
132
+ const candidates = await candidateKeys(entry, keys);
133
+ if (candidates.length === 0)
134
+ return { ok: null, reason: "The key that signed this entry is not known here." };
135
+ let signed = false;
136
+ try {
137
+ for (const der of candidates) {
138
+ const key = await subtle().importKey("spki", der, { name: "Ed25519" }, false, ["verify"]);
139
+ if (await subtle().verify({ name: "Ed25519" }, key, hexToBytes(entry.signature), hexToBytes(entry.bodyHash))) {
140
+ signed = true;
141
+ break;
142
+ }
143
+ }
144
+ }
145
+ catch {
146
+ return { ok: null, reason: "The Ed25519 signature could not be checked here." };
147
+ }
148
+ if (!signed)
149
+ return { ok: false, reason: "The signature does not verify with the workspace's key." };
150
+ if ((await sha256Hex(utf8(entry.prevHash + entry.bodyHash + entry.signature))) !== entry.hash) {
151
+ return { ok: false, reason: "The entry's chain hash does not match." };
152
+ }
153
+ return { ok: true };
154
+ }
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@vestiarion/sdk",
3
+ "version": "0.1.0",
4
+ "description": "A typed client for the Vestiarion API and its signed webhooks.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "engines": { "node": ">=20" },
9
+ "exports": {
10
+ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
11
+ "./webhooks": { "types": "./dist/webhooks.d.ts", "import": "./dist/webhooks.js" }
12
+ },
13
+ "types": "./dist/index.d.ts",
14
+ "files": ["dist", "README.md", "LICENSE"],
15
+ "homepage": "https://www.vestiarion.xyz/docs/get-started/sdk",
16
+ "repository": { "type": "git", "url": "git+https://github.com/duongnq2798/vestiarion.git", "directory": "sdk" },
17
+ "keywords": ["vestiarion", "stablecoin", "treasury", "arc", "usdc", "webhooks"]
18
+ }