nixamp 0.4.0 → 0.5.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.
package/dist/device.js ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Signing in on one screen for a session on another.
3
+ *
4
+ * This is the device authorization grant (RFC 8628), which is the flow a
5
+ * television has been using to sign you in for years: the terminal shows a
6
+ * short code, you open a page on whatever device has a keyboard and a browser,
7
+ * you type the code, and the terminal -- which was polling all along -- is
8
+ * signed in.
9
+ *
10
+ * It is the right shape for nixamp for the same reason a magic link is the
11
+ * wrong one: the thing being signed in has no browser to hand off to, and may
12
+ * not be the device where you read your mail. It also means the CLI never sees
13
+ * a password or a provider token, only the session it ends up with.
14
+ *
15
+ * Grants are held in memory. They live ten minutes, they are worth nothing
16
+ * after they are redeemed, and a restart costing somebody a retyped code is a
17
+ * better trade than a table to migrate.
18
+ */
19
+ import { randomBytes } from "node:crypto";
20
+ /** Long enough to walk to another room, short enough that a stolen code is stale. */
21
+ export const GRANT_TTL_MS = 600_000;
22
+ /** What the CLI is told to wait between polls, in seconds. */
23
+ export const POLL_INTERVAL_SECONDS = 5;
24
+ /**
25
+ * Not a timestamp, so the first poll is never mistaken for a fast one. Zero
26
+ * would be, and a clock that starts at zero is exactly what a test has.
27
+ */
28
+ const NEVER_POLLED = -1;
29
+ /**
30
+ * No vowels, so the generator cannot produce a word; no 0/O or 1/I, so nobody
31
+ * mistypes one for the other. This is the alphabet RFC 8628 suggests.
32
+ */
33
+ const ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
34
+ function randomFrom(alphabet, length, bytes) {
35
+ let out = "";
36
+ for (let index = 0; index < length; index += 1) {
37
+ out += alphabet[bytes[index] % alphabet.length];
38
+ }
39
+ return out;
40
+ }
41
+ /** `WXYZ-4RTB`. Hyphenated because it is read aloud and typed by hand. */
42
+ export function makeUserCode(random) {
43
+ const bytes = random(8);
44
+ return `${randomFrom(ALPHABET, 4, bytes.subarray(0, 4))}-${randomFrom(ALPHABET, 4, bytes.subarray(4, 8))}`;
45
+ }
46
+ /** Accept what a person typed however they typed it: lower case, no hyphen. */
47
+ export function normalizeUserCode(value) {
48
+ if (typeof value !== "string")
49
+ return "";
50
+ const bare = value.toUpperCase().replace(/[^A-Z0-9]/g, "");
51
+ if (bare.length !== 8)
52
+ return "";
53
+ return `${bare.slice(0, 4)}-${bare.slice(4)}`;
54
+ }
55
+ export class DeviceGrants {
56
+ byDevice = new Map();
57
+ byUser = new Map();
58
+ now;
59
+ random;
60
+ /** Told to the terminal, and enforced here: the two must be the same number. */
61
+ interval;
62
+ constructor(options = {}) {
63
+ this.now = options.now ?? Date.now;
64
+ this.random = options.random ?? ((size) => randomBytes(size));
65
+ this.interval = Math.max(1, options.intervalSeconds ?? POLL_INTERVAL_SECONDS);
66
+ }
67
+ start() {
68
+ this.sweep();
69
+ const at = this.now();
70
+ // A user code can collide -- there are only 20^8 of them and they are
71
+ // short-lived -- so it is retried rather than handed out twice.
72
+ let userCode = makeUserCode(this.random);
73
+ for (let tries = 0; this.byUser.has(userCode) && tries < 10; tries += 1) {
74
+ userCode = makeUserCode(this.random);
75
+ }
76
+ const grant = {
77
+ deviceCode: Buffer.from(this.random(32)).toString("base64url"),
78
+ userCode,
79
+ createdAt: at,
80
+ expiresAt: at + GRANT_TTL_MS,
81
+ lastPolledAt: NEVER_POLLED,
82
+ session: null,
83
+ denied: false,
84
+ };
85
+ this.byDevice.set(grant.deviceCode, grant);
86
+ this.byUser.set(grant.userCode, grant);
87
+ return grant;
88
+ }
89
+ /** The grant behind a code somebody typed, if it is still worth anything. */
90
+ find(userCode) {
91
+ const grant = this.byUser.get(normalizeUserCode(userCode));
92
+ if (!grant || grant.expiresAt <= this.now() || grant.session !== null || grant.denied)
93
+ return null;
94
+ return grant;
95
+ }
96
+ approve(userCode, session) {
97
+ const grant = this.find(userCode);
98
+ if (grant === null)
99
+ return false;
100
+ grant.session = session;
101
+ return true;
102
+ }
103
+ deny(userCode) {
104
+ const grant = this.find(userCode);
105
+ if (grant === null)
106
+ return false;
107
+ grant.denied = true;
108
+ return true;
109
+ }
110
+ /**
111
+ * What the waiting terminal is told. A grant is forgotten the moment it
112
+ * answers with a session, so the same device code cannot be redeemed twice.
113
+ */
114
+ poll(deviceCode) {
115
+ const at = this.now();
116
+ const grant = this.byDevice.get(deviceCode);
117
+ if (!grant || grant.expiresAt <= at) {
118
+ this.forget(grant);
119
+ return { status: "expired" };
120
+ }
121
+ // Polling faster than it was told to is answered with slow_down rather
122
+ // than an answer, which is what RFC 8628 asks of a server.
123
+ if (grant.lastPolledAt !== NEVER_POLLED && at - grant.lastPolledAt < this.interval * 1000 - 250) {
124
+ return { status: "slow_down" };
125
+ }
126
+ grant.lastPolledAt = at;
127
+ if (grant.denied) {
128
+ this.forget(grant);
129
+ return { status: "denied" };
130
+ }
131
+ if (grant.session === null)
132
+ return { status: "pending" };
133
+ this.forget(grant);
134
+ return { status: "ok", token: grant.session.token, email: grant.session.email };
135
+ }
136
+ forget(grant) {
137
+ if (!grant)
138
+ return;
139
+ this.byDevice.delete(grant.deviceCode);
140
+ this.byUser.delete(grant.userCode);
141
+ }
142
+ sweep() {
143
+ const at = this.now();
144
+ for (const grant of this.byDevice.values()) {
145
+ if (grant.expiresAt <= at)
146
+ this.forget(grant);
147
+ }
148
+ }
149
+ get size() {
150
+ return this.byDevice.size;
151
+ }
152
+ /** The grants nobody has answered yet, newest last. */
153
+ pending() {
154
+ const at = this.now();
155
+ return [...this.byDevice.values()].filter((grant) => grant.expiresAt > at && grant.session === null && !grant.denied);
156
+ }
157
+ }
package/dist/main.d.ts CHANGED
@@ -25,6 +25,18 @@ export interface State {
25
25
  export declare function createState(tracks: Track[], root: string, silent: boolean): State;
26
26
  export declare function current(state: State): Track | undefined;
27
27
  export declare function barGlyph(value: number): string;
28
+ /** `-h`, `--help`, or the word, which is what people type when they forget. */
29
+ export declare function isHelp(arg: string | undefined): boolean;
30
+ /**
31
+ * Was help asked for, given what the command already means by its flags?
32
+ *
33
+ * `serve` has had `-h HOST` since the beginning, and `daemon start` passes its
34
+ * flags straight through, so for those two `-h` is a bind address and only the
35
+ * spelled-out forms ask for help. Everywhere else `-h` is help, because that
36
+ * is what it is everywhere else.
37
+ */
38
+ export declare function wantsHelp(first: string | undefined, rest: string[]): boolean;
39
+ export declare function helpFor(topic: string | undefined): string;
28
40
  /**
29
41
  * The whole CLI, as a function. `bin/nixamp.mjs` imports and calls it: relying
30
42
  * on `import.meta.main` there would leave the installed binary doing nothing,
package/dist/main.js CHANGED
@@ -46,9 +46,11 @@ const HELP = `nixamp — it really whips the terminal's ass.
46
46
  nixamp [source] play it in the terminal
47
47
  nixamp serve [source] [options] play here, and hand out a browser remote
48
48
  nixamp daemon start|stop|status serve in the background, and let go of it
49
+ nixamp attach put the player back in front of the daemon
49
50
  nixamp admin [--url U] [--key K] who is connected, and re-stream to them
50
- nixamp login [--signup] sign in to nixamp.com
51
+ nixamp login [--with github] sign in to nixamp.com, in a browser or here
51
52
  nixamp logout / whoami forget it, or check it
53
+ nixamp token create|list|revoke tokens for a machine that cannot sign in
52
54
  nixamp update [version] re-run the installer, keeping your choices
53
55
  nixamp uninstall [--yes] remove everything the installer created
54
56
 
@@ -72,9 +74,110 @@ Options for serve:
72
74
  --x402 charge for listening once more than 5 people are listening
73
75
  --no-x402 never charge
74
76
 
77
+ Options for login:
78
+ --with NAME sign in with a provider (github, google) in a browser
79
+ --device approve in a browser, whichever way it is signed in
80
+ --password ask for an address and a password here instead
81
+ --token T keep a token made with \`nixamp token create\`
82
+ --signup make an account with an address and a password
83
+ --no-browser print the URL rather than trying to open one
84
+ --site URL somewhere other than https://nixamp.com
85
+
86
+ NIXAMP_TOKEN in the environment is a signed-in nixamp with no login at all,
87
+ which is what a build server wants.
88
+
89
+ Keys in the player:
90
+ space play/pause enter play s stop n/p next/previous up/down choose
91
+ d detach: hand the music to a daemon and get the terminal back
92
+ q quit
93
+
75
94
  -v, --version print the version
76
- --help print this
95
+ -h, --help print this. \`nixamp help <command>\` says more about one
77
96
  `;
97
+ /** `-h`, `--help`, or the word, which is what people type when they forget. */
98
+ export function isHelp(arg) {
99
+ return arg === "-h" || arg === "--help" || arg === "help";
100
+ }
101
+ /**
102
+ * Was help asked for, given what the command already means by its flags?
103
+ *
104
+ * `serve` has had `-h HOST` since the beginning, and `daemon start` passes its
105
+ * flags straight through, so for those two `-h` is a bind address and only the
106
+ * spelled-out forms ask for help. Everywhere else `-h` is help, because that
107
+ * is what it is everywhere else.
108
+ */
109
+ export function wantsHelp(first, rest) {
110
+ if (isHelp(first))
111
+ return true;
112
+ const shortIsHost = first === "serve" || first === "daemon";
113
+ return rest.some((arg) => (shortIsHost ? arg !== "-h" && isHelp(arg) : isHelp(arg)));
114
+ }
115
+ /**
116
+ * Longer help, one command at a time.
117
+ *
118
+ * The summary in HELP is a list of what exists; these say how each is used,
119
+ * which is the thing you want at the moment you ask, and the thing that makes
120
+ * the summary unreadable if it is folded in.
121
+ */
122
+ const TOPICS = {
123
+ login: `nixamp login — sign in to nixamp.com.
124
+
125
+ nixamp login choose how: a provider in a browser, or a password
126
+ nixamp login --with github go straight to a provider (github, google)
127
+ nixamp login --device approve in a browser you are already signed in to
128
+ nixamp login --password an address and a password, here in the terminal
129
+ nixamp login --token TOKEN keep a token made with \`nixamp token create\`
130
+ nixamp signup make an account with an address and a password
131
+
132
+ A provider sign-in never asks this terminal for anything secret. It shows a
133
+ short code, you approve it in a browser on whatever device has a keyboard, and
134
+ this terminal ends up holding the session. That works over ssh, and it works on
135
+ a television, which is why it is the default.
136
+
137
+ --no-browser print the URL rather than trying to open one
138
+ --site URL somewhere other than https://nixamp.com
139
+
140
+ NIXAMP_TOKEN in the environment is a signed-in nixamp with no login at all.
141
+ `,
142
+ token: `nixamp token — tokens for a machine that cannot sign in.
143
+
144
+ nixamp token create --name ci make one, and print it once
145
+ nixamp token list id, when it was made, when it was last used
146
+ nixamp token revoke ID stop it working, everywhere, now
147
+
148
+ A token is shown once because the server keeps only its hash. Put it in the
149
+ environment as NIXAMP_TOKEN, or keep it here with \`nixamp login --token\`.
150
+ Signing out does not touch it: that is what it is for.
151
+ `,
152
+ daemon: `nixamp daemon — a nixamp that outlives the terminal that started it.
153
+
154
+ nixamp daemon start [source] [serve options] start it, detached
155
+ nixamp daemon status where it is, and how long
156
+ nixamp daemon stop stop it
157
+
158
+ It is \`nixamp serve\` with nobody holding its terminal, so it keeps playing and
159
+ keeps serving its browser remote. One per user.
160
+
161
+ nixamp attach put the player back in front of it
162
+ nixamp admin who is connected, and re-stream to them
163
+
164
+ From inside the player, d hands the music to a daemon without stopping it.
165
+ `,
166
+ attach: `nixamp attach — the player, in front of the running daemon.
167
+
168
+ The same view and the same keys as the local player, except that the music is
169
+ the daemon's: keys are sent to it, and what you see is what it is doing. Any
170
+ number of terminals may attach at once.
171
+
172
+ nixamp attach the daemon on this machine
173
+ nixamp attach --url URL [--key K] a nixamp somewhere else
174
+
175
+ q or d leaves; neither stops anything. \`nixamp daemon stop\` is what stops it.
176
+ `,
177
+ };
178
+ export function helpFor(topic) {
179
+ return (topic ? TOPICS[topic] : undefined) ?? HELP;
180
+ }
78
181
  /**
79
182
  * `nixamp daemon <start|stop|status>`.
80
183
  *
@@ -127,7 +230,13 @@ async function runDaemon(argv) {
127
230
  console.log(` up ${Math.round((Date.now() - state.startedAt) / 1000)}s`);
128
231
  return 0;
129
232
  }
130
- console.error(`nixamp daemon: unknown action ${action}. Try start, stop or status.`);
233
+ // `nixamp daemon attach` is what people try before `nixamp attach`, so it is
234
+ // the same thing rather than an error about a word that means what it says.
235
+ if (action === "attach") {
236
+ const { attach } = await import("./attach.js");
237
+ return attach(rest);
238
+ }
239
+ console.error(`nixamp daemon: unknown action ${action}. Try start, stop, status or attach.`);
131
240
  return 64;
132
241
  }
133
242
  /**
@@ -137,6 +246,17 @@ async function runDaemon(argv) {
137
246
  */
138
247
  export async function main() {
139
248
  const [first, ...rest] = process.argv.slice(2);
249
+ // Asked for however anybody asks for it. `nixamp help serve` and
250
+ // `nixamp serve --help` are the same question, so they get the same answer.
251
+ if (wantsHelp(first, rest)) {
252
+ console.log(helpFor(isHelp(first) ? rest[0] : first));
253
+ return;
254
+ }
255
+ if (first === "attach") {
256
+ const { attach } = await import("./attach.js");
257
+ process.exitCode = await attach(rest);
258
+ return;
259
+ }
140
260
  if (first === "serve") {
141
261
  const { serve } = await import("./server.js");
142
262
  await serve(rest, version());
@@ -156,6 +276,11 @@ export async function main() {
156
276
  process.exitCode = await login(first === "signup" ? [...rest, "--signup"] : rest);
157
277
  return;
158
278
  }
279
+ if (first === "token" || first === "tokens") {
280
+ const { tokens } = await import("./session.js");
281
+ process.exitCode = await tokens(rest);
282
+ return;
283
+ }
159
284
  if (first === "logout" || first === "whoami") {
160
285
  const session = await import("./session.js");
161
286
  process.exitCode = first === "logout" ? session.logout() : await session.whoami();
@@ -170,10 +295,6 @@ export async function main() {
170
295
  console.log(version());
171
296
  return;
172
297
  }
173
- if (first === "--help") {
174
- console.log(HELP);
175
- return;
176
- }
177
298
  // resolve() would turn https://host/x into /cwd/https:/host/x, so a URL is
178
299
  // left exactly as it was typed.
179
300
  const asked = first ?? ".";
@@ -186,6 +307,10 @@ export async function main() {
186
307
  }
187
308
  const state = createState(tracks, target, tools.play === null);
188
309
  const app = await createApp({ theme: themes.matrix, title: "nixamp", quitKeys: ["ctrl+c"] });
310
+ // Set when d handed the music to a daemon, and printed after the TUI is
311
+ // gone. A field rather than a local, because a local assigned only inside a
312
+ // closure stays narrowed to null for the checker.
313
+ const handoff = { to: null };
189
314
  const analyser = new Analyser(FFT_SIZE, RATE);
190
315
  const edges = bandEdges(BAND_COUNT, RATE, FFT_SIZE);
191
316
  // Samples accumulate until there are enough for one transform.
@@ -249,12 +374,41 @@ export async function main() {
249
374
  state.position = 0;
250
375
  app.invalidate();
251
376
  };
377
+ /**
378
+ * Hand the music to a daemon and give the terminal back.
379
+ *
380
+ * The local stream is stopped first, because two processes fighting over the
381
+ * audio device is a worse experience than a second of silence. What comes
382
+ * back is where it went, so `nixamp attach` is a suggestion rather than a
383
+ * thing to remember.
384
+ */
385
+ const detach = async () => {
386
+ state.note = "Handing over to a daemon...";
387
+ app.invalidate();
388
+ stream.stop();
389
+ state.playing = false;
390
+ try {
391
+ const d = await import("./daemon.js");
392
+ const daemon = await d.start([target], fileURLToPath(new URL("./main.js", import.meta.url)));
393
+ handoff.to = { daemon, url: d.daemonUrl(daemon) };
394
+ app.quit();
395
+ }
396
+ catch (error) {
397
+ // Most often: a daemon is already running, which is worth saying rather
398
+ // than leaving somebody looking at a player that stopped for no reason.
399
+ state.note = error.message;
400
+ app.invalidate();
401
+ }
402
+ };
252
403
  app.on("key", (event) => {
253
404
  switch (event.key) {
254
405
  case "q":
255
406
  stream.stop();
256
407
  app.quit();
257
408
  return;
409
+ case "d":
410
+ void detach();
411
+ return;
258
412
  case "space":
259
413
  state.playing ? stopAll() : play();
260
414
  return;
@@ -291,6 +445,13 @@ export async function main() {
291
445
  app.on("exit", () => stream.stop());
292
446
  app.render((args) => view(args, state));
293
447
  await app.start();
448
+ const handed = handoff.to;
449
+ if (handed !== null) {
450
+ console.log(`Detached. Still playing as pid ${handed.daemon.pid}.`);
451
+ console.log(` ${handed.daemon.key ? `${handed.url}/s/${handed.daemon.key}` : handed.url}`);
452
+ console.log(" nixamp attach come back to it");
453
+ console.log(" nixamp daemon stop when you are done");
454
+ }
294
455
  }
295
456
  /**
296
457
  * The bars, on a braille canvas: four vertical pixels per cell, so a bar moves
@@ -0,0 +1,108 @@
1
+ import type { Account } from "./accounts.ts";
2
+ import type { DeviceGrants } from "./device.ts";
3
+ import type { Queryable } from "./follows.ts";
4
+ export interface Provider {
5
+ /** As it appears in a URL and in `nixamp login --with <id>`. */
6
+ id: string;
7
+ /** As it appears to a person. */
8
+ name: string;
9
+ clientId: string;
10
+ clientSecret: string;
11
+ authorizeUrl: string;
12
+ tokenUrl: string;
13
+ scope: string;
14
+ /** Turn the provider's access token into an address it stands behind. */
15
+ identify(accessToken: string, send: typeof fetch): Promise<Identity | null>;
16
+ }
17
+ /** Who the provider says this is. `subject` is stable when an address is not. */
18
+ export interface Identity {
19
+ provider: string;
20
+ subject: string;
21
+ email: string;
22
+ }
23
+ export declare function githubProvider(clientId: string, clientSecret: string): Provider;
24
+ export declare function googleProvider(clientId: string, clientSecret: string): Provider;
25
+ /** Whichever providers this deployment has been given both halves of. */
26
+ export declare function providersFrom(env: Record<string, string | undefined>): Provider[];
27
+ export declare function redirectUri(site: string, provider: Provider): string;
28
+ /** Where to send the browser. `state` is the only thing standing between this and CSRF. */
29
+ export declare function authorizeUrl(provider: Provider, site: string, state: string): string;
30
+ /** Swap the code for an access token. Empty string means the provider refused. */
31
+ export declare function exchangeCode(provider: Provider, code: string, site: string, send?: typeof fetch): Promise<string>;
32
+ /** The slice of the auth module's storage adapter that identities need. */
33
+ export interface Users {
34
+ getUserByEmail(email: string): Promise<{
35
+ id?: string;
36
+ email?: string;
37
+ } | null | undefined>;
38
+ createUser(user: {
39
+ email: string;
40
+ password: string | null;
41
+ emailVerified: boolean;
42
+ profile?: Record<string, unknown>;
43
+ }): Promise<{
44
+ id?: string;
45
+ email?: string;
46
+ }>;
47
+ }
48
+ /**
49
+ * The account behind a provider identity, creating one the first time.
50
+ *
51
+ * The account row is made with no password at all rather than a random one
52
+ * nobody knows. A null password is a fact -- this account signs in with GitHub
53
+ * -- where a random password is a credential sitting in a database waiting to
54
+ * be found.
55
+ */
56
+ export declare class Identities {
57
+ private readonly db;
58
+ private readonly users;
59
+ private ready;
60
+ constructor(db: Queryable, users: Users);
61
+ private ensure;
62
+ resolve(identity: Identity): Promise<Account | null>;
63
+ }
64
+ /**
65
+ * What a round trip to a provider is for.
66
+ *
67
+ * A browser sent to GitHub comes back with a code and a state, and nothing
68
+ * else: whatever the request knew has to be remembered here in the meantime.
69
+ * The state is unguessable and single-use, which is what makes a callback
70
+ * somebody else caused useless.
71
+ */
72
+ export interface Pending {
73
+ provider: string;
74
+ /** Set when this round trip is approving a terminal rather than a browser. */
75
+ userCode: string;
76
+ createdAt: number;
77
+ }
78
+ /** A state is only ever open for the length of one sign-in. */
79
+ export declare const STATE_TTL_MS = 600000;
80
+ export declare class SignIn {
81
+ readonly providers: Provider[];
82
+ readonly device: DeviceGrants;
83
+ readonly site: string;
84
+ private readonly now;
85
+ private readonly states;
86
+ constructor(providers: Provider[], device: DeviceGrants, site: string, now?: () => number);
87
+ /** What /api/v1/auth/providers says, and what the CLI menu is built from. */
88
+ get offered(): {
89
+ id: string;
90
+ name: string;
91
+ }[];
92
+ provider(id: unknown): Provider | null;
93
+ /** Start a round trip, and answer the URL the browser should go to. */
94
+ begin(provider: Provider, userCode?: string): string;
95
+ /** Redeem a state exactly once, so a replayed callback finds nothing. */
96
+ claim(state: unknown): Pending | null;
97
+ private sweep;
98
+ }
99
+ /**
100
+ * The page the terminal sends somebody to.
101
+ *
102
+ * It is served by the API rather than the app because it has to work before
103
+ * there is a session and without the web build being present -- a nixamp
104
+ * deployed with no web assets can still sign a terminal in.
105
+ */
106
+ export declare function devicePage(signIn: SignIn, code: string, signedInAs: string): string;
107
+ export declare function deviceDonePage(email: string): string;
108
+ export declare function signInFailedPage(why: string): string;