@arc402/sdk 0.6.3 → 0.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +35 -3
- package/dist/arena.d.ts +112 -0
- package/dist/arena.d.ts.map +1 -0
- package/dist/arena.js +223 -0
- package/dist/daemon.d.ts +118 -0
- package/dist/daemon.d.ts.map +1 -0
- package/dist/daemon.js +174 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -1
- package/package.json +1 -1
- package/src/arena.ts +347 -0
- package/src/daemon.ts +280 -0
- package/src/index.ts +4 -0
- package/test/daemon-client.test.js +82 -0
- package/test/daemon.test.js +103 -0
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_DAEMON_HTTP_URL = "http://127.0.0.1:4402";
|
|
6
|
+
export const DEFAULT_DAEMON_API_URL = "http://127.0.0.1:4403";
|
|
7
|
+
export const DEFAULT_DAEMON_TOKEN_PATH = path.join(os.homedir(), ".arc402", "daemon.token");
|
|
8
|
+
export const DEFAULT_DAEMON_CONFIG_PATH = path.join(os.homedir(), ".arc402", "daemon.toml");
|
|
9
|
+
|
|
10
|
+
export interface DaemonHealthStatus {
|
|
11
|
+
ok: boolean;
|
|
12
|
+
wallet: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DaemonWalletStatus {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
wallet: string;
|
|
18
|
+
daemonId: string;
|
|
19
|
+
chainId: number;
|
|
20
|
+
rpcUrl: string;
|
|
21
|
+
policyEngineAddress: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DaemonWorkroomStatus {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
status: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DaemonAgreementRecord {
|
|
30
|
+
id: string;
|
|
31
|
+
agreement_id: string | null;
|
|
32
|
+
hirer_address: string;
|
|
33
|
+
capability: string;
|
|
34
|
+
price_eth: string;
|
|
35
|
+
deadline_unix: number;
|
|
36
|
+
spec_hash: string;
|
|
37
|
+
task_description: string | null;
|
|
38
|
+
status: string;
|
|
39
|
+
created_at: number;
|
|
40
|
+
updated_at: number;
|
|
41
|
+
reject_reason: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DaemonAgreementsResponse {
|
|
45
|
+
ok: boolean;
|
|
46
|
+
agreements: DaemonAgreementRecord[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AuthChallengeResponse {
|
|
50
|
+
challengeId: string;
|
|
51
|
+
challenge: string;
|
|
52
|
+
daemonId: string;
|
|
53
|
+
wallet: string;
|
|
54
|
+
chainId: number;
|
|
55
|
+
scope: string;
|
|
56
|
+
expiresAt: number;
|
|
57
|
+
issuedAt: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface AuthSessionResponse {
|
|
61
|
+
ok: true;
|
|
62
|
+
token: string;
|
|
63
|
+
wallets: string[];
|
|
64
|
+
wallet: string;
|
|
65
|
+
scope: string;
|
|
66
|
+
expiresAt: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RevokeSessionsResponse {
|
|
70
|
+
ok: boolean;
|
|
71
|
+
revoked: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface DaemonNodeClientOptions {
|
|
75
|
+
/** Split daemon API base URL. Defaults to http://127.0.0.1:4403. */
|
|
76
|
+
apiUrl?: string;
|
|
77
|
+
/** Alias for apiUrl to match older docs/examples. */
|
|
78
|
+
baseUrl?: string;
|
|
79
|
+
/** Local daemon token. Defaults to ~/.arc402/daemon.token when present. */
|
|
80
|
+
token?: string;
|
|
81
|
+
/** Explicit path to daemon.token if you don't want the default. */
|
|
82
|
+
tokenPath?: string;
|
|
83
|
+
/** Explicit path to daemon.toml if you want port inference from a non-default location. */
|
|
84
|
+
configPath?: string;
|
|
85
|
+
/** Custom fetch implementation for testing or non-standard runtimes. */
|
|
86
|
+
fetchImpl?: typeof fetch;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class DaemonClientError extends Error {
|
|
90
|
+
constructor(
|
|
91
|
+
message: string,
|
|
92
|
+
public readonly statusCode?: number,
|
|
93
|
+
public readonly details?: unknown,
|
|
94
|
+
) {
|
|
95
|
+
super(message);
|
|
96
|
+
this.name = "DaemonClientError";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function trimTrailingSlash(input: string): string {
|
|
101
|
+
return input.replace(/\/$/, "");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readTomlInteger(contents: string, key: string): number | undefined {
|
|
105
|
+
const match = contents.match(new RegExp(`^\\s*${key}\\s*=\\s*(\\d+)\\s*$`, "m"));
|
|
106
|
+
if (!match) return undefined;
|
|
107
|
+
const parsed = Number.parseInt(match[1], 10);
|
|
108
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function loadLocalDaemonToken(tokenPath = DEFAULT_DAEMON_TOKEN_PATH): string | undefined {
|
|
112
|
+
try {
|
|
113
|
+
const token = readFileSync(tokenPath, "utf-8").trim();
|
|
114
|
+
return token.length > 0 ? token : undefined;
|
|
115
|
+
} catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function resolveDaemonHttpBaseUrl(configPath = DEFAULT_DAEMON_CONFIG_PATH): string {
|
|
121
|
+
try {
|
|
122
|
+
if (existsSync(configPath)) {
|
|
123
|
+
const config = readFileSync(configPath, "utf-8");
|
|
124
|
+
const port = readTomlInteger(config, "listen_port") ?? 4402;
|
|
125
|
+
return `http://127.0.0.1:${port}`;
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// Fall through to default.
|
|
129
|
+
}
|
|
130
|
+
return DEFAULT_DAEMON_HTTP_URL;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function resolveDaemonApiBaseUrl(options: Pick<DaemonNodeClientOptions, "apiUrl" | "baseUrl" | "configPath"> = {}): string {
|
|
134
|
+
const explicit = options.apiUrl ?? options.baseUrl;
|
|
135
|
+
if (explicit && explicit.trim().length > 0) {
|
|
136
|
+
return trimTrailingSlash(explicit.trim());
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const httpBase = resolveDaemonHttpBaseUrl(options.configPath ?? DEFAULT_DAEMON_CONFIG_PATH);
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const url = new URL(httpBase);
|
|
143
|
+
const httpPort = url.port ? Number.parseInt(url.port, 10) : (url.protocol === "https:" ? 443 : 80);
|
|
144
|
+
if (Number.isFinite(httpPort)) {
|
|
145
|
+
url.port = String(httpPort + 1);
|
|
146
|
+
return trimTrailingSlash(url.toString());
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
// Fall through to default.
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return DEFAULT_DAEMON_API_URL;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function safeJsonParse(value: string): unknown {
|
|
156
|
+
try {
|
|
157
|
+
return JSON.parse(value);
|
|
158
|
+
} catch {
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Thin typed client for the split ARC-402 node API (`arc402-api`).
|
|
165
|
+
*
|
|
166
|
+
* This surface is for operator-side reads and auth against the host daemon/node process:
|
|
167
|
+
* - `/health`
|
|
168
|
+
* - `/auth/challenge`
|
|
169
|
+
* - `/auth/session`
|
|
170
|
+
* - `/auth/revoke`
|
|
171
|
+
* - `/wallet/status`
|
|
172
|
+
* - `/workroom/status`
|
|
173
|
+
* - `/agreements`
|
|
174
|
+
*
|
|
175
|
+
* The delivery plane still lives on the host daemon HTTP port (usually `:4402`)
|
|
176
|
+
* and is wrapped separately by `DeliveryClient`.
|
|
177
|
+
*/
|
|
178
|
+
export class DaemonNodeClient {
|
|
179
|
+
readonly apiUrl: string;
|
|
180
|
+
private readonly options: DaemonNodeClientOptions;
|
|
181
|
+
private readonly fetchImpl: typeof fetch;
|
|
182
|
+
|
|
183
|
+
constructor(options: DaemonNodeClientOptions = {}) {
|
|
184
|
+
this.options = options;
|
|
185
|
+
this.apiUrl = resolveDaemonApiBaseUrl(options);
|
|
186
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private async request<T>(
|
|
190
|
+
method: "GET" | "POST",
|
|
191
|
+
urlPath: string,
|
|
192
|
+
body?: unknown,
|
|
193
|
+
useSession = false,
|
|
194
|
+
): Promise<T> {
|
|
195
|
+
const token = this.options.token ?? loadLocalDaemonToken(this.options.tokenPath ?? DEFAULT_DAEMON_TOKEN_PATH);
|
|
196
|
+
const headers: Record<string, string> = {};
|
|
197
|
+
|
|
198
|
+
if (body !== undefined) {
|
|
199
|
+
headers["Content-Type"] = "application/json";
|
|
200
|
+
}
|
|
201
|
+
if (useSession) {
|
|
202
|
+
if (!token) {
|
|
203
|
+
throw new DaemonClientError("No daemon session token available");
|
|
204
|
+
}
|
|
205
|
+
headers.Authorization = `Bearer ${token}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const response = await this.fetchImpl(`${this.apiUrl}${urlPath}`, {
|
|
209
|
+
method,
|
|
210
|
+
headers,
|
|
211
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const text = await response.text();
|
|
215
|
+
const payload = text.length > 0 ? safeJsonParse(text) : undefined;
|
|
216
|
+
|
|
217
|
+
if (!response.ok) {
|
|
218
|
+
const message =
|
|
219
|
+
typeof payload === "object" &&
|
|
220
|
+
payload !== null &&
|
|
221
|
+
"error" in payload &&
|
|
222
|
+
typeof (payload as { error?: unknown }).error === "string"
|
|
223
|
+
? (payload as { error: string }).error
|
|
224
|
+
: text || `Daemon request failed (${response.status})`;
|
|
225
|
+
throw new DaemonClientError(message, response.status, payload ?? text);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return (payload ?? {}) as T;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async getHealth(): Promise<DaemonHealthStatus> {
|
|
232
|
+
return this.request<DaemonHealthStatus>("GET", "/health");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async requestAuthChallenge(wallet: string, requestedScope = "operator"): Promise<AuthChallengeResponse> {
|
|
236
|
+
return this.request<AuthChallengeResponse>("POST", "/auth/challenge", { wallet, requestedScope });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async health(): Promise<DaemonHealthStatus> {
|
|
240
|
+
return this.getHealth();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async createSession(challengeId: string, signature: string): Promise<AuthSessionResponse> {
|
|
244
|
+
return this.request<AuthSessionResponse>("POST", "/auth/session", { challengeId, signature });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async revokeSessions(): Promise<RevokeSessionsResponse> {
|
|
248
|
+
return this.request<RevokeSessionsResponse>("POST", "/auth/revoke", undefined, true);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async revokeSession(): Promise<RevokeSessionsResponse> {
|
|
252
|
+
return this.revokeSessions();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async getWalletStatus(): Promise<DaemonWalletStatus> {
|
|
256
|
+
return this.request<DaemonWalletStatus>("GET", "/wallet/status", undefined, true);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async walletStatus(): Promise<DaemonWalletStatus> {
|
|
260
|
+
return this.getWalletStatus();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async getWorkroomStatus(): Promise<DaemonWorkroomStatus> {
|
|
264
|
+
return this.request<DaemonWorkroomStatus>("GET", "/workroom/status", undefined, true);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async workroomStatus(): Promise<DaemonWorkroomStatus> {
|
|
268
|
+
return this.getWorkroomStatus();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async listAgreements(): Promise<DaemonAgreementsResponse> {
|
|
272
|
+
return this.request<DaemonAgreementsResponse>("GET", "/agreements", undefined, true);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async agreements(): Promise<DaemonAgreementsResponse> {
|
|
276
|
+
return this.listAgreements();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export { DaemonNodeClient as DaemonClient };
|
package/src/index.ts
CHANGED
|
@@ -31,10 +31,14 @@ export { ColdStartClient } from "./coldstart";
|
|
|
31
31
|
export { ComputeAgreementClient } from "./compute";
|
|
32
32
|
export type { ComputeSession, ComputeUsageReport } from "./compute";
|
|
33
33
|
export { MigrationClient } from "./migration";
|
|
34
|
+
export { ArenaClient, ARENA_ADDRESSES } from "./arena";
|
|
35
|
+
export type { ArenaRound, ArenaSquad, ArenaBriefing, ArenaArtifact, ArenaStatus, ArenaNewsletter, ArenaArtifactParams } from "./arena";
|
|
34
36
|
export { resolveEndpoint, notifyEndpoint, notifyHire, notifyHandshake, notifyHireAccepted, notifyDelivery, notifyDeliveryAccepted, notifyDispute, notifyMessage, DEFAULT_REGISTRY_ADDRESS } from "./endpoint";
|
|
35
37
|
export type { EndpointNotifyResult } from "./endpoint";
|
|
36
38
|
export { DeliveryClient, DEFAULT_DAEMON_URL } from "./delivery";
|
|
37
39
|
export type { DeliveryFile, DeliveryManifest, DeliveryClientOptions } from "./delivery";
|
|
40
|
+
export { DaemonClient, DaemonNodeClient, DaemonClientError, DEFAULT_DAEMON_HTTP_URL, DEFAULT_DAEMON_API_URL, DEFAULT_DAEMON_TOKEN_PATH, DEFAULT_DAEMON_CONFIG_PATH, resolveDaemonHttpBaseUrl, resolveDaemonApiBaseUrl, loadLocalDaemonToken } from "./daemon";
|
|
41
|
+
export type { DaemonNodeClientOptions, DaemonHealthStatus, DaemonWalletStatus, DaemonWorkroomStatus, DaemonAgreementRecord, DaemonAgreementsResponse, AuthChallengeResponse, AuthSessionResponse, RevokeSessionsResponse } from "./daemon";
|
|
38
42
|
|
|
39
43
|
// Base Mainnet contract addresses
|
|
40
44
|
export const COMPUTE_AGREEMENT_ADDRESS = "0xf898A8A2cF9900A588B174d9f96349BBA95e57F3";
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
DaemonClient,
|
|
9
|
+
DaemonNodeClient,
|
|
10
|
+
loadLocalDaemonToken,
|
|
11
|
+
resolveDaemonHttpBaseUrl,
|
|
12
|
+
resolveDaemonApiBaseUrl,
|
|
13
|
+
DEFAULT_DAEMON_HTTP_URL,
|
|
14
|
+
DEFAULT_DAEMON_API_URL,
|
|
15
|
+
} = require('../dist');
|
|
16
|
+
|
|
17
|
+
test('daemon URL helpers infer split ports from daemon.toml', () => {
|
|
18
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'arc402-sdk-daemon-'));
|
|
19
|
+
const configPath = path.join(tmp, 'daemon.toml');
|
|
20
|
+
fs.writeFileSync(configPath, '[relay]\nlisten_port = 5510\n');
|
|
21
|
+
|
|
22
|
+
assert.equal(resolveDaemonHttpBaseUrl(configPath), 'http://127.0.0.1:5510');
|
|
23
|
+
assert.equal(resolveDaemonApiBaseUrl({ configPath }), 'http://127.0.0.1:5511');
|
|
24
|
+
assert.equal(resolveDaemonApiBaseUrl({ baseUrl: 'https://node.example.com/' }), 'https://node.example.com');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('daemon token loader trims file contents and tolerates missing file', () => {
|
|
28
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'arc402-sdk-token-'));
|
|
29
|
+
const tokenPath = path.join(tmp, 'daemon.token');
|
|
30
|
+
fs.writeFileSync(tokenPath, 'secret-token\n');
|
|
31
|
+
|
|
32
|
+
assert.equal(loadLocalDaemonToken(tokenPath), 'secret-token');
|
|
33
|
+
assert.equal(loadLocalDaemonToken(path.join(tmp, 'missing.token')), undefined);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('daemon client reads split API endpoints and keeps compatibility aliases', async () => {
|
|
37
|
+
const calls = [];
|
|
38
|
+
const originalFetch = global.fetch;
|
|
39
|
+
global.fetch = async (url, init = {}) => {
|
|
40
|
+
calls.push({ url, init });
|
|
41
|
+
const pathname = new URL(url).pathname;
|
|
42
|
+
const payloads = {
|
|
43
|
+
'/health': { ok: true, wallet: '0xabc' },
|
|
44
|
+
'/wallet/status': { ok: true, wallet: '0xabc', daemonId: '0xabc', chainId: 8453, rpcUrl: 'http://rpc', policyEngineAddress: '0xdef' },
|
|
45
|
+
'/workroom/status': { ok: true, status: 'running' },
|
|
46
|
+
'/agreements': { ok: true, agreements: [{ agreement_id: '1' }] },
|
|
47
|
+
'/auth/challenge': { challengeId: 'cid', challenge: 'sign me', daemonId: '0xabc', wallet: '0xabc', chainId: 8453, scope: 'operator', expiresAt: 1, issuedAt: 1 },
|
|
48
|
+
'/auth/session': { ok: true, token: 'tok', wallets: ['0xabc'], wallet: '0xabc', scope: 'operator', expiresAt: 2 },
|
|
49
|
+
'/auth/revoke': { ok: true },
|
|
50
|
+
};
|
|
51
|
+
return new Response(JSON.stringify(payloads[pathname]), {
|
|
52
|
+
status: 200,
|
|
53
|
+
headers: { 'content-type': 'application/json' },
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const client = new DaemonClient({ baseUrl: 'http://127.0.0.1:6603', token: 'daemon-token' });
|
|
59
|
+
const aliasClient = new DaemonNodeClient({ apiUrl: 'http://127.0.0.1:6603', token: 'daemon-token' });
|
|
60
|
+
|
|
61
|
+
assert.equal(client.apiUrl, 'http://127.0.0.1:6603');
|
|
62
|
+
assert.equal(aliasClient.apiUrl, 'http://127.0.0.1:6603');
|
|
63
|
+
assert.equal(DEFAULT_DAEMON_HTTP_URL, 'http://127.0.0.1:4402');
|
|
64
|
+
assert.equal(DEFAULT_DAEMON_API_URL, 'http://127.0.0.1:4403');
|
|
65
|
+
|
|
66
|
+
assert.deepEqual(await client.health(), { ok: true, wallet: '0xabc' });
|
|
67
|
+
assert.equal((await client.walletStatus()).chainId, 8453);
|
|
68
|
+
assert.equal((await client.workroomStatus()).status, 'running');
|
|
69
|
+
assert.equal((await client.agreements()).agreements.length, 1);
|
|
70
|
+
assert.equal((await client.requestAuthChallenge('0xabc')).challengeId, 'cid');
|
|
71
|
+
assert.equal((await client.createSession('cid', '0xsig')).ok, true);
|
|
72
|
+
assert.equal((await client.revokeSession()).ok, true);
|
|
73
|
+
assert.deepEqual(await aliasClient.getHealth(), { ok: true, wallet: '0xabc' });
|
|
74
|
+
|
|
75
|
+
const challengeCall = calls.find((entry) => new URL(entry.url).pathname === '/auth/challenge');
|
|
76
|
+
assert.equal(challengeCall.init.method, 'POST');
|
|
77
|
+
assert.equal(challengeCall.init.headers.Authorization, undefined);
|
|
78
|
+
assert.match(challengeCall.init.body, /"wallet":"0xabc"/);
|
|
79
|
+
} finally {
|
|
80
|
+
global.fetch = originalFetch;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
DaemonClient,
|
|
9
|
+
DaemonClientError,
|
|
10
|
+
resolveDaemonApiBaseUrl,
|
|
11
|
+
loadLocalDaemonToken,
|
|
12
|
+
} = require('../dist');
|
|
13
|
+
|
|
14
|
+
test('resolveDaemonApiBaseUrl prefers explicit value and trims trailing slashes', () => {
|
|
15
|
+
assert.equal(resolveDaemonApiBaseUrl(), 'http://127.0.0.1:4403');
|
|
16
|
+
assert.equal(resolveDaemonApiBaseUrl({ apiUrl: 'http://example.com:1234/' }), 'http://example.com:1234');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('loadLocalDaemonToken reads trimmed token values', async () => {
|
|
20
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'arc402-sdk-daemon-'));
|
|
21
|
+
const tokenPath = path.join(dir, 'daemon.token');
|
|
22
|
+
await fs.promises.writeFile(tokenPath, 'secret-token\n', 'utf8');
|
|
23
|
+
assert.equal(loadLocalDaemonToken(tokenPath), 'secret-token');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('DaemonClient sends bearer token to authenticated endpoints', async () => {
|
|
27
|
+
const calls = [];
|
|
28
|
+
const daemon = new DaemonClient({
|
|
29
|
+
apiUrl: 'http://127.0.0.1:4403',
|
|
30
|
+
token: 'token-123',
|
|
31
|
+
fetchImpl: async (url, init) => {
|
|
32
|
+
calls.push({ url, init });
|
|
33
|
+
return new Response(JSON.stringify({ ok: true, status: 'running' }), {
|
|
34
|
+
status: 200,
|
|
35
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const result = await daemon.getWorkroomStatus();
|
|
41
|
+
assert.deepEqual(result, { ok: true, status: 'running' });
|
|
42
|
+
assert.equal(calls[0].url, 'http://127.0.0.1:4403/workroom/status');
|
|
43
|
+
assert.equal(calls[0].init.headers.Authorization, 'Bearer token-123');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('DaemonClient exposes auth challenge and session endpoints without bearer auth', async () => {
|
|
47
|
+
const seen = [];
|
|
48
|
+
const daemon = new DaemonClient({
|
|
49
|
+
apiUrl: 'http://node.example.com',
|
|
50
|
+
fetchImpl: async (url, init) => {
|
|
51
|
+
seen.push({ url, init });
|
|
52
|
+
if (url.endsWith('/auth/challenge')) {
|
|
53
|
+
return new Response(JSON.stringify({
|
|
54
|
+
challengeId: 'abc',
|
|
55
|
+
challenge: 'ARC-402 Remote Auth\nChallenge: 0x123',
|
|
56
|
+
daemonId: '0x0000000000000000000000000000000000000001',
|
|
57
|
+
wallet: '0x0000000000000000000000000000000000000002',
|
|
58
|
+
chainId: 8453,
|
|
59
|
+
scope: 'operator',
|
|
60
|
+
expiresAt: 123,
|
|
61
|
+
issuedAt: 100,
|
|
62
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return new Response(JSON.stringify({
|
|
66
|
+
ok: true,
|
|
67
|
+
token: 'session-token',
|
|
68
|
+
wallets: ['0x0000000000000000000000000000000000000002'],
|
|
69
|
+
wallet: '0x0000000000000000000000000000000000000002',
|
|
70
|
+
scope: 'operator',
|
|
71
|
+
expiresAt: 999,
|
|
72
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const challenge = await daemon.requestAuthChallenge('0x0000000000000000000000000000000000000002');
|
|
77
|
+
const session = await daemon.createSession('abc', '0xsig');
|
|
78
|
+
|
|
79
|
+
assert.equal(challenge.challengeId, 'abc');
|
|
80
|
+
assert.equal(session.token, 'session-token');
|
|
81
|
+
assert.equal(seen[0].init.headers.Authorization, undefined);
|
|
82
|
+
assert.equal(seen[1].init.headers.Authorization, undefined);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('DaemonClient throws typed errors for HTTP failures', async () => {
|
|
86
|
+
const daemon = new DaemonClient({
|
|
87
|
+
token: 'token-123',
|
|
88
|
+
fetchImpl: async () => new Response(JSON.stringify({ error: 'invalid_session' }), {
|
|
89
|
+
status: 401,
|
|
90
|
+
headers: { 'Content-Type': 'application/json' },
|
|
91
|
+
}),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await assert.rejects(
|
|
95
|
+
daemon.getWalletStatus(),
|
|
96
|
+
(error) => {
|
|
97
|
+
assert.ok(error instanceof DaemonClientError);
|
|
98
|
+
assert.equal(error.message, 'invalid_session');
|
|
99
|
+
assert.equal(error.statusCode, 401);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
});
|