pinomad 0.2.0 → 0.4.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.
@@ -1,103 +1,21 @@
1
1
  import {
2
+ HOST_STREAM_NAMES,
2
3
  INVALID_FRAME_CLOSE_CODE,
3
4
  PROTOCOL,
4
5
  UNAUTHORIZED_CLOSE_CODE,
6
+ checkoutOf,
5
7
  conversationStream,
6
- docStream
7
- } from "./chunk-h35fermn.js";
8
+ docStream,
9
+ homeOf,
10
+ hostStreams,
11
+ hostSupportsCall,
12
+ hostSupportsFeature,
13
+ organize
14
+ } from "./chunk-myjp910s.js";
8
15
 
9
16
  // packages/protocol/src/remote-durable.ts
10
17
  import { applyImmutable } from "@earendil-works/chord/delta";
11
18
 
12
- // packages/protocol/src/organization.ts
13
- function organize(index, summaries) {
14
- const summaryById = new Map(summaries.map((summary) => [summary.id, summary]));
15
- const nodes = new Map;
16
- const topLevel = new Map;
17
- for (const entry of index.conversations) {
18
- if (entry.archived === true)
19
- continue;
20
- const home = entry.home;
21
- if (home.kind === "project" && !index.projects.some((project) => project.path === home.path))
22
- continue;
23
- const summary = summaryById.get(entry.id);
24
- if (summary === undefined)
25
- continue;
26
- topLevel.set(entry.id, entry);
27
- nodes.set(entry.id, { summary, children: [] });
28
- }
29
- for (const summary of summaries) {
30
- const chain = [summary];
31
- let current = summary;
32
- while (!topLevel.has(current.id)) {
33
- const parent = current.parent === undefined ? undefined : summaryById.get(current.parent);
34
- if (parent === undefined || chain.includes(parent))
35
- break;
36
- chain.push(parent);
37
- current = parent;
38
- }
39
- if (!topLevel.has(current.id))
40
- continue;
41
- for (let i = chain.length - 1;i >= 0; i--) {
42
- const member = chain[i];
43
- if (!nodes.has(member.id))
44
- nodes.set(member.id, { summary: member, children: [] });
45
- if (i < chain.length - 1) {
46
- const parent = nodes.get(chain[i + 1].id);
47
- const child = nodes.get(member.id);
48
- if (!parent.children.includes(child))
49
- parent.children.push(child);
50
- }
51
- }
52
- }
53
- const byIdAscending = (a, b) => a.summary.id - b.summary.id;
54
- const nodeList = (entry) => sortChildren(nodes.get(entry.id));
55
- const sortChildren = (node) => ({
56
- summary: node.summary,
57
- children: [...node.children].sort(byIdAscending).map(sortChildren)
58
- });
59
- const entries = [...topLevel.values()].sort((a, b) => b.createdAt - a.createdAt);
60
- return {
61
- chats: entries.filter((entry) => entry.home.kind === "chat").map(nodeList),
62
- projects: [...index.projects].sort((a, b) => b.addedAt - a.addedAt).map((project) => ({
63
- project,
64
- conversations: entries.filter((entry) => entry.home.kind === "project" && entry.home.path === project.path).map(nodeList)
65
- }))
66
- };
67
- }
68
- function checkoutOf(index, summaries, id) {
69
- const record = index.checkouts?.find((checkout) => checkout.conversationId === id);
70
- if (record !== undefined)
71
- return record;
72
- const summaryById = new Map(summaries.map((summary) => [summary.id, summary]));
73
- const seen = new Set;
74
- let current = summaryById.get(id);
75
- while (current !== undefined && !seen.has(current.id)) {
76
- seen.add(current.id);
77
- const ancestor = index.checkouts?.find((checkout) => checkout.conversationId === current.id);
78
- if (ancestor !== undefined)
79
- return ancestor;
80
- current = current.parent === undefined ? undefined : summaryById.get(current.parent);
81
- }
82
- return;
83
- }
84
- function homeOf(index, summaries, id) {
85
- const entry = index.conversations.find((candidate) => candidate.id === id);
86
- if (entry !== undefined)
87
- return entry.home;
88
- const summaryById = new Map(summaries.map((summary) => [summary.id, summary]));
89
- const seen = new Set;
90
- let current = summaryById.get(id);
91
- while (current !== undefined && !seen.has(current.id)) {
92
- seen.add(current.id);
93
- const ancestor = index.conversations.find((candidate) => candidate.id === current.id);
94
- if (ancestor !== undefined)
95
- return ancestor.home;
96
- current = current.parent === undefined ? undefined : summaryById.get(current.parent);
97
- }
98
- return;
99
- }
100
-
101
19
  // packages/protocol/src/transport.ts
102
20
  function webSocketTransport(url, token) {
103
21
  return {
@@ -147,7 +65,7 @@ class RemoteClient {
147
65
  #options;
148
66
  #listeners = new Set;
149
67
  #values = new Map;
150
- #wanted = new Set(["conversations", "index", "devices", "mcp"]);
68
+ #wanted = new Set;
151
69
  #fresh = new Set;
152
70
  #docs = [];
153
71
  #toolPresentations = {};
@@ -160,6 +78,10 @@ class RemoteClient {
160
78
  #side;
161
79
  #nextCall = 1;
162
80
  #nextNotice = 1;
81
+ #hostMinor = 0;
82
+ #helloModels = [];
83
+ #helloDefaults = {};
84
+ #hostVersion;
163
85
  #attempt = 0;
164
86
  #closed = false;
165
87
  #notifying = false;
@@ -249,10 +171,25 @@ class RemoteClient {
249
171
  this.#connection?.close();
250
172
  return;
251
173
  }
174
+ const hostMinor = announced !== null && typeof announced === "object" && typeof announced.minor === "number" ? announced.minor : 0;
175
+ this.#hostMinor = hostMinor;
252
176
  this.#attempt = 0;
253
177
  this.#docs = frame.docs;
254
178
  this.#toolPresentations = frame.toolPresentations;
179
+ this.#helloModels = frame.models;
180
+ this.#helloDefaults = frame.defaults ?? {};
181
+ this.#hostVersion = frame.hostVersion;
255
182
  this.#fresh.clear();
183
+ const covered = new Set(hostStreams(hostMinor));
184
+ for (const name of HOST_STREAM_NAMES) {
185
+ if (covered.has(name)) {
186
+ this.#wanted.add(name);
187
+ } else {
188
+ this.#wanted.delete(name);
189
+ this.#values.delete(name);
190
+ this.#fresh.delete(name);
191
+ }
192
+ }
256
193
  if (this.#current !== undefined)
257
194
  for (const stream of this.#conversationStreams(this.#current))
258
195
  this.#wanted.add(stream);
@@ -264,13 +201,13 @@ class RemoteClient {
264
201
  if (this.#state !== undefined) {
265
202
  this.#update({
266
203
  session: frame.session,
267
- models: frame.models,
268
204
  toolPresentations: frame.toolPresentations,
269
- defaults: frame.defaults ?? {},
270
- connection: "connected"
205
+ connection: "connected",
206
+ host: this.#hostInfo()
271
207
  });
208
+ this.#refresh();
272
209
  } else
273
- this.#awaitFirstView(frame);
210
+ this.#awaitFirstView(frame, hostMinor);
274
211
  return;
275
212
  }
276
213
  case "snapshot":
@@ -311,19 +248,23 @@ class RemoteClient {
311
248
  return;
312
249
  }
313
250
  }
314
- #awaitFirstView(hello) {
315
- Promise.all([this.#snapshot("conversations"), this.#snapshot("index"), this.#snapshot("devices"), this.#snapshot("mcp")]).then(() => {
251
+ #awaitFirstView(hello, hostMinor) {
252
+ Promise.all(hostStreams(hostMinor).map((stream) => this.#snapshot(stream))).then(() => {
316
253
  this.#state = {
317
254
  session: hello.session,
318
- organized: { chats: [], projects: [] },
319
- models: hello.models,
320
- defaults: hello.defaults ?? {},
255
+ organized: { chats: [], projects: [], archived: [] },
256
+ models: this.#host("models") ?? hello.models,
257
+ defaults: this.#effectiveDefaults(hello.defaults ?? {}),
321
258
  notices: [],
322
259
  connection: "connected",
323
260
  docs: [],
324
261
  toolPresentations: hello.toolPresentations,
325
262
  devices: [],
326
- mcp: null
263
+ mcp: null,
264
+ auth: null,
265
+ settings: null,
266
+ agents: null,
267
+ host: this.#hostInfo()
327
268
  };
328
269
  this.#refresh();
329
270
  this.#resolveReady({ view: this.#viewSource(), controller: this.#controller(), close: () => this.#close() });
@@ -360,8 +301,26 @@ class RemoteClient {
360
301
  this.#send({ type: "unsubscribe", stream });
361
302
  }
362
303
  }
304
+ #host(name) {
305
+ return this.#values.get(name);
306
+ }
307
+ #hostInfo() {
308
+ return {
309
+ protocol: { major: PROTOCOL.major, minor: this.#hostMinor },
310
+ ...this.#hostVersion === undefined ? {} : { version: this.#hostVersion }
311
+ };
312
+ }
313
+ #effectiveDefaults(fallback) {
314
+ const settings = this.#host("settings");
315
+ if (settings === undefined)
316
+ return fallback;
317
+ return {
318
+ ...settings.defaultModel === null ? {} : { model: settings.defaultModel },
319
+ ...settings.defaultThinkingLevel === null ? {} : { thinkingLevel: settings.defaultThinkingLevel }
320
+ };
321
+ }
363
322
  #descendsFrom(id, ancestor) {
364
- const summaries = this.#values.get("conversations") ?? [];
323
+ const summaries = this.#host("conversations") ?? [];
365
324
  const parentOf = new Map(summaries.map((summary) => [summary.id, summary.parent]));
366
325
  let cursor = id;
367
326
  for (let depth = 0;cursor !== undefined && depth < 8; depth++) {
@@ -383,12 +342,13 @@ class RemoteClient {
383
342
  const current = this.#current;
384
343
  const side = this.#side;
385
344
  const conversation = current === undefined ? undefined : this.#values.get(conversationStream(current));
386
- const summaries = this.#values.get("conversations") ?? [];
387
- const index = this.#values.get("index") ?? { projects: [], conversations: [] };
388
- const devices = this.#values.get("devices")?.devices ?? [];
345
+ const summaries = this.#host("conversations") ?? [];
346
+ const index = this.#host("index") ?? { projects: [], conversations: [] };
347
+ const devices = this.#host("devices")?.devices ?? [];
389
348
  const tasks = this.#wanted.has("tasks") ? this.#values.get("tasks") : undefined;
390
349
  this.#update({
391
350
  conversation,
351
+ models: this.#host("models") ?? this.#helloModels,
392
352
  organized: organize(index, summaries),
393
353
  home: current === undefined ? undefined : homeOf(index, summaries, current),
394
354
  checkout: current === undefined ? undefined : checkoutOf(index, summaries, current),
@@ -400,7 +360,11 @@ class RemoteClient {
400
360
  docs: this.#docsOf(side)
401
361
  },
402
362
  devices,
403
- mcp: this.#values.get("mcp") ?? null
363
+ mcp: this.#host("mcp") ?? null,
364
+ auth: this.#host("auth") ?? null,
365
+ settings: this.#host("settings") ?? null,
366
+ agents: this.#host("agents") ?? null,
367
+ defaults: this.#effectiveDefaults(this.#helloDefaults)
404
368
  });
405
369
  }
406
370
  #update(patch) {
@@ -435,6 +399,11 @@ class RemoteClient {
435
399
  this.#commands = this.#commands.then(operation).then(() => {}, (error) => this.#notice("error", error instanceof Error ? error.message : String(error)));
436
400
  return this.#commands;
437
401
  }
402
+ #request(operation) {
403
+ const request = this.#commands.then(operation);
404
+ this.#commands = request.then(() => {}, () => {});
405
+ return request;
406
+ }
438
407
  #viewSource() {
439
408
  return {
440
409
  current: () => this.#state,
@@ -452,16 +421,33 @@ class RemoteClient {
452
421
  return this.#current;
453
422
  };
454
423
  return {
455
- addProject: (path) => this.#command(() => this.#call("addProject", { path })),
424
+ addProject: (path) => this.#request(() => this.#call("addProject", { path })),
425
+ listFolders: (path) => this.#request(() => {
426
+ if (!hostSupportsCall(this.#hostMinor, "listFolders")) {
427
+ throw new Error("The host is too old for this (needs protocol 5.3); run `pinomad upgrade` on the host");
428
+ }
429
+ return this.#call("listFolders", path === undefined ? {} : { path });
430
+ }),
456
431
  removeProject: (path) => this.#command(() => this.#call("removeProject", { path })),
432
+ renameConversation: (id, title) => this.#request(async () => {
433
+ if (!hostSupportsCall(this.#hostMinor, "renameConversation")) {
434
+ throw new Error("The host is too old for this (needs protocol 5.4); run `pinomad upgrade` on the host");
435
+ }
436
+ await this.#call("renameConversation", { conversationId: id, title });
437
+ }),
457
438
  createConversation: (home, text, options) => this.#command(async () => {
439
+ const images = options?.images ?? [];
440
+ if (images.length > 0 && !hostSupportsFeature(this.#hostMinor, "images")) {
441
+ throw new Error("The host is too old for images (needs protocol 5.5); run `pinomad upgrade` on the host");
442
+ }
458
443
  const { conversationId: conversationId2 } = await this.#call("createConversation", {
459
444
  home,
460
445
  text,
461
446
  requestId: newRequestId(),
462
447
  ...options?.checkout === undefined ? {} : { checkout: options.checkout },
463
448
  ...options?.model === undefined ? {} : { model: options.model },
464
- ...options?.thinkingLevel === undefined ? {} : { thinkingLevel: options.thinkingLevel }
449
+ ...options?.thinkingLevel === undefined ? {} : { thinkingLevel: options.thinkingLevel },
450
+ ...images.length === 0 ? {} : { images }
465
451
  });
466
452
  await this.#switch(conversationId2);
467
453
  }),
@@ -472,7 +458,19 @@ class RemoteClient {
472
458
  if (archived && this.#side !== undefined && this.#descendsFrom(this.#side, id))
473
459
  await this.#showSide(undefined);
474
460
  }),
475
- submit: (text, whenBusy, target) => this.#command(() => this.#call("submit", { conversationId: target ?? conversationId(), text, whenBusy, requestId: newRequestId() })),
461
+ submit: (text, whenBusy, options) => this.#command(() => {
462
+ const images = options?.images ?? [];
463
+ if (images.length > 0 && !hostSupportsFeature(this.#hostMinor, "images")) {
464
+ throw new Error("The host is too old for images (needs protocol 5.5); run `pinomad upgrade` on the host");
465
+ }
466
+ return this.#call("submit", {
467
+ conversationId: options?.target ?? conversationId(),
468
+ text,
469
+ whenBusy,
470
+ requestId: options?.requestId ?? newRequestId(),
471
+ ...images.length === 0 ? {} : { images }
472
+ });
473
+ }),
476
474
  compact: (instructions) => this.#command(() => this.#call("compact", { conversationId: conversationId(), ...instructions === undefined ? {} : { instructions } })),
477
475
  abort: (target) => {
478
476
  const targetConversation = target ?? this.#current;
@@ -511,17 +509,48 @@ class RemoteClient {
511
509
  await this.#call("submit", { conversationId: forked, text: prompt, whenBusy: "followUp", requestId: newRequestId() });
512
510
  }),
513
511
  answer: (kind, requestId, answers, target) => this.#command(() => this.#call("answer", { conversationId: target ?? conversationId(), kind, requestId, answers })),
514
- changes: () => {
515
- const call = this.#commands.then(() => this.#call("changes", { conversationId: conversationId() }));
516
- this.#commands = call.then(() => {}, () => {});
517
- return call;
518
- },
519
- createPairing: () => {
520
- const pairing = this.#commands.then(() => this.#call("createPairing", {}));
521
- this.#commands = pairing.then(() => {}, () => {});
522
- return pairing;
523
- },
524
- revokeDevice: (publicKey) => this.#command(() => this.#call("revokeDevice", { publicKey }))
512
+ changes: () => this.#request(() => this.#call("changes", { conversationId: conversationId() })),
513
+ createPairing: () => this.#request(() => this.#call("createPairing", {})),
514
+ revokeDevice: (publicKey) => this.#command(() => this.#call("revokeDevice", { publicKey })),
515
+ logout: (providerId) => this.#command(() => {
516
+ if (!hostSupportsCall(this.#hostMinor, "logout")) {
517
+ throw new Error("The host is too old for this (needs protocol 5.1); run `pinomad upgrade` on the host");
518
+ }
519
+ return this.#call("logout", { providerId });
520
+ }),
521
+ updateSettings: (patch) => this.#command(() => {
522
+ if (!hostSupportsCall(this.#hostMinor, "updateSettings")) {
523
+ throw new Error("The host is too old for this (needs protocol 5.2); run `pinomad upgrade` on the host");
524
+ }
525
+ if (patch.acceptAccountDevices !== undefined && !hostSupportsFeature(this.#hostMinor, "acceptAccountDevices")) {
526
+ throw new Error("The host is too old for this (needs protocol 5.8); run `pinomad upgrade` on the host");
527
+ }
528
+ return this.#call("updateSettings", { patch });
529
+ }),
530
+ createAgent: (role) => this.#request(async () => {
531
+ if (!hostSupportsCall(this.#hostMinor, "createAgent")) {
532
+ throw new Error("The host is too old for this (needs protocol 5.6); run `pinomad upgrade` on the host");
533
+ }
534
+ await this.#call("createAgent", { role });
535
+ }),
536
+ updateAgent: (role) => this.#request(async () => {
537
+ if (!hostSupportsCall(this.#hostMinor, "updateAgent")) {
538
+ throw new Error("The host is too old for this (needs protocol 5.6); run `pinomad upgrade` on the host");
539
+ }
540
+ await this.#call("updateAgent", { role });
541
+ }),
542
+ deleteAgent: (name) => this.#request(async () => {
543
+ if (!hostSupportsCall(this.#hostMinor, "deleteAgent")) {
544
+ throw new Error("The host is too old for this (needs protocol 5.6); run `pinomad upgrade` on the host");
545
+ }
546
+ await this.#call("deleteAgent", { name });
547
+ }),
548
+ setPushToken: (token) => this.#request(async () => {
549
+ if (!hostSupportsCall(this.#hostMinor, "setPushToken")) {
550
+ throw new Error("The host is too old for this (needs protocol 5.7); run `pinomad upgrade` on the host");
551
+ }
552
+ await this.#call("setPushToken", { token });
553
+ })
525
554
  };
526
555
  }
527
556
  async#switch(id) {
@@ -0,0 +1,128 @@
1
+ import {
2
+ relayWsBase
3
+ } from "./chunk-knaw1a9g.js";
4
+ import {
5
+ RELAY_CLOSE,
6
+ decodeRelayToHost,
7
+ encodeRelayFrame,
8
+ relayHostId,
9
+ signRelayChallenge,
10
+ signRelayChallengeV2,
11
+ toBase64Url
12
+ } from "./chunk-7gmkgyaz.js";
13
+
14
+ // apps/host/src/cli/server-ops.ts
15
+ import { WebSocket } from "ws";
16
+
17
+ // packages/client/src/account.ts
18
+ var fallbackFetch = (url, init) => fetch(url, init);
19
+ async function probeAccounts(origin, fetchImpl = fallbackFetch) {
20
+ const timeout = new AbortController;
21
+ const timer = setTimeout(() => timeout.abort(), 5000);
22
+ try {
23
+ const response = await fetchImpl(`${origin.replace(/\/+$/, "")}/api/me`, { signal: timeout.signal });
24
+ if (response.status === 401)
25
+ return "enabled";
26
+ if (response.status === 200) {
27
+ const text = await response.text();
28
+ let body;
29
+ try {
30
+ body = JSON.parse(text);
31
+ } catch {
32
+ return "disabled";
33
+ }
34
+ return typeof body?.user === "object" && body.user !== null ? "enabled" : "disabled";
35
+ }
36
+ return response.status === 404 ? "disabled" : "unreachable";
37
+ } catch {
38
+ return "unreachable";
39
+ } finally {
40
+ clearTimeout(timer);
41
+ }
42
+ }
43
+ function isSecureServerOrigin(origin) {
44
+ let url;
45
+ try {
46
+ url = new URL(origin);
47
+ } catch {
48
+ return false;
49
+ }
50
+ const host = url.hostname;
51
+ const loopback = host === "localhost" || host.endsWith(".localhost") || host.startsWith("127.") || host === "::1" || host === "[::1]";
52
+ return url.protocol === "https:" || url.protocol === "http:" && loopback;
53
+ }
54
+
55
+ // apps/host/src/cli/server-ops.ts
56
+ function assertSecureServerOrigin(origin) {
57
+ try {
58
+ new URL(origin);
59
+ } catch {
60
+ throw new Error(`not a URL: ${origin}`);
61
+ }
62
+ if (isSecureServerOrigin(origin))
63
+ return;
64
+ throw new Error(`account operations need an https origin (loopback http allowed for development) — got ${origin}`);
65
+ }
66
+ var probeServerAccounts = (origin, fetchImpl = fetch) => probeAccounts(origin, fetchImpl);
67
+ var closeReason = (code, origin) => {
68
+ switch (code) {
69
+ case RELAY_CLOSE.badSignature:
70
+ return `the server rejected this host's registration signature (${code})`;
71
+ case RELAY_CLOSE.hostNotAllowed:
72
+ return `the server does not know this host (${code}) — a pre-accounts server can't do join/leave; upgrade it first`;
73
+ default:
74
+ return `the connection to ${origin} closed (${code})`;
75
+ }
76
+ };
77
+ async function runServerOperation(options) {
78
+ const origin = options.origin.replace(/\/+$/, "");
79
+ assertSecureServerOrigin(origin);
80
+ const hostId = relayHostId(options.signingKey.publicKey);
81
+ const timeoutMs = options.timeoutMs ?? 1e4;
82
+ const socket = new WebSocket(`${relayWsBase(origin)}/host`, { handshakeTimeout: timeoutMs });
83
+ return await new Promise((resolve, reject) => {
84
+ const finish = (done) => {
85
+ clearTimeout(timer);
86
+ socket.close();
87
+ done();
88
+ };
89
+ const timer = setTimeout(() => finish(() => reject(new Error(`no answer from ${origin} within ${timeoutMs / 1000}s`))), timeoutMs);
90
+ socket.once("error", () => {});
91
+ socket.once("close", (code) => finish(() => reject(new Error(closeReason(code, origin)))));
92
+ socket.on("message", (data, isBinary) => {
93
+ let frame;
94
+ try {
95
+ if (isBinary)
96
+ throw new Error("binary frame");
97
+ frame = decodeRelayToHost(data.toString());
98
+ } catch {
99
+ finish(() => reject(new Error(`${origin} is not speaking the pinomad relay protocol`)));
100
+ return;
101
+ }
102
+ if (frame.t === "challenge") {
103
+ const noisePublicKey = toBase64Url(options.noisePublicKey);
104
+ const challenge = { relayOrigin: origin, hostId, nonce: frame.nonce };
105
+ socket.send(encodeRelayFrame({
106
+ t: "register",
107
+ hostId,
108
+ signature: signRelayChallenge(options.signingKey.secretKey, challenge),
109
+ signatureV2: signRelayChallengeV2(options.signingKey.secretKey, { ...challenge, noisePublicKey }),
110
+ noisePublicKey,
111
+ op: options.op,
112
+ ...options.joinToken === undefined ? {} : { joinToken: options.joinToken },
113
+ ...options.name === undefined ? {} : { name: options.name }
114
+ }));
115
+ return;
116
+ }
117
+ if (frame.t === "registered") {
118
+ finish(() => reject(new Error(`${origin} ignored the operation — the server is too old for accounts; upgrade it first`)));
119
+ return;
120
+ }
121
+ if (frame.t === "incoming" || frame.t === "pairing-request")
122
+ return;
123
+ finish(() => resolve(frame));
124
+ });
125
+ });
126
+ }
127
+
128
+ export { assertSecureServerOrigin, probeServerAccounts, runServerOperation };
@@ -290,6 +290,7 @@ function encodeRelayFrame(frame) {
290
290
  return JSON.stringify(frame);
291
291
  }
292
292
  var isString2 = (value) => typeof value === "string";
293
+ var isBoundedString = (value, max = 64) => isString2(value) && value.length > 0 && value.length <= max;
293
294
  function decodeJson2(text) {
294
295
  try {
295
296
  return JSON.parse(text);
@@ -310,16 +311,71 @@ function decodeRelayToHost(text) {
310
311
  if (!isString2(value.connId))
311
312
  throw new Error("malformed incoming");
312
313
  return { t: "incoming", connId: value.connId };
314
+ case "pairing-request":
315
+ if (!isBoundedString(value.requestId))
316
+ throw new Error("malformed pairing-request");
317
+ return { t: "pairing-request", requestId: value.requestId };
318
+ case "joined":
319
+ if (!isString2(value.username))
320
+ throw new Error("malformed joined");
321
+ return { t: "joined", username: value.username };
322
+ case "left":
323
+ return { t: "left" };
324
+ case "refused":
325
+ if (!isString2(value.reason))
326
+ throw new Error("malformed refused");
327
+ return { t: "refused", reason: value.reason };
313
328
  default:
314
329
  throw new Error("unknown relay frame");
315
330
  }
316
331
  }
317
332
  function decodeHostToRelay(text) {
318
333
  const value = decodeJson2(text);
319
- if (value?.t !== "register" || !isString2(value.hostId) || !isString2(value.signature)) {
334
+ switch (value?.t) {
335
+ case "register":
336
+ return decodeRegister(value);
337
+ case "pairing":
338
+ if (!isBoundedString(value.requestId) || !isBoundedString(value.secret) || typeof value.expiresAt !== "number" || !Number.isFinite(value.expiresAt)) {
339
+ throw new Error("malformed pairing");
340
+ }
341
+ return { t: "pairing", requestId: value.requestId, secret: value.secret, expiresAt: value.expiresAt };
342
+ case "pairing-refused":
343
+ if (!isBoundedString(value.requestId) || !isBoundedString(value.reason)) {
344
+ throw new Error("malformed pairing-refused");
345
+ }
346
+ return { t: "pairing-refused", requestId: value.requestId, reason: value.reason };
347
+ default:
348
+ throw new Error("unknown host frame");
349
+ }
350
+ }
351
+ function decodeRegister(value) {
352
+ if (value.t !== "register" || !isString2(value.hostId) || !isString2(value.signature)) {
353
+ throw new Error("malformed register");
354
+ }
355
+ if (value.op !== undefined && value.op !== "join" && value.op !== "leave")
356
+ throw new Error("malformed register");
357
+ if (value.signatureV2 !== undefined && !isString2(value.signatureV2))
358
+ throw new Error("malformed register");
359
+ if (value.noisePublicKey !== undefined && !isString2(value.noisePublicKey))
360
+ throw new Error("malformed register");
361
+ if (value.joinToken !== undefined && !isString2(value.joinToken))
362
+ throw new Error("malformed register");
363
+ if (value.name !== undefined && !isString2(value.name))
364
+ throw new Error("malformed register");
365
+ if (value.features !== undefined && (!Array.isArray(value.features) || value.features.length > 16 || !value.features.every((feature) => isString2(feature) && feature.length <= 64))) {
320
366
  throw new Error("malformed register");
321
367
  }
322
- return { t: "register", hostId: value.hostId, signature: value.signature };
368
+ return {
369
+ t: "register",
370
+ hostId: value.hostId,
371
+ signature: value.signature,
372
+ ...value.signatureV2 === undefined ? {} : { signatureV2: value.signatureV2 },
373
+ ...value.noisePublicKey === undefined ? {} : { noisePublicKey: value.noisePublicKey },
374
+ ...value.op === undefined ? {} : { op: value.op },
375
+ ...value.joinToken === undefined ? {} : { joinToken: value.joinToken },
376
+ ...value.name === undefined ? {} : { name: value.name },
377
+ ...value.features === undefined ? {} : { features: value.features }
378
+ };
323
379
  }
324
380
  function relayHostId(publicKey) {
325
381
  if (publicKey.length !== 32)
@@ -343,5 +399,22 @@ function verifyRelayChallenge(signature, args) {
343
399
  return false;
344
400
  }
345
401
  }
402
+ function relayChallengeMessageV2(args) {
403
+ return utf82.encode(`pinomad-relay/2
404
+ ${args.relayOrigin}
405
+ ${args.hostId}
406
+ ${args.nonce}
407
+ ${args.noisePublicKey}`);
408
+ }
409
+ function signRelayChallengeV2(secretKey, args) {
410
+ return toBase64Url(ed25519.sign(relayChallengeMessageV2(args), secretKey));
411
+ }
412
+ function verifyRelayChallengeV2(signature, args) {
413
+ try {
414
+ return ed25519.verify(fromBase64Url(signature), relayChallengeMessageV2(args), fromBase64Url(args.hostId));
415
+ } catch {
416
+ return false;
417
+ }
418
+ }
346
419
 
347
- export { generateKeyPair, keyPairFromPrivate, respondIK, SECURE_PROLOGUE, encodeHostHello, decodeClientHello, toBase64Url, fromBase64Url, SecureSession, RELAY_CLOSE, encodeRelayFrame, decodeRelayToHost, decodeHostToRelay, relayHostId, signRelayChallenge, verifyRelayChallenge };
420
+ export { generateKeyPair, keyPairFromPrivate, respondIK, SECURE_PROLOGUE, encodeHostHello, decodeClientHello, toBase64Url, fromBase64Url, SecureSession, RELAY_CLOSE, encodeRelayFrame, decodeRelayToHost, decodeHostToRelay, relayHostId, signRelayChallenge, verifyRelayChallenge, signRelayChallengeV2, verifyRelayChallengeV2 };