pinomad 0.10.0 → 0.11.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.
@@ -9,14 +9,14 @@ import {
9
9
  relayWsBase,
10
10
  verifyRelayChallenge,
11
11
  verifyRelayChallengeV2
12
- } from "./chunk-cx8k63by.js";
12
+ } from "./chunk-y127fe1a.js";
13
+ import {
14
+ normalizeHostName
15
+ } from "./chunk-pqe7q1s7.js";
13
16
  import {
14
17
  commandHint,
15
18
  webRoot
16
- } from "./chunk-zehwx009.js";
17
- import {
18
- normalizeHostName
19
- } from "./chunk-sygeb9qb.js";
19
+ } from "./chunk-sd5fkxjj.js";
20
20
  import"./chunk-df32avw6.js";
21
21
 
22
22
  // apps/server/src/main.ts
@@ -25,7 +25,7 @@ import { join as join2, resolve } from "node:path";
25
25
  import { parseArgs } from "node:util";
26
26
 
27
27
  // apps/server/src/accounts.ts
28
- import { createHash, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
28
+ import { createHash, randomBytes, randomInt, scrypt, timingSafeEqual } from "node:crypto";
29
29
  import { mkdir } from "node:fs/promises";
30
30
  import { join } from "node:path";
31
31
  import { DatabaseSync } from "node:sqlite";
@@ -58,9 +58,26 @@ async function lockDataDir(dir, options) {
58
58
 
59
59
  // apps/server/src/accounts.ts
60
60
  var DB_FILE = "server.sqlite";
61
- var SCHEMA_VERSION = 4;
61
+ var SCHEMA_VERSION = 6;
62
62
  var SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
63
63
  var JOIN_TOKEN_TTL_MS = 10 * 60 * 1000;
64
+ var USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
65
+ var USER_CODE_LENGTH = 8;
66
+ var HOST_LINK_TTL_MS = JOIN_TOKEN_TTL_MS;
67
+ var HOST_LINK_INTERVAL_S = 5;
68
+ var SLOW_DOWN_STEP_S = 5;
69
+ var POLL_SLACK_MS = 1000;
70
+ var HOST_OS_MAX_CODEPOINTS = 64;
71
+ function normalizeUserCode(input) {
72
+ const code = input.replace(/[\s-]/g, "").toUpperCase();
73
+ if (code.length !== USER_CODE_LENGTH)
74
+ return;
75
+ for (const letter of code)
76
+ if (!USER_CODE_ALPHABET.includes(letter))
77
+ return;
78
+ return code;
79
+ }
80
+ var formatUserCode = (code) => `${code.slice(0, 4)}-${code.slice(4)}`;
64
81
  var INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
65
82
  var SIGN_UP_USERNAME = /^[a-z0-9][a-z0-9._-]{1,31}$/;
66
83
  var PUSH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000;
@@ -190,6 +207,38 @@ function migrate(db) {
190
207
  }
191
208
  if (version < 4)
192
209
  migrateTo4(db);
210
+ if (version < 5) {
211
+ db.exec(`
212
+ BEGIN;
213
+ ALTER TABLE push_tokens ADD COLUMN device_id INTEGER REFERENCES account_devices(id) ON DELETE SET NULL;
214
+ CREATE INDEX push_tokens_device ON push_tokens (device_id);
215
+ PRAGMA user_version = 5;
216
+ COMMIT;
217
+ `);
218
+ }
219
+ if (version < 6) {
220
+ db.exec(`
221
+ BEGIN;
222
+ CREATE TABLE host_link_requests (
223
+ device_code_hash TEXT PRIMARY KEY,
224
+ user_code_hash TEXT NOT NULL UNIQUE,
225
+ host_id TEXT NOT NULL,
226
+ host_name TEXT,
227
+ host_os TEXT,
228
+ source_ip TEXT,
229
+ state TEXT NOT NULL DEFAULT 'pending',
230
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
231
+ interval_s INTEGER NOT NULL,
232
+ last_poll_at INTEGER,
233
+ created_at INTEGER NOT NULL,
234
+ expires_at INTEGER NOT NULL
235
+ );
236
+ CREATE INDEX host_link_requests_expiry ON host_link_requests (expires_at);
237
+ ALTER TABLE join_tokens ADD COLUMN host_id TEXT;
238
+ PRAGMA user_version = 6;
239
+ COMMIT;
240
+ `);
241
+ }
193
242
  }
194
243
  function migrateTo4(db) {
195
244
  db.exec("BEGIN");
@@ -386,6 +435,53 @@ async function openAccounts(options) {
386
435
  const gate = new ScryptGate(limits.scryptConcurrency, limits.scryptQueue);
387
436
  const ipAttempts = new WindowedCounter(limits.counterMaxEntries, now);
388
437
  const userFailures = new WindowedCounter(limits.counterMaxEntries, now);
438
+ const linkLimits = {
439
+ startMaxPerIp: options.hostLinkLimits?.startMaxPerIp ?? 10,
440
+ startWindowMs: options.hostLinkLimits?.startWindowMs ?? 10 * 60000,
441
+ maxPending: options.hostLinkLimits?.maxPending ?? 1000,
442
+ userMaxFailures: options.hostLinkLimits?.userMaxFailures ?? 10,
443
+ ipMaxFailures: options.hostLinkLimits?.ipMaxFailures ?? 30,
444
+ failureWindowMs: options.hostLinkLimits?.failureWindowMs ?? 15 * 60000,
445
+ counterMaxEntries: options.hostLinkLimits?.counterMaxEntries ?? 4096
446
+ };
447
+ const linkStarts = new WindowedCounter(linkLimits.counterMaxEntries, now);
448
+ const linkUserFailures = new WindowedCounter(linkLimits.counterMaxEntries, now);
449
+ const linkIpFailures = new WindowedCounter(linkLimits.counterMaxEntries, now);
450
+ const sweepExpired = () => {
451
+ const at = now();
452
+ db.prepare("DELETE FROM join_tokens WHERE expires_at <= ?").run(at);
453
+ db.prepare("DELETE FROM host_link_requests WHERE expires_at <= ?").run(at);
454
+ };
455
+ const mintJoinToken = (userId, hostId) => {
456
+ const token = randomBytes(32).toString("base64url");
457
+ const expiresAt = now() + JOIN_TOKEN_TTL_MS;
458
+ db.prepare("INSERT INTO join_tokens (token_hash, user_id, created_at, expires_at, host_id) VALUES (?, ?, ?, ?, ?)").run(sha256Hex(token), userId, now(), expiresAt, hostId);
459
+ return { token, expiresAt };
460
+ };
461
+ const pendingByCode = (userId, userCode, ip) => {
462
+ const userKey = String(userId);
463
+ if (linkUserFailures.isOver(userKey, linkLimits.userMaxFailures) || linkIpFailures.isOver(ip, linkLimits.ipMaxFailures)) {
464
+ return "limited";
465
+ }
466
+ const code = normalizeUserCode(userCode);
467
+ const codeHash = code === undefined ? undefined : sha256Hex(code);
468
+ const row = codeHash === undefined ? undefined : db.prepare(`SELECT host_id AS hostId, host_name AS name, host_os AS os, source_ip AS sourceIp, expires_at AS expiresAt
469
+ FROM host_link_requests WHERE user_code_hash = ? AND state = 'pending' AND expires_at > ?`).get(codeHash, now());
470
+ if (codeHash === undefined || row === undefined) {
471
+ linkUserFailures.bump(userKey, linkLimits.failureWindowMs);
472
+ linkIpFailures.bump(ip, linkLimits.failureWindowMs);
473
+ return "not_found";
474
+ }
475
+ return { codeHash, row };
476
+ };
477
+ const settleHostLink = (args, state) => {
478
+ const found = pendingByCode(args.userId, args.userCode, args.ip);
479
+ if (found === "limited" || found === "not_found")
480
+ return { status: found };
481
+ const settled = db.prepare(`UPDATE host_link_requests SET state = ?, user_id = ?, host_name = NULL, host_os = NULL, source_ip = NULL
482
+ WHERE user_code_hash = ? AND state = 'pending' AND expires_at > ?`).run(state, state === "confirmed" ? args.userId : null, found.codeHash, now());
483
+ return Number(settled.changes) === 1 ? { status: "ok" } : { status: "not_found" };
484
+ };
389
485
  const deviceForSignIn = (userId, claim) => {
390
486
  const at = now();
391
487
  if (claim === undefined) {
@@ -411,6 +507,19 @@ async function openAccounts(options) {
411
507
  const sweepVouchings = () => {
412
508
  db.prepare("DELETE FROM vouchings WHERE public_key IS NULL AND expires_at <= ?").run(now() - ADMISSION_REPORT_GRACE_MS);
413
509
  };
510
+ const withdrawDevice = (deviceId) => {
511
+ const at = now();
512
+ sweepVouchings();
513
+ db.prepare("DELETE FROM sessions WHERE device_id = ?").run(deviceId);
514
+ db.prepare("DELETE FROM push_tokens WHERE device_id = ?").run(deviceId);
515
+ db.prepare("UPDATE account_devices SET kicked_at = ? WHERE id = ?").run(at, deviceId);
516
+ const hostIds = db.prepare("SELECT DISTINCT host_id AS hostId FROM vouchings WHERE device_id = ? AND public_key IS NOT NULL AND revoked_at IS NULL").all(deviceId).map((row) => row.hostId);
517
+ db.prepare(`INSERT OR IGNORE INTO pending_revocations (host_id, public_key, created_at)
518
+ SELECT host_id, public_key, ? FROM vouchings
519
+ WHERE device_id = ? AND public_key IS NOT NULL AND revoked_at IS NULL`).run(at, deviceId);
520
+ db.prepare("UPDATE vouchings SET revoked_at = ? WHERE device_id = ? AND revoked_at IS NULL").run(at, deviceId);
521
+ return hostIds;
522
+ };
414
523
  const currentHash = (userId) => db.prepare("SELECT password_hash AS passwordHash FROM users WHERE id = ?").get(userId)?.passwordHash;
415
524
  const checkPassword = async (userId, username, password) => {
416
525
  if (userFailures.isOver(username, limits.userMaxFailures))
@@ -549,8 +658,23 @@ async function openAccounts(options) {
549
658
  ...row.deviceId === null ? {} : { deviceId: row.deviceId }
550
659
  };
551
660
  },
552
- logout(token) {
553
- db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(sha256Hex(token));
661
+ logout(token, options2) {
662
+ const tokenHash = sha256Hex(token);
663
+ if (options2?.revoke !== true) {
664
+ db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash);
665
+ return [];
666
+ }
667
+ db.exec("BEGIN IMMEDIATE");
668
+ try {
669
+ const session = db.prepare("SELECT device_id AS deviceId FROM sessions WHERE token_hash = ?").get(tokenHash);
670
+ db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash);
671
+ const hostIds = session?.deviceId == null ? [] : withdrawDevice(session.deviceId);
672
+ db.exec("COMMIT");
673
+ return hostIds;
674
+ } catch (error) {
675
+ db.exec("ROLLBACK");
676
+ throw error;
677
+ }
554
678
  },
555
679
  async changePassword({ userId, username, keepToken, currentPassword, newPassword }) {
556
680
  const length = [...newPassword].length;
@@ -622,13 +746,14 @@ async function openAccounts(options) {
622
746
  const row = db.prepare("SELECT user_id AS userId FROM hosts WHERE host_id = ?").get(hostId);
623
747
  return row?.userId;
624
748
  },
625
- registerPushToken(userId, token, environment) {
749
+ registerPushToken(userId, token, environment, deviceId) {
626
750
  const at = now();
627
751
  db.exec("BEGIN");
628
752
  try {
629
753
  db.prepare("DELETE FROM push_tokens WHERE updated_at < ?").run(at - PUSH_TOKEN_TTL_MS);
630
- db.prepare(`INSERT INTO push_tokens (token, environment, user_id, updated_at) VALUES (?, ?, ?, ?)
631
- ON CONFLICT (token, environment) DO UPDATE SET user_id = excluded.user_id, updated_at = excluded.updated_at`).run(token.toLowerCase(), environment, userId, at);
754
+ db.prepare(`INSERT INTO push_tokens (token, environment, user_id, updated_at, device_id) VALUES (?, ?, ?, ?, ?)
755
+ ON CONFLICT (token, environment) DO UPDATE SET
756
+ user_id = excluded.user_id, updated_at = excluded.updated_at, device_id = excluded.device_id`).run(token.toLowerCase(), environment, userId, at, deviceId ?? null);
632
757
  db.prepare(`DELETE FROM push_tokens WHERE user_id = ? AND rowid NOT IN (
633
758
  SELECT rowid FROM push_tokens WHERE user_id = ? ORDER BY updated_at DESC LIMIT ?
634
759
  )`).run(userId, userId, MAX_PUSH_TOKENS_PER_USER);
@@ -649,22 +774,96 @@ async function openAccounts(options) {
649
774
  db.prepare("DELETE FROM push_tokens WHERE token = ? AND environment = ? AND updated_at < ?").run(token.toLowerCase(), environment, unregisteredAt);
650
775
  },
651
776
  createJoinToken(userId) {
652
- db.prepare("DELETE FROM join_tokens WHERE expires_at <= ?").run(now());
653
- const token = randomBytes(32).toString("base64url");
654
- const expiresAt = now() + JOIN_TOKEN_TTL_MS;
655
- db.prepare("INSERT INTO join_tokens (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(sha256Hex(token), userId, now(), expiresAt);
656
- return { token, expiresAt };
777
+ sweepExpired();
778
+ return mintJoinToken(userId, null);
779
+ },
780
+ startHostLink({ hostId, name, os, ip }) {
781
+ if (linkStarts.hit(ip, linkLimits.startMaxPerIp, linkLimits.startWindowMs))
782
+ return { status: "limited" };
783
+ sweepExpired();
784
+ const live = db.prepare("SELECT COUNT(*) AS n FROM host_link_requests").get().n;
785
+ if (live >= linkLimits.maxPending)
786
+ return { status: "busy" };
787
+ const deviceCode = randomBytes(32).toString("base64url");
788
+ const at = now();
789
+ const expiresAt = at + HOST_LINK_TTL_MS;
790
+ const insert = db.prepare(`INSERT INTO host_link_requests
791
+ (device_code_hash, user_code_hash, host_id, host_name, host_os, source_ip, interval_s, created_at, expires_at)
792
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
793
+ for (let attempt = 0;; attempt++) {
794
+ const code = Array.from({ length: USER_CODE_LENGTH }, () => USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)]).join("");
795
+ try {
796
+ insert.run(sha256Hex(deviceCode), sha256Hex(code), hostId, normalizeHostName(name), [...os.trim()].slice(0, HOST_OS_MAX_CODEPOINTS).join(""), ip, HOST_LINK_INTERVAL_S, at, expiresAt);
797
+ return { status: "ok", deviceCode, userCode: formatUserCode(code), expiresAt, interval: HOST_LINK_INTERVAL_S };
798
+ } catch (error) {
799
+ if (attempt >= 4 || !String(error).includes("UNIQUE"))
800
+ throw error;
801
+ }
802
+ }
803
+ },
804
+ pollHostLink(deviceCode) {
805
+ const deviceCodeHash = sha256Hex(deviceCode);
806
+ const at = now();
807
+ db.exec("BEGIN IMMEDIATE");
808
+ try {
809
+ const row = db.prepare(`SELECT host_id AS hostId, state, user_id AS userId, interval_s AS intervalS, last_poll_at AS lastPollAt, expires_at AS expiresAt
810
+ FROM host_link_requests WHERE device_code_hash = ?`).get(deviceCodeHash);
811
+ let result;
812
+ if (row === undefined || row.expiresAt <= at) {
813
+ db.prepare("DELETE FROM host_link_requests WHERE device_code_hash = ?").run(deviceCodeHash);
814
+ result = { status: "expired" };
815
+ } else if (row.lastPollAt !== null && at - row.lastPollAt < row.intervalS * 1000 - POLL_SLACK_MS) {
816
+ const interval = row.intervalS + SLOW_DOWN_STEP_S;
817
+ db.prepare("UPDATE host_link_requests SET interval_s = ?, last_poll_at = ? WHERE device_code_hash = ?").run(interval, at, deviceCodeHash);
818
+ result = { status: "slow_down", interval };
819
+ } else if (row.state === "pending") {
820
+ db.prepare("UPDATE host_link_requests SET last_poll_at = ? WHERE device_code_hash = ?").run(at, deviceCodeHash);
821
+ result = { status: "pending" };
822
+ } else {
823
+ db.prepare("DELETE FROM host_link_requests WHERE device_code_hash = ?").run(deviceCodeHash);
824
+ result = row.state === "confirmed" && row.userId !== null ? { status: "confirmed", ...mintJoinToken(row.userId, row.hostId) } : { status: "denied" };
825
+ }
826
+ db.exec("COMMIT");
827
+ return result;
828
+ } catch (error) {
829
+ db.exec("ROLLBACK");
830
+ throw error;
831
+ }
832
+ },
833
+ lookupHostLink({ userId, userCode, ip }) {
834
+ const found = pendingByCode(userId, userCode, ip);
835
+ if (found === "limited" || found === "not_found")
836
+ return { status: found };
837
+ const { row } = found;
838
+ return {
839
+ status: "ok",
840
+ request: {
841
+ hostId: row.hostId,
842
+ name: row.name ?? "",
843
+ os: row.os ?? "",
844
+ sourceIp: row.sourceIp ?? "",
845
+ ipDiffers: row.sourceIp !== ip,
846
+ expiresAt: row.expiresAt
847
+ }
848
+ };
657
849
  },
850
+ confirmHostLink(args) {
851
+ return settleHostLink(args, "confirmed");
852
+ },
853
+ denyHostLink(args) {
854
+ return settleHostLink(args, "denied");
855
+ },
856
+ sweepExpired,
658
857
  joinHost({ token, hostId, noisePublicKey, name }) {
659
858
  const tokenHash = sha256Hex(token);
660
859
  const at = now();
661
860
  db.exec("BEGIN");
662
861
  try {
663
- const row = db.prepare("SELECT user_id AS userId, expires_at AS expiresAt, used_at AS usedAt FROM join_tokens WHERE token_hash = ?").get(tokenHash);
862
+ const row = db.prepare("SELECT user_id AS userId, expires_at AS expiresAt, used_at AS usedAt, host_id AS boundHostId FROM join_tokens WHERE token_hash = ?").get(tokenHash);
664
863
  let failure;
665
- if (row === undefined || row.usedAt !== null)
864
+ if (row === undefined || row.usedAt !== null || row.boundHostId !== null && row.boundHostId !== hostId) {
666
865
  failure = "invalid_token";
667
- else if (row.expiresAt <= at)
866
+ } else if (row.expiresAt <= at)
668
867
  failure = "expired_token";
669
868
  else {
670
869
  const consumed = db.prepare("UPDATE join_tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL").run(at, tokenHash);
@@ -800,15 +999,7 @@ async function openAccounts(options) {
800
999
  db.exec("ROLLBACK");
801
1000
  return { status: "refused", reason: "not_found" };
802
1001
  }
803
- const at = now();
804
- sweepVouchings();
805
- db.prepare("DELETE FROM sessions WHERE device_id = ?").run(deviceId);
806
- db.prepare("UPDATE account_devices SET kicked_at = ? WHERE id = ?").run(at, deviceId);
807
- const hostIds = db.prepare("SELECT DISTINCT host_id AS hostId FROM vouchings WHERE device_id = ? AND public_key IS NOT NULL AND revoked_at IS NULL").all(deviceId).map((row) => row.hostId);
808
- db.prepare(`INSERT OR IGNORE INTO pending_revocations (host_id, public_key, created_at)
809
- SELECT host_id, public_key, ? FROM vouchings
810
- WHERE device_id = ? AND public_key IS NOT NULL AND revoked_at IS NULL`).run(at, deviceId);
811
- db.prepare("UPDATE vouchings SET revoked_at = ? WHERE device_id = ? AND revoked_at IS NULL").run(at, deviceId);
1002
+ const hostIds = withdrawDevice(deviceId);
812
1003
  db.exec("COMMIT");
813
1004
  return { status: "ok", hostIds };
814
1005
  } catch (error) {
@@ -831,6 +1022,7 @@ function dropCredentials(db, userId, keepTokenHash) {
831
1022
  else
832
1023
  db.prepare("DELETE FROM sessions WHERE user_id = ? AND token_hash <> ?").run(userId, keepTokenHash);
833
1024
  db.prepare("DELETE FROM join_tokens WHERE user_id = ? AND used_at IS NULL").run(userId);
1025
+ db.prepare("DELETE FROM host_link_requests WHERE user_id = ?").run(userId);
834
1026
  db.prepare("DELETE FROM push_tokens WHERE user_id = ?").run(userId);
835
1027
  }
836
1028
  async function resetPassword(options) {
@@ -1229,6 +1421,7 @@ function createRelayLimiter(limits, now = Date.now) {
1229
1421
 
1230
1422
  // apps/server/src/api.ts
1231
1423
  var SESSION_COOKIE = "__Host-pinomad-session";
1424
+ var HOST_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/;
1232
1425
  var MAX_BODY = 8 * 1024;
1233
1426
  var MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
1234
1427
  var DESKTOP_ORIGIN = "app://pinomad";
@@ -1254,7 +1447,7 @@ function respond(response, status, body, headers) {
1254
1447
  response.writeHead(status, all);
1255
1448
  response.end(body === undefined ? undefined : JSON.stringify(body));
1256
1449
  }
1257
- async function readJson(request) {
1450
+ async function readJson(request, optional = false) {
1258
1451
  const chunks = [];
1259
1452
  let size = 0;
1260
1453
  let tooLarge = false;
@@ -1268,6 +1461,8 @@ async function readJson(request) {
1268
1461
  }
1269
1462
  if (tooLarge)
1270
1463
  return { ok: false, tooLarge: true };
1464
+ if (optional && size === 0)
1465
+ return { ok: true, value: undefined };
1271
1466
  try {
1272
1467
  return { ok: true, value: JSON.parse(Buffer.concat(chunks).toString("utf8")) };
1273
1468
  } catch {
@@ -1317,7 +1512,8 @@ async function handleApiRequest(request, response, ctx) {
1317
1512
  }
1318
1513
  const bearer = (request.headers.authorization ?? "").startsWith("Bearer ");
1319
1514
  const loginTokenMode = (pathname === "/api/login" || pathname === "/api/register") && (origin === undefined || origin === DESKTOP_ORIGIN);
1320
- if (MUTATING.has(method) && !bearer && !loginTokenMode && origin !== ctx.publicOrigin) {
1515
+ const hostLinkHostMode = (pathname === "/api/host-link/start" || pathname === "/api/host-link/poll") && origin === undefined;
1516
+ if (MUTATING.has(method) && !bearer && !loginTokenMode && !hostLinkHostMode && origin !== ctx.publicOrigin) {
1321
1517
  deny(403, "origin_mismatch");
1322
1518
  return;
1323
1519
  }
@@ -1397,6 +1593,110 @@ async function handleApiRequest(request, response, ctx) {
1397
1593
  send(200, { user: { username }, expiresAt: result.expiresAt, ...cookieMode ? {} : { token: result.token } }, cookieMode ? { "set-cookie": setSessionCookie(result.token) } : undefined);
1398
1594
  return;
1399
1595
  }
1596
+ if (pathname === "/api/host-link/start" || pathname === "/api/host-link/poll") {
1597
+ if (method !== "POST") {
1598
+ deny(405, "method_not_allowed", { allow: "POST" });
1599
+ return;
1600
+ }
1601
+ const body = await readJson(request);
1602
+ if (!body.ok) {
1603
+ deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
1604
+ return;
1605
+ }
1606
+ if (pathname === "/api/host-link/start") {
1607
+ const value = body.value;
1608
+ if (typeof value?.hostId !== "string" || !HOST_ID_PATTERN.test(value.hostId) || typeof value.name !== "string" || typeof value.os !== "string") {
1609
+ deny(400, "invalid_request");
1610
+ return;
1611
+ }
1612
+ const result2 = accounts.startHostLink({
1613
+ hostId: value.hostId,
1614
+ name: value.name,
1615
+ os: value.os,
1616
+ ip: clientIp(request, ctx.trustProxy)
1617
+ });
1618
+ if (result2.status === "limited") {
1619
+ deny(429, "rate_limited");
1620
+ return;
1621
+ }
1622
+ if (result2.status === "busy") {
1623
+ deny(503, "busy");
1624
+ return;
1625
+ }
1626
+ send(200, {
1627
+ deviceCode: result2.deviceCode,
1628
+ userCode: result2.userCode,
1629
+ verifyUrl: `${ctx.publicOrigin}/host-link`,
1630
+ interval: result2.interval,
1631
+ expiresAt: result2.expiresAt
1632
+ });
1633
+ return;
1634
+ }
1635
+ const deviceCode = body.value?.deviceCode;
1636
+ if (typeof deviceCode !== "string" || deviceCode.length > 256) {
1637
+ deny(400, "invalid_request");
1638
+ return;
1639
+ }
1640
+ const result = accounts.pollHostLink(deviceCode);
1641
+ switch (result.status) {
1642
+ case "confirmed":
1643
+ send(200, { token: result.token, expiresAt: result.expiresAt });
1644
+ return;
1645
+ case "pending":
1646
+ deny(400, "authorization_pending");
1647
+ return;
1648
+ case "slow_down":
1649
+ send(400, { error: "slow_down", interval: result.interval });
1650
+ return;
1651
+ case "denied":
1652
+ deny(400, "access_denied");
1653
+ return;
1654
+ case "expired":
1655
+ deny(400, "expired_token");
1656
+ return;
1657
+ }
1658
+ }
1659
+ if (pathname === "/api/host-link/lookup" || pathname === "/api/host-link/confirm" || pathname === "/api/host-link/deny") {
1660
+ if (method !== "POST") {
1661
+ deny(405, "method_not_allowed", { allow: "POST" });
1662
+ return;
1663
+ }
1664
+ const token = sessionToken(request);
1665
+ if (token === undefined || accounts.authenticate(token) === undefined) {
1666
+ deny(401, "unauthenticated");
1667
+ return;
1668
+ }
1669
+ const body = await readJson(request);
1670
+ if (!body.ok) {
1671
+ deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
1672
+ return;
1673
+ }
1674
+ const userCode = body.value?.userCode;
1675
+ if (typeof userCode !== "string" || userCode.length > 64) {
1676
+ deny(400, "invalid_request");
1677
+ return;
1678
+ }
1679
+ const session = accounts.authenticate(token);
1680
+ if (session === undefined) {
1681
+ deny(401, "unauthenticated");
1682
+ return;
1683
+ }
1684
+ const args = { userId: session.userId, userCode, ip: clientIp(request, ctx.trustProxy) };
1685
+ const result = pathname === "/api/host-link/lookup" ? accounts.lookupHostLink(args) : pathname === "/api/host-link/confirm" ? accounts.confirmHostLink(args) : accounts.denyHostLink(args);
1686
+ if (result.status === "limited") {
1687
+ deny(429, "rate_limited");
1688
+ return;
1689
+ }
1690
+ if (result.status === "not_found") {
1691
+ deny(404, "not_found");
1692
+ return;
1693
+ }
1694
+ if ("request" in result)
1695
+ send(200, result.request);
1696
+ else
1697
+ send(204);
1698
+ return;
1699
+ }
1400
1700
  if (pathname === "/api/invites" || pathname === "/api/invites/revoke") {
1401
1701
  const allowed = pathname === "/api/invites" ? ["GET", "POST"] : ["POST"];
1402
1702
  if (!allowed.includes(method)) {
@@ -1598,12 +1898,13 @@ async function handleApiRequest(request, response, ctx) {
1598
1898
  deny(400, "invalid_request");
1599
1899
  return;
1600
1900
  }
1601
- if (accounts.authenticate(token) === undefined) {
1901
+ const current = accounts.authenticate(token);
1902
+ if (current === undefined) {
1602
1903
  deny(401, "unauthenticated");
1603
1904
  return;
1604
1905
  }
1605
1906
  if (pathname === "/api/push-tokens")
1606
- accounts.registerPushToken(session.userId, apns, environment);
1907
+ accounts.registerPushToken(session.userId, apns, environment, current.deviceId);
1607
1908
  else
1608
1909
  accounts.removePushToken(session.userId, apns, environment);
1609
1910
  send(204);
@@ -1669,12 +1970,24 @@ async function handleApiRequest(request, response, ctx) {
1669
1970
  send(200, {
1670
1971
  token: joinToken.token,
1671
1972
  expiresAt: joinToken.expiresAt,
1672
- command: `npm i -g pinomad && pinomad join ${ctx.publicOrigin} ${joinToken.token}`
1973
+ command: `curl -fsSL https://pinomad.si/install.sh | sh -s -- join ${ctx.publicOrigin} ${joinToken.token}`
1673
1974
  });
1674
1975
  return;
1675
1976
  }
1676
1977
  if (pathname === "/api/logout") {
1677
- accounts.logout(token);
1978
+ const body = await readJson(request, true);
1979
+ if (!body.ok) {
1980
+ deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
1981
+ return;
1982
+ }
1983
+ const value = body.value;
1984
+ const revoke = typeof value === "object" && value !== null ? value.revoke : undefined;
1985
+ if (value === null || value !== undefined && typeof value !== "object" || revoke !== undefined && typeof revoke !== "boolean") {
1986
+ deny(400, "invalid_request");
1987
+ return;
1988
+ }
1989
+ for (const hostId of accounts.logout(token, { revoke: revoke === true }))
1990
+ ctx.sendRevocations(hostId);
1678
1991
  send(204, undefined, { "set-cookie": clearSessionCookie });
1679
1992
  return;
1680
1993
  }
@@ -2192,6 +2505,9 @@ async function startRelay(options) {
2192
2505
  socket.terminate();
2193
2506
  }
2194
2507
  }
2508
+ try {
2509
+ accounts?.sweepExpired();
2510
+ } catch {}
2195
2511
  }, pingIntervalMs);
2196
2512
  return {
2197
2513
  port,
@@ -1,9 +1,9 @@
1
- import {
2
- packagedVersion
3
- } from "./chunk-zehwx009.js";
4
1
  import {
5
2
  hostAddress
6
3
  } from "./chunk-ngf1zatm.js";
4
+ import {
5
+ packagedVersion
6
+ } from "./chunk-sd5fkxjj.js";
7
7
  import"./chunk-df32avw6.js";
8
8
 
9
9
  // apps/host/src/cli/web-link.ts
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ readServerConfig
4
+ } from "./chunk-etf7ef97.js";
5
+ import {
6
+ argValue,
7
+ reachabilityLine,
8
+ serviceManager,
9
+ versionLine
10
+ } from "./chunk-vvj9b1gc.js";
11
+ import"./chunk-jt34g8st.js";
12
+ import"./chunk-kzbzacz9.js";
13
+ import"./chunk-xqycs43g.js";
14
+ import"./chunk-pqe7q1s7.js";
15
+ import {
16
+ DEFAULT_DATA_DIR
17
+ } from "./chunk-ngf1zatm.js";
18
+ import {
19
+ commandHint
20
+ } from "./chunk-sd5fkxjj.js";
21
+ import"./chunk-df32avw6.js";
22
+
23
+ // apps/host/src/cli/status.ts
24
+ import { resolve } from "node:path";
25
+ import { parseArgs } from "node:util";
26
+ async function statusReport(args, deps) {
27
+ const dataDir = resolve(args.dataDir);
28
+ const manager = deps?.manager ?? serviceManager();
29
+ const server = await readServerConfig(dataDir);
30
+ const service = await manager.state();
31
+ const spec = service.installed ? await manager.read() : undefined;
32
+ const own = spec !== undefined && resolve(spec.dataDir) === dataDir;
33
+ const lanPort = own ? argValue(spec.hostArgs, "--remote-port") : undefined;
34
+ const port = own ? argValue(spec.hostArgs, "--port") : undefined;
35
+ return {
36
+ ...server === undefined ? {} : { server },
37
+ ...lanPort === undefined ? {} : { lanPort },
38
+ service,
39
+ ...port === undefined ? {} : { port },
40
+ ...spec === undefined || own ? {} : { otherDataDir: spec.dataDir }
41
+ };
42
+ }
43
+ async function main(argv) {
44
+ const { values } = parseArgs({ args: [...argv], options: { "data-dir": { type: "string" } } });
45
+ const dataDir = resolve(values["data-dir"] ?? DEFAULT_DATA_DIR);
46
+ try {
47
+ const report = await statusReport({ dataDir });
48
+ const { server } = report;
49
+ console.log(server === undefined ? `server: not joined (\`${commandHint("up")}\` to join)` : `server: ${server.username === undefined ? "joined" : server.username} at ${server.origin}`);
50
+ console.log(`LAN and tailnet: ${report.lanPort === undefined ? "off" : `port ${report.lanPort}`}`);
51
+ console.log(`service: ${report.service.installed ? report.service.detail : "not installed"}`);
52
+ if (report.otherDataDir !== undefined)
53
+ console.log(`note: the installed service runs ${report.otherDataDir}, not ${dataDir}`);
54
+ console.log(await versionLine());
55
+ console.log(await reachabilityLine([
56
+ "--data-dir",
57
+ dataDir,
58
+ ...report.port === undefined ? [] : ["--url", `ws://127.0.0.1:${report.port}`]
59
+ ]));
60
+ } catch (error) {
61
+ console.error(error instanceof Error ? error.message : String(error));
62
+ process.exitCode = 1;
63
+ }
64
+ }
65
+ if (false)
66
+ ;
67
+ export {
68
+ statusReport,
69
+ main
70
+ };
@@ -0,0 +1,55 @@
1
+ // apps/host/src/server-config.ts
2
+ import { randomBytes } from "node:crypto";
3
+ import { readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ var SERVER_CONFIG_FILE = "server.json";
6
+ var normalizeOrigin = (origin) => origin.replace(/\/+$/, "");
7
+ async function readServerConfig(dataDir) {
8
+ let text;
9
+ try {
10
+ text = await readFile(join(dataDir, SERVER_CONFIG_FILE), "utf8");
11
+ } catch (error) {
12
+ if (error.code === "ENOENT")
13
+ return;
14
+ throw error;
15
+ }
16
+ let value;
17
+ try {
18
+ value = JSON.parse(text);
19
+ } catch {
20
+ throw new Error(`${join(dataDir, SERVER_CONFIG_FILE)} is not valid JSON — delete it or run \`pinomad leave\``);
21
+ }
22
+ const origin = value?.origin;
23
+ if (typeof origin !== "string" || origin.trim() === "") {
24
+ throw new Error(`${join(dataDir, SERVER_CONFIG_FILE)} is missing a string "origin"`);
25
+ }
26
+ const { binding, username } = value;
27
+ return {
28
+ origin,
29
+ ...typeof binding === "string" && binding !== "" ? { binding } : {},
30
+ ...typeof username === "string" && username !== "" ? { username } : {}
31
+ };
32
+ }
33
+ async function writeServerConfig(dataDir, config) {
34
+ const path = join(dataDir, SERVER_CONFIG_FILE);
35
+ const tmp = `${path}.tmp-${process.pid}`;
36
+ await writeFile(tmp, `${JSON.stringify({
37
+ origin: normalizeOrigin(config.origin),
38
+ binding: randomBytes(16).toString("base64url"),
39
+ ...config.username === undefined ? {} : { username: config.username }
40
+ })}
41
+ `, { mode: 384 });
42
+ await rename(tmp, path);
43
+ }
44
+ var removeServerConfig = (dataDir) => rm(join(dataDir, SERVER_CONFIG_FILE), { force: true });
45
+ async function readServerBinding(dataDir) {
46
+ return (await readServerConfig(dataDir))?.binding;
47
+ }
48
+ function resolveRelayOrigin(flag, config) {
49
+ if (flag !== undefined && config !== undefined && normalizeOrigin(flag) !== normalizeOrigin(config.origin)) {
50
+ throw new Error(`--relay ${flag} disagrees with ${SERVER_CONFIG_FILE} (${config.origin}) — remove one`);
51
+ }
52
+ return flag ?? config?.origin;
53
+ }
54
+
55
+ export { normalizeOrigin, readServerConfig, writeServerConfig, removeServerConfig, readServerBinding, resolveRelayOrigin };