nixamp 0.1.0 → 0.3.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 (65) hide show
  1. package/README.md +225 -0
  2. package/bin/nixamp.mjs +4 -1
  3. package/dist/accounts.d.ts +54 -0
  4. package/dist/accounts.js +160 -0
  5. package/dist/admin.d.ts +47 -0
  6. package/dist/admin.js +209 -0
  7. package/dist/broadcast.d.ts +96 -0
  8. package/dist/broadcast.js +193 -0
  9. package/dist/channels.d.ts +94 -0
  10. package/dist/channels.js +235 -0
  11. package/dist/connections.d.ts +72 -0
  12. package/dist/connections.js +128 -0
  13. package/dist/daemon.d.ts +39 -0
  14. package/dist/daemon.js +170 -0
  15. package/dist/directory.d.ts +63 -0
  16. package/dist/directory.js +111 -0
  17. package/dist/ingest.d.ts +80 -0
  18. package/dist/ingest.js +252 -0
  19. package/dist/main.js +103 -6
  20. package/dist/manage.js +30 -7
  21. package/dist/owner.d.ts +53 -0
  22. package/dist/owner.js +96 -0
  23. package/dist/paywall.d.ts +60 -0
  24. package/dist/paywall.js +162 -0
  25. package/dist/playlist.d.ts +16 -0
  26. package/dist/playlist.js +57 -2
  27. package/dist/publish.d.ts +36 -0
  28. package/dist/publish.js +90 -0
  29. package/dist/rtmp-in.d.ts +22 -0
  30. package/dist/rtmp-in.js +79 -0
  31. package/dist/server.d.ts +117 -4
  32. package/dist/server.js +923 -24
  33. package/dist/session.d.ts +29 -0
  34. package/dist/session.js +184 -0
  35. package/dist/share.d.ts +74 -0
  36. package/dist/share.js +172 -0
  37. package/dist/sources.d.ts +37 -0
  38. package/dist/sources.js +125 -0
  39. package/package.json +5 -2
  40. package/src/accounts.ts +193 -0
  41. package/src/admin.ts +243 -0
  42. package/src/broadcast.ts +264 -0
  43. package/src/channels.ts +281 -0
  44. package/src/connections.ts +158 -0
  45. package/src/daemon.ts +193 -0
  46. package/src/directory.ts +135 -0
  47. package/src/ingest.ts +297 -0
  48. package/src/main.ts +107 -6
  49. package/src/manage.ts +35 -7
  50. package/src/owner.ts +113 -0
  51. package/src/paywall.ts +198 -0
  52. package/src/playlist.ts +68 -2
  53. package/src/publish.ts +101 -0
  54. package/src/rtmp-in.ts +90 -0
  55. package/src/server.ts +1087 -23
  56. package/src/session.ts +209 -0
  57. package/src/share.ts +193 -0
  58. package/src/sources.ts +136 -0
  59. package/src/types/auth-system.d.ts +77 -0
  60. package/web/dist/assets/{index-BGKWWaIx.css → index-0wAv50Ay.css} +1 -1
  61. package/web/dist/assets/index-WYJ6R4uF.js +1 -0
  62. package/web/dist/index.html +37 -6
  63. package/web/dist/install.ps1 +214 -0
  64. package/web/dist/sw.js +3 -3
  65. package/web/dist/assets/index-Dhja5wxB.js +0 -1
package/dist/server.js CHANGED
@@ -11,11 +11,27 @@
11
11
  */
12
12
  import { createReadStream, statSync } from "node:fs";
13
13
  import { createServer as createHttpServer } from "node:http";
14
- import { networkInterfaces } from "node:os";
14
+ import { hostname } from "node:os";
15
+ import { spawn, spawnSync } from "node:child_process";
16
+ import { readFileSync } from "node:fs";
17
+ import { Connections } from "./connections.js";
18
+ import { Broadcaster, DEFAULT_ENCODER, PRESETS, redact, } from "./broadcast.js";
19
+ import { Ingest, normaliseFormat } from "./ingest.js";
20
+ import { Channels, cleanId } from "./channels.js";
21
+ import { RtmpListeners } from "./rtmp-in.js";
22
+ import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.js";
23
+ import { needsAdmin, Owner } from "./owner.js";
24
+ import { readSession } from "./session.js";
25
+ import { Directory, parseAnnouncement } from "./directory.js";
26
+ import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
27
+ import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
28
+ import { isRemote } from "./sources.js";
29
+ import { allowedForListening, elevate, firewallInUse, keyCookie, keyFrom, newKey, portCommands, reachableAddresses, scopeOf, shareLink, } from "./share.js";
15
30
  import { extname, join, normalize, resolve, sep } from "node:path";
31
+ import { fileURLToPath } from "node:url";
16
32
  import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
17
33
  import { Analyser, bandEdges, bands, decay } from "./fft.js";
18
- import { loadPlaylist } from "./playlist.js";
34
+ import { loadSource } from "./playlist.js";
19
35
  import { emptySnapshot, parseCommand, } from "./protocol.js";
20
36
  const FFT_SIZE = 2048;
21
37
  export const SERVE_BAND_COUNT = 24;
@@ -30,9 +46,24 @@ export function parseServeArgs(argv) {
30
46
  const options = {
31
47
  root: ".",
32
48
  port: Number.isInteger(fromEnv) && fromEnv > 0 && fromEnv <= 65535 ? fromEnv : DEFAULT_PORT,
33
- host: "127.0.0.1",
49
+ // Every interface, because a player nobody else can reach is not much of a
50
+ // remote. The key in the link is what makes that safe; --no-key gives up
51
+ // both at once, and --host pins it back to one address.
52
+ host: "0.0.0.0",
34
53
  web: null,
35
54
  media: true,
55
+ key: true,
56
+ openPort: false,
57
+ announce: false,
58
+ directory: false,
59
+ publish: "ask",
60
+ name: "",
61
+ x402: false,
62
+ owner: "",
63
+ ingest: false,
64
+ rtmpIn: 0,
65
+ rtmpStreams: 3,
66
+ rtmp: [],
36
67
  };
37
68
  let sawRoot = false;
38
69
  for (let i = 0; i < argv.length; i++) {
@@ -60,6 +91,58 @@ export function parseServeArgs(argv) {
60
91
  else if (arg === "--no-media") {
61
92
  options.media = false;
62
93
  }
94
+ else if (arg === "--no-key") {
95
+ options.key = false;
96
+ }
97
+ else if (arg === "--open-port") {
98
+ options.openPort = true;
99
+ }
100
+ else if (arg === "--announce") {
101
+ options.announce = true;
102
+ }
103
+ else if (arg === "--directory") {
104
+ options.directory = true;
105
+ }
106
+ else if (arg === "--publish") {
107
+ options.publish = "yes";
108
+ }
109
+ else if (arg === "--no-publish") {
110
+ options.publish = "no";
111
+ }
112
+ else if (arg === "--name") {
113
+ options.name = value();
114
+ }
115
+ else if (arg === "--owner") {
116
+ options.owner = value();
117
+ }
118
+ else if (arg === "--ingest") {
119
+ options.ingest = true;
120
+ }
121
+ else if (arg === "--rtmp-streams") {
122
+ const count = Number(value());
123
+ if (!Number.isInteger(count) || count < 1 || count > 16) {
124
+ throw new Error("nixamp serve: --rtmp-streams must be between 1 and 16");
125
+ }
126
+ options.rtmpStreams = count;
127
+ }
128
+ else if (arg === "--rtmp-in") {
129
+ const port = Number(value());
130
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
131
+ throw new Error("nixamp serve: --rtmp-in must be a port number");
132
+ }
133
+ options.rtmpIn = port;
134
+ // Listening for RTMP is accepting a live stream, so it implies --ingest.
135
+ options.ingest = true;
136
+ }
137
+ else if (arg === "--rtmp") {
138
+ options.rtmp.push(value());
139
+ }
140
+ else if (arg === "--x402") {
141
+ options.x402 = true;
142
+ }
143
+ else if (arg === "--no-x402") {
144
+ options.x402 = false;
145
+ }
63
146
  else if (arg.startsWith("-")) {
64
147
  throw new Error(`nixamp serve: unknown option ${arg}`);
65
148
  }
@@ -80,6 +163,7 @@ const TYPES = {
80
163
  // The installer, so `curl https://nixamp.com/install.sh` is readable rather
81
164
  // than a download prompt.
82
165
  ".sh": "text/x-shellscript; charset=utf-8",
166
+ ".ps1": "text/plain; charset=utf-8",
83
167
  ".svg": "image/svg+xml",
84
168
  ".png": "image/png",
85
169
  ".ico": "image/x-icon",
@@ -343,6 +427,15 @@ export class PlayerEngine {
343
427
  }
344
428
  this.listeners.clear();
345
429
  }
430
+ replace(tracks, root) {
431
+ this.stop();
432
+ this.tracks = tracks;
433
+ this.root = root;
434
+ this.state.index = 0;
435
+ this.state.position = 0;
436
+ this.state.note = "";
437
+ this.push();
438
+ }
346
439
  }
347
440
  /** An engine with no library behind it, for the hosted PWA. */
348
441
  export class EmptyEngine {
@@ -361,6 +454,7 @@ export class EmptyEngine {
361
454
  trackPath() {
362
455
  return undefined;
363
456
  }
457
+ replace() { }
364
458
  stop() { }
365
459
  }
366
460
  const CORS = {
@@ -399,23 +493,404 @@ async function readBody(request, limit = 64 * 1024) {
399
493
  * drive it with a real socket and no ffmpeg in sight.
400
494
  */
401
495
  export function createHandler(engine, options) {
496
+ const tracker = options.connections ?? new Connections();
497
+ const started = Date.now();
498
+ /** Count a request in, count its bytes, and close it out exactly once. */
499
+ const watch = (request, response, kind, track) => {
500
+ const { id } = tracker.open(request, kind, track);
501
+ const write = response.write.bind(response);
502
+ response.write = ((chunk, ...rest) => {
503
+ if (typeof chunk === "string" || chunk instanceof Uint8Array) {
504
+ tracker.add(id, typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength);
505
+ }
506
+ return write(chunk, ...rest);
507
+ });
508
+ // 'close' fires for a finished response and for a listener that walked
509
+ // away, which are the same thing as far as "is it still going" goes.
510
+ response.once("close", () => tracker.close(id));
511
+ };
402
512
  return async function handle(request, response) {
403
513
  const url = new URL(request.url ?? "/", "http://localhost");
404
514
  const path = url.pathname;
515
+ const key = options.key ?? null;
516
+ const listenKey = options.listenKey ?? null;
405
517
  if (request.method === "OPTIONS") {
406
518
  response.writeHead(204, CORS);
407
519
  response.end();
408
520
  return;
409
521
  }
522
+ // Opening a share link is what hands a browser its key. It comes back as a
523
+ // cookie, so every later fetch, EventSource and <audio src> carries it
524
+ // without the page knowing anything about keys. Either key works here, and
525
+ // which one was used decides what the browser can then do.
526
+ if (key !== null && path.startsWith("/s/")) {
527
+ const offered = decodeURIComponent(path.slice("/s/".length));
528
+ if (scopeOf(offered, key, listenKey) === null) {
529
+ json(response, 404, { error: "not found" });
530
+ return;
531
+ }
532
+ response.writeHead(302, { ...CORS, "set-cookie": keyCookie(offered), location: "/" });
533
+ response.end();
534
+ return;
535
+ }
536
+ // /api/health answers unauthenticated on purpose: it is how you check the
537
+ // port is open from another device before wondering whether the link is
538
+ // wrong, and it says nothing about the library.
539
+ if (key !== null &&
540
+ path !== "/api/health" &&
541
+ path !== "/api/directory" &&
542
+ !path.startsWith("/api/v1/auth/")) {
543
+ const scope = scopeOf(keyFrom(request, url), key, listenKey);
544
+ if (scope === null) {
545
+ json(response, 401, { error: "this nixamp needs the key from its share link" });
546
+ return;
547
+ }
548
+ if (scope === "listen" && !allowedForListening(path)) {
549
+ json(response, 403, { error: "this link can listen, not drive" });
550
+ return;
551
+ }
552
+ }
410
553
  if (path === "/api/health") {
411
554
  json(response, 200, { name: "nixamp", version: options.version, media: options.media });
412
555
  return;
413
556
  }
557
+ // --- accounts ---------------------------------------------------------
558
+ //
559
+ // Before the share-key check, because signing in is how somebody without a
560
+ // key becomes somebody with one. The API is versioned and namespaced the
561
+ // way the rest of the fleet's is.
562
+ if (path.startsWith("/api/v1/auth/") && options.accounts) {
563
+ const accounts = options.accounts;
564
+ const secure = options.secureCookies ?? false;
565
+ if (path === "/api/v1/auth/me") {
566
+ const who = await accounts.whoIs(tokenFrom(request.headers));
567
+ if (who === null) {
568
+ json(response, 401, { error: "not signed in" });
569
+ return;
570
+ }
571
+ json(response, 200, { account: who });
572
+ return;
573
+ }
574
+ if (path === "/api/v1/auth/logout") {
575
+ response.writeHead(200, {
576
+ ...CORS,
577
+ "content-type": "application/json; charset=utf-8",
578
+ "set-cookie": clearedCookie(),
579
+ });
580
+ response.end(JSON.stringify({ ok: true }));
581
+ return;
582
+ }
583
+ const signingUp = path === "/api/v1/auth/signup";
584
+ if (!signingUp && path !== "/api/v1/auth/login") {
585
+ json(response, 404, { error: "no such endpoint" });
586
+ return;
587
+ }
588
+ if (request.method !== "POST") {
589
+ json(response, 405, { error: "POST only" });
590
+ return;
591
+ }
592
+ let body;
593
+ try {
594
+ body = JSON.parse(await readBody(request));
595
+ }
596
+ catch {
597
+ json(response, 400, { error: "bad JSON" });
598
+ return;
599
+ }
600
+ const result = signingUp
601
+ ? await accounts.signUp(body.email, body.password)
602
+ : await accounts.signIn(body.email, body.password);
603
+ if (!result.ok) {
604
+ // 409 for an address that is taken, 401 for credentials that are not.
605
+ json(response, signingUp ? 409 : 401, { error: result.error });
606
+ return;
607
+ }
608
+ // The token goes back in the body for the CLI and the desktop app, and
609
+ // as a cookie for the browser, which then needs to know nothing about it.
610
+ response.writeHead(200, {
611
+ ...CORS,
612
+ "content-type": "application/json; charset=utf-8",
613
+ "set-cookie": sessionCookie(result.token, secure),
614
+ });
615
+ response.end(JSON.stringify({ account: result.account, token: result.token }));
616
+ return;
617
+ }
618
+ // The directory is public in both directions: anyone may read the list,
619
+ // and anyone running a nixamp may add themselves to it. It is answered
620
+ // before the key check, because a visitor to nixamp.com has no key and is
621
+ // exactly who it is for.
622
+ if (path === "/api/directory" && options.directory) {
623
+ if (request.method === "GET") {
624
+ json(response, 200, { streams: options.directory.list(), now: Date.now() });
625
+ return;
626
+ }
627
+ if (request.method === "POST") {
628
+ let announcement;
629
+ try {
630
+ announcement = parseAnnouncement(JSON.parse(await readBody(request)));
631
+ }
632
+ catch {
633
+ json(response, 400, { error: "bad JSON" });
634
+ return;
635
+ }
636
+ if (announcement === null) {
637
+ json(response, 422, { error: "a listing needs a name and a URL a browser can reach" });
638
+ return;
639
+ }
640
+ json(response, 200, options.directory.announce(announcement));
641
+ return;
642
+ }
643
+ if (request.method === "DELETE") {
644
+ const id = url.searchParams.get("id");
645
+ if (id)
646
+ options.directory.withdraw(id);
647
+ json(response, 200, { ok: true });
648
+ return;
649
+ }
650
+ json(response, 405, { error: "GET, POST or DELETE" });
651
+ return;
652
+ }
653
+ // Administering is a different question from listening, and it is asked
654
+ // after the share key: the control key answers both, but a listen key or a
655
+ // nixamp.com session answers only one of them.
656
+ if (options.owner && needsAdmin(path, request.method ?? "GET")) {
657
+ // A server started with --no-key has said that anyone who can reach the
658
+ // port may drive it, and prints exactly that. Locking administration to
659
+ // nobody would contradict it and leave such a server unadministrable.
660
+ const holdsControl = key === null || scopeOf(keyFrom(request, url), key, null) === "control";
661
+ const check = await options.owner.check(holdsControl, tokenFrom(request.headers));
662
+ if (path === "/api/admin") {
663
+ // Always answered, and honestly: the page has to know whether to draw
664
+ // an admin panel at all, and "no" is a real answer rather than a 403.
665
+ json(response, 200, { allowed: check.allowed, as: check.as, claimed: options.owner.claimed });
666
+ return;
667
+ }
668
+ if (!check.allowed) {
669
+ json(response, 403, {
670
+ error: options.owner.claimed
671
+ ? "sign in to nixamp.com as this server's owner, or use its control link"
672
+ : "this server has no owner signed in; use its control link",
673
+ });
674
+ return;
675
+ }
676
+ }
677
+ // After the key check: a paying listener still needs the link, and a 402
678
+ // is a worse answer than a 401 to someone who has neither.
679
+ if (options.paywall && (await options.paywall(request, response, path)))
680
+ return;
681
+ // --- several streams at once ------------------------------------------
682
+ //
683
+ // A channel is one publisher and everybody listening to them. Two or three
684
+ // devices can publish at once, each to their own channel, and a listener
685
+ // picks which to hear.
686
+ if (path === "/api/channels" && options.channels) {
687
+ json(response, 200, { channels: options.channels.list(), listeners: options.channels.listeners });
688
+ return;
689
+ }
690
+ // Publishing. Anyone with the control link may; listening to the result is
691
+ // open to whoever has the share link, like the rest of the audio.
692
+ if (path.startsWith("/api/channels/") && options.channels) {
693
+ const channels = options.channels;
694
+ const rest = path.slice("/api/channels/".length);
695
+ const [rawId, action] = rest.split("/");
696
+ const id = cleanId(rawId);
697
+ if (action === undefined && request.method === "GET") {
698
+ // Listening. The response is the fan-out target: whatever ffmpeg
699
+ // produces for this channel is written to it until one end goes away.
700
+ const detach = channels.listen(id, response);
701
+ if (detach === null) {
702
+ json(response, 404, { error: "nothing is playing on that channel" });
703
+ return;
704
+ }
705
+ watch(request, response, "stream", id);
706
+ response.writeHead(200, {
707
+ ...CORS,
708
+ "content-type": "audio/mpeg",
709
+ "cache-control": "no-store",
710
+ });
711
+ const leave = () => detach();
712
+ request.on("close", leave);
713
+ response.on("close", leave);
714
+ return;
715
+ }
716
+ if (action === undefined && request.method === "DELETE") {
717
+ // Asked once: the second call would answer false, having just stopped
718
+ // the thing it was asking about.
719
+ const stopped = channels.stop(id);
720
+ json(response, stopped ? 200 : 404, { ok: stopped });
721
+ return;
722
+ }
723
+ if (request.method !== "POST") {
724
+ json(response, 405, { error: "GET, POST or DELETE" });
725
+ return;
726
+ }
727
+ const format = normaliseFormat(url.searchParams.get("format") ?? request.headers["content-type"]);
728
+ if (format === null) {
729
+ json(response, 415, { error: "give a container ffmpeg knows: webm, ogg, mp4, mp3, wav" });
730
+ return;
731
+ }
732
+ const name = url.searchParams.get("name") ?? "";
733
+ // A chunked publisher sends many requests to one channel, so the first
734
+ // claims it and the rest feed what is already there.
735
+ if (action === "chunk") {
736
+ if (!channels.has(id) && channels.publish(id, name, format, "http") === null) {
737
+ json(response, 409, { error: "that channel is already being published to" });
738
+ return;
739
+ }
740
+ const chunks = [];
741
+ for await (const chunk of request)
742
+ chunks.push(chunk);
743
+ channels.writeTo(id, Buffer.concat(chunks));
744
+ json(response, 200, { ok: true });
745
+ return;
746
+ }
747
+ const claimed = channels.publish(id, name, format, "http");
748
+ if (claimed === null) {
749
+ json(response, 409, { error: "that channel is already being published to" });
750
+ return;
751
+ }
752
+ try {
753
+ await claimed.pump(request);
754
+ }
755
+ catch {
756
+ // A publisher that hung up is not an error worth a 500.
757
+ }
758
+ claimed.close();
759
+ json(response, 200, { ok: true, bytes: claimed.info.bytes });
760
+ return;
761
+ }
762
+ // --- streaming in ---------------------------------------------------
763
+ //
764
+ // Both shapes write into the same ffmpeg, so everything downstream cannot
765
+ // tell which one a sender used.
766
+ if (path === "/api/ingest" && options.ingest) {
767
+ if (request.method === "GET") {
768
+ json(response, 200, options.ingest.status());
769
+ return;
770
+ }
771
+ if (request.method === "DELETE") {
772
+ options.ingest.close();
773
+ json(response, 200, { ok: true });
774
+ return;
775
+ }
776
+ if (request.method !== "POST") {
777
+ json(response, 405, { error: "GET, POST or DELETE" });
778
+ return;
779
+ }
780
+ const format = normaliseFormat(url.searchParams.get("format") ?? request.headers["content-type"]);
781
+ if (format === null) {
782
+ json(response, 415, { error: "give a container ffmpeg knows: webm, ogg, mp4, mp3, wav" });
783
+ return;
784
+ }
785
+ const session = options.ingest.open(url.searchParams.get("name") ?? "", format);
786
+ if (session === null) {
787
+ // Somebody else is already broadcasting, which is a different problem
788
+ // from the request being wrong.
789
+ json(response, 409, { error: "something is already streaming in" });
790
+ return;
791
+ }
792
+ try {
793
+ await options.ingest.pump(request);
794
+ }
795
+ catch {
796
+ // A sender that hung up is not an error worth a 500.
797
+ }
798
+ options.ingest.close();
799
+ json(response, 200, { ok: true, bytes: session.bytes });
800
+ return;
801
+ }
802
+ // A browser cannot stream a request body over plain HTTP/1.1, so a phone
803
+ // sends its recording a chunk at a time instead.
804
+ if (path === "/api/ingest/chunk" && options.ingest) {
805
+ if (request.method !== "POST") {
806
+ json(response, 405, { error: "POST only" });
807
+ return;
808
+ }
809
+ if (!options.ingest.live) {
810
+ const format = normaliseFormat(url.searchParams.get("format") ?? request.headers["content-type"]);
811
+ if (format === null) {
812
+ json(response, 415, { error: "give a container ffmpeg knows: webm, ogg, mp4, mp3, wav" });
813
+ return;
814
+ }
815
+ if (options.ingest.open(url.searchParams.get("name") ?? "", format) === null) {
816
+ json(response, 409, { error: "something is already streaming in" });
817
+ return;
818
+ }
819
+ }
820
+ const chunks = [];
821
+ for await (const chunk of request)
822
+ chunks.push(chunk);
823
+ options.ingest.write(Buffer.concat(chunks));
824
+ json(response, 200, options.ingest.status());
825
+ return;
826
+ }
827
+ // --- broadcasting out -------------------------------------------------
828
+ if (path === "/api/broadcast" && options.broadcaster) {
829
+ if (request.method === "GET") {
830
+ json(response, 200, options.broadcaster.status());
831
+ return;
832
+ }
833
+ if (request.method === "DELETE") {
834
+ options.broadcaster.stop();
835
+ json(response, 200, options.broadcaster.status());
836
+ return;
837
+ }
838
+ if (request.method !== "POST") {
839
+ json(response, 405, { error: "GET, POST or DELETE" });
840
+ return;
841
+ }
842
+ let source = "";
843
+ try {
844
+ source = String(JSON.parse(await readBody(request)).source ?? "");
845
+ }
846
+ catch {
847
+ json(response, 400, { error: "bad JSON" });
848
+ return;
849
+ }
850
+ const current = engine.snapshot();
851
+ const chosen = source || engine.trackPath(current.index) || "";
852
+ if (!chosen) {
853
+ json(response, 422, { error: "nothing to broadcast" });
854
+ return;
855
+ }
856
+ const plan = options.broadcast?.() ?? { destinations: [], settings: DEFAULT_ENCODER };
857
+ const started = options.broadcaster.start({
858
+ source: chosen,
859
+ destinations: plan.destinations,
860
+ settings: plan.settings,
861
+ webAudio: false,
862
+ // Music has no picture, and RTMP platforms insist on a video track.
863
+ needsVideo: true,
864
+ });
865
+ if (!started.ok) {
866
+ json(response, 422, { error: started.error });
867
+ return;
868
+ }
869
+ json(response, 200, options.broadcaster.status());
870
+ return;
871
+ }
872
+ // Names and URLs, never a key.
873
+ if (path === "/api/broadcast/destinations" && options.broadcast) {
874
+ json(response, 200, { destinations: options.broadcast().destinations.map(redact) });
875
+ return;
876
+ }
414
877
  if (path === "/api/state") {
415
878
  json(response, 200, engine.snapshot());
416
879
  return;
417
880
  }
881
+ // Everything the admin view draws, in one request: who is connected, and
882
+ // what this server is.
883
+ if (path === "/api/connections") {
884
+ json(response, 200, {
885
+ connections: tracker.list(),
886
+ active: tracker.active,
887
+ startedAt: started,
888
+ now: Date.now(),
889
+ });
890
+ return;
891
+ }
418
892
  if (path === "/api/events") {
893
+ watch(request, response, "events", "");
419
894
  response.writeHead(200, {
420
895
  ...CORS,
421
896
  "content-type": "text/event-stream; charset=utf-8",
@@ -461,6 +936,39 @@ export function createHandler(engine, options) {
461
936
  json(response, 200, engine.snapshot());
462
937
  return;
463
938
  }
939
+ // Re-stream: hand the running server a different source. The listeners
940
+ // stay connected; what they are listening to changes under them.
941
+ if (path === "/api/source") {
942
+ if (request.method !== "POST") {
943
+ json(response, 405, { error: "POST only" });
944
+ return;
945
+ }
946
+ let source = "";
947
+ try {
948
+ source = String(JSON.parse(await readBody(request)).source ?? "");
949
+ }
950
+ catch {
951
+ json(response, 400, { error: "bad JSON" });
952
+ return;
953
+ }
954
+ if (!source) {
955
+ json(response, 400, { error: "no source given" });
956
+ return;
957
+ }
958
+ try {
959
+ const tracks = await options.load(source);
960
+ if (tracks.length === 0) {
961
+ json(response, 422, { error: `nothing to play at ${source}` });
962
+ return;
963
+ }
964
+ engine.replace(tracks, source);
965
+ json(response, 200, engine.snapshot());
966
+ }
967
+ catch (error) {
968
+ json(response, 422, { error: error.message.replace(/^nixamp: /, "") });
969
+ }
970
+ return;
971
+ }
464
972
  if (path.startsWith("/api/media/")) {
465
973
  if (!options.media) {
466
974
  json(response, 403, { error: "media streaming is off" });
@@ -472,9 +980,29 @@ export function createHandler(engine, options) {
472
980
  json(response, 404, { error: "no such track" });
473
981
  return;
474
982
  }
983
+ watch(request, response, "media", engine.snapshot().tracks[index]?.title ?? file);
475
984
  sendFile(request, response, file);
476
985
  return;
477
986
  }
987
+ // Whatever the source is, this comes back as MP3 a browser will play:
988
+ // a flac, a wma, a URL, an HLS stream. ffmpeg reads them all and we hand
989
+ // the bytes on as they arrive, so a live stream starts immediately rather
990
+ // than after it ends, which for a live stream is never.
991
+ if (path.startsWith("/api/stream/")) {
992
+ const index = Number(path.slice("/api/stream/".length));
993
+ const source = Number.isInteger(index) ? engine.trackPath(index) : undefined;
994
+ if (source === undefined) {
995
+ json(response, 404, { error: "no such track" });
996
+ return;
997
+ }
998
+ if (!options.media) {
999
+ json(response, 403, { error: "media streaming is off" });
1000
+ return;
1001
+ }
1002
+ watch(request, response, "stream", engine.snapshot().tracks[index]?.title ?? source);
1003
+ transcode(request, response, source, options.ffmpeg ?? ["ffmpeg"]);
1004
+ return;
1005
+ }
478
1006
  if (path.startsWith("/api/")) {
479
1007
  json(response, 404, { error: "no such endpoint" });
480
1008
  return;
@@ -501,6 +1029,91 @@ export function createHandler(engine, options) {
501
1029
  json(response, 404, { error: "not found" });
502
1030
  };
503
1031
  }
1032
+ /** Read a file, or null. The firewall probe asks about files it may not have. */
1033
+ function readIfPossible(path) {
1034
+ try {
1035
+ return readFileSync(path, "utf8");
1036
+ }
1037
+ catch {
1038
+ return null;
1039
+ }
1040
+ }
1041
+ /**
1042
+ * Decode anything and hand back MP3, as it is produced.
1043
+ *
1044
+ * No seeking: this is a pipe, and the length is not known until it ends. The
1045
+ * player falls back to /api/media for a local file it can seek, and uses this
1046
+ * for everything else.
1047
+ */
1048
+ function transcode(request, response, source, ffmpeg) {
1049
+ const [command, ...prefix] = ffmpeg;
1050
+ const child = spawn(command, [
1051
+ ...prefix,
1052
+ "-hide_banner",
1053
+ "-loglevel", "error",
1054
+ // Reconnect through the sort of hiccup a long stream runs into. These
1055
+ // belong to the http protocol, and ffmpeg rejects the whole command
1056
+ // when they are handed to it for a file on disk.
1057
+ ...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
1058
+ "-i", source,
1059
+ "-vn",
1060
+ "-f", "mp3",
1061
+ "-b:a", "192k",
1062
+ "-",
1063
+ ], { stdio: ["ignore", "pipe", "pipe"] });
1064
+ let failed = "";
1065
+ child.stderr.on("data", (chunk) => {
1066
+ // Keep the tail: ffmpeg says what went wrong on its last line.
1067
+ failed = (failed + chunk.toString()).slice(-2000);
1068
+ });
1069
+ let started = false;
1070
+ const begin = () => {
1071
+ if (started)
1072
+ return;
1073
+ started = true;
1074
+ response.writeHead(200, {
1075
+ ...CORS,
1076
+ "content-type": "audio/mpeg",
1077
+ "cache-control": "no-store",
1078
+ // Length is unknowable up front, and a browser is happy without it.
1079
+ "transfer-encoding": "chunked",
1080
+ });
1081
+ };
1082
+ // Wait for a first byte before promising success. ffmpeg rejects a bad option
1083
+ // or a missing input immediately, and answering 200 with nothing looks the
1084
+ // same from a player as a track that is simply silent.
1085
+ child.stdout.once("data", begin);
1086
+ // Both ends can fail: a listener closing the tab breaks the socket under the
1087
+ // pipe, and an EPIPE nobody is listening for takes the process down.
1088
+ child.stdout.on("error", () => child.kill("SIGKILL"));
1089
+ response.on("error", () => child.kill("SIGKILL"));
1090
+ child.stdout.pipe(response);
1091
+ child.on("error", (error) => {
1092
+ console.error(`nixamp: ffmpeg could not start: ${error.message}`);
1093
+ if (!response.headersSent)
1094
+ json(response, 500, { error: "ffmpeg could not start" });
1095
+ else
1096
+ response.end();
1097
+ });
1098
+ child.on("close", (code) => {
1099
+ const message = failed.trim();
1100
+ if (code !== 0 && code !== null)
1101
+ console.error(`nixamp: ffmpeg exited ${code}: ${message}`);
1102
+ if (!started) {
1103
+ // Nothing was ever produced, so the status can still tell the truth.
1104
+ json(response, 502, { error: "could not decode that source", detail: message.split("\n").pop() ?? "" });
1105
+ return;
1106
+ }
1107
+ response.end();
1108
+ });
1109
+ // A listener that closes the tab should not leave an ffmpeg decoding into
1110
+ // nothing for the rest of the album.
1111
+ const stop = () => {
1112
+ child.kill("SIGKILL");
1113
+ };
1114
+ request.on("close", stop);
1115
+ response.on("close", stop);
1116
+ }
504
1117
  function isFile(path) {
505
1118
  try {
506
1119
  return statSync(path).isFile();
@@ -559,38 +1172,264 @@ export function createServer(engine, options) {
559
1172
  });
560
1173
  });
561
1174
  }
562
- /** Where a remote on another device should point its browser. */
563
- export function addressesFor(host, port) {
564
- if (host !== "0.0.0.0" && host !== "::")
565
- return [`http://${host}:${port}`];
566
- const out = [`http://localhost:${port}`];
567
- for (const entries of Object.values(networkInterfaces())) {
568
- for (const entry of entries ?? []) {
569
- if (entry.family === "IPv4" && !entry.internal)
570
- out.push(`http://${entry.address}:${port}`);
571
- }
572
- }
573
- return out;
574
- }
575
1175
  export async function serve(argv, version = "0.1.0") {
576
1176
  const options = parseServeArgs(argv);
577
- const root = resolve(options.root);
1177
+ const root = isRemote(options.root) ? options.root : resolve(options.root);
578
1178
  const tools = detectTools();
579
- const tracks = loadPlaylist(tools, root);
1179
+ const tracks = await loadSource(tools, root);
580
1180
  const engine = tracks.length > 0
581
1181
  ? new PlayerEngine(tracks, root, tools)
582
1182
  : new EmptyEngine(`No audio files under ${root}.`);
583
1183
  const web = options.web !== null ? resolve(options.web) : defaultWebDir();
584
- const server = createServer(engine, { web, media: options.media, version });
585
- await new Promise((done) => server.listen(options.port, options.host, done));
1184
+ const key = options.key ? newKey() : null;
1185
+ // Minted whether or not it is published, so `nixamp admin` and the operator
1186
+ // both have a link they can hand out without handing over the controls.
1187
+ const listenKey = key === null ? null : newKey();
1188
+ // Configuration can arrive from the directory later, so it is a box the
1189
+ // paywall reads rather than a value it was handed once.
1190
+ let paywallConfig = { ...paywallFromEnv(), enabled: options.x402 || paywallFromEnv().enabled };
1191
+ const connections = new Connections();
1192
+ const paywall = createPaywall({
1193
+ config: () => paywallConfig,
1194
+ liveListeners: () => connections.listening,
1195
+ // The address a payer can actually reach: the public one where there is
1196
+ // one, since a quote pointing at 192.168.1.5 is one they cannot pay from.
1197
+ siteUrl: () => {
1198
+ const bound = server.address();
1199
+ const live = typeof bound === "object" && bound !== null ? bound.port : options.port;
1200
+ const reachable = reachableAddresses(options.host, live);
1201
+ return (reachable.find((a) => a.label === "on the internet") ?? reachable[0])?.url
1202
+ ?? `http://127.0.0.1:${live}`;
1203
+ },
1204
+ // The operator drives with the control key, and is not a customer.
1205
+ exempt: (request) => key !== null && scopeOf(keyFrom(request, new URL(request.url ?? "/", "http://localhost")), key, null) === "control",
1206
+ });
1207
+ const channels = new Channels({
1208
+ ffmpeg: tools.ffmpeg,
1209
+ onStart: (info) => console.log(` ${info.name} is publishing to "${info.id}" (${info.format} over ${info.via}).`),
1210
+ onEnd: (info) => console.log(` "${info.id}" stopped.`),
1211
+ });
1212
+ const destinations = parseDestinations(options.rtmp);
1213
+ const broadcaster = new Broadcaster(tools.ffmpeg);
1214
+ const ingest = options.ingest
1215
+ ? new Ingest({
1216
+ ffmpeg: tools.ffmpeg,
1217
+ sink: "pipe:1",
1218
+ onStart: (session) => console.log(` ${session.name} started streaming in (${session.format}).`),
1219
+ onEnd: (session, error) => console.log(` ${session.name} stopped streaming in${error ? `: ${error}` : ""}.`),
1220
+ })
1221
+ : undefined;
1222
+ // The account signed in on this machine owns the server it starts. That is
1223
+ // the whole claim: `nixamp login` then `nixamp serve`, and the phone in your
1224
+ // pocket can administer it from anywhere by signing in as the same person.
1225
+ const session = readSession();
1226
+ const owner = new Owner({
1227
+ ownerId: options.owner || (session?.token ? await ownerIdOf(session) : ""),
1228
+ site: session?.site ?? DEFAULT_DIRECTORY,
1229
+ });
1230
+ const server = createServer(engine, {
1231
+ web,
1232
+ media: options.media,
1233
+ owner,
1234
+ channels,
1235
+ ...(ingest ? { ingest } : {}),
1236
+ broadcaster,
1237
+ broadcast: () => ({ destinations, settings: DEFAULT_ENCODER }),
1238
+ version,
1239
+ key,
1240
+ listenKey,
1241
+ connections,
1242
+ paywall,
1243
+ ffmpeg: tools.ffmpeg,
1244
+ load: (next) => loadSource(tools, next),
1245
+ ...(options.directory ? { directory: new Directory() } : {}),
1246
+ // Accounts live where the directory lives, and only there: a nixamp on a
1247
+ // laptop has nobody to be an account of.
1248
+ ...(options.directory && process.env["DATABASE_URL"]
1249
+ ? {
1250
+ accounts: new Accounts({
1251
+ connectionString: process.env["DATABASE_URL"],
1252
+ secret: process.env["NIXAMP_JWT_SECRET"] ?? "",
1253
+ }),
1254
+ secureCookies: (process.env["NIXAMP_SITE"] ?? "").startsWith("https://"),
1255
+ }
1256
+ : {}),
1257
+ });
1258
+ // A port already in use is the most ordinary failure there is, and it
1259
+ // arrives as an unhandled 'error' event that takes the process down with a
1260
+ // stack trace nobody reads.
1261
+ await new Promise((done, fail) => {
1262
+ server.once("error", (error) => {
1263
+ fail(new Error(error.code === "EADDRINUSE"
1264
+ ? `nixamp: port ${options.port} is already in use. Pass --port to pick another.`
1265
+ : error.code === "EACCES"
1266
+ ? `nixamp: not allowed to listen on port ${options.port}. Ports below 1024 need root.`
1267
+ : `nixamp: could not listen on ${options.host}:${options.port}: ${error.message}`));
1268
+ });
1269
+ server.listen(options.port, options.host, done);
1270
+ });
586
1271
  const bound = server.address();
587
1272
  const port = typeof bound === "object" && bound !== null ? bound.port : options.port;
1273
+ const io = {
1274
+ read: readIfPossible,
1275
+ run: (command, args) => {
1276
+ const done = spawnSync(command, args, { encoding: "utf8" });
1277
+ return { status: done.status, stdout: done.stdout ?? "" };
1278
+ },
1279
+ };
1280
+ if (options.announce) {
1281
+ console.log(JSON.stringify({ nixamp: "listening", host: options.host, port, key, source: root }));
1282
+ }
588
1283
  console.log(`nixamp serve — ${tracks.length} tracks under ${root}`);
589
- for (const address of addressesFor(options.host, port))
590
- console.log(` ${address}`);
1284
+ console.log("");
1285
+ // The link, not the address. Without the key the address is a 401, so
1286
+ // printing a bare host:port would be printing something that does not work.
1287
+ const addresses = reachableAddresses(options.host, port);
1288
+ const width = Math.max(...addresses.map((a) => a.label.length));
1289
+ for (const { label, url } of addresses) {
1290
+ console.log(` ${label.padEnd(width)} ${shareLink(url, key)}`);
1291
+ }
1292
+ console.log("");
1293
+ if (key === null) {
1294
+ console.log(" No key: anyone who can reach this port can drive it and hear it.");
1295
+ }
1296
+ else {
1297
+ console.log(" Open that link once on a phone or a laptop and it stays signed in.");
1298
+ console.log(` Anything without the key gets a 401. Key: ${key}`);
1299
+ if (listenKey !== null) {
1300
+ console.log("");
1301
+ console.log(" A listen-only link, for someone you want to hear it but not drive it:");
1302
+ for (const { label, url } of addresses) {
1303
+ if (label === "here")
1304
+ continue;
1305
+ console.log(` ${shareLink(url, listenKey)}`);
1306
+ }
1307
+ }
1308
+ }
1309
+ if (addresses.some((a) => a.label === "on the internet")) {
1310
+ console.log("");
1311
+ console.log(key === null
1312
+ ? " The public address is open to anyone: --no-key means no key. --host 127.0.0.1 keeps it here."
1313
+ : " The public address works from anywhere, for anyone with the key. --host 127.0.0.1 keeps it here.");
1314
+ }
1315
+ if (!options.media)
1316
+ console.log(" Audio stays on this machine: --no-media is set.");
1317
+ if (owner.claimed) {
1318
+ console.log(` ${session?.email} can administer this from anywhere, signed in at ${session?.site}.`);
1319
+ }
1320
+ if (options.ingest)
1321
+ console.log(" Accepting a live stream in at POST /api/ingest.");
1322
+ let rtmp = null;
1323
+ if (options.rtmpIn > 0) {
1324
+ const publish = addresses.find((a) => a.label !== "here") ?? addresses[0];
1325
+ const host = publish ? new URL(publish.url).hostname : "127.0.0.1";
1326
+ // One listener per stream, because ffmpeg's RTMP listener serves a single
1327
+ // connection per process. Three devices going live at once is three ports.
1328
+ const slots = Array.from({ length: options.rtmpStreams }, (_, i) => ({
1329
+ port: options.rtmpIn + i,
1330
+ id: i === 0 ? "live" : `live-${i + 1}`,
1331
+ }));
1332
+ rtmp = new RtmpListeners(channels, tools.ffmpeg, listenKey ?? "live");
1333
+ rtmp.listen(slots);
1334
+ console.log(" Or publish from OBS, Larix or ffmpeg, one per URL:");
1335
+ for (const slot of slots) {
1336
+ console.log(` rtmp://${host}:${slot.port}/live/${listenKey ?? "live"} -> "${slot.id}"`);
1337
+ }
1338
+ }
1339
+ if (destinations.length > 0) {
1340
+ console.log(` Ready to broadcast to ${destinations.map((d) => d.name).join(", ")}.`);
1341
+ }
591
1342
  if (web === null)
592
- console.log(" (no built PWA found — run `bun run web:build` to serve one)");
1343
+ console.log(" No built PWA found, so / has nothing to serve: run `bun run web:build`.");
1344
+ // Listening on every interface proves the socket is open here and nothing
1345
+ // about the path between here and the phone.
1346
+ const listening = options.host === "0.0.0.0" || options.host === "::";
1347
+ const firewall = listening ? firewallInUse(io) : null;
1348
+ let closePort = null;
1349
+ if (firewall !== null) {
1350
+ const { open, close } = portCommands(firewall, port);
1351
+ if (!options.openPort) {
1352
+ console.log("");
1353
+ console.log(` ${firewall} is running, so other devices cannot reach this port yet:`);
1354
+ console.log(` sudo ${open.join(" ")}`);
1355
+ console.log(" or start with --open-port and nixamp will do it, and undo it on exit.");
1356
+ }
1357
+ else {
1358
+ const elevated = elevate(io, open);
1359
+ if (elevated === null) {
1360
+ console.log("");
1361
+ console.log(` --open-port needs root or passwordless sudo. Run this yourself:`);
1362
+ console.log(` sudo ${open.join(" ")}`);
1363
+ }
1364
+ else {
1365
+ const done = spawnSync(elevated[0], elevated.slice(1), { encoding: "utf8" });
1366
+ if (done.status === 0) {
1367
+ console.log("");
1368
+ console.log(` Opened ${port}/tcp in ${firewall}. It closes again when this exits.`);
1369
+ // Leave the machine as it was found. A player should not be the
1370
+ // reason a port is still open next week.
1371
+ closePort = () => {
1372
+ const undo = elevate(io, close);
1373
+ if (undo)
1374
+ spawnSync(undo[0], undo.slice(1), { stdio: "ignore" });
1375
+ };
1376
+ }
1377
+ else {
1378
+ console.log("");
1379
+ console.log(` Could not open the port: ${(done.stderr || done.stdout || "").trim() || "unknown error"}`);
1380
+ }
1381
+ }
1382
+ }
1383
+ }
1384
+ // The listing carries the listen link, and only ever a public address: an
1385
+ // entry pointing at 192.168.1.5 is one nobody outside that house can open.
1386
+ const publishable_ = addresses.find((a) => a.label === "on the internet")
1387
+ ?? addresses.find((a) => a.label === "on tailscale");
1388
+ let publisher = null;
1389
+ if (options.publish !== "no" && publishable_) {
1390
+ const listen = shareLink(publishable_.url, listenKey);
1391
+ const wanted = options.publish === "yes"
1392
+ ? true
1393
+ : await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
1394
+ if (wanted) {
1395
+ publisher = new Publisher({
1396
+ directory: DEFAULT_DIRECTORY,
1397
+ name: options.name || hostname(),
1398
+ url: listen,
1399
+ tracks: tracks.length,
1400
+ nowPlaying: () => {
1401
+ const snapshot = engine.snapshot();
1402
+ return snapshot.tracks[snapshot.index]?.title ?? "";
1403
+ },
1404
+ onConfig: (remote) => {
1405
+ const next = applyRemoteConfig(paywallConfig, remote?.x402);
1406
+ if (JSON.stringify(next) === JSON.stringify(paywallConfig))
1407
+ return;
1408
+ paywallConfig = next;
1409
+ console.log(next.enabled
1410
+ ? ` nixamp.com turned paid listening on: $${(next.priceCents / 100).toFixed(2)} for ${next.passMinutes} minutes, over ${FREE_LISTENERS} listeners.`
1411
+ : " nixamp.com turned paid listening off.");
1412
+ },
1413
+ });
1414
+ const listing = await publisher.start();
1415
+ console.log("");
1416
+ console.log(listing
1417
+ ? ` Listed at ${DEFAULT_DIRECTORY}/directory as "${listing.name}". It leaves the list when this stops.`
1418
+ : ` Could not reach ${DEFAULT_DIRECTORY}; not listed.`);
1419
+ }
1420
+ }
1421
+ else if (options.publish === "yes" && !publishable_) {
1422
+ console.log("");
1423
+ console.log(" --publish needs an address the world can reach. This machine has none.");
1424
+ }
593
1425
  const shutdown = () => {
1426
+ rtmp?.stop();
1427
+ channels.stopAll();
1428
+ ingest?.stopRtmp();
1429
+ ingest?.close();
1430
+ broadcaster.stop();
1431
+ void publisher?.stop();
1432
+ closePort?.();
594
1433
  engine.stop();
595
1434
  server.close(() => process.exit(0));
596
1435
  // A hung keep-alive should not outlive a ctrl-c.
@@ -599,12 +1438,72 @@ export async function serve(argv, version = "0.1.0") {
599
1438
  process.on("SIGINT", shutdown);
600
1439
  process.on("SIGTERM", shutdown);
601
1440
  }
1441
+ /**
1442
+ * `--rtmp youtube=<key>` or `--rtmp name=rtmp://host/app/key`.
1443
+ *
1444
+ * A key is a password, so it is taken from the command line or the environment
1445
+ * and never from a request: a client that could name its own destination could
1446
+ * point your broadcast at itself.
1447
+ */
1448
+ export function parseDestinations(specs) {
1449
+ const out = [];
1450
+ for (const [index, spec] of specs.entries()) {
1451
+ const at = spec.indexOf("=");
1452
+ if (at <= 0)
1453
+ continue;
1454
+ const name = spec.slice(0, at).trim();
1455
+ const rest = spec.slice(at + 1).trim();
1456
+ if (!name || !rest)
1457
+ continue;
1458
+ const preset = PRESETS[name.toLowerCase()];
1459
+ if (preset && !/^rtmps?:\/\//i.test(rest)) {
1460
+ out.push({ id: String(index + 1), name, url: preset, key: rest, enabled: true });
1461
+ continue;
1462
+ }
1463
+ if (!/^rtmps?:\/\//i.test(rest))
1464
+ continue;
1465
+ // A full URL: the last path segment is the key.
1466
+ const cut = rest.lastIndexOf("/");
1467
+ if (cut <= "rtmp://".length)
1468
+ continue;
1469
+ out.push({
1470
+ id: String(index + 1),
1471
+ name,
1472
+ url: rest.slice(0, cut),
1473
+ key: rest.slice(cut + 1),
1474
+ enabled: true,
1475
+ });
1476
+ }
1477
+ return out;
1478
+ }
1479
+ /**
1480
+ * Which account the signed-in session belongs to. Asked once at startup rather
1481
+ * than trusted from the file: a token that nixamp.com no longer accepts should
1482
+ * not confer ownership of anything.
1483
+ */
1484
+ async function ownerIdOf(session) {
1485
+ try {
1486
+ const answer = await fetch(`${session.site}/api/v1/auth/me`, {
1487
+ headers: { authorization: `Bearer ${session.token}` },
1488
+ });
1489
+ if (!answer.ok)
1490
+ return "";
1491
+ const body = (await answer.json());
1492
+ return typeof body.account?.id === "string" ? body.account.id : "";
1493
+ }
1494
+ catch {
1495
+ // Offline at startup means no remote administration until a restart, and
1496
+ // the control key still works. Better than claiming an owner we cannot
1497
+ // check.
1498
+ return "";
1499
+ }
1500
+ }
602
1501
  /** The built PWA, when it is sitting next to us in the same install. */
603
1502
  function defaultWebDir() {
604
1503
  const fromEnv = process.env.NIXAMP_WEB_DIR;
605
1504
  if (fromEnv && isFile(join(fromEnv, "index.html")))
606
1505
  return fromEnv;
607
- const here = new URL(".", import.meta.url).pathname;
1506
+ const here = fileURLToPath(new URL(".", import.meta.url));
608
1507
  for (const guess of [
609
1508
  join(here, "..", "web", "dist"),
610
1509
  join(here, "..", "..", "web", "dist"),