pinomad 0.3.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.
- package/dist/{chunk-n60kkf1m.js → chunk-17p6bfvy.js} +4 -4
- package/dist/chunk-302y6by9.js +50 -0
- package/dist/{chunk-bggesvnj.js → chunk-374zqx5h.js} +15 -92
- package/dist/chunk-5785kwej.js +128 -0
- package/dist/{chunk-z4x88s0q.js → chunk-7gmkgyaz.js} +76 -3
- package/dist/chunk-837gh8nr.js +138 -0
- package/dist/{chunk-t9y2wfk5.js → chunk-akgxwg8f.js} +98 -6
- package/dist/chunk-b3f0ced1.js +147 -0
- package/dist/{chunk-syf0z68f.js → chunk-d4y99pfy.js} +2 -2
- package/dist/{chunk-3tf23vjq.js → chunk-fgx2sh7d.js} +1 -1
- package/dist/chunk-knaw1a9g.js +182 -0
- package/dist/{chunk-c155p0t0.js → chunk-myjp910s.js} +106 -7
- package/dist/{chunk-3qn8gbjb.js → chunk-n73t5xzw.js} +1 -1
- package/dist/{chunk-kxws8b83.js → chunk-prhvksh3.js} +4 -4
- package/dist/chunk-vw5yb5pd.js +1123 -0
- package/dist/chunk-xhwkx9bd.js +139 -0
- package/dist/{chunk-7fqn69ys.js → chunk-xrvmnymc.js} +2 -2
- package/dist/{chunk-tfqc1djy.js → chunk-z5pmcscx.js} +530 -242
- package/dist/pinomad.js +14 -9
- package/package.json +1 -1
- package/web/assets/index-8mNNPXPT.js +383 -0
- package/web/assets/{index-CG33GBxM.css → index-BFJJgGGo.css} +1 -1
- package/web/index.html +2 -2
- package/dist/chunk-4dj2vb8t.js +0 -284
- package/dist/chunk-fxaewke2.js +0 -113
- package/web/assets/index-CBXdQLua.js +0 -383
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
import {
|
|
3
3
|
restartService,
|
|
4
4
|
serviceManager
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-akgxwg8f.js";
|
|
6
6
|
import {
|
|
7
7
|
globalPrefixOf,
|
|
8
8
|
packageDir,
|
|
9
9
|
packagedVersion
|
|
10
|
-
} from "./chunk-
|
|
11
|
-
import"./chunk-
|
|
12
|
-
import"./chunk-
|
|
10
|
+
} from "./chunk-fgx2sh7d.js";
|
|
11
|
+
import"./chunk-374zqx5h.js";
|
|
12
|
+
import"./chunk-myjp910s.js";
|
|
13
13
|
import"./chunk-cjfmfyjb.js";
|
|
14
14
|
import"./chunk-df32avw6.js";
|
|
15
15
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// apps/host/src/server-config.ts
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
var SERVER_CONFIG_FILE = "server.json";
|
|
6
|
+
var normalizeOrigin = (origin) => origin.replace(/\/+$/, "");
|
|
7
|
+
async function readServerConfig(dataDir) {
|
|
8
|
+
let text;
|
|
9
|
+
try {
|
|
10
|
+
text = await readFile(join(dataDir, SERVER_CONFIG_FILE), "utf8");
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (error.code === "ENOENT")
|
|
13
|
+
return;
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
let value;
|
|
17
|
+
try {
|
|
18
|
+
value = JSON.parse(text);
|
|
19
|
+
} catch {
|
|
20
|
+
throw new Error(`${join(dataDir, SERVER_CONFIG_FILE)} is not valid JSON — delete it or run \`pinomad leave\``);
|
|
21
|
+
}
|
|
22
|
+
const origin = value?.origin;
|
|
23
|
+
if (typeof origin !== "string" || origin.trim() === "") {
|
|
24
|
+
throw new Error(`${join(dataDir, SERVER_CONFIG_FILE)} is missing a string "origin"`);
|
|
25
|
+
}
|
|
26
|
+
const binding = value.binding;
|
|
27
|
+
return {
|
|
28
|
+
origin,
|
|
29
|
+
...typeof binding === "string" && binding !== "" ? { binding } : {}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
async function writeServerConfig(dataDir, config) {
|
|
33
|
+
const path = join(dataDir, SERVER_CONFIG_FILE);
|
|
34
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
35
|
+
await writeFile(tmp, `${JSON.stringify({ origin: normalizeOrigin(config.origin), binding: randomBytes(16).toString("base64url") })}
|
|
36
|
+
`, { mode: 384 });
|
|
37
|
+
await rename(tmp, path);
|
|
38
|
+
}
|
|
39
|
+
var removeServerConfig = (dataDir) => rm(join(dataDir, SERVER_CONFIG_FILE), { force: true });
|
|
40
|
+
async function readServerBinding(dataDir) {
|
|
41
|
+
return (await readServerConfig(dataDir))?.binding;
|
|
42
|
+
}
|
|
43
|
+
function resolveRelayOrigin(flag, config) {
|
|
44
|
+
if (flag !== undefined && config !== undefined && normalizeOrigin(flag) !== normalizeOrigin(config.origin)) {
|
|
45
|
+
throw new Error(`--relay ${flag} disagrees with ${SERVER_CONFIG_FILE} (${config.origin}) — remove one`);
|
|
46
|
+
}
|
|
47
|
+
return flag ?? config?.origin;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export { normalizeOrigin, readServerConfig, writeServerConfig, removeServerConfig, readServerBinding, resolveRelayOrigin };
|
|
@@ -3,105 +3,19 @@ import {
|
|
|
3
3
|
INVALID_FRAME_CLOSE_CODE,
|
|
4
4
|
PROTOCOL,
|
|
5
5
|
UNAUTHORIZED_CLOSE_CODE,
|
|
6
|
+
checkoutOf,
|
|
6
7
|
conversationStream,
|
|
7
8
|
docStream,
|
|
9
|
+
homeOf,
|
|
8
10
|
hostStreams,
|
|
9
11
|
hostSupportsCall,
|
|
10
|
-
hostSupportsFeature
|
|
11
|
-
|
|
12
|
+
hostSupportsFeature,
|
|
13
|
+
organize
|
|
14
|
+
} from "./chunk-myjp910s.js";
|
|
12
15
|
|
|
13
16
|
// packages/protocol/src/remote-durable.ts
|
|
14
17
|
import { applyImmutable } from "@earendil-works/chord/delta";
|
|
15
18
|
|
|
16
|
-
// packages/protocol/src/organization.ts
|
|
17
|
-
function organize(index, summaries) {
|
|
18
|
-
const summaryById = new Map(summaries.map((summary) => [summary.id, summary]));
|
|
19
|
-
const nodes = new Map;
|
|
20
|
-
const topLevel = new Map;
|
|
21
|
-
for (const entry of index.conversations) {
|
|
22
|
-
if (entry.archived === true)
|
|
23
|
-
continue;
|
|
24
|
-
const home = entry.home;
|
|
25
|
-
if (home.kind === "project" && !index.projects.some((project) => project.path === home.path))
|
|
26
|
-
continue;
|
|
27
|
-
const summary = summaryById.get(entry.id);
|
|
28
|
-
if (summary === undefined)
|
|
29
|
-
continue;
|
|
30
|
-
topLevel.set(entry.id, entry);
|
|
31
|
-
nodes.set(entry.id, { summary, children: [] });
|
|
32
|
-
}
|
|
33
|
-
for (const summary of summaries) {
|
|
34
|
-
const chain = [summary];
|
|
35
|
-
let current = summary;
|
|
36
|
-
while (!topLevel.has(current.id)) {
|
|
37
|
-
const parent = current.parent === undefined ? undefined : summaryById.get(current.parent);
|
|
38
|
-
if (parent === undefined || chain.includes(parent))
|
|
39
|
-
break;
|
|
40
|
-
chain.push(parent);
|
|
41
|
-
current = parent;
|
|
42
|
-
}
|
|
43
|
-
if (!topLevel.has(current.id))
|
|
44
|
-
continue;
|
|
45
|
-
for (let i = chain.length - 1;i >= 0; i--) {
|
|
46
|
-
const member = chain[i];
|
|
47
|
-
if (!nodes.has(member.id))
|
|
48
|
-
nodes.set(member.id, { summary: member, children: [] });
|
|
49
|
-
if (i < chain.length - 1) {
|
|
50
|
-
const parent = nodes.get(chain[i + 1].id);
|
|
51
|
-
const child = nodes.get(member.id);
|
|
52
|
-
if (!parent.children.includes(child))
|
|
53
|
-
parent.children.push(child);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
const byIdAscending = (a, b) => a.summary.id - b.summary.id;
|
|
58
|
-
const nodeList = (entry) => sortChildren(nodes.get(entry.id));
|
|
59
|
-
const sortChildren = (node) => ({
|
|
60
|
-
summary: node.summary,
|
|
61
|
-
children: [...node.children].sort(byIdAscending).map(sortChildren)
|
|
62
|
-
});
|
|
63
|
-
const entries = [...topLevel.values()].sort((a, b) => b.createdAt - a.createdAt);
|
|
64
|
-
return {
|
|
65
|
-
chats: entries.filter((entry) => entry.home.kind === "chat").map(nodeList),
|
|
66
|
-
projects: [...index.projects].sort((a, b) => b.addedAt - a.addedAt).map((project) => ({
|
|
67
|
-
project,
|
|
68
|
-
conversations: entries.filter((entry) => entry.home.kind === "project" && entry.home.path === project.path).map(nodeList)
|
|
69
|
-
}))
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
function checkoutOf(index, summaries, id) {
|
|
73
|
-
const record = index.checkouts?.find((checkout) => checkout.conversationId === id);
|
|
74
|
-
if (record !== undefined)
|
|
75
|
-
return record;
|
|
76
|
-
const summaryById = new Map(summaries.map((summary) => [summary.id, summary]));
|
|
77
|
-
const seen = new Set;
|
|
78
|
-
let current = summaryById.get(id);
|
|
79
|
-
while (current !== undefined && !seen.has(current.id)) {
|
|
80
|
-
seen.add(current.id);
|
|
81
|
-
const ancestor = index.checkouts?.find((checkout) => checkout.conversationId === current.id);
|
|
82
|
-
if (ancestor !== undefined)
|
|
83
|
-
return ancestor;
|
|
84
|
-
current = current.parent === undefined ? undefined : summaryById.get(current.parent);
|
|
85
|
-
}
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
function homeOf(index, summaries, id) {
|
|
89
|
-
const entry = index.conversations.find((candidate) => candidate.id === id);
|
|
90
|
-
if (entry !== undefined)
|
|
91
|
-
return entry.home;
|
|
92
|
-
const summaryById = new Map(summaries.map((summary) => [summary.id, summary]));
|
|
93
|
-
const seen = new Set;
|
|
94
|
-
let current = summaryById.get(id);
|
|
95
|
-
while (current !== undefined && !seen.has(current.id)) {
|
|
96
|
-
seen.add(current.id);
|
|
97
|
-
const ancestor = index.conversations.find((candidate) => candidate.id === current.id);
|
|
98
|
-
if (ancestor !== undefined)
|
|
99
|
-
return ancestor.home;
|
|
100
|
-
current = current.parent === undefined ? undefined : summaryById.get(current.parent);
|
|
101
|
-
}
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
19
|
// packages/protocol/src/transport.ts
|
|
106
20
|
function webSocketTransport(url, token) {
|
|
107
21
|
return {
|
|
@@ -338,7 +252,7 @@ class RemoteClient {
|
|
|
338
252
|
Promise.all(hostStreams(hostMinor).map((stream) => this.#snapshot(stream))).then(() => {
|
|
339
253
|
this.#state = {
|
|
340
254
|
session: hello.session,
|
|
341
|
-
organized: { chats: [], projects: [] },
|
|
255
|
+
organized: { chats: [], projects: [], archived: [] },
|
|
342
256
|
models: this.#host("models") ?? hello.models,
|
|
343
257
|
defaults: this.#effectiveDefaults(hello.defaults ?? {}),
|
|
344
258
|
notices: [],
|
|
@@ -608,6 +522,9 @@ class RemoteClient {
|
|
|
608
522
|
if (!hostSupportsCall(this.#hostMinor, "updateSettings")) {
|
|
609
523
|
throw new Error("The host is too old for this (needs protocol 5.2); run `pinomad upgrade` on the host");
|
|
610
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
|
+
}
|
|
611
528
|
return this.#call("updateSettings", { patch });
|
|
612
529
|
}),
|
|
613
530
|
createAgent: (role) => this.#request(async () => {
|
|
@@ -627,6 +544,12 @@ class RemoteClient {
|
|
|
627
544
|
throw new Error("The host is too old for this (needs protocol 5.6); run `pinomad upgrade` on the host");
|
|
628
545
|
}
|
|
629
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 });
|
|
630
553
|
})
|
|
631
554
|
};
|
|
632
555
|
}
|
|
@@ -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
|
-
|
|
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 {
|
|
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 };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fromBase64Url,
|
|
3
|
+
generateKeyPair,
|
|
4
|
+
keyPairFromPrivate,
|
|
5
|
+
toBase64Url
|
|
6
|
+
} from "./chunk-7gmkgyaz.js";
|
|
7
|
+
|
|
8
|
+
// apps/host/src/devices.ts
|
|
9
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
12
|
+
import { ed25519 } from "@noble/curves/ed25519.js";
|
|
13
|
+
import { defineDoc } from "@earendil-works/pi-durable";
|
|
14
|
+
var DevicesDoc = defineDoc({
|
|
15
|
+
kind: "pinomad.devices",
|
|
16
|
+
version: 1,
|
|
17
|
+
scope: "session",
|
|
18
|
+
initial: () => ({ devices: [] })
|
|
19
|
+
});
|
|
20
|
+
async function ensureDevices(harness, context) {
|
|
21
|
+
await harness.commit(async (tx) => void await tx.doc(DevicesDoc), context);
|
|
22
|
+
}
|
|
23
|
+
var RevocationsDoc = defineDoc({
|
|
24
|
+
kind: "pinomad.revocations",
|
|
25
|
+
version: 1,
|
|
26
|
+
scope: "session",
|
|
27
|
+
initial: () => ({ revoked: [] })
|
|
28
|
+
});
|
|
29
|
+
async function admitDevice(harness, input, context) {
|
|
30
|
+
return await harness.commit(async (tx) => {
|
|
31
|
+
const devices = await tx.doc(DevicesDoc);
|
|
32
|
+
if (devices.devices.some((entry2) => entry2.publicKey === input.publicKey)) {
|
|
33
|
+
return { outcome: "registered" };
|
|
34
|
+
}
|
|
35
|
+
if (input.pair === undefined)
|
|
36
|
+
return { outcome: "refused" };
|
|
37
|
+
const offer = input.offers.consume(input.pair.secret);
|
|
38
|
+
if (offer === undefined)
|
|
39
|
+
return { outcome: "refused" };
|
|
40
|
+
const revocations = await tx.doc(RevocationsDoc);
|
|
41
|
+
if (offer.source === "account") {
|
|
42
|
+
const binding = await input.serverBinding();
|
|
43
|
+
if (revocations.revoked.includes(input.publicKey) || binding === undefined || offer.binding !== binding || !input.acceptAccountDevices()) {
|
|
44
|
+
return { outcome: "refused" };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const name = input.pair.name.trim().slice(0, 64) || "Unknown device";
|
|
48
|
+
const entry = { publicKey: input.publicKey, name, pairedAt: Date.now(), source: offer.source };
|
|
49
|
+
devices.devices.push(entry);
|
|
50
|
+
if (revocations.revoked.includes(input.publicKey)) {
|
|
51
|
+
revocations.revoked = revocations.revoked.filter((key) => key !== input.publicKey);
|
|
52
|
+
}
|
|
53
|
+
return { outcome: "admitted", entry };
|
|
54
|
+
}, context);
|
|
55
|
+
}
|
|
56
|
+
async function loadHostKey(dataDir) {
|
|
57
|
+
const path = join(dataDir, "host-key");
|
|
58
|
+
try {
|
|
59
|
+
const text = (await readFile(path, "utf8")).trim();
|
|
60
|
+
return keyPairFromPrivate(fromBase64Url(text));
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error.code !== "ENOENT")
|
|
63
|
+
throw error;
|
|
64
|
+
const pair = generateKeyPair();
|
|
65
|
+
try {
|
|
66
|
+
await writeFile(path, `${toBase64Url(pair.privateKey)}
|
|
67
|
+
`, { flag: "wx", mode: 384 });
|
|
68
|
+
} catch (race) {
|
|
69
|
+
if (race.code !== "EEXIST")
|
|
70
|
+
throw race;
|
|
71
|
+
return loadHostKey(dataDir);
|
|
72
|
+
}
|
|
73
|
+
return pair;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function loadRelayKey(dataDir) {
|
|
77
|
+
const path = join(dataDir, "relay-key");
|
|
78
|
+
try {
|
|
79
|
+
const secretKey = fromBase64Url((await readFile(path, "utf8")).trim());
|
|
80
|
+
if (secretKey.length !== 32)
|
|
81
|
+
throw new Error(`bad relay key in ${path}`);
|
|
82
|
+
return { secretKey, publicKey: ed25519.getPublicKey(secretKey) };
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error.code !== "ENOENT")
|
|
85
|
+
throw error;
|
|
86
|
+
const { secretKey, publicKey } = ed25519.keygen();
|
|
87
|
+
try {
|
|
88
|
+
await writeFile(path, `${toBase64Url(secretKey)}
|
|
89
|
+
`, { flag: "wx", mode: 384 });
|
|
90
|
+
} catch (race) {
|
|
91
|
+
if (race.code !== "EEXIST")
|
|
92
|
+
throw race;
|
|
93
|
+
return loadRelayKey(dataDir);
|
|
94
|
+
}
|
|
95
|
+
return { secretKey, publicKey };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function pairingOffers(ttlMs = 5 * 60 * 1000) {
|
|
99
|
+
const live = new Map;
|
|
100
|
+
const prune = () => {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
for (const [secret, offer] of live)
|
|
103
|
+
if (offer.expiresAt <= now)
|
|
104
|
+
live.delete(secret);
|
|
105
|
+
};
|
|
106
|
+
const mint = (offer) => {
|
|
107
|
+
prune();
|
|
108
|
+
const secret = toBase64Url(randomBytes(16));
|
|
109
|
+
const expiresAt = Date.now() + ttlMs;
|
|
110
|
+
live.set(secret, { expiresAt, ...offer });
|
|
111
|
+
return { secret, expiresAt };
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
create: (source) => mint({ source }),
|
|
115
|
+
createAccount: (binding) => mint({ source: "account", binding }),
|
|
116
|
+
consume(secret) {
|
|
117
|
+
prune();
|
|
118
|
+
const candidate = Buffer.from(secret);
|
|
119
|
+
for (const [key, offer] of live) {
|
|
120
|
+
const offerBytes = Buffer.from(key);
|
|
121
|
+
if (offerBytes.length !== candidate.length)
|
|
122
|
+
continue;
|
|
123
|
+
if (timingSafeEqual(offerBytes, candidate)) {
|
|
124
|
+
live.delete(key);
|
|
125
|
+
return offer;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
},
|
|
130
|
+
revokeAccount() {
|
|
131
|
+
for (const [key, offer] of live)
|
|
132
|
+
if (offer.source === "account")
|
|
133
|
+
live.delete(key);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export { DevicesDoc, ensureDevices, RevocationsDoc, admitDevice, loadHostKey, loadRelayKey, pairingOffers };
|