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/README.md +55 -6
- package/dist/accounts.d.ts +40 -0
- package/dist/accounts.js +95 -2
- package/dist/attach.d.ts +15 -0
- package/dist/attach.js +174 -0
- package/dist/device.d.ts +67 -0
- package/dist/device.js +157 -0
- package/dist/main.d.ts +12 -0
- package/dist/main.js +168 -7
- package/dist/oauth.d.ts +108 -0
- package/dist/oauth.js +302 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +242 -2
- package/dist/session.d.ts +73 -2
- package/dist/session.js +308 -9
- package/dist/tokens.d.ts +63 -0
- package/dist/tokens.js +196 -0
- package/package.json +1 -1
- package/src/accounts.ts +103 -2
- package/src/attach.ts +191 -0
- package/src/device.ts +194 -0
- package/src/main.ts +174 -4
- package/src/oauth.ts +391 -0
- package/src/server.ts +278 -2
- package/src/session.ts +351 -9
- package/src/tokens.ts +247 -0
- package/web/dist/assets/index-pztl5rKf.js +1 -0
- package/web/dist/index.html +2 -1
- package/web/dist/sw.js +2 -2
- package/web/dist/assets/index-qRguFskX.js +0 -1
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 [--
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/oauth.d.ts
ADDED
|
@@ -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;
|