@jaw.id/cli 0.0.8 → 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/dist/base-command.js +26 -2
- package/dist/base-command.js.map +1 -1
- package/dist/commands/config/set.js +19 -7
- package/dist/commands/config/set.js.map +1 -1
- package/dist/commands/config/show.js +26 -2
- package/dist/commands/config/show.js.map +1 -1
- package/dist/commands/config/write.js +288 -0
- package/dist/commands/config/write.js.map +1 -0
- package/dist/commands/disconnect.js +29 -5
- package/dist/commands/disconnect.js.map +1 -1
- package/dist/commands/mcp/index.js +34 -17
- package/dist/commands/mcp/index.js.map +1 -1
- package/dist/commands/rpc/call.js +205 -37
- package/dist/commands/rpc/call.js.map +1 -1
- package/dist/commands/session/revoke.js +727 -0
- package/dist/commands/session/revoke.js.map +1 -0
- package/dist/commands/session/setup.js +960 -0
- package/dist/commands/session/setup.js.map +1 -0
- package/dist/commands/session/status.js +206 -0
- package/dist/commands/session/status.js.map +1 -0
- package/dist/commands/version.js +26 -2
- package/dist/commands/version.js.map +1 -1
- package/dist/index.js +28 -13
- package/dist/index.js.map +1 -1
- package/dist/lib/bridge-singleton.js +35 -13
- package/dist/lib/bridge-singleton.js.map +1 -1
- package/dist/lib/config.js +15 -7
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/keystore.js +64 -0
- package/dist/lib/keystore.js.map +1 -0
- package/dist/lib/output.js +1 -12
- package/dist/lib/output.js.map +1 -1
- package/dist/lib/paths.js +3 -1
- package/dist/lib/paths.js.map +1 -1
- package/dist/lib/session-bridge.js +168 -0
- package/dist/lib/session-bridge.js.map +1 -0
- package/dist/lib/session-config.js +52 -0
- package/dist/lib/session-config.js.map +1 -0
- package/dist/lib/validation.js +62 -27
- package/dist/lib/validation.js.map +1 -1
- package/dist/lib/ws-bridge.js +4 -3
- package/dist/lib/ws-bridge.js.map +1 -1
- package/dist/mcp/handlers/config.js +16 -4
- package/dist/mcp/handlers/config.js.map +1 -1
- package/dist/mcp/handlers/daemon.js +29 -5
- package/dist/mcp/handlers/daemon.js.map +1 -1
- package/dist/mcp/handlers/rpc.js +49 -25
- package/dist/mcp/handlers/rpc.js.map +1 -1
- package/dist/mcp/server.js +34 -17
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools.js +1 -1
- package/dist/mcp/tools.js.map +1 -1
- package/oclif.manifest.json +304 -4
- package/package.json +18 -1
- package/dist/lib/session-store.js +0 -80
- package/dist/lib/session-store.js.map +0 -1
|
@@ -0,0 +1,960 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import * as crypto from 'crypto';
|
|
6
|
+
import WebSocket from 'ws';
|
|
7
|
+
|
|
8
|
+
// src/commands/session/setup.ts
|
|
9
|
+
var JAW_DIR = path.join(os.homedir(), ".jaw");
|
|
10
|
+
var PATHS = {
|
|
11
|
+
root: JAW_DIR,
|
|
12
|
+
config: path.join(JAW_DIR, "config.json"),
|
|
13
|
+
session: path.join(JAW_DIR, "session.json"),
|
|
14
|
+
relay: path.join(JAW_DIR, "relay.json"),
|
|
15
|
+
keystore: path.join(JAW_DIR, "keystore.json"),
|
|
16
|
+
sessionConfig: path.join(JAW_DIR, "session-config.json")
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// src/lib/validation.ts
|
|
20
|
+
var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
21
|
+
var SELECTOR_RE = /^0x[0-9a-fA-F]{8}$/;
|
|
22
|
+
var HEX_RE = /^0x[0-9a-fA-F]+$/;
|
|
23
|
+
var VALID_SPEND_UNITS = /* @__PURE__ */ new Set(["minute", "hour", "day", "week", "month", "year", "forever"]);
|
|
24
|
+
function parsePermissionsConfig(raw) {
|
|
25
|
+
const errors = [];
|
|
26
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
27
|
+
throw new Error("Invalid permissions:\n - Must be an object");
|
|
28
|
+
}
|
|
29
|
+
const obj = raw;
|
|
30
|
+
const calls = obj.calls;
|
|
31
|
+
const spends = obj.spends;
|
|
32
|
+
if (!calls && !spends) {
|
|
33
|
+
throw new Error('Invalid permissions:\n - Must include at least "calls" or "spends"');
|
|
34
|
+
}
|
|
35
|
+
if (calls !== void 0) {
|
|
36
|
+
if (!Array.isArray(calls) || calls.length === 0) {
|
|
37
|
+
errors.push("calls: Must be a non-empty array");
|
|
38
|
+
} else {
|
|
39
|
+
for (let i = 0; i < calls.length; i++) {
|
|
40
|
+
const c = calls[i];
|
|
41
|
+
if (!c || typeof c !== "object") {
|
|
42
|
+
errors.push(`calls.${i}: Must be an object`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (typeof c.target !== "string" || !ADDRESS_RE.test(c.target)) {
|
|
46
|
+
errors.push(`calls.${i}.target: Must be a valid 0x address (40 hex chars)`);
|
|
47
|
+
}
|
|
48
|
+
if (c.selector !== void 0 && (typeof c.selector !== "string" || !SELECTOR_RE.test(c.selector))) {
|
|
49
|
+
errors.push(`calls.${i}.selector: Must be a 4-byte hex selector (e.g. 0xa9059cbb)`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (spends !== void 0) {
|
|
55
|
+
if (!Array.isArray(spends) || spends.length === 0) {
|
|
56
|
+
errors.push("spends: Must be a non-empty array");
|
|
57
|
+
} else {
|
|
58
|
+
for (let i = 0; i < spends.length; i++) {
|
|
59
|
+
const s = spends[i];
|
|
60
|
+
if (!s || typeof s !== "object") {
|
|
61
|
+
errors.push(`spends.${i}: Must be an object`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (typeof s.token !== "string" || !ADDRESS_RE.test(s.token)) {
|
|
65
|
+
errors.push(`spends.${i}.token: Must be a valid 0x address (40 hex chars)`);
|
|
66
|
+
}
|
|
67
|
+
if (typeof s.allowance !== "string" || !HEX_RE.test(s.allowance)) {
|
|
68
|
+
errors.push(`spends.${i}.allowance: Must be a non-empty 0x hex value`);
|
|
69
|
+
}
|
|
70
|
+
if (typeof s.unit !== "string" || !VALID_SPEND_UNITS.has(s.unit)) {
|
|
71
|
+
errors.push(`spends.${i}.unit: Must be one of: ${[...VALID_SPEND_UNITS].join(", ")}`);
|
|
72
|
+
}
|
|
73
|
+
if (s.multiplier !== void 0 && (!Number.isInteger(s.multiplier) || s.multiplier < 1)) {
|
|
74
|
+
errors.push(`spends.${i}.multiplier: Must be a positive integer`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (errors.length > 0) {
|
|
80
|
+
throw new Error(`Invalid permissions:
|
|
81
|
+
${errors.map((e) => ` - ${e}`).join("\n")}`);
|
|
82
|
+
}
|
|
83
|
+
return raw;
|
|
84
|
+
}
|
|
85
|
+
function isValidKeysUrl(url) {
|
|
86
|
+
try {
|
|
87
|
+
const parsed = new URL(url);
|
|
88
|
+
const isTrustedHost = parsed.hostname.endsWith(".jaw.id") || parsed.hostname === "jaw.id" || parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
|
89
|
+
const isSecure = parsed.protocol === "https:" || parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
|
90
|
+
return isTrustedHost && isSecure;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function isValidRelayUrl(url) {
|
|
96
|
+
try {
|
|
97
|
+
const parsed = new URL(url);
|
|
98
|
+
const isTrustedHost = parsed.hostname.endsWith(".jaw.id") || parsed.hostname === "jaw.id" || parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
|
99
|
+
const isSecure = parsed.protocol === "wss:" || parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
|
100
|
+
const isWebSocket = parsed.protocol === "wss:" || parsed.protocol === "ws:";
|
|
101
|
+
return isTrustedHost && isSecure && isWebSocket;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/lib/config.ts
|
|
108
|
+
function ensureDir(dir) {
|
|
109
|
+
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
110
|
+
fs.chmodSync(dir, 448);
|
|
111
|
+
}
|
|
112
|
+
function migrateConfig(config) {
|
|
113
|
+
if (config.paymasterUrl && !config.paymasters) {
|
|
114
|
+
const chainId = config.defaultChain ?? 1;
|
|
115
|
+
config.paymasters = { [chainId]: { url: config.paymasterUrl } };
|
|
116
|
+
delete config.paymasterUrl;
|
|
117
|
+
saveConfig(config);
|
|
118
|
+
}
|
|
119
|
+
return config;
|
|
120
|
+
}
|
|
121
|
+
function loadConfig() {
|
|
122
|
+
if (!fs.existsSync(PATHS.config)) {
|
|
123
|
+
return {};
|
|
124
|
+
}
|
|
125
|
+
const raw = fs.readFileSync(PATHS.config, "utf-8");
|
|
126
|
+
try {
|
|
127
|
+
const config = JSON.parse(raw);
|
|
128
|
+
return migrateConfig(config);
|
|
129
|
+
} catch {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Config file at ${PATHS.config} is not valid JSON. Run \`jaw config set apiKey=<key>\` to reset it.`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function saveConfig(config) {
|
|
136
|
+
ensureDir(PATHS.root);
|
|
137
|
+
fs.writeFileSync(PATHS.config, JSON.stringify(config, null, 2) + "\n", {
|
|
138
|
+
encoding: "utf-8",
|
|
139
|
+
mode: 384
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/lib/output.ts
|
|
144
|
+
function formatOutput(data, format) {
|
|
145
|
+
if (format === "json") {
|
|
146
|
+
return JSON.stringify(data, replaceBigInt, 2);
|
|
147
|
+
}
|
|
148
|
+
return formatHuman(data);
|
|
149
|
+
}
|
|
150
|
+
function replaceBigInt(_key, value) {
|
|
151
|
+
if (typeof value === "bigint") {
|
|
152
|
+
return value.toString();
|
|
153
|
+
}
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
function formatHuman(data, indent = 0) {
|
|
157
|
+
if (data === null || data === void 0) {
|
|
158
|
+
return "null";
|
|
159
|
+
}
|
|
160
|
+
if (typeof data === "string" || typeof data === "number" || typeof data === "boolean" || typeof data === "bigint") {
|
|
161
|
+
return String(data);
|
|
162
|
+
}
|
|
163
|
+
if (Array.isArray(data)) {
|
|
164
|
+
if (data.length === 0) return "(empty)";
|
|
165
|
+
return data.map((item, i) => `${i + 1}. ${formatHuman(item, indent + 2)}`).join("\n");
|
|
166
|
+
}
|
|
167
|
+
if (typeof data === "object") {
|
|
168
|
+
const entries = Object.entries(data);
|
|
169
|
+
if (entries.length === 0) return "(empty)";
|
|
170
|
+
const pad = " ".repeat(indent);
|
|
171
|
+
const maxKeyLen = Math.max(...entries.map(([k]) => k.length));
|
|
172
|
+
return entries.map(([key, val]) => {
|
|
173
|
+
const paddedKey = key.padEnd(maxKeyLen);
|
|
174
|
+
const valStr = typeof val === "object" && val !== null ? "\n" + formatHuman(val, indent + 2) : String(val);
|
|
175
|
+
return `${pad}${paddedKey} ${valStr}`;
|
|
176
|
+
}).join("\n");
|
|
177
|
+
}
|
|
178
|
+
return String(data);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// src/base-command.ts
|
|
182
|
+
var BaseCommand = class extends Command {
|
|
183
|
+
static baseFlags = {
|
|
184
|
+
output: Flags.string({
|
|
185
|
+
char: "o",
|
|
186
|
+
description: "Output format",
|
|
187
|
+
options: ["json", "human"],
|
|
188
|
+
default: "human",
|
|
189
|
+
env: "JAW_OUTPUT"
|
|
190
|
+
}),
|
|
191
|
+
chain: Flags.integer({
|
|
192
|
+
char: "c",
|
|
193
|
+
description: "Chain ID",
|
|
194
|
+
env: "JAW_CHAIN_ID"
|
|
195
|
+
}),
|
|
196
|
+
"api-key": Flags.string({
|
|
197
|
+
description: "JAW API key",
|
|
198
|
+
env: "JAW_API_KEY"
|
|
199
|
+
}),
|
|
200
|
+
yes: Flags.boolean({
|
|
201
|
+
char: "y",
|
|
202
|
+
description: "Skip confirmations (for AI agents)",
|
|
203
|
+
default: false
|
|
204
|
+
}),
|
|
205
|
+
quiet: Flags.boolean({
|
|
206
|
+
char: "q",
|
|
207
|
+
description: "Suppress non-essential output",
|
|
208
|
+
default: false
|
|
209
|
+
})
|
|
210
|
+
};
|
|
211
|
+
resolveApiKey(flags) {
|
|
212
|
+
const apiKey = flags["api-key"] ?? loadConfig().apiKey;
|
|
213
|
+
if (!apiKey) {
|
|
214
|
+
this.error("API key required. Set via --api-key, JAW_API_KEY env, or `jaw config set apiKey <key>`");
|
|
215
|
+
}
|
|
216
|
+
return apiKey;
|
|
217
|
+
}
|
|
218
|
+
resolveChainId(flags) {
|
|
219
|
+
const chainId = flags.chain ?? loadConfig().defaultChain;
|
|
220
|
+
if (!chainId) {
|
|
221
|
+
this.error("Chain ID required. Set via --chain, JAW_CHAIN_ID env, or `jaw config set defaultChain <id>`");
|
|
222
|
+
}
|
|
223
|
+
return chainId;
|
|
224
|
+
}
|
|
225
|
+
outputResult(data, format) {
|
|
226
|
+
const output = formatOutput(data, format);
|
|
227
|
+
this.log(output);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// src/lib/crypto.ts
|
|
232
|
+
var subtle = globalThis.crypto.subtle;
|
|
233
|
+
async function generateKeyPair() {
|
|
234
|
+
return subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveKey"]);
|
|
235
|
+
}
|
|
236
|
+
async function deriveSharedSecret(privateKey, peerPublicKey) {
|
|
237
|
+
return subtle.deriveKey(
|
|
238
|
+
{ name: "ECDH", public: peerPublicKey },
|
|
239
|
+
privateKey,
|
|
240
|
+
{ name: "AES-GCM", length: 256 },
|
|
241
|
+
false,
|
|
242
|
+
["encrypt", "decrypt"]
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
async function encryptMessage(sharedSecret, payload) {
|
|
246
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
|
|
247
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
248
|
+
const cipherBuf = await subtle.encrypt({ name: "AES-GCM", iv }, sharedSecret, plaintext);
|
|
249
|
+
return {
|
|
250
|
+
iv: bufferToBase64(iv),
|
|
251
|
+
ciphertext: bufferToBase64(new Uint8Array(cipherBuf))
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
async function decryptMessage(sharedSecret, envelope) {
|
|
255
|
+
const iv = Buffer.from(envelope.iv, "base64");
|
|
256
|
+
const ciphertext = Buffer.from(envelope.ciphertext, "base64");
|
|
257
|
+
const plainBuf = await subtle.decrypt({ name: "AES-GCM", iv }, sharedSecret, ciphertext);
|
|
258
|
+
return JSON.parse(new TextDecoder().decode(plainBuf));
|
|
259
|
+
}
|
|
260
|
+
async function exportKeyToHex(type, key) {
|
|
261
|
+
const format = type === "private" ? "pkcs8" : "spki";
|
|
262
|
+
const buf = await subtle.exportKey(format, key);
|
|
263
|
+
return bytesToHex(new Uint8Array(buf));
|
|
264
|
+
}
|
|
265
|
+
async function importKeyFromHex(type, hex) {
|
|
266
|
+
const format = type === "private" ? "pkcs8" : "spki";
|
|
267
|
+
return subtle.importKey(
|
|
268
|
+
format,
|
|
269
|
+
Buffer.from(hexToBytes(hex)),
|
|
270
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
271
|
+
true,
|
|
272
|
+
type === "private" ? ["deriveKey"] : []
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
function bytesToHex(bytes) {
|
|
276
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
277
|
+
}
|
|
278
|
+
function hexToBytes(hex) {
|
|
279
|
+
if (hex.length % 2 !== 0) throw new Error("Invalid hex: odd length");
|
|
280
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
281
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
282
|
+
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
283
|
+
}
|
|
284
|
+
return bytes;
|
|
285
|
+
}
|
|
286
|
+
function bufferToBase64(buf) {
|
|
287
|
+
return Buffer.from(buf).toString("base64");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// src/lib/ws-bridge.ts
|
|
291
|
+
var DEFAULT_TIMEOUT_MS = 12e4;
|
|
292
|
+
var MAX_MESSAGE_BYTES = 5 * 1024 * 1024;
|
|
293
|
+
var BROWSER_REOPEN_COOLDOWN_MS = 5e3;
|
|
294
|
+
var MAX_RECONNECT_ATTEMPTS = 3;
|
|
295
|
+
var RECONNECT_BASE_DELAY_MS = 1e3;
|
|
296
|
+
var WSBridge = class {
|
|
297
|
+
relayUrl;
|
|
298
|
+
session;
|
|
299
|
+
timeout;
|
|
300
|
+
config;
|
|
301
|
+
privateKeyHex;
|
|
302
|
+
publicKeyHex;
|
|
303
|
+
peerPublicKeyHex;
|
|
304
|
+
sharedSecret = null;
|
|
305
|
+
ws = null;
|
|
306
|
+
disposed = false;
|
|
307
|
+
// Auto-reopen browser state
|
|
308
|
+
onBrowserNeeded;
|
|
309
|
+
onPeerKeyChanged;
|
|
310
|
+
lastBrowserOpenTime = 0;
|
|
311
|
+
// Reconnection state
|
|
312
|
+
reconnectAttempts = 0;
|
|
313
|
+
/** Updated after key exchange — caller should persist this. */
|
|
314
|
+
get peerPublicKey() {
|
|
315
|
+
return this.peerPublicKeyHex;
|
|
316
|
+
}
|
|
317
|
+
constructor(options) {
|
|
318
|
+
this.relayUrl = options.relayUrl;
|
|
319
|
+
this.session = options.session;
|
|
320
|
+
this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
321
|
+
this.config = options.config;
|
|
322
|
+
this.privateKeyHex = options.privateKeyHex;
|
|
323
|
+
this.publicKeyHex = options.publicKeyHex;
|
|
324
|
+
this.peerPublicKeyHex = options.peerPublicKeyHex;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Connect to the relay and wait for the browser to be ready.
|
|
328
|
+
*
|
|
329
|
+
* @param onBrowserNeeded — called when the relay reports no browser connected.
|
|
330
|
+
* @param onPeerKeyChanged — called when a key_exchange updates the peer key.
|
|
331
|
+
*/
|
|
332
|
+
async connect(onBrowserNeeded, onPeerKeyChanged) {
|
|
333
|
+
this.onBrowserNeeded = onBrowserNeeded;
|
|
334
|
+
this.onPeerKeyChanged = onPeerKeyChanged;
|
|
335
|
+
if (this.peerPublicKeyHex) {
|
|
336
|
+
await this.deriveSecret();
|
|
337
|
+
}
|
|
338
|
+
return this.connectInternal(onBrowserNeeded, onPeerKeyChanged);
|
|
339
|
+
}
|
|
340
|
+
async connectInternal(onBrowserNeeded, onPeerKeyChanged) {
|
|
341
|
+
return new Promise((resolve, reject) => {
|
|
342
|
+
const url = `${this.relayUrl}?session=${encodeURIComponent(this.session)}&role=cli`;
|
|
343
|
+
const ws = new WebSocket(url);
|
|
344
|
+
let browserOpened = false;
|
|
345
|
+
let resolved = false;
|
|
346
|
+
let expectingKeyExchange = !this.peerPublicKeyHex;
|
|
347
|
+
const timer = setTimeout(() => {
|
|
348
|
+
ws.close();
|
|
349
|
+
reject(new Error("Browser did not connect in time.\nRun `jaw disconnect` then try again."));
|
|
350
|
+
}, 3e4);
|
|
351
|
+
const sendEncryptedInit = async () => {
|
|
352
|
+
if (!this.sharedSecret) return;
|
|
353
|
+
const envelope = await encryptMessage(this.sharedSecret, {
|
|
354
|
+
type: "init",
|
|
355
|
+
apiKey: this.config.apiKey,
|
|
356
|
+
chainId: this.config.chainId,
|
|
357
|
+
ens: this.config.ens,
|
|
358
|
+
paymasterUrl: this.config.paymasterUrl
|
|
359
|
+
});
|
|
360
|
+
this.sendRaw(ws, JSON.stringify({ type: "encrypted", ...envelope }));
|
|
361
|
+
};
|
|
362
|
+
const waitForReady = () => {
|
|
363
|
+
const readyTimer = setTimeout(() => {
|
|
364
|
+
ws.close();
|
|
365
|
+
reject(new Error("Browser SDK did not become ready in time."));
|
|
366
|
+
}, 15e3);
|
|
367
|
+
const onMsg = async (data) => {
|
|
368
|
+
const msg = safeParse(data);
|
|
369
|
+
if (!msg) return;
|
|
370
|
+
if (msg.type === "encrypted" && this.sharedSecret) {
|
|
371
|
+
try {
|
|
372
|
+
const inner = await decryptMessage(this.sharedSecret, msg);
|
|
373
|
+
if (inner.type === "ready") {
|
|
374
|
+
clearTimeout(readyTimer);
|
|
375
|
+
ws.off("message", onMsg);
|
|
376
|
+
this.reconnectAttempts = 0;
|
|
377
|
+
resolve();
|
|
378
|
+
}
|
|
379
|
+
} catch {
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
ws.on("message", onMsg);
|
|
384
|
+
};
|
|
385
|
+
const onBrowserReady = async () => {
|
|
386
|
+
if (resolved) return;
|
|
387
|
+
resolved = true;
|
|
388
|
+
clearTimeout(timer);
|
|
389
|
+
waitForReady();
|
|
390
|
+
await sendEncryptedInit();
|
|
391
|
+
};
|
|
392
|
+
ws.on("open", () => {
|
|
393
|
+
this.ws = ws;
|
|
394
|
+
});
|
|
395
|
+
ws.on("message", async (data) => {
|
|
396
|
+
const msg = safeParse(data);
|
|
397
|
+
if (!msg) return;
|
|
398
|
+
if (msg.type === "status") {
|
|
399
|
+
if (msg.browserConnected) {
|
|
400
|
+
if (this.sharedSecret) {
|
|
401
|
+
await onBrowserReady();
|
|
402
|
+
} else {
|
|
403
|
+
expectingKeyExchange = true;
|
|
404
|
+
}
|
|
405
|
+
} else if (!browserOpened && onBrowserNeeded) {
|
|
406
|
+
browserOpened = true;
|
|
407
|
+
expectingKeyExchange = true;
|
|
408
|
+
onBrowserNeeded().catch(() => {
|
|
409
|
+
});
|
|
410
|
+
} else if (!onBrowserNeeded) {
|
|
411
|
+
clearTimeout(timer);
|
|
412
|
+
ws.close();
|
|
413
|
+
reject(new Error("Browser not connected \u2014 relay session is stale."));
|
|
414
|
+
}
|
|
415
|
+
} else if (msg.type === "browser_connected") {
|
|
416
|
+
expectingKeyExchange = true;
|
|
417
|
+
} else if (msg.type === "browser_disconnected") {
|
|
418
|
+
this.handleBrowserDisconnect();
|
|
419
|
+
} else if (msg.type === "key_exchange" && expectingKeyExchange) {
|
|
420
|
+
expectingKeyExchange = false;
|
|
421
|
+
const peerKey = msg.publicKey;
|
|
422
|
+
this.peerPublicKeyHex = peerKey;
|
|
423
|
+
await this.deriveSecret();
|
|
424
|
+
onPeerKeyChanged?.(peerKey);
|
|
425
|
+
await onBrowserReady();
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
ws.on("error", (err) => {
|
|
429
|
+
clearTimeout(timer);
|
|
430
|
+
reject(err);
|
|
431
|
+
});
|
|
432
|
+
ws.on("close", () => {
|
|
433
|
+
clearTimeout(timer);
|
|
434
|
+
if (!this.disposed) {
|
|
435
|
+
this.handleRelayDisconnect();
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Send an encrypted RPC request through the relay to the browser SDK.
|
|
442
|
+
*/
|
|
443
|
+
async request(method, params) {
|
|
444
|
+
const ws = this.ws;
|
|
445
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
446
|
+
throw new Error("Not connected to relay");
|
|
447
|
+
}
|
|
448
|
+
if (!this.sharedSecret) {
|
|
449
|
+
throw new Error("No shared secret \u2014 key exchange not completed");
|
|
450
|
+
}
|
|
451
|
+
const id = crypto.randomUUID();
|
|
452
|
+
const envelope = await encryptMessage(this.sharedSecret, {
|
|
453
|
+
type: "rpc_request",
|
|
454
|
+
id,
|
|
455
|
+
method,
|
|
456
|
+
params
|
|
457
|
+
});
|
|
458
|
+
const serialized = JSON.stringify({ type: "encrypted", ...envelope });
|
|
459
|
+
assertMessageSize(serialized, method);
|
|
460
|
+
return new Promise((resolve, reject) => {
|
|
461
|
+
const timer = setTimeout(() => {
|
|
462
|
+
reject(
|
|
463
|
+
new Error(`Request timed out after ${this.timeout / 1e3}s. Did you complete the action in the browser?`)
|
|
464
|
+
);
|
|
465
|
+
this.close();
|
|
466
|
+
}, this.timeout);
|
|
467
|
+
const onMessage = async (data) => {
|
|
468
|
+
const msg = safeParse(data);
|
|
469
|
+
if (!msg || msg.type !== "encrypted" || !this.sharedSecret) return;
|
|
470
|
+
try {
|
|
471
|
+
const inner = await decryptMessage(this.sharedSecret, msg);
|
|
472
|
+
if (inner.type === "rpc_response" && inner.id === id) {
|
|
473
|
+
clearTimeout(timer);
|
|
474
|
+
ws.off("message", onMessage);
|
|
475
|
+
if (inner.success) {
|
|
476
|
+
resolve(inner.data);
|
|
477
|
+
} else {
|
|
478
|
+
const err = inner.error;
|
|
479
|
+
reject(new Error(err ? `[${err.code}] ${err.message}` : "Request failed"));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
} catch {
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
ws.on("message", onMessage);
|
|
486
|
+
this.sendRaw(ws, serialized);
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
async shutdown() {
|
|
490
|
+
this.disposed = true;
|
|
491
|
+
if (this.ws?.readyState === WebSocket.OPEN && this.sharedSecret) {
|
|
492
|
+
try {
|
|
493
|
+
const envelope = await encryptMessage(this.sharedSecret, {
|
|
494
|
+
type: "shutdown"
|
|
495
|
+
});
|
|
496
|
+
this.sendRaw(this.ws, JSON.stringify({ type: "encrypted", ...envelope }));
|
|
497
|
+
} catch {
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
this.close();
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Connect to relay and send shutdown directly — no init/ready handshake.
|
|
504
|
+
* Used by `jaw disconnect` when we just need to tell the browser to close.
|
|
505
|
+
*/
|
|
506
|
+
async connectAndShutdown() {
|
|
507
|
+
if (!this.peerPublicKeyHex) {
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
this.disposed = true;
|
|
511
|
+
await this.deriveSecret();
|
|
512
|
+
return new Promise((resolve) => {
|
|
513
|
+
const url = `${this.relayUrl}?session=${encodeURIComponent(this.session)}&role=cli`;
|
|
514
|
+
const ws = new WebSocket(url);
|
|
515
|
+
const timer = setTimeout(() => {
|
|
516
|
+
try {
|
|
517
|
+
ws.close();
|
|
518
|
+
} catch {
|
|
519
|
+
}
|
|
520
|
+
resolve();
|
|
521
|
+
}, 3e3);
|
|
522
|
+
ws.on("open", async () => {
|
|
523
|
+
this.ws = ws;
|
|
524
|
+
try {
|
|
525
|
+
await this.shutdown();
|
|
526
|
+
} catch {
|
|
527
|
+
}
|
|
528
|
+
clearTimeout(timer);
|
|
529
|
+
resolve();
|
|
530
|
+
});
|
|
531
|
+
ws.on("error", () => {
|
|
532
|
+
clearTimeout(timer);
|
|
533
|
+
resolve();
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
close() {
|
|
538
|
+
this.disposed = true;
|
|
539
|
+
if (this.ws) {
|
|
540
|
+
try {
|
|
541
|
+
this.ws.close();
|
|
542
|
+
} catch {
|
|
543
|
+
}
|
|
544
|
+
this.ws = null;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Auto-reopen browser when browser_disconnected is received from relay.
|
|
549
|
+
* Respects a cooldown to prevent rapid re-opening.
|
|
550
|
+
*/
|
|
551
|
+
handleBrowserDisconnect() {
|
|
552
|
+
if (this.disposed || !this.onBrowserNeeded) return;
|
|
553
|
+
const now = Date.now();
|
|
554
|
+
if (now - this.lastBrowserOpenTime < BROWSER_REOPEN_COOLDOWN_MS) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
this.lastBrowserOpenTime = now;
|
|
558
|
+
this.sharedSecret = null;
|
|
559
|
+
this.peerPublicKeyHex = null;
|
|
560
|
+
this.onBrowserNeeded().catch(() => {
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Attempt to reconnect to the relay with exponential backoff
|
|
565
|
+
* when the WebSocket connection drops unexpectedly.
|
|
566
|
+
*/
|
|
567
|
+
handleRelayDisconnect() {
|
|
568
|
+
if (this.disposed) return;
|
|
569
|
+
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) return;
|
|
570
|
+
const delay = RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts);
|
|
571
|
+
this.reconnectAttempts++;
|
|
572
|
+
setTimeout(() => {
|
|
573
|
+
if (this.disposed) return;
|
|
574
|
+
this.connectInternal(this.onBrowserNeeded, this.onPeerKeyChanged).catch(() => {
|
|
575
|
+
});
|
|
576
|
+
}, delay);
|
|
577
|
+
}
|
|
578
|
+
/** Send a raw string over the WebSocket, enforcing message size limits. */
|
|
579
|
+
sendRaw(ws, data) {
|
|
580
|
+
ws.send(data);
|
|
581
|
+
}
|
|
582
|
+
async deriveSecret() {
|
|
583
|
+
if (!this.peerPublicKeyHex) return;
|
|
584
|
+
const privateKey = await importKeyFromHex("private", this.privateKeyHex);
|
|
585
|
+
const peerPublicKey = await importKeyFromHex("public", this.peerPublicKeyHex);
|
|
586
|
+
this.sharedSecret = await deriveSharedSecret(privateKey, peerPublicKey);
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
function assertMessageSize(serialized, method) {
|
|
590
|
+
const byteLength = Buffer.byteLength(serialized, "utf-8");
|
|
591
|
+
if (byteLength > MAX_MESSAGE_BYTES) {
|
|
592
|
+
const sizeMB = (byteLength / (1024 * 1024)).toFixed(2);
|
|
593
|
+
throw new Error(
|
|
594
|
+
`Message for ${method} is too large (${sizeMB} MB, limit ${MAX_MESSAGE_BYTES / (1024 * 1024)} MB). Try reducing the number of calls in your batch.`
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
function safeParse(data) {
|
|
599
|
+
try {
|
|
600
|
+
return JSON.parse(data.toString());
|
|
601
|
+
} catch {
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// src/lib/bridge-singleton.ts
|
|
607
|
+
var DEFAULT_KEYS_URL = "https://keys.jaw.id";
|
|
608
|
+
var DEFAULT_RELAY_URL = "wss://relay.jaw.id";
|
|
609
|
+
function loadRelaySession() {
|
|
610
|
+
try {
|
|
611
|
+
if (!fs.existsSync(PATHS.relay)) return null;
|
|
612
|
+
const raw = fs.readFileSync(PATHS.relay, "utf-8");
|
|
613
|
+
const parsed = JSON.parse(raw);
|
|
614
|
+
if (!parsed.session || !parsed.relayUrl || !parsed.privateKey || !parsed.publicKey) {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
return parsed;
|
|
618
|
+
} catch {
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
function saveRelaySession(info) {
|
|
623
|
+
ensureDir(PATHS.root);
|
|
624
|
+
fs.writeFileSync(PATHS.relay, JSON.stringify(info, null, 2) + "\n", {
|
|
625
|
+
encoding: "utf-8",
|
|
626
|
+
mode: 384
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
function deleteRelaySession() {
|
|
630
|
+
try {
|
|
631
|
+
if (fs.existsSync(PATHS.relay)) fs.unlinkSync(PATHS.relay);
|
|
632
|
+
} catch {
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
async function getBridge(options) {
|
|
636
|
+
const config = loadConfig();
|
|
637
|
+
const keysUrl = options.keysUrl ?? config.keysUrl ?? DEFAULT_KEYS_URL;
|
|
638
|
+
const relayUrl = options.relayUrl ?? config.relayUrl ?? DEFAULT_RELAY_URL;
|
|
639
|
+
const chainId = options.chainId ?? config.defaultChain ?? 1;
|
|
640
|
+
if (!isValidKeysUrl(keysUrl)) {
|
|
641
|
+
throw new Error(`Untrusted keysUrl: ${keysUrl}. Must be a *.jaw.id domain (HTTPS) or localhost.`);
|
|
642
|
+
}
|
|
643
|
+
if (!isValidRelayUrl(relayUrl)) {
|
|
644
|
+
throw new Error(`Untrusted relayUrl: ${relayUrl}. Must be wss://*.jaw.id or ws://localhost.`);
|
|
645
|
+
}
|
|
646
|
+
let relaySession = loadRelaySession();
|
|
647
|
+
if (relaySession && relaySession.relayUrl === relayUrl && relaySession.peerPublicKey) {
|
|
648
|
+
try {
|
|
649
|
+
return await connectBridge(relaySession, options, chainId, keysUrl, relayUrl, false);
|
|
650
|
+
} catch {
|
|
651
|
+
deleteRelaySession();
|
|
652
|
+
relaySession = null;
|
|
653
|
+
}
|
|
654
|
+
} else if (relaySession) {
|
|
655
|
+
deleteRelaySession();
|
|
656
|
+
}
|
|
657
|
+
const session = await createNewSession(relayUrl);
|
|
658
|
+
saveRelaySession(session);
|
|
659
|
+
return await connectBridge(session, options, chainId, keysUrl, relayUrl, true);
|
|
660
|
+
}
|
|
661
|
+
async function createNewSession(relayUrl) {
|
|
662
|
+
const kp = await generateKeyPair();
|
|
663
|
+
const privateKey = await exportKeyToHex("private", kp.privateKey);
|
|
664
|
+
const publicKey = await exportKeyToHex("public", kp.publicKey);
|
|
665
|
+
return {
|
|
666
|
+
session: crypto.randomUUID(),
|
|
667
|
+
relayUrl,
|
|
668
|
+
privateKey,
|
|
669
|
+
publicKey,
|
|
670
|
+
peerPublicKey: null,
|
|
671
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
async function connectBridge(relaySession, options, chainId, keysUrl, relayUrl, openBrowser) {
|
|
675
|
+
const config = loadConfig();
|
|
676
|
+
const bridge = new WSBridge({
|
|
677
|
+
relayUrl,
|
|
678
|
+
session: relaySession.session,
|
|
679
|
+
timeout: options.timeout,
|
|
680
|
+
config: {
|
|
681
|
+
apiKey: options.apiKey,
|
|
682
|
+
chainId,
|
|
683
|
+
ens: options.ens ?? config.ens,
|
|
684
|
+
paymasterUrl: options.paymasterUrl ?? config.paymasters?.[chainId]?.url
|
|
685
|
+
},
|
|
686
|
+
privateKeyHex: relaySession.privateKey,
|
|
687
|
+
publicKeyHex: relaySession.publicKey,
|
|
688
|
+
peerPublicKeyHex: relaySession.peerPublicKey
|
|
689
|
+
});
|
|
690
|
+
await bridge.connect(
|
|
691
|
+
// onBrowserNeeded — only open a browser for new sessions
|
|
692
|
+
openBrowser ? async () => {
|
|
693
|
+
const bridgeUrl = buildBridgeUrl(keysUrl, relaySession.session, relayUrl, relaySession.publicKey);
|
|
694
|
+
const { default: open } = await import('open');
|
|
695
|
+
await open(bridgeUrl);
|
|
696
|
+
} : void 0,
|
|
697
|
+
// onPeerKeyChanged
|
|
698
|
+
(newPeerKey) => {
|
|
699
|
+
relaySession.peerPublicKey = newPeerKey;
|
|
700
|
+
saveRelaySession(relaySession);
|
|
701
|
+
}
|
|
702
|
+
);
|
|
703
|
+
return bridge;
|
|
704
|
+
}
|
|
705
|
+
function buildBridgeUrl(keysUrl, session, relayUrl, cliPublicKeyHex) {
|
|
706
|
+
const url = new URL("/cli-bridge", keysUrl);
|
|
707
|
+
url.searchParams.set("session", session);
|
|
708
|
+
url.searchParams.set("relay", relayUrl);
|
|
709
|
+
url.hash = `pk=${cliPublicKeyHex}`;
|
|
710
|
+
return url.toString();
|
|
711
|
+
}
|
|
712
|
+
function generateSessionKey() {
|
|
713
|
+
const bytes = crypto.randomBytes(32);
|
|
714
|
+
return `0x${bytes.toString("hex")}`;
|
|
715
|
+
}
|
|
716
|
+
function saveKeystore(privateKeyHex, address) {
|
|
717
|
+
const keystore = {
|
|
718
|
+
version: 2,
|
|
719
|
+
privateKey: privateKeyHex,
|
|
720
|
+
address,
|
|
721
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
722
|
+
};
|
|
723
|
+
ensureDir(PATHS.root);
|
|
724
|
+
fs.writeFileSync(PATHS.keystore, JSON.stringify(keystore, null, 2) + "\n", {
|
|
725
|
+
encoding: "utf-8",
|
|
726
|
+
mode: 384
|
|
727
|
+
});
|
|
728
|
+
fs.chmodSync(PATHS.keystore, 384);
|
|
729
|
+
}
|
|
730
|
+
function loadSessionKey() {
|
|
731
|
+
if (!fs.existsSync(PATHS.keystore)) {
|
|
732
|
+
throw new Error("No session configured. Run `jaw session setup` first.");
|
|
733
|
+
}
|
|
734
|
+
const contents = fs.readFileSync(PATHS.keystore, "utf-8");
|
|
735
|
+
let parsed;
|
|
736
|
+
try {
|
|
737
|
+
parsed = JSON.parse(contents);
|
|
738
|
+
} catch {
|
|
739
|
+
throw new Error(`Keystore at ${PATHS.keystore} is corrupted. Run \`jaw session setup\` to recreate it.`);
|
|
740
|
+
}
|
|
741
|
+
return parsed.privateKey;
|
|
742
|
+
}
|
|
743
|
+
function keystoreExists() {
|
|
744
|
+
return fs.existsSync(PATHS.keystore);
|
|
745
|
+
}
|
|
746
|
+
function saveSessionConfig(input) {
|
|
747
|
+
const config = {
|
|
748
|
+
...input,
|
|
749
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
750
|
+
};
|
|
751
|
+
ensureDir(PATHS.root);
|
|
752
|
+
fs.writeFileSync(PATHS.sessionConfig, JSON.stringify(config, null, 2) + "\n", {
|
|
753
|
+
encoding: "utf-8",
|
|
754
|
+
mode: 384
|
|
755
|
+
});
|
|
756
|
+
fs.chmodSync(PATHS.sessionConfig, 384);
|
|
757
|
+
}
|
|
758
|
+
function loadSessionConfig() {
|
|
759
|
+
if (!fs.existsSync(PATHS.sessionConfig)) {
|
|
760
|
+
throw new Error("No session configured. Run `jaw session setup` first.");
|
|
761
|
+
}
|
|
762
|
+
const raw = fs.readFileSync(PATHS.sessionConfig, "utf-8");
|
|
763
|
+
try {
|
|
764
|
+
return JSON.parse(raw);
|
|
765
|
+
} catch {
|
|
766
|
+
throw new Error(`Session config at ${PATHS.sessionConfig} is corrupted. Run \`jaw session setup\` to recreate it.`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// src/commands/session/setup.ts
|
|
771
|
+
var SessionSetup = class _SessionSetup extends BaseCommand {
|
|
772
|
+
static description = "Generate a session key and grant scoped on-chain permissions (one-time browser approval).";
|
|
773
|
+
static examples = [
|
|
774
|
+
"<%= config.bin %> session setup --chain 84532",
|
|
775
|
+
`<%= config.bin %> session setup --permissions '{"calls":[...]}' --expiry 14`,
|
|
776
|
+
"<%= config.bin %> session setup --permissions ./permissions.json"
|
|
777
|
+
];
|
|
778
|
+
static flags = {
|
|
779
|
+
...BaseCommand.baseFlags,
|
|
780
|
+
permissions: Flags.string({
|
|
781
|
+
description: "Permission scope (inline JSON or file path). Overrides config.permissions."
|
|
782
|
+
}),
|
|
783
|
+
expiry: Flags.integer({
|
|
784
|
+
description: "Permission expiry in days. Overrides config.sessionExpiry."
|
|
785
|
+
})
|
|
786
|
+
};
|
|
787
|
+
async run() {
|
|
788
|
+
const { flags } = await this.parse(_SessionSetup);
|
|
789
|
+
const config = loadConfig();
|
|
790
|
+
const format = flags.output;
|
|
791
|
+
const apiKey = this.resolveApiKey(flags);
|
|
792
|
+
const chainId = this.resolveChainId(flags);
|
|
793
|
+
let reuseKey = null;
|
|
794
|
+
let oldPermissionRevoked = false;
|
|
795
|
+
if (keystoreExists()) {
|
|
796
|
+
const existing = loadSessionConfig();
|
|
797
|
+
const isActive = existing.expiry > Date.now() / 1e3;
|
|
798
|
+
if (!flags.yes && !process.stdin.isTTY) {
|
|
799
|
+
this.error(
|
|
800
|
+
"Existing session found, but stdin is not a terminal (piped, redirected, or running in CI). Re-run with --yes to overwrite the existing session non-interactively."
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
if (!flags.yes) {
|
|
804
|
+
const readline = await import('readline');
|
|
805
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
806
|
+
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
807
|
+
if (isActive) {
|
|
808
|
+
const remaining = Math.floor((existing.expiry - Date.now() / 1e3) / 86400);
|
|
809
|
+
this.log("Active session found:\n");
|
|
810
|
+
this.log(` Session address: ${existing.sessionAddress}`);
|
|
811
|
+
this.log(` Permission ID: ${existing.permissionId}`);
|
|
812
|
+
this.log(` Chain: ${existing.chainId}`);
|
|
813
|
+
this.log(
|
|
814
|
+
` Expires: ${new Date(existing.expiry * 1e3).toISOString()} (${remaining} days remaining)`
|
|
815
|
+
);
|
|
816
|
+
this.log("\nThe old on-chain permission will NOT be revoked automatically.");
|
|
817
|
+
this.log("Anyone with the old session key can still use it until expiry.\n");
|
|
818
|
+
const revokeAnswer = await ask("Revoke old permission on-chain first? (Y/n) ");
|
|
819
|
+
if (revokeAnswer.toLowerCase() !== "n") {
|
|
820
|
+
this.log("Opening browser to revoke old permission...");
|
|
821
|
+
const pm = config.paymasters?.[existing.chainId];
|
|
822
|
+
const revokeBridge = await getBridge({
|
|
823
|
+
keysUrl: config.keysUrl,
|
|
824
|
+
apiKey,
|
|
825
|
+
chainId: existing.chainId,
|
|
826
|
+
ens: config.ens,
|
|
827
|
+
paymasterUrl: pm?.url
|
|
828
|
+
});
|
|
829
|
+
try {
|
|
830
|
+
await revokeBridge.request("wallet_revokePermissions", [{ id: existing.permissionId }]);
|
|
831
|
+
} finally {
|
|
832
|
+
revokeBridge.close();
|
|
833
|
+
}
|
|
834
|
+
oldPermissionRevoked = true;
|
|
835
|
+
this.log("Old permission revoked.");
|
|
836
|
+
}
|
|
837
|
+
const reuseAnswer = await ask("Reuse existing session key? (Y/n) ");
|
|
838
|
+
if (reuseAnswer.toLowerCase() !== "n") {
|
|
839
|
+
reuseKey = loadSessionKey();
|
|
840
|
+
}
|
|
841
|
+
} else {
|
|
842
|
+
const overwrite = await ask("Expired session found. Overwrite? (y/N) ");
|
|
843
|
+
if (overwrite.toLowerCase() !== "y") {
|
|
844
|
+
rl.close();
|
|
845
|
+
this.log("Aborted.");
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
rl.close();
|
|
850
|
+
} else if (isActive) {
|
|
851
|
+
this.logToStderr(
|
|
852
|
+
`Warning: overwriting active session without revoking. Old permission ${existing.permissionId} on chain ${existing.chainId} remains live until ${new Date(existing.expiry * 1e3).toISOString()}.`
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
try {
|
|
857
|
+
const permissions = this.resolvePermissions(flags.permissions, config.permissions);
|
|
858
|
+
const expiryDays = flags.expiry ?? config.sessionExpiry ?? 7;
|
|
859
|
+
const expiryTimestamp = Math.floor(Date.now() / 1e3) + expiryDays * 86400;
|
|
860
|
+
const privateKeyHex = reuseKey ?? generateSessionKey();
|
|
861
|
+
const { privateKeyToAccount } = await import('viem/accounts');
|
|
862
|
+
const localAccount = privateKeyToAccount(privateKeyHex);
|
|
863
|
+
const { Account } = await import('@jaw.id/core');
|
|
864
|
+
const pm = config.paymasters?.[chainId];
|
|
865
|
+
const account = await Account.fromLocalAccount(
|
|
866
|
+
{
|
|
867
|
+
chainId,
|
|
868
|
+
apiKey,
|
|
869
|
+
paymasterUrl: pm?.url,
|
|
870
|
+
paymasterContext: pm?.context
|
|
871
|
+
},
|
|
872
|
+
localAccount
|
|
873
|
+
);
|
|
874
|
+
const sessionAddress = account.address;
|
|
875
|
+
if (!flags.quiet) {
|
|
876
|
+
this.log("Opening browser to approve permissions...");
|
|
877
|
+
}
|
|
878
|
+
const bridge = await getBridge({
|
|
879
|
+
keysUrl: config.keysUrl,
|
|
880
|
+
apiKey,
|
|
881
|
+
chainId,
|
|
882
|
+
ens: config.ens,
|
|
883
|
+
paymasterUrl: pm?.url
|
|
884
|
+
});
|
|
885
|
+
let grantResponse;
|
|
886
|
+
try {
|
|
887
|
+
grantResponse = await bridge.request("wallet_grantPermissions", [
|
|
888
|
+
{
|
|
889
|
+
spender: sessionAddress,
|
|
890
|
+
expiry: expiryTimestamp,
|
|
891
|
+
permissions,
|
|
892
|
+
chainId
|
|
893
|
+
}
|
|
894
|
+
]);
|
|
895
|
+
} finally {
|
|
896
|
+
bridge.close();
|
|
897
|
+
}
|
|
898
|
+
saveKeystore(privateKeyHex, sessionAddress);
|
|
899
|
+
saveSessionConfig({
|
|
900
|
+
ownerAddress: grantResponse.account,
|
|
901
|
+
sessionAddress,
|
|
902
|
+
permissionId: grantResponse.permissionId,
|
|
903
|
+
chainId,
|
|
904
|
+
expiry: expiryTimestamp
|
|
905
|
+
});
|
|
906
|
+
const summary = {
|
|
907
|
+
ownerAddress: grantResponse.account,
|
|
908
|
+
sessionAddress,
|
|
909
|
+
permissionId: grantResponse.permissionId,
|
|
910
|
+
expiry: expiryTimestamp
|
|
911
|
+
};
|
|
912
|
+
if (flags.quiet) {
|
|
913
|
+
this.outputResult(summary, format);
|
|
914
|
+
} else {
|
|
915
|
+
this.log("\nSession created successfully.\n");
|
|
916
|
+
this.log(` Session address: ${sessionAddress}`);
|
|
917
|
+
this.log(` Owner address: ${grantResponse.account}`);
|
|
918
|
+
this.log(` Permission ID: ${grantResponse.permissionId}`);
|
|
919
|
+
this.log(` Chain: ${chainId}`);
|
|
920
|
+
this.log(` Expires: ${new Date(expiryTimestamp * 1e3).toISOString()} (${expiryDays} days)`);
|
|
921
|
+
this.log("\nUse --session flag to execute RPC calls in auto mode.");
|
|
922
|
+
}
|
|
923
|
+
} catch (error) {
|
|
924
|
+
if (oldPermissionRevoked) {
|
|
925
|
+
this.logToStderr(
|
|
926
|
+
"Old permission was revoked on-chain but setup did not complete. Local session-config still references the revoked permission; run `jaw session setup` again to create a new session."
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
throw error;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
resolvePermissions(flagValue, configValue) {
|
|
933
|
+
let raw;
|
|
934
|
+
if (flagValue) {
|
|
935
|
+
if (flagValue.trimStart().startsWith("{")) {
|
|
936
|
+
try {
|
|
937
|
+
raw = JSON.parse(flagValue);
|
|
938
|
+
} catch {
|
|
939
|
+
this.error(`--permissions is not valid JSON: ${flagValue}`);
|
|
940
|
+
}
|
|
941
|
+
} else {
|
|
942
|
+
const content = fs.readFileSync(flagValue, "utf-8");
|
|
943
|
+
try {
|
|
944
|
+
raw = JSON.parse(content);
|
|
945
|
+
} catch {
|
|
946
|
+
this.error(`Permissions file at ${flagValue} is not valid JSON.`);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
} else if (configValue) {
|
|
950
|
+
raw = configValue;
|
|
951
|
+
} else {
|
|
952
|
+
this.error('Permissions required. Set via --permissions flag or add "permissions" to ~/.jaw/config.json');
|
|
953
|
+
}
|
|
954
|
+
return parsePermissionsConfig(raw);
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
export { SessionSetup as default };
|
|
959
|
+
//# sourceMappingURL=setup.js.map
|
|
960
|
+
//# sourceMappingURL=setup.js.map
|