@roamcode.ai/server 1.0.23 → 1.2.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/container/relay.js +2028 -0
- package/dist/health-watchdog.js +87 -0
- package/dist/index.d.ts +1821 -24
- package/dist/index.js +18902 -3853
- package/dist/managed-update-helper.js +4 -1
- package/dist/relay-start.d.ts +130 -0
- package/dist/relay-start.js +2023 -0
- package/dist/start.d.ts +504 -19
- package/dist/start.js +17559 -4662
- package/package.json +6 -5
|
@@ -0,0 +1,2023 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/relay-start.ts
|
|
4
|
+
import {
|
|
5
|
+
closeSync as closeSync2,
|
|
6
|
+
constants as constants2,
|
|
7
|
+
fstatSync as fstatSync2,
|
|
8
|
+
lstatSync as lstatSync2,
|
|
9
|
+
openSync as openSync2,
|
|
10
|
+
readFileSync as readFileSync2,
|
|
11
|
+
readdirSync,
|
|
12
|
+
realpathSync
|
|
13
|
+
} from "fs";
|
|
14
|
+
import { join as join2, resolve } from "path";
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
16
|
+
|
|
17
|
+
// src/relay-broker.ts
|
|
18
|
+
import { randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
19
|
+
import Fastify from "fastify";
|
|
20
|
+
import websocket from "@fastify/websocket";
|
|
21
|
+
|
|
22
|
+
// src/relay-account-store.ts
|
|
23
|
+
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
|
24
|
+
import { createRequire } from "module";
|
|
25
|
+
var require2 = createRequire(import.meta.url);
|
|
26
|
+
var RelayAccountRevisionConflictError = class extends Error {
|
|
27
|
+
constructor(current) {
|
|
28
|
+
super("relay account revision conflict");
|
|
29
|
+
this.current = current;
|
|
30
|
+
this.name = "RelayAccountRevisionConflictError";
|
|
31
|
+
}
|
|
32
|
+
current;
|
|
33
|
+
};
|
|
34
|
+
var PLAN_DEFAULTS = {
|
|
35
|
+
free: { maxRoutes: 3, maxDevicesPerRoute: 16 },
|
|
36
|
+
team: { maxRoutes: 25, maxDevicesPerRoute: 64 },
|
|
37
|
+
enterprise: { maxRoutes: 500, maxDevicesPerRoute: 500 }
|
|
38
|
+
};
|
|
39
|
+
var UNSAFE_TERMINAL_TEXT = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
|
|
40
|
+
function safeId(value) {
|
|
41
|
+
if (typeof value !== "string" || !/^rra_[A-Za-z0-9_-]{16,128}$/.test(value)) {
|
|
42
|
+
throw new Error("invalid relay account id");
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
function safeLabel(value) {
|
|
47
|
+
if (typeof value !== "string") throw new Error("relay account label is required");
|
|
48
|
+
const label = value.trim().replace(/\s+/g, " ");
|
|
49
|
+
if (!label || label.length > 120 || UNSAFE_TERMINAL_TEXT.test(label)) {
|
|
50
|
+
throw new Error("invalid relay account label");
|
|
51
|
+
}
|
|
52
|
+
return label;
|
|
53
|
+
}
|
|
54
|
+
function safeCredential(value) {
|
|
55
|
+
if (typeof value !== "string" || !/^rrk_[A-Za-z0-9_-]{43}$/.test(value)) {
|
|
56
|
+
throw new Error("invalid relay account credential");
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
function digest(label, credential) {
|
|
61
|
+
return createHash("sha256").update(label).update("\0").update(safeCredential(credential)).digest("base64url");
|
|
62
|
+
}
|
|
63
|
+
function relayAccountCredentialHash(credential) {
|
|
64
|
+
return `sha256:${digest("roamcode-relay-account-credential-v1", credential)}`;
|
|
65
|
+
}
|
|
66
|
+
function relayAccountCredentialLookup(credential) {
|
|
67
|
+
return `lookup:${digest("roamcode-relay-account-lookup-v1", credential)}`;
|
|
68
|
+
}
|
|
69
|
+
function generateRelayAccountCredential() {
|
|
70
|
+
return `rrk_${randomBytes(32).toString("base64url")}`;
|
|
71
|
+
}
|
|
72
|
+
function safeHash(value) {
|
|
73
|
+
if (typeof value !== "string" || !/^sha256:[A-Za-z0-9_-]{43}$/.test(value)) {
|
|
74
|
+
throw new Error("invalid relay account credential hash");
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
function safeLookup(value) {
|
|
79
|
+
if (typeof value !== "string" || !/^lookup:[A-Za-z0-9_-]{43}$/.test(value)) {
|
|
80
|
+
throw new Error("invalid relay account credential lookup");
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
function credentialMaterial(input) {
|
|
85
|
+
if (typeof input === "string") {
|
|
86
|
+
return {
|
|
87
|
+
credentialHash: relayAccountCredentialHash(input),
|
|
88
|
+
credentialLookup: relayAccountCredentialLookup(input)
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if ("credential" in input && input.credential !== void 0) {
|
|
92
|
+
if (input.credentialHash !== void 0 || input.credentialLookup !== void 0) {
|
|
93
|
+
throw new Error("relay account credential must be raw or pre-hashed, not both");
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
credentialHash: relayAccountCredentialHash(input.credential),
|
|
97
|
+
credentialLookup: relayAccountCredentialLookup(input.credential)
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
credentialHash: safeHash(input.credentialHash),
|
|
102
|
+
credentialLookup: safeLookup(input.credentialLookup)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function hashMatches(expected, credential) {
|
|
106
|
+
try {
|
|
107
|
+
const left = Buffer.from(safeHash(expected));
|
|
108
|
+
const right = Buffer.from(relayAccountCredentialHash(credential));
|
|
109
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function safePlan(value) {
|
|
115
|
+
if (value !== "free" && value !== "team" && value !== "enterprise") throw new Error("invalid relay account plan");
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
function boundedLimit(value, fallback, maximum, field) {
|
|
119
|
+
const candidate = value === void 0 ? fallback : value;
|
|
120
|
+
if (!Number.isSafeInteger(candidate) || candidate < 1 || candidate > maximum) {
|
|
121
|
+
throw new Error(`invalid relay account ${field}`);
|
|
122
|
+
}
|
|
123
|
+
return candidate;
|
|
124
|
+
}
|
|
125
|
+
function safeStatus(value) {
|
|
126
|
+
if (value !== "active" && value !== "suspended" && value !== "deleted") {
|
|
127
|
+
throw new Error("invalid relay account status");
|
|
128
|
+
}
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
function clone(record) {
|
|
132
|
+
return { ...record };
|
|
133
|
+
}
|
|
134
|
+
function createMemoryStore(options) {
|
|
135
|
+
const accounts = /* @__PURE__ */ new Map();
|
|
136
|
+
const lookup = /* @__PURE__ */ new Map();
|
|
137
|
+
const generateAccountId = options.generateAccountId ?? (() => `rra_${randomBytes(18).toString("base64url")}`);
|
|
138
|
+
const verifyCredential = (credential) => {
|
|
139
|
+
let credentialLookup;
|
|
140
|
+
try {
|
|
141
|
+
credentialLookup = relayAccountCredentialLookup(credential);
|
|
142
|
+
} catch {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const id = lookup.get(credentialLookup);
|
|
146
|
+
const record = id ? accounts.get(id) : void 0;
|
|
147
|
+
return record && record.status !== "deleted" && hashMatches(record.credentialHash, credential) ? clone(record) : void 0;
|
|
148
|
+
};
|
|
149
|
+
return {
|
|
150
|
+
mode: "memory",
|
|
151
|
+
createAccount(input, now = Date.now()) {
|
|
152
|
+
const id = safeId(generateAccountId());
|
|
153
|
+
if (accounts.has(id)) throw new Error("relay account already exists");
|
|
154
|
+
const plan = safePlan(input.plan ?? "free");
|
|
155
|
+
const { credentialHash, credentialLookup } = credentialMaterial(input);
|
|
156
|
+
if (lookup.has(credentialLookup)) throw new Error("relay account credential already exists");
|
|
157
|
+
const defaults = PLAN_DEFAULTS[plan];
|
|
158
|
+
const record = {
|
|
159
|
+
id,
|
|
160
|
+
label: safeLabel(input.label),
|
|
161
|
+
status: "active",
|
|
162
|
+
plan,
|
|
163
|
+
maxRoutes: boundedLimit(input.maxRoutes, defaults.maxRoutes, 1e4, "route limit"),
|
|
164
|
+
maxDevicesPerRoute: boundedLimit(
|
|
165
|
+
input.maxDevicesPerRoute,
|
|
166
|
+
defaults.maxDevicesPerRoute,
|
|
167
|
+
1e5,
|
|
168
|
+
"device limit"
|
|
169
|
+
),
|
|
170
|
+
revision: 1,
|
|
171
|
+
createdAt: now,
|
|
172
|
+
updatedAt: now,
|
|
173
|
+
credentialHash,
|
|
174
|
+
credentialLookup
|
|
175
|
+
};
|
|
176
|
+
accounts.set(id, record);
|
|
177
|
+
lookup.set(credentialLookup, id);
|
|
178
|
+
return clone(record);
|
|
179
|
+
},
|
|
180
|
+
getAccount(id) {
|
|
181
|
+
const record = accounts.get(safeId(id));
|
|
182
|
+
return record ? clone(record) : void 0;
|
|
183
|
+
},
|
|
184
|
+
listAccounts({ includeDeleted = false } = {}) {
|
|
185
|
+
return [...accounts.values()].filter((record) => includeDeleted || record.status !== "deleted").sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)).map(clone);
|
|
186
|
+
},
|
|
187
|
+
verifyCredential,
|
|
188
|
+
authenticate(credential) {
|
|
189
|
+
const record = verifyCredential(credential);
|
|
190
|
+
return record?.status === "active" ? record : void 0;
|
|
191
|
+
},
|
|
192
|
+
updateAccount(id, input, expectedRevision, now = Date.now()) {
|
|
193
|
+
const current = accounts.get(safeId(id));
|
|
194
|
+
if (!current) return void 0;
|
|
195
|
+
if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(clone(current));
|
|
196
|
+
if (current.status === "deleted") throw new Error("deleted relay account is immutable");
|
|
197
|
+
const plan = input.plan === void 0 ? current.plan : safePlan(input.plan);
|
|
198
|
+
const status = input.status === void 0 ? current.status : safeStatus(input.status);
|
|
199
|
+
const defaults = PLAN_DEFAULTS[plan];
|
|
200
|
+
const next = {
|
|
201
|
+
...current,
|
|
202
|
+
...input.label === void 0 ? {} : { label: safeLabel(input.label) },
|
|
203
|
+
status,
|
|
204
|
+
plan,
|
|
205
|
+
maxRoutes: boundedLimit(
|
|
206
|
+
input.maxRoutes,
|
|
207
|
+
input.plan === void 0 ? current.maxRoutes : defaults.maxRoutes,
|
|
208
|
+
1e4,
|
|
209
|
+
"route limit"
|
|
210
|
+
),
|
|
211
|
+
maxDevicesPerRoute: boundedLimit(
|
|
212
|
+
input.maxDevicesPerRoute,
|
|
213
|
+
input.plan === void 0 ? current.maxDevicesPerRoute : defaults.maxDevicesPerRoute,
|
|
214
|
+
1e5,
|
|
215
|
+
"device limit"
|
|
216
|
+
),
|
|
217
|
+
revision: current.revision + 1,
|
|
218
|
+
updatedAt: now
|
|
219
|
+
};
|
|
220
|
+
accounts.set(current.id, next);
|
|
221
|
+
return clone(next);
|
|
222
|
+
},
|
|
223
|
+
rotateCredential(id, credential, expectedRevision, now = Date.now()) {
|
|
224
|
+
const current = accounts.get(safeId(id));
|
|
225
|
+
if (!current) return void 0;
|
|
226
|
+
if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(clone(current));
|
|
227
|
+
if (current.status === "deleted") throw new Error("deleted relay account is immutable");
|
|
228
|
+
const { credentialHash, credentialLookup } = credentialMaterial(credential);
|
|
229
|
+
const owner = lookup.get(credentialLookup);
|
|
230
|
+
if (owner && owner !== current.id) throw new Error("relay account credential already exists");
|
|
231
|
+
lookup.delete(current.credentialLookup);
|
|
232
|
+
lookup.set(credentialLookup, current.id);
|
|
233
|
+
const next = {
|
|
234
|
+
...current,
|
|
235
|
+
credentialHash,
|
|
236
|
+
credentialLookup,
|
|
237
|
+
revision: current.revision + 1,
|
|
238
|
+
updatedAt: now
|
|
239
|
+
};
|
|
240
|
+
accounts.set(current.id, next);
|
|
241
|
+
return clone(next);
|
|
242
|
+
},
|
|
243
|
+
close() {
|
|
244
|
+
accounts.clear();
|
|
245
|
+
lookup.clear();
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function fromRow(row) {
|
|
250
|
+
return {
|
|
251
|
+
id: row.id,
|
|
252
|
+
label: row.label,
|
|
253
|
+
status: row.status,
|
|
254
|
+
plan: row.plan,
|
|
255
|
+
maxRoutes: row.max_routes,
|
|
256
|
+
maxDevicesPerRoute: row.max_devices_per_route,
|
|
257
|
+
revision: row.revision,
|
|
258
|
+
createdAt: row.created_at,
|
|
259
|
+
updatedAt: row.updated_at
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function openRelayAccountStore(options) {
|
|
263
|
+
let Database;
|
|
264
|
+
try {
|
|
265
|
+
if (options.loadDatabase) Database = options.loadDatabase();
|
|
266
|
+
else {
|
|
267
|
+
const mod = require2("better-sqlite3");
|
|
268
|
+
Database = mod.default ?? mod;
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
return createMemoryStore(options);
|
|
272
|
+
}
|
|
273
|
+
const db = new Database(options.dbPath);
|
|
274
|
+
db.pragma("journal_mode = WAL");
|
|
275
|
+
db.pragma("busy_timeout = 5000");
|
|
276
|
+
db.exec(`
|
|
277
|
+
CREATE TABLE IF NOT EXISTS relay_accounts (
|
|
278
|
+
id TEXT PRIMARY KEY,
|
|
279
|
+
label TEXT NOT NULL,
|
|
280
|
+
status TEXT NOT NULL CHECK(status IN ('active', 'suspended', 'deleted')),
|
|
281
|
+
plan TEXT NOT NULL CHECK(plan IN ('free', 'team', 'enterprise')),
|
|
282
|
+
max_routes INTEGER NOT NULL,
|
|
283
|
+
max_devices_per_route INTEGER NOT NULL,
|
|
284
|
+
revision INTEGER NOT NULL,
|
|
285
|
+
credential_hash TEXT NOT NULL,
|
|
286
|
+
credential_lookup TEXT NOT NULL UNIQUE,
|
|
287
|
+
created_at INTEGER NOT NULL,
|
|
288
|
+
updated_at INTEGER NOT NULL
|
|
289
|
+
);
|
|
290
|
+
CREATE INDEX IF NOT EXISTS relay_accounts_status_idx ON relay_accounts(status);
|
|
291
|
+
`);
|
|
292
|
+
const generateAccountId = options.generateAccountId ?? (() => `rra_${randomBytes(18).toString("base64url")}`);
|
|
293
|
+
const rowById = (id) => db.prepare("SELECT * FROM relay_accounts WHERE id = ?").get(id);
|
|
294
|
+
const verifyCredential = (credential) => {
|
|
295
|
+
let credentialLookup;
|
|
296
|
+
try {
|
|
297
|
+
credentialLookup = relayAccountCredentialLookup(credential);
|
|
298
|
+
} catch {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const row = db.prepare("SELECT * FROM relay_accounts WHERE credential_lookup = ? AND status != 'deleted'").get(credentialLookup);
|
|
302
|
+
return row && hashMatches(row.credential_hash, credential) ? fromRow(row) : void 0;
|
|
303
|
+
};
|
|
304
|
+
const createAccount = db.transaction((input, now) => {
|
|
305
|
+
const id = safeId(generateAccountId());
|
|
306
|
+
if (rowById(id)) throw new Error("relay account already exists");
|
|
307
|
+
const plan = safePlan(input.plan ?? "free");
|
|
308
|
+
const { credentialHash, credentialLookup } = credentialMaterial(input);
|
|
309
|
+
const defaults = PLAN_DEFAULTS[plan];
|
|
310
|
+
const record = {
|
|
311
|
+
id,
|
|
312
|
+
label: safeLabel(input.label),
|
|
313
|
+
status: "active",
|
|
314
|
+
plan,
|
|
315
|
+
maxRoutes: boundedLimit(input.maxRoutes, defaults.maxRoutes, 1e4, "route limit"),
|
|
316
|
+
maxDevicesPerRoute: boundedLimit(input.maxDevicesPerRoute, defaults.maxDevicesPerRoute, 1e5, "device limit"),
|
|
317
|
+
revision: 1,
|
|
318
|
+
createdAt: now,
|
|
319
|
+
updatedAt: now
|
|
320
|
+
};
|
|
321
|
+
db.prepare(
|
|
322
|
+
`INSERT INTO relay_accounts
|
|
323
|
+
(id, label, status, plan, max_routes, max_devices_per_route, revision, credential_hash,
|
|
324
|
+
credential_lookup, created_at, updated_at)
|
|
325
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
326
|
+
).run(
|
|
327
|
+
record.id,
|
|
328
|
+
record.label,
|
|
329
|
+
record.status,
|
|
330
|
+
record.plan,
|
|
331
|
+
record.maxRoutes,
|
|
332
|
+
record.maxDevicesPerRoute,
|
|
333
|
+
record.revision,
|
|
334
|
+
credentialHash,
|
|
335
|
+
credentialLookup,
|
|
336
|
+
record.createdAt,
|
|
337
|
+
record.updatedAt
|
|
338
|
+
);
|
|
339
|
+
return record;
|
|
340
|
+
});
|
|
341
|
+
return {
|
|
342
|
+
mode: "sqlite",
|
|
343
|
+
createAccount: (input, now = Date.now()) => clone(createAccount(input, now)),
|
|
344
|
+
getAccount(id) {
|
|
345
|
+
const row = rowById(safeId(id));
|
|
346
|
+
return row ? fromRow(row) : void 0;
|
|
347
|
+
},
|
|
348
|
+
listAccounts({ includeDeleted = false } = {}) {
|
|
349
|
+
const rows = db.prepare(
|
|
350
|
+
`SELECT * FROM relay_accounts ${includeDeleted ? "" : "WHERE status != 'deleted'"}
|
|
351
|
+
ORDER BY created_at, id`
|
|
352
|
+
).all();
|
|
353
|
+
return rows.map(fromRow);
|
|
354
|
+
},
|
|
355
|
+
verifyCredential,
|
|
356
|
+
authenticate(credential) {
|
|
357
|
+
const record = verifyCredential(credential);
|
|
358
|
+
return record?.status === "active" ? record : void 0;
|
|
359
|
+
},
|
|
360
|
+
updateAccount(id, input, expectedRevision, now = Date.now()) {
|
|
361
|
+
const safeAccountId = safeId(id);
|
|
362
|
+
const currentRow = rowById(safeAccountId);
|
|
363
|
+
if (!currentRow) return void 0;
|
|
364
|
+
const current = fromRow(currentRow);
|
|
365
|
+
if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(current);
|
|
366
|
+
if (current.status === "deleted") throw new Error("deleted relay account is immutable");
|
|
367
|
+
const plan = input.plan === void 0 ? current.plan : safePlan(input.plan);
|
|
368
|
+
const defaults = PLAN_DEFAULTS[plan];
|
|
369
|
+
const next = {
|
|
370
|
+
...current,
|
|
371
|
+
...input.label === void 0 ? {} : { label: safeLabel(input.label) },
|
|
372
|
+
status: input.status === void 0 ? current.status : safeStatus(input.status),
|
|
373
|
+
plan,
|
|
374
|
+
maxRoutes: boundedLimit(
|
|
375
|
+
input.maxRoutes,
|
|
376
|
+
input.plan === void 0 ? current.maxRoutes : defaults.maxRoutes,
|
|
377
|
+
1e4,
|
|
378
|
+
"route limit"
|
|
379
|
+
),
|
|
380
|
+
maxDevicesPerRoute: boundedLimit(
|
|
381
|
+
input.maxDevicesPerRoute,
|
|
382
|
+
input.plan === void 0 ? current.maxDevicesPerRoute : defaults.maxDevicesPerRoute,
|
|
383
|
+
1e5,
|
|
384
|
+
"device limit"
|
|
385
|
+
),
|
|
386
|
+
revision: current.revision + 1,
|
|
387
|
+
updatedAt: now
|
|
388
|
+
};
|
|
389
|
+
const result = db.prepare(
|
|
390
|
+
`UPDATE relay_accounts SET label = ?, status = ?, plan = ?, max_routes = ?,
|
|
391
|
+
max_devices_per_route = ?, revision = ?, updated_at = ? WHERE id = ? AND revision = ?`
|
|
392
|
+
).run(
|
|
393
|
+
next.label,
|
|
394
|
+
next.status,
|
|
395
|
+
next.plan,
|
|
396
|
+
next.maxRoutes,
|
|
397
|
+
next.maxDevicesPerRoute,
|
|
398
|
+
next.revision,
|
|
399
|
+
next.updatedAt,
|
|
400
|
+
next.id,
|
|
401
|
+
expectedRevision
|
|
402
|
+
);
|
|
403
|
+
if (result.changes !== 1) {
|
|
404
|
+
const latest = rowById(safeAccountId);
|
|
405
|
+
if (latest) throw new RelayAccountRevisionConflictError(fromRow(latest));
|
|
406
|
+
return void 0;
|
|
407
|
+
}
|
|
408
|
+
return next;
|
|
409
|
+
},
|
|
410
|
+
rotateCredential(id, credential, expectedRevision, now = Date.now()) {
|
|
411
|
+
const safeAccountId = safeId(id);
|
|
412
|
+
const currentRow = rowById(safeAccountId);
|
|
413
|
+
if (!currentRow) return void 0;
|
|
414
|
+
const current = fromRow(currentRow);
|
|
415
|
+
if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(current);
|
|
416
|
+
if (current.status === "deleted") throw new Error("deleted relay account is immutable");
|
|
417
|
+
const { credentialHash, credentialLookup } = credentialMaterial(credential);
|
|
418
|
+
const result = db.prepare(
|
|
419
|
+
`UPDATE relay_accounts SET credential_hash = ?, credential_lookup = ?, revision = revision + 1,
|
|
420
|
+
updated_at = ? WHERE id = ? AND revision = ?`
|
|
421
|
+
).run(credentialHash, credentialLookup, now, safeAccountId, expectedRevision);
|
|
422
|
+
if (result.changes !== 1) {
|
|
423
|
+
const latest = rowById(safeAccountId);
|
|
424
|
+
if (latest) throw new RelayAccountRevisionConflictError(fromRow(latest));
|
|
425
|
+
return void 0;
|
|
426
|
+
}
|
|
427
|
+
return fromRow(rowById(safeAccountId));
|
|
428
|
+
},
|
|
429
|
+
close: () => db.close()
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// src/relay-store.ts
|
|
434
|
+
import { createHash as createHash2, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
435
|
+
import { createRequire as createRequire2 } from "module";
|
|
436
|
+
var require3 = createRequire2(import.meta.url);
|
|
437
|
+
var UNSAFE_DISPLAY_TEXT = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
|
|
438
|
+
function safeId2(value, field) {
|
|
439
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9._:-]{1,256}$/.test(value)) throw new Error(`invalid relay ${field}`);
|
|
440
|
+
return value;
|
|
441
|
+
}
|
|
442
|
+
function safeOwnerAccountId(value) {
|
|
443
|
+
if (typeof value !== "string" || !/^rra_[A-Za-z0-9_-]{16,128}$/.test(value)) {
|
|
444
|
+
throw new Error("invalid relay route owner");
|
|
445
|
+
}
|
|
446
|
+
return value;
|
|
447
|
+
}
|
|
448
|
+
function safeLabel2(value) {
|
|
449
|
+
if (typeof value !== "string") throw new Error("relay route label is required");
|
|
450
|
+
const normalized = value.trim().replace(/\s+/g, " ");
|
|
451
|
+
if (!normalized || normalized.length > 80 || UNSAFE_DISPLAY_TEXT.test(normalized)) {
|
|
452
|
+
throw new Error("invalid relay route label");
|
|
453
|
+
}
|
|
454
|
+
return normalized;
|
|
455
|
+
}
|
|
456
|
+
function safeCredential2(value) {
|
|
457
|
+
if (typeof value !== "string" || value.length < 32 || value.length > 256 || !/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
458
|
+
throw new Error("invalid relay credential");
|
|
459
|
+
}
|
|
460
|
+
return value;
|
|
461
|
+
}
|
|
462
|
+
function relayCredentialHash(credential) {
|
|
463
|
+
return `sha256:${createHash2("sha256").update("roamcode-relay-credential-v1\0").update(safeCredential2(credential)).digest("base64url")}`;
|
|
464
|
+
}
|
|
465
|
+
function generateRelayCredential(prefix = "rrd") {
|
|
466
|
+
return `${prefix}_${randomBytes2(32).toString("base64url")}`;
|
|
467
|
+
}
|
|
468
|
+
function safeCredentialHash(value) {
|
|
469
|
+
if (typeof value !== "string" || !/^sha256:[A-Za-z0-9_-]{43}$/.test(value)) {
|
|
470
|
+
throw new Error("invalid relay credential hash");
|
|
471
|
+
}
|
|
472
|
+
return value;
|
|
473
|
+
}
|
|
474
|
+
function safeExpiry(value) {
|
|
475
|
+
if (value === void 0) return void 0;
|
|
476
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error("invalid relay device expiry");
|
|
477
|
+
return value;
|
|
478
|
+
}
|
|
479
|
+
function hashMatches2(expected, credential) {
|
|
480
|
+
try {
|
|
481
|
+
const actual = relayCredentialHash(credential);
|
|
482
|
+
const left = Buffer.from(safeCredentialHash(expected));
|
|
483
|
+
const right = Buffer.from(actual);
|
|
484
|
+
return left.length === right.length && timingSafeEqual2(left, right);
|
|
485
|
+
} catch {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function cloneRoute(route) {
|
|
490
|
+
return { ...route };
|
|
491
|
+
}
|
|
492
|
+
function cloneDevice(device) {
|
|
493
|
+
return {
|
|
494
|
+
routeId: device.routeId,
|
|
495
|
+
deviceId: device.deviceId,
|
|
496
|
+
credentialHash: device.credentialHash,
|
|
497
|
+
createdAt: device.createdAt,
|
|
498
|
+
updatedAt: device.updatedAt,
|
|
499
|
+
...device.expiresAt === void 0 ? {} : { expiresAt: device.expiresAt }
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
function credentialOverlap(current, credentialHash, expiresAt, now) {
|
|
503
|
+
if (!current || expiresAt !== void 0) return {};
|
|
504
|
+
if (current.expiresAt !== void 0) {
|
|
505
|
+
if (current.credentialHash === credentialHash) {
|
|
506
|
+
throw new Error("relay bootstrap promotion must rotate the device credential");
|
|
507
|
+
}
|
|
508
|
+
return {
|
|
509
|
+
previousCredentialHash: current.credentialHash,
|
|
510
|
+
previousCredentialExpiresAt: current.expiresAt
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
if (current.previousCredentialHash !== void 0 && current.previousCredentialExpiresAt !== void 0 && current.previousCredentialExpiresAt >= now) {
|
|
514
|
+
if (current.previousCredentialHash === credentialHash) {
|
|
515
|
+
throw new Error("relay bootstrap credential cannot become durable");
|
|
516
|
+
}
|
|
517
|
+
return {
|
|
518
|
+
previousCredentialHash: current.previousCredentialHash,
|
|
519
|
+
previousCredentialExpiresAt: current.previousCredentialExpiresAt
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
return {};
|
|
523
|
+
}
|
|
524
|
+
function createMemoryStore2(options) {
|
|
525
|
+
const routes = /* @__PURE__ */ new Map();
|
|
526
|
+
const devices = /* @__PURE__ */ new Map();
|
|
527
|
+
const generateRouteId = options.generateRouteId ?? (() => `rrt_${randomBytes2(16).toString("base64url")}`);
|
|
528
|
+
const deviceKey = (routeId, deviceId) => `${routeId}\0${deviceId}`;
|
|
529
|
+
const pruneExpiredDevices = (now) => {
|
|
530
|
+
for (const [key, device] of devices) {
|
|
531
|
+
if (device.expiresAt !== void 0 && device.expiresAt < now) devices.delete(key);
|
|
532
|
+
else if (device.previousCredentialExpiresAt !== void 0 && device.previousCredentialExpiresAt < now) {
|
|
533
|
+
delete device.previousCredentialHash;
|
|
534
|
+
delete device.previousCredentialExpiresAt;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
const liveDevice = (routeId, deviceId, now) => {
|
|
539
|
+
const key = deviceKey(routeId, deviceId);
|
|
540
|
+
const device = devices.get(key);
|
|
541
|
+
if (device?.expiresAt !== void 0 && device.expiresAt < now) {
|
|
542
|
+
devices.delete(key);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
return device;
|
|
546
|
+
};
|
|
547
|
+
const listRoutes = (now, ownerAccountId) => {
|
|
548
|
+
pruneExpiredDevices(now);
|
|
549
|
+
return [...routes.values()].filter((route) => ownerAccountId === void 0 || route.ownerAccountId === ownerAccountId).sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)).map((route) => ({
|
|
550
|
+
id: route.id,
|
|
551
|
+
label: route.label,
|
|
552
|
+
deviceCount: [...devices.values()].filter(
|
|
553
|
+
(device) => device.routeId === route.id && (device.expiresAt === void 0 || device.expiresAt >= now)
|
|
554
|
+
).length,
|
|
555
|
+
createdAt: route.createdAt,
|
|
556
|
+
updatedAt: route.updatedAt
|
|
557
|
+
}));
|
|
558
|
+
};
|
|
559
|
+
return {
|
|
560
|
+
mode: "memory",
|
|
561
|
+
createRoute(input, now = Date.now()) {
|
|
562
|
+
const id = safeId2(input.id ?? generateRouteId(), "route id");
|
|
563
|
+
if (routes.has(id)) throw new Error("relay route already exists");
|
|
564
|
+
const route = {
|
|
565
|
+
id,
|
|
566
|
+
label: safeLabel2(input.label),
|
|
567
|
+
hostCredentialHash: safeCredentialHash(input.hostCredentialHash),
|
|
568
|
+
...input.ownerAccountId === void 0 ? {} : { ownerAccountId: safeOwnerAccountId(input.ownerAccountId) },
|
|
569
|
+
createdAt: now,
|
|
570
|
+
updatedAt: now
|
|
571
|
+
};
|
|
572
|
+
routes.set(id, route);
|
|
573
|
+
return cloneRoute(route);
|
|
574
|
+
},
|
|
575
|
+
getRoute(id) {
|
|
576
|
+
const route = routes.get(safeId2(id, "route id"));
|
|
577
|
+
return route ? cloneRoute(route) : void 0;
|
|
578
|
+
},
|
|
579
|
+
listRoutes: (now = Date.now()) => listRoutes(now),
|
|
580
|
+
listRoutesByOwner(ownerAccountId, now = Date.now()) {
|
|
581
|
+
return listRoutes(now, safeOwnerAccountId(ownerAccountId));
|
|
582
|
+
},
|
|
583
|
+
countDevices(routeId, now = Date.now()) {
|
|
584
|
+
pruneExpiredDevices(now);
|
|
585
|
+
const safeRouteId = safeId2(routeId, "route id");
|
|
586
|
+
return [...devices.values()].filter(
|
|
587
|
+
(device) => device.routeId === safeRouteId && (device.expiresAt === void 0 || device.expiresAt >= now)
|
|
588
|
+
).length;
|
|
589
|
+
},
|
|
590
|
+
rotateHostCredential(routeId, credentialHash, now = Date.now()) {
|
|
591
|
+
const route = routes.get(safeId2(routeId, "route id"));
|
|
592
|
+
if (!route) return false;
|
|
593
|
+
route.hostCredentialHash = safeCredentialHash(credentialHash);
|
|
594
|
+
route.updatedAt = now;
|
|
595
|
+
return true;
|
|
596
|
+
},
|
|
597
|
+
deleteRoute(id) {
|
|
598
|
+
const safeRouteId = safeId2(id, "route id");
|
|
599
|
+
if (!routes.delete(safeRouteId)) return false;
|
|
600
|
+
for (const [key, device] of devices) if (device.routeId === safeRouteId) devices.delete(key);
|
|
601
|
+
return true;
|
|
602
|
+
},
|
|
603
|
+
authenticateHost(routeId, credential) {
|
|
604
|
+
const route = routes.get(safeId2(routeId, "route id"));
|
|
605
|
+
return !!route && hashMatches2(route.hostCredentialHash, credential);
|
|
606
|
+
},
|
|
607
|
+
putDevice(input, now = Date.now()) {
|
|
608
|
+
pruneExpiredDevices(now);
|
|
609
|
+
const routeId = safeId2(input.routeId, "route id");
|
|
610
|
+
if (!routes.has(routeId)) throw new Error("relay route not found");
|
|
611
|
+
const deviceId = safeId2(input.deviceId, "device id");
|
|
612
|
+
const key = deviceKey(routeId, deviceId);
|
|
613
|
+
const current = devices.get(key);
|
|
614
|
+
const credentialHash = safeCredentialHash(input.credentialHash);
|
|
615
|
+
const expiresAt = input.expiresAt === void 0 ? void 0 : safeExpiry(input.expiresAt);
|
|
616
|
+
const device = {
|
|
617
|
+
routeId,
|
|
618
|
+
deviceId,
|
|
619
|
+
credentialHash,
|
|
620
|
+
createdAt: current?.createdAt ?? now,
|
|
621
|
+
updatedAt: now,
|
|
622
|
+
...expiresAt === void 0 ? {} : { expiresAt },
|
|
623
|
+
...credentialOverlap(current, credentialHash, expiresAt, now)
|
|
624
|
+
};
|
|
625
|
+
devices.set(key, device);
|
|
626
|
+
const route = routes.get(routeId);
|
|
627
|
+
route.updatedAt = now;
|
|
628
|
+
return cloneDevice(device);
|
|
629
|
+
},
|
|
630
|
+
getDevice(routeId, deviceId, now = Date.now()) {
|
|
631
|
+
const device = liveDevice(safeId2(routeId, "route id"), safeId2(deviceId, "device id"), now);
|
|
632
|
+
return device ? cloneDevice(device) : void 0;
|
|
633
|
+
},
|
|
634
|
+
authenticateDevice(routeId, deviceId, credential, now = Date.now()) {
|
|
635
|
+
const device = liveDevice(safeId2(routeId, "route id"), safeId2(deviceId, "device id"), now);
|
|
636
|
+
return !!device && (hashMatches2(device.credentialHash, credential) || device.previousCredentialHash !== void 0 && device.previousCredentialExpiresAt !== void 0 && device.previousCredentialExpiresAt >= now && hashMatches2(device.previousCredentialHash, credential));
|
|
637
|
+
},
|
|
638
|
+
revokeDevice(routeId, deviceId) {
|
|
639
|
+
const safeRouteId = safeId2(routeId, "route id");
|
|
640
|
+
const removed = devices.delete(deviceKey(safeRouteId, safeId2(deviceId, "device id")));
|
|
641
|
+
if (removed) routes.get(safeRouteId).updatedAt = Date.now();
|
|
642
|
+
return removed;
|
|
643
|
+
},
|
|
644
|
+
close() {
|
|
645
|
+
routes.clear();
|
|
646
|
+
devices.clear();
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
function routeFromRow(row) {
|
|
651
|
+
return {
|
|
652
|
+
id: row.id,
|
|
653
|
+
label: row.label,
|
|
654
|
+
hostCredentialHash: row.host_credential_hash,
|
|
655
|
+
...row.owner_account_id === null ? {} : { ownerAccountId: row.owner_account_id },
|
|
656
|
+
createdAt: row.created_at,
|
|
657
|
+
updatedAt: row.updated_at
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
function deviceFromRow(row) {
|
|
661
|
+
return {
|
|
662
|
+
routeId: row.route_id,
|
|
663
|
+
deviceId: row.device_id,
|
|
664
|
+
credentialHash: row.credential_hash,
|
|
665
|
+
createdAt: row.created_at,
|
|
666
|
+
updatedAt: row.updated_at,
|
|
667
|
+
...row.expires_at === null || row.expires_at === void 0 ? {} : { expiresAt: row.expires_at }
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
function openRelayRouteStore(options) {
|
|
671
|
+
let Database;
|
|
672
|
+
try {
|
|
673
|
+
if (options.loadDatabase) Database = options.loadDatabase();
|
|
674
|
+
else {
|
|
675
|
+
const mod = require3("better-sqlite3");
|
|
676
|
+
Database = mod.default ?? mod;
|
|
677
|
+
}
|
|
678
|
+
} catch {
|
|
679
|
+
return createMemoryStore2(options);
|
|
680
|
+
}
|
|
681
|
+
const db = new Database(options.dbPath);
|
|
682
|
+
db.pragma("journal_mode = WAL");
|
|
683
|
+
db.pragma("busy_timeout = 5000");
|
|
684
|
+
db.pragma("foreign_keys = ON");
|
|
685
|
+
db.exec(`
|
|
686
|
+
CREATE TABLE IF NOT EXISTS relay_routes (
|
|
687
|
+
id TEXT PRIMARY KEY,
|
|
688
|
+
label TEXT NOT NULL,
|
|
689
|
+
host_credential_hash TEXT NOT NULL,
|
|
690
|
+
owner_account_id TEXT,
|
|
691
|
+
created_at INTEGER NOT NULL,
|
|
692
|
+
updated_at INTEGER NOT NULL
|
|
693
|
+
);
|
|
694
|
+
CREATE TABLE IF NOT EXISTS relay_route_devices (
|
|
695
|
+
route_id TEXT NOT NULL REFERENCES relay_routes(id) ON DELETE CASCADE,
|
|
696
|
+
device_id TEXT NOT NULL,
|
|
697
|
+
credential_hash TEXT NOT NULL,
|
|
698
|
+
previous_credential_hash TEXT,
|
|
699
|
+
previous_credential_expires_at INTEGER,
|
|
700
|
+
created_at INTEGER NOT NULL,
|
|
701
|
+
updated_at INTEGER NOT NULL,
|
|
702
|
+
expires_at INTEGER,
|
|
703
|
+
PRIMARY KEY(route_id, device_id)
|
|
704
|
+
);
|
|
705
|
+
`);
|
|
706
|
+
const relayDeviceColumns = db.prepare("PRAGMA table_info(relay_route_devices)").all();
|
|
707
|
+
if (!relayDeviceColumns.some((column) => column.name === "expires_at")) {
|
|
708
|
+
db.exec("ALTER TABLE relay_route_devices ADD COLUMN expires_at INTEGER");
|
|
709
|
+
}
|
|
710
|
+
if (!relayDeviceColumns.some((column) => column.name === "previous_credential_hash")) {
|
|
711
|
+
db.exec("ALTER TABLE relay_route_devices ADD COLUMN previous_credential_hash TEXT");
|
|
712
|
+
}
|
|
713
|
+
if (!relayDeviceColumns.some((column) => column.name === "previous_credential_expires_at")) {
|
|
714
|
+
db.exec("ALTER TABLE relay_route_devices ADD COLUMN previous_credential_expires_at INTEGER");
|
|
715
|
+
}
|
|
716
|
+
const relayRouteColumns = db.prepare("PRAGMA table_info(relay_routes)").all();
|
|
717
|
+
if (!relayRouteColumns.some((column) => column.name === "owner_account_id")) {
|
|
718
|
+
db.exec("ALTER TABLE relay_routes ADD COLUMN owner_account_id TEXT");
|
|
719
|
+
}
|
|
720
|
+
db.exec("CREATE INDEX IF NOT EXISTS relay_routes_owner_idx ON relay_routes(owner_account_id)");
|
|
721
|
+
const generateRouteId = options.generateRouteId ?? (() => `rrt_${randomBytes2(16).toString("base64url")}`);
|
|
722
|
+
const getRoute = (id) => db.prepare("SELECT * FROM relay_routes WHERE id = ?").get(id);
|
|
723
|
+
const getDevice = (routeId, deviceId) => db.prepare("SELECT * FROM relay_route_devices WHERE route_id = ? AND device_id = ?").get(routeId, deviceId);
|
|
724
|
+
const pruneExpiredDevices = (now) => {
|
|
725
|
+
db.prepare("DELETE FROM relay_route_devices WHERE expires_at IS NOT NULL AND expires_at < ?").run(now);
|
|
726
|
+
db.prepare(
|
|
727
|
+
`UPDATE relay_route_devices SET previous_credential_hash = NULL, previous_credential_expires_at = NULL
|
|
728
|
+
WHERE previous_credential_expires_at IS NOT NULL AND previous_credential_expires_at < ?`
|
|
729
|
+
).run(now);
|
|
730
|
+
};
|
|
731
|
+
const liveDevice = (routeId, deviceId, now) => {
|
|
732
|
+
const row = getDevice(routeId, deviceId);
|
|
733
|
+
if (row?.expires_at !== null && row?.expires_at !== void 0 && row.expires_at < now) {
|
|
734
|
+
db.prepare("DELETE FROM relay_route_devices WHERE route_id = ? AND device_id = ?").run(routeId, deviceId);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
return row;
|
|
738
|
+
};
|
|
739
|
+
const createRoute = db.transaction(
|
|
740
|
+
(input, now) => {
|
|
741
|
+
const id = safeId2(input.id ?? generateRouteId(), "route id");
|
|
742
|
+
if (getRoute(id)) throw new Error("relay route already exists");
|
|
743
|
+
const route = {
|
|
744
|
+
id,
|
|
745
|
+
label: safeLabel2(input.label),
|
|
746
|
+
hostCredentialHash: safeCredentialHash(input.hostCredentialHash),
|
|
747
|
+
...input.ownerAccountId === void 0 ? {} : { ownerAccountId: safeOwnerAccountId(input.ownerAccountId) },
|
|
748
|
+
createdAt: now,
|
|
749
|
+
updatedAt: now
|
|
750
|
+
};
|
|
751
|
+
db.prepare(
|
|
752
|
+
`INSERT INTO relay_routes
|
|
753
|
+
(id, label, host_credential_hash, owner_account_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`
|
|
754
|
+
).run(
|
|
755
|
+
route.id,
|
|
756
|
+
route.label,
|
|
757
|
+
route.hostCredentialHash,
|
|
758
|
+
route.ownerAccountId ?? null,
|
|
759
|
+
route.createdAt,
|
|
760
|
+
route.updatedAt
|
|
761
|
+
);
|
|
762
|
+
return route;
|
|
763
|
+
}
|
|
764
|
+
);
|
|
765
|
+
const putDevice = db.transaction(
|
|
766
|
+
(input, now) => {
|
|
767
|
+
const routeId = safeId2(input.routeId, "route id");
|
|
768
|
+
if (!getRoute(routeId)) throw new Error("relay route not found");
|
|
769
|
+
pruneExpiredDevices(now);
|
|
770
|
+
const deviceId = safeId2(input.deviceId, "device id");
|
|
771
|
+
const current = getDevice(routeId, deviceId);
|
|
772
|
+
const credentialHash = safeCredentialHash(input.credentialHash);
|
|
773
|
+
const expiresAt = input.expiresAt === void 0 ? void 0 : safeExpiry(input.expiresAt);
|
|
774
|
+
const overlap = credentialOverlap(
|
|
775
|
+
current ? {
|
|
776
|
+
...deviceFromRow(current),
|
|
777
|
+
...current.previous_credential_hash === null || current.previous_credential_hash === void 0 ? {} : { previousCredentialHash: current.previous_credential_hash },
|
|
778
|
+
...current.previous_credential_expires_at === null || current.previous_credential_expires_at === void 0 ? {} : { previousCredentialExpiresAt: current.previous_credential_expires_at }
|
|
779
|
+
} : void 0,
|
|
780
|
+
credentialHash,
|
|
781
|
+
expiresAt,
|
|
782
|
+
now
|
|
783
|
+
);
|
|
784
|
+
const device = {
|
|
785
|
+
routeId,
|
|
786
|
+
deviceId,
|
|
787
|
+
credentialHash,
|
|
788
|
+
createdAt: current?.created_at ?? now,
|
|
789
|
+
updatedAt: now,
|
|
790
|
+
...expiresAt === void 0 ? {} : { expiresAt }
|
|
791
|
+
};
|
|
792
|
+
db.prepare(
|
|
793
|
+
`INSERT INTO relay_route_devices
|
|
794
|
+
(route_id, device_id, credential_hash, previous_credential_hash, previous_credential_expires_at,
|
|
795
|
+
created_at, updated_at, expires_at)
|
|
796
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
797
|
+
ON CONFLICT(route_id, device_id) DO UPDATE SET credential_hash = excluded.credential_hash,
|
|
798
|
+
previous_credential_hash = excluded.previous_credential_hash,
|
|
799
|
+
previous_credential_expires_at = excluded.previous_credential_expires_at,
|
|
800
|
+
updated_at = excluded.updated_at, expires_at = excluded.expires_at`
|
|
801
|
+
).run(
|
|
802
|
+
device.routeId,
|
|
803
|
+
device.deviceId,
|
|
804
|
+
device.credentialHash,
|
|
805
|
+
overlap.previousCredentialHash ?? null,
|
|
806
|
+
overlap.previousCredentialExpiresAt ?? null,
|
|
807
|
+
device.createdAt,
|
|
808
|
+
device.updatedAt,
|
|
809
|
+
device.expiresAt ?? null
|
|
810
|
+
);
|
|
811
|
+
db.prepare("UPDATE relay_routes SET updated_at = ? WHERE id = ?").run(now, routeId);
|
|
812
|
+
return device;
|
|
813
|
+
}
|
|
814
|
+
);
|
|
815
|
+
const listRoutes = (now, ownerAccountId) => {
|
|
816
|
+
pruneExpiredDevices(now);
|
|
817
|
+
return db.prepare(
|
|
818
|
+
`SELECT r.id, r.label, r.created_at, r.updated_at, COUNT(d.device_id) AS device_count
|
|
819
|
+
FROM relay_routes r LEFT JOIN relay_route_devices d ON d.route_id = r.id
|
|
820
|
+
AND (d.expires_at IS NULL OR d.expires_at >= ?)
|
|
821
|
+
${ownerAccountId === void 0 ? "" : "WHERE r.owner_account_id = ?"}
|
|
822
|
+
GROUP BY r.id ORDER BY r.created_at, r.id`
|
|
823
|
+
).all(now, ...ownerAccountId === void 0 ? [] : [safeOwnerAccountId(ownerAccountId)]).map((row) => ({
|
|
824
|
+
id: row.id,
|
|
825
|
+
label: row.label,
|
|
826
|
+
deviceCount: row.device_count,
|
|
827
|
+
createdAt: row.created_at,
|
|
828
|
+
updatedAt: row.updated_at
|
|
829
|
+
}));
|
|
830
|
+
};
|
|
831
|
+
return {
|
|
832
|
+
mode: "sqlite",
|
|
833
|
+
createRoute: (input, now = Date.now()) => cloneRoute(createRoute(input, now)),
|
|
834
|
+
getRoute(id) {
|
|
835
|
+
const row = getRoute(safeId2(id, "route id"));
|
|
836
|
+
return row ? routeFromRow(row) : void 0;
|
|
837
|
+
},
|
|
838
|
+
listRoutes: (now = Date.now()) => listRoutes(now),
|
|
839
|
+
listRoutesByOwner: (ownerAccountId, now = Date.now()) => listRoutes(now, ownerAccountId),
|
|
840
|
+
countDevices(routeId, now = Date.now()) {
|
|
841
|
+
pruneExpiredDevices(now);
|
|
842
|
+
const row = db.prepare(
|
|
843
|
+
`SELECT COUNT(*) AS count FROM relay_route_devices
|
|
844
|
+
WHERE route_id = ? AND (expires_at IS NULL OR expires_at >= ?)`
|
|
845
|
+
).get(safeId2(routeId, "route id"), now);
|
|
846
|
+
return row.count;
|
|
847
|
+
},
|
|
848
|
+
rotateHostCredential(routeId, credentialHash, now = Date.now()) {
|
|
849
|
+
return db.prepare("UPDATE relay_routes SET host_credential_hash = ?, updated_at = ? WHERE id = ?").run(safeCredentialHash(credentialHash), now, safeId2(routeId, "route id")).changes > 0;
|
|
850
|
+
},
|
|
851
|
+
deleteRoute(id) {
|
|
852
|
+
return db.prepare("DELETE FROM relay_routes WHERE id = ?").run(safeId2(id, "route id")).changes > 0;
|
|
853
|
+
},
|
|
854
|
+
authenticateHost(routeId, credential) {
|
|
855
|
+
const row = getRoute(safeId2(routeId, "route id"));
|
|
856
|
+
return !!row && hashMatches2(row.host_credential_hash, credential);
|
|
857
|
+
},
|
|
858
|
+
putDevice: (input, now = Date.now()) => cloneDevice(putDevice(input, now)),
|
|
859
|
+
getDevice(routeId, deviceId, now = Date.now()) {
|
|
860
|
+
const row = liveDevice(safeId2(routeId, "route id"), safeId2(deviceId, "device id"), now);
|
|
861
|
+
return row ? deviceFromRow(row) : void 0;
|
|
862
|
+
},
|
|
863
|
+
authenticateDevice(routeId, deviceId, credential, now = Date.now()) {
|
|
864
|
+
const row = liveDevice(safeId2(routeId, "route id"), safeId2(deviceId, "device id"), now);
|
|
865
|
+
return !!row && (hashMatches2(row.credential_hash, credential) || row.previous_credential_hash !== null && row.previous_credential_hash !== void 0 && row.previous_credential_expires_at !== null && row.previous_credential_expires_at !== void 0 && row.previous_credential_expires_at >= now && hashMatches2(row.previous_credential_hash, credential));
|
|
866
|
+
},
|
|
867
|
+
revokeDevice(routeId, deviceId) {
|
|
868
|
+
const safeRouteId = safeId2(routeId, "route id");
|
|
869
|
+
const result = db.prepare("DELETE FROM relay_route_devices WHERE route_id = ? AND device_id = ?").run(safeRouteId, safeId2(deviceId, "device id"));
|
|
870
|
+
if (result.changes > 0)
|
|
871
|
+
db.prepare("UPDATE relay_routes SET updated_at = ? WHERE id = ?").run(Date.now(), safeRouteId);
|
|
872
|
+
return result.changes > 0;
|
|
873
|
+
},
|
|
874
|
+
close: () => db.close()
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// src/relay-broker.ts
|
|
879
|
+
var BLIND_RELAY_PROTOCOL_VERSION = 1;
|
|
880
|
+
var BLIND_RELAY_DEFAULT_MAX_FRAME_BYTES = 15e5;
|
|
881
|
+
var BLIND_RELAY_DEFAULT_MAX_QUEUE_BYTES = 4e6;
|
|
882
|
+
var BLIND_RELAY_DEFAULT_MAX_TOTAL_CONNECTIONS = 1024;
|
|
883
|
+
var BLIND_RELAY_DEFAULT_MAX_CONNECTIONS_PER_ROUTE = 64;
|
|
884
|
+
var BLIND_RELAY_DEFAULT_MAX_BYTES_PER_MINUTE = 64 * 1024 * 1024;
|
|
885
|
+
var BLIND_RELAY_DEFAULT_MAX_MESSAGES_PER_MINUTE = 12e3;
|
|
886
|
+
var UNSAFE_DISPLAY_TEXT2 = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
|
|
887
|
+
function safeToken(value, field) {
|
|
888
|
+
if (typeof value !== "string" || value.length < 32 || value.length > 256 || !/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
889
|
+
throw new Error(`invalid relay ${field}`);
|
|
890
|
+
}
|
|
891
|
+
return value;
|
|
892
|
+
}
|
|
893
|
+
function safeCredentialHash2(value) {
|
|
894
|
+
if (typeof value !== "string" || !/^sha256:[A-Za-z0-9_-]{43}$/.test(value)) {
|
|
895
|
+
throw new Error("invalid relay credential hash");
|
|
896
|
+
}
|
|
897
|
+
return value;
|
|
898
|
+
}
|
|
899
|
+
function safeId3(value, field) {
|
|
900
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9._:-]{1,256}$/.test(value)) throw new Error(`invalid relay ${field}`);
|
|
901
|
+
return value;
|
|
902
|
+
}
|
|
903
|
+
function safeLabel3(value) {
|
|
904
|
+
if (typeof value !== "string") throw new Error("relay route label is required");
|
|
905
|
+
const label = value.trim().replace(/\s+/g, " ");
|
|
906
|
+
if (!label || label.length > 80 || UNSAFE_DISPLAY_TEXT2.test(label)) throw new Error("invalid relay route label");
|
|
907
|
+
return label;
|
|
908
|
+
}
|
|
909
|
+
function safeExpiry2(value, now) {
|
|
910
|
+
if (value === void 0 || value === null) return void 0;
|
|
911
|
+
if (!Number.isSafeInteger(value) || value <= now || value > now + 10 * 6e4) {
|
|
912
|
+
throw new Error("invalid relay device expiry");
|
|
913
|
+
}
|
|
914
|
+
return value;
|
|
915
|
+
}
|
|
916
|
+
function safeRevision(value) {
|
|
917
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new Error("invalid relay account revision");
|
|
918
|
+
return value;
|
|
919
|
+
}
|
|
920
|
+
function bearer(request) {
|
|
921
|
+
const value = request.headers.authorization;
|
|
922
|
+
if (!value || Array.isArray(value)) return;
|
|
923
|
+
const match = /^Bearer ([A-Za-z0-9_-]{32,256})$/.exec(value);
|
|
924
|
+
return match?.[1];
|
|
925
|
+
}
|
|
926
|
+
function tokenMatches(left, right) {
|
|
927
|
+
if (!right) return false;
|
|
928
|
+
const a = Buffer.from(left);
|
|
929
|
+
const b = Buffer.from(right);
|
|
930
|
+
return a.length === b.length && timingSafeEqual3(a, b);
|
|
931
|
+
}
|
|
932
|
+
function publicRoute(route) {
|
|
933
|
+
return {
|
|
934
|
+
id: route.id,
|
|
935
|
+
label: route.label,
|
|
936
|
+
deviceCount: route.deviceCount,
|
|
937
|
+
createdAt: route.createdAt,
|
|
938
|
+
updatedAt: route.updatedAt
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
function normalizeOrigin(value) {
|
|
942
|
+
try {
|
|
943
|
+
const url = new URL(value);
|
|
944
|
+
const loopback = url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(url.hostname);
|
|
945
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback) || url.username || url.password || url.pathname !== "/" || url.search || url.hash)
|
|
946
|
+
return;
|
|
947
|
+
return url.origin;
|
|
948
|
+
} catch {
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
function parseJson(raw, limit) {
|
|
953
|
+
const buffer = Buffer.isBuffer(raw) ? raw : raw instanceof ArrayBuffer ? Buffer.from(raw) : Buffer.concat(raw);
|
|
954
|
+
if (buffer.byteLength > limit) throw new Error("relay frame too large");
|
|
955
|
+
return JSON.parse(buffer.toString("utf8"));
|
|
956
|
+
}
|
|
957
|
+
function parseAuthHello(value) {
|
|
958
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid relay hello");
|
|
959
|
+
const hello = value;
|
|
960
|
+
if (hello.v !== BLIND_RELAY_PROTOCOL_VERSION || hello.role !== "host" && hello.role !== "device") {
|
|
961
|
+
throw new Error("invalid relay hello");
|
|
962
|
+
}
|
|
963
|
+
const routeId = safeId3(hello.routeId, "route id");
|
|
964
|
+
const credential = safeToken(hello.credential, "credential");
|
|
965
|
+
return hello.role === "host" ? { v: 1, role: "host", routeId, credential } : { v: 1, role: "device", routeId, deviceId: safeId3(hello.deviceId, "device id"), credential };
|
|
966
|
+
}
|
|
967
|
+
function parsePayload(value, requireChannel, maxBytes) {
|
|
968
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid relay frame");
|
|
969
|
+
const frame = value;
|
|
970
|
+
if (frame.t !== "frame" || typeof frame.payload !== "string" || !/^[A-Za-z0-9_-]+$/.test(frame.payload)) {
|
|
971
|
+
throw new Error("invalid relay frame");
|
|
972
|
+
}
|
|
973
|
+
const bytes = Math.floor(frame.payload.length * 3 / 4);
|
|
974
|
+
if (bytes < 1 || bytes > maxBytes) throw new Error("relay frame too large");
|
|
975
|
+
const canonical = Buffer.from(frame.payload, "base64url").toString("base64url");
|
|
976
|
+
if (canonical !== frame.payload) throw new Error("invalid relay frame");
|
|
977
|
+
return {
|
|
978
|
+
...requireChannel ? { channelId: safeId3(frame.channelId, "channel id") } : {},
|
|
979
|
+
payload: frame.payload,
|
|
980
|
+
bytes
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
function safeSend(socket, value, maxQueueBytes) {
|
|
984
|
+
if (socket.readyState !== socket.OPEN) return false;
|
|
985
|
+
const encoded = JSON.stringify(value);
|
|
986
|
+
if (socket.bufferedAmount + Buffer.byteLength(encoded) > maxQueueBytes) return false;
|
|
987
|
+
try {
|
|
988
|
+
socket.send(encoded);
|
|
989
|
+
return true;
|
|
990
|
+
} catch {
|
|
991
|
+
return false;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
function createBlindRelayServer(options) {
|
|
995
|
+
const rootTokens = [safeToken(options.rootToken, "root token")];
|
|
996
|
+
for (const token of options.previousRootTokens ?? []) rootTokens.push(safeToken(token, "previous root token"));
|
|
997
|
+
if (rootTokens.length > 4 || new Set(rootTokens).size !== rootTokens.length) {
|
|
998
|
+
throw new Error("invalid relay root token rotation set");
|
|
999
|
+
}
|
|
1000
|
+
const store = options.store ?? openRelayRouteStore({ dbPath: ":memory:" });
|
|
1001
|
+
const ownsStore = options.store === void 0;
|
|
1002
|
+
const accountStore = options.accountStore;
|
|
1003
|
+
const now = options.now ?? Date.now;
|
|
1004
|
+
const handshakeTimeoutMs = options.handshakeTimeoutMs ?? 5e3;
|
|
1005
|
+
const idleTimeoutMs = options.idleTimeoutMs ?? 2 * 6e4;
|
|
1006
|
+
const maxFrameBytes = options.maxFrameBytes ?? BLIND_RELAY_DEFAULT_MAX_FRAME_BYTES;
|
|
1007
|
+
const maxQueueBytes = options.maxQueueBytes ?? BLIND_RELAY_DEFAULT_MAX_QUEUE_BYTES;
|
|
1008
|
+
const maxTotalConnections = options.maxTotalConnections ?? BLIND_RELAY_DEFAULT_MAX_TOTAL_CONNECTIONS;
|
|
1009
|
+
const maxConnectionsPerRoute = options.maxConnectionsPerRoute ?? BLIND_RELAY_DEFAULT_MAX_CONNECTIONS_PER_ROUTE;
|
|
1010
|
+
const maxBytesPerMinute = options.maxBytesPerMinute ?? BLIND_RELAY_DEFAULT_MAX_BYTES_PER_MINUTE;
|
|
1011
|
+
const maxMessagesPerMinute = options.maxMessagesPerMinute ?? BLIND_RELAY_DEFAULT_MAX_MESSAGES_PER_MINUTE;
|
|
1012
|
+
for (const [value, minimum, maximum, label] of [
|
|
1013
|
+
[handshakeTimeoutMs, 1e3, 3e4, "handshake timeout"],
|
|
1014
|
+
[idleTimeoutMs, 1e4, 60 * 6e4, "idle timeout"],
|
|
1015
|
+
[maxFrameBytes, 1024, 16 * 1024 * 1024, "frame limit"],
|
|
1016
|
+
[maxQueueBytes, 1024, 64 * 1024 * 1024, "queue limit"],
|
|
1017
|
+
[maxTotalConnections, 1, 1e5, "total connection limit"],
|
|
1018
|
+
[maxConnectionsPerRoute, 1, 1e4, "connection limit"],
|
|
1019
|
+
[maxBytesPerMinute, 1024, 1024 * 1024 * 1024, "byte rate"],
|
|
1020
|
+
[maxMessagesPerMinute, 10, 1e6, "message rate"]
|
|
1021
|
+
]) {
|
|
1022
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) throw new Error(`invalid relay ${label}`);
|
|
1023
|
+
}
|
|
1024
|
+
const maxEnvelopeBytes = Math.max(8 * 1024, Math.ceil(maxFrameBytes * 4 / 3) + 8 * 1024);
|
|
1025
|
+
const configuredOrigins = options.allowedOrigins ?? [];
|
|
1026
|
+
const normalizedOrigins = configuredOrigins.map(normalizeOrigin);
|
|
1027
|
+
if (normalizedOrigins.some((origin) => origin === void 0)) {
|
|
1028
|
+
throw new Error("invalid relay allowed origin");
|
|
1029
|
+
}
|
|
1030
|
+
const allowedOrigins = new Set(normalizedOrigins);
|
|
1031
|
+
const generateChannelId = options.generateChannelId ?? (() => `rrc_${randomBytes3(16).toString("base64url")}`);
|
|
1032
|
+
const hosts = /* @__PURE__ */ new Map();
|
|
1033
|
+
const devicesByChannel = /* @__PURE__ */ new Map();
|
|
1034
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
1035
|
+
const hostRates = /* @__PURE__ */ new Map();
|
|
1036
|
+
const deviceRates = /* @__PURE__ */ new Map();
|
|
1037
|
+
const maxRateIdentities = Math.min(1e5, Math.max(256, maxTotalConnections * 4));
|
|
1038
|
+
const deviceRateKey = (routeId, deviceId) => `${routeId}\0${deviceId}`;
|
|
1039
|
+
const clearRouteRates = (routeId) => {
|
|
1040
|
+
hostRates.delete(routeId);
|
|
1041
|
+
for (const key of deviceRates.keys()) if (key.startsWith(`${routeId}\0`)) deviceRates.delete(key);
|
|
1042
|
+
};
|
|
1043
|
+
const rateWindowFor = (windows, key) => {
|
|
1044
|
+
const current = now();
|
|
1045
|
+
if (windows.size >= maxRateIdentities) {
|
|
1046
|
+
for (const [candidate, window] of windows) {
|
|
1047
|
+
if (current - window.lastSeenAt >= 12e4) windows.delete(candidate);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
const existing = windows.get(key);
|
|
1051
|
+
if (existing) {
|
|
1052
|
+
existing.lastSeenAt = current;
|
|
1053
|
+
return existing;
|
|
1054
|
+
}
|
|
1055
|
+
if (windows.size >= maxRateIdentities) return;
|
|
1056
|
+
const created = { startedAt: current, lastSeenAt: current, bytes: 0, messages: 0 };
|
|
1057
|
+
windows.set(key, created);
|
|
1058
|
+
return created;
|
|
1059
|
+
};
|
|
1060
|
+
const consumeRate = (window, bytes) => {
|
|
1061
|
+
const current = now();
|
|
1062
|
+
if (current - window.startedAt >= 6e4) {
|
|
1063
|
+
window.startedAt = current;
|
|
1064
|
+
window.bytes = 0;
|
|
1065
|
+
window.messages = 0;
|
|
1066
|
+
}
|
|
1067
|
+
window.lastSeenAt = current;
|
|
1068
|
+
window.bytes += bytes;
|
|
1069
|
+
window.messages += 1;
|
|
1070
|
+
return window.bytes <= maxBytesPerMinute && window.messages <= maxMessagesPerMinute;
|
|
1071
|
+
};
|
|
1072
|
+
const metrics = {
|
|
1073
|
+
activeConnections: 0,
|
|
1074
|
+
activeHosts: 0,
|
|
1075
|
+
activeDevices: 0,
|
|
1076
|
+
acceptedConnections: 0,
|
|
1077
|
+
rejectedConnections: 0,
|
|
1078
|
+
forwardedFrames: 0,
|
|
1079
|
+
forwardedBytes: 0,
|
|
1080
|
+
droppedFrames: 0
|
|
1081
|
+
};
|
|
1082
|
+
const app = Fastify({ logger: false, trustProxy: false, bodyLimit: 32 * 1024 });
|
|
1083
|
+
const authenticatedAccounts = /* @__PURE__ */ new WeakMap();
|
|
1084
|
+
const routeAccountIsActive = (routeId) => {
|
|
1085
|
+
const ownerAccountId = store.getRoute(routeId)?.ownerAccountId;
|
|
1086
|
+
if (!ownerAccountId) return true;
|
|
1087
|
+
return accountStore?.getAccount(ownerAccountId)?.status === "active";
|
|
1088
|
+
};
|
|
1089
|
+
const requireRoot = async (request, reply) => {
|
|
1090
|
+
const presented = bearer(request);
|
|
1091
|
+
if (rootTokens.some((token) => tokenMatches(token, presented))) return;
|
|
1092
|
+
reply.header("www-authenticate", "Bearer").code(401).send({ code: "RELAY_UNAUTHORIZED", error: "unauthorized" });
|
|
1093
|
+
};
|
|
1094
|
+
const requireHost = async (request, reply) => {
|
|
1095
|
+
try {
|
|
1096
|
+
if (routeAccountIsActive(request.params.routeId) && store.authenticateHost(request.params.routeId, bearer(request) ?? ""))
|
|
1097
|
+
return;
|
|
1098
|
+
} catch {
|
|
1099
|
+
}
|
|
1100
|
+
reply.header("www-authenticate", "Bearer").code(401).send({ code: "RELAY_UNAUTHORIZED", error: "unauthorized" });
|
|
1101
|
+
};
|
|
1102
|
+
const requireAccount = async (request, reply) => {
|
|
1103
|
+
const account = accountStore?.authenticate(bearer(request) ?? "");
|
|
1104
|
+
if (account) {
|
|
1105
|
+
authenticatedAccounts.set(request, account);
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
reply.header("www-authenticate", "Bearer").code(401).send({ code: "RELAY_UNAUTHORIZED", error: "unauthorized" });
|
|
1109
|
+
};
|
|
1110
|
+
const requireRecoverableAccount = async (request, reply) => {
|
|
1111
|
+
const account = accountStore?.verifyCredential(bearer(request) ?? "");
|
|
1112
|
+
if (account) {
|
|
1113
|
+
authenticatedAccounts.set(request, account);
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
reply.header("www-authenticate", "Bearer").code(401).send({ code: "RELAY_UNAUTHORIZED", error: "unauthorized" });
|
|
1117
|
+
};
|
|
1118
|
+
app.addHook("onSend", async (_request, reply, payload) => {
|
|
1119
|
+
reply.header("cache-control", "no-store");
|
|
1120
|
+
reply.header("content-security-policy", "default-src 'none'");
|
|
1121
|
+
reply.header("x-content-type-options", "nosniff");
|
|
1122
|
+
return payload;
|
|
1123
|
+
});
|
|
1124
|
+
app.get("/health", async () => ({ status: "ok", protocolVersion: BLIND_RELAY_PROTOCOL_VERSION }));
|
|
1125
|
+
app.get("/ready", async (_request, reply) => {
|
|
1126
|
+
try {
|
|
1127
|
+
store.listRoutes(now());
|
|
1128
|
+
accountStore?.listAccounts();
|
|
1129
|
+
return { status: "ready", protocolVersion: BLIND_RELAY_PROTOCOL_VERSION };
|
|
1130
|
+
} catch {
|
|
1131
|
+
reply.code(503);
|
|
1132
|
+
return { status: "unavailable", protocolVersion: BLIND_RELAY_PROTOCOL_VERSION };
|
|
1133
|
+
}
|
|
1134
|
+
});
|
|
1135
|
+
app.get("/v1/metrics", { preHandler: requireRoot }, async () => ({
|
|
1136
|
+
protocolVersion: BLIND_RELAY_PROTOCOL_VERSION,
|
|
1137
|
+
metrics: {
|
|
1138
|
+
...metrics,
|
|
1139
|
+
activeConnections: sockets.size,
|
|
1140
|
+
activeHosts: hosts.size,
|
|
1141
|
+
activeDevices: devicesByChannel.size
|
|
1142
|
+
}
|
|
1143
|
+
}));
|
|
1144
|
+
app.get("/v1/routes", { preHandler: requireRoot }, async () => ({ routes: store.listRoutes().map(publicRoute) }));
|
|
1145
|
+
app.post(
|
|
1146
|
+
"/v1/routes",
|
|
1147
|
+
{ preHandler: requireRoot },
|
|
1148
|
+
async (request, reply) => {
|
|
1149
|
+
try {
|
|
1150
|
+
const hostCredential = generateRelayCredential("rrh");
|
|
1151
|
+
const route = store.createRoute({
|
|
1152
|
+
...request.body?.id === void 0 ? {} : { id: safeId3(request.body.id, "route id") },
|
|
1153
|
+
label: safeLabel3(request.body?.label),
|
|
1154
|
+
hostCredentialHash: relayCredentialHash(hostCredential)
|
|
1155
|
+
});
|
|
1156
|
+
reply.code(201).send({
|
|
1157
|
+
route: {
|
|
1158
|
+
id: route.id,
|
|
1159
|
+
label: route.label,
|
|
1160
|
+
deviceCount: 0,
|
|
1161
|
+
createdAt: route.createdAt,
|
|
1162
|
+
updatedAt: route.updatedAt
|
|
1163
|
+
},
|
|
1164
|
+
hostCredential
|
|
1165
|
+
});
|
|
1166
|
+
} catch (error) {
|
|
1167
|
+
const conflict = error.message === "relay route already exists";
|
|
1168
|
+
reply.code(conflict ? 409 : 400).send({
|
|
1169
|
+
code: conflict ? "RELAY_ROUTE_EXISTS" : "INVALID_RELAY_ROUTE",
|
|
1170
|
+
error: conflict ? "relay route already exists" : "invalid relay route"
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
);
|
|
1175
|
+
app.delete(
|
|
1176
|
+
"/v1/routes/:routeId",
|
|
1177
|
+
{ preHandler: requireRoot },
|
|
1178
|
+
async (request, reply) => {
|
|
1179
|
+
let removed = false;
|
|
1180
|
+
try {
|
|
1181
|
+
removed = store.deleteRoute(request.params.routeId);
|
|
1182
|
+
} catch {
|
|
1183
|
+
}
|
|
1184
|
+
if (!removed) {
|
|
1185
|
+
reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
clearRouteRates(request.params.routeId);
|
|
1189
|
+
const host = hosts.get(request.params.routeId);
|
|
1190
|
+
host?.socket.close(4403, "route deleted");
|
|
1191
|
+
for (const device of host?.devices.values() ?? []) device.socket.close(4403, "route deleted");
|
|
1192
|
+
reply.code(204).send();
|
|
1193
|
+
}
|
|
1194
|
+
);
|
|
1195
|
+
app.get(
|
|
1196
|
+
"/v1/routes/:routeId/status",
|
|
1197
|
+
{ preHandler: requireHost },
|
|
1198
|
+
async (request) => ({
|
|
1199
|
+
routeId: request.params.routeId,
|
|
1200
|
+
hostOnline: hosts.has(request.params.routeId),
|
|
1201
|
+
activeDevices: hosts.get(request.params.routeId)?.devices.size ?? 0
|
|
1202
|
+
})
|
|
1203
|
+
);
|
|
1204
|
+
app.put("/v1/routes/:routeId/devices/:deviceId", { preHandler: requireHost }, async (request, reply) => {
|
|
1205
|
+
try {
|
|
1206
|
+
const route = store.getRoute(request.params.routeId);
|
|
1207
|
+
if (route?.ownerAccountId && !store.getDevice(route.id, request.params.deviceId, now())) {
|
|
1208
|
+
const account = accountStore?.getAccount(route.ownerAccountId);
|
|
1209
|
+
if (!account || store.countDevices(route.id, now()) >= account.maxDevicesPerRoute) {
|
|
1210
|
+
reply.code(429).send({ code: "RELAY_DEVICE_LIMIT", error: "relay device limit reached" });
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
const device = store.putDevice({
|
|
1215
|
+
routeId: request.params.routeId,
|
|
1216
|
+
deviceId: request.params.deviceId,
|
|
1217
|
+
credentialHash: typeof request.body?.credentialHash === "string" ? request.body.credentialHash : "invalid",
|
|
1218
|
+
...request.body?.expiresAt === void 0 ? {} : { expiresAt: safeExpiry2(request.body.expiresAt, now()) }
|
|
1219
|
+
});
|
|
1220
|
+
reply.code(200).send({
|
|
1221
|
+
device: {
|
|
1222
|
+
routeId: device.routeId,
|
|
1223
|
+
deviceId: device.deviceId,
|
|
1224
|
+
createdAt: device.createdAt,
|
|
1225
|
+
updatedAt: device.updatedAt
|
|
1226
|
+
}
|
|
1227
|
+
});
|
|
1228
|
+
} catch {
|
|
1229
|
+
reply.code(400).send({ code: "INVALID_RELAY_DEVICE", error: "invalid relay device route" });
|
|
1230
|
+
}
|
|
1231
|
+
});
|
|
1232
|
+
app.delete(
|
|
1233
|
+
"/v1/routes/:routeId/devices/:deviceId",
|
|
1234
|
+
{ preHandler: requireHost },
|
|
1235
|
+
async (request, reply) => {
|
|
1236
|
+
let removed = false;
|
|
1237
|
+
try {
|
|
1238
|
+
removed = store.revokeDevice(request.params.routeId, request.params.deviceId);
|
|
1239
|
+
} catch {
|
|
1240
|
+
}
|
|
1241
|
+
if (!removed) {
|
|
1242
|
+
reply.code(404).send({ code: "RELAY_DEVICE_NOT_FOUND", error: "relay device not found" });
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
deviceRates.delete(deviceRateKey(request.params.routeId, request.params.deviceId));
|
|
1246
|
+
const live = hosts.get(request.params.routeId)?.devices.get(request.params.deviceId);
|
|
1247
|
+
live?.socket.close(4403, "device revoked");
|
|
1248
|
+
reply.code(204).send();
|
|
1249
|
+
}
|
|
1250
|
+
);
|
|
1251
|
+
const closeDevice = (device, code = 1e3, reason = "device disconnected") => {
|
|
1252
|
+
if (device.closed) return;
|
|
1253
|
+
device.closed = true;
|
|
1254
|
+
if (device.idle) clearTimeout(device.idle);
|
|
1255
|
+
devicesByChannel.delete(device.channelId);
|
|
1256
|
+
const host = hosts.get(device.routeId);
|
|
1257
|
+
if (host?.devices.get(device.deviceId) === device) host.devices.delete(device.deviceId);
|
|
1258
|
+
metrics.activeDevices = Math.max(0, metrics.activeDevices - 1);
|
|
1259
|
+
if (host && !safeSend(host.socket, { t: "peer-close", channelId: device.channelId, code }, maxQueueBytes)) {
|
|
1260
|
+
metrics.droppedFrames += 1;
|
|
1261
|
+
closeHost(host, 4408, "relay backpressure");
|
|
1262
|
+
}
|
|
1263
|
+
if (device.socket.readyState === device.socket.OPEN) device.socket.close(code, reason);
|
|
1264
|
+
};
|
|
1265
|
+
const touchDevice = (device) => {
|
|
1266
|
+
if (device.idle) clearTimeout(device.idle);
|
|
1267
|
+
device.idle = setTimeout(() => closeDevice(device, 4408, "idle timeout"), idleTimeoutMs);
|
|
1268
|
+
device.idle.unref?.();
|
|
1269
|
+
};
|
|
1270
|
+
const closeHost = (host, code = 1e3, reason = "host disconnected") => {
|
|
1271
|
+
if (host.closed) return;
|
|
1272
|
+
host.closed = true;
|
|
1273
|
+
if (host.idle) clearTimeout(host.idle);
|
|
1274
|
+
if (hosts.get(host.routeId) === host) hosts.delete(host.routeId);
|
|
1275
|
+
for (const device of [...host.devices.values()]) closeDevice(device, 4412, "host unavailable");
|
|
1276
|
+
metrics.activeHosts = Math.max(0, metrics.activeHosts - 1);
|
|
1277
|
+
if (host.socket.readyState === host.socket.OPEN) host.socket.close(code, reason);
|
|
1278
|
+
};
|
|
1279
|
+
const touchHost = (host) => {
|
|
1280
|
+
if (host.idle) clearTimeout(host.idle);
|
|
1281
|
+
host.idle = setTimeout(() => closeHost(host, 4408, "idle timeout"), idleTimeoutMs);
|
|
1282
|
+
host.idle.unref?.();
|
|
1283
|
+
};
|
|
1284
|
+
if (accountStore) {
|
|
1285
|
+
const accountEnvelope = (account) => ({
|
|
1286
|
+
account,
|
|
1287
|
+
usage: { routes: store.listRoutesByOwner(account.id, now()).length, maxRoutes: account.maxRoutes }
|
|
1288
|
+
});
|
|
1289
|
+
const closeAccountRoutes = (accountId, reason) => {
|
|
1290
|
+
for (const host of [...hosts.values()]) if (host.ownerAccountId === accountId) closeHost(host, 4403, reason);
|
|
1291
|
+
};
|
|
1292
|
+
const purgeAccountRoutes = (accountId) => {
|
|
1293
|
+
closeAccountRoutes(accountId, "account deleted");
|
|
1294
|
+
for (const route of store.listRoutesByOwner(accountId, now())) {
|
|
1295
|
+
store.deleteRoute(route.id);
|
|
1296
|
+
clearRouteRates(route.id);
|
|
1297
|
+
}
|
|
1298
|
+
};
|
|
1299
|
+
for (const listedRoute of store.listRoutes(now())) {
|
|
1300
|
+
const route = store.getRoute(listedRoute.id);
|
|
1301
|
+
if (!route?.ownerAccountId) continue;
|
|
1302
|
+
const owner = accountStore.getAccount(route.ownerAccountId);
|
|
1303
|
+
if (!owner || owner.status === "deleted") {
|
|
1304
|
+
store.deleteRoute(route.id);
|
|
1305
|
+
clearRouteRates(route.id);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
const ownedRoute = (accountId, routeId) => {
|
|
1309
|
+
const route = store.getRoute(routeId);
|
|
1310
|
+
return route?.ownerAccountId === accountId ? route : void 0;
|
|
1311
|
+
};
|
|
1312
|
+
app.get("/v1/accounts", { preHandler: requireRoot }, async () => ({
|
|
1313
|
+
accounts: accountStore.listAccounts().map((account) => accountEnvelope(account))
|
|
1314
|
+
}));
|
|
1315
|
+
const createAccountHandler = (clientHashedOnly) => async (request, reply) => {
|
|
1316
|
+
try {
|
|
1317
|
+
const hasCredentialHash = request.body?.credentialHash !== void 0;
|
|
1318
|
+
const hasCredentialLookup = request.body?.credentialLookup !== void 0;
|
|
1319
|
+
if (clientHashedOnly && (!hasCredentialHash || !hasCredentialLookup)) {
|
|
1320
|
+
throw new Error("client-hashed account credential material is required");
|
|
1321
|
+
}
|
|
1322
|
+
const suppliedCredentialMaterial = hasCredentialHash || hasCredentialLookup;
|
|
1323
|
+
const accountCredential = suppliedCredentialMaterial ? void 0 : generateRelayAccountCredential();
|
|
1324
|
+
const credential = suppliedCredentialMaterial ? {
|
|
1325
|
+
credentialHash: request.body?.credentialHash,
|
|
1326
|
+
credentialLookup: request.body?.credentialLookup
|
|
1327
|
+
} : accountCredential;
|
|
1328
|
+
const account = accountStore.createAccount({
|
|
1329
|
+
label: request.body?.label,
|
|
1330
|
+
...request.body?.plan === void 0 ? {} : { plan: request.body.plan },
|
|
1331
|
+
...request.body?.maxRoutes === void 0 ? {} : { maxRoutes: request.body.maxRoutes },
|
|
1332
|
+
...request.body?.maxDevicesPerRoute === void 0 ? {} : { maxDevicesPerRoute: request.body.maxDevicesPerRoute },
|
|
1333
|
+
...typeof credential === "string" ? { credential } : {
|
|
1334
|
+
credentialHash: credential.credentialHash,
|
|
1335
|
+
credentialLookup: credential.credentialLookup
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
1338
|
+
reply.code(201).send({
|
|
1339
|
+
...accountEnvelope(account),
|
|
1340
|
+
...accountCredential === void 0 ? {} : { accountCredential }
|
|
1341
|
+
});
|
|
1342
|
+
} catch {
|
|
1343
|
+
reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
app.post(
|
|
1347
|
+
"/v1/accounts",
|
|
1348
|
+
{ preHandler: requireRoot },
|
|
1349
|
+
createAccountHandler(false)
|
|
1350
|
+
);
|
|
1351
|
+
app.post(
|
|
1352
|
+
"/v1/accounts/client-hashed",
|
|
1353
|
+
{ preHandler: requireRoot },
|
|
1354
|
+
createAccountHandler(true)
|
|
1355
|
+
);
|
|
1356
|
+
app.patch(
|
|
1357
|
+
"/v1/accounts/:accountId",
|
|
1358
|
+
{ preHandler: requireRoot },
|
|
1359
|
+
async (request, reply) => {
|
|
1360
|
+
try {
|
|
1361
|
+
const account = accountStore.updateAccount(
|
|
1362
|
+
request.params.accountId,
|
|
1363
|
+
request.body,
|
|
1364
|
+
safeRevision(request.body?.expectedRevision)
|
|
1365
|
+
);
|
|
1366
|
+
if (!account) {
|
|
1367
|
+
reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
if (account.status === "deleted") purgeAccountRoutes(account.id);
|
|
1371
|
+
else if (account.status !== "active") closeAccountRoutes(account.id, "account unavailable");
|
|
1372
|
+
reply.code(200).send(accountEnvelope(account));
|
|
1373
|
+
} catch (error) {
|
|
1374
|
+
if (error instanceof RelayAccountRevisionConflictError) {
|
|
1375
|
+
reply.code(409).send({
|
|
1376
|
+
code: "RELAY_ACCOUNT_REVISION_CONFLICT",
|
|
1377
|
+
error: "relay account revision conflict",
|
|
1378
|
+
current: accountEnvelope(error.current)
|
|
1379
|
+
});
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
);
|
|
1386
|
+
const rotateAccountHandler = (clientHashedOnly) => async (request, reply) => {
|
|
1387
|
+
try {
|
|
1388
|
+
const hasCredentialHash = request.body?.credentialHash !== void 0;
|
|
1389
|
+
const hasCredentialLookup = request.body?.credentialLookup !== void 0;
|
|
1390
|
+
if (clientHashedOnly && (!hasCredentialHash || !hasCredentialLookup)) {
|
|
1391
|
+
throw new Error("client-hashed account credential material is required");
|
|
1392
|
+
}
|
|
1393
|
+
const suppliedCredentialMaterial = hasCredentialHash || hasCredentialLookup;
|
|
1394
|
+
const accountCredential = suppliedCredentialMaterial ? void 0 : generateRelayAccountCredential();
|
|
1395
|
+
const credential = suppliedCredentialMaterial ? {
|
|
1396
|
+
credentialHash: request.body?.credentialHash,
|
|
1397
|
+
credentialLookup: request.body?.credentialLookup
|
|
1398
|
+
} : accountCredential;
|
|
1399
|
+
const account = accountStore.rotateCredential(
|
|
1400
|
+
request.params.accountId,
|
|
1401
|
+
credential,
|
|
1402
|
+
safeRevision(request.body?.expectedRevision)
|
|
1403
|
+
);
|
|
1404
|
+
if (!account) {
|
|
1405
|
+
reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
reply.code(200).send({
|
|
1409
|
+
...accountEnvelope(account),
|
|
1410
|
+
...accountCredential === void 0 ? {} : { accountCredential }
|
|
1411
|
+
});
|
|
1412
|
+
} catch (error) {
|
|
1413
|
+
if (error instanceof RelayAccountRevisionConflictError) {
|
|
1414
|
+
reply.code(409).send({
|
|
1415
|
+
code: "RELAY_ACCOUNT_REVISION_CONFLICT",
|
|
1416
|
+
error: "relay account revision conflict",
|
|
1417
|
+
current: accountEnvelope(error.current)
|
|
1418
|
+
});
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
app.post(
|
|
1425
|
+
"/v1/accounts/:accountId/credential",
|
|
1426
|
+
{ preHandler: requireRoot },
|
|
1427
|
+
rotateAccountHandler(false)
|
|
1428
|
+
);
|
|
1429
|
+
app.post(
|
|
1430
|
+
"/v1/accounts/:accountId/credential/client-hashed",
|
|
1431
|
+
{ preHandler: requireRoot },
|
|
1432
|
+
rotateAccountHandler(true)
|
|
1433
|
+
);
|
|
1434
|
+
app.get(
|
|
1435
|
+
"/v1/account/recovery",
|
|
1436
|
+
{ preHandler: requireRecoverableAccount },
|
|
1437
|
+
async (request) => accountEnvelope(authenticatedAccounts.get(request))
|
|
1438
|
+
);
|
|
1439
|
+
app.get(
|
|
1440
|
+
"/v1/account",
|
|
1441
|
+
{ preHandler: requireAccount },
|
|
1442
|
+
async (request) => accountEnvelope(authenticatedAccounts.get(request))
|
|
1443
|
+
);
|
|
1444
|
+
app.get("/v1/account/routes", { preHandler: requireAccount }, async (request) => ({
|
|
1445
|
+
routes: store.listRoutesByOwner(authenticatedAccounts.get(request).id, now()).map((route) => ({
|
|
1446
|
+
...publicRoute(route),
|
|
1447
|
+
hostOnline: hosts.has(route.id)
|
|
1448
|
+
}))
|
|
1449
|
+
}));
|
|
1450
|
+
app.post(
|
|
1451
|
+
"/v1/account/routes",
|
|
1452
|
+
{ preHandler: requireAccount },
|
|
1453
|
+
async (request, reply) => {
|
|
1454
|
+
const account = authenticatedAccounts.get(request);
|
|
1455
|
+
let id;
|
|
1456
|
+
let label;
|
|
1457
|
+
let credentialHash;
|
|
1458
|
+
try {
|
|
1459
|
+
id = safeId3(request.body?.id, "route id");
|
|
1460
|
+
label = safeLabel3(request.body?.label);
|
|
1461
|
+
credentialHash = safeCredentialHash2(request.body?.credentialHash);
|
|
1462
|
+
} catch {
|
|
1463
|
+
reply.code(400).send({ code: "INVALID_RELAY_ROUTE", error: "invalid relay route" });
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
const existing = store.getRoute(id);
|
|
1467
|
+
if (existing) {
|
|
1468
|
+
if (existing.ownerAccountId !== account.id || existing.label !== label || !tokenMatches(existing.hostCredentialHash, credentialHash)) {
|
|
1469
|
+
reply.code(409).send({ code: "RELAY_ROUTE_EXISTS", error: "relay route already exists" });
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
reply.code(200).send({
|
|
1473
|
+
route: {
|
|
1474
|
+
...publicRoute({ ...existing, deviceCount: store.countDevices(existing.id, now()) }),
|
|
1475
|
+
hostOnline: hosts.has(id)
|
|
1476
|
+
},
|
|
1477
|
+
connection: { path: "/v1/connect", protocolVersion: BLIND_RELAY_PROTOCOL_VERSION }
|
|
1478
|
+
});
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
if (store.listRoutesByOwner(account.id, now()).length >= account.maxRoutes) {
|
|
1482
|
+
reply.code(429).send({ code: "RELAY_ROUTE_LIMIT", error: "relay route limit reached" });
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
try {
|
|
1486
|
+
const route = store.createRoute({
|
|
1487
|
+
id,
|
|
1488
|
+
label,
|
|
1489
|
+
hostCredentialHash: credentialHash,
|
|
1490
|
+
ownerAccountId: account.id
|
|
1491
|
+
});
|
|
1492
|
+
reply.code(201).send({
|
|
1493
|
+
route: { ...publicRoute({ ...route, deviceCount: 0 }), hostOnline: false },
|
|
1494
|
+
connection: { path: "/v1/connect", protocolVersion: BLIND_RELAY_PROTOCOL_VERSION }
|
|
1495
|
+
});
|
|
1496
|
+
} catch {
|
|
1497
|
+
reply.code(400).send({ code: "INVALID_RELAY_ROUTE", error: "invalid relay route" });
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
);
|
|
1501
|
+
app.delete(
|
|
1502
|
+
"/v1/account/routes/:routeId",
|
|
1503
|
+
{ preHandler: requireAccount },
|
|
1504
|
+
async (request, reply) => {
|
|
1505
|
+
const account = authenticatedAccounts.get(request);
|
|
1506
|
+
let removed = false;
|
|
1507
|
+
try {
|
|
1508
|
+
removed = !!ownedRoute(account.id, request.params.routeId) && store.deleteRoute(request.params.routeId);
|
|
1509
|
+
} catch {
|
|
1510
|
+
}
|
|
1511
|
+
if (!removed) {
|
|
1512
|
+
reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
clearRouteRates(request.params.routeId);
|
|
1516
|
+
const host = hosts.get(request.params.routeId);
|
|
1517
|
+
if (host) closeHost(host, 4403, "route deleted");
|
|
1518
|
+
reply.code(204).send();
|
|
1519
|
+
}
|
|
1520
|
+
);
|
|
1521
|
+
app.post(
|
|
1522
|
+
"/v1/account/routes/:routeId/credential",
|
|
1523
|
+
{ preHandler: requireAccount },
|
|
1524
|
+
async (request, reply) => {
|
|
1525
|
+
const account = authenticatedAccounts.get(request);
|
|
1526
|
+
try {
|
|
1527
|
+
if (!ownedRoute(account.id, request.params.routeId)) {
|
|
1528
|
+
reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
} catch {
|
|
1532
|
+
reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
let credentialHash;
|
|
1536
|
+
try {
|
|
1537
|
+
credentialHash = safeCredentialHash2(request.body?.credentialHash);
|
|
1538
|
+
} catch {
|
|
1539
|
+
reply.code(400).send({ code: "INVALID_RELAY_CREDENTIAL", error: "invalid relay credential hash" });
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
if (!store.rotateHostCredential(request.params.routeId, credentialHash, now())) {
|
|
1543
|
+
reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
const host = hosts.get(request.params.routeId);
|
|
1547
|
+
if (host) closeHost(host, 4409, "host credential rotated");
|
|
1548
|
+
reply.code(204).send();
|
|
1549
|
+
}
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
app.register(websocket, { options: { maxPayload: maxEnvelopeBytes, perMessageDeflate: false } });
|
|
1553
|
+
app.register(async (scope) => {
|
|
1554
|
+
scope.get("/v1/connect", { websocket: true }, (socket, request) => {
|
|
1555
|
+
if (sockets.size >= maxTotalConnections) {
|
|
1556
|
+
metrics.rejectedConnections += 1;
|
|
1557
|
+
socket.close(4429, "relay connection limit");
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
sockets.add(socket);
|
|
1561
|
+
let authenticated = false;
|
|
1562
|
+
let liveHost;
|
|
1563
|
+
let liveDevice;
|
|
1564
|
+
const handshakeTimer = setTimeout(() => socket.close(4401, "authentication timeout"), handshakeTimeoutMs);
|
|
1565
|
+
handshakeTimer.unref?.();
|
|
1566
|
+
const reject = (code, reason) => {
|
|
1567
|
+
metrics.rejectedConnections += 1;
|
|
1568
|
+
socket.close(code, reason);
|
|
1569
|
+
};
|
|
1570
|
+
socket.on("message", (raw, isBinary) => {
|
|
1571
|
+
if (isBinary) {
|
|
1572
|
+
metrics.droppedFrames += 1;
|
|
1573
|
+
reject(4400, "text envelope required");
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
if (!authenticated) {
|
|
1577
|
+
try {
|
|
1578
|
+
const hello = parseAuthHello(parseJson(raw, 8 * 1024));
|
|
1579
|
+
const presentedOrigin = request.headers.origin;
|
|
1580
|
+
const origin = typeof presentedOrigin === "string" ? normalizeOrigin(presentedOrigin) : void 0;
|
|
1581
|
+
if (hello.role === "device" && allowedOrigins.size > 0 && presentedOrigin !== void 0 && (!origin || !allowedOrigins.has(origin))) {
|
|
1582
|
+
reject(4403, "origin denied");
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
if (hello.role === "host") {
|
|
1586
|
+
const route = store.getRoute(hello.routeId);
|
|
1587
|
+
if (!route || route.ownerAccountId !== void 0 && accountStore?.getAccount(route.ownerAccountId)?.status !== "active" || !store.authenticateHost(hello.routeId, hello.credential)) {
|
|
1588
|
+
reject(4401, "authentication failed");
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
const rate = rateWindowFor(hostRates, hello.routeId);
|
|
1592
|
+
if (!rate || !consumeRate(rate, Buffer.byteLength(raw.toString()))) {
|
|
1593
|
+
reject(4429, "relay identity rate limit");
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
const previous = hosts.get(hello.routeId);
|
|
1597
|
+
if (previous) closeHost(previous, 4409, "superseded host");
|
|
1598
|
+
liveHost = {
|
|
1599
|
+
socket,
|
|
1600
|
+
routeId: hello.routeId,
|
|
1601
|
+
...route.ownerAccountId === void 0 ? {} : { ownerAccountId: route.ownerAccountId },
|
|
1602
|
+
devices: /* @__PURE__ */ new Map(),
|
|
1603
|
+
rate,
|
|
1604
|
+
closed: false
|
|
1605
|
+
};
|
|
1606
|
+
hosts.set(hello.routeId, liveHost);
|
|
1607
|
+
metrics.activeHosts += 1;
|
|
1608
|
+
touchHost(liveHost);
|
|
1609
|
+
if (!safeSend(socket, { t: "ready", role: "host", protocolVersion: 1 }, maxQueueBytes)) {
|
|
1610
|
+
metrics.rejectedConnections += 1;
|
|
1611
|
+
closeHost(liveHost, 4408, "relay backpressure");
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
} else {
|
|
1615
|
+
if (!routeAccountIsActive(hello.routeId) || !store.authenticateDevice(hello.routeId, hello.deviceId, hello.credential, now())) {
|
|
1616
|
+
reject(4401, "authentication failed");
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
const host = hosts.get(hello.routeId);
|
|
1620
|
+
if (!host || host.socket.readyState !== host.socket.OPEN) {
|
|
1621
|
+
reject(4412, "host unavailable");
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
if (host.devices.size >= maxConnectionsPerRoute && !host.devices.has(hello.deviceId)) {
|
|
1625
|
+
reject(4429, "route connection limit");
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
const rate = rateWindowFor(deviceRates, deviceRateKey(hello.routeId, hello.deviceId));
|
|
1629
|
+
if (!rate || !consumeRate(rate, Buffer.byteLength(raw.toString()))) {
|
|
1630
|
+
reject(4429, "relay identity rate limit");
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
const previous = host.devices.get(hello.deviceId);
|
|
1634
|
+
if (previous) closeDevice(previous, 4409, "superseded device");
|
|
1635
|
+
const channelId = safeId3(generateChannelId(), "channel id");
|
|
1636
|
+
liveDevice = {
|
|
1637
|
+
socket,
|
|
1638
|
+
routeId: hello.routeId,
|
|
1639
|
+
deviceId: hello.deviceId,
|
|
1640
|
+
channelId,
|
|
1641
|
+
rate,
|
|
1642
|
+
closed: false
|
|
1643
|
+
};
|
|
1644
|
+
host.devices.set(hello.deviceId, liveDevice);
|
|
1645
|
+
devicesByChannel.set(channelId, liveDevice);
|
|
1646
|
+
metrics.activeDevices += 1;
|
|
1647
|
+
touchDevice(liveDevice);
|
|
1648
|
+
if (!safeSend(socket, { t: "ready", role: "device", protocolVersion: 1, channelId }, maxQueueBytes)) {
|
|
1649
|
+
metrics.rejectedConnections += 1;
|
|
1650
|
+
closeDevice(liveDevice, 4408, "relay backpressure");
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
if (!safeSend(host.socket, { t: "peer-open", channelId, deviceId: hello.deviceId }, maxQueueBytes)) {
|
|
1654
|
+
metrics.rejectedConnections += 1;
|
|
1655
|
+
closeDevice(liveDevice, 4408, "host backpressure");
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
authenticated = true;
|
|
1660
|
+
metrics.acceptedConnections += 1;
|
|
1661
|
+
clearTimeout(handshakeTimer);
|
|
1662
|
+
} catch {
|
|
1663
|
+
reject(4400, "invalid authentication frame");
|
|
1664
|
+
}
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
try {
|
|
1668
|
+
const value = parseJson(raw, maxEnvelopeBytes);
|
|
1669
|
+
if (liveDevice) {
|
|
1670
|
+
touchDevice(liveDevice);
|
|
1671
|
+
if (value?.t === "ping") {
|
|
1672
|
+
if (!consumeRate(liveDevice.rate, 1)) {
|
|
1673
|
+
closeDevice(liveDevice, 4429, "rate limit");
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
if (!safeSend(socket, { t: "pong", at: now() }, maxQueueBytes)) {
|
|
1677
|
+
metrics.droppedFrames += 1;
|
|
1678
|
+
closeDevice(liveDevice, 4408, "relay backpressure");
|
|
1679
|
+
}
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
const frame = parsePayload(value, false, maxFrameBytes);
|
|
1683
|
+
if (!consumeRate(liveDevice.rate, frame.bytes)) {
|
|
1684
|
+
closeDevice(liveDevice, 4429, "rate limit");
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
const host = hosts.get(liveDevice.routeId);
|
|
1688
|
+
if (!host || !safeSend(
|
|
1689
|
+
host.socket,
|
|
1690
|
+
{ t: "frame", channelId: liveDevice.channelId, payload: frame.payload },
|
|
1691
|
+
maxQueueBytes
|
|
1692
|
+
)) {
|
|
1693
|
+
metrics.droppedFrames += 1;
|
|
1694
|
+
closeDevice(liveDevice, 4408, "host unavailable or slow");
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
metrics.forwardedFrames += 1;
|
|
1698
|
+
metrics.forwardedBytes += frame.bytes;
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
if (liveHost) {
|
|
1702
|
+
touchHost(liveHost);
|
|
1703
|
+
if (value?.t === "ping") {
|
|
1704
|
+
if (!consumeRate(liveHost.rate, 1)) {
|
|
1705
|
+
closeHost(liveHost, 4429, "rate limit");
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
if (!safeSend(socket, { t: "pong", at: now() }, maxQueueBytes)) {
|
|
1709
|
+
metrics.droppedFrames += 1;
|
|
1710
|
+
closeHost(liveHost, 4408, "relay backpressure");
|
|
1711
|
+
}
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
if (value?.t === "close-peer") {
|
|
1715
|
+
const channelId = safeId3(value.channelId, "channel id");
|
|
1716
|
+
if (!consumeRate(liveHost.rate, 1)) {
|
|
1717
|
+
closeHost(liveHost, 4429, "rate limit");
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
const device2 = devicesByChannel.get(channelId);
|
|
1721
|
+
if (device2?.routeId === liveHost.routeId) closeDevice(device2, 4400, "host closed channel");
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
const frame = parsePayload(value, true, maxFrameBytes);
|
|
1725
|
+
if (!consumeRate(liveHost.rate, frame.bytes)) {
|
|
1726
|
+
closeHost(liveHost, 4429, "rate limit");
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
const device = devicesByChannel.get(frame.channelId);
|
|
1730
|
+
if (!device || device.routeId !== liveHost.routeId) {
|
|
1731
|
+
metrics.droppedFrames += 1;
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
if (!safeSend(device.socket, { t: "frame", payload: frame.payload }, maxQueueBytes)) {
|
|
1735
|
+
metrics.droppedFrames += 1;
|
|
1736
|
+
closeDevice(device, 4408, "device backpressure");
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
metrics.forwardedFrames += 1;
|
|
1740
|
+
metrics.forwardedBytes += frame.bytes;
|
|
1741
|
+
}
|
|
1742
|
+
} catch {
|
|
1743
|
+
metrics.droppedFrames += 1;
|
|
1744
|
+
if (liveDevice) closeDevice(liveDevice, 4400, "invalid relay frame");
|
|
1745
|
+
else if (liveHost) closeHost(liveHost, 4400, "invalid relay frame");
|
|
1746
|
+
}
|
|
1747
|
+
});
|
|
1748
|
+
socket.once("close", () => {
|
|
1749
|
+
sockets.delete(socket);
|
|
1750
|
+
clearTimeout(handshakeTimer);
|
|
1751
|
+
if (liveDevice) closeDevice(liveDevice);
|
|
1752
|
+
if (liveHost) closeHost(liveHost);
|
|
1753
|
+
});
|
|
1754
|
+
socket.once("error", () => {
|
|
1755
|
+
});
|
|
1756
|
+
});
|
|
1757
|
+
});
|
|
1758
|
+
app.addHook("onClose", async () => {
|
|
1759
|
+
for (const host of [...hosts.values()]) closeHost(host, 1001, "relay shutting down");
|
|
1760
|
+
for (const socket of [...sockets]) socket.close(1001, "relay shutting down");
|
|
1761
|
+
hostRates.clear();
|
|
1762
|
+
deviceRates.clear();
|
|
1763
|
+
if (ownsStore) store.close();
|
|
1764
|
+
});
|
|
1765
|
+
return {
|
|
1766
|
+
app,
|
|
1767
|
+
store,
|
|
1768
|
+
...accountStore ? { accountStore } : {},
|
|
1769
|
+
metrics: () => ({
|
|
1770
|
+
...metrics,
|
|
1771
|
+
activeConnections: sockets.size,
|
|
1772
|
+
activeHosts: hosts.size,
|
|
1773
|
+
activeDevices: devicesByChannel.size
|
|
1774
|
+
})
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// src/data-dir.ts
|
|
1779
|
+
import {
|
|
1780
|
+
closeSync,
|
|
1781
|
+
constants,
|
|
1782
|
+
existsSync,
|
|
1783
|
+
fchmodSync,
|
|
1784
|
+
fstatSync,
|
|
1785
|
+
fsyncSync,
|
|
1786
|
+
linkSync,
|
|
1787
|
+
lstatSync,
|
|
1788
|
+
mkdirSync,
|
|
1789
|
+
openSync,
|
|
1790
|
+
readFileSync,
|
|
1791
|
+
renameSync,
|
|
1792
|
+
unlinkSync,
|
|
1793
|
+
writeFileSync
|
|
1794
|
+
} from "fs";
|
|
1795
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
1796
|
+
import { dirname, join } from "path";
|
|
1797
|
+
var MAX_ACCESS_TOKEN_BYTES = 4 * 1024;
|
|
1798
|
+
var MAX_ACCESS_TOKEN_FILE_BYTES = MAX_ACCESS_TOKEN_BYTES + 2;
|
|
1799
|
+
function resolveDataDir(env, exists = existsSync) {
|
|
1800
|
+
if (env.ROAMCODE_DATA_DIR) return env.ROAMCODE_DATA_DIR;
|
|
1801
|
+
if (env.REMOTE_CODER_DATA_DIR) return env.REMOTE_CODER_DATA_DIR;
|
|
1802
|
+
const pick = (next, legacy) => !exists(next) && exists(legacy) ? legacy : next;
|
|
1803
|
+
if (env.XDG_CONFIG_HOME)
|
|
1804
|
+
return pick(join(env.XDG_CONFIG_HOME, "roamcode"), join(env.XDG_CONFIG_HOME, "remote-coder"));
|
|
1805
|
+
if (env.HOME) return pick(join(env.HOME, ".config", "roamcode"), join(env.HOME, ".config", "remote-coder"));
|
|
1806
|
+
return pick(join(process.cwd(), ".roamcode"), join(process.cwd(), ".remote-coder"));
|
|
1807
|
+
}
|
|
1808
|
+
function ensureDataDir(dir) {
|
|
1809
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
// src/relay-start.ts
|
|
1813
|
+
function boundedInteger(value, fallback, minimum, maximum, field) {
|
|
1814
|
+
const parsed = value === void 0 ? fallback : Number(value);
|
|
1815
|
+
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) throw new Error(`invalid ${field}`);
|
|
1816
|
+
return parsed;
|
|
1817
|
+
}
|
|
1818
|
+
function relayDataDir(env) {
|
|
1819
|
+
const configured = env.ROAMCODE_RELAY_DATA_DIR?.trim();
|
|
1820
|
+
return configured ? resolve(configured) : join2(resolveDataDir(env), "relay");
|
|
1821
|
+
}
|
|
1822
|
+
function relayOrigins(env) {
|
|
1823
|
+
return (env.ROAMCODE_RELAY_ALLOWED_ORIGINS ?? "").split(",").map((value) => value.trim()).filter(Boolean);
|
|
1824
|
+
}
|
|
1825
|
+
function privateSecretFile(path, field) {
|
|
1826
|
+
let descriptor;
|
|
1827
|
+
try {
|
|
1828
|
+
const before = lstatSync2(path);
|
|
1829
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > 4096 || (before.mode & 63) !== 0 || typeof process.getuid === "function" && before.uid !== process.getuid()) {
|
|
1830
|
+
throw new Error("unsafe secret file");
|
|
1831
|
+
}
|
|
1832
|
+
descriptor = openSync2(path, constants2.O_RDONLY | (constants2.O_NOFOLLOW ?? 0));
|
|
1833
|
+
const opened = fstatSync2(descriptor);
|
|
1834
|
+
if (!opened.isFile() || opened.size > 4096 || (opened.mode & 63) !== 0 || typeof process.getuid === "function" && opened.uid !== process.getuid() || opened.dev !== before.dev || opened.ino !== before.ino) {
|
|
1835
|
+
throw new Error("unsafe secret file");
|
|
1836
|
+
}
|
|
1837
|
+
return readFileSync2(descriptor, "utf8").trim();
|
|
1838
|
+
} catch {
|
|
1839
|
+
throw new Error(`${field} could not be read securely`);
|
|
1840
|
+
} finally {
|
|
1841
|
+
if (descriptor !== void 0) closeSync2(descriptor);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
function previousRootTokens(env) {
|
|
1845
|
+
const inline = (env.ROAMCODE_RELAY_PREVIOUS_ROOT_TOKENS ?? "").split(",").map((value) => value.trim()).filter(Boolean);
|
|
1846
|
+
const directory = env.ROAMCODE_RELAY_PREVIOUS_ROOT_TOKEN_DIR?.trim();
|
|
1847
|
+
if (inline.length > 0 && directory) {
|
|
1848
|
+
throw new Error(
|
|
1849
|
+
"ROAMCODE_RELAY_PREVIOUS_ROOT_TOKENS and ROAMCODE_RELAY_PREVIOUS_ROOT_TOKEN_DIR are mutually exclusive"
|
|
1850
|
+
);
|
|
1851
|
+
}
|
|
1852
|
+
if (!directory) return inline;
|
|
1853
|
+
let before;
|
|
1854
|
+
let entries;
|
|
1855
|
+
try {
|
|
1856
|
+
before = lstatSync2(directory);
|
|
1857
|
+
if (!before.isDirectory() || before.isSymbolicLink() || (before.mode & 63) !== 0 || typeof process.getuid === "function" && before.uid !== process.getuid()) {
|
|
1858
|
+
throw new Error("unsafe secret directory");
|
|
1859
|
+
}
|
|
1860
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
1861
|
+
const after = lstatSync2(directory);
|
|
1862
|
+
if (after.dev !== before.dev || after.ino !== before.ino || (after.mode & 63) !== 0 || typeof process.getuid === "function" && after.uid !== process.getuid() || entries.some((entry) => !entry.isFile())) {
|
|
1863
|
+
throw new Error("unsafe secret directory");
|
|
1864
|
+
}
|
|
1865
|
+
} catch {
|
|
1866
|
+
throw new Error("ROAMCODE_RELAY_PREVIOUS_ROOT_TOKEN_DIR could not be read securely");
|
|
1867
|
+
}
|
|
1868
|
+
if (entries.length > 3) throw new Error("ROAMCODE_RELAY_PREVIOUS_ROOT_TOKEN_DIR may contain at most three files");
|
|
1869
|
+
return entries.map((entry) => entry.name).sort().map((name) => privateSecretFile(join2(directory, name), "previous relay root capability"));
|
|
1870
|
+
}
|
|
1871
|
+
function explicitBoolean(value, field) {
|
|
1872
|
+
if (value === void 0 || value === "" || value === "0" || value === "false") return false;
|
|
1873
|
+
if (value === "1" || value === "true") return true;
|
|
1874
|
+
throw new Error(`invalid ${field}`);
|
|
1875
|
+
}
|
|
1876
|
+
function secretValue(env, directKey, fileKey) {
|
|
1877
|
+
const direct = env[directKey]?.trim();
|
|
1878
|
+
const file = env[fileKey]?.trim();
|
|
1879
|
+
if (direct && file) throw new Error(`${directKey} and ${fileKey} are mutually exclusive`);
|
|
1880
|
+
if (direct) return direct;
|
|
1881
|
+
if (!file) return void 0;
|
|
1882
|
+
return privateSecretFile(file, fileKey);
|
|
1883
|
+
}
|
|
1884
|
+
async function startBlindRelay(env = process.env) {
|
|
1885
|
+
const rootToken = secretValue(env, "ROAMCODE_RELAY_ROOT_TOKEN", "ROAMCODE_RELAY_ROOT_TOKEN_FILE");
|
|
1886
|
+
if (!rootToken) throw new Error("ROAMCODE_RELAY_ROOT_TOKEN or ROAMCODE_RELAY_ROOT_TOKEN_FILE is required");
|
|
1887
|
+
const previousRoots = previousRootTokens(env);
|
|
1888
|
+
const allowedOrigins = relayOrigins(env);
|
|
1889
|
+
const allowAnyOrigin = explicitBoolean(env.ROAMCODE_RELAY_ALLOW_ANY_ORIGIN, "relay allow-any-origin flag");
|
|
1890
|
+
if (env.NODE_ENV === "production" && allowedOrigins.length === 0 && !allowAnyOrigin) {
|
|
1891
|
+
throw new Error(
|
|
1892
|
+
"ROAMCODE_RELAY_ALLOWED_ORIGINS is required in production; explicitly set ROAMCODE_RELAY_ALLOW_ANY_ORIGIN=1 only for a reviewed deployment"
|
|
1893
|
+
);
|
|
1894
|
+
}
|
|
1895
|
+
const dataDir = relayDataDir(env);
|
|
1896
|
+
ensureDataDir(dataDir);
|
|
1897
|
+
const store = openRelayRouteStore({ dbPath: join2(dataDir, "routes.db") });
|
|
1898
|
+
if (store.mode !== "sqlite") {
|
|
1899
|
+
store.close();
|
|
1900
|
+
throw new Error("relay requires durable SQLite; rebuild better-sqlite3 before starting");
|
|
1901
|
+
}
|
|
1902
|
+
const accountsEnabled = explicitBoolean(env.ROAMCODE_RELAY_ACCOUNTS_ENABLED, "relay accounts-enabled flag");
|
|
1903
|
+
let accountStore;
|
|
1904
|
+
if (accountsEnabled) {
|
|
1905
|
+
accountStore = openRelayAccountStore({ dbPath: join2(dataDir, "accounts.db") });
|
|
1906
|
+
if (accountStore.mode !== "sqlite") {
|
|
1907
|
+
accountStore.close();
|
|
1908
|
+
store.close();
|
|
1909
|
+
throw new Error("relay accounts require durable SQLite; rebuild better-sqlite3 before starting");
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
let relay;
|
|
1913
|
+
try {
|
|
1914
|
+
relay = createBlindRelayServer({
|
|
1915
|
+
rootToken,
|
|
1916
|
+
previousRootTokens: previousRoots,
|
|
1917
|
+
store,
|
|
1918
|
+
...accountStore ? { accountStore } : {},
|
|
1919
|
+
allowedOrigins,
|
|
1920
|
+
handshakeTimeoutMs: boundedInteger(
|
|
1921
|
+
env.ROAMCODE_RELAY_HANDSHAKE_TIMEOUT_MS,
|
|
1922
|
+
5e3,
|
|
1923
|
+
1e3,
|
|
1924
|
+
3e4,
|
|
1925
|
+
"relay handshake timeout"
|
|
1926
|
+
),
|
|
1927
|
+
idleTimeoutMs: boundedInteger(
|
|
1928
|
+
env.ROAMCODE_RELAY_IDLE_TIMEOUT_MS,
|
|
1929
|
+
12e4,
|
|
1930
|
+
1e4,
|
|
1931
|
+
36e5,
|
|
1932
|
+
"relay idle timeout"
|
|
1933
|
+
),
|
|
1934
|
+
maxFrameBytes: boundedInteger(
|
|
1935
|
+
env.ROAMCODE_RELAY_MAX_FRAME_BYTES,
|
|
1936
|
+
15e5,
|
|
1937
|
+
1024,
|
|
1938
|
+
16 * 1024 * 1024,
|
|
1939
|
+
"relay frame limit"
|
|
1940
|
+
),
|
|
1941
|
+
maxQueueBytes: boundedInteger(
|
|
1942
|
+
env.ROAMCODE_RELAY_MAX_QUEUE_BYTES,
|
|
1943
|
+
4e6,
|
|
1944
|
+
1024,
|
|
1945
|
+
64 * 1024 * 1024,
|
|
1946
|
+
"relay queue limit"
|
|
1947
|
+
),
|
|
1948
|
+
maxTotalConnections: boundedInteger(
|
|
1949
|
+
env.ROAMCODE_RELAY_MAX_TOTAL_CONNECTIONS,
|
|
1950
|
+
1024,
|
|
1951
|
+
1,
|
|
1952
|
+
1e5,
|
|
1953
|
+
"relay total connection limit"
|
|
1954
|
+
),
|
|
1955
|
+
maxConnectionsPerRoute: boundedInteger(
|
|
1956
|
+
env.ROAMCODE_RELAY_MAX_CONNECTIONS_PER_ROUTE,
|
|
1957
|
+
64,
|
|
1958
|
+
1,
|
|
1959
|
+
1e4,
|
|
1960
|
+
"relay route connection limit"
|
|
1961
|
+
),
|
|
1962
|
+
maxBytesPerMinute: boundedInteger(
|
|
1963
|
+
env.ROAMCODE_RELAY_MAX_BYTES_PER_MINUTE,
|
|
1964
|
+
64 * 1024 * 1024,
|
|
1965
|
+
1024,
|
|
1966
|
+
1024 * 1024 * 1024,
|
|
1967
|
+
"relay byte rate"
|
|
1968
|
+
),
|
|
1969
|
+
maxMessagesPerMinute: boundedInteger(
|
|
1970
|
+
env.ROAMCODE_RELAY_MAX_MESSAGES_PER_MINUTE,
|
|
1971
|
+
12e3,
|
|
1972
|
+
10,
|
|
1973
|
+
1e6,
|
|
1974
|
+
"relay message rate"
|
|
1975
|
+
)
|
|
1976
|
+
});
|
|
1977
|
+
} catch (error) {
|
|
1978
|
+
accountStore?.close();
|
|
1979
|
+
store.close();
|
|
1980
|
+
throw error;
|
|
1981
|
+
}
|
|
1982
|
+
relay.app.addHook("onClose", async () => {
|
|
1983
|
+
accountStore?.close();
|
|
1984
|
+
store.close();
|
|
1985
|
+
});
|
|
1986
|
+
const host = env.ROAMCODE_RELAY_BIND?.trim() || "127.0.0.1";
|
|
1987
|
+
const port = boundedInteger(env.ROAMCODE_RELAY_PORT, 4281, 0, 65535, "relay port");
|
|
1988
|
+
try {
|
|
1989
|
+
const url = await relay.app.listen({ host, port });
|
|
1990
|
+
return { ...relay, url };
|
|
1991
|
+
} catch (error) {
|
|
1992
|
+
accountStore?.close();
|
|
1993
|
+
store.close();
|
|
1994
|
+
throw error;
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
function isRelayDirectExecution(moduleUrl, argv1, embeddedContainerBuild2 = false) {
|
|
1998
|
+
if (embeddedContainerBuild2) return false;
|
|
1999
|
+
if (!argv1) return false;
|
|
2000
|
+
try {
|
|
2001
|
+
return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(argv1);
|
|
2002
|
+
} catch {
|
|
2003
|
+
return moduleUrl === pathToFileURL(argv1).href;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
var embeddedContainerBuild = typeof __RELAY_CONTAINER_BUILD__ === "boolean" && __RELAY_CONTAINER_BUILD__ === true;
|
|
2007
|
+
if (isRelayDirectExecution(import.meta.url, process.argv[1], embeddedContainerBuild)) {
|
|
2008
|
+
void startBlindRelay().then((relay) => {
|
|
2009
|
+
process.stdout.write(`RoamCode blind relay is listening at ${relay.url}
|
|
2010
|
+
`);
|
|
2011
|
+
const shutdown = () => relay.app.close().finally(() => process.exit(0));
|
|
2012
|
+
process.once("SIGINT", shutdown);
|
|
2013
|
+
process.once("SIGTERM", shutdown);
|
|
2014
|
+
}).catch((error) => {
|
|
2015
|
+
process.stderr.write(`roamcode relay failed to start: ${error.message}
|
|
2016
|
+
`);
|
|
2017
|
+
process.exitCode = 1;
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
export {
|
|
2021
|
+
isRelayDirectExecution,
|
|
2022
|
+
startBlindRelay
|
|
2023
|
+
};
|