nixamp 0.2.0 → 0.4.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 (69) hide show
  1. package/README.md +171 -0
  2. package/dist/accounts.d.ts +54 -0
  3. package/dist/accounts.js +160 -0
  4. package/dist/broadcast.d.ts +96 -0
  5. package/dist/broadcast.js +193 -0
  6. package/dist/channels.d.ts +94 -0
  7. package/dist/channels.js +235 -0
  8. package/dist/connections.d.ts +6 -0
  9. package/dist/connections.js +13 -0
  10. package/dist/directory.d.ts +186 -0
  11. package/dist/directory.js +275 -0
  12. package/dist/durable.d.ts +70 -0
  13. package/dist/durable.js +156 -0
  14. package/dist/follows.d.ts +92 -0
  15. package/dist/follows.js +248 -0
  16. package/dist/ingest.d.ts +80 -0
  17. package/dist/ingest.js +252 -0
  18. package/dist/main.js +21 -0
  19. package/dist/manage.js +2 -1
  20. package/dist/notify.d.ts +83 -0
  21. package/dist/notify.js +126 -0
  22. package/dist/optin.d.ts +37 -0
  23. package/dist/optin.js +122 -0
  24. package/dist/owner.d.ts +53 -0
  25. package/dist/owner.js +96 -0
  26. package/dist/partyline.d.ts +259 -0
  27. package/dist/partyline.js +616 -0
  28. package/dist/paywall.d.ts +60 -0
  29. package/dist/paywall.js +162 -0
  30. package/dist/playlist.js +5 -0
  31. package/dist/publish.d.ts +57 -0
  32. package/dist/publish.js +106 -0
  33. package/dist/rtmp-in.d.ts +22 -0
  34. package/dist/rtmp-in.js +79 -0
  35. package/dist/server.d.ts +94 -0
  36. package/dist/server.js +1158 -12
  37. package/dist/session.d.ts +29 -0
  38. package/dist/session.js +184 -0
  39. package/dist/share.d.ts +26 -0
  40. package/dist/share.js +31 -0
  41. package/package.json +8 -2
  42. package/src/accounts.ts +193 -0
  43. package/src/broadcast.ts +264 -0
  44. package/src/channels.ts +281 -0
  45. package/src/connections.ts +13 -0
  46. package/src/directory.ts +362 -0
  47. package/src/durable.ts +215 -0
  48. package/src/follows.ts +307 -0
  49. package/src/ingest.ts +297 -0
  50. package/src/main.ts +21 -0
  51. package/src/manage.ts +2 -1
  52. package/src/notify.ts +217 -0
  53. package/src/optin.ts +128 -0
  54. package/src/owner.ts +113 -0
  55. package/src/partyline.ts +742 -0
  56. package/src/paywall.ts +198 -0
  57. package/src/playlist.ts +5 -0
  58. package/src/publish.ts +137 -0
  59. package/src/rtmp-in.ts +90 -0
  60. package/src/server.ts +1304 -12
  61. package/src/session.ts +209 -0
  62. package/src/share.ts +40 -0
  63. package/src/types/auth-system.d.ts +77 -0
  64. package/web/dist/assets/{index-BGKWWaIx.css → index-DSIDSSPF.css} +1 -1
  65. package/web/dist/assets/index-qRguFskX.js +1 -0
  66. package/web/dist/index.html +62 -6
  67. package/web/dist/install.sh +82 -0
  68. package/web/dist/sw.js +45 -3
  69. package/web/dist/assets/index-Dhja5wxB.js +0 -1
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Telling followers a broadcaster went live, wherever they are.
3
+ *
4
+ * Three channels, because "any device" is not one thing. A browser that has
5
+ * granted permission gets a push -- which is also how the desktop app and a
6
+ * phone with the PWA installed hear about it, since all three are the same
7
+ * subscription under different chrome. An inbox gets mail. A handset that gave
8
+ * us a number gets a text.
9
+ *
10
+ * Every channel is optional at both ends. The operator may configure none of
11
+ * them, in which case nothing is sent and nothing throws; and a follower may
12
+ * want mail but not texts, which is the default because a text is the most
13
+ * intrusive of the three and the one you should have to ask for.
14
+ *
15
+ * Nothing here retries. A missed "so-and-so is live" is worth very little an
16
+ * hour later, and a retry queue for a message with that shelf life is machinery
17
+ * that will outlive its usefulness. What it does do is notice a push endpoint
18
+ * the browser vendor has retired, and say so, so the row can be dropped rather
19
+ * than pushed at forever.
20
+ */
21
+ import type { PushTarget, Reachable } from "./follows.ts";
22
+ import type { Sms } from "./partyline.ts";
23
+ export interface Notification {
24
+ /** "Chovy is live" */
25
+ title: string;
26
+ /** "Playing Top Gun: Maverick. Call 408-357-2326 and key 482917." */
27
+ body: string;
28
+ /** Where a click should land. */
29
+ url: string;
30
+ }
31
+ /** What happened to one push. `gone` means the subscription should be dropped. */
32
+ export type PushResult = "sent" | "gone" | "failed";
33
+ export interface NotifyChannels {
34
+ email?: (to: string, note: Notification) => Promise<boolean>;
35
+ sms?: Sms;
36
+ push?: (target: PushTarget, note: Notification) => Promise<PushResult>;
37
+ /** Called with an endpoint the vendor has retired, so it can be forgotten. */
38
+ onGone?: (endpoint: string) => Promise<void>;
39
+ onEvent?: (message: string) => void;
40
+ }
41
+ export interface NotifyReport {
42
+ email: number;
43
+ sms: number;
44
+ push: number;
45
+ dropped: number;
46
+ }
47
+ /**
48
+ * Tell an audience, on every channel each of them wants.
49
+ *
50
+ * Sent in parallel across people and channels. A thousand followers is a
51
+ * thousand independent HTTP calls, and doing them in sequence would mean the
52
+ * last person hears about a stream that has already finished.
53
+ */
54
+ export declare function notifyAll(audience: readonly Reachable[], note: Notification, channels: NotifyChannels): Promise<NotifyReport>;
55
+ /**
56
+ * Mail, over Resend's HTTP API.
57
+ *
58
+ * HTTP rather than SMTP so there is no connection to hold, no port to be
59
+ * blocked, and no dependency: a fetch is the whole client.
60
+ */
61
+ export declare function resendEmail({ apiKey, from, fetch, onEvent }: {
62
+ apiKey: string;
63
+ from: string;
64
+ fetch?: typeof globalThis.fetch;
65
+ onEvent?: (message: string) => void;
66
+ }): (to: string, note: Notification) => Promise<boolean>;
67
+ /**
68
+ * Web push, to a browser, a desktop app or an installed PWA.
69
+ *
70
+ * The library is imported lazily because it is only needed on the instance
71
+ * that has VAPID keys -- which is nixamp.com and nowhere else. A laptop
72
+ * running `nixamp serve` should not pay to load it.
73
+ *
74
+ * 404 and 410 mean the vendor has retired the subscription. That is not a
75
+ * failure to retry; it is a row to delete.
76
+ */
77
+ export declare function webPush({ publicKey, privateKey, subject, onEvent }: {
78
+ publicKey: string;
79
+ privateKey: string;
80
+ /** A mailto: or https: URL identifying us to the push service. */
81
+ subject: string;
82
+ onEvent?: (message: string) => void;
83
+ }): (target: PushTarget, note: Notification) => Promise<PushResult>;
package/dist/notify.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Tell an audience, on every channel each of them wants.
3
+ *
4
+ * Sent in parallel across people and channels. A thousand followers is a
5
+ * thousand independent HTTP calls, and doing them in sequence would mean the
6
+ * last person hears about a stream that has already finished.
7
+ */
8
+ export async function notifyAll(audience, note, channels) {
9
+ const report = { email: 0, sms: 0, push: 0, dropped: 0 };
10
+ const gone = [];
11
+ const jobs = [];
12
+ for (const person of audience) {
13
+ if (person.wantsEmail && person.email && channels.email) {
14
+ jobs.push(channels.email(person.email, note).then((ok) => {
15
+ if (ok)
16
+ report.email += 1;
17
+ }));
18
+ }
19
+ if (person.wantsSms && person.phone && channels.sms) {
20
+ // The text carries the same words, plus how to stop getting them --
21
+ // which is not optional on an automated message to a US number.
22
+ const text = `${note.title}. ${note.body} Reply STOP to opt out.`;
23
+ jobs.push(channels.sms.send(person.phone, text).then((ok) => {
24
+ if (ok)
25
+ report.sms += 1;
26
+ }));
27
+ }
28
+ if (person.wantsWeb && channels.push) {
29
+ for (const target of person.push) {
30
+ jobs.push(channels.push(target, note).then((result) => {
31
+ if (result === "sent")
32
+ report.push += 1;
33
+ else if (result === "gone")
34
+ gone.push(target.endpoint);
35
+ }));
36
+ }
37
+ }
38
+ }
39
+ // allSettled, not all: one bad address must not cancel everybody else's.
40
+ await Promise.allSettled(jobs);
41
+ if (channels.onGone) {
42
+ await Promise.allSettled(gone.map((endpoint) => channels.onGone(endpoint)));
43
+ report.dropped = gone.length;
44
+ }
45
+ channels.onEvent?.(` told followers: ${report.push} push, ${report.email} email, ${report.sms} sms` +
46
+ (report.dropped ? `, dropped ${report.dropped} dead subscription(s)` : ""));
47
+ return report;
48
+ }
49
+ /**
50
+ * Mail, over Resend's HTTP API.
51
+ *
52
+ * HTTP rather than SMTP so there is no connection to hold, no port to be
53
+ * blocked, and no dependency: a fetch is the whole client.
54
+ */
55
+ export function resendEmail({ apiKey, from, fetch = globalThis.fetch, onEvent }) {
56
+ return async (to, note) => {
57
+ try {
58
+ const response = await fetch("https://api.resend.com/emails", {
59
+ method: "POST",
60
+ headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
61
+ body: JSON.stringify({
62
+ from,
63
+ to: [to],
64
+ subject: note.title,
65
+ text: `${note.body}\n\n${note.url}\n\nYou are getting this because you follow them on nixamp.`,
66
+ html: `<p>${escapeHtml(note.body)}</p>` +
67
+ `<p><a href="${escapeHtml(note.url)}">${escapeHtml(note.url)}</a></p>` +
68
+ `<p style="color:#666;font-size:12px">You are getting this because you follow them on nixamp.</p>`,
69
+ }),
70
+ });
71
+ if (!response.ok) {
72
+ onEvent?.(` email to ${to} -> ${response.status}`);
73
+ return false;
74
+ }
75
+ return true;
76
+ }
77
+ catch (error) {
78
+ onEvent?.(` email to ${to} failed: ${error.message}`);
79
+ return false;
80
+ }
81
+ };
82
+ }
83
+ function escapeHtml(value) {
84
+ return value
85
+ .replace(/&/g, "&amp;")
86
+ .replace(/</g, "&lt;")
87
+ .replace(/>/g, "&gt;")
88
+ .replace(/"/g, "&quot;");
89
+ }
90
+ /**
91
+ * Web push, to a browser, a desktop app or an installed PWA.
92
+ *
93
+ * The library is imported lazily because it is only needed on the instance
94
+ * that has VAPID keys -- which is nixamp.com and nowhere else. A laptop
95
+ * running `nixamp serve` should not pay to load it.
96
+ *
97
+ * 404 and 410 mean the vendor has retired the subscription. That is not a
98
+ * failure to retry; it is a row to delete.
99
+ */
100
+ export function webPush({ publicKey, privateKey, subject, onEvent }) {
101
+ let library = null;
102
+ const load = async () => {
103
+ library ??= import("web-push").then((mod) => {
104
+ const wp = (mod["default"] ?? mod);
105
+ wp.setVapidDetails(subject, publicKey, privateKey);
106
+ return wp;
107
+ });
108
+ return library;
109
+ };
110
+ return async (target, note) => {
111
+ try {
112
+ const wp = await load();
113
+ await wp.sendNotification({ endpoint: target.endpoint, keys: { p256dh: target.p256dh, auth: target.auth } }, JSON.stringify({ title: note.title, body: note.body, url: note.url }), { TTL: 60 * 30 });
114
+ return "sent";
115
+ }
116
+ catch (error) {
117
+ const status = error.statusCode;
118
+ if (status === 404 || status === 410) {
119
+ onEvent?.(` push endpoint retired by the vendor, dropping it`);
120
+ return "gone";
121
+ }
122
+ onEvent?.(` push failed: ${status ?? error.message}`);
123
+ return "failed";
124
+ }
125
+ };
126
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The page that explains the text messages.
3
+ *
4
+ * Not decoration and not marketing. A carrier reviewing a toll-free number for
5
+ * A2P messaging asks to see where the consent comes from, and answers "a
6
+ * screenshot" -- which is awkward when the consent is somebody pressing 1 on a
7
+ * telephone and there is no screen to shoot. This page is that evidence: the
8
+ * exact prompt the caller hears, what they get, how often, and how to stop.
9
+ *
10
+ * It is also the honest thing to publish regardless of who is asking. Anyone
11
+ * who gets a text from us can find out here why, and stop it, without having
12
+ * to reply to a number they do not recognise.
13
+ *
14
+ * Served as a page of its own rather than a route in the app, because it has
15
+ * to be readable by someone with no JavaScript and no account -- a reviewer,
16
+ * or a person holding a phone that just buzzed.
17
+ */
18
+ export declare const OPT_IN_PATH = "/sms";
19
+ /**
20
+ * The number a caller dials.
21
+ *
22
+ * Local, not the toll-free one, and the reason is billing rather than taste.
23
+ * Only standard DIDs are eligible for channel billing -- a flat fee for
24
+ * unlimited inbound minutes -- while toll-free is pay-per-minute forever, at
25
+ * roughly five times the rate. On a line people stay on for hours that is the
26
+ * whole cost of the product, so the number we print is the cheap one.
27
+ *
28
+ * 888-ROOM-818 still answers, for anyone who has it. It is a vanity alias, not
29
+ * the number to publish, and it cannot reach the cheap tier at any volume.
30
+ */
31
+ export declare const CALL_IN_NUMBER = "408-357-2326";
32
+ /** The number a reminder is sent from. Not the one above; see partyline.ts. */
33
+ export declare const SMS_FROM_NUMBER = "408-426-9127";
34
+ export declare function optInPage({ callIn, smsFrom }?: {
35
+ callIn?: string | undefined;
36
+ smsFrom?: string | undefined;
37
+ }): string;
package/dist/optin.js ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The page that explains the text messages.
3
+ *
4
+ * Not decoration and not marketing. A carrier reviewing a toll-free number for
5
+ * A2P messaging asks to see where the consent comes from, and answers "a
6
+ * screenshot" -- which is awkward when the consent is somebody pressing 1 on a
7
+ * telephone and there is no screen to shoot. This page is that evidence: the
8
+ * exact prompt the caller hears, what they get, how often, and how to stop.
9
+ *
10
+ * It is also the honest thing to publish regardless of who is asking. Anyone
11
+ * who gets a text from us can find out here why, and stop it, without having
12
+ * to reply to a number they do not recognise.
13
+ *
14
+ * Served as a page of its own rather than a route in the app, because it has
15
+ * to be readable by someone with no JavaScript and no account -- a reviewer,
16
+ * or a person holding a phone that just buzzed.
17
+ */
18
+ export const OPT_IN_PATH = "/sms";
19
+ /**
20
+ * The number a caller dials.
21
+ *
22
+ * Local, not the toll-free one, and the reason is billing rather than taste.
23
+ * Only standard DIDs are eligible for channel billing -- a flat fee for
24
+ * unlimited inbound minutes -- while toll-free is pay-per-minute forever, at
25
+ * roughly five times the rate. On a line people stay on for hours that is the
26
+ * whole cost of the product, so the number we print is the cheap one.
27
+ *
28
+ * 888-ROOM-818 still answers, for anyone who has it. It is a vanity alias, not
29
+ * the number to publish, and it cannot reach the cheap tier at any volume.
30
+ */
31
+ export const CALL_IN_NUMBER = "408-357-2326";
32
+ /** The number a reminder is sent from. Not the one above; see partyline.ts. */
33
+ export const SMS_FROM_NUMBER = "408-426-9127";
34
+ export function optInPage({ callIn = CALL_IN_NUMBER, smsFrom = SMS_FROM_NUMBER } = {}) {
35
+ return `<!doctype html>
36
+ <html lang="en">
37
+ <meta charset="utf-8">
38
+ <meta name="viewport" content="width=device-width,initial-scale=1">
39
+ <title>Stream reminders by text &mdash; nixamp</title>
40
+ <style>
41
+ :root { color-scheme: light dark; }
42
+ body {
43
+ margin: 0 auto; padding: 2rem 1.25rem 4rem; max-width: 42rem;
44
+ font: 16px/1.65 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
45
+ }
46
+ h1 { font-size: 1.5rem; margin: 0 0 .25rem; }
47
+ h2 { font-size: 1.05rem; margin: 2rem 0 .5rem; }
48
+ .sub { opacity: .7; margin: 0 0 2rem; }
49
+ dt { font-weight: 600; margin-top: .9rem; }
50
+ dd { margin: .15rem 0 0; }
51
+ ol { padding-left: 1.25rem; }
52
+ li { margin: .4rem 0; }
53
+ code, .n { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
54
+ blockquote {
55
+ margin: .75rem 0; padding: .75rem 1rem; border-left: 3px solid currentColor;
56
+ opacity: .85; font-style: italic;
57
+ }
58
+ footer { margin-top: 3rem; font-size: .9rem; opacity: .7; }
59
+ </style>
60
+
61
+ <h1>Stream reminders by text</h1>
62
+ <p class="sub">What these messages are, and how to stop them.</p>
63
+
64
+ <h2>How you sign up</h2>
65
+ <p>
66
+ There is one way, and it happens on the phone. Call
67
+ <strong class="n">${callIn}</strong> and key the six-digit code of a stream.
68
+ If that stream has finished, you hear this:
69
+ </p>
70
+ <blockquote>
71
+ Welcome to &lt;name&gt;&rsquo;s live stream of &lt;what they were playing&gt;.
72
+ The live stream ended at &lt;time&gt; Pacific. Call back later when they
73
+ stream again. Press&nbsp;1 to get a text message when they do.
74
+ </blockquote>
75
+ <ol>
76
+ <li>You press <strong>1</strong>.</li>
77
+ <li>We keep the number you called from, and nothing else.</li>
78
+ <li>When that stream goes live again, you get one text.</li>
79
+ </ol>
80
+ <p>
81
+ Pressing anything else, or hanging up, signs you up for nothing. We never add
82
+ a number that did not press&nbsp;1 on that prompt.
83
+ </p>
84
+
85
+ <h2>What you get</h2>
86
+ <dl>
87
+ <dt>Message</dt>
88
+ <dd class="n">&lt;name&gt; is live now of &lt;what&gt; on nixamp. Call ${callIn} and key &lt;code&gt; to listen. Reply STOP to opt out.</dd>
89
+
90
+ <dt>How often</dt>
91
+ <dd>
92
+ Once per stream you asked about. Asking is a one-time thing: after that
93
+ text is sent you are off the list, and you would have to call and press 1
94
+ again to be told about the next one. There is no schedule and no marketing.
95
+ </dd>
96
+
97
+ <dt>Sent from</dt>
98
+ <dd class="n">${smsFrom}</dd>
99
+ </dl>
100
+
101
+ <h2>How to stop</h2>
102
+ <p>
103
+ Reply <strong>STOP</strong> to any message and you will get no more.
104
+ Reply <strong>HELP</strong> for help. You can also simply never press 1.
105
+ </p>
106
+ <p>Message and data rates may apply.</p>
107
+
108
+ <h2>What we keep</h2>
109
+ <p>
110
+ The phone number you called from, tied to the stream you asked about, until
111
+ that text is sent &mdash; then it is deleted. Nothing is sold, and nothing is
112
+ shared with anyone but the carrier that has to deliver the message.
113
+ </p>
114
+
115
+ <footer>
116
+ ProFullStack, Inc. &middot;
117
+ <a href="mailto:anthony@profullstack.com">anthony@profullstack.com</a> &middot;
118
+ <a href="/">nixamp</a>
119
+ </footer>
120
+ </html>
121
+ `;
122
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Who may administer this server.
3
+ *
4
+ * Two ways to be allowed, and they answer different questions:
5
+ *
6
+ * - You hold the control key. That is possession of the share link the server
7
+ * printed, which means you are at the machine or someone at it told you.
8
+ * - You are signed in to nixamp.com as the account that owns this server. That
9
+ * is identity, and it works from a phone on the other side of the world.
10
+ *
11
+ * The server cannot check a nixamp.com token itself -- it has no part of that
12
+ * secret, and it should not. So it asks nixamp.com who the token belongs to and
13
+ * compares the answer to the owner it recorded at startup. Delegating identity
14
+ * and keeping authorisation local is what lets a nixamp on a laptop trust an
15
+ * account it has never seen.
16
+ */
17
+ /** How long an answer from nixamp.com is trusted before asking again. */
18
+ export declare const CACHE_MS = 60000;
19
+ export interface OwnerOptions {
20
+ /** The account id that owns this server, from the CLI session at startup. */
21
+ ownerId: string;
22
+ /** Where to ask about a token. */
23
+ site: string;
24
+ fetcher?: typeof fetch;
25
+ now?: () => number;
26
+ }
27
+ export interface AdminCheck {
28
+ /** May this caller administer the server? */
29
+ allowed: boolean;
30
+ /** How they proved it, for the admin view to show. */
31
+ as: "key" | "owner" | null;
32
+ }
33
+ /**
34
+ * Ask nixamp.com who a token belongs to, and remember the answer briefly.
35
+ *
36
+ * Briefly, because an admin request should not cost a round trip to another
37
+ * host every time, and not for long, because a revoked session should stop
38
+ * working in about a minute rather than whenever the process restarts.
39
+ */
40
+ export declare class Owner {
41
+ private readonly options;
42
+ private readonly cache;
43
+ constructor(options: OwnerOptions);
44
+ get claimed(): boolean;
45
+ /** The account a token belongs to, or "" for one nixamp.com does not accept. */
46
+ accountFor(token: string): Promise<string>;
47
+ check(hasControlKey: boolean, token: string): Promise<AdminCheck>;
48
+ /** Forget everything remembered, so a sign-out takes effect at once. */
49
+ forget(): void;
50
+ }
51
+ /** Paths only an administrator may reach. */
52
+ export declare const ADMIN_PATHS: string[];
53
+ export declare function needsAdmin(path: string, method?: string): boolean;
package/dist/owner.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Who may administer this server.
3
+ *
4
+ * Two ways to be allowed, and they answer different questions:
5
+ *
6
+ * - You hold the control key. That is possession of the share link the server
7
+ * printed, which means you are at the machine or someone at it told you.
8
+ * - You are signed in to nixamp.com as the account that owns this server. That
9
+ * is identity, and it works from a phone on the other side of the world.
10
+ *
11
+ * The server cannot check a nixamp.com token itself -- it has no part of that
12
+ * secret, and it should not. So it asks nixamp.com who the token belongs to and
13
+ * compares the answer to the owner it recorded at startup. Delegating identity
14
+ * and keeping authorisation local is what lets a nixamp on a laptop trust an
15
+ * account it has never seen.
16
+ */
17
+ /** How long an answer from nixamp.com is trusted before asking again. */
18
+ export const CACHE_MS = 60_000;
19
+ /**
20
+ * Ask nixamp.com who a token belongs to, and remember the answer briefly.
21
+ *
22
+ * Briefly, because an admin request should not cost a round trip to another
23
+ * host every time, and not for long, because a revoked session should stop
24
+ * working in about a minute rather than whenever the process restarts.
25
+ */
26
+ export class Owner {
27
+ options;
28
+ cache = new Map();
29
+ constructor(options) {
30
+ this.options = options;
31
+ }
32
+ get claimed() {
33
+ return this.options.ownerId !== "";
34
+ }
35
+ /** The account a token belongs to, or "" for one nixamp.com does not accept. */
36
+ async accountFor(token) {
37
+ if (!token)
38
+ return "";
39
+ const now = (this.options.now ?? Date.now)();
40
+ const remembered = this.cache.get(token);
41
+ if (remembered && now - remembered.at < CACHE_MS)
42
+ return remembered.id;
43
+ const send = this.options.fetcher ?? fetch;
44
+ try {
45
+ const answer = await send(`${this.options.site}/api/v1/auth/me`, {
46
+ headers: { authorization: `Bearer ${token}` },
47
+ });
48
+ if (!answer.ok) {
49
+ // Remember the refusal too, or a wrong token costs a round trip on
50
+ // every request it is presented with.
51
+ this.cache.set(token, { id: "", at: now });
52
+ return "";
53
+ }
54
+ const body = (await answer.json());
55
+ const id = typeof body.account?.id === "string" ? body.account.id : "";
56
+ this.cache.set(token, { id, at: now });
57
+ return id;
58
+ }
59
+ catch {
60
+ // nixamp.com being unreachable must not turn into "everyone is the
61
+ // owner". It turns into "nobody is", and the control key still works.
62
+ return "";
63
+ }
64
+ }
65
+ async check(hasControlKey, token) {
66
+ if (hasControlKey)
67
+ return { allowed: true, as: "key" };
68
+ if (!this.claimed)
69
+ return { allowed: false, as: null };
70
+ const account = await this.accountFor(token);
71
+ return account !== "" && account === this.options.ownerId
72
+ ? { allowed: true, as: "owner" }
73
+ : { allowed: false, as: null };
74
+ }
75
+ /** Forget everything remembered, so a sign-out takes effect at once. */
76
+ forget() {
77
+ this.cache.clear();
78
+ }
79
+ }
80
+ /** Paths only an administrator may reach. */
81
+ export const ADMIN_PATHS = [
82
+ "/api/connections",
83
+ "/api/source",
84
+ "/api/broadcast",
85
+ "/api/ingest",
86
+ "/api/admin",
87
+ ];
88
+ export function needsAdmin(path, method = "GET") {
89
+ if (ADMIN_PATHS.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)))
90
+ return true;
91
+ // Publishing to a channel, or ending one, is administering the server.
92
+ // Listening to a channel is not: that is what the share link is for.
93
+ if (path.startsWith("/api/channels/") && method !== "GET")
94
+ return true;
95
+ return false;
96
+ }