pinomad 0.12.0 → 0.14.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 (47) hide show
  1. package/dist/chunk-0dh2k3er.js +43 -0
  2. package/dist/{chunk-47ff1d1t.js → chunk-1mjbt2z2.js} +191 -11
  3. package/dist/{chunk-7wj0jh3t.js → chunk-1rdbnvy6.js} +1 -1
  4. package/dist/{chunk-ksfxqd8s.js → chunk-35szq73k.js} +2682 -459
  5. package/dist/{chunk-mnmqyq7d.js → chunk-5j7ybhjj.js} +1984 -333
  6. package/dist/{chunk-agq4tn9r.js → chunk-5pv0g1nj.js} +12 -10
  7. package/dist/{chunk-7mn38n4f.js → chunk-63ymn5c9.js} +2 -2
  8. package/dist/{chunk-nppgxh6c.js → chunk-6j6hkfbj.js} +8 -4
  9. package/dist/{chunk-pqe7q1s7.js → chunk-6vbp1vva.js} +2 -1
  10. package/dist/{chunk-g3ahp7b9.js → chunk-9trbbz4r.js} +1 -1
  11. package/dist/{chunk-gg2dcqbg.js → chunk-b0nwq1dt.js} +29 -12
  12. package/dist/chunk-dsr19q5h.js +23 -0
  13. package/dist/{chunk-41q0saz0.js → chunk-e53c0s8z.js} +9 -7
  14. package/dist/{chunk-np4jd070.js → chunk-e59k3bmz.js} +307 -56
  15. package/dist/chunk-eerpgvy9.js +315 -0
  16. package/dist/chunk-eewxy4cp.js +240 -0
  17. package/dist/{chunk-vqkb70d7.js → chunk-evbv1fkn.js} +3 -3
  18. package/dist/{chunk-vx7dy07y.js → chunk-ex5yr09h.js} +11 -6
  19. package/dist/{chunk-t91zt5f0.js → chunk-hg9gs396.js} +1 -1
  20. package/dist/{chunk-aqj9wfmb.js → chunk-mtkvft88.js} +1 -1
  21. package/dist/{chunk-t5abqsva.js → chunk-mxwcnnkg.js} +15 -305
  22. package/dist/{chunk-5qf1grkk.js → chunk-w927q9m1.js} +52 -16
  23. package/dist/pinomad.js +12 -12
  24. package/package.json +16 -11
  25. package/web/assets/{core-DnpzcQzs.js → core-BWHGsFpV.js} +1 -1
  26. package/web/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  27. package/web/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  28. package/web/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  29. package/web/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  30. package/web/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  31. package/web/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  32. package/web/assets/{highlighted-diff-Dn5ifsvK.js → highlighted-diff-DJ7KZCpS.js} +2 -2
  33. package/web/assets/{highlights-DnHv6NBM.js → highlights-CyEmo6OI.js} +1 -1
  34. package/web/assets/{highlights-themes-CUdrlZk3.js → highlights-themes-CSNkBojy.js} +1 -1
  35. package/web/assets/index-C_qkbyL7.css +1 -0
  36. package/web/assets/index-DefQtCI2.js +396 -0
  37. package/web/assets/{shiki-BuPP5HCg.js → shiki-BzCmRG-v.js} +2 -2
  38. package/web/assets/{shiki-langs-FJk0v0Ga.js → shiki-langs-DOkjyV4d.js} +1 -1
  39. package/web/assets/{stream-tokenizer-Bl6bisIa.js → stream-tokenizer-CBPqFe4Z.js} +1 -1
  40. package/web/assets/{theming-themes-BL_CC-Mm.js → theming-themes-wKVca0aB.js} +1 -1
  41. package/web/assets/{xterm-view-ZsuzJb2C.js → xterm-view-CKa1-QCM.js} +12 -12
  42. package/web/index.html +3 -2
  43. package/web/theme-boot.js +11 -0
  44. package/dist/chunk-42nqtx5r.js +0 -22
  45. package/dist/chunk-jeqsdcj1.js +0 -96
  46. package/web/assets/index-BV0ecolo.css +0 -1
  47. package/web/assets/index-DNsTTgPE.js +0 -396
@@ -9,14 +9,14 @@ import {
9
9
  relayWsBase,
10
10
  verifyRelayChallenge,
11
11
  verifyRelayChallengeV2
12
- } from "./chunk-g3ahp7b9.js";
12
+ } from "./chunk-9trbbz4r.js";
13
13
  import {
14
14
  normalizeHostName
15
- } from "./chunk-pqe7q1s7.js";
15
+ } from "./chunk-6vbp1vva.js";
16
16
  import {
17
17
  commandHint,
18
18
  webRoot
19
- } from "./chunk-7wj0jh3t.js";
19
+ } from "./chunk-1rdbnvy6.js";
20
20
  import"./chunk-df32avw6.js";
21
21
 
22
22
  // apps/server/src/main.ts
@@ -58,7 +58,7 @@ 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 = 8;
61
+ var SCHEMA_VERSION = 10;
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";
@@ -79,8 +79,20 @@ function normalizeUserCode(input) {
79
79
  return code;
80
80
  }
81
81
  var formatUserCode = (code) => `${code.slice(0, 4)}-${code.slice(4)}`;
82
- var INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
83
- var SIGN_UP_USERNAME = /^[a-z0-9][a-z0-9._-]{1,31}$/;
82
+ var EMAIL_CODE_TTL_MS = 10 * 60000;
83
+ var EMAIL_CODE_MAX_ATTEMPTS = 5;
84
+ var OAUTH_FLOW_TTL_MS = 10 * 60000;
85
+ var OAUTH_PENDING_TTL_MS = 10 * 60000;
86
+ var OAUTH_CODE_TTL_MS = 2 * 60000;
87
+ var REAUTH_TTL_MS = 5 * 60000;
88
+ var PROVIDER_NAMES = { google: "Google", github: "GitHub", apple: "Apple" };
89
+ var MAX_EMAIL_LENGTH = 254;
90
+ var MAX_LOCAL_PART_LENGTH = 64;
91
+ var EMAIL_PATTERN = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
92
+ function normalizeEmail(raw) {
93
+ const email = raw.trim().toLowerCase();
94
+ return email.length <= MAX_EMAIL_LENGTH && EMAIL_PATTERN.test(email) && email.indexOf("@") <= MAX_LOCAL_PART_LENGTH ? email : undefined;
95
+ }
84
96
  var PUSH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000;
85
97
  var MAX_PUSH_TOKENS_PER_USER = 32;
86
98
  var APNS_TOKEN_PATTERN = /^(?:[0-9a-f]{2})+$/;
@@ -115,6 +127,17 @@ var MAX_USERNAME_CODEPOINTS = 254;
115
127
  var scryptAsync = promisify(scrypt);
116
128
  var DUMMY_HASH = `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${"A".repeat(22)}$${"A".repeat(43)}`;
117
129
  var sha256Hex = (input) => createHash("sha256").update(input, "utf8").digest("hex");
130
+ var DAY_MS = 24 * 60 * 60 * 1000;
131
+ var codeHash = (purpose, email, code) => sha256Hex(`${purpose}:${email}:${code}`);
132
+ var USER_SELECT = "SELECT id, username, email, password_hash AS passwordHash FROM users";
133
+ var accountUser = (row) => row.email === null ? { username: row.username ?? "" } : { username: row.email, email: row.email };
134
+ var accountLockKey = (userId) => `user:${userId}`;
135
+ function requireEmail(raw) {
136
+ const email = normalizeEmail(raw);
137
+ if (email === undefined)
138
+ throw new Error(`not a valid email: ${raw}`);
139
+ return email;
140
+ }
118
141
  var safeEqual = (a, b) => {
119
142
  const left = Buffer.from(a);
120
143
  const right = Buffer.from(b);
@@ -271,6 +294,135 @@ function migrate(db) {
271
294
  COMMIT;
272
295
  `);
273
296
  }
297
+ if (version < 9)
298
+ migrateTo9(db);
299
+ if (version < 10) {
300
+ db.exec(`
301
+ BEGIN;
302
+ -- Apple sends a name only the first time; its refresh token is revoked
303
+ -- when the account goes or Apple is unlinked, with the client_id that
304
+ -- received it (the web Services ID or the app's bundle id).
305
+ ALTER TABLE user_identities ADD COLUMN name TEXT;
306
+ ALTER TABLE user_identities ADD COLUMN refresh_token TEXT;
307
+ ALTER TABLE user_identities ADD COLUMN refresh_client_id TEXT;
308
+ CREATE TABLE oauth_flows (
309
+ state_hash TEXT PRIMARY KEY,
310
+ provider TEXT NOT NULL,
311
+ kind TEXT NOT NULL CHECK (kind IN ('web', 'native', 'link')),
312
+ verifier TEXT NOT NULL,
313
+ nonce TEXT NOT NULL,
314
+ -- web and link: the hash of the browser's binding cookie.
315
+ binding_hash TEXT,
316
+ -- native: where the code goes, its PKCE challenge and the client's state.
317
+ redirect_uri TEXT,
318
+ code_challenge TEXT,
319
+ client_state TEXT,
320
+ link_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
321
+ -- link: the session that started it, which must still be live when
322
+ -- the identity is written.
323
+ link_session_hash TEXT,
324
+ created_at INTEGER NOT NULL,
325
+ expires_at INTEGER NOT NULL
326
+ );
327
+ CREATE INDEX oauth_flows_expiry ON oauth_flows (expires_at);
328
+ -- A provider sign-in that found no account, waiting for the person to
329
+ -- create one or link it to an account they sign in to.
330
+ CREATE TABLE oauth_pending (
331
+ id_hash TEXT PRIMARY KEY,
332
+ provider TEXT NOT NULL,
333
+ subject TEXT NOT NULL,
334
+ email TEXT,
335
+ email_verified INTEGER NOT NULL DEFAULT 0,
336
+ name TEXT,
337
+ refresh_token TEXT,
338
+ refresh_client_id TEXT,
339
+ kind TEXT NOT NULL CHECK (kind IN ('web', 'native', 'direct')),
340
+ binding_hash TEXT,
341
+ redirect_uri TEXT,
342
+ code_challenge TEXT,
343
+ client_state TEXT,
344
+ created_at INTEGER NOT NULL,
345
+ expires_at INTEGER NOT NULL
346
+ );
347
+ CREATE INDEX oauth_pending_expiry ON oauth_pending (expires_at);
348
+ CREATE TABLE oauth_codes (
349
+ code_hash TEXT PRIMARY KEY,
350
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
351
+ code_challenge TEXT NOT NULL,
352
+ expires_at INTEGER NOT NULL
353
+ );
354
+ -- A grant dies with the session it was issued to.
355
+ CREATE TABLE reauth_grants (
356
+ grant_hash TEXT PRIMARY KEY,
357
+ session_hash TEXT NOT NULL REFERENCES sessions(token_hash) ON DELETE CASCADE,
358
+ expires_at INTEGER NOT NULL
359
+ );
360
+ CREATE INDEX reauth_grants_session ON reauth_grants (session_hash);
361
+ PRAGMA user_version = 10;
362
+ COMMIT;
363
+ `);
364
+ }
365
+ }
366
+ function migrateTo9(db) {
367
+ db.exec("PRAGMA foreign_keys = OFF");
368
+ try {
369
+ db.exec("BEGIN");
370
+ try {
371
+ db.exec(`
372
+ DROP TABLE invites;
373
+ CREATE TABLE users_v9 (
374
+ id INTEGER PRIMARY KEY,
375
+ -- Legacy accounts only (ADR-0044 §8); NULL for email accounts.
376
+ username TEXT UNIQUE,
377
+ -- Lowercased; set only once verified, so email_verified_at goes with it.
378
+ email TEXT UNIQUE,
379
+ email_verified_at INTEGER,
380
+ -- NULL for an account that only signs in through a provider (#620).
381
+ password_hash TEXT,
382
+ created_at INTEGER NOT NULL,
383
+ CHECK (username IS NOT NULL OR email IS NOT NULL)
384
+ );
385
+ INSERT INTO users_v9 (id, username, password_hash, created_at) SELECT id, username, password_hash, created_at FROM users;
386
+ DROP TABLE users;
387
+ ALTER TABLE users_v9 RENAME TO users;
388
+ -- Sign-in providers bound to an account (#620): subject is the
389
+ -- provider's stable id for the person, never an email.
390
+ CREATE TABLE user_identities (
391
+ provider TEXT NOT NULL,
392
+ subject TEXT NOT NULL,
393
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
394
+ email TEXT,
395
+ created_at INTEGER NOT NULL,
396
+ PRIMARY KEY (provider, subject)
397
+ );
398
+ CREATE INDEX user_identities_user ON user_identities (user_id);
399
+ -- Mailed codes: only the hash. A sign-up code has no account yet.
400
+ -- AUTOINCREMENT, like every id here that could be reused.
401
+ CREATE TABLE email_codes (
402
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
403
+ purpose TEXT NOT NULL CHECK (purpose IN ('signup', 'reset', 'add_email', 'reauth')),
404
+ email TEXT NOT NULL,
405
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
406
+ code_hash TEXT NOT NULL,
407
+ attempts INTEGER NOT NULL DEFAULT 0,
408
+ created_at INTEGER NOT NULL,
409
+ expires_at INTEGER NOT NULL
410
+ );
411
+ CREATE INDEX email_codes_email ON email_codes (purpose, email);
412
+ CREATE INDEX email_codes_user ON email_codes (user_id);
413
+ CREATE INDEX email_codes_expiry ON email_codes (expires_at);
414
+ `);
415
+ const dangling = db.prepare("PRAGMA foreign_key_check").all();
416
+ if (dangling.length > 0)
417
+ throw new Error(`schema 9 migration left ${dangling.length} dangling references`);
418
+ db.exec("PRAGMA user_version = 9; COMMIT");
419
+ } catch (error) {
420
+ db.exec("ROLLBACK");
421
+ throw error;
422
+ }
423
+ } finally {
424
+ db.exec("PRAGMA foreign_keys = ON");
425
+ }
274
426
  }
275
427
  function migrateTo4(db) {
276
428
  db.exec("BEGIN");
@@ -451,10 +603,12 @@ async function openAccounts(options) {
451
603
  if (options.bootstrapUser === undefined || options.bootstrapPassword === undefined) {
452
604
  throw new Error("PINOMAD_SERVER_USER and PINOMAD_SERVER_PASSWORD are required to create the first account");
453
605
  }
454
- assertUsernamePolicy(options.bootstrapUser);
606
+ const email = options.bootstrapUser.includes("@") ? requireEmail(options.bootstrapUser) : undefined;
607
+ if (email === undefined)
608
+ assertUsernamePolicy(options.bootstrapUser);
455
609
  assertPasswordPolicy(options.bootstrapPassword);
456
610
  const hash = await hashPassword(options.bootstrapPassword);
457
- db.prepare("INSERT INTO users (username, password_hash, created_at, admin) VALUES (?, ?, ?, 1)").run(options.bootstrapUser, hash, now());
611
+ db.prepare("INSERT INTO users (username, email, email_verified_at, password_hash, created_at) VALUES (?, ?, ?, ?, ?)").run(email === undefined ? options.bootstrapUser : null, email ?? null, email === undefined ? null : now(), hash, now());
458
612
  }
459
613
  } catch (error) {
460
614
  db?.close();
@@ -485,12 +639,91 @@ async function openAccounts(options) {
485
639
  const linkStarts = new WindowedCounter(linkLimits.counterMaxEntries, now);
486
640
  const linkUserFailures = new WindowedCounter(linkLimits.counterMaxEntries, now);
487
641
  const linkIpFailures = new WindowedCounter(linkLimits.counterMaxEntries, now);
642
+ const codeLimits = {
643
+ ipMax: options.codeLimits?.ipMax ?? 10,
644
+ ipWindowMs: options.codeLimits?.ipWindowMs ?? 60 * 60000,
645
+ emailMax: options.codeLimits?.emailMax ?? 5,
646
+ emailWindowMs: options.codeLimits?.emailWindowMs ?? 60 * 60000,
647
+ dailyMax: options.codeLimits?.dailyMax ?? 1000,
648
+ counterMaxEntries: options.codeLimits?.counterMaxEntries ?? 4096
649
+ };
650
+ const codeIps = new WindowedCounter(codeLimits.counterMaxEntries, now);
651
+ const codeEmails = new WindowedCounter(codeLimits.counterMaxEntries, now);
652
+ const codesToday = new WindowedCounter(1, now);
653
+ const oauthLimits = {
654
+ startMaxPerIp: options.oauthLimits?.startMaxPerIp ?? 30,
655
+ startWindowMs: options.oauthLimits?.startWindowMs ?? 10 * 60000,
656
+ maxPending: options.oauthLimits?.maxPending ?? 1e4
657
+ };
658
+ const oauthStarts = new WindowedCounter(limits.counterMaxEntries, now);
659
+ const linkCodes = new Map;
660
+ const mailer = options.mailer;
661
+ const registrationOpen = options.registration === "open";
662
+ const emailSignup = registrationOpen && mailer !== undefined;
663
+ const codeAllowed = (ip, email) => {
664
+ if (codeIps.hit(ip, codeLimits.ipMax, codeLimits.ipWindowMs))
665
+ return false;
666
+ if (codeEmails.isOver(email, codeLimits.emailMax) || codesToday.isOver("all", codeLimits.dailyMax))
667
+ return false;
668
+ codeEmails.bump(email, codeLimits.emailWindowMs);
669
+ codesToday.bump("all", DAY_MS);
670
+ return true;
671
+ };
672
+ const deliver = (purpose, to, subject, text) => {
673
+ mailer?.send(to, subject, text).catch(() => {
674
+ console.error(JSON.stringify({ event: "mail_failed", purpose }));
675
+ });
676
+ };
677
+ const issueCode = (purpose, email, userId) => {
678
+ const at = now();
679
+ db.prepare("DELETE FROM email_codes WHERE expires_at <= ?").run(at);
680
+ if (purpose === "add_email")
681
+ db.prepare("DELETE FROM email_codes WHERE purpose = ? AND user_id = ?").run(purpose, userId);
682
+ else
683
+ db.prepare("DELETE FROM email_codes WHERE purpose = ? AND email = ?").run(purpose, email);
684
+ const code = String(randomInt(1e6)).padStart(6, "0");
685
+ db.prepare("INSERT INTO email_codes (purpose, email, user_id, code_hash, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)").run(purpose, email, userId, codeHash(purpose, email, code), at, at + EMAIL_CODE_TTL_MS);
686
+ return code;
687
+ };
688
+ const spendCode = (purpose, email, code, lockKey, userId) => {
689
+ const row = db.prepare(`SELECT id, code_hash AS codeHash, attempts FROM email_codes
690
+ WHERE purpose = ? AND email = ? AND user_id IS ? AND expires_at > ?`).get(purpose, email, userId ?? null, now());
691
+ if (row !== undefined && safeEqual(codeHash(purpose, email, code.replace(/\s/g, "")), row.codeHash)) {
692
+ db.prepare("DELETE FROM email_codes WHERE id = ?").run(row.id);
693
+ return true;
694
+ }
695
+ userFailures.bump(lockKey, limits.userWindowMs);
696
+ if (row !== undefined) {
697
+ if (row.attempts + 1 >= EMAIL_CODE_MAX_ATTEMPTS)
698
+ db.prepare("DELETE FROM email_codes WHERE id = ?").run(row.id);
699
+ else
700
+ db.prepare("UPDATE email_codes SET attempts = attempts + 1 WHERE id = ?").run(row.id);
701
+ }
702
+ return false;
703
+ };
704
+ const findUser = (identifier) => {
705
+ const email = normalizeEmail(identifier);
706
+ return db.prepare(`${USER_SELECT} WHERE email = ? OR username = ? ORDER BY email IS ? DESC LIMIT 1`).get(email ?? null, identifier, email ?? null);
707
+ };
708
+ const userById = (userId) => db.prepare(`${USER_SELECT} WHERE id = ?`).get(userId);
709
+ const lockKeyFor = (identifier, user) => user === undefined ? `name:${normalizeEmail(identifier) ?? identifier}` : accountLockKey(user.id);
488
710
  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
711
  redirect_uri AS redirectUri, client_state AS clientState FROM host_link_requests`;
712
+ const linkIdentity = ({ linkUserId: userId, linkSessionHash }, identity) => transaction(() => {
713
+ const live = db.prepare("SELECT 1 AS x FROM sessions WHERE token_hash = ? AND user_id = ? AND expires_at > ?").get(linkSessionHash, userId, now());
714
+ if (live === undefined)
715
+ return "session_ended";
716
+ const bound = identityOwner(identity.provider, identity.subject);
717
+ if (bound !== undefined && bound !== userId)
718
+ return "identity_taken";
719
+ bindIdentity(userId, identity);
720
+ return "linked";
721
+ });
490
722
  const sweepExpired = () => {
491
723
  const at = now();
492
- db.prepare("DELETE FROM join_tokens WHERE expires_at <= ?").run(at);
493
- db.prepare("DELETE FROM host_link_requests WHERE expires_at <= ?").run(at);
724
+ for (const table of ["join_tokens", "host_link_requests", "email_codes", "oauth_flows", "oauth_pending", "oauth_codes", "reauth_grants"]) {
725
+ db.prepare(`DELETE FROM ${table} WHERE expires_at <= ?`).run(at);
726
+ }
494
727
  };
495
728
  const mintJoinToken = (userId, hostId) => {
496
729
  const token = randomBytes(32).toString("base64url");
@@ -510,14 +743,14 @@ async function openAccounts(options) {
510
743
  return "limited";
511
744
  }
512
745
  const code = normalizeUserCode(userCode);
513
- const codeHash = code === undefined ? undefined : sha256Hex(code);
514
- const row = codeHash === undefined ? undefined : db.prepare(`${PENDING_SELECT} WHERE user_code_hash = ? AND state = 'pending' AND expires_at > ?`).get(codeHash, now());
515
- if (codeHash === undefined || row === undefined) {
746
+ const codeHash2 = code === undefined ? undefined : sha256Hex(code);
747
+ const row = codeHash2 === undefined ? undefined : db.prepare(`${PENDING_SELECT} WHERE user_code_hash = ? AND state = 'pending' AND expires_at > ?`).get(codeHash2, now());
748
+ if (codeHash2 === undefined || row === undefined) {
516
749
  linkUserFailures.bump(userKey, linkLimits.failureWindowMs);
517
750
  linkIpFailures.bump(ip, linkLimits.failureWindowMs);
518
751
  return "not_found";
519
752
  }
520
- return { column: "user_code_hash", hash: codeHash, row };
753
+ return { column: "user_code_hash", hash: codeHash2, row };
521
754
  };
522
755
  const settleHostLink = (args, state) => {
523
756
  const found = pendingByRef(args.userId, args, args.ip);
@@ -578,16 +811,17 @@ async function openAccounts(options) {
578
811
  db.prepare("UPDATE vouchings SET revoked_at = ? WHERE device_id = ? AND revoked_at IS NULL").run(at, deviceId);
579
812
  return hostIds;
580
813
  };
581
- const currentHash = (userId) => db.prepare("SELECT password_hash AS passwordHash FROM users WHERE id = ?").get(userId)?.passwordHash;
582
- const checkPassword = async (userId, username, password) => {
583
- if (userFailures.isOver(username, limits.userMaxFailures))
814
+ const currentHash = (userId) => userById(userId)?.passwordHash;
815
+ const checkPassword = async (userId, password) => {
816
+ const lockKey = accountLockKey(userId);
817
+ if (userFailures.isOver(lockKey, limits.userMaxFailures))
584
818
  return "limited";
585
- const stored = currentHash(userId);
819
+ const stored = currentHash(userId) ?? undefined;
586
820
  const outcome = await gate.run(async () => {
587
- if (userFailures.isOver(username, limits.userMaxFailures))
821
+ if (userFailures.isOver(lockKey, limits.userMaxFailures))
588
822
  return "limited";
589
823
  if (!await verifyPassword(password, stored ?? DUMMY_HASH) || stored === undefined) {
590
- userFailures.bump(username, limits.userWindowMs);
824
+ userFailures.bump(lockKey, limits.userWindowMs);
591
825
  return "wrong_password";
592
826
  }
593
827
  return "ok";
@@ -596,30 +830,85 @@ async function openAccounts(options) {
596
830
  return "limited";
597
831
  if (outcome === "wrong_password")
598
832
  return "wrong_password";
599
- userFailures.reset(username);
833
+ userFailures.reset(lockKey);
600
834
  return { verifiedHash: stored };
601
835
  };
602
- const inviteState = (codeHash) => {
603
- const row = db.prepare("SELECT expires_at AS expiresAt, used_at AS usedAt FROM invites WHERE code_hash = ?").get(codeHash);
604
- if (row === undefined || row.usedAt !== null)
605
- return "invalid_invite";
606
- return row.expiresAt <= now() ? "invite_expired" : "ok";
836
+ const reauthValid = (userId, token, grant) => {
837
+ const at = now();
838
+ return db.prepare(`SELECT 1 AS x FROM reauth_grants g JOIN sessions s ON s.token_hash = g.session_hash
839
+ WHERE g.grant_hash = ? AND g.session_hash = ? AND g.expires_at > ? AND s.user_id = ? AND s.expires_at > ?`).get(sha256Hex(grant), sha256Hex(token), at, userId, at) !== undefined;
840
+ };
841
+ const sessionLive = (userId, token) => db.prepare("SELECT 1 AS x FROM sessions WHERE token_hash = ? AND user_id = ? AND expires_at > ?").get(sha256Hex(token), userId, now()) !== undefined;
842
+ const confirmAccount = async (userId, confirmation) => {
843
+ if ("reauth" in confirmation) {
844
+ return reauthValid(userId, confirmation.token, confirmation.reauth) ? { reauth: confirmation.reauth, token: confirmation.token } : "invalid_reauth";
845
+ }
846
+ return checkPassword(userId, confirmation.password);
847
+ };
848
+ const stillConfirmed = (userId, confirmed) => {
849
+ if ("verifiedHash" in confirmed)
850
+ return currentHash(userId) === confirmed.verifiedHash ? undefined : "wrong_password";
851
+ return reauthValid(userId, confirmed.token, confirmed.reauth) ? undefined : "invalid_reauth";
852
+ };
853
+ const appleGrants = (userId) => db.prepare(`SELECT refresh_token AS refreshToken, refresh_client_id AS clientId FROM user_identities
854
+ WHERE user_id = ? AND provider = 'apple' AND refresh_token IS NOT NULL AND refresh_client_id IS NOT NULL`).all(userId);
855
+ const identityOwner = (provider, subject) => db.prepare("SELECT user_id AS userId FROM user_identities WHERE provider = ? AND subject = ?").get(provider, subject)?.userId;
856
+ const bindIdentity = (userId, identity) => {
857
+ db.prepare(`INSERT INTO user_identities (provider, subject, user_id, email, name, refresh_token, refresh_client_id, created_at)
858
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
859
+ ON CONFLICT (provider, subject) DO UPDATE SET
860
+ email = COALESCE(excluded.email, email), name = COALESCE(name, excluded.name),
861
+ refresh_token = COALESCE(excluded.refresh_token, refresh_token),
862
+ refresh_client_id = COALESCE(excluded.refresh_client_id, refresh_client_id)
863
+ WHERE user_id = excluded.user_id`).run(identity.provider, identity.subject, userId, identity.email ?? null, identity.name ?? null, identity.refreshToken ?? null, identity.refreshClientId ?? null, now());
864
+ };
865
+ const noticeNewMethod = (email, provider) => {
866
+ const name = PROVIDER_NAMES[provider];
867
+ deliver("new_sign_in_method", email, `A new sign-in method was added: ${name}`, `${name} can now sign in to your PiNomad account, because it confirmed this email address.
868
+
869
+ ` + `If this wasn't you, sign in, remove ${name} under Sign-in methods, and sign out everywhere.
870
+ `);
871
+ };
872
+ const pendingRow = (id) => db.prepare(`SELECT provider, subject, email, email_verified AS emailVerified, name, refresh_token AS refreshToken,
873
+ refresh_client_id AS refreshClientId, kind, binding_hash AS bindingHash, redirect_uri AS redirectUri,
874
+ code_challenge AS codeChallenge, client_state AS clientState
875
+ FROM oauth_pending WHERE id_hash = ? AND expires_at > ?`).get(sha256Hex(id), now());
876
+ const completionOf = (row) => row.kind === "web" ? { kind: "web", bindingHash: row.bindingHash ?? "" } : row.kind === "native" ? {
877
+ kind: "native",
878
+ bindingHash: row.bindingHash ?? "",
879
+ native: { redirectUri: row.redirectUri ?? "", codeChallenge: row.codeChallenge ?? "", state: row.clientState ?? "" }
880
+ } : { kind: "direct" };
881
+ const identityOf = (row) => ({
882
+ provider: row.provider,
883
+ subject: row.subject,
884
+ ...row.email === null ? {} : { email: row.email },
885
+ emailVerified: row.emailVerified === 1,
886
+ ...row.name === null ? {} : { name: row.name },
887
+ ...row.refreshToken === null || row.refreshClientId === null ? {} : { refreshToken: row.refreshToken, refreshClientId: row.refreshClientId }
888
+ });
889
+ const transaction = (write) => inTransaction(db, write);
890
+ const emailOwner = (email) => db.prepare("SELECT id FROM users WHERE email = ?").get(email)?.id;
891
+ const weakPassword = (password) => {
892
+ const length = [...password].length;
893
+ return length < MIN_PASSWORD_CODEPOINTS || length > MAX_PASSWORD_CODEPOINTS;
607
894
  };
608
- const usernameTaken = (username) => db.prepare("SELECT 1 AS x FROM users WHERE lower(username) = ?").get(username) !== undefined;
609
895
  return {
610
896
  dbPath: join(options.dataDir, DB_FILE),
611
- async login(username, password, ip, device) {
897
+ emailEnabled: mailer !== undefined,
898
+ registrationOpen,
899
+ async login(identifier, password, ip, device) {
612
900
  if (ipAttempts.hit(ip, limits.ipMaxAttempts, limits.ipWindowMs))
613
901
  return { status: "limited" };
614
- if (userFailures.isOver(username, limits.userMaxFailures))
902
+ const user = findUser(identifier);
903
+ const lockKey = lockKeyFor(identifier, user);
904
+ if (userFailures.isOver(lockKey, limits.userMaxFailures))
615
905
  return { status: "limited" };
616
- const user = db.prepare("SELECT id, username, password_hash AS passwordHash, admin FROM users WHERE username = ?").get(username);
617
906
  const outcome = await gate.run(async () => {
618
- if (userFailures.isOver(username, limits.userMaxFailures))
907
+ if (userFailures.isOver(lockKey, limits.userMaxFailures))
619
908
  return "limited";
620
909
  const ok = await verifyPassword(password, user?.passwordHash ?? DUMMY_HASH);
621
- if (!ok || user === undefined) {
622
- userFailures.bump(username, limits.userWindowMs);
910
+ if (!ok || user === undefined || user.passwordHash === null) {
911
+ userFailures.bump(lockKey, limits.userWindowMs);
623
912
  return "invalid";
624
913
  }
625
914
  return user.id;
@@ -630,89 +919,187 @@ async function openAccounts(options) {
630
919
  return { status: "invalid" };
631
920
  db.exec("BEGIN IMMEDIATE");
632
921
  let session;
922
+ let current;
633
923
  try {
634
- if (currentHash(outcome) === user?.passwordHash)
924
+ current = userById(outcome);
925
+ if (current !== undefined && current.passwordHash === user?.passwordHash)
635
926
  session = issueSession(outcome, device);
636
927
  db.exec("COMMIT");
637
928
  } catch (error) {
638
929
  db.exec("ROLLBACK");
639
930
  throw error;
640
931
  }
641
- if (session === undefined)
932
+ if (session === undefined || current === undefined)
642
933
  return { status: "invalid" };
643
- userFailures.reset(username);
644
- return { status: "ok", ...session, admin: user?.admin === 1 };
934
+ userFailures.reset(lockKey);
935
+ return { status: "ok", ...session, user: accountUser(current) };
645
936
  },
646
- async register({ invite, username, password, ip, device }) {
937
+ requestSignupCode({ email, ip }) {
938
+ if (!emailSignup)
939
+ return "closed";
940
+ if (!codeAllowed(ip, email))
941
+ return "limited";
942
+ if (emailOwner(email) !== undefined) {
943
+ deliver("signup_existing", email, "Your PiNomad account", `Someone asked to create a PiNomad account with this email, but you already have an account.
944
+
945
+ ` + `Sign in, or reset your password if you forgot it. If this wasn't you, ignore this email.
946
+ `);
947
+ return "sent";
948
+ }
949
+ const code = issueCode("signup", email, null);
950
+ deliver("signup", email, `${code} is your PiNomad sign-up code`, `Your PiNomad sign-up code is ${code}. It expires in 10 minutes.
951
+
952
+ If you didn't ask for it, ignore this email.
953
+ `);
954
+ return "sent";
955
+ },
956
+ async register({ email, code, password, ip, device }) {
957
+ if (!emailSignup)
958
+ return { status: "refused", reason: "registration_closed" };
959
+ if (weakPassword(password))
960
+ return { status: "refused", reason: "weak_password" };
647
961
  if (ipAttempts.hit(ip, limits.ipMaxAttempts, limits.ipWindowMs))
648
962
  return { status: "limited" };
649
- const codeHash = sha256Hex(invite);
650
- const invited = inviteState(codeHash);
651
- if (invited !== "ok")
652
- return { status: "refused", reason: invited };
653
- if (!SIGN_UP_USERNAME.test(username))
654
- return { status: "refused", reason: "invalid_username" };
655
- const length = [...password].length;
656
- if (length < MIN_PASSWORD_CODEPOINTS || length > MAX_PASSWORD_CODEPOINTS) {
657
- return { status: "refused", reason: "weak_password" };
658
- }
659
- if (usernameTaken(username))
660
- return { status: "refused", reason: "username_taken" };
963
+ const lockKey = lockKeyFor(email, findUser(email));
964
+ if (userFailures.isOver(lockKey, limits.userMaxFailures))
965
+ return { status: "limited" };
661
966
  const hash = await gate.run(() => hashPassword(password));
662
967
  if (hash === undefined)
663
968
  return { status: "limited" };
664
- db.exec("BEGIN");
969
+ db.exec("BEGIN IMMEDIATE");
665
970
  try {
666
- const again = inviteState(codeHash);
667
- const failure = again !== "ok" ? again : usernameTaken(username) ? "username_taken" : undefined;
668
- if (failure !== undefined) {
669
- db.exec("ROLLBACK");
670
- return { status: "refused", reason: failure };
671
- }
672
- const userId = Number(db.prepare("INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)").run(username, hash, now()).lastInsertRowid);
673
- const claimed = db.prepare("UPDATE invites SET used_at = ?, used_by = ? WHERE code_hash = ? AND used_at IS NULL").run(now(), userId, codeHash);
674
- if (Number(claimed.changes) !== 1) {
675
- db.exec("ROLLBACK");
676
- return { status: "refused", reason: "invalid_invite" };
971
+ if (!spendCode("signup", email, code, lockKey) || emailOwner(email) !== undefined) {
972
+ db.exec("COMMIT");
973
+ return { status: "refused", reason: "invalid_code" };
677
974
  }
975
+ const at = now();
976
+ const userId = Number(db.prepare("INSERT INTO users (email, email_verified_at, password_hash, created_at) VALUES (?, ?, ?, ?)").run(email, at, hash, at).lastInsertRowid);
678
977
  const session = issueSession(userId, device);
679
978
  db.exec("COMMIT");
680
- return { status: "ok", ...session };
979
+ userFailures.reset(lockKey);
980
+ return { status: "ok", ...session, user: { username: email, email } };
681
981
  } catch (error) {
682
982
  db.exec("ROLLBACK");
683
983
  throw error;
684
984
  }
685
985
  },
686
- createInvite(userId) {
687
- db.prepare("DELETE FROM invites WHERE used_at IS NULL AND expires_at <= ?").run(now());
688
- const code = randomBytes(16).toString("base64url");
689
- const expiresAt = now() + INVITE_TTL_MS;
690
- db.prepare("INSERT INTO invites (code_hash, created_by, created_at, expires_at) VALUES (?, ?, ?, ?)").run(sha256Hex(code), userId, now(), expiresAt);
691
- return { code, expiresAt };
692
- },
693
- listInvites() {
694
- const rows = db.prepare(`SELECT i.id AS id, i.created_at AS createdAt, i.expires_at AS expiresAt, i.used_at AS usedAt, u.username AS usedBy
695
- FROM invites i LEFT JOIN users u ON u.id = i.used_by ORDER BY i.created_at DESC, i.id DESC`).all();
696
- return rows.map((row) => ({
697
- id: row.id,
698
- createdAt: row.createdAt,
699
- expiresAt: row.expiresAt,
700
- ...row.usedAt === null ? {} : { usedAt: row.usedAt },
701
- ...row.usedBy === null ? {} : { usedBy: row.usedBy }
702
- }));
986
+ requestPasswordReset({ email, ip }) {
987
+ if (mailer === undefined)
988
+ return "closed";
989
+ if (!codeAllowed(ip, email))
990
+ return "limited";
991
+ const userId = emailOwner(email);
992
+ if (userId === undefined)
993
+ return "sent";
994
+ const code = issueCode("reset", email, userId);
995
+ deliver("reset", email, `${code} is your PiNomad password reset code`, `Your PiNomad password reset code is ${code}. It expires in 10 minutes.
996
+
997
+ ` + `If you didn't ask to reset your password, ignore this email; your password stays as it is.
998
+ `);
999
+ return "sent";
1000
+ },
1001
+ async resetPasswordWithCode({ email, code, password, ip }) {
1002
+ if (mailer === undefined)
1003
+ return { status: "refused", reason: "email_unavailable" };
1004
+ if (weakPassword(password))
1005
+ return { status: "refused", reason: "weak_password" };
1006
+ if (ipAttempts.hit(ip, limits.ipMaxAttempts, limits.ipWindowMs))
1007
+ return { status: "limited" };
1008
+ const lockKey = lockKeyFor(email, findUser(email));
1009
+ if (userFailures.isOver(lockKey, limits.userMaxFailures))
1010
+ return { status: "limited" };
1011
+ const hash = await gate.run(() => hashPassword(password));
1012
+ if (hash === undefined)
1013
+ return { status: "limited" };
1014
+ db.exec("BEGIN IMMEDIATE");
1015
+ try {
1016
+ const userId = emailOwner(email);
1017
+ if (userId === undefined || !spendCode("reset", email, code, lockKey, userId)) {
1018
+ if (userId === undefined)
1019
+ userFailures.bump(lockKey, limits.userWindowMs);
1020
+ db.exec("COMMIT");
1021
+ return { status: "refused", reason: "invalid_code" };
1022
+ }
1023
+ db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hash, userId);
1024
+ dropCredentials(db, userId);
1025
+ db.exec("COMMIT");
1026
+ userFailures.reset(lockKey);
1027
+ return { status: "ok" };
1028
+ } catch (error) {
1029
+ db.exec("ROLLBACK");
1030
+ throw error;
1031
+ }
1032
+ },
1033
+ async requestEmailCode(args) {
1034
+ const { userId, email, ip } = args;
1035
+ if (mailer === undefined)
1036
+ return "closed";
1037
+ const checked = await confirmAccount(userId, args);
1038
+ if (typeof checked === "string")
1039
+ return checked;
1040
+ const owner = emailOwner(email);
1041
+ if (owner !== undefined && owner !== userId)
1042
+ return "taken";
1043
+ if (!codeAllowed(ip, email))
1044
+ return "limited";
1045
+ const code = issueCode("add_email", email, userId);
1046
+ deliver("add_email", email, `${code} is your PiNomad confirmation code`, `Your code to use this email for your PiNomad account is ${code}. It expires in 10 minutes.
1047
+
1048
+ ` + `If you didn't ask for it, ignore this email.
1049
+ `);
1050
+ return "sent";
703
1051
  },
704
- revokeInvite(id) {
705
- return Number(db.prepare("DELETE FROM invites WHERE id = ? AND used_at IS NULL").run(id).changes) > 0;
1052
+ async setEmail(args) {
1053
+ const { userId, email, code } = args;
1054
+ if (mailer === undefined)
1055
+ return { status: "refused", reason: "email_unavailable" };
1056
+ const checked = await confirmAccount(userId, args);
1057
+ if (checked === "limited")
1058
+ return { status: "limited" };
1059
+ if (typeof checked === "string")
1060
+ return { status: "refused", reason: checked };
1061
+ const lockKey = accountLockKey(userId);
1062
+ let previous;
1063
+ db.exec("BEGIN IMMEDIATE");
1064
+ try {
1065
+ const user = userById(userId);
1066
+ const stale = user === undefined ? "wrong_password" : stillConfirmed(userId, checked);
1067
+ if (user === undefined || stale !== undefined) {
1068
+ db.exec("ROLLBACK");
1069
+ return { status: "refused", reason: stale ?? "wrong_password" };
1070
+ }
1071
+ const owner = emailOwner(email);
1072
+ if (owner !== undefined && owner !== userId) {
1073
+ db.exec("COMMIT");
1074
+ return { status: "refused", reason: "email_taken" };
1075
+ }
1076
+ if (!spendCode("add_email", email, code, lockKey, userId)) {
1077
+ db.exec("COMMIT");
1078
+ return { status: "refused", reason: "invalid_code" };
1079
+ }
1080
+ db.prepare("UPDATE users SET email = ?, email_verified_at = ? WHERE id = ?").run(email, now(), userId);
1081
+ previous = user.email;
1082
+ db.exec("COMMIT");
1083
+ } catch (error) {
1084
+ db.exec("ROLLBACK");
1085
+ throw error;
1086
+ }
1087
+ if (previous !== null && previous !== undefined && previous !== email) {
1088
+ deliver("email_changed", previous, "Your PiNomad email was changed", `The email of your PiNomad account was changed to ${email}.
1089
+
1090
+ ` + "If this wasn't you, someone else has your password and now controls the account's email. " + `Contact your server's operator.
1091
+ `);
1092
+ }
1093
+ return { status: "ok", user: { username: email, email } };
706
1094
  },
707
1095
  authenticate(token) {
708
- const row = db.prepare(`SELECT s.user_id AS userId, u.username AS username, u.admin AS admin, s.expires_at AS expiresAt, s.device_id AS deviceId
1096
+ const row = db.prepare(`SELECT s.user_id AS userId, u.username AS username, u.email AS email, s.expires_at AS expiresAt, s.device_id AS deviceId
709
1097
  FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ?`).get(sha256Hex(token));
710
1098
  if (row === undefined || row.expiresAt <= now())
711
1099
  return;
712
1100
  return {
713
1101
  userId: row.userId,
714
- username: row.username,
715
- admin: row.admin === 1,
1102
+ user: accountUser(row),
716
1103
  ...row.deviceId === null ? {} : { deviceId: row.deviceId }
717
1104
  };
718
1105
  },
@@ -734,26 +1121,26 @@ async function openAccounts(options) {
734
1121
  throw error;
735
1122
  }
736
1123
  },
737
- async changePassword({ userId, username, keepToken, currentPassword, newPassword }) {
738
- const length = [...newPassword].length;
739
- if (length < MIN_PASSWORD_CODEPOINTS || length > MAX_PASSWORD_CODEPOINTS) {
1124
+ async changePassword(args) {
1125
+ const { userId, keepToken, newPassword } = args;
1126
+ if (weakPassword(newPassword))
740
1127
  return { status: "refused", reason: "weak_password" };
741
- }
742
- const checked = await checkPassword(userId, username, currentPassword);
1128
+ const checked = await confirmAccount(userId, "reauth" in args ? { reauth: args.reauth, token: keepToken } : { password: args.currentPassword });
743
1129
  if (checked === "limited")
744
1130
  return { status: "limited" };
745
- if (checked === "wrong_password")
746
- return { status: "refused", reason: "wrong_password" };
1131
+ if (typeof checked === "string")
1132
+ return { status: "refused", reason: checked };
747
1133
  const hash = await gate.run(() => hashPassword(newPassword));
748
1134
  if (hash === undefined)
749
1135
  return { status: "limited" };
750
1136
  db.exec("BEGIN IMMEDIATE");
751
1137
  try {
752
- const swapped = db.prepare("UPDATE users SET password_hash = ? WHERE id = ? AND password_hash = ?").run(hash, userId, checked.verifiedHash);
753
- if (Number(swapped.changes) !== 1) {
1138
+ const stale = "verifiedHash" in checked && !sessionLive(userId, keepToken) ? "unauthenticated" : stillConfirmed(userId, checked);
1139
+ if (stale !== undefined) {
754
1140
  db.exec("ROLLBACK");
755
- return { status: "refused", reason: "wrong_password" };
1141
+ return { status: "refused", reason: stale };
756
1142
  }
1143
+ db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hash, userId);
757
1144
  dropCredentials(db, userId, sha256Hex(keepToken));
758
1145
  db.exec("COMMIT");
759
1146
  } catch (error) {
@@ -762,30 +1149,283 @@ async function openAccounts(options) {
762
1149
  }
763
1150
  return { status: "ok" };
764
1151
  },
765
- async deleteAccount({ userId, username, password }) {
766
- const user = db.prepare("SELECT admin FROM users WHERE id = ?").get(userId);
767
- if (user?.admin === 1)
768
- return { status: "refused", reason: "admin_account" };
769
- const checked = await checkPassword(userId, username, password);
1152
+ async deleteAccount(args) {
1153
+ const { userId, keepToken } = args;
1154
+ const checked = await confirmAccount(userId, args);
770
1155
  if (checked === "limited")
771
1156
  return { status: "limited" };
772
- if (checked === "wrong_password")
773
- return { status: "refused", reason: "wrong_password" };
1157
+ if (typeof checked === "string")
1158
+ return { status: "refused", reason: checked };
774
1159
  db.exec("BEGIN IMMEDIATE");
775
1160
  try {
776
- const hostIds = db.prepare("SELECT host_id AS hostId FROM hosts WHERE user_id = ?").all(userId).map((row) => row.hostId);
777
- const deleted = db.prepare("DELETE FROM users WHERE id = ? AND password_hash = ?").run(userId, checked.verifiedHash);
778
- if (Number(deleted.changes) !== 1) {
1161
+ const stale = "verifiedHash" in checked && !sessionLive(userId, keepToken) ? "unauthenticated" : stillConfirmed(userId, checked);
1162
+ if (stale !== undefined) {
779
1163
  db.exec("ROLLBACK");
780
- return { status: "refused", reason: "wrong_password" };
1164
+ return { status: "refused", reason: stale };
781
1165
  }
1166
+ const hostIds = db.prepare("SELECT host_id AS hostId FROM hosts WHERE user_id = ?").all(userId).map((row) => row.hostId);
1167
+ const grants = appleGrants(userId);
1168
+ db.prepare("DELETE FROM users WHERE id = ?").run(userId);
782
1169
  db.exec("COMMIT");
783
- return { status: "ok", hostIds };
1170
+ return { status: "ok", hostIds, appleGrants: grants };
784
1171
  } catch (error) {
785
1172
  db.exec("ROLLBACK");
786
1173
  throw error;
787
1174
  }
788
1175
  },
1176
+ async confirm(userId, confirmation) {
1177
+ const checked = await confirmAccount(userId, confirmation);
1178
+ return typeof checked === "string" ? checked : "ok";
1179
+ },
1180
+ requestReauthCode({ userId, ip }) {
1181
+ if (mailer === undefined)
1182
+ return "closed";
1183
+ const email = userById(userId)?.email ?? null;
1184
+ if (email === null)
1185
+ return "no_email";
1186
+ if (!codeAllowed(ip, email))
1187
+ return "limited";
1188
+ const code = issueCode("reauth", email, userId);
1189
+ deliver("reauth", email, `${code} is your PiNomad confirmation code`, `Your code to confirm a change to your PiNomad account is ${code}. It expires in 10 minutes.
1190
+
1191
+ ` + `If you didn't ask for it, someone else may be signed in to your account: sign out everywhere.
1192
+ `);
1193
+ return "sent";
1194
+ },
1195
+ async grantReauth(args) {
1196
+ const { userId, token } = args;
1197
+ const lockKey = accountLockKey(userId);
1198
+ if ("password" in args) {
1199
+ const checked = await checkPassword(userId, args.password);
1200
+ if (checked === "limited")
1201
+ return { status: "limited" };
1202
+ if (checked === "wrong_password")
1203
+ return { status: "refused", reason: "wrong_password" };
1204
+ } else {
1205
+ if (userFailures.isOver(lockKey, limits.userMaxFailures))
1206
+ return { status: "limited" };
1207
+ const email = userById(userId)?.email ?? null;
1208
+ const spent = email !== null && transaction(() => spendCode("reauth", email, args.code, lockKey, userId));
1209
+ if (!spent) {
1210
+ if (email === null)
1211
+ userFailures.bump(lockKey, limits.userWindowMs);
1212
+ return { status: "refused", reason: "invalid_code" };
1213
+ }
1214
+ }
1215
+ const reauth = randomBytes(32).toString("base64url");
1216
+ const at = now();
1217
+ const expiresAt = at + REAUTH_TTL_MS;
1218
+ const granted = transaction(() => {
1219
+ db.prepare("DELETE FROM reauth_grants WHERE expires_at <= ?").run(at);
1220
+ return db.prepare(`INSERT INTO reauth_grants (grant_hash, session_hash, expires_at)
1221
+ SELECT ?, token_hash, ? FROM sessions WHERE token_hash = ? AND user_id = ? AND expires_at > ?`).run(sha256Hex(reauth), expiresAt, sha256Hex(token), userId, at).changes;
1222
+ });
1223
+ return Number(granted) === 1 ? { status: "ok", reauth, expiresAt } : { status: "refused", reason: "unauthenticated" };
1224
+ },
1225
+ startOAuthFlow({ provider, kind, ip, bindingHash, native, linkUserId, linkSessionToken }) {
1226
+ if (oauthStarts.hit(ip, oauthLimits.startMaxPerIp, oauthLimits.startWindowMs))
1227
+ return { status: "limited" };
1228
+ const at = now();
1229
+ db.prepare("DELETE FROM oauth_flows WHERE expires_at <= ?").run(at);
1230
+ const live = db.prepare("SELECT COUNT(*) AS n FROM oauth_flows").get().n;
1231
+ if (live >= oauthLimits.maxPending)
1232
+ return { status: "busy" };
1233
+ const state = randomBytes(32).toString("base64url");
1234
+ const verifier = randomBytes(32).toString("base64url");
1235
+ const nonce = randomBytes(16).toString("base64url");
1236
+ db.prepare(`INSERT INTO oauth_flows (state_hash, provider, kind, verifier, nonce, binding_hash, redirect_uri, code_challenge, client_state,
1237
+ link_user_id, link_session_hash, created_at, expires_at)
1238
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(sha256Hex(state), provider, kind, verifier, nonce, bindingHash ?? null, native?.redirectUri ?? null, native?.codeChallenge ?? null, native?.state ?? null, linkUserId ?? null, linkSessionToken === undefined ? null : sha256Hex(linkSessionToken), at, at + OAUTH_FLOW_TTL_MS);
1239
+ return { status: "ok", state, verifier, nonce };
1240
+ },
1241
+ startNativeLink({ provider, ticket, bindingHash, ip }) {
1242
+ if (oauthStarts.hit(ip, oauthLimits.startMaxPerIp, oauthLimits.startWindowMs))
1243
+ return { status: "limited" };
1244
+ const state = randomBytes(32).toString("base64url");
1245
+ const row = db.prepare(`UPDATE oauth_flows SET state_hash = ?, binding_hash = ?
1246
+ WHERE binding_hash = ? AND provider = ? AND kind = 'link' AND redirect_uri IS NOT NULL AND expires_at > ?
1247
+ RETURNING verifier, nonce`).get(sha256Hex(state), bindingHash, sha256Hex(ticket), provider, now());
1248
+ return row === undefined ? undefined : { status: "ok", state, verifier: row.verifier, nonce: row.nonce };
1249
+ },
1250
+ takeOAuthFlow(provider, state) {
1251
+ const hash = sha256Hex(state);
1252
+ const row = db.prepare(`SELECT provider, kind, verifier, nonce, binding_hash AS bindingHash, redirect_uri AS redirectUri, code_challenge AS codeChallenge,
1253
+ client_state AS clientState, link_user_id AS linkUserId, link_session_hash AS linkSessionHash, expires_at AS expiresAt
1254
+ FROM oauth_flows WHERE state_hash = ?`).get(hash);
1255
+ if (row !== undefined)
1256
+ db.prepare("DELETE FROM oauth_flows WHERE state_hash = ?").run(hash);
1257
+ if (row === undefined || row.expiresAt <= now() || row.provider !== provider)
1258
+ return;
1259
+ return {
1260
+ provider: row.provider,
1261
+ kind: row.kind,
1262
+ verifier: row.verifier,
1263
+ nonce: row.nonce,
1264
+ ...row.bindingHash === null ? {} : { bindingHash: row.bindingHash },
1265
+ ...row.redirectUri === null || row.codeChallenge === null ? {} : { native: { redirectUri: row.redirectUri, codeChallenge: row.codeChallenge, state: row.clientState ?? "" } },
1266
+ ...row.linkUserId === null ? {} : { linkUserId: row.linkUserId },
1267
+ ...row.linkSessionHash === null ? {} : { linkSessionHash: row.linkSessionHash }
1268
+ };
1269
+ },
1270
+ resolveIdentity(identity, completion) {
1271
+ const result = transaction(() => {
1272
+ const bound = identityOwner(identity.provider, identity.subject);
1273
+ if (bound !== undefined) {
1274
+ bindIdentity(bound, identity);
1275
+ return { status: "user", userId: bound };
1276
+ }
1277
+ if (identity.emailVerified && identity.email !== undefined) {
1278
+ const owner = db.prepare("SELECT id FROM users WHERE email = ? AND email_verified_at IS NOT NULL").get(identity.email);
1279
+ if (owner !== undefined) {
1280
+ bindIdentity(owner.id, identity);
1281
+ return { status: "user", userId: owner.id, notify: identity.email };
1282
+ }
1283
+ }
1284
+ if (!registrationOpen)
1285
+ return { status: "refused", reason: "registration_closed" };
1286
+ const id = randomBytes(32).toString("base64url");
1287
+ const at = now();
1288
+ db.prepare("DELETE FROM oauth_pending WHERE expires_at <= ?").run(at);
1289
+ db.prepare(`INSERT INTO oauth_pending (id_hash, provider, subject, email, email_verified, name, refresh_token, refresh_client_id,
1290
+ kind, binding_hash, redirect_uri, code_challenge, client_state, created_at, expires_at)
1291
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(sha256Hex(id), identity.provider, identity.subject, identity.email ?? null, identity.emailVerified ? 1 : 0, identity.name ?? null, identity.refreshToken ?? null, identity.refreshClientId ?? null, completion.kind, completion.kind === "direct" ? null : completion.bindingHash, completion.kind === "native" ? completion.native.redirectUri : null, completion.kind === "native" ? completion.native.codeChallenge : null, completion.kind === "native" ? completion.native.state : null, at, at + OAUTH_PENDING_TTL_MS);
1292
+ return { status: "pending", id, ...identity.email === undefined ? {} : { email: identity.email } };
1293
+ });
1294
+ if (result.notify !== undefined)
1295
+ noticeNewMethod(result.notify, identity.provider);
1296
+ const { notify: _notify, ...answer } = result;
1297
+ return answer;
1298
+ },
1299
+ linkIdentity: (flow, identity) => linkIdentity(flow, identity),
1300
+ pendingSignIn(id) {
1301
+ const row = pendingRow(id);
1302
+ if (row === undefined)
1303
+ return;
1304
+ return {
1305
+ provider: row.provider,
1306
+ ...row.email === null ? {} : { email: row.email },
1307
+ ...row.name === null ? {} : { name: row.name },
1308
+ completion: completionOf(row)
1309
+ };
1310
+ },
1311
+ async createFromPending(id, password) {
1312
+ if (password === undefined && mailer === undefined)
1313
+ return { status: "refused", reason: "password_required" };
1314
+ if (password !== undefined && weakPassword(password))
1315
+ return { status: "refused", reason: "weak_password" };
1316
+ const hash = password === undefined ? null : await gate.run(() => hashPassword(password));
1317
+ if (hash === undefined)
1318
+ return { status: "limited" };
1319
+ return transaction(() => {
1320
+ const row = pendingRow(id);
1321
+ if (row === undefined)
1322
+ return { status: "refused", reason: "not_found" };
1323
+ if (!registrationOpen)
1324
+ return { status: "refused", reason: "registration_closed" };
1325
+ if (row.email === null || row.emailVerified !== 1)
1326
+ return { status: "refused", reason: "no_verified_email" };
1327
+ if (emailOwner(row.email) !== undefined)
1328
+ return { status: "refused", reason: "email_taken" };
1329
+ if (identityOwner(row.provider, row.subject) !== undefined)
1330
+ return { status: "refused", reason: "identity_taken" };
1331
+ const at = now();
1332
+ const userId = Number(db.prepare("INSERT INTO users (email, email_verified_at, password_hash, created_at) VALUES (?, ?, ?, ?)").run(row.email, at, hash, at).lastInsertRowid);
1333
+ bindIdentity(userId, identityOf(row));
1334
+ db.prepare("DELETE FROM oauth_pending WHERE id_hash = ?").run(sha256Hex(id));
1335
+ return { status: "ok", userId, completion: completionOf(row) };
1336
+ });
1337
+ },
1338
+ linkPending(id, userId) {
1339
+ return transaction(() => {
1340
+ const row = pendingRow(id);
1341
+ if (row === undefined)
1342
+ return { status: "refused", reason: "not_found" };
1343
+ const bound = identityOwner(row.provider, row.subject);
1344
+ if (bound !== undefined && bound !== userId)
1345
+ return { status: "refused", reason: "identity_taken" };
1346
+ bindIdentity(userId, identityOf(row));
1347
+ db.prepare("DELETE FROM oauth_pending WHERE id_hash = ?").run(sha256Hex(id));
1348
+ return { status: "ok", userId, completion: completionOf(row) };
1349
+ });
1350
+ },
1351
+ signIn(userId, device) {
1352
+ return transaction(() => {
1353
+ const user = userById(userId);
1354
+ return user === undefined ? undefined : { ...issueSession(userId, device), user: accountUser(user) };
1355
+ });
1356
+ },
1357
+ issueLinkCode({ identity, linkUserId, linkSessionHash, codeChallenge }) {
1358
+ const at = now();
1359
+ const live = db.prepare("SELECT 1 AS x FROM sessions WHERE token_hash = ? AND user_id = ? AND expires_at > ?").get(linkSessionHash, linkUserId, at);
1360
+ if (live === undefined)
1361
+ return "session_ended";
1362
+ for (const [hash, entry] of linkCodes)
1363
+ if (entry.expiresAt <= at)
1364
+ linkCodes.delete(hash);
1365
+ if (linkCodes.size >= oauthLimits.maxPending)
1366
+ linkCodes.delete(linkCodes.keys().next().value);
1367
+ const code = randomBytes(32).toString("base64url");
1368
+ linkCodes.set(sha256Hex(code), { identity, linkUserId, linkSessionHash, codeChallenge, expiresAt: at + OAUTH_CODE_TTL_MS });
1369
+ return { code };
1370
+ },
1371
+ completeLink({ code, codeVerifier, token }) {
1372
+ const hash = sha256Hex(code);
1373
+ const entry = linkCodes.get(hash);
1374
+ linkCodes.delete(hash);
1375
+ if (entry === undefined || entry.expiresAt <= now() || !safeEqual(sha256Hex(token), entry.linkSessionHash) || !safeEqual(createHash("sha256").update(codeVerifier).digest("base64url"), entry.codeChallenge)) {
1376
+ return "invalid_grant";
1377
+ }
1378
+ return linkIdentity({ linkUserId: entry.linkUserId, linkSessionHash: entry.linkSessionHash }, entry.identity);
1379
+ },
1380
+ issueOAuthCode(userId, codeChallenge) {
1381
+ const code = randomBytes(32).toString("base64url");
1382
+ const at = now();
1383
+ db.prepare("DELETE FROM oauth_codes WHERE expires_at <= ?").run(at);
1384
+ db.prepare("INSERT INTO oauth_codes (code_hash, user_id, code_challenge, expires_at) VALUES (?, ?, ?, ?)").run(sha256Hex(code), userId, codeChallenge, at + OAUTH_CODE_TTL_MS);
1385
+ return code;
1386
+ },
1387
+ exchangeOAuthCode(code, codeVerifier, device) {
1388
+ const hash = sha256Hex(code);
1389
+ return transaction(() => {
1390
+ const row = db.prepare("SELECT user_id AS userId, code_challenge AS challenge, expires_at AS expiresAt FROM oauth_codes WHERE code_hash = ?").get(hash);
1391
+ if (row === undefined)
1392
+ return;
1393
+ db.prepare("DELETE FROM oauth_codes WHERE code_hash = ?").run(hash);
1394
+ if (row.expiresAt <= now() || !safeEqual(createHash("sha256").update(codeVerifier).digest("base64url"), row.challenge))
1395
+ return;
1396
+ const user = userById(row.userId);
1397
+ return user === undefined ? undefined : { ...issueSession(row.userId, device), user: accountUser(user) };
1398
+ });
1399
+ },
1400
+ listIdentities(userId) {
1401
+ const rows = db.prepare("SELECT provider, email, created_at AS createdAt FROM user_identities WHERE user_id = ? ORDER BY created_at, provider").all(userId);
1402
+ return {
1403
+ identities: rows.map((row) => ({ provider: row.provider, ...row.email === null ? {} : { email: row.email }, createdAt: row.createdAt })),
1404
+ hasPassword: (userById(userId)?.passwordHash ?? null) !== null
1405
+ };
1406
+ },
1407
+ async removeIdentity(args) {
1408
+ const { userId, provider } = args;
1409
+ const checked = await confirmAccount(userId, args);
1410
+ if (checked === "limited")
1411
+ return { status: "limited" };
1412
+ if (typeof checked === "string")
1413
+ return { status: "refused", reason: checked };
1414
+ return transaction(() => {
1415
+ const stale = stillConfirmed(userId, checked);
1416
+ if (stale !== undefined)
1417
+ return { status: "refused", reason: stale };
1418
+ const counts = db.prepare("SELECT SUM(provider = ?) AS mine, SUM(provider <> ?) AS others FROM user_identities WHERE user_id = ?").get(provider, provider, userId);
1419
+ if ((counts.mine ?? 0) === 0)
1420
+ return { status: "refused", reason: "not_found" };
1421
+ if ((counts.others ?? 0) === 0 && (userById(userId)?.passwordHash ?? null) === null) {
1422
+ return { status: "refused", reason: "last_method" };
1423
+ }
1424
+ const grants = provider === "apple" ? appleGrants(userId) : [];
1425
+ db.prepare("DELETE FROM user_identities WHERE user_id = ? AND provider = ?").run(userId, provider);
1426
+ return { status: "ok", appleGrants: grants };
1427
+ });
1428
+ },
789
1429
  revokeAllSessions(userId) {
790
1430
  db.exec("BEGIN");
791
1431
  try {
@@ -923,14 +1563,14 @@ async function openAccounts(options) {
923
1563
  return settleHostLink(args, "denied");
924
1564
  },
925
1565
  exchangeHostLinkCode(code, codeVerifier) {
926
- const codeHash = sha256Hex(code);
1566
+ const codeHash2 = sha256Hex(code);
927
1567
  const at = now();
928
1568
  db.exec("BEGIN IMMEDIATE");
929
1569
  try {
930
1570
  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);
1571
+ FROM host_link_requests WHERE auth_code_hash = ? AND state = 'confirmed'`).get(codeHash2);
932
1572
  if (row !== undefined)
933
- db.prepare("DELETE FROM host_link_requests WHERE auth_code_hash = ?").run(codeHash);
1573
+ db.prepare("DELETE FROM host_link_requests WHERE auth_code_hash = ?").run(codeHash2);
934
1574
  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
1575
  const result = valid ? { status: "ok", ...mintJoinToken(row.userId, row.hostId) } : { status: "invalid" };
936
1576
  db.exec("COMMIT");
@@ -976,9 +1616,9 @@ async function openAccounts(options) {
976
1616
  joined_at = excluded.joined_at,
977
1617
  last_seen_at = excluded.last_seen_at`).run(hostId, row.userId, noisePublicKey, hostName, at, at);
978
1618
  db.prepare("DELETE FROM removed_hosts WHERE host_id = ?").run(hostId);
979
- const user = db.prepare("SELECT username FROM users WHERE id = ?").get(row.userId);
1619
+ const user = userById(row.userId);
980
1620
  db.exec("COMMIT");
981
- return { status: "joined", username: user?.username ?? "unknown" };
1621
+ return { status: "joined", username: user === undefined ? "unknown" : accountUser(user).username };
982
1622
  } catch (error) {
983
1623
  db.exec("ROLLBACK");
984
1624
  throw error;
@@ -987,20 +1627,22 @@ async function openAccounts(options) {
987
1627
  leaveHost(hostId) {
988
1628
  return Number(db.prepare("DELETE FROM hosts WHERE host_id = ?").run(hostId).changes) > 0;
989
1629
  },
990
- async removeHost({ userId, username, password, hostId }) {
1630
+ async removeHost(args) {
1631
+ const { userId, hostId } = args;
991
1632
  const owned = () => db.prepare("SELECT 1 AS x FROM hosts WHERE host_id = ? AND user_id = ?").get(hostId, userId) !== undefined;
992
1633
  if (!owned())
993
1634
  return { status: "refused", reason: "not_found" };
994
- const checked = await checkPassword(userId, username, password);
1635
+ const checked = await confirmAccount(userId, args);
995
1636
  if (checked === "limited")
996
1637
  return { status: "limited" };
997
- if (checked === "wrong_password")
998
- return { status: "refused", reason: "wrong_password" };
1638
+ if (typeof checked === "string")
1639
+ return { status: "refused", reason: checked };
999
1640
  db.exec("BEGIN IMMEDIATE");
1000
1641
  try {
1001
- if (currentHash(userId) !== checked.verifiedHash) {
1642
+ const stale = stillConfirmed(userId, checked);
1643
+ if (stale !== undefined) {
1002
1644
  db.exec("ROLLBACK");
1003
- return { status: "refused", reason: "wrong_password" };
1645
+ return { status: "refused", reason: stale };
1004
1646
  }
1005
1647
  if (!owned()) {
1006
1648
  db.exec("ROLLBACK");
@@ -1097,22 +1739,24 @@ async function openAccounts(options) {
1097
1739
  confirmRevocation(hostId, publicKey) {
1098
1740
  db.prepare("DELETE FROM pending_revocations WHERE host_id = ? AND public_key = ?").run(hostId, publicKey);
1099
1741
  },
1100
- async kickDevice({ userId, username, password, deviceId, currentDeviceId }) {
1742
+ async kickDevice(args) {
1743
+ const { userId, deviceId, currentDeviceId } = args;
1101
1744
  const owned = () => db.prepare("SELECT 1 AS x FROM account_devices WHERE id = ? AND user_id = ?").get(deviceId, userId) !== undefined;
1102
1745
  if (!owned())
1103
1746
  return { status: "refused", reason: "not_found" };
1104
1747
  if (deviceId === currentDeviceId)
1105
1748
  return { status: "refused", reason: "current_device" };
1106
- const checked = await checkPassword(userId, username, password);
1749
+ const checked = await confirmAccount(userId, args);
1107
1750
  if (checked === "limited")
1108
1751
  return { status: "limited" };
1109
- if (checked === "wrong_password")
1110
- return { status: "refused", reason: "wrong_password" };
1752
+ if (typeof checked === "string")
1753
+ return { status: "refused", reason: checked };
1111
1754
  db.exec("BEGIN IMMEDIATE");
1112
1755
  try {
1113
- if (currentHash(userId) !== checked.verifiedHash) {
1756
+ const stale = stillConfirmed(userId, checked);
1757
+ if (stale !== undefined) {
1114
1758
  db.exec("ROLLBACK");
1115
- return { status: "refused", reason: "wrong_password" };
1759
+ return { status: "refused", reason: stale };
1116
1760
  }
1117
1761
  if (!owned()) {
1118
1762
  db.exec("ROLLBACK");
@@ -1143,43 +1787,82 @@ function dropCredentials(db, userId, keepTokenHash) {
1143
1787
  db.prepare("DELETE FROM join_tokens WHERE user_id = ? AND used_at IS NULL").run(userId);
1144
1788
  db.prepare("DELETE FROM host_link_requests WHERE user_id = ?").run(userId);
1145
1789
  db.prepare("DELETE FROM push_tokens WHERE user_id = ?").run(userId);
1790
+ db.prepare("DELETE FROM email_codes WHERE user_id = ?").run(userId);
1146
1791
  }
1147
- async function resetPassword(options) {
1148
- assertPasswordPolicy(options.password);
1149
- await mkdir(options.dataDir, { recursive: true });
1150
- const release = await lockDataDir(options.dataDir, { retries: 0 }).catch((error) => {
1792
+ async function withOperatorDatabase(dataDir, write) {
1793
+ await mkdir(dataDir, { recursive: true });
1794
+ const release = await lockDataDir(dataDir, { retries: 0 }).catch((error) => {
1151
1795
  if (isDataDirInUse(error))
1152
1796
  return;
1153
1797
  throw error;
1154
1798
  });
1155
- const db = openDatabase(options.dataDir);
1799
+ const db = openDatabase(dataDir);
1156
1800
  try {
1157
1801
  if (release !== undefined) {
1158
1802
  migrate(db);
1159
1803
  } else if (schemaVersion(db) !== SCHEMA_VERSION) {
1160
1804
  throw new Error(`the running server's database schema (${schemaVersion(db)}) differs from this build's (${SCHEMA_VERSION}); restart the server on this version first`);
1161
1805
  }
1162
- const hash = await hashPassword(options.password);
1163
- db.exec("BEGIN IMMEDIATE");
1164
- try {
1165
- const users = options.user === undefined ? db.prepare("SELECT id FROM users").all() : db.prepare("SELECT id FROM users WHERE username = ?").all(options.user);
1166
- if (users.length === 0)
1167
- throw new Error(options.user === undefined ? "no account exists yet" : `no such account: ${options.user}`);
1168
- if (users.length > 1)
1169
- throw new Error("more than one account exists — pass --user");
1170
- const userId = users[0].id;
1171
- db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hash, userId);
1172
- dropCredentials(db, userId);
1173
- db.exec("COMMIT");
1174
- } catch (error) {
1175
- db.exec("ROLLBACK");
1176
- throw error;
1177
- }
1806
+ return await write(db);
1178
1807
  } finally {
1179
1808
  db.close();
1180
1809
  await release?.();
1181
1810
  }
1182
1811
  }
1812
+ function inTransaction(db, write) {
1813
+ db.exec("BEGIN IMMEDIATE");
1814
+ try {
1815
+ const result = write();
1816
+ db.exec("COMMIT");
1817
+ return result;
1818
+ } catch (error) {
1819
+ db.exec("ROLLBACK");
1820
+ throw error;
1821
+ }
1822
+ }
1823
+ function operatorTarget(db, user) {
1824
+ const users = user === undefined ? db.prepare("SELECT id FROM users").all() : db.prepare("SELECT id FROM users WHERE email = ? OR username = ?").all(normalizeEmail(user) ?? null, user);
1825
+ if (users.length === 0)
1826
+ throw new Error(user === undefined ? "no account exists yet" : `no such account: ${user}`);
1827
+ if (users.length > 1)
1828
+ throw new Error(user === undefined ? "more than one account exists — pass --user" : `${user} names more than one account`);
1829
+ return users[0].id;
1830
+ }
1831
+ async function resetPassword(options) {
1832
+ assertPasswordPolicy(options.password);
1833
+ await withOperatorDatabase(options.dataDir, async (db) => {
1834
+ const hash = await hashPassword(options.password);
1835
+ inTransaction(db, () => {
1836
+ const userId = operatorTarget(db, options.user);
1837
+ db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hash, userId);
1838
+ dropCredentials(db, userId);
1839
+ });
1840
+ });
1841
+ }
1842
+ async function addUser(options) {
1843
+ const email = requireEmail(options.email);
1844
+ assertPasswordPolicy(options.password);
1845
+ await withOperatorDatabase(options.dataDir, async (db) => {
1846
+ const hash = await hashPassword(options.password);
1847
+ inTransaction(db, () => {
1848
+ if (db.prepare("SELECT 1 AS x FROM users WHERE email = ?").get(email) !== undefined) {
1849
+ throw new Error(`an account with ${email} already exists`);
1850
+ }
1851
+ const at = Date.now();
1852
+ db.prepare("INSERT INTO users (email, email_verified_at, password_hash, created_at) VALUES (?, ?, ?, ?)").run(email, at, hash, at);
1853
+ });
1854
+ });
1855
+ }
1856
+ async function setUserEmail(options) {
1857
+ const email = requireEmail(options.email);
1858
+ await withOperatorDatabase(options.dataDir, (db) => inTransaction(db, () => {
1859
+ const userId = operatorTarget(db, options.user);
1860
+ const owner = db.prepare("SELECT id FROM users WHERE email = ?").get(email);
1861
+ if (owner !== undefined && owner.id !== userId)
1862
+ throw new Error(`another account already has ${email}`);
1863
+ db.prepare("UPDATE users SET email = ?, email_verified_at = ? WHERE id = ?").run(email, Date.now(), userId);
1864
+ }));
1865
+ }
1183
1866
 
1184
1867
  // apps/server/src/apns.ts
1185
1868
  import { createPrivateKey, sign } from "node:crypto";
@@ -1405,41 +2088,382 @@ function openApns(config) {
1405
2088
  };
1406
2089
  }
1407
2090
 
1408
- // apps/server/src/relay.ts
1409
- import { randomBytes as randomBytes2 } from "node:crypto";
1410
- import { once } from "node:events";
1411
- import { createServer } from "node:http";
1412
- import { WebSocket, WebSocketServer } from "ws";
2091
+ // apps/server/src/mail.ts
2092
+ import { createTransport } from "nodemailer";
2093
+ function loadMailer(env = process.env) {
2094
+ const url = env.PINOMAD_SERVER_SMTP_URL;
2095
+ const from = env.PINOMAD_SERVER_MAIL_FROM;
2096
+ if (url === undefined && from === undefined)
2097
+ return;
2098
+ if (url === undefined || from === undefined) {
2099
+ throw new Error("PINOMAD_SERVER_SMTP_URL and PINOMAD_SERVER_MAIL_FROM go together");
2100
+ }
2101
+ const transport = createTransport({ url, ...url.startsWith("smtp:") ? { requireTLS: true } : {} });
2102
+ return {
2103
+ async send(to, subject, text) {
2104
+ await transport.sendMail({ from, to, subject, text });
2105
+ }
2106
+ };
2107
+ }
1413
2108
 
1414
- // apps/server/src/relay-limits.ts
1415
- var DEFAULT_RELAY_LIMITS = {
1416
- perHost: 64,
1417
- perAccount: 256,
1418
- unauthenticatedPerHost: 32,
1419
- handshakeTimeoutMs: 30000,
1420
- handshakeBytes: 64 * 1024,
1421
- accountBytesPerSecond: 4 * 1024 * 1024,
1422
- accountBurstBytes: 1024 * 1024 * 1024
1423
- };
1424
- var RELAY_LIMIT_OPTIONS = [
1425
- { field: "perHost", flag: "relay-max-per-host", env: "PINOMAD_SERVER_RELAY_MAX_PER_HOST", unit: "count" },
1426
- { field: "perAccount", flag: "relay-max-per-account", env: "PINOMAD_SERVER_RELAY_MAX_PER_ACCOUNT", unit: "count" },
1427
- {
1428
- field: "unauthenticatedPerHost",
1429
- flag: "relay-max-unauthenticated-per-host",
1430
- env: "PINOMAD_SERVER_RELAY_MAX_UNAUTHENTICATED_PER_HOST",
1431
- unit: "count"
2109
+ // apps/server/src/oauth.ts
2110
+ import { createHash as createHash2, createPrivateKey as createPrivateKey2, createPublicKey, sign as sign2, verify } from "node:crypto";
2111
+ var OAUTH_PROVIDERS = ["google", "github", "apple"];
2112
+ var DEFAULT_ENDPOINTS = {
2113
+ google: {
2114
+ authorize: "https://accounts.google.com/o/oauth2/v2/auth",
2115
+ token: "https://oauth2.googleapis.com/token",
2116
+ jwks: "https://www.googleapis.com/oauth2/v3/certs",
2117
+ issuer: "https://accounts.google.com"
1432
2118
  },
1433
- { field: "handshakeTimeoutMs", flag: "relay-handshake-timeout", env: "PINOMAD_SERVER_RELAY_HANDSHAKE_TIMEOUT", unit: "seconds" },
1434
- { field: "handshakeBytes", flag: "relay-handshake-bytes", env: "PINOMAD_SERVER_RELAY_HANDSHAKE_BYTES", unit: "bytes" },
1435
- { field: "accountBytesPerSecond", flag: "relay-account-rate", env: "PINOMAD_SERVER_RELAY_ACCOUNT_RATE", unit: "bytes" },
1436
- { field: "accountBurstBytes", flag: "relay-account-burst", env: "PINOMAD_SERVER_RELAY_ACCOUNT_BURST", unit: "bytes" }
1437
- ];
1438
- var BYTE_SUFFIX = { "": 1, k: 1024, m: 1024 ** 2, g: 1024 ** 3 };
1439
- function readRelayLimits(flags, env) {
1440
- const limits = {};
1441
- for (const option of RELAY_LIMIT_OPTIONS) {
1442
- const flag = flags[option.flag];
2119
+ github: {
2120
+ authorize: "https://github.com/login/oauth/authorize",
2121
+ token: "https://github.com/login/oauth/access_token",
2122
+ user: "https://api.github.com/user",
2123
+ emails: "https://api.github.com/user/emails"
2124
+ },
2125
+ apple: {
2126
+ authorize: "https://appleid.apple.com/auth/authorize",
2127
+ token: "https://appleid.apple.com/auth/token",
2128
+ jwks: "https://appleid.apple.com/auth/keys",
2129
+ issuer: "https://appleid.apple.com",
2130
+ revoke: "https://appleid.apple.com/auth/revoke"
2131
+ }
2132
+ };
2133
+ var GOOGLE_BARE_ISSUER = "accounts.google.com";
2134
+ function loadOAuthConfig(env = process.env) {
2135
+ const read = (name) => {
2136
+ const value = env[`PINOMAD_SERVER_${name}`]?.replace(/\\n/g, `
2137
+ `).trim();
2138
+ return value === "" ? undefined : value;
2139
+ };
2140
+ const all = (provider, names) => {
2141
+ const values = Object.fromEntries(Object.entries(names).map(([key, name]) => [key, read(name)]));
2142
+ const set = Object.values(values).filter((value) => value !== undefined).length;
2143
+ if (set === 0)
2144
+ return;
2145
+ if (set < Object.keys(names).length) {
2146
+ console.error(`oauth: ${Object.values(names).map((name) => `PINOMAD_SERVER_${name}`).join(", ")} must all be set — ${provider} sign-in disabled`);
2147
+ return;
2148
+ }
2149
+ return values;
2150
+ };
2151
+ const google = all("Google", { clientId: "GOOGLE_CLIENT_ID", clientSecret: "GOOGLE_CLIENT_SECRET" });
2152
+ const github = all("GitHub", { clientId: "GITHUB_CLIENT_ID", clientSecret: "GITHUB_CLIENT_SECRET" });
2153
+ const apple = all("Apple", {
2154
+ servicesId: "APPLE_SERVICES_ID",
2155
+ teamId: "APPLE_TEAM_ID",
2156
+ keyId: "APPLE_KEY_ID",
2157
+ privateKey: "APPLE_PRIVATE_KEY"
2158
+ });
2159
+ const bundleId = read("APPLE_BUNDLE_ID");
2160
+ return {
2161
+ ...google === undefined ? {} : { google },
2162
+ ...github === undefined ? {} : { github },
2163
+ ...apple === undefined ? {} : { apple: { ...apple, ...bundleId === undefined ? {} : { bundleId } } }
2164
+ };
2165
+ }
2166
+ var FETCH_TIMEOUT_MS = 1e4;
2167
+ var JWKS_REFETCH_MS = 60000;
2168
+ var APPLE_SECRET_TTL_S = 30 * 24 * 60 * 60;
2169
+ var APPLE_SECRET_RENEW_S = 24 * 60 * 60;
2170
+ var MAX_NAME_CODEPOINTS = 128;
2171
+ var isRecord = (value) => typeof value === "object" && value !== null;
2172
+ var failure = (error) => ({ error });
2173
+ var clipName = (name) => {
2174
+ const clipped = [...(name ?? "").trim()].slice(0, MAX_NAME_CODEPOINTS).join("").trim();
2175
+ return clipped === "" ? undefined : clipped;
2176
+ };
2177
+ var truthy = (value) => value === true || value === "true";
2178
+ async function fetchJson(url, init) {
2179
+ const response = await fetch(url, { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), redirect: "error" });
2180
+ if (!response.ok)
2181
+ throw new Error(`${new URL(url).host} answered ${response.status}`);
2182
+ return await response.json();
2183
+ }
2184
+ var postForm = (url, form, headers = {}) => fetchJson(url, {
2185
+ method: "POST",
2186
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json", ...headers },
2187
+ body: new URLSearchParams(form).toString()
2188
+ });
2189
+
2190
+ class JwksCache {
2191
+ sets = new Map;
2192
+ now;
2193
+ constructor(now) {
2194
+ this.now = now;
2195
+ }
2196
+ async key(url, kid) {
2197
+ const cached = this.sets.get(url);
2198
+ const known = cached?.keys.get(kid);
2199
+ if (known !== undefined)
2200
+ return known;
2201
+ if (cached !== undefined && this.now() - cached.fetchedAt < JWKS_REFETCH_MS)
2202
+ return;
2203
+ const body = await fetchJson(url, { headers: { accept: "application/json" } });
2204
+ const keys = new Map;
2205
+ for (const jwk of isRecord(body) && Array.isArray(body.keys) ? body.keys : []) {
2206
+ if (!isRecord(jwk) || typeof jwk.kid !== "string" || jwk.kty !== "RSA")
2207
+ continue;
2208
+ try {
2209
+ keys.set(jwk.kid, createPublicKey({ key: jwk, format: "jwk" }));
2210
+ } catch {}
2211
+ }
2212
+ this.sets.set(url, { keys, fetchedAt: this.now() });
2213
+ return keys.get(kid);
2214
+ }
2215
+ }
2216
+ var decodeSegment = (segment) => JSON.parse(Buffer.from(segment, "base64url").toString("utf8"));
2217
+ async function verifyIdToken(jwks, token, expect) {
2218
+ const parts = token.split(".");
2219
+ if (parts.length !== 3)
2220
+ return;
2221
+ let header, claims;
2222
+ try {
2223
+ header = decodeSegment(parts[0]);
2224
+ claims = decodeSegment(parts[1]);
2225
+ } catch {
2226
+ return;
2227
+ }
2228
+ if (!isRecord(header) || header.alg !== "RS256" || typeof header.kid !== "string" || !isRecord(claims))
2229
+ return;
2230
+ const key = await jwks.key(expect.jwksUrl, header.kid);
2231
+ if (key === undefined)
2232
+ return;
2233
+ const signed = verify("RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], "base64url"));
2234
+ if (!signed)
2235
+ return;
2236
+ const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
2237
+ if (typeof claims.iss !== "string" || !expect.issuers.includes(claims.iss))
2238
+ return;
2239
+ if (!audience.includes(expect.audience))
2240
+ return;
2241
+ if (typeof claims.exp !== "number" || claims.exp <= expect.nowS)
2242
+ return;
2243
+ if (expect.nonce !== undefined && claims.nonce !== expect.nonce)
2244
+ return;
2245
+ if (typeof claims.sub !== "string" || claims.sub === "")
2246
+ return;
2247
+ return claims;
2248
+ }
2249
+ function tokenEmail(claims, relayCountsVerified) {
2250
+ const email = typeof claims.email === "string" ? normalizeEmail(claims.email) : undefined;
2251
+ if (email === undefined)
2252
+ return { emailVerified: false };
2253
+ const relay = relayCountsVerified && (truthy(claims.is_private_email) || email.endsWith("@privaterelay.appleid.com"));
2254
+ return { email, emailVerified: truthy(claims.email_verified) || relay };
2255
+ }
2256
+ function appleName(user) {
2257
+ if (user === undefined)
2258
+ return;
2259
+ try {
2260
+ const parsed = JSON.parse(user);
2261
+ const name = isRecord(parsed) && isRecord(parsed.name) ? parsed.name : undefined;
2262
+ const parts = [name?.firstName, name?.lastName].filter((part) => typeof part === "string" && part.trim() !== "");
2263
+ return clipName(parts.join(" "));
2264
+ } catch {
2265
+ return;
2266
+ }
2267
+ }
2268
+ function createOAuth(config, options = {}) {
2269
+ const now = options.now ?? Date.now;
2270
+ const jwks = new JwksCache(now);
2271
+ const endpoints = (provider) => ({ ...DEFAULT_ENDPOINTS[provider], ...config[provider]?.endpoints });
2272
+ const enabled = OAUTH_PROVIDERS.filter((provider) => config[provider] !== undefined);
2273
+ const nowS = () => Math.floor(now() / 1000);
2274
+ const appleKey = config.apple === undefined ? undefined : createPrivateKey2(config.apple.privateKey);
2275
+ const appleSecrets = new Map;
2276
+ const appleSecret = (clientId) => {
2277
+ const apple = config.apple;
2278
+ const cached = appleSecrets.get(clientId);
2279
+ if (cached !== undefined && cached.expiresAt - nowS() > APPLE_SECRET_RENEW_S)
2280
+ return cached.secret;
2281
+ const iat = nowS();
2282
+ const header = Buffer.from(JSON.stringify({ alg: "ES256", kid: apple.keyId })).toString("base64url");
2283
+ const payload = Buffer.from(JSON.stringify({ iss: apple.teamId, iat, exp: iat + APPLE_SECRET_TTL_S, aud: endpoints("apple").issuer, sub: clientId })).toString("base64url");
2284
+ const signature = sign2("sha256", Buffer.from(`${header}.${payload}`), { key: appleKey, dsaEncoding: "ieee-p1363" });
2285
+ const secret = `${header}.${payload}.${signature.toString("base64url")}`;
2286
+ appleSecrets.set(clientId, { secret, expiresAt: iat + APPLE_SECRET_TTL_S });
2287
+ return secret;
2288
+ };
2289
+ const oidc = async (provider, idToken, audience, nonce) => {
2290
+ const { jwks: jwksUrl, issuer } = endpoints(provider);
2291
+ if (typeof idToken !== "string" || jwksUrl === undefined || issuer === undefined)
2292
+ return failure("exchange_failed");
2293
+ const issuers = provider === "google" && issuer === DEFAULT_ENDPOINTS.google.issuer ? [issuer, GOOGLE_BARE_ISSUER] : [issuer];
2294
+ const claims = await verifyIdToken(jwks, idToken, { jwksUrl, issuers, audience, nowS: nowS(), ...nonce === undefined ? {} : { nonce } });
2295
+ return claims === undefined ? failure("invalid_token") : { claims };
2296
+ };
2297
+ const exchangeGoogle = async (args) => {
2298
+ const google = config.google;
2299
+ const body = await postForm(endpoints("google").token, {
2300
+ grant_type: "authorization_code",
2301
+ code: args.code,
2302
+ client_id: google.clientId,
2303
+ client_secret: google.clientSecret,
2304
+ redirect_uri: args.redirectUri,
2305
+ code_verifier: args.verifier
2306
+ });
2307
+ const checked = await oidc("google", isRecord(body) ? body.id_token : undefined, google.clientId, args.nonce);
2308
+ if ("error" in checked)
2309
+ return checked;
2310
+ const { claims } = checked;
2311
+ const name = clipName(typeof claims.name === "string" ? claims.name : undefined);
2312
+ return { provider: "google", subject: claims.sub, ...tokenEmail(claims, false), ...name === undefined ? {} : { name } };
2313
+ };
2314
+ const exchangeGithub = async (args) => {
2315
+ const github = config.github;
2316
+ const { token, user: userUrl, emails: emailsUrl } = endpoints("github");
2317
+ const body = await postForm(token, {
2318
+ code: args.code,
2319
+ client_id: github.clientId,
2320
+ client_secret: github.clientSecret,
2321
+ redirect_uri: args.redirectUri,
2322
+ code_verifier: args.verifier
2323
+ });
2324
+ const accessToken = isRecord(body) && typeof body.access_token === "string" ? body.access_token : undefined;
2325
+ if (accessToken === undefined || userUrl === undefined || emailsUrl === undefined)
2326
+ return failure("exchange_failed");
2327
+ const headers = { authorization: `Bearer ${accessToken}`, accept: "application/vnd.github+json", "user-agent": "pinomad-server" };
2328
+ const [user, emails] = await Promise.all([fetchJson(userUrl, { headers }), fetchJson(emailsUrl, { headers })]);
2329
+ if (!isRecord(user) || typeof user.id !== "number" || !Number.isSafeInteger(user.id))
2330
+ return failure("exchange_failed");
2331
+ const primary = Array.isArray(emails) ? emails.find((entry) => isRecord(entry) && entry.primary === true) : undefined;
2332
+ const email = isRecord(primary) && typeof primary.email === "string" ? normalizeEmail(primary.email) : undefined;
2333
+ const name = clipName(typeof user.name === "string" ? user.name : undefined);
2334
+ return {
2335
+ provider: "github",
2336
+ subject: String(user.id),
2337
+ ...email === undefined ? {} : { email },
2338
+ emailVerified: email !== undefined && isRecord(primary) && primary.verified === true,
2339
+ ...name === undefined ? {} : { name }
2340
+ };
2341
+ };
2342
+ const exchangeApple = async (args) => {
2343
+ const body = await postForm(endpoints("apple").token, {
2344
+ grant_type: "authorization_code",
2345
+ code: args.code,
2346
+ client_id: args.clientId,
2347
+ client_secret: appleSecret(args.clientId),
2348
+ ...args.redirectUri === undefined ? {} : { redirect_uri: args.redirectUri }
2349
+ });
2350
+ if (!isRecord(body))
2351
+ return failure("exchange_failed");
2352
+ const checked = await oidc("apple", body.id_token, args.clientId, args.nonce);
2353
+ if ("error" in checked)
2354
+ return checked;
2355
+ const { claims } = checked;
2356
+ return {
2357
+ provider: "apple",
2358
+ subject: claims.sub,
2359
+ ...tokenEmail(claims, true),
2360
+ ...args.name === undefined ? {} : { name: args.name },
2361
+ ...typeof body.refresh_token === "string" ? { refreshToken: body.refresh_token, refreshClientId: args.clientId } : {}
2362
+ };
2363
+ };
2364
+ const guarded = async (run, provider) => {
2365
+ try {
2366
+ return await run();
2367
+ } catch (error) {
2368
+ console.error(JSON.stringify({ event: "oauth_failed", provider, error: error instanceof Error ? error.message : String(error) }));
2369
+ return failure("exchange_failed");
2370
+ }
2371
+ };
2372
+ return {
2373
+ enabled,
2374
+ appleNative: config.apple?.bundleId !== undefined,
2375
+ authorizeUrl(provider, { state, nonce, codeChallenge, redirectUri }) {
2376
+ const url = new URL(endpoints(provider).authorize);
2377
+ const params = provider === "google" ? {
2378
+ client_id: config.google.clientId,
2379
+ scope: "openid email profile",
2380
+ nonce,
2381
+ code_challenge: codeChallenge,
2382
+ code_challenge_method: "S256"
2383
+ } : provider === "github" ? { client_id: config.github.clientId, scope: "read:user user:email", code_challenge: codeChallenge, code_challenge_method: "S256" } : { client_id: config.apple.servicesId, scope: "name email", response_mode: "form_post", nonce };
2384
+ for (const [key, value] of Object.entries({ response_type: "code", redirect_uri: redirectUri, state, ...params })) {
2385
+ url.searchParams.set(key, value);
2386
+ }
2387
+ return url.toString();
2388
+ },
2389
+ exchange(provider, args) {
2390
+ return guarded(() => {
2391
+ if (provider === "google")
2392
+ return exchangeGoogle(args);
2393
+ if (provider === "github")
2394
+ return exchangeGithub(args);
2395
+ return exchangeApple({ code: args.code, clientId: config.apple.servicesId, redirectUri: args.redirectUri, nonce: args.nonce, name: appleName(args.user) });
2396
+ }, provider);
2397
+ },
2398
+ verifyAppleNative({ identityToken, authorizationCode, nonce, name }) {
2399
+ return guarded(async () => {
2400
+ const bundleId = config.apple?.bundleId;
2401
+ if (bundleId === undefined)
2402
+ return failure("exchange_failed");
2403
+ const hashed = nonce === undefined ? undefined : createHash2("sha256").update(nonce).digest("hex");
2404
+ const checked = await oidc("apple", identityToken, bundleId, hashed);
2405
+ if ("error" in checked)
2406
+ return checked;
2407
+ const traded = await exchangeApple({ code: authorizationCode, clientId: bundleId, nonce: undefined, name: clipName(name) });
2408
+ if ("error" in traded)
2409
+ return traded;
2410
+ return traded.subject === checked.claims.sub ? traded : failure("invalid_token");
2411
+ }, "apple");
2412
+ },
2413
+ revokeApple(refreshToken, clientId) {
2414
+ const revoke = endpoints("apple").revoke;
2415
+ if (config.apple === undefined || revoke === undefined)
2416
+ return;
2417
+ fetch(revoke, {
2418
+ method: "POST",
2419
+ headers: { "content-type": "application/x-www-form-urlencoded" },
2420
+ body: new URLSearchParams({ client_id: clientId, client_secret: appleSecret(clientId), token: refreshToken, token_type_hint: "refresh_token" }).toString(),
2421
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
2422
+ }).then((response) => {
2423
+ if (!response.ok)
2424
+ throw new Error(`apple answered ${response.status}`);
2425
+ }).catch((error) => {
2426
+ console.error(JSON.stringify({ event: "apple_revoke_failed", error: error instanceof Error ? error.message : String(error) }));
2427
+ });
2428
+ }
2429
+ };
2430
+ }
2431
+
2432
+ // apps/server/src/relay.ts
2433
+ import { randomBytes as randomBytes3 } from "node:crypto";
2434
+ import { once } from "node:events";
2435
+ import { createServer } from "node:http";
2436
+ import { WebSocket, WebSocketServer } from "ws";
2437
+
2438
+ // apps/server/src/relay-limits.ts
2439
+ var DEFAULT_RELAY_LIMITS = {
2440
+ perHost: 64,
2441
+ perAccount: 256,
2442
+ unauthenticatedPerHost: 32,
2443
+ handshakeTimeoutMs: 30000,
2444
+ handshakeBytes: 64 * 1024,
2445
+ accountBytesPerSecond: 4 * 1024 * 1024,
2446
+ accountBurstBytes: 1024 * 1024 * 1024
2447
+ };
2448
+ var RELAY_LIMIT_OPTIONS = [
2449
+ { field: "perHost", flag: "relay-max-per-host", env: "PINOMAD_SERVER_RELAY_MAX_PER_HOST", unit: "count" },
2450
+ { field: "perAccount", flag: "relay-max-per-account", env: "PINOMAD_SERVER_RELAY_MAX_PER_ACCOUNT", unit: "count" },
2451
+ {
2452
+ field: "unauthenticatedPerHost",
2453
+ flag: "relay-max-unauthenticated-per-host",
2454
+ env: "PINOMAD_SERVER_RELAY_MAX_UNAUTHENTICATED_PER_HOST",
2455
+ unit: "count"
2456
+ },
2457
+ { field: "handshakeTimeoutMs", flag: "relay-handshake-timeout", env: "PINOMAD_SERVER_RELAY_HANDSHAKE_TIMEOUT", unit: "seconds" },
2458
+ { field: "handshakeBytes", flag: "relay-handshake-bytes", env: "PINOMAD_SERVER_RELAY_HANDSHAKE_BYTES", unit: "bytes" },
2459
+ { field: "accountBytesPerSecond", flag: "relay-account-rate", env: "PINOMAD_SERVER_RELAY_ACCOUNT_RATE", unit: "bytes" },
2460
+ { field: "accountBurstBytes", flag: "relay-account-burst", env: "PINOMAD_SERVER_RELAY_ACCOUNT_BURST", unit: "bytes" }
2461
+ ];
2462
+ var BYTE_SUFFIX = { "": 1, k: 1024, m: 1024 ** 2, g: 1024 ** 3 };
2463
+ function readRelayLimits(flags, env) {
2464
+ const limits = {};
2465
+ for (const option of RELAY_LIMIT_OPTIONS) {
2466
+ const flag = flags[option.flag];
1443
2467
  const raw = typeof flag === "string" ? flag : env[option.env];
1444
2468
  if (raw === undefined)
1445
2469
  continue;
@@ -1539,12 +2563,14 @@ function createRelayLimiter(limits, now = Date.now) {
1539
2563
  }
1540
2564
 
1541
2565
  // apps/server/src/api.ts
2566
+ import { createHash as createHash3, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
1542
2567
  var SESSION_COOKIE = "__Host-pinomad-session";
1543
2568
  var HOST_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/;
1544
2569
  var PKCE_CHALLENGE = /^[A-Za-z0-9_-]{43}$/;
1545
2570
  var PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/;
1546
2571
  var LOOPBACK_STATE = /^[A-Za-z0-9_-]{1,128}$/;
1547
2572
  var MAX_REDIRECT_URI = 256;
2573
+ var LINK_TICKET = /^[A-Za-z0-9_-]{43}$/;
1548
2574
  function parseLoopback(value) {
1549
2575
  const { redirectUri, codeChallenge, state } = value;
1550
2576
  if (typeof redirectUri !== "string" || redirectUri.length > MAX_REDIRECT_URI)
@@ -1562,7 +2588,50 @@ function parseLoopback(value) {
1562
2588
  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
2589
  return loopbackOnly ? { redirectUri, codeChallenge, state } : null;
1564
2590
  }
2591
+ function parseNativeRedirect(redirectUri, codeChallenge, state) {
2592
+ if (redirectUri === null || codeChallenge === null || state === null)
2593
+ return null;
2594
+ if (!PKCE_CHALLENGE.test(codeChallenge) || !LOOPBACK_STATE.test(state))
2595
+ return null;
2596
+ const loopback = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})(?:\/|\/callback)?$/.exec(redirectUri);
2597
+ const allowed = redirectUri === "pinomad://auth" || loopback !== null && Number(loopback[1]) <= 65535;
2598
+ return allowed ? { redirectUri, codeChallenge, state } : null;
2599
+ }
2600
+ var withParams = (redirectUri, params) => {
2601
+ const url = new URL(redirectUri);
2602
+ for (const [key, value] of Object.entries(params))
2603
+ url.searchParams.set(key, value);
2604
+ return url.toString();
2605
+ };
1565
2606
  var MAX_BODY = 8 * 1024;
2607
+ var MAX_CODE_LENGTH = 32;
2608
+ var ANONYMOUS_ACCOUNT_ROUTES = new Set([
2609
+ "/api/login",
2610
+ "/api/register/code",
2611
+ "/api/register",
2612
+ "/api/password/reset/code",
2613
+ "/api/password/reset"
2614
+ ]);
2615
+ var bodyEmail = (value) => {
2616
+ const email = value?.email;
2617
+ return typeof email === "string" && email.length <= 1024 ? normalizeEmail(email) : undefined;
2618
+ };
2619
+ var bodyCode = (value) => {
2620
+ const code = value?.code;
2621
+ return typeof code === "string" && code.length <= MAX_CODE_LENGTH ? code : undefined;
2622
+ };
2623
+ var OAUTH_TOKEN_ROUTE = /^\/api\/oauth\/(?:token|apple\/native|pending\/[A-Za-z0-9_-]{43}\/(?:create|link))$/;
2624
+ function bodyConfirmation(value, token) {
2625
+ const { password, reauth } = typeof value === "object" && value !== null ? value : {};
2626
+ if (typeof reauth === "string" && password === undefined)
2627
+ return reauth.length <= 256 ? { reauth, token } : undefined;
2628
+ if (typeof password === "string" && reauth === undefined)
2629
+ return [...password].length <= MAX_PASSWORD_CODEPOINTS ? { password } : undefined;
2630
+ return;
2631
+ }
2632
+ var sha256Hex2 = (value) => createHash3("sha256").update(value, "utf8").digest("hex");
2633
+ var pkceChallenge = (verifier) => createHash3("sha256").update(verifier).digest("base64url");
2634
+ var sameHash = (a, b) => a.length === b.length && timingSafeEqual2(Buffer.from(a), Buffer.from(b));
1566
2635
  var MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
1567
2636
  var DESKTOP_ORIGIN = "app://pinomad";
1568
2637
  var isLoopback = (address) => address !== undefined && (address.startsWith("127.") || address === "::1" || address.startsWith("::ffff:127."));
@@ -1609,23 +2678,53 @@ async function readJson(request, optional = false) {
1609
2678
  return { ok: false, tooLarge: false };
1610
2679
  }
1611
2680
  }
1612
- function sessionToken(request) {
1613
- const authorization = request.headers.authorization;
1614
- if (authorization !== undefined && authorization.startsWith("Bearer "))
1615
- return authorization.slice("Bearer ".length);
2681
+ async function authedJson(accounts, request, token, optional = false) {
2682
+ const body = await readJson(request, optional);
2683
+ if (!body.ok)
2684
+ return { ok: false, status: body.tooLarge ? 413 : 400, error: body.tooLarge ? "payload_too_large" : "invalid_json" };
2685
+ const session = accounts.authenticate(token);
2686
+ if (session === undefined)
2687
+ return { ok: false, status: 401, error: "unauthenticated" };
2688
+ return { ok: true, value: body.value, session };
2689
+ }
2690
+ function cookie(request, name) {
1616
2691
  const header = request.headers.cookie;
1617
2692
  if (header === undefined)
1618
2693
  return;
1619
2694
  for (const part of header.split(";")) {
1620
2695
  const eq = part.indexOf("=");
1621
- if (eq > 0 && part.slice(0, eq).trim() === SESSION_COOKIE)
2696
+ if (eq > 0 && part.slice(0, eq).trim() === name)
1622
2697
  return part.slice(eq + 1).trim();
1623
2698
  }
1624
2699
  return;
1625
2700
  }
2701
+ function sessionToken(request) {
2702
+ const authorization = request.headers.authorization;
2703
+ if (authorization !== undefined && authorization.startsWith("Bearer "))
2704
+ return authorization.slice("Bearer ".length);
2705
+ return cookie(request, SESSION_COOKIE);
2706
+ }
1626
2707
  var cookieAttributes = "Path=/; Secure; HttpOnly; SameSite=Strict";
1627
2708
  var setSessionCookie = (token) => `${SESSION_COOKIE}=${token}; ${cookieAttributes}`;
1628
2709
  var clearSessionCookie = `${SESSION_COOKIE}=; ${cookieAttributes}; Max-Age=0`;
2710
+ var BINDING_COOKIE = "__Host-pinomad-oauth";
2711
+ var bindingAttributes = "Path=/; Secure; HttpOnly; SameSite=None";
2712
+ var setBindingCookie = (value) => `${BINDING_COOKIE}=${value}; ${bindingAttributes}; Max-Age=600`;
2713
+ var clearBindingCookie = `${BINDING_COOKIE}=; ${bindingAttributes}; Max-Age=0`;
2714
+ var bound = (request, bindingHash) => {
2715
+ const value = cookie(request, BINDING_COOKIE);
2716
+ return value !== undefined && bindingHash !== undefined && sameHash(sha256Hex2(value), bindingHash);
2717
+ };
2718
+ async function readForm(request) {
2719
+ const chunks = [];
2720
+ let size = 0;
2721
+ for await (const chunk of request) {
2722
+ size += chunk.length;
2723
+ if (size <= MAX_BODY)
2724
+ chunks.push(chunk);
2725
+ }
2726
+ return size > MAX_BODY ? undefined : new URLSearchParams(Buffer.concat(chunks).toString("utf8"));
2727
+ }
1629
2728
  async function handleApiRequest(request, response, ctx) {
1630
2729
  const { accounts } = ctx;
1631
2730
  const origin = request.headers.origin;
@@ -1639,6 +2738,26 @@ async function handleApiRequest(request, response, ctx) {
1639
2738
  const cors = origin === DESKTOP_ORIGIN ? { "access-control-allow-origin": DESKTOP_ORIGIN, vary: "Origin" } : {};
1640
2739
  const send = (status, body, headers) => respond(response, status, body, { ...cors, ...headers });
1641
2740
  const deny = (status, error, headers) => send(status, { error }, headers);
2741
+ const profile = (user) => ({
2742
+ user,
2743
+ ...user.email === undefined && accounts.emailEnabled ? { needsEmail: true } : {}
2744
+ });
2745
+ const signedIn = (session) => {
2746
+ const cookieMode = origin === ctx.publicOrigin;
2747
+ send(200, { ...profile(session.user), expiresAt: session.expiresAt, ...cookieMode ? {} : { token: session.token } }, cookieMode ? { "set-cookie": setSessionCookie(session.token) } : undefined);
2748
+ };
2749
+ const codeSent = (result, closed) => {
2750
+ if (result === "limited")
2751
+ deny(429, "rate_limited");
2752
+ else if (result === "closed")
2753
+ deny(403, closed);
2754
+ else if (result === "taken")
2755
+ deny(409, "email_taken");
2756
+ else if (result === "wrong_password" || result === "invalid_reauth")
2757
+ deny(403, result);
2758
+ else
2759
+ send(202);
2760
+ };
1642
2761
  if (!secureTransport(request, ctx.trustProxy)) {
1643
2762
  deny(403, "https_required");
1644
2763
  return;
@@ -1651,9 +2770,10 @@ async function handleApiRequest(request, response, ctx) {
1651
2770
  return;
1652
2771
  }
1653
2772
  const bearer = (request.headers.authorization ?? "").startsWith("Bearer ");
1654
- const loginTokenMode = (pathname === "/api/login" || pathname === "/api/register") && (origin === undefined || origin === DESKTOP_ORIGIN);
2773
+ const loginTokenMode = (ANONYMOUS_ACCOUNT_ROUTES.has(pathname) || OAUTH_TOKEN_ROUTE.test(pathname)) && (origin === undefined || origin === DESKTOP_ORIGIN);
2774
+ const appleFormPost = pathname === "/api/oauth/apple/callback";
1655
2775
  const hostLinkHostMode = (pathname === "/api/host-link/start" || pathname === "/api/host-link/poll" || pathname === "/api/host-link/token") && origin === undefined;
1656
- if (MUTATING.has(method) && !bearer && !loginTokenMode && !hostLinkHostMode && origin !== ctx.publicOrigin) {
2776
+ if (MUTATING.has(method) && !bearer && !loginTokenMode && !hostLinkHostMode && !appleFormPost && origin !== ctx.publicOrigin) {
1657
2777
  deny(403, "origin_mismatch");
1658
2778
  return;
1659
2779
  }
@@ -1687,50 +2807,562 @@ async function handleApiRequest(request, response, ctx) {
1687
2807
  deny(401, "invalid_credentials");
1688
2808
  return;
1689
2809
  }
1690
- const cookieMode = origin === ctx.publicOrigin;
1691
- send(200, {
1692
- user: { username },
1693
- ...result.admin ? { admin: true } : {},
1694
- expiresAt: result.expiresAt,
1695
- ...cookieMode ? {} : { token: result.token }
1696
- }, cookieMode ? { "set-cookie": setSessionCookie(result.token) } : undefined);
2810
+ signedIn(result);
2811
+ return;
2812
+ }
2813
+ if (pathname === "/api/auth/methods") {
2814
+ if (method !== "GET") {
2815
+ deny(405, "method_not_allowed", { allow: "GET" });
2816
+ return;
2817
+ }
2818
+ send(200, { registration: accounts.registrationOpen, email: accounts.emailEnabled, providers: ctx.oauth?.enabled ?? [] });
2819
+ return;
2820
+ }
2821
+ const oauth = ctx.oauth;
2822
+ const enabledProvider = (name) => oauth?.enabled.find((provider) => provider === name);
2823
+ const callbackUri = (provider) => `${ctx.publicOrigin}/api/oauth/${provider}/callback`;
2824
+ const redirect = (to, cookies = []) => {
2825
+ response.writeHead(302, { location: to, ...cookies.length === 0 ? {} : { "set-cookie": [...cookies] } });
2826
+ response.end();
2827
+ };
2828
+ const toWebClient = (param, value, cookies) => redirect(`${ctx.publicOrigin}/?${new URLSearchParams({ [param]: value })}`, cookies);
2829
+ const nativeCode = (userId, native) => withParams(native.redirectUri, { code: accounts.issueOAuthCode(userId, native.codeChallenge), state: native.state });
2830
+ const pendingRefused = (result) => {
2831
+ if (result.status === "limited") {
2832
+ deny(429, "rate_limited");
2833
+ return;
2834
+ }
2835
+ const status = {
2836
+ not_found: 404,
2837
+ registration_closed: 403,
2838
+ no_verified_email: 400,
2839
+ email_taken: 409,
2840
+ identity_taken: 409,
2841
+ password_required: 400,
2842
+ weak_password: 400
2843
+ }[result.reason];
2844
+ deny(status, result.reason);
2845
+ };
2846
+ const providerRoute = /^\/api\/oauth\/([a-z]+)\/(start|callback)$/.exec(pathname);
2847
+ if (providerRoute !== null) {
2848
+ const provider = enabledProvider(providerRoute[1]);
2849
+ const expected = providerRoute[2] === "callback" && provider === "apple" ? "POST" : "GET";
2850
+ if (provider === undefined) {
2851
+ deny(404, "not_found");
2852
+ return;
2853
+ }
2854
+ if (method !== expected) {
2855
+ deny(405, "method_not_allowed", { allow: expected });
2856
+ return;
2857
+ }
2858
+ if (providerRoute[2] === "start") {
2859
+ const query = new URL(request.url ?? "/", "http://server.invalid").searchParams;
2860
+ const ticket = query.get("link");
2861
+ if (ticket !== null) {
2862
+ const binding2 = randomBytes2(32).toString("base64url");
2863
+ const flow3 = LINK_TICKET.test(ticket) ? accounts.startNativeLink({ provider, ticket, bindingHash: sha256Hex2(binding2), ip: clientIp(request, ctx.trustProxy) }) : undefined;
2864
+ if (flow3 === undefined)
2865
+ toWebClient("oauth_error", "invalid_state");
2866
+ else if (flow3.status === "limited")
2867
+ deny(429, "rate_limited");
2868
+ else {
2869
+ const to = oauth.authorizeUrl(provider, {
2870
+ state: flow3.state,
2871
+ nonce: flow3.nonce,
2872
+ codeChallenge: pkceChallenge(flow3.verifier),
2873
+ redirectUri: callbackUri(provider)
2874
+ });
2875
+ redirect(to, [setBindingCookie(binding2)]);
2876
+ }
2877
+ return;
2878
+ }
2879
+ const client = query.get("client") ?? "web";
2880
+ const native = client === "native" ? parseNativeRedirect(query.get("redirect_uri"), query.get("code_challenge"), query.get("state")) : undefined;
2881
+ if (client !== "web" && client !== "native" || native === null) {
2882
+ deny(400, "invalid_request");
2883
+ return;
2884
+ }
2885
+ const binding = randomBytes2(32).toString("base64url");
2886
+ const flow2 = accounts.startOAuthFlow({
2887
+ provider,
2888
+ kind: native === undefined ? "web" : "native",
2889
+ ip: clientIp(request, ctx.trustProxy),
2890
+ bindingHash: sha256Hex2(binding),
2891
+ ...native === undefined ? {} : { native }
2892
+ });
2893
+ if (flow2.status === "limited")
2894
+ deny(429, "rate_limited");
2895
+ else if (flow2.status === "busy")
2896
+ deny(503, "busy");
2897
+ else {
2898
+ const to = oauth.authorizeUrl(provider, {
2899
+ state: flow2.state,
2900
+ nonce: flow2.nonce,
2901
+ codeChallenge: pkceChallenge(flow2.verifier),
2902
+ redirectUri: callbackUri(provider)
2903
+ });
2904
+ redirect(to, [setBindingCookie(binding)]);
2905
+ }
2906
+ return;
2907
+ }
2908
+ const params = method === "POST" ? await readForm(request) : new URL(request.url ?? "/", "http://server.invalid").searchParams;
2909
+ const state = params?.get("state");
2910
+ const flow = state === null || state === undefined || state.length > 256 ? undefined : accounts.takeOAuthFlow(provider, state);
2911
+ if (flow === undefined) {
2912
+ toWebClient("oauth_error", "invalid_state");
2913
+ return;
2914
+ }
2915
+ const fail = (reason) => {
2916
+ if (flow.native === undefined)
2917
+ toWebClient("oauth_error", reason);
2918
+ else
2919
+ redirect(withParams(flow.native.redirectUri, { error: reason, state: flow.native.state }));
2920
+ };
2921
+ if (!bound(request, flow.bindingHash)) {
2922
+ fail("invalid_state");
2923
+ return;
2924
+ }
2925
+ const code = params.get("code");
2926
+ if (params.get("error") !== null || code === null) {
2927
+ fail(params.get("error") === null ? "invalid_request" : "access_denied");
2928
+ return;
2929
+ }
2930
+ const identity = await oauth.exchange(provider, {
2931
+ code,
2932
+ verifier: flow.verifier,
2933
+ nonce: flow.nonce,
2934
+ redirectUri: callbackUri(provider),
2935
+ ...params.get("user") === null ? {} : { user: params.get("user") }
2936
+ });
2937
+ if ("error" in identity) {
2938
+ fail(identity.error);
2939
+ return;
2940
+ }
2941
+ if (flow.kind === "link") {
2942
+ if (flow.linkUserId === undefined || flow.linkSessionHash === undefined) {
2943
+ fail("session_ended");
2944
+ return;
2945
+ }
2946
+ const link = { linkUserId: flow.linkUserId, linkSessionHash: flow.linkSessionHash };
2947
+ if (flow.native !== undefined) {
2948
+ const issued = accounts.issueLinkCode({ identity, ...link, codeChallenge: flow.native.codeChallenge });
2949
+ if (issued === "session_ended")
2950
+ fail(issued);
2951
+ else
2952
+ redirect(withParams(flow.native.redirectUri, { code: issued.code, state: flow.native.state }), [clearBindingCookie]);
2953
+ return;
2954
+ }
2955
+ const linked = accounts.linkIdentity(link, identity);
2956
+ if (linked !== "linked")
2957
+ fail(linked);
2958
+ else
2959
+ toWebClient("oauth_linked", provider, [clearBindingCookie]);
2960
+ return;
2961
+ }
2962
+ const completion = flow.native === undefined ? { kind: "web", bindingHash: flow.bindingHash } : { kind: "native", bindingHash: flow.bindingHash, native: flow.native };
2963
+ const resolved = accounts.resolveIdentity(identity, completion);
2964
+ if (resolved.status === "refused") {
2965
+ fail(resolved.reason);
2966
+ return;
2967
+ }
2968
+ if (resolved.status === "pending") {
2969
+ toWebClient("oauth_pending", resolved.id, [setBindingCookie(cookie(request, BINDING_COOKIE))]);
2970
+ return;
2971
+ }
2972
+ if (flow.native !== undefined) {
2973
+ redirect(nativeCode(resolved.userId, flow.native));
2974
+ return;
2975
+ }
2976
+ const session = accounts.signIn(resolved.userId);
2977
+ if (session === undefined)
2978
+ fail("not_found");
2979
+ else
2980
+ redirect(`${ctx.publicOrigin}/`, [setSessionCookie(session.token), clearBindingCookie]);
1697
2981
  return;
1698
2982
  }
1699
- if (pathname === "/api/register") {
2983
+ if (pathname === "/api/oauth/token" || pathname === "/api/oauth/apple/native") {
1700
2984
  if (method !== "POST") {
1701
2985
  deny(405, "method_not_allowed", { allow: "POST" });
1702
2986
  return;
1703
2987
  }
2988
+ if (pathname === "/api/oauth/apple/native" && oauth?.appleNative !== true) {
2989
+ deny(404, "not_found");
2990
+ return;
2991
+ }
1704
2992
  const body = await readJson(request);
1705
2993
  if (!body.ok) {
1706
2994
  deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
1707
2995
  return;
1708
2996
  }
1709
- const value = body.value;
1710
- const device = parseDeviceClaim(value?.device);
1711
- if (typeof value?.invite !== "string" || typeof value.username !== "string" || typeof value.password !== "string" || device === "invalid") {
2997
+ const value = typeof body.value === "object" && body.value !== null ? body.value : {};
2998
+ const device = parseDeviceClaim(value.device);
2999
+ if (device === "invalid") {
3000
+ deny(400, "invalid_request");
3001
+ return;
3002
+ }
3003
+ if (pathname === "/api/oauth/token") {
3004
+ const { code, code_verifier: verifier } = value;
3005
+ if (typeof code !== "string" || code.length > 256 || typeof verifier !== "string" || !PKCE_VERIFIER.test(verifier)) {
3006
+ deny(400, "invalid_request");
3007
+ return;
3008
+ }
3009
+ const session = accounts.exchangeOAuthCode(code, verifier, device);
3010
+ if (session === undefined)
3011
+ deny(400, "invalid_grant");
3012
+ else
3013
+ signedIn(session);
3014
+ return;
3015
+ }
3016
+ const { identityToken, authorizationCode, nonce, name } = value;
3017
+ if (typeof identityToken !== "string" || typeof authorizationCode !== "string" || authorizationCode.length > 1024 || nonce !== undefined && (typeof nonce !== "string" || nonce.length > 256) || name !== undefined && typeof name !== "string") {
3018
+ deny(400, "invalid_request");
3019
+ return;
3020
+ }
3021
+ let linkToken;
3022
+ if (value.password !== undefined || value.reauth !== undefined) {
3023
+ const token = bearer ? sessionToken(request) : undefined;
3024
+ if (token === undefined) {
3025
+ deny(401, "unauthenticated");
3026
+ return;
3027
+ }
3028
+ const current = accounts.authenticate(token);
3029
+ if (current === undefined) {
3030
+ deny(401, "unauthenticated");
3031
+ return;
3032
+ }
3033
+ const confirmation = bodyConfirmation(value, token);
3034
+ if (confirmation === undefined) {
3035
+ deny(400, "invalid_request");
3036
+ return;
3037
+ }
3038
+ const confirmed = await accounts.confirm(current.userId, confirmation);
3039
+ if (confirmed !== "ok") {
3040
+ deny(confirmed === "limited" ? 429 : 403, confirmed === "limited" ? "rate_limited" : confirmed);
3041
+ return;
3042
+ }
3043
+ linkToken = token;
3044
+ }
3045
+ const identity = await oauth.verifyAppleNative({
3046
+ identityToken,
3047
+ authorizationCode,
3048
+ ...nonce === undefined ? {} : { nonce },
3049
+ ...name === undefined ? {} : { name }
3050
+ });
3051
+ if ("error" in identity) {
3052
+ deny(identity.error === "invalid_token" ? 401 : 400, identity.error === "invalid_token" ? "invalid_token" : "invalid_grant");
3053
+ return;
3054
+ }
3055
+ if (linkToken !== undefined) {
3056
+ const current = accounts.authenticate(linkToken);
3057
+ const linked = current === undefined ? "session_ended" : accounts.linkIdentity({ linkUserId: current.userId, linkSessionHash: sha256Hex2(linkToken) }, identity);
3058
+ if (linked === "linked")
3059
+ send(204);
3060
+ else
3061
+ deny(linked === "identity_taken" ? 409 : 401, linked);
3062
+ return;
3063
+ }
3064
+ const resolved = accounts.resolveIdentity(identity, { kind: "direct" });
3065
+ if (resolved.status === "refused")
3066
+ deny(403, resolved.reason);
3067
+ else if (resolved.status === "pending") {
3068
+ send(409, { error: "no_account", reason: "no_account", pending: resolved.id, ...resolved.email === undefined ? {} : { email: resolved.email } });
3069
+ } else {
3070
+ const session = accounts.signIn(resolved.userId, device);
3071
+ if (session === undefined)
3072
+ deny(404, "not_found");
3073
+ else
3074
+ signedIn(session);
3075
+ }
3076
+ return;
3077
+ }
3078
+ const pendingRoute = /^\/api\/oauth\/pending\/([A-Za-z0-9_-]{43})(?:\/(create|link))?$/.exec(pathname);
3079
+ if (pendingRoute !== null) {
3080
+ const id = pendingRoute[1];
3081
+ const action = pendingRoute[2];
3082
+ const expected = action === undefined ? "GET" : "POST";
3083
+ if (method !== expected) {
3084
+ deny(405, "method_not_allowed", { allow: expected });
3085
+ return;
3086
+ }
3087
+ const visible = () => {
3088
+ const pending = accounts.pendingSignIn(id);
3089
+ if (pending === undefined)
3090
+ return;
3091
+ const { completion } = pending;
3092
+ if (completion.kind !== "direct")
3093
+ return bound(request, completion.bindingHash) ? pending : undefined;
3094
+ const tokenMode = (origin === undefined || origin === DESKTOP_ORIGIN) && cookie(request, SESSION_COOKIE) === undefined;
3095
+ return (action === "link" ? bearer : tokenMode) ? pending : undefined;
3096
+ };
3097
+ if (action === undefined) {
3098
+ const pending = visible();
3099
+ if (pending === undefined)
3100
+ deny(404, "not_found");
3101
+ else {
3102
+ send(200, {
3103
+ provider: pending.provider,
3104
+ ...pending.email === undefined ? {} : { email: pending.email },
3105
+ ...pending.name === undefined ? {} : { name: pending.name }
3106
+ });
3107
+ }
3108
+ return;
3109
+ }
3110
+ if (action === "create") {
3111
+ const body2 = await readJson(request, true);
3112
+ if (!body2.ok) {
3113
+ deny(body2.tooLarge ? 413 : 400, body2.tooLarge ? "payload_too_large" : "invalid_json");
3114
+ return;
3115
+ }
3116
+ const value = body2.value;
3117
+ const device = parseDeviceClaim(value?.device);
3118
+ const password = value?.password;
3119
+ if (device === "invalid" || password !== undefined && (typeof password !== "string" || [...password].length > MAX_PASSWORD_CODEPOINTS)) {
3120
+ deny(400, "invalid_request");
3121
+ return;
3122
+ }
3123
+ if (visible() === undefined) {
3124
+ deny(404, "not_found");
3125
+ return;
3126
+ }
3127
+ const result2 = await accounts.createFromPending(id, password);
3128
+ if (result2.status !== "ok") {
3129
+ pendingRefused(result2);
3130
+ return;
3131
+ }
3132
+ if (result2.completion.kind === "native") {
3133
+ send(200, { redirect: nativeCode(result2.userId, result2.completion.native) });
3134
+ return;
3135
+ }
3136
+ const session = accounts.signIn(result2.userId, device);
3137
+ if (session === undefined)
3138
+ deny(404, "not_found");
3139
+ else
3140
+ signedIn(session);
3141
+ return;
3142
+ }
3143
+ const token = sessionToken(request);
3144
+ if (token === undefined || accounts.authenticate(token) === undefined) {
3145
+ deny(401, "unauthenticated");
3146
+ return;
3147
+ }
3148
+ const body = await authedJson(accounts, request, token, true);
3149
+ if (!body.ok) {
3150
+ deny(body.status, body.error);
3151
+ return;
3152
+ }
3153
+ const confirmation = bodyConfirmation(body.value, token);
3154
+ if (confirmation === undefined) {
3155
+ deny(400, "invalid_request");
3156
+ return;
3157
+ }
3158
+ if (visible() === undefined) {
3159
+ deny(404, "not_found");
3160
+ return;
3161
+ }
3162
+ const confirmed = await accounts.confirm(body.session.userId, confirmation);
3163
+ if (confirmed !== "ok") {
3164
+ deny(confirmed === "limited" ? 429 : 403, confirmed === "limited" ? "rate_limited" : confirmed);
3165
+ return;
3166
+ }
3167
+ if (accounts.authenticate(token)?.userId !== body.session.userId) {
3168
+ deny(401, "unauthenticated");
3169
+ return;
3170
+ }
3171
+ const result = accounts.linkPending(id, body.session.userId);
3172
+ if (result.status !== "ok")
3173
+ pendingRefused(result);
3174
+ else if (result.completion.kind === "native")
3175
+ send(200, { redirect: nativeCode(result.userId, result.completion.native) });
3176
+ else
3177
+ send(204);
3178
+ return;
3179
+ }
3180
+ const linkRoute = /^\/api\/oauth\/([a-z]+)\/link$/.exec(pathname);
3181
+ const removeRoute = /^\/api\/me\/identities\/([a-z]+)\/remove$/.exec(pathname);
3182
+ const linkComplete = pathname === "/api/oauth/link/complete";
3183
+ if (pathname === "/api/me/identities" || pathname === "/api/reauth/code" || pathname === "/api/reauth" || linkComplete || linkRoute !== null || removeRoute !== null) {
3184
+ const expected = pathname === "/api/me/identities" ? "GET" : "POST";
3185
+ if (method !== expected) {
3186
+ deny(405, "method_not_allowed", { allow: expected });
3187
+ return;
3188
+ }
3189
+ const token = sessionToken(request);
3190
+ const session = token === undefined ? undefined : accounts.authenticate(token);
3191
+ if (session === undefined) {
3192
+ deny(401, "unauthenticated");
3193
+ return;
3194
+ }
3195
+ if (pathname === "/api/me/identities") {
3196
+ send(200, accounts.listIdentities(session.userId));
3197
+ return;
3198
+ }
3199
+ const body = await authedJson(accounts, request, token, true);
3200
+ if (!body.ok) {
3201
+ deny(body.status, body.error);
3202
+ return;
3203
+ }
3204
+ const current = body.session;
3205
+ if (linkComplete) {
3206
+ const { code, code_verifier: verifier } = typeof body.value === "object" && body.value !== null ? body.value : {};
3207
+ if (typeof code !== "string" || code.length > 256 || typeof verifier !== "string" || !PKCE_VERIFIER.test(verifier)) {
3208
+ deny(400, "invalid_request");
3209
+ return;
3210
+ }
3211
+ const linked = accounts.completeLink({ code, codeVerifier: verifier, token });
3212
+ if (linked === "linked")
3213
+ send(204);
3214
+ else
3215
+ deny({ invalid_grant: 400, identity_taken: 409, session_ended: 401 }[linked], linked);
3216
+ return;
3217
+ }
3218
+ if (pathname === "/api/reauth/code") {
3219
+ const result = accounts.requestReauthCode({ userId: current.userId, ip: clientIp(request, ctx.trustProxy) });
3220
+ if (result === "no_email")
3221
+ deny(409, "no_email");
3222
+ else
3223
+ codeSent(result, "email_unavailable");
3224
+ return;
3225
+ }
3226
+ if (pathname === "/api/reauth") {
3227
+ const { password, code } = typeof body.value === "object" && body.value !== null ? body.value : {};
3228
+ const proof = typeof password === "string" && code === undefined && [...password].length <= MAX_PASSWORD_CODEPOINTS ? { password } : typeof code === "string" && password === undefined && code.length <= MAX_CODE_LENGTH ? { code } : undefined;
3229
+ if (proof === undefined) {
3230
+ deny(400, "invalid_request");
3231
+ return;
3232
+ }
3233
+ const result = await accounts.grantReauth({ userId: current.userId, token, ...proof });
3234
+ if (result.status === "limited")
3235
+ deny(429, "rate_limited");
3236
+ else if (result.status === "refused")
3237
+ deny(result.reason === "unauthenticated" ? 401 : 403, result.reason);
3238
+ else
3239
+ send(200, { reauth: result.reauth, expiresAt: result.expiresAt });
3240
+ return;
3241
+ }
3242
+ const confirmation = bodyConfirmation(body.value, token);
3243
+ if (confirmation === undefined) {
1712
3244
  deny(400, "invalid_request");
1713
3245
  return;
1714
3246
  }
1715
- const { invite, username } = value;
1716
- const result = await accounts.register({
1717
- invite,
1718
- username,
1719
- password: value.password,
3247
+ if (removeRoute !== null) {
3248
+ const provider2 = OAUTH_PROVIDERS.find((name) => name === removeRoute[1]);
3249
+ if (provider2 === undefined) {
3250
+ deny(404, "not_found");
3251
+ return;
3252
+ }
3253
+ const result = await accounts.removeIdentity({ userId: current.userId, provider: provider2, ...confirmation });
3254
+ if (result.status === "limited")
3255
+ deny(429, "rate_limited");
3256
+ else if (result.status === "refused") {
3257
+ deny(result.reason === "not_found" ? 404 : result.reason === "last_method" ? 409 : 403, result.reason);
3258
+ } else {
3259
+ for (const grant of result.appleGrants)
3260
+ oauth?.revokeApple(grant.refreshToken, grant.clientId);
3261
+ send(204);
3262
+ }
3263
+ return;
3264
+ }
3265
+ const provider = enabledProvider(linkRoute[1]);
3266
+ if (provider === undefined) {
3267
+ deny(404, "not_found");
3268
+ return;
3269
+ }
3270
+ const linkParams = typeof body.value === "object" && body.value !== null ? body.value : {};
3271
+ const client = linkParams.client ?? "web";
3272
+ const text = (key) => typeof linkParams[key] === "string" ? linkParams[key] : null;
3273
+ const native = client === "native" ? parseNativeRedirect(text("redirect_uri"), text("code_challenge"), text("state")) : undefined;
3274
+ if (client !== "web" && client !== "native" || native === null) {
3275
+ deny(400, "invalid_request");
3276
+ return;
3277
+ }
3278
+ const confirmed = await accounts.confirm(current.userId, confirmation);
3279
+ if (confirmed !== "ok") {
3280
+ deny(confirmed === "limited" ? 429 : 403, confirmed === "limited" ? "rate_limited" : confirmed);
3281
+ return;
3282
+ }
3283
+ const binding = randomBytes2(32).toString("base64url");
3284
+ const flow = accounts.startOAuthFlow({
3285
+ provider,
3286
+ kind: "link",
1720
3287
  ip: clientIp(request, ctx.trustProxy),
1721
- ...device === undefined ? {} : { device }
3288
+ bindingHash: sha256Hex2(binding),
3289
+ linkUserId: current.userId,
3290
+ linkSessionToken: token,
3291
+ ...native === undefined ? {} : { native }
1722
3292
  });
1723
- if (result.status === "limited") {
3293
+ if (flow.status === "limited")
1724
3294
  deny(429, "rate_limited");
3295
+ else if (flow.status === "busy")
3296
+ deny(503, "busy");
3297
+ else if (native !== undefined) {
3298
+ send(200, { url: `${ctx.publicOrigin}/api/oauth/${provider}/start?${new URLSearchParams({ link: binding })}` });
3299
+ } else {
3300
+ const url = oauth.authorizeUrl(provider, {
3301
+ state: flow.state,
3302
+ nonce: flow.nonce,
3303
+ codeChallenge: pkceChallenge(flow.verifier),
3304
+ redirectUri: callbackUri(provider)
3305
+ });
3306
+ send(200, { url }, { "set-cookie": setBindingCookie(binding) });
3307
+ }
3308
+ return;
3309
+ }
3310
+ if (pathname === "/api/register/code" || pathname === "/api/password/reset/code") {
3311
+ if (method !== "POST") {
3312
+ deny(405, "method_not_allowed", { allow: "POST" });
1725
3313
  return;
1726
3314
  }
1727
- if (result.status === "refused") {
1728
- const status = result.reason === "invalid_invite" || result.reason === "invite_expired" ? 403 : result.reason === "username_taken" ? 409 : 400;
1729
- deny(status, result.reason);
3315
+ const body = await readJson(request);
3316
+ if (!body.ok) {
3317
+ deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
1730
3318
  return;
1731
3319
  }
1732
- const cookieMode = origin === ctx.publicOrigin;
1733
- send(200, { user: { username }, expiresAt: result.expiresAt, ...cookieMode ? {} : { token: result.token } }, cookieMode ? { "set-cookie": setSessionCookie(result.token) } : undefined);
3320
+ const email = bodyEmail(body.value);
3321
+ if (email === undefined) {
3322
+ deny(400, "invalid_email");
3323
+ return;
3324
+ }
3325
+ const args = { email, ip: clientIp(request, ctx.trustProxy) };
3326
+ const signup = pathname === "/api/register/code";
3327
+ codeSent(signup ? accounts.requestSignupCode(args) : accounts.requestPasswordReset(args), signup ? "registration_closed" : "email_unavailable");
3328
+ return;
3329
+ }
3330
+ if (pathname === "/api/register" || pathname === "/api/password/reset") {
3331
+ if (method !== "POST") {
3332
+ deny(405, "method_not_allowed", { allow: "POST" });
3333
+ return;
3334
+ }
3335
+ const body = await readJson(request);
3336
+ if (!body.ok) {
3337
+ deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
3338
+ return;
3339
+ }
3340
+ const value = body.value;
3341
+ const email = bodyEmail(value);
3342
+ const code = bodyCode(value);
3343
+ const device = parseDeviceClaim(value?.device);
3344
+ if (email === undefined || code === undefined || typeof value?.password !== "string" || device === "invalid") {
3345
+ deny(400, "invalid_request");
3346
+ return;
3347
+ }
3348
+ const args = { email, code, password: value.password, ip: clientIp(request, ctx.trustProxy) };
3349
+ if (pathname === "/api/password/reset") {
3350
+ const result2 = await accounts.resetPasswordWithCode(args);
3351
+ if (result2.status === "limited")
3352
+ deny(429, "rate_limited");
3353
+ else if (result2.status === "refused")
3354
+ deny(result2.reason === "email_unavailable" ? 403 : 400, result2.reason);
3355
+ else
3356
+ send(204);
3357
+ return;
3358
+ }
3359
+ const result = await accounts.register({ ...args, ...device === undefined ? {} : { device } });
3360
+ if (result.status === "limited")
3361
+ deny(429, "rate_limited");
3362
+ else if (result.status === "refused")
3363
+ deny(result.reason === "registration_closed" ? 403 : 400, result.reason);
3364
+ else
3365
+ signedIn(result);
1734
3366
  return;
1735
3367
  }
1736
3368
  if (pathname === "/api/host-link/start" || pathname === "/api/host-link/poll" || pathname === "/api/host-link/token") {
@@ -1829,9 +3461,9 @@ async function handleApiRequest(request, response, ctx) {
1829
3461
  deny(401, "unauthenticated");
1830
3462
  return;
1831
3463
  }
1832
- const body = await readJson(request);
3464
+ const body = await authedJson(accounts, request, token);
1833
3465
  if (!body.ok) {
1834
- deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
3466
+ deny(body.status, body.error);
1835
3467
  return;
1836
3468
  }
1837
3469
  const named = body.value;
@@ -1840,12 +3472,7 @@ async function handleApiRequest(request, response, ctx) {
1840
3472
  deny(400, "invalid_request");
1841
3473
  return;
1842
3474
  }
1843
- const session = accounts.authenticate(token);
1844
- if (session === undefined) {
1845
- deny(401, "unauthenticated");
1846
- return;
1847
- }
1848
- const args = { userId: session.userId, ip: clientIp(request, ctx.trustProxy), ...ref };
3475
+ const args = { userId: body.session.userId, ip: clientIp(request, ctx.trustProxy), ...ref };
1849
3476
  const result = pathname === "/api/host-link/lookup" ? accounts.lookupHostLink(args) : pathname === "/api/host-link/confirm" ? accounts.confirmHostLink(args) : accounts.denyHostLink(args);
1850
3477
  if (result.status === "limited") {
1851
3478
  deny(429, "rate_limited");
@@ -1863,49 +3490,6 @@ async function handleApiRequest(request, response, ctx) {
1863
3490
  send(204);
1864
3491
  return;
1865
3492
  }
1866
- if (pathname === "/api/invites" || pathname === "/api/invites/revoke") {
1867
- const allowed = pathname === "/api/invites" ? ["GET", "POST"] : ["POST"];
1868
- if (!allowed.includes(method)) {
1869
- deny(405, "method_not_allowed", { allow: allowed.join(", ") });
1870
- return;
1871
- }
1872
- const token = sessionToken(request);
1873
- const session = token === undefined ? undefined : accounts.authenticate(token);
1874
- if (session === undefined) {
1875
- deny(401, "unauthenticated");
1876
- return;
1877
- }
1878
- if (!session.admin) {
1879
- deny(403, "forbidden");
1880
- return;
1881
- }
1882
- if (pathname === "/api/invites") {
1883
- if (method === "GET")
1884
- send(200, accounts.listInvites());
1885
- else
1886
- send(200, accounts.createInvite(session.userId));
1887
- return;
1888
- }
1889
- const body = await readJson(request);
1890
- if (!body.ok) {
1891
- deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
1892
- return;
1893
- }
1894
- const id = body.value?.id;
1895
- if (typeof id !== "number" || !Number.isSafeInteger(id)) {
1896
- deny(400, "invalid_request");
1897
- return;
1898
- }
1899
- if (accounts.authenticate(token)?.admin !== true) {
1900
- deny(401, "unauthenticated");
1901
- return;
1902
- }
1903
- if (accounts.revokeInvite(id))
1904
- send(204);
1905
- else
1906
- deny(404, "not_found");
1907
- return;
1908
- }
1909
3493
  const hostRemove = /^\/api\/hosts\/([A-Za-z0-9_-]{43})\/remove$/.exec(pathname);
1910
3494
  if (hostRemove !== null) {
1911
3495
  if (method !== "POST") {
@@ -1917,27 +3501,18 @@ async function handleApiRequest(request, response, ctx) {
1917
3501
  deny(401, "unauthenticated");
1918
3502
  return;
1919
3503
  }
1920
- const body = await readJson(request);
3504
+ const body = await authedJson(accounts, request, token);
1921
3505
  if (!body.ok) {
1922
- deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
3506
+ deny(body.status, body.error);
1923
3507
  return;
1924
3508
  }
1925
- const value = body.value;
1926
- if (typeof value?.password !== "string" || [...value.password].length > MAX_PASSWORD_CODEPOINTS) {
3509
+ const confirmation = bodyConfirmation(body.value, token);
3510
+ if (confirmation === undefined) {
1927
3511
  deny(400, "invalid_request");
1928
3512
  return;
1929
3513
  }
1930
- const current = accounts.authenticate(token);
1931
- if (current === undefined) {
1932
- deny(401, "unauthenticated");
1933
- return;
1934
- }
1935
- const result = await accounts.removeHost({
1936
- userId: current.userId,
1937
- username: current.username,
1938
- password: value.password,
1939
- hostId: hostRemove[1]
1940
- });
3514
+ const current = body.session;
3515
+ const result = await accounts.removeHost({ userId: current.userId, hostId: hostRemove[1], ...confirmation });
1941
3516
  if (result.status === "limited") {
1942
3517
  deny(429, "rate_limited");
1943
3518
  return;
@@ -2044,27 +3619,22 @@ async function handleApiRequest(request, response, ctx) {
2044
3619
  send(200, accounts.listDevices(session.userId).map((device) => ({ ...device, current: device.id === session.deviceId })));
2045
3620
  return;
2046
3621
  }
2047
- const body = await readJson(request);
3622
+ const body = await authedJson(accounts, request, token);
2048
3623
  if (!body.ok) {
2049
- deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
3624
+ deny(body.status, body.error);
2050
3625
  return;
2051
3626
  }
2052
- const value = body.value;
2053
- if (typeof value?.password !== "string" || [...value.password].length > MAX_PASSWORD_CODEPOINTS) {
3627
+ const confirmation = bodyConfirmation(body.value, token);
3628
+ if (confirmation === undefined) {
2054
3629
  deny(400, "invalid_request");
2055
3630
  return;
2056
3631
  }
2057
- const current = accounts.authenticate(token);
2058
- if (current === undefined) {
2059
- deny(401, "unauthenticated");
2060
- return;
2061
- }
3632
+ const current = body.session;
2062
3633
  const result = await accounts.kickDevice({
2063
3634
  userId: current.userId,
2064
- username: current.username,
2065
- password: value.password,
2066
3635
  deviceId: Number(deviceKick[1]),
2067
- ...current.deviceId === undefined ? {} : { currentDeviceId: current.deviceId }
3636
+ ...current.deviceId === undefined ? {} : { currentDeviceId: current.deviceId },
3637
+ ...confirmation
2068
3638
  });
2069
3639
  if (result.status === "limited") {
2070
3640
  deny(429, "rate_limited");
@@ -2079,7 +3649,7 @@ async function handleApiRequest(request, response, ctx) {
2079
3649
  send(204);
2080
3650
  return;
2081
3651
  }
2082
- if (pathname === "/api/me" || pathname === "/api/logout" || pathname === "/api/sessions/revoke-all" || pathname === "/api/password" || pathname === "/api/me/delete" || pathname === "/api/join-tokens" || pathname === "/api/push-tokens" || pathname === "/api/push-tokens/remove") {
3652
+ if (pathname === "/api/me" || pathname === "/api/me/email/code" || pathname === "/api/me/email" || pathname === "/api/logout" || pathname === "/api/sessions/revoke-all" || pathname === "/api/password" || pathname === "/api/me/delete" || pathname === "/api/join-tokens" || pathname === "/api/push-tokens" || pathname === "/api/push-tokens/remove") {
2083
3653
  const expected = pathname === "/api/me" ? "GET" : "POST";
2084
3654
  if (method !== expected) {
2085
3655
  deny(405, "method_not_allowed", { allow: expected });
@@ -2092,13 +3662,49 @@ async function handleApiRequest(request, response, ctx) {
2092
3662
  return;
2093
3663
  }
2094
3664
  if (pathname === "/api/me") {
2095
- send(200, { user: { username: session.username }, ...session.admin ? { admin: true } : {} });
3665
+ send(200, profile(session.user));
3666
+ return;
3667
+ }
3668
+ if (pathname === "/api/me/email/code" || pathname === "/api/me/email") {
3669
+ const body = await authedJson(accounts, request, token);
3670
+ if (!body.ok) {
3671
+ deny(body.status, body.error);
3672
+ return;
3673
+ }
3674
+ const confirmation = bodyConfirmation(body.value, token);
3675
+ if (confirmation === undefined) {
3676
+ deny(400, "invalid_request");
3677
+ return;
3678
+ }
3679
+ const email = bodyEmail(body.value);
3680
+ if (email === undefined) {
3681
+ deny(400, "invalid_email");
3682
+ return;
3683
+ }
3684
+ const current = body.session;
3685
+ if (pathname === "/api/me/email/code") {
3686
+ codeSent(await accounts.requestEmailCode({ userId: current.userId, email, ip: clientIp(request, ctx.trustProxy), ...confirmation }), "email_unavailable");
3687
+ return;
3688
+ }
3689
+ const code = bodyCode(body.value);
3690
+ if (code === undefined) {
3691
+ deny(400, "invalid_request");
3692
+ return;
3693
+ }
3694
+ const result = await accounts.setEmail({ userId: current.userId, email, code, ...confirmation });
3695
+ if (result.status === "limited")
3696
+ deny(429, "rate_limited");
3697
+ else if (result.status === "refused") {
3698
+ const status = { email_taken: 409, email_unavailable: 403, wrong_password: 403, invalid_reauth: 403, invalid_code: 400 }[result.reason];
3699
+ deny(status, result.reason);
3700
+ } else
3701
+ send(200, profile(result.user));
2096
3702
  return;
2097
3703
  }
2098
3704
  if (pathname === "/api/push-tokens" || pathname === "/api/push-tokens/remove") {
2099
- const body = await readJson(request);
3705
+ const body = await authedJson(accounts, request, token);
2100
3706
  if (!body.ok) {
2101
- deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
3707
+ deny(body.status, body.error);
2102
3708
  return;
2103
3709
  }
2104
3710
  const value = body.value;
@@ -2108,68 +3714,64 @@ async function handleApiRequest(request, response, ctx) {
2108
3714
  deny(400, "invalid_request");
2109
3715
  return;
2110
3716
  }
2111
- const current = accounts.authenticate(token);
2112
- if (current === undefined) {
2113
- deny(401, "unauthenticated");
2114
- return;
2115
- }
3717
+ const current = body.session;
2116
3718
  if (pathname === "/api/push-tokens")
2117
- accounts.registerPushToken(session.userId, apns, environment, current.deviceId);
3719
+ accounts.registerPushToken(current.userId, apns, environment, current.deviceId);
2118
3720
  else
2119
- accounts.removePushToken(session.userId, apns, environment);
3721
+ accounts.removePushToken(current.userId, apns, environment);
2120
3722
  send(204);
2121
3723
  return;
2122
3724
  }
2123
3725
  if (pathname === "/api/me/delete") {
2124
- const body = await readJson(request);
3726
+ const body = await authedJson(accounts, request, token);
2125
3727
  if (!body.ok) {
2126
- deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
3728
+ deny(body.status, body.error);
2127
3729
  return;
2128
3730
  }
2129
- const value = body.value;
2130
- if (typeof value?.password !== "string" || [...value.password].length > MAX_PASSWORD_CODEPOINTS) {
3731
+ const current = body.session;
3732
+ const confirmation = bodyConfirmation(body.value, token);
3733
+ if (confirmation === undefined) {
2131
3734
  deny(400, "invalid_request");
2132
3735
  return;
2133
3736
  }
2134
- const result = await accounts.deleteAccount({ userId: session.userId, username: session.username, password: value.password });
3737
+ const result = await accounts.deleteAccount({ userId: current.userId, keepToken: token, ...confirmation });
2135
3738
  if (result.status === "limited") {
2136
3739
  deny(429, "rate_limited");
2137
3740
  return;
2138
3741
  }
2139
3742
  if (result.status === "refused") {
2140
- deny(403, result.reason);
3743
+ deny(result.reason === "unauthenticated" ? 401 : 403, result.reason);
2141
3744
  return;
2142
3745
  }
2143
3746
  for (const hostId of result.hostIds)
2144
3747
  ctx.disconnectHost(hostId);
2145
- ctx.forgetAccount(session.userId);
3748
+ ctx.forgetAccount(current.userId);
3749
+ for (const grant of result.appleGrants)
3750
+ oauth?.revokeApple(grant.refreshToken, grant.clientId);
2146
3751
  send(204, undefined, { "set-cookie": clearSessionCookie });
2147
3752
  return;
2148
3753
  }
2149
3754
  if (pathname === "/api/password") {
2150
- const body = await readJson(request);
3755
+ const body = await authedJson(accounts, request, token);
2151
3756
  if (!body.ok) {
2152
- deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
3757
+ deny(body.status, body.error);
2153
3758
  return;
2154
3759
  }
3760
+ const current = body.session;
2155
3761
  const value = body.value;
2156
- if (typeof value?.currentPassword !== "string" || typeof value.newPassword !== "string" || [...value.currentPassword].length > MAX_PASSWORD_CODEPOINTS) {
3762
+ const proof = typeof value?.currentPassword === "string" && value.reauth === undefined && [...value.currentPassword].length <= MAX_PASSWORD_CODEPOINTS ? { currentPassword: value.currentPassword } : typeof value?.reauth === "string" && value.currentPassword === undefined && value.reauth.length <= 256 ? { reauth: value.reauth } : undefined;
3763
+ if (proof === undefined || typeof value?.newPassword !== "string") {
2157
3764
  deny(400, "invalid_request");
2158
3765
  return;
2159
3766
  }
2160
- const result = await accounts.changePassword({
2161
- userId: session.userId,
2162
- username: session.username,
2163
- keepToken: token,
2164
- currentPassword: value.currentPassword,
2165
- newPassword: value.newPassword
2166
- });
3767
+ const result = await accounts.changePassword({ userId: current.userId, keepToken: token, newPassword: value.newPassword, ...proof });
2167
3768
  if (result.status === "limited") {
2168
3769
  deny(429, "rate_limited");
2169
3770
  return;
2170
3771
  }
2171
3772
  if (result.status === "refused") {
2172
- deny(result.reason === "wrong_password" ? 403 : 400, result.reason);
3773
+ const status = result.reason === "weak_password" ? 400 : result.reason === "unauthenticated" ? 401 : 403;
3774
+ deny(status, result.reason);
2173
3775
  return;
2174
3776
  }
2175
3777
  send(204);
@@ -2185,9 +3787,9 @@ async function handleApiRequest(request, response, ctx) {
2185
3787
  return;
2186
3788
  }
2187
3789
  if (pathname === "/api/logout") {
2188
- const body = await readJson(request, true);
3790
+ const body = await authedJson(accounts, request, token, true);
2189
3791
  if (!body.ok) {
2190
- deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
3792
+ deny(body.status, body.error);
2191
3793
  return;
2192
3794
  }
2193
3795
  const value = body.value;
@@ -2312,7 +3914,7 @@ async function startRelay(options) {
2312
3914
  return Promise.resolve({ status: "too_many" });
2313
3915
  }
2314
3916
  return new Promise((resolve) => {
2315
- const requestId = randomBytes2(16).toString("base64url");
3917
+ const requestId = randomBytes3(16).toString("base64url");
2316
3918
  const settle = (result) => {
2317
3919
  if (!pairingRequests.delete(requestId))
2318
3920
  return;
@@ -2432,7 +4034,7 @@ async function startRelay(options) {
2432
4034
  }
2433
4035
  function onControl(socket) {
2434
4036
  track(socket);
2435
- const nonce = randomBytes2(32).toString("base64url");
4037
+ const nonce = randomBytes3(32).toString("base64url");
2436
4038
  socket.send(encodeRelayFrame({ t: "challenge", nonce }));
2437
4039
  let hostId;
2438
4040
  const deadline = setTimeout(() => socket.close(1008, "register timeout"), registerTimeoutMs);
@@ -2496,13 +4098,13 @@ async function startRelay(options) {
2496
4098
  }
2497
4099
  let noisePublicKey;
2498
4100
  if (frame.signatureV2 !== undefined) {
2499
- const bound = frame.noisePublicKey !== undefined && verifyRelayChallengeV2(frame.signatureV2, {
4101
+ const bound2 = frame.noisePublicKey !== undefined && verifyRelayChallengeV2(frame.signatureV2, {
2500
4102
  relayOrigin: publicOrigin,
2501
4103
  hostId: frame.hostId,
2502
4104
  nonce,
2503
4105
  noisePublicKey: frame.noisePublicKey
2504
4106
  });
2505
- if (!bound) {
4107
+ if (!bound2) {
2506
4108
  socket.close(RELAY_CLOSE.badSignature);
2507
4109
  return;
2508
4110
  }
@@ -2576,7 +4178,7 @@ async function startRelay(options) {
2576
4178
  if (devices.size === 0 && hostDevices.get(hostId) === devices)
2577
4179
  hostDevices.delete(hostId);
2578
4180
  });
2579
- const connId = randomBytes2(16).toString("base64url");
4181
+ const connId = randomBytes3(16).toString("base64url");
2580
4182
  const entry = {
2581
4183
  control,
2582
4184
  socket,
@@ -2648,7 +4250,8 @@ async function startRelay(options) {
2648
4250
  pairing,
2649
4251
  disconnectHost,
2650
4252
  forgetAccount: (userId) => limiter.forgetAccount(accountKey(userId)),
2651
- sendRevocations
4253
+ sendRevocations,
4254
+ ...options.oauth === undefined ? {} : { oauth: options.oauth }
2652
4255
  }).catch(() => {
2653
4256
  if (!response.headersSent)
2654
4257
  response.writeHead(500);
@@ -2764,6 +4367,12 @@ async function readStdin(limit) {
2764
4367
  }
2765
4368
  return Buffer.concat(chunks).toString("utf8");
2766
4369
  }
4370
+ async function passwordFromStdin(command) {
4371
+ if (process.stdin.isTTY) {
4372
+ throw new Error(`pipe the password on stdin, e.g. \`printenv NEW_PINOMAD_SERVER_PASSWORD | ${commandHint("server")} ${command}\``);
4373
+ }
4374
+ return (await readStdin(16 * 1024)).replace(/[\r\n]+$/, "");
4375
+ }
2767
4376
  async function passwdMain(argv) {
2768
4377
  const { values } = parseArgs({
2769
4378
  args: [...argv],
@@ -2773,18 +4382,36 @@ async function passwdMain(argv) {
2773
4382
  },
2774
4383
  strict: true
2775
4384
  });
2776
- if (process.stdin.isTTY) {
2777
- console.error("pipe the new password on stdin, e.g. `printenv NEW_PINOMAD_SERVER_PASSWORD | " + commandHint("server") + " passwd`");
2778
- process.exit(1);
2779
- }
2780
4385
  const dataDir = resolveDataDir(values["data-dir"]);
2781
- const password = (await readStdin(16 * 1024)).replace(/[\r\n]+$/, "");
4386
+ const password = await passwordFromStdin("passwd");
2782
4387
  await resetPassword({ dataDir, user: values.user, password });
2783
4388
  console.log(JSON.stringify({ event: "passwd", dataDir }));
2784
4389
  }
4390
+ async function userMain(argv) {
4391
+ const { values, positionals } = parseArgs({
4392
+ args: [...argv],
4393
+ options: { "data-dir": { type: "string" } },
4394
+ allowPositionals: true,
4395
+ strict: true
4396
+ });
4397
+ const dataDir = resolveDataDir(values["data-dir"]);
4398
+ const [command, ...rest] = positionals;
4399
+ if (command === "add" && rest.length === 1) {
4400
+ await addUser({ dataDir, email: rest[0], password: await passwordFromStdin(`user add ${rest[0]}`) });
4401
+ console.log(JSON.stringify({ event: "user-added", dataDir }));
4402
+ return;
4403
+ }
4404
+ if (command === "set-email" && rest.length === 2) {
4405
+ await setUserEmail({ dataDir, user: rest[0], email: rest[1] });
4406
+ console.log(JSON.stringify({ event: "email-set", dataDir }));
4407
+ return;
4408
+ }
4409
+ throw new Error(`usage: ${commandHint("server")} user add EMAIL | user set-email NAME EMAIL [--data-dir DIR]`);
4410
+ }
2785
4411
  async function main(argv) {
2786
- if (argv[0] === "passwd") {
2787
- await passwdMain(argv.slice(1)).catch((error) => {
4412
+ const subcommand = argv[0] === "passwd" ? passwdMain : argv[0] === "user" ? userMain : undefined;
4413
+ if (subcommand !== undefined) {
4414
+ await subcommand(argv.slice(1)).catch((error) => {
2788
4415
  console.error(error instanceof Error ? error.message : String(error));
2789
4416
  process.exit(1);
2790
4417
  });
@@ -2803,8 +4430,13 @@ async function main(argv) {
2803
4430
  });
2804
4431
  if (values["public-origin"] === undefined) {
2805
4432
  console.error(`usage: ${commandHint("server")} --public-origin ORIGIN [--data-dir DIR] [--trust-proxy] [--port 7430] [--listen 127.0.0.1]
2806
- ` + ` ${commandHint("server")} passwd [--data-dir DIR] [--user NAME] (reset a password, server running or not; new password on stdin)
2807
- ` + ` env: PINOMAD_SERVER_USER, PINOMAD_SERVER_PASSWORD, PINOMAD_SERVER_DATA_DIR
4433
+ ` + ` ${commandHint("server")} passwd [--data-dir DIR] [--user EMAIL|NAME] (reset a password, server running or not; new password on stdin)
4434
+ ` + ` ${commandHint("server")} user add EMAIL [--data-dir DIR] (add an account; its password on stdin)
4435
+ ` + ` ${commandHint("server")} user set-email NAME EMAIL [--data-dir DIR] (give an account its email, taken as verified)
4436
+ ` + ` env: PINOMAD_SERVER_USER, PINOMAD_SERVER_PASSWORD, PINOMAD_SERVER_DATA_DIR,
4437
+ ` + ` PINOMAD_SERVER_REGISTRATION (open|closed), PINOMAD_SERVER_SMTP_URL, PINOMAD_SERVER_MAIL_FROM,
4438
+ ` + ` PINOMAD_SERVER_GOOGLE_CLIENT_ID/_SECRET, PINOMAD_SERVER_GITHUB_CLIENT_ID/_SECRET,
4439
+ ` + ` PINOMAD_SERVER_APPLE_SERVICES_ID/_TEAM_ID/_KEY_ID/_PRIVATE_KEY [/_BUNDLE_ID]
2808
4440
  ` + " relay limits (flag or env, 0 = off): " + RELAY_LIMIT_OPTIONS.map((option) => `--${option.flag} / ${option.env}`).join(", "));
2809
4441
  process.exit(1);
2810
4442
  }
@@ -2815,10 +4447,25 @@ async function main(argv) {
2815
4447
  console.error(error instanceof Error ? error.message : String(error));
2816
4448
  process.exit(1);
2817
4449
  }
4450
+ const registration = process.env.PINOMAD_SERVER_REGISTRATION ?? "closed";
4451
+ let mailer;
4452
+ let oauth;
4453
+ try {
4454
+ if (registration !== "open" && registration !== "closed") {
4455
+ throw new Error(`PINOMAD_SERVER_REGISTRATION must be open or closed, not ${registration}`);
4456
+ }
4457
+ mailer = loadMailer();
4458
+ oauth = createOAuth(loadOAuthConfig());
4459
+ } catch (error) {
4460
+ console.error(error instanceof Error ? error.message : String(error));
4461
+ process.exit(1);
4462
+ }
2818
4463
  const accounts = await openAccounts({
2819
4464
  dataDir: resolveDataDir(values["data-dir"]),
2820
4465
  bootstrapUser: process.env.PINOMAD_SERVER_USER,
2821
- bootstrapPassword: process.env.PINOMAD_SERVER_PASSWORD
4466
+ bootstrapPassword: process.env.PINOMAD_SERVER_PASSWORD,
4467
+ registration,
4468
+ ...mailer === undefined ? {} : { mailer }
2822
4469
  }).catch((error) => {
2823
4470
  console.error(error instanceof Error ? error.message : String(error));
2824
4471
  process.exit(1);
@@ -2833,6 +4480,7 @@ async function main(argv) {
2833
4480
  accounts,
2834
4481
  trustProxy: values["trust-proxy"],
2835
4482
  ...apns === undefined ? {} : { push: apns },
4483
+ oauth,
2836
4484
  limits
2837
4485
  });
2838
4486
  console.log(JSON.stringify({
@@ -2840,6 +4488,9 @@ async function main(argv) {
2840
4488
  url: relay.url,
2841
4489
  publicOrigin: values["public-origin"],
2842
4490
  push: apns?.environments ?? [],
4491
+ registration: accounts.registrationOpen ? "open" : "closed",
4492
+ mail: accounts.emailEnabled,
4493
+ providers: oauth.enabled,
2843
4494
  relayLimits: { ...DEFAULT_RELAY_LIMITS, ...limits }
2844
4495
  }));
2845
4496
  let stopping = false;