pinomad 0.11.0 → 0.13.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.
Files changed (103) hide show
  1. package/README.md +1 -1
  2. package/dist/{chunk-wb10efyq.js → chunk-445cevpm.js} +1 -1
  3. package/dist/{chunk-jt34g8st.js → chunk-4fferbxw.js} +247 -27
  4. package/dist/{chunk-67gq282t.js → chunk-53xv3fny.js} +725 -75
  5. package/dist/{chunk-44pnts98.js → chunk-563fnad9.js} +1 -1
  6. package/dist/{chunk-b457q0tm.js → chunk-6th9xv39.js} +11 -8
  7. package/dist/{chunk-ncbqa8jk.js → chunk-7mn38n4f.js} +1 -1
  8. package/dist/{chunk-as6pyf00.js → chunk-90g1cg9n.js} +1 -1
  9. package/dist/{chunk-9h5vawnm.js → chunk-aapsc9g0.js} +270 -66
  10. package/dist/{chunk-n1m1tjrk.js → chunk-d4tq62yr.js} +57 -16
  11. package/dist/{chunk-gtdpjqa3.js → chunk-d9mq5d46.js} +205 -49
  12. package/dist/{chunk-y127fe1a.js → chunk-g3ahp7b9.js} +11 -0
  13. package/dist/{chunk-xqycs43g.js → chunk-kmzzjv53.js} +83 -8
  14. package/dist/{chunk-4dcs8sbj.js → chunk-m51mq8vh.js} +43 -21
  15. package/dist/{chunk-tj57aqav.js → chunk-maqv34cs.js} +2 -2
  16. package/dist/chunk-nq57fdnq.js +106 -0
  17. package/dist/{chunk-h5q2y57m.js → chunk-ra86wjp5.js} +10 -4
  18. package/dist/{chunk-sd5fkxjj.js → chunk-s0h7c7da.js} +1 -1
  19. package/dist/chunk-sd640dt7.js +22 -0
  20. package/dist/{chunk-6d16c2fv.js → chunk-t6fw0wa9.js} +13 -6
  21. package/dist/{chunk-vvj9b1gc.js → chunk-wd7aracm.js} +188 -54
  22. package/dist/pinomad.js +15 -14
  23. package/package.json +13 -11
  24. package/web/assets/astro-DOly5024.js +1 -0
  25. package/web/assets/c-BIGW1oBm.js +1 -0
  26. package/web/assets/core-BHm2gdHL.js +4 -0
  27. package/web/assets/cpp-DddhVWhK.js +1 -0
  28. package/web/assets/csharp-B_TYUkTI.js +1 -0
  29. package/web/assets/css-Z72CF80r.js +1 -0
  30. package/web/assets/dart-CtKP3W-j.js +1 -0
  31. package/web/assets/diff-D97Zzqfu.js +1 -0
  32. package/web/assets/dockerfile-BcOcwvcX.js +1 -0
  33. package/web/assets/elixir-DkJLSxtU.js +1 -0
  34. package/web/assets/engine-javascript-BHPwbUfN.js +141 -0
  35. package/web/assets/engine-oniguruma-CQjQPVHy.js +1 -0
  36. package/web/assets/fish-BvzEVeQv.js +1 -0
  37. package/web/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  38. package/web/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  39. package/web/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  40. package/web/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  41. package/web/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  42. package/web/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  43. package/web/assets/go-C27-OAKa.js +1 -0
  44. package/web/assets/graphql-ChdNCCLP.js +1 -0
  45. package/web/assets/haskell-Df6bDoY_.js +1 -0
  46. package/web/assets/hcl-BWvSN4gD.js +1 -0
  47. package/web/assets/highlighted-diff-DrHvSLK9.js +175 -0
  48. package/web/assets/highlights-1aLwssz-.js +4 -0
  49. package/web/assets/highlights-themes-C1p4pFYS.js +1 -0
  50. package/web/assets/html-BlS82ilI.js +1 -0
  51. package/web/assets/index-A_AUJoO6.css +1 -0
  52. package/web/assets/index-BafYMKiN.js +396 -0
  53. package/web/assets/ini-BEwlwnbL.js +1 -0
  54. package/web/assets/java-CylS5w8V.js +1 -0
  55. package/web/assets/javascript-wDzz0qaB.js +1 -0
  56. package/web/assets/json-Cp-IABpG.js +1 -0
  57. package/web/assets/jsonc-Des-eS-w.js +1 -0
  58. package/web/assets/jsx-g9-lgVsj.js +1 -0
  59. package/web/assets/kotlin-BdnUsdx6.js +1 -0
  60. package/web/assets/less-B1dDrJ26.js +1 -0
  61. package/web/assets/lua-BaeVxFsk.js +1 -0
  62. package/web/assets/make-CHLpvVh8.js +1 -0
  63. package/web/assets/markdown-Cvjx9yec.js +1 -0
  64. package/web/assets/nix-CwoSXNpI.js +1 -0
  65. package/web/assets/objective-c-DXmwc3jG.js +1 -0
  66. package/web/assets/php-DoruYMF0.js +1 -0
  67. package/web/assets/pierre-dark-CyvmCCZW.js +1 -0
  68. package/web/assets/pierre-dark-VKo95ZgA.js +1 -0
  69. package/web/assets/pierre-light-480U9XYS.js +1 -0
  70. package/web/assets/pierre-light-BVSW-Xev.js +1 -0
  71. package/web/assets/postcss-DnzrXTVv.js +1 -0
  72. package/web/assets/powershell-Dd0kTxKP.js +1 -0
  73. package/web/assets/prisma-Vru482bI.js +1 -0
  74. package/web/assets/proto-C7zT0LnQ.js +1 -0
  75. package/web/assets/python-B6aJPvgy.js +1 -0
  76. package/web/assets/ruby-B43yHlf9.js +1 -0
  77. package/web/assets/rust-DPvrY3IQ.js +1 -0
  78. package/web/assets/scala-CqE71os6.js +1 -0
  79. package/web/assets/scss-Cw6XMfVs.js +1 -0
  80. package/web/assets/shellscript-Yzrsuije.js +1 -0
  81. package/web/assets/shiki-BwyCm3kZ.js +2 -0
  82. package/web/assets/shiki-langs-CMlALLSg.js +2 -0
  83. package/web/assets/shiki-wasm-CgFRq91U.js +1 -0
  84. package/web/assets/sql-DkvoVjww.js +1 -0
  85. package/web/assets/stream-tokenizer-BlgIhw0y.js +4 -0
  86. package/web/assets/svelte-DoIXUgJc.js +1 -0
  87. package/web/assets/swift-C2oV4EkX.js +1 -0
  88. package/web/assets/terraform-BETggiCN.js +1 -0
  89. package/web/assets/theming-themes-DDvoqEqT.js +1 -0
  90. package/web/assets/toml-vGWfd6FD.js +1 -0
  91. package/web/assets/tsx-COt5Ahok.js +1 -0
  92. package/web/assets/typescript-BPQ3VLAy.js +1 -0
  93. package/web/assets/vue-CwSeJimo.js +1 -0
  94. package/web/assets/xml-sdJ4AIDG.js +1 -0
  95. package/web/assets/xterm-view-6GBZ9nXN.css +32 -0
  96. package/web/assets/xterm-view-BnKOnF3C.js +38 -0
  97. package/web/assets/yaml-Buea-lGh.js +1 -0
  98. package/web/assets/zig-VOosw3JB.js +1 -0
  99. package/web/index.html +2 -2
  100. package/dist/chunk-etf7ef97.js +0 -55
  101. package/dist/chunk-greb3dp6.js +0 -22
  102. package/web/assets/index-BhHrKpoo.js +0 -395
  103. package/web/assets/index-DoyHMPwf.css +0 -1
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  serveWebClient
4
- } from "./chunk-wb10efyq.js";
4
+ } from "./chunk-445cevpm.js";
5
5
  import {
6
6
  RELAY_CLOSE,
7
7
  decodeHostToRelay,
@@ -9,14 +9,14 @@ import {
9
9
  relayWsBase,
10
10
  verifyRelayChallenge,
11
11
  verifyRelayChallengeV2
12
- } from "./chunk-y127fe1a.js";
12
+ } from "./chunk-g3ahp7b9.js";
13
13
  import {
14
14
  normalizeHostName
15
15
  } from "./chunk-pqe7q1s7.js";
16
16
  import {
17
17
  commandHint,
18
18
  webRoot
19
- } from "./chunk-sd5fkxjj.js";
19
+ } from "./chunk-s0h7c7da.js";
20
20
  import"./chunk-df32avw6.js";
21
21
 
22
22
  // apps/server/src/main.ts
@@ -58,13 +58,14 @@ 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 = 6;
61
+ var SCHEMA_VERSION = 8;
62
62
  var SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
63
63
  var JOIN_TOKEN_TTL_MS = 10 * 60 * 1000;
64
64
  var USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
65
65
  var USER_CODE_LENGTH = 8;
66
66
  var HOST_LINK_TTL_MS = JOIN_TOKEN_TTL_MS;
67
67
  var HOST_LINK_INTERVAL_S = 5;
68
+ var HOST_LINK_CODE_TTL_MS = 2 * 60000;
68
69
  var SLOW_DOWN_STEP_S = 5;
69
70
  var POLL_SLACK_MS = 1000;
70
71
  var HOST_OS_MAX_CODEPOINTS = 64;
@@ -114,6 +115,11 @@ var MAX_USERNAME_CODEPOINTS = 254;
114
115
  var scryptAsync = promisify(scrypt);
115
116
  var DUMMY_HASH = `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${"A".repeat(22)}$${"A".repeat(43)}`;
116
117
  var sha256Hex = (input) => createHash("sha256").update(input, "utf8").digest("hex");
118
+ var safeEqual = (a, b) => {
119
+ const left = Buffer.from(a);
120
+ const right = Buffer.from(b);
121
+ return left.length === right.length && timingSafeEqual(left, right);
122
+ };
117
123
  async function hashPassword(password) {
118
124
  const salt = randomBytes(SALT_LEN);
119
125
  const hash = await scryptAsync(password, salt, KEY_LEN, SCRYPT);
@@ -239,6 +245,32 @@ function migrate(db) {
239
245
  COMMIT;
240
246
  `);
241
247
  }
248
+ if (version < 7) {
249
+ db.exec(`
250
+ BEGIN;
251
+ CREATE TABLE removed_hosts (
252
+ host_id TEXT PRIMARY KEY,
253
+ removed_at INTEGER NOT NULL
254
+ );
255
+ PRAGMA user_version = 7;
256
+ COMMIT;
257
+ `);
258
+ }
259
+ if (version < 8) {
260
+ db.exec(`
261
+ BEGIN;
262
+ ALTER TABLE host_link_requests ADD COLUMN request_id_hash TEXT;
263
+ ALTER TABLE host_link_requests ADD COLUMN redirect_uri TEXT;
264
+ ALTER TABLE host_link_requests ADD COLUMN code_challenge TEXT;
265
+ ALTER TABLE host_link_requests ADD COLUMN client_state TEXT;
266
+ ALTER TABLE host_link_requests ADD COLUMN auth_code_hash TEXT;
267
+ ALTER TABLE host_link_requests ADD COLUMN auth_code_expires_at INTEGER;
268
+ CREATE UNIQUE INDEX host_link_requests_request_id ON host_link_requests (request_id_hash);
269
+ CREATE UNIQUE INDEX host_link_requests_auth_code ON host_link_requests (auth_code_hash);
270
+ PRAGMA user_version = 8;
271
+ COMMIT;
272
+ `);
273
+ }
242
274
  }
243
275
  function migrateTo4(db) {
244
276
  db.exec("BEGIN");
@@ -329,6 +361,12 @@ function migrateTo1(db) {
329
361
  COMMIT;
330
362
  `);
331
363
  }
364
+ var loopbackRedirect = (redirectUri, params) => {
365
+ const url = new URL(redirectUri);
366
+ for (const [key, value] of Object.entries(params))
367
+ url.searchParams.set(key, value);
368
+ return url.toString();
369
+ };
332
370
 
333
371
  class WindowedCounter {
334
372
  map = new Map;
@@ -447,6 +485,8 @@ async function openAccounts(options) {
447
485
  const linkStarts = new WindowedCounter(linkLimits.counterMaxEntries, now);
448
486
  const linkUserFailures = new WindowedCounter(linkLimits.counterMaxEntries, now);
449
487
  const linkIpFailures = new WindowedCounter(linkLimits.counterMaxEntries, now);
488
+ const PENDING_SELECT = `SELECT host_id AS hostId, host_name AS name, host_os AS os, source_ip AS sourceIp, expires_at AS expiresAt,
489
+ redirect_uri AS redirectUri, client_state AS clientState FROM host_link_requests`;
450
490
  const sweepExpired = () => {
451
491
  const at = now();
452
492
  db.prepare("DELETE FROM join_tokens WHERE expires_at <= ?").run(at);
@@ -458,29 +498,47 @@ async function openAccounts(options) {
458
498
  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
499
  return { token, expiresAt };
460
500
  };
461
- const pendingByCode = (userId, userCode, ip) => {
501
+ const pendingByRef = (userId, ref, ip) => {
502
+ if ("requestId" in ref) {
503
+ const hash = sha256Hex(ref.requestId);
504
+ const row2 = db.prepare(`${PENDING_SELECT} WHERE request_id_hash = ? AND state = 'pending' AND expires_at > ?`).get(hash, now());
505
+ return row2 === undefined ? "not_found" : { column: "request_id_hash", hash, row: row2 };
506
+ }
507
+ const { userCode } = ref;
462
508
  const userKey = String(userId);
463
509
  if (linkUserFailures.isOver(userKey, linkLimits.userMaxFailures) || linkIpFailures.isOver(ip, linkLimits.ipMaxFailures)) {
464
510
  return "limited";
465
511
  }
466
512
  const code = normalizeUserCode(userCode);
467
513
  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());
514
+ const row = codeHash === undefined ? undefined : db.prepare(`${PENDING_SELECT} WHERE user_code_hash = ? AND state = 'pending' AND expires_at > ?`).get(codeHash, now());
470
515
  if (codeHash === undefined || row === undefined) {
471
516
  linkUserFailures.bump(userKey, linkLimits.failureWindowMs);
472
517
  linkIpFailures.bump(ip, linkLimits.failureWindowMs);
473
518
  return "not_found";
474
519
  }
475
- return { codeHash, row };
520
+ return { column: "user_code_hash", hash: codeHash, row };
476
521
  };
477
522
  const settleHostLink = (args, state) => {
478
- const found = pendingByCode(args.userId, args.userCode, args.ip);
523
+ const found = pendingByRef(args.userId, args, args.ip);
479
524
  if (found === "limited" || found === "not_found")
480
525
  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" };
526
+ const { column, hash, row } = found;
527
+ const at = now();
528
+ const loopback = row.redirectUri === null ? undefined : { redirectUri: row.redirectUri, state: row.clientState ?? "" };
529
+ if (loopback !== undefined && state === "denied") {
530
+ const dropped = db.prepare(`DELETE FROM host_link_requests WHERE ${column} = ? AND state = 'pending' AND expires_at > ?`).run(hash, at);
531
+ if (Number(dropped.changes) !== 1)
532
+ return { status: "not_found" };
533
+ return { status: "ok", redirect: loopbackRedirect(loopback.redirectUri, { error: "access_denied", state: loopback.state }) };
534
+ }
535
+ const code = loopback === undefined ? undefined : randomBytes(32).toString("base64url");
536
+ const settled = db.prepare(`UPDATE host_link_requests SET state = ?, user_id = ?, host_name = NULL, host_os = NULL, source_ip = NULL,
537
+ auth_code_hash = ?, auth_code_expires_at = ?
538
+ WHERE ${column} = ? AND state = 'pending' AND expires_at > ?`).run(state, state === "confirmed" ? args.userId : null, code === undefined ? null : sha256Hex(code), code === undefined ? null : at + HOST_LINK_CODE_TTL_MS, hash, at);
539
+ if (Number(settled.changes) !== 1)
540
+ return { status: "not_found" };
541
+ return loopback === undefined || code === undefined ? { status: "ok" } : { status: "ok", redirect: loopbackRedirect(loopback.redirectUri, { code, state: loopback.state }) };
484
542
  };
485
543
  const deviceForSignIn = (userId, claim) => {
486
544
  const at = now();
@@ -777,7 +835,7 @@ async function openAccounts(options) {
777
835
  sweepExpired();
778
836
  return mintJoinToken(userId, null);
779
837
  },
780
- startHostLink({ hostId, name, os, ip }) {
838
+ startHostLink({ hostId, name, os, ip, loopback }) {
781
839
  if (linkStarts.hit(ip, linkLimits.startMaxPerIp, linkLimits.startWindowMs))
782
840
  return { status: "limited" };
783
841
  sweepExpired();
@@ -787,14 +845,24 @@ async function openAccounts(options) {
787
845
  const deviceCode = randomBytes(32).toString("base64url");
788
846
  const at = now();
789
847
  const expiresAt = at + HOST_LINK_TTL_MS;
848
+ const hostName = normalizeHostName(name);
849
+ const hostOs = [...os.trim()].slice(0, HOST_OS_MAX_CODEPOINTS).join("");
850
+ if (loopback !== undefined) {
851
+ const requestId = randomBytes(32).toString("base64url");
852
+ db.prepare(`INSERT INTO host_link_requests
853
+ (device_code_hash, user_code_hash, request_id_hash, redirect_uri, code_challenge, client_state,
854
+ host_id, host_name, host_os, source_ip, interval_s, created_at, expires_at)
855
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(sha256Hex(randomBytes(32).toString("base64url")), sha256Hex(randomBytes(32).toString("base64url")), sha256Hex(requestId), loopback.redirectUri, loopback.codeChallenge, loopback.state, hostId, hostName, hostOs, ip, HOST_LINK_INTERVAL_S, at, expiresAt);
856
+ return { status: "ok", mode: "loopback", requestId, expiresAt };
857
+ }
790
858
  const insert = db.prepare(`INSERT INTO host_link_requests
791
859
  (device_code_hash, user_code_hash, host_id, host_name, host_os, source_ip, interval_s, created_at, expires_at)
792
860
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
793
861
  for (let attempt = 0;; attempt++) {
794
862
  const code = Array.from({ length: USER_CODE_LENGTH }, () => USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)]).join("");
795
863
  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 };
864
+ insert.run(sha256Hex(deviceCode), sha256Hex(code), hostId, hostName, hostOs, ip, HOST_LINK_INTERVAL_S, at, expiresAt);
865
+ return { status: "ok", mode: "code", deviceCode, userCode: formatUserCode(code), expiresAt, interval: HOST_LINK_INTERVAL_S };
798
866
  } catch (error) {
799
867
  if (attempt >= 4 || !String(error).includes("UNIQUE"))
800
868
  throw error;
@@ -830,8 +898,9 @@ async function openAccounts(options) {
830
898
  throw error;
831
899
  }
832
900
  },
833
- lookupHostLink({ userId, userCode, ip }) {
834
- const found = pendingByCode(userId, userCode, ip);
901
+ lookupHostLink(args) {
902
+ const { userId, ip } = args;
903
+ const found = pendingByRef(userId, args, ip);
835
904
  if (found === "limited" || found === "not_found")
836
905
  return { status: found };
837
906
  const { row } = found;
@@ -853,6 +922,24 @@ async function openAccounts(options) {
853
922
  denyHostLink(args) {
854
923
  return settleHostLink(args, "denied");
855
924
  },
925
+ exchangeHostLinkCode(code, codeVerifier) {
926
+ const codeHash = sha256Hex(code);
927
+ const at = now();
928
+ db.exec("BEGIN IMMEDIATE");
929
+ try {
930
+ const row = db.prepare(`SELECT host_id AS hostId, user_id AS userId, code_challenge AS challenge, auth_code_expires_at AS codeExpiresAt
931
+ FROM host_link_requests WHERE auth_code_hash = ? AND state = 'confirmed'`).get(codeHash);
932
+ if (row !== undefined)
933
+ db.prepare("DELETE FROM host_link_requests WHERE auth_code_hash = ?").run(codeHash);
934
+ const valid = row !== undefined && row.userId !== null && row.challenge !== null && row.codeExpiresAt !== null && row.codeExpiresAt > at && safeEqual(createHash("sha256").update(codeVerifier).digest("base64url"), row.challenge);
935
+ const result = valid ? { status: "ok", ...mintJoinToken(row.userId, row.hostId) } : { status: "invalid" };
936
+ db.exec("COMMIT");
937
+ return result;
938
+ } catch (error) {
939
+ db.exec("ROLLBACK");
940
+ throw error;
941
+ }
942
+ },
856
943
  sweepExpired,
857
944
  joinHost({ token, hostId, noisePublicKey, name }) {
858
945
  const tokenHash = sha256Hex(token);
@@ -888,6 +975,7 @@ async function openAccounts(options) {
888
975
  name = excluded.name,
889
976
  joined_at = excluded.joined_at,
890
977
  last_seen_at = excluded.last_seen_at`).run(hostId, row.userId, noisePublicKey, hostName, at, at);
978
+ db.prepare("DELETE FROM removed_hosts WHERE host_id = ?").run(hostId);
891
979
  const user = db.prepare("SELECT username FROM users WHERE id = ?").get(row.userId);
892
980
  db.exec("COMMIT");
893
981
  return { status: "joined", username: user?.username ?? "unknown" };
@@ -899,6 +987,37 @@ async function openAccounts(options) {
899
987
  leaveHost(hostId) {
900
988
  return Number(db.prepare("DELETE FROM hosts WHERE host_id = ?").run(hostId).changes) > 0;
901
989
  },
990
+ async removeHost({ userId, username, password, hostId }) {
991
+ const owned = () => db.prepare("SELECT 1 AS x FROM hosts WHERE host_id = ? AND user_id = ?").get(hostId, userId) !== undefined;
992
+ if (!owned())
993
+ return { status: "refused", reason: "not_found" };
994
+ const checked = await checkPassword(userId, username, password);
995
+ if (checked === "limited")
996
+ return { status: "limited" };
997
+ if (checked === "wrong_password")
998
+ return { status: "refused", reason: "wrong_password" };
999
+ db.exec("BEGIN IMMEDIATE");
1000
+ try {
1001
+ if (currentHash(userId) !== checked.verifiedHash) {
1002
+ db.exec("ROLLBACK");
1003
+ return { status: "refused", reason: "wrong_password" };
1004
+ }
1005
+ if (!owned()) {
1006
+ db.exec("ROLLBACK");
1007
+ return { status: "refused", reason: "not_found" };
1008
+ }
1009
+ db.prepare("DELETE FROM hosts WHERE host_id = ?").run(hostId);
1010
+ db.prepare("INSERT INTO removed_hosts (host_id, removed_at) VALUES (?, ?) ON CONFLICT(host_id) DO UPDATE SET removed_at = excluded.removed_at").run(hostId, now());
1011
+ db.exec("COMMIT");
1012
+ return { status: "ok" };
1013
+ } catch (error) {
1014
+ db.exec("ROLLBACK");
1015
+ throw error;
1016
+ }
1017
+ },
1018
+ wasRemoved(hostId) {
1019
+ return db.prepare("SELECT 1 AS x FROM removed_hosts WHERE host_id = ?").get(hostId) !== undefined;
1020
+ },
902
1021
  listHosts(userId) {
903
1022
  return db.prepare(`SELECT host_id AS hostId, user_id AS userId, noise_public_key AS noisePublicKey,
904
1023
  name, joined_at AS joinedAt, last_seen_at AS lastSeenAt, device_reports AS deviceReports
@@ -1422,6 +1541,27 @@ function createRelayLimiter(limits, now = Date.now) {
1422
1541
  // apps/server/src/api.ts
1423
1542
  var SESSION_COOKIE = "__Host-pinomad-session";
1424
1543
  var HOST_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/;
1544
+ var PKCE_CHALLENGE = /^[A-Za-z0-9_-]{43}$/;
1545
+ var PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/;
1546
+ var LOOPBACK_STATE = /^[A-Za-z0-9_-]{1,128}$/;
1547
+ var MAX_REDIRECT_URI = 256;
1548
+ function parseLoopback(value) {
1549
+ const { redirectUri, codeChallenge, state } = value;
1550
+ if (typeof redirectUri !== "string" || redirectUri.length > MAX_REDIRECT_URI)
1551
+ return null;
1552
+ if (typeof codeChallenge !== "string" || !PKCE_CHALLENGE.test(codeChallenge))
1553
+ return null;
1554
+ if (typeof state !== "string" || !LOOPBACK_STATE.test(state))
1555
+ return null;
1556
+ let url;
1557
+ try {
1558
+ url = new URL(redirectUri);
1559
+ } catch {
1560
+ return null;
1561
+ }
1562
+ const loopbackOnly = url.protocol === "http:" && url.hostname === "127.0.0.1" && url.port !== "" && url.username === "" && url.password === "" && url.search === "" && url.hash === "" && url.href === redirectUri;
1563
+ return loopbackOnly ? { redirectUri, codeChallenge, state } : null;
1564
+ }
1425
1565
  var MAX_BODY = 8 * 1024;
1426
1566
  var MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
1427
1567
  var DESKTOP_ORIGIN = "app://pinomad";
@@ -1469,6 +1609,15 @@ async function readJson(request, optional = false) {
1469
1609
  return { ok: false, tooLarge: false };
1470
1610
  }
1471
1611
  }
1612
+ async function authedJson(accounts, request, token, optional = false) {
1613
+ const body = await readJson(request, optional);
1614
+ if (!body.ok)
1615
+ return { ok: false, status: body.tooLarge ? 413 : 400, error: body.tooLarge ? "payload_too_large" : "invalid_json" };
1616
+ const session = accounts.authenticate(token);
1617
+ if (session === undefined)
1618
+ return { ok: false, status: 401, error: "unauthenticated" };
1619
+ return { ok: true, value: body.value, session };
1620
+ }
1472
1621
  function sessionToken(request) {
1473
1622
  const authorization = request.headers.authorization;
1474
1623
  if (authorization !== undefined && authorization.startsWith("Bearer "))
@@ -1512,7 +1661,7 @@ async function handleApiRequest(request, response, ctx) {
1512
1661
  }
1513
1662
  const bearer = (request.headers.authorization ?? "").startsWith("Bearer ");
1514
1663
  const loginTokenMode = (pathname === "/api/login" || pathname === "/api/register") && (origin === undefined || origin === DESKTOP_ORIGIN);
1515
- const hostLinkHostMode = (pathname === "/api/host-link/start" || pathname === "/api/host-link/poll") && origin === undefined;
1664
+ const hostLinkHostMode = (pathname === "/api/host-link/start" || pathname === "/api/host-link/poll" || pathname === "/api/host-link/token") && origin === undefined;
1516
1665
  if (MUTATING.has(method) && !bearer && !loginTokenMode && !hostLinkHostMode && origin !== ctx.publicOrigin) {
1517
1666
  deny(403, "origin_mismatch");
1518
1667
  return;
@@ -1593,7 +1742,7 @@ async function handleApiRequest(request, response, ctx) {
1593
1742
  send(200, { user: { username }, expiresAt: result.expiresAt, ...cookieMode ? {} : { token: result.token } }, cookieMode ? { "set-cookie": setSessionCookie(result.token) } : undefined);
1594
1743
  return;
1595
1744
  }
1596
- if (pathname === "/api/host-link/start" || pathname === "/api/host-link/poll") {
1745
+ if (pathname === "/api/host-link/start" || pathname === "/api/host-link/poll" || pathname === "/api/host-link/token") {
1597
1746
  if (method !== "POST") {
1598
1747
  deny(405, "method_not_allowed", { allow: "POST" });
1599
1748
  return;
@@ -1605,7 +1754,8 @@ async function handleApiRequest(request, response, ctx) {
1605
1754
  }
1606
1755
  if (pathname === "/api/host-link/start") {
1607
1756
  const value = body.value;
1608
- if (typeof value?.hostId !== "string" || !HOST_ID_PATTERN.test(value.hostId) || typeof value.name !== "string" || typeof value.os !== "string") {
1757
+ const loopback = value?.redirectUri === undefined ? undefined : parseLoopback(value);
1758
+ if (typeof value?.hostId !== "string" || !HOST_ID_PATTERN.test(value.hostId) || typeof value.name !== "string" || typeof value.os !== "string" || loopback === null) {
1609
1759
  deny(400, "invalid_request");
1610
1760
  return;
1611
1761
  }
@@ -1613,7 +1763,8 @@ async function handleApiRequest(request, response, ctx) {
1613
1763
  hostId: value.hostId,
1614
1764
  name: value.name,
1615
1765
  os: value.os,
1616
- ip: clientIp(request, ctx.trustProxy)
1766
+ ip: clientIp(request, ctx.trustProxy),
1767
+ ...loopback === undefined ? {} : { loopback }
1617
1768
  });
1618
1769
  if (result2.status === "limited") {
1619
1770
  deny(429, "rate_limited");
@@ -1623,6 +1774,14 @@ async function handleApiRequest(request, response, ctx) {
1623
1774
  deny(503, "busy");
1624
1775
  return;
1625
1776
  }
1777
+ if (result2.mode === "loopback") {
1778
+ send(200, {
1779
+ requestId: result2.requestId,
1780
+ verifyUrl: `${ctx.publicOrigin}/host-link?request=${result2.requestId}`,
1781
+ expiresAt: result2.expiresAt
1782
+ });
1783
+ return;
1784
+ }
1626
1785
  send(200, {
1627
1786
  deviceCode: result2.deviceCode,
1628
1787
  userCode: result2.userCode,
@@ -1632,6 +1791,19 @@ async function handleApiRequest(request, response, ctx) {
1632
1791
  });
1633
1792
  return;
1634
1793
  }
1794
+ if (pathname === "/api/host-link/token") {
1795
+ const value = body.value;
1796
+ if (typeof value?.code !== "string" || value.code.length > 256 || typeof value.codeVerifier !== "string" || !PKCE_VERIFIER.test(value.codeVerifier)) {
1797
+ deny(400, "invalid_request");
1798
+ return;
1799
+ }
1800
+ const result2 = accounts.exchangeHostLinkCode(value.code, value.codeVerifier);
1801
+ if (result2.status === "invalid")
1802
+ deny(400, "invalid_grant");
1803
+ else
1804
+ send(200, { token: result2.token, expiresAt: result2.expiresAt });
1805
+ return;
1806
+ }
1635
1807
  const deviceCode = body.value?.deviceCode;
1636
1808
  if (typeof deviceCode !== "string" || deviceCode.length > 256) {
1637
1809
  deny(400, "invalid_request");
@@ -1666,22 +1838,18 @@ async function handleApiRequest(request, response, ctx) {
1666
1838
  deny(401, "unauthenticated");
1667
1839
  return;
1668
1840
  }
1669
- const body = await readJson(request);
1841
+ const body = await authedJson(accounts, request, token);
1670
1842
  if (!body.ok) {
1671
- deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
1843
+ deny(body.status, body.error);
1672
1844
  return;
1673
1845
  }
1674
- const userCode = body.value?.userCode;
1675
- if (typeof userCode !== "string" || userCode.length > 64) {
1846
+ const named = body.value;
1847
+ const ref = typeof named?.userCode === "string" && named.userCode.length <= 64 && named.requestId === undefined ? { userCode: named.userCode } : typeof named?.requestId === "string" && named.requestId.length <= 64 && named.userCode === undefined ? { requestId: named.requestId } : undefined;
1848
+ if (ref === undefined) {
1676
1849
  deny(400, "invalid_request");
1677
1850
  return;
1678
1851
  }
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) };
1852
+ const args = { userId: body.session.userId, ip: clientIp(request, ctx.trustProxy), ...ref };
1685
1853
  const result = pathname === "/api/host-link/lookup" ? accounts.lookupHostLink(args) : pathname === "/api/host-link/confirm" ? accounts.confirmHostLink(args) : accounts.denyHostLink(args);
1686
1854
  if (result.status === "limited") {
1687
1855
  deny(429, "rate_limited");
@@ -1693,6 +1861,8 @@ async function handleApiRequest(request, response, ctx) {
1693
1861
  }
1694
1862
  if ("request" in result)
1695
1863
  send(200, result.request);
1864
+ else if (result.redirect !== undefined)
1865
+ send(200, { redirect: result.redirect });
1696
1866
  else
1697
1867
  send(204);
1698
1868
  return;
@@ -1720,9 +1890,13 @@ async function handleApiRequest(request, response, ctx) {
1720
1890
  send(200, accounts.createInvite(session.userId));
1721
1891
  return;
1722
1892
  }
1723
- const body = await readJson(request);
1893
+ const body = await authedJson(accounts, request, token);
1724
1894
  if (!body.ok) {
1725
- deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
1895
+ deny(body.status, body.error);
1896
+ return;
1897
+ }
1898
+ if (!body.session.admin) {
1899
+ deny(401, "unauthenticated");
1726
1900
  return;
1727
1901
  }
1728
1902
  const id = body.value?.id;
@@ -1730,16 +1904,52 @@ async function handleApiRequest(request, response, ctx) {
1730
1904
  deny(400, "invalid_request");
1731
1905
  return;
1732
1906
  }
1733
- if (accounts.authenticate(token)?.admin !== true) {
1734
- deny(401, "unauthenticated");
1735
- return;
1736
- }
1737
1907
  if (accounts.revokeInvite(id))
1738
1908
  send(204);
1739
1909
  else
1740
1910
  deny(404, "not_found");
1741
1911
  return;
1742
1912
  }
1913
+ const hostRemove = /^\/api\/hosts\/([A-Za-z0-9_-]{43})\/remove$/.exec(pathname);
1914
+ if (hostRemove !== null) {
1915
+ if (method !== "POST") {
1916
+ deny(405, "method_not_allowed", { allow: "POST" });
1917
+ return;
1918
+ }
1919
+ const token = sessionToken(request);
1920
+ if (token === undefined || accounts.authenticate(token) === undefined) {
1921
+ deny(401, "unauthenticated");
1922
+ return;
1923
+ }
1924
+ const body = await authedJson(accounts, request, token);
1925
+ if (!body.ok) {
1926
+ deny(body.status, body.error);
1927
+ return;
1928
+ }
1929
+ const value = body.value;
1930
+ if (typeof value?.password !== "string" || [...value.password].length > MAX_PASSWORD_CODEPOINTS) {
1931
+ deny(400, "invalid_request");
1932
+ return;
1933
+ }
1934
+ const current = body.session;
1935
+ const result = await accounts.removeHost({
1936
+ userId: current.userId,
1937
+ username: current.username,
1938
+ password: value.password,
1939
+ hostId: hostRemove[1]
1940
+ });
1941
+ if (result.status === "limited") {
1942
+ deny(429, "rate_limited");
1943
+ return;
1944
+ }
1945
+ if (result.status === "refused") {
1946
+ deny(result.reason === "not_found" ? 404 : 403, result.reason);
1947
+ return;
1948
+ }
1949
+ ctx.disconnectHost(hostRemove[1], true);
1950
+ send(204);
1951
+ return;
1952
+ }
1743
1953
  const hostPairing = /^\/api\/hosts\/([A-Za-z0-9_-]{43})\/pairing$/.exec(pathname);
1744
1954
  if (pathname === "/api/hosts" || hostPairing !== null) {
1745
1955
  const expected = pathname === "/api/hosts" ? "GET" : "POST";
@@ -1834,9 +2044,9 @@ async function handleApiRequest(request, response, ctx) {
1834
2044
  send(200, accounts.listDevices(session.userId).map((device) => ({ ...device, current: device.id === session.deviceId })));
1835
2045
  return;
1836
2046
  }
1837
- const body = await readJson(request);
2047
+ const body = await authedJson(accounts, request, token);
1838
2048
  if (!body.ok) {
1839
- deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
2049
+ deny(body.status, body.error);
1840
2050
  return;
1841
2051
  }
1842
2052
  const value = body.value;
@@ -1844,11 +2054,7 @@ async function handleApiRequest(request, response, ctx) {
1844
2054
  deny(400, "invalid_request");
1845
2055
  return;
1846
2056
  }
1847
- const current = accounts.authenticate(token);
1848
- if (current === undefined) {
1849
- deny(401, "unauthenticated");
1850
- return;
1851
- }
2057
+ const current = body.session;
1852
2058
  const result = await accounts.kickDevice({
1853
2059
  userId: current.userId,
1854
2060
  username: current.username,
@@ -1886,9 +2092,9 @@ async function handleApiRequest(request, response, ctx) {
1886
2092
  return;
1887
2093
  }
1888
2094
  if (pathname === "/api/push-tokens" || pathname === "/api/push-tokens/remove") {
1889
- const body = await readJson(request);
2095
+ const body = await authedJson(accounts, request, token);
1890
2096
  if (!body.ok) {
1891
- deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
2097
+ deny(body.status, body.error);
1892
2098
  return;
1893
2099
  }
1894
2100
  const value = body.value;
@@ -1898,30 +2104,27 @@ async function handleApiRequest(request, response, ctx) {
1898
2104
  deny(400, "invalid_request");
1899
2105
  return;
1900
2106
  }
1901
- const current = accounts.authenticate(token);
1902
- if (current === undefined) {
1903
- deny(401, "unauthenticated");
1904
- return;
1905
- }
2107
+ const current = body.session;
1906
2108
  if (pathname === "/api/push-tokens")
1907
- accounts.registerPushToken(session.userId, apns, environment, current.deviceId);
2109
+ accounts.registerPushToken(current.userId, apns, environment, current.deviceId);
1908
2110
  else
1909
- accounts.removePushToken(session.userId, apns, environment);
2111
+ accounts.removePushToken(current.userId, apns, environment);
1910
2112
  send(204);
1911
2113
  return;
1912
2114
  }
1913
2115
  if (pathname === "/api/me/delete") {
1914
- const body = await readJson(request);
2116
+ const body = await authedJson(accounts, request, token);
1915
2117
  if (!body.ok) {
1916
- deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
2118
+ deny(body.status, body.error);
1917
2119
  return;
1918
2120
  }
2121
+ const current = body.session;
1919
2122
  const value = body.value;
1920
2123
  if (typeof value?.password !== "string" || [...value.password].length > MAX_PASSWORD_CODEPOINTS) {
1921
2124
  deny(400, "invalid_request");
1922
2125
  return;
1923
2126
  }
1924
- const result = await accounts.deleteAccount({ userId: session.userId, username: session.username, password: value.password });
2127
+ const result = await accounts.deleteAccount({ userId: current.userId, username: current.username, password: value.password });
1925
2128
  if (result.status === "limited") {
1926
2129
  deny(429, "rate_limited");
1927
2130
  return;
@@ -1932,24 +2135,25 @@ async function handleApiRequest(request, response, ctx) {
1932
2135
  }
1933
2136
  for (const hostId of result.hostIds)
1934
2137
  ctx.disconnectHost(hostId);
1935
- ctx.forgetAccount(session.userId);
2138
+ ctx.forgetAccount(current.userId);
1936
2139
  send(204, undefined, { "set-cookie": clearSessionCookie });
1937
2140
  return;
1938
2141
  }
1939
2142
  if (pathname === "/api/password") {
1940
- const body = await readJson(request);
2143
+ const body = await authedJson(accounts, request, token);
1941
2144
  if (!body.ok) {
1942
- deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
2145
+ deny(body.status, body.error);
1943
2146
  return;
1944
2147
  }
2148
+ const current = body.session;
1945
2149
  const value = body.value;
1946
2150
  if (typeof value?.currentPassword !== "string" || typeof value.newPassword !== "string" || [...value.currentPassword].length > MAX_PASSWORD_CODEPOINTS) {
1947
2151
  deny(400, "invalid_request");
1948
2152
  return;
1949
2153
  }
1950
2154
  const result = await accounts.changePassword({
1951
- userId: session.userId,
1952
- username: session.username,
2155
+ userId: current.userId,
2156
+ username: current.username,
1953
2157
  keepToken: token,
1954
2158
  currentPassword: value.currentPassword,
1955
2159
  newPassword: value.newPassword
@@ -1975,9 +2179,9 @@ async function handleApiRequest(request, response, ctx) {
1975
2179
  return;
1976
2180
  }
1977
2181
  if (pathname === "/api/logout") {
1978
- const body = await readJson(request, true);
2182
+ const body = await authedJson(accounts, request, token, true);
1979
2183
  if (!body.ok) {
1980
- deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
2184
+ deny(body.status, body.error);
1981
2185
  return;
1982
2186
  }
1983
2187
  const value = body.value;
@@ -2118,8 +2322,8 @@ async function startRelay(options) {
2118
2322
  });
2119
2323
  }
2120
2324
  const pairing = { status: pairingStatus, request: requestPairing };
2121
- function disconnectHost(hostId) {
2122
- controls.get(hostId)?.socket.close(RELAY_CLOSE.hostNotAllowed);
2325
+ function disconnectHost(hostId, removed = false) {
2326
+ controls.get(hostId)?.socket.close(removed ? RELAY_CLOSE.hostRemoved : RELAY_CLOSE.hostNotAllowed);
2123
2327
  closeHostDevices(hostId, RELAY_CLOSE.hostNotAllowed, "host left its account");
2124
2328
  }
2125
2329
  function closeHostDevices(hostId, code, reason) {
@@ -2306,7 +2510,7 @@ async function startRelay(options) {
2306
2510
  return;
2307
2511
  }
2308
2512
  if (accounts?.hasHost(frame.hostId) !== true) {
2309
- socket.close(RELAY_CLOSE.hostNotAllowed);
2513
+ socket.close(accounts?.wasRemoved(frame.hostId) === true ? RELAY_CLOSE.hostRemoved : RELAY_CLOSE.hostNotAllowed);
2310
2514
  return;
2311
2515
  }
2312
2516
  hostId = frame.hostId;