pinomad 0.4.0 → 0.6.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 (34) hide show
  1. package/README.md +4 -2
  2. package/dist/{chunk-837gh8nr.js → chunk-0xh6cy9v.js} +69 -17
  3. package/dist/{chunk-n73t5xzw.js → chunk-367n9je8.js} +2 -2
  4. package/dist/{chunk-xhwkx9bd.js → chunk-3dnnaz4j.js} +14 -15
  5. package/dist/{chunk-z5pmcscx.js → chunk-4btj08we.js} +676 -377
  6. package/dist/{chunk-7gmkgyaz.js → chunk-7qb9tq54.js} +324 -4
  7. package/dist/{chunk-d4y99pfy.js → chunk-7s1f4z4g.js} +4 -3
  8. package/dist/chunk-b1h374jr.js +159 -0
  9. package/dist/{chunk-fgx2sh7d.js → chunk-c8nxym2z.js} +1 -1
  10. package/dist/{chunk-akgxwg8f.js → chunk-d5ypmmr7.js} +29 -12
  11. package/dist/{chunk-374zqx5h.js → chunk-d93kvxvc.js} +18 -2
  12. package/dist/chunk-ggb1taqy.js +16 -0
  13. package/dist/chunk-jn7y815x.js +79 -0
  14. package/dist/{chunk-b3f0ced1.js → chunk-k2s1hfq5.js} +39 -16
  15. package/dist/chunk-kzbzacz9.js +12 -0
  16. package/dist/{chunk-myjp910s.js → chunk-n4tjhcb7.js} +25 -21
  17. package/dist/{chunk-cjfmfyjb.js → chunk-ngf1zatm.js} +7 -6
  18. package/dist/{chunk-vw5yb5pd.js → chunk-wqvp1fwh.js} +304 -38
  19. package/dist/{chunk-5785kwej.js → chunk-ywmm63ks.js} +9 -7
  20. package/dist/chunk-znhn8af7.js +33 -0
  21. package/dist/pinomad.js +40 -16
  22. package/package.json +1 -1
  23. package/web/apple-touch-icon.png +0 -0
  24. package/web/assets/index-C_aU_ZiV.js +383 -0
  25. package/web/assets/index-DaPcXZPM.css +1 -0
  26. package/web/favicon.svg +1 -0
  27. package/web/index.html +4 -2
  28. package/dist/chunk-17p6bfvy.js +0 -63
  29. package/dist/chunk-302y6by9.js +0 -50
  30. package/dist/chunk-knaw1a9g.js +0 -182
  31. package/dist/chunk-prhvksh3.js +0 -14
  32. package/dist/chunk-xrvmnymc.js +0 -33
  33. package/web/assets/index-8mNNPXPT.js +0 -383
  34. package/web/assets/index-BFJJgGGo.css +0 -1
@@ -2,35 +2,36 @@
2
2
  import {
3
3
  serveWebClient
4
4
  } from "./chunk-r6w5aqkw.js";
5
- import {
6
- relayWsBase
7
- } from "./chunk-knaw1a9g.js";
8
- import {
9
- commandHint,
10
- webRoot
11
- } from "./chunk-fgx2sh7d.js";
12
5
  import {
13
6
  RELAY_CLOSE,
14
7
  decodeHostToRelay,
15
8
  encodeRelayFrame,
9
+ relayWsBase,
16
10
  verifyRelayChallenge,
17
11
  verifyRelayChallengeV2
18
- } from "./chunk-7gmkgyaz.js";
12
+ } from "./chunk-7qb9tq54.js";
13
+ import {
14
+ commandHint,
15
+ webRoot
16
+ } from "./chunk-c8nxym2z.js";
17
+ import {
18
+ normalizeHostName
19
+ } from "./chunk-znhn8af7.js";
19
20
  import"./chunk-df32avw6.js";
20
21
 
21
- // apps/relay/src/main.ts
22
+ // apps/server/src/main.ts
22
23
  import { homedir } from "node:os";
23
24
  import { join as join2, resolve } from "node:path";
24
25
  import { parseArgs } from "node:util";
25
26
 
26
- // apps/relay/src/accounts.ts
27
+ // apps/server/src/accounts.ts
27
28
  import { createHash, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
28
29
  import { mkdir } from "node:fs/promises";
29
30
  import { join } from "node:path";
30
31
  import { DatabaseSync } from "node:sqlite";
31
32
  import { promisify } from "node:util";
32
33
 
33
- // apps/relay/src/lock.ts
34
+ // apps/server/src/lock.ts
34
35
  import lockfile from "proper-lockfile";
35
36
  async function lockDataDir(dir, options) {
36
37
  const stale = Math.max(2000, options?.staleMs ?? 1e4);
@@ -44,6 +45,8 @@ async function lockDataDir(dir, options) {
44
45
  throw error;
45
46
  }
46
47
  }).catch((error) => {
48
+ if (error.code !== "ELOCKED")
49
+ throw error;
47
50
  throw new Error(`Data directory is already in use by a running process: ${dir}`, { cause: error });
48
51
  });
49
52
  return async () => {
@@ -52,7 +55,7 @@ async function lockDataDir(dir, options) {
52
55
  };
53
56
  }
54
57
 
55
- // apps/relay/src/accounts.ts
58
+ // apps/server/src/accounts.ts
56
59
  var DB_FILE = "server.sqlite";
57
60
  var SCHEMA_VERSION = 1;
58
61
  var SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
@@ -325,7 +328,7 @@ async function openAccounts(options) {
325
328
  db.exec("ROLLBACK");
326
329
  return { status: "refused", reason: failure };
327
330
  }
328
- const hostName = name === undefined || name.trim() === "" ? "Unknown host" : name.trim().slice(0, 64);
331
+ const hostName = name === undefined ? "Unknown host" : normalizeHostName(name);
329
332
  db.prepare(`INSERT INTO hosts (host_id, user_id, noise_public_key, name, joined_at, last_seen_at)
330
333
  VALUES (?, ?, ?, ?, ?, ?)
331
334
  ON CONFLICT(host_id) DO UPDATE SET
@@ -358,6 +361,9 @@ async function openAccounts(options) {
358
361
  markHostSeen(hostId) {
359
362
  db.prepare("UPDATE hosts SET last_seen_at = ? WHERE host_id = ?").run(now(), hostId);
360
363
  },
364
+ renameHost(hostId, name) {
365
+ db.prepare("UPDATE hosts SET name = ? WHERE host_id = ?").run(normalizeHostName(name), hostId);
366
+ },
361
367
  async close() {
362
368
  db.close();
363
369
  await release();
@@ -394,13 +400,220 @@ async function changePassword(options) {
394
400
  }
395
401
  }
396
402
 
397
- // apps/relay/src/relay.ts
403
+ // apps/server/src/apns.ts
404
+ import { createPrivateKey, sign } from "node:crypto";
405
+ import { connect } from "node:http2";
406
+ var AUTHORITIES = {
407
+ production: "https://api.push.apple.com",
408
+ development: "https://api.sandbox.push.apple.com"
409
+ };
410
+ var PAYLOAD_MAX_BYTES = 4096;
411
+ var EXPIRATION_SECONDS = 3600;
412
+ var TOKEN_REFRESH_MS = 45 * 60 * 1000;
413
+ var REQUEST_TIMEOUT_MS = 8000;
414
+ var SERVER_ERROR_RETRY_MS = 15 * 60 * 1000;
415
+ var base64url = (text) => Buffer.from(text, "utf8").toString("base64url");
416
+ function loadApnsConfig(env = process.env) {
417
+ const teamId = env.PINOMAD_SERVER_APNS_TEAM_ID?.trim();
418
+ const topic = env.PINOMAD_SERVER_APNS_TOPIC?.trim();
419
+ if (teamId === undefined || teamId === "" || topic === undefined || topic === "")
420
+ return;
421
+ const loadEnv = (name) => {
422
+ const keyId = env[`PINOMAD_SERVER_APNS_${name}_KEY_ID`]?.trim();
423
+ const keyPem = env[`PINOMAD_SERVER_APNS_${name}_KEY`]?.replace(/\\n/g, `
424
+ `).trim();
425
+ if (keyId === undefined && keyPem === undefined)
426
+ return;
427
+ if (keyId === undefined || keyId === "" || keyPem === undefined || keyPem === "") {
428
+ console.error(`apns: PINOMAD_SERVER_APNS_${name}_KEY and _KEY_ID must both be set — environment disabled`);
429
+ return;
430
+ }
431
+ return { keyId, keyPem };
432
+ };
433
+ const production = loadEnv("PRODUCTION");
434
+ const development = loadEnv("DEVELOPMENT");
435
+ return {
436
+ teamId,
437
+ topic,
438
+ environments: {
439
+ ...production === undefined ? {} : { production },
440
+ ...development === undefined ? {} : { development }
441
+ }
442
+ };
443
+ }
444
+ function providerToken(key, keyId, teamId, iatSeconds) {
445
+ const signingInput = `${base64url(JSON.stringify({ alg: "ES256", kid: keyId }))}.${base64url(JSON.stringify({ iss: teamId, iat: iatSeconds }))}`;
446
+ const signature = sign("sha256", Buffer.from(signingInput, "utf8"), { key, dsaEncoding: "ieee-p1363" });
447
+ return `${signingInput}.${signature.toString("base64url")}`;
448
+ }
449
+ function openApns(config) {
450
+ const states = new Map;
451
+ for (const environment of ["production", "development"]) {
452
+ const env = config.environments[environment];
453
+ if (env === undefined)
454
+ continue;
455
+ try {
456
+ states.set(environment, {
457
+ key: createPrivateKey(env.keyPem),
458
+ keyId: env.keyId,
459
+ authority: env.authority ?? AUTHORITIES[environment]
460
+ });
461
+ } catch {
462
+ console.error(`apns: the ${environment} key is not a valid EC private key — environment disabled`);
463
+ }
464
+ }
465
+ const environments = [...states.keys()];
466
+ const bearerFor = (state) => {
467
+ const now = Date.now();
468
+ if (state.token !== undefined && now - state.token.issuedAt < TOKEN_REFRESH_MS)
469
+ return state.token.jwt;
470
+ const jwt = providerToken(state.key, state.keyId, config.teamId, Math.floor(now / 1000));
471
+ state.token = { jwt, issuedAt: now };
472
+ return jwt;
473
+ };
474
+ const sessionFor = (environment, state) => {
475
+ const existing = state.session;
476
+ if (existing !== undefined && !existing.destroyed && !existing.closed)
477
+ return existing;
478
+ const session = connect(state.authority);
479
+ const drop = () => {
480
+ if (state.session === session)
481
+ state.session = undefined;
482
+ if (!session.destroyed)
483
+ session.destroy();
484
+ };
485
+ session.once("goaway", drop);
486
+ session.once("error", drop);
487
+ session.once("close", () => {
488
+ if (state.session === session)
489
+ state.session = undefined;
490
+ });
491
+ state.session = session;
492
+ return session;
493
+ };
494
+ const payloadFor = (notification) => JSON.stringify({
495
+ aps: {
496
+ alert: { title: notification.title, body: notification.body },
497
+ sound: "default",
498
+ "thread-id": notification.collapseId
499
+ },
500
+ hostKey: notification.hostKey,
501
+ conversationId: notification.conversationId
502
+ });
503
+ const classify = (environment, status, headers, body, state) => {
504
+ const reason = typeof body?.reason === "string" ? body.reason : `HTTP ${status}`;
505
+ const timestamp = body?.timestamp;
506
+ if (status === 200)
507
+ return { result: "accepted", reason: "ok" };
508
+ if (status === 410) {
509
+ return {
510
+ result: "unregistered",
511
+ reason,
512
+ unregisteredAt: typeof timestamp === "number" ? timestamp : Date.now()
513
+ };
514
+ }
515
+ if (status === 403 && reason === "ExpiredProviderToken") {
516
+ state.token = undefined;
517
+ return { result: "retry", reason };
518
+ }
519
+ if (reason === "InvalidProviderToken" || reason === "TooManyProviderTokenUpdates") {
520
+ console.error(`apns: provider token problem for ${environment}: ${reason}`);
521
+ return { result: "rejected", reason };
522
+ }
523
+ if (status === 429) {
524
+ const retryAfter = Number(headers["retry-after"]);
525
+ return {
526
+ result: "retry",
527
+ reason,
528
+ ...Number.isFinite(retryAfter) && retryAfter > 0 ? { retryAfterMs: retryAfter * 1000 } : {}
529
+ };
530
+ }
531
+ if (status >= 500)
532
+ return { result: "retry", reason, retryAfterMs: SERVER_ERROR_RETRY_MS };
533
+ return { result: "rejected", reason };
534
+ };
535
+ return {
536
+ environments,
537
+ async send(request) {
538
+ const state = states.get(request.environment);
539
+ if (state === undefined)
540
+ return { result: "unavailable", reason: "environment not configured" };
541
+ const body = payloadFor(request.notification);
542
+ if (Buffer.byteLength(body, "utf8") > PAYLOAD_MAX_BYTES) {
543
+ return { result: "rejected", reason: "PayloadTooLarge" };
544
+ }
545
+ return await new Promise((resolve) => {
546
+ let session;
547
+ try {
548
+ session = sessionFor(request.environment, state);
549
+ } catch {
550
+ resolve({ result: "retry", reason: "session unavailable" });
551
+ return;
552
+ }
553
+ let stream;
554
+ try {
555
+ stream = session.request({
556
+ ":method": "POST",
557
+ ":path": `/3/device/${request.token}`,
558
+ authorization: `bearer ${bearerFor(state)}`,
559
+ "apns-push-type": "alert",
560
+ "apns-topic": config.topic,
561
+ "apns-expiration": String(Math.floor(Date.now() / 1000) + EXPIRATION_SECONDS),
562
+ "apns-collapse-id": request.notification.collapseId,
563
+ "content-type": "application/json"
564
+ });
565
+ } catch {
566
+ resolve({ result: "retry", reason: "session unavailable" });
567
+ return;
568
+ }
569
+ let status = 0;
570
+ let responseHeaders = {};
571
+ const chunks = [];
572
+ let settled = false;
573
+ const finish = (verdict) => {
574
+ if (settled)
575
+ return;
576
+ settled = true;
577
+ clearTimeout(timer);
578
+ stream.destroy();
579
+ resolve(verdict);
580
+ };
581
+ const timer = setTimeout(() => finish({ result: "retry", reason: "apns timeout" }), REQUEST_TIMEOUT_MS);
582
+ stream.on("response", (headers) => {
583
+ status = Number(headers[":status"] ?? 0);
584
+ responseHeaders = headers;
585
+ });
586
+ stream.on("data", (chunk) => chunks.push(chunk));
587
+ stream.on("end", () => {
588
+ let body2;
589
+ try {
590
+ body2 = JSON.parse(Buffer.concat(chunks).toString("utf8"));
591
+ } catch {
592
+ body2 = undefined;
593
+ }
594
+ finish(classify(request.environment, status, responseHeaders, body2, state));
595
+ });
596
+ stream.on("error", () => finish({ result: "retry", reason: "connection error" }));
597
+ stream.on("aborted", () => finish({ result: "retry", reason: "connection aborted" }));
598
+ stream.end(body);
599
+ });
600
+ },
601
+ close() {
602
+ for (const state of states.values())
603
+ state.session?.destroy();
604
+ states.clear();
605
+ return Promise.resolve();
606
+ }
607
+ };
608
+ }
609
+
610
+ // apps/server/src/relay.ts
398
611
  import { randomBytes as randomBytes2 } from "node:crypto";
399
612
  import { once } from "node:events";
400
613
  import { createServer } from "node:http";
401
614
  import { WebSocket, WebSocketServer } from "ws";
402
615
 
403
- // apps/relay/src/api.ts
616
+ // apps/server/src/api.ts
404
617
  var SESSION_COOKIE = "__Host-pinomad-session";
405
618
  var MAX_BODY = 8 * 1024;
406
619
  var MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
@@ -635,7 +848,7 @@ async function handleApiRequest(request, response, ctx) {
635
848
  deny(404, "not_found");
636
849
  }
637
850
 
638
- // apps/relay/src/relay.ts
851
+ // apps/server/src/relay.ts
639
852
  var MAX_PAYLOAD = 128 * 1024;
640
853
  var MAX_BUFFERED = 64 * 1024;
641
854
  var HIGH_WATER = 1024 * 1024;
@@ -647,11 +860,12 @@ var isSendableCloseCode = (code) => code >= 1000 && code <= 1003 || code >= 1007
647
860
  var rawLength = (data) => Array.isArray(data) ? data.reduce((sum, part) => sum + part.length, 0) : data.byteLength;
648
861
  var MAX_PAIRING_PER_HOST = 4;
649
862
  var MAX_PAIRING_PER_SESSION = 2;
863
+ var MAX_PUSH_PER_HOST = 4;
864
+ var MAX_PUSH_TOTAL = 32;
650
865
  async function startRelay(options) {
651
866
  const publicOrigin = options.publicOrigin.replace(/\/+$/, "");
652
867
  if (publicOrigin === "")
653
868
  throw new Error("publicOrigin is required");
654
- const allowedHosts = new Set(options.allowedHosts);
655
869
  const acceptTimeoutMs = options.acceptTimeoutMs ?? 1e4;
656
870
  const registerTimeoutMs = options.registerTimeoutMs ?? 1e4;
657
871
  const pingIntervalMs = options.pingIntervalMs ?? 30000;
@@ -659,6 +873,12 @@ async function startRelay(options) {
659
873
  const accounts = options.accounts;
660
874
  const trustProxy = options.trustProxy ?? false;
661
875
  const pairingTimeoutMs = options.pairingTimeoutMs ?? 1e4;
876
+ const apns = options.push;
877
+ const pushEnvironments = apns?.environments ?? [];
878
+ const pushPerHost = options.pushLimits?.perHost ?? MAX_PUSH_PER_HOST;
879
+ const pushTotal = options.pushLimits?.total ?? MAX_PUSH_TOTAL;
880
+ let pushInflightTotal = 0;
881
+ const pushInflightHosts = new Map;
662
882
  const controls = new Map;
663
883
  const pending = new Map;
664
884
  const pairingRequests = new Map;
@@ -732,6 +952,45 @@ async function startRelay(options) {
732
952
  });
733
953
  }
734
954
  const pairing = { status: pairingStatus, request: requestPairing };
955
+ function handlePush(socket, hostId, frame) {
956
+ const startedAt = Date.now();
957
+ const finish = (verdict) => {
958
+ console.log(JSON.stringify({
959
+ event: "push",
960
+ hostId,
961
+ environment: frame.environment,
962
+ result: verdict.result,
963
+ reason: verdict.reason,
964
+ ms: Date.now() - startedAt
965
+ }));
966
+ if (socket.readyState === WebSocket.OPEN) {
967
+ socket.send(encodeRelayFrame({ t: "push-result", requestId: frame.requestId, ...verdict }));
968
+ }
969
+ };
970
+ if (accounts?.hasHost(hostId) !== true) {
971
+ finish({ result: "rejected", reason: "unauthorized" });
972
+ return;
973
+ }
974
+ if (apns === undefined || !pushEnvironments.includes(frame.environment)) {
975
+ finish({ result: "unavailable", reason: "environment not configured" });
976
+ return;
977
+ }
978
+ const perHost = pushInflightHosts.get(hostId) ?? 0;
979
+ if (perHost >= pushPerHost || pushInflightTotal >= pushTotal) {
980
+ finish({ result: "retry", reason: "rate_limited" });
981
+ return;
982
+ }
983
+ pushInflightTotal += 1;
984
+ pushInflightHosts.set(hostId, perHost + 1);
985
+ apns.send({ token: frame.token, environment: frame.environment, notification: frame.notification }).then(finish, () => finish({ result: "retry", reason: "server error" })).finally(() => {
986
+ pushInflightTotal -= 1;
987
+ const left = (pushInflightHosts.get(hostId) ?? 1) - 1;
988
+ if (left <= 0)
989
+ pushInflightHosts.delete(hostId);
990
+ else
991
+ pushInflightHosts.set(hostId, left);
992
+ });
993
+ }
735
994
  function runOp(socket, frame, noisePublicKey) {
736
995
  const answer = (reply) => socket.send(encodeRelayFrame(reply), () => socket.close(1000));
737
996
  if (accounts === undefined) {
@@ -763,8 +1022,7 @@ async function startRelay(options) {
763
1022
  return;
764
1023
  }
765
1024
  answer({ t: "left" });
766
- if (!allowedHosts.has(frame.hostId))
767
- controls.get(frame.hostId)?.socket.close(RELAY_CLOSE.hostNotAllowed);
1025
+ controls.get(frame.hostId)?.socket.close(RELAY_CLOSE.hostNotAllowed);
768
1026
  }
769
1027
  function onControl(socket) {
770
1028
  track(socket);
@@ -785,6 +1043,15 @@ async function startRelay(options) {
785
1043
  socket.close(1008, "malformed frame");
786
1044
  return;
787
1045
  }
1046
+ if (answer.t === "push") {
1047
+ handlePush(socket, hostId, answer);
1048
+ return;
1049
+ }
1050
+ if (answer.t === "name") {
1051
+ if (controls.get(hostId)?.socket === socket)
1052
+ accounts?.renameHost(hostId, answer.name);
1053
+ return;
1054
+ }
788
1055
  if (answer.t === "pairing" || answer.t === "pairing-refused") {
789
1056
  const request = pairingRequests.get(answer.requestId);
790
1057
  if (request === undefined || request.control !== socket)
@@ -826,7 +1093,7 @@ async function startRelay(options) {
826
1093
  runOp(socket, frame, noisePublicKey);
827
1094
  return;
828
1095
  }
829
- if (!allowedHosts.has(frame.hostId) && accounts?.hasHost(frame.hostId) !== true) {
1096
+ if (accounts?.hasHost(frame.hostId) !== true) {
830
1097
  socket.close(RELAY_CLOSE.hostNotAllowed);
831
1098
  return;
832
1099
  }
@@ -841,7 +1108,9 @@ async function startRelay(options) {
841
1108
  ...frame.features === undefined ? {} : { features: frame.features }
842
1109
  });
843
1110
  accounts?.markHostSeen(hostId);
844
- socket.send(encodeRelayFrame({ t: "registered" }));
1111
+ if (frame.name !== undefined)
1112
+ accounts?.renameHost(hostId, frame.name);
1113
+ socket.send(encodeRelayFrame({ t: "registered", push: pushEnvironments, features: ["name"] }));
845
1114
  });
846
1115
  socket.once("close", () => {
847
1116
  clearTimeout(deadline);
@@ -1016,11 +1285,12 @@ async function startRelay(options) {
1016
1285
  await closing;
1017
1286
  await new Promise((resolve) => httpServer.close(() => resolve()));
1018
1287
  await accounts?.close();
1288
+ await apns?.close();
1019
1289
  }
1020
1290
  };
1021
1291
  }
1022
1292
 
1023
- // apps/relay/src/main.ts
1293
+ // apps/server/src/main.ts
1024
1294
  function resolveDataDir(flag) {
1025
1295
  return resolve(flag ?? process.env.PINOMAD_SERVER_DATA_DIR ?? join2(homedir(), ".pinomad-server"));
1026
1296
  }
@@ -1045,7 +1315,7 @@ async function passwdMain(argv) {
1045
1315
  strict: true
1046
1316
  });
1047
1317
  if (process.stdin.isTTY) {
1048
- console.error("pipe the new password on stdin, e.g. `printenv NEW_PINOMAD_SERVER_PASSWORD | " + commandHint("relay") + " passwd`");
1318
+ console.error("pipe the new password on stdin, e.g. `printenv NEW_PINOMAD_SERVER_PASSWORD | " + commandHint("server") + " passwd`");
1049
1319
  process.exit(1);
1050
1320
  }
1051
1321
  const dataDir = resolveDataDir(values["data-dir"]);
@@ -1067,44 +1337,40 @@ async function main(argv) {
1067
1337
  port: { type: "string", default: "7430" },
1068
1338
  listen: { type: "string", default: "127.0.0.1" },
1069
1339
  "public-origin": { type: "string" },
1070
- "allow-host": { type: "string", multiple: true },
1071
1340
  "data-dir": { type: "string" },
1072
1341
  "trust-proxy": { type: "boolean", default: false }
1073
1342
  }
1074
1343
  });
1075
- const allowedHosts = values["allow-host"] ?? [];
1076
- const bootstrapUser = process.env.PINOMAD_SERVER_USER;
1077
- const bootstrapPassword = process.env.PINOMAD_SERVER_PASSWORD;
1078
- const accountMode = allowedHosts.length === 0 || values["data-dir"] !== undefined || process.env.PINOMAD_SERVER_DATA_DIR !== undefined || bootstrapUser !== undefined || bootstrapPassword !== undefined;
1079
1344
  if (values["public-origin"] === undefined) {
1080
- console.error(`usage: ${commandHint("relay")} --public-origin ORIGIN [--allow-host HOST_ID ...] [--data-dir DIR] [--trust-proxy] [--port 7430] [--listen 127.0.0.1]
1081
- ` + ` ${commandHint("relay")} passwd [--data-dir DIR] [--user NAME] (server stopped; new password on stdin)
1345
+ console.error(`usage: ${commandHint("server")} --public-origin ORIGIN [--data-dir DIR] [--trust-proxy] [--port 7430] [--listen 127.0.0.1]
1346
+ ` + ` ${commandHint("server")} passwd [--data-dir DIR] [--user NAME] (server stopped; new password on stdin)
1082
1347
  ` + " env: PINOMAD_SERVER_USER, PINOMAD_SERVER_PASSWORD, PINOMAD_SERVER_DATA_DIR");
1083
1348
  process.exit(1);
1084
1349
  }
1085
- const accounts = accountMode ? await openAccounts({
1350
+ const accounts = await openAccounts({
1086
1351
  dataDir: resolveDataDir(values["data-dir"]),
1087
- bootstrapUser,
1088
- bootstrapPassword
1352
+ bootstrapUser: process.env.PINOMAD_SERVER_USER,
1353
+ bootstrapPassword: process.env.PINOMAD_SERVER_PASSWORD
1089
1354
  }).catch((error) => {
1090
1355
  console.error(error instanceof Error ? error.message : String(error));
1091
1356
  process.exit(1);
1092
- }) : undefined;
1357
+ });
1358
+ const apnsConfig = loadApnsConfig();
1359
+ const apns = apnsConfig === undefined ? undefined : openApns(apnsConfig);
1093
1360
  const relay = await startRelay({
1094
1361
  port: Number(values.port),
1095
1362
  listenHost: values.listen,
1096
1363
  publicOrigin: values["public-origin"],
1097
- allowedHosts,
1098
1364
  webRoot,
1099
1365
  accounts,
1100
- trustProxy: values["trust-proxy"]
1366
+ trustProxy: values["trust-proxy"],
1367
+ ...apns === undefined ? {} : { push: apns }
1101
1368
  });
1102
1369
  console.log(JSON.stringify({
1103
1370
  event: "ready",
1104
1371
  url: relay.url,
1105
1372
  publicOrigin: values["public-origin"],
1106
- allowedHosts: allowedHosts.length,
1107
- accounts: accounts !== undefined
1373
+ push: apns?.environments ?? []
1108
1374
  }));
1109
1375
  let stopping = false;
1110
1376
  const stop = () => {
@@ -1,15 +1,13 @@
1
- import {
2
- relayWsBase
3
- } from "./chunk-knaw1a9g.js";
4
1
  import {
5
2
  RELAY_CLOSE,
6
3
  decodeRelayToHost,
7
4
  encodeRelayFrame,
8
5
  relayHostId,
6
+ relayWsBase,
9
7
  signRelayChallenge,
10
8
  signRelayChallengeV2,
11
9
  toBase64Url
12
- } from "./chunk-7gmkgyaz.js";
10
+ } from "./chunk-7qb9tq54.js";
13
11
 
14
12
  // apps/host/src/cli/server-ops.ts
15
13
  import { WebSocket } from "ws";
@@ -20,7 +18,10 @@ async function probeAccounts(origin, fetchImpl = fallbackFetch) {
20
18
  const timeout = new AbortController;
21
19
  const timer = setTimeout(() => timeout.abort(), 5000);
22
20
  try {
23
- const response = await fetchImpl(`${origin.replace(/\/+$/, "")}/api/me`, { signal: timeout.signal });
21
+ const response = await fetchImpl(`${origin.replace(/\/+$/, "")}/api/me`, {
22
+ signal: timeout.signal,
23
+ redirect: "manual"
24
+ });
24
25
  if (response.status === 401)
25
26
  return "enabled";
26
27
  if (response.status === 200) {
@@ -48,7 +49,8 @@ function isSecureServerOrigin(origin) {
48
49
  return false;
49
50
  }
50
51
  const host = url.hostname;
51
- const loopback = host === "localhost" || host.endsWith(".localhost") || host.startsWith("127.") || host === "::1" || host === "[::1]";
52
+ const ipv4Loopback = /^127(?:\.\d{1,3}){3}$/.test(host);
53
+ const loopback = host === "localhost" || host.endsWith(".localhost") || ipv4Loopback || host === "::1" || host === "[::1]";
52
54
  return url.protocol === "https:" || url.protocol === "http:" && loopback;
53
55
  }
54
56
 
@@ -118,7 +120,7 @@ async function runServerOperation(options) {
118
120
  finish(() => reject(new Error(`${origin} ignored the operation — the server is too old for accounts; upgrade it first`)));
119
121
  return;
120
122
  }
121
- if (frame.t === "incoming" || frame.t === "pairing-request")
123
+ if (frame.t === "incoming" || frame.t === "pairing-request" || frame.t === "push-result")
122
124
  return;
123
125
  finish(() => resolve(frame));
124
126
  });
@@ -0,0 +1,33 @@
1
+ // packages/protocol/src/settings.ts
2
+ var HOST_SETTING_KEY_SET = {
3
+ defaultModel: true,
4
+ defaultThinkingLevel: true,
5
+ steeringMode: true,
6
+ followUpMode: true,
7
+ acceptAccountDevices: true,
8
+ hostName: true
9
+ };
10
+ var HOST_SETTING_KEYS = Object.keys(HOST_SETTING_KEY_SET);
11
+ var THINKING_LEVEL_KEYS = { off: true, minimal: true, low: true, medium: true, high: true, xhigh: true, max: true };
12
+ var THINKING_LEVELS = Object.keys(THINKING_LEVEL_KEYS);
13
+ var isThinkingLevel = (value) => typeof value === "string" && Object.prototype.hasOwnProperty.call(THINKING_LEVEL_KEYS, value);
14
+ var isQueueMode = (value) => value === "all" || value === "one-at-a-time";
15
+ var HOST_NAME_FALLBACK = "pinomad";
16
+ var HOST_NAME_MAX_UNITS = 64;
17
+ var isHostNameControl = (code) => code < 32 || code >= 127 && code <= 159;
18
+ function cleanHostName(name) {
19
+ let cleaned = name;
20
+ for (;; ) {
21
+ const local = cleaned.endsWith(".local") ? cleaned.slice(0, -".local".length) : cleaned;
22
+ const next = [...local.trim()].filter((c) => !isHostNameControl(c.codePointAt(0))).join("").slice(0, HOST_NAME_MAX_UNITS);
23
+ if (next === cleaned)
24
+ return next;
25
+ cleaned = next;
26
+ }
27
+ }
28
+ function normalizeHostName(name) {
29
+ const cleaned = cleanHostName(name);
30
+ return cleaned === "" ? HOST_NAME_FALLBACK : cleaned;
31
+ }
32
+
33
+ export { HOST_SETTING_KEYS, THINKING_LEVELS, isThinkingLevel, isQueueMode, HOST_NAME_MAX_UNITS, cleanHostName, normalizeHostName };
package/dist/pinomad.js CHANGED
@@ -1,39 +1,53 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ isParseArgsError,
4
+ reportParseArgsError
5
+ } from "./chunk-kzbzacz9.js";
2
6
  import {
3
7
  packagedVersion
4
- } from "./chunk-fgx2sh7d.js";
8
+ } from "./chunk-c8nxym2z.js";
5
9
  import {
6
10
  __require
7
11
  } from "./chunk-df32avw6.js";
8
12
 
9
13
  // apps/cli/src/pinomad.ts
10
14
  var COMMANDS = {
11
- host: () => import("./chunk-z5pmcscx.js"),
12
- relay: () => import("./chunk-vw5yb5pd.js"),
13
- service: () => import("./chunk-prhvksh3.js"),
14
- upgrade: () => import("./chunk-17p6bfvy.js"),
15
- link: () => import("./chunk-n73t5xzw.js"),
16
- pair: () => import("./chunk-d4y99pfy.js"),
17
- "relay-id": () => import("./chunk-xrvmnymc.js"),
18
- join: () => import("./chunk-b3f0ced1.js"),
19
- leave: () => import("./chunk-xhwkx9bd.js")
15
+ host: () => import("./chunk-4btj08we.js"),
16
+ server: () => import("./chunk-wqvp1fwh.js"),
17
+ relay: () => import("./chunk-wqvp1fwh.js"),
18
+ service: () => import("./chunk-ggb1taqy.js"),
19
+ upgrade: () => import("./chunk-jn7y815x.js"),
20
+ link: () => import("./chunk-367n9je8.js"),
21
+ pair: () => import("./chunk-7s1f4z4g.js"),
22
+ join: () => import("./chunk-k2s1hfq5.js"),
23
+ leave: () => import("./chunk-3dnnaz4j.js")
20
24
  };
21
- var USAGE = `pinomad — PiNomad host, relay, and utilities
25
+ var USAGE = `pinomad — PiNomad host, server, and utilities
22
26
 
23
27
  usage: pinomad <command> [args…]
24
28
 
25
- host [host args…] run the host in the foreground
26
- relay --public-origin O [--allow-host H…] [--data-dir D] [--trust-proxy] run the server
27
- relay passwd [--data-dir D] [--user U] change the account password (stdin)
29
+ host [--data-dir DIR] [--port 7420] [--project DIR]… run the host in the foreground
30
+ host --remote-port PORT [--public-url URL] remote access, LAN (ADR-0008)
31
+ host --relay URL remote access via relay (ADR-0015)
32
+ host --faux "answer" [--faux-tps 40] no real model, for tests
33
+ server --public-origin O [--data-dir D] [--trust-proxy] run the server
34
+ server passwd [--data-dir D] [--user U] change the account password (stdin)
35
+ relay … old name of server
28
36
  service install|uninstall|status|restart|logs user-level host service
29
37
  upgrade [--force] [address flags] upgrade the package and restart the service
30
38
  link [--data-dir D|--url U …] print the browser link
31
39
  pair [--data-dir D|--url U …] print a pairing QR
32
- relay-id [--data-dir D] print this host's relay id (for --allow-host)
33
40
  join <origin> <token> [--data-dir D] join this host to a server account
34
41
  leave [--data-dir D] leave the server account
35
42
  --version, -v print the version
36
43
  `;
44
+ function usageFor(command) {
45
+ const rows = USAGE.split(`
46
+ `).filter((line) => line.startsWith(` ${command} `));
47
+ return `usage: pinomad ${command} [args…]
48
+ ${rows.join(`
49
+ `)}`;
50
+ }
37
51
  async function main(argv) {
38
52
  const [command, ...rest] = argv;
39
53
  if (command === "--version" || command === "-v") {
@@ -51,7 +65,17 @@ async function main(argv) {
51
65
  process.exitCode = 1;
52
66
  return;
53
67
  }
54
- await (await load()).main(rest);
68
+ if (rest.includes("--help") || rest.includes("-h")) {
69
+ console.log(usageFor(command));
70
+ return;
71
+ }
72
+ try {
73
+ await (await load()).main(rest);
74
+ } catch (error) {
75
+ if (!isParseArgsError(error))
76
+ throw error;
77
+ reportParseArgsError(`pinomad ${command}`, error);
78
+ }
55
79
  }
56
80
  if (__require.main == __require.module)
57
81
  await main(process.argv.slice(2));