nixamp 0.4.1 → 0.5.1

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 CHANGED
@@ -78,22 +78,55 @@ If you would rather not pipe a script into a shell, `npm i -g nixamp` and
78
78
  ## Signing in
79
79
 
80
80
  An account on nixamp.com is what lets you publish, be paid, and administer a
81
- server you own. Email and password, on every surface:
81
+ server you own. Three ways in, because a terminal is a bad place to be asked
82
+ for a password:
82
83
 
83
84
  ```
84
- nixamp login # or: nixamp signup
85
+ nixamp login # choose: a provider in a browser, or a password
86
+ nixamp login --with github # straight to GitHub (or google)
87
+ nixamp login --password # an address and a password, here
85
88
  nixamp whoami
86
89
  nixamp logout
87
90
  ```
88
91
 
89
- The PWA and the desktop app share one form, since the desktop is that page in a
90
- window. The CLI keeps its token beside the daemon's state, mode 600, so signing
91
- in there and in the desktop app are the same thing on disk. The password is read
92
- with the echo off and is never written down.
92
+ `--with github` is OAuth 2.0 through the device grant (RFC 8628), which is how
93
+ a television has signed you in for years: the terminal shows a short code, you
94
+ approve it in a browser on whatever device has a keyboard, and the terminal ends
95
+ up holding the session. It never sees your password or the provider's token, and
96
+ it works over ssh.
97
+
98
+ The PWA and the desktop app offer the same providers, since an account made by
99
+ signing in with GitHub has no password to type anywhere. The CLI keeps its token
100
+ beside the daemon's state, mode 600, so signing in there and in the desktop app
101
+ are the same thing on disk. A password, where one is used, is read with the echo
102
+ off and is never written down.
93
103
 
94
104
  No magic link. A link in an inbox is no use on a television, or on a phone that
95
105
  is not the one you read mail on.
96
106
 
107
+ ### Tokens, for a machine that cannot sign in
108
+
109
+ ```
110
+ nixamp token create --name ci # printed once, and only once
111
+ nixamp token list
112
+ nixamp token revoke <id>
113
+ ```
114
+
115
+ `NIXAMP_TOKEN` in the environment is a signed-in nixamp with no login at all,
116
+ which is the only thing that works in CI. Tokens are stored as hashes and can be
117
+ withdrawn from anywhere; signing out does not touch them, which is the point of
118
+ them. Sessions are the same kind of thing with an expiry, so `nixamp logout`
119
+ really does end one.
120
+
121
+ Providers are configured per deployment, and only a provider with both halves is
122
+ offered:
123
+
124
+ ```
125
+ GITHUB_CLIENT_ID=… GITHUB_CLIENT_SECRET=… nixamp serve --directory
126
+ ```
127
+
128
+ The callback to register is `https://your-site/api/v1/<provider>/oauth/callback`.
129
+
97
130
  Running the account side of nixamp.com needs Postgres:
98
131
 
99
132
  ```
@@ -235,6 +268,21 @@ Start writes down where it went and the key it minted, waits until the server
235
268
  is actually answering before saying it started, and prints the share link. It is
236
269
  one daemon per user, and the state lives in `$XDG_STATE_HOME/nixamp`.
237
270
 
271
+ ### Detaching, and coming back
272
+
273
+ `d` in the player hands the music to a daemon and gives you your terminal back.
274
+ Nothing stops. `nixamp attach` puts the player back in front of it:
275
+
276
+ ```
277
+ nixamp attach # the daemon on this machine
278
+ nixamp attach --url URL --key KEY # a nixamp somewhere else
279
+ ```
280
+
281
+ An attached player is the same view and the same keys; the difference is that
282
+ the keys are sent to the daemon and what you see is what the daemon is doing.
283
+ Any number of terminals may attach at once. `q` or `d` leaves without stopping
284
+ anything, which is what `nixamp daemon stop` is for.
285
+
238
286
  ## Who may administer a server
239
287
 
240
288
  Two ways to be allowed, and they answer different questions.
@@ -382,6 +430,7 @@ A bare `ffmpeg` on `PATH` is used when there is one; `mise` shims are detected a
382
430
  | `↑` `↓` | Move through the playlist |
383
431
  | `n` `p` (or `→` `←`) | Next and previous track |
384
432
  | `s` | Stop |
433
+ | `d` | Detach: hand the music to a daemon and keep the terminal |
385
434
  | `q` | Quit |
386
435
 
387
436
  ## Formats
@@ -1,3 +1,6 @@
1
+ import { type Identity, type Users } from "./oauth.ts";
2
+ import type { Queryable } from "./follows.ts";
3
+ import { type IssuedToken, type TokenKind, type TokenRecord, Tokens } from "./tokens.ts";
1
4
  export interface Account {
2
5
  id: string;
3
6
  email: string;
@@ -16,6 +19,15 @@ export interface AccountsOptions {
16
19
  secret: string;
17
20
  /** Injected by the tests, which have no database. */
18
21
  system?: AuthLike;
22
+ /** Also injected by the tests: the storage tokens and identities sit in. */
23
+ adapter?: AdapterLike;
24
+ }
25
+ /**
26
+ * The slice of the auth module's storage adapter the rest of this file needs.
27
+ * It is the same Postgres pool the users table lives in, which is why nixamp's
28
+ * two tables need no connection of their own.
29
+ */
30
+ export interface AdapterLike extends Queryable, Users {
19
31
  }
20
32
  /** The slice of the auth system nixamp uses. */
21
33
  export interface AuthLike {
@@ -34,14 +46,59 @@ export interface AuthLike {
34
46
  export declare function readResult(value: unknown): AuthResult;
35
47
  /** `validateToken` answers claims directly, unlike login and register. */
36
48
  export declare function readClaims(value: unknown): Account | null;
49
+ /**
50
+ * How short a password may be, and what it must contain.
51
+ *
52
+ * Eight characters, a number somewhere in it, and no requirement about case.
53
+ * That is deliberately weaker than it was: the composition rules were the kind
54
+ * that make people write a password down, and a password is no longer the only
55
+ * way in -- a provider or a token is a better one, and is what the CLI offers
56
+ * first. What this floor is really for is keeping a one-character password out
57
+ * of the database, not pretending eight digits are strong.
58
+ */
59
+ export declare const PASSWORD_RULES: {
60
+ readonly minLength: 8;
61
+ readonly requireUppercase: false;
62
+ readonly requireLowercase: false;
63
+ readonly requireNumbers: true;
64
+ readonly requireSpecialChars: false;
65
+ };
37
66
  /** An address that could exist, and a password long enough to be worth having. */
38
67
  export declare function checkCredentials(email: unknown, password: unknown): string;
39
68
  export declare class Accounts {
40
69
  private readonly system;
70
+ /** Null only where a test injected an auth system and no storage. */
71
+ readonly tokens: Tokens | null;
72
+ private readonly identities;
41
73
  constructor(options: AccountsOptions);
42
74
  signUp(email: unknown, password: unknown): Promise<AuthResult>;
43
75
  signIn(email: unknown, password: unknown): Promise<AuthResult>;
76
+ /**
77
+ * Who a token belongs to, whichever kind of token it is.
78
+ *
79
+ * A `nxa_` token is one this server issued and can withdraw, so it is looked
80
+ * up. Anything else is a JWT from the auth module, which is self-describing
81
+ * and cannot be. Both answer the same shape, so nothing downstream has to
82
+ * know which door the caller came in by.
83
+ */
44
84
  whoIs(token: string): Promise<Account | null>;
85
+ /**
86
+ * The token a signed-in caller carries away.
87
+ *
88
+ * A revocable token is preferred to the module's JWT wherever there is
89
+ * storage to keep one in, because signing out of a laptop you no longer have
90
+ * should mean something. The JWT is the fallback, and the only difference to
91
+ * a caller is that one of the two can be taken away.
92
+ */
93
+ sessionFor(account: Account, fallback?: string): Promise<string>;
94
+ /** Sign in as whoever a provider says this is, making the account if it is new. */
95
+ signInWith(identity: Identity): Promise<AuthResult>;
96
+ /** A token a person made on purpose, for a script that cannot sign in. */
97
+ mintCliToken(account: Account, name: string, ttlMs?: number | null): Promise<IssuedToken | null>;
98
+ listTokens(userId: string, kind?: TokenKind): Promise<TokenRecord[]>;
99
+ revokeToken(userId: string, id: string): Promise<boolean>;
100
+ /** Signing out ends this session and leaves every other token alone. */
101
+ endSession(token: string): Promise<void>;
45
102
  }
46
103
  /** The bearer token on a request, from the header or the session cookie. */
47
104
  export declare function tokenFrom(headers: Record<string, string | string[] | undefined>): string;
package/dist/accounts.js CHANGED
@@ -15,9 +15,13 @@
15
15
  * would be nothing to click.
16
16
  *
17
17
  * No magic link: a link in an inbox is no use on a television or a phone that
18
- * is not the one you read mail on.
18
+ * is not the one you read mail on. There is now a third way in that suits a
19
+ * terminal better than either -- OAuth 2.0 reached through the device grant in
20
+ * device.ts -- and what it ends with is a token from tokens.ts.
19
21
  */
20
22
  import { createAuthSystem, PostgresAdapter } from "@profullstack/auth-system";
23
+ import { Identities } from "./oauth.js";
24
+ import { looksLikeToken, Tokens } from "./tokens.js";
21
25
  const NO_ACCOUNT = { ok: false, account: null, token: "", error: "" };
22
26
  /**
23
27
  * The same sentence for a wrong password and an address with no account.
@@ -43,16 +47,35 @@ export function readClaims(value) {
43
47
  const email = typeof claims["email"] === "string" ? claims["email"] : "";
44
48
  return id ? { id, email } : null;
45
49
  }
50
+ /**
51
+ * How short a password may be, and what it must contain.
52
+ *
53
+ * Eight characters, a number somewhere in it, and no requirement about case.
54
+ * That is deliberately weaker than it was: the composition rules were the kind
55
+ * that make people write a password down, and a password is no longer the only
56
+ * way in -- a provider or a token is a better one, and is what the CLI offers
57
+ * first. What this floor is really for is keeping a one-character password out
58
+ * of the database, not pretending eight digits are strong.
59
+ */
60
+ export const PASSWORD_RULES = {
61
+ minLength: 8,
62
+ requireUppercase: false,
63
+ requireLowercase: false,
64
+ requireNumbers: true,
65
+ requireSpecialChars: false,
66
+ };
46
67
  /** An address that could exist, and a password long enough to be worth having. */
47
68
  export function checkCredentials(email, password) {
48
69
  if (typeof email !== "string" || !/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) {
49
70
  return "that does not look like an email address";
50
71
  }
51
- if (typeof password !== "string" || password.length < 10) {
72
+ if (typeof password !== "string" || password.length < PASSWORD_RULES.minLength) {
52
73
  // Length is checked here so a hopeless password never reaches the
53
74
  // database. The auth module then applies its own composition rules on top,
54
- // and its refusals are passed through rather than swallowed.
55
- return "a password needs at least 10 characters";
75
+ // and its refusals are passed through rather than swallowed -- which is why
76
+ // the two have to agree about the minimum, or a password this accepts is
77
+ // refused a layer down with a different sentence.
78
+ return `a password needs at least ${PASSWORD_RULES.minLength} characters`;
56
79
  }
57
80
  if (password.length > 200)
58
81
  return "that password is too long";
@@ -60,13 +83,29 @@ export function checkCredentials(email, password) {
60
83
  }
61
84
  export class Accounts {
62
85
  system;
86
+ /** Null only where a test injected an auth system and no storage. */
87
+ tokens;
88
+ identities;
63
89
  constructor(options) {
90
+ // The adapter is kept rather than only handed over: tokens and provider
91
+ // identities are nixamp's own tables in the same database, and a second
92
+ // pool for two small tables would be a second thing to configure.
93
+ const adapter = options.adapter ??
94
+ (options.system
95
+ ? null
96
+ : new PostgresAdapter({ connectionString: options.connectionString }));
64
97
  this.system =
65
98
  options.system ??
66
99
  createAuthSystem({
67
- adapter: new PostgresAdapter({ connectionString: options.connectionString }),
100
+ adapter,
68
101
  jwtSecret: options.secret,
102
+ // The module defaults to requiring an uppercase and a lowercase
103
+ // letter. Its rules have to match the ones checked above, or a password
104
+ // this accepts is refused a layer down in a different sentence.
105
+ passwordOptions: { ...PASSWORD_RULES },
69
106
  });
107
+ this.tokens = adapter ? new Tokens(adapter) : null;
108
+ this.identities = adapter ? new Identities(adapter, adapter) : null;
70
109
  }
71
110
  async signUp(email, password) {
72
111
  const wrong = checkCredentials(email, password);
@@ -112,9 +151,27 @@ export class Accounts {
112
151
  return { ...NO_ACCOUNT, error: REFUSED };
113
152
  }
114
153
  }
154
+ /**
155
+ * Who a token belongs to, whichever kind of token it is.
156
+ *
157
+ * A `nxa_` token is one this server issued and can withdraw, so it is looked
158
+ * up. Anything else is a JWT from the auth module, which is self-describing
159
+ * and cannot be. Both answer the same shape, so nothing downstream has to
160
+ * know which door the caller came in by.
161
+ */
115
162
  async whoIs(token) {
116
163
  if (!token)
117
164
  return null;
165
+ if (looksLikeToken(token)) {
166
+ if (this.tokens === null)
167
+ return null;
168
+ try {
169
+ return await this.tokens.verify(token);
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ }
118
175
  try {
119
176
  return readClaims(await this.system.validateToken(token));
120
177
  }
@@ -122,6 +179,65 @@ export class Accounts {
122
179
  return null;
123
180
  }
124
181
  }
182
+ /**
183
+ * The token a signed-in caller carries away.
184
+ *
185
+ * A revocable token is preferred to the module's JWT wherever there is
186
+ * storage to keep one in, because signing out of a laptop you no longer have
187
+ * should mean something. The JWT is the fallback, and the only difference to
188
+ * a caller is that one of the two can be taken away.
189
+ */
190
+ async sessionFor(account, fallback = "") {
191
+ if (this.tokens === null)
192
+ return fallback;
193
+ try {
194
+ return (await this.tokens.issue({ account, kind: "session" })).token;
195
+ }
196
+ catch {
197
+ return fallback;
198
+ }
199
+ }
200
+ /** Sign in as whoever a provider says this is, making the account if it is new. */
201
+ async signInWith(identity) {
202
+ if (this.identities === null)
203
+ return { ...NO_ACCOUNT, error: "this nixamp does not keep accounts" };
204
+ let account = null;
205
+ try {
206
+ account = await this.identities.resolve(identity);
207
+ }
208
+ catch {
209
+ account = null;
210
+ }
211
+ if (account === null) {
212
+ return { ...NO_ACCOUNT, error: `${identity.provider} did not give a verified email address` };
213
+ }
214
+ const token = await this.sessionFor(account);
215
+ if (!token)
216
+ return { ...NO_ACCOUNT, error: "could not start a session" };
217
+ return { ok: true, account, token, error: "" };
218
+ }
219
+ /** A token a person made on purpose, for a script that cannot sign in. */
220
+ async mintCliToken(account, name, ttlMs = null) {
221
+ return this.tokens === null ? null : this.tokens.issue({ account, kind: "cli", name, ttlMs });
222
+ }
223
+ async listTokens(userId, kind) {
224
+ return this.tokens === null ? [] : this.tokens.list(userId, kind);
225
+ }
226
+ async revokeToken(userId, id) {
227
+ return this.tokens === null ? false : this.tokens.revoke(userId, id);
228
+ }
229
+ /** Signing out ends this session and leaves every other token alone. */
230
+ async endSession(token) {
231
+ if (this.tokens === null || !looksLikeToken(token))
232
+ return;
233
+ try {
234
+ await this.tokens.revokeToken(token);
235
+ }
236
+ catch {
237
+ // A session that cannot be deleted still expires, and refusing to sign
238
+ // somebody out because the database blinked would be worse.
239
+ }
240
+ }
125
241
  }
126
242
  /** The bearer token on a request, from the header or the session cookie. */
127
243
  export function tokenFrom(headers) {
@@ -0,0 +1,15 @@
1
+ import { type State } from "./main.ts";
2
+ import type { Command, Snapshot } from "./protocol.ts";
3
+ /** A remote track has no path, because no filesystem path leaves the machine. */
4
+ export declare function applySnapshot(state: State, snapshot: Snapshot): void;
5
+ /**
6
+ * Read the server's event stream, calling back with every snapshot.
7
+ *
8
+ * Reconnects for as long as it is wanted: a daemon restarting under an
9
+ * attached player should look like a pause, not a crash.
10
+ */
11
+ export declare function follow(url: string, headers: Record<string, string>, onSnapshot: (snapshot: Snapshot) => void, onTrouble: (why: string) => void, signal: AbortSignal, send?: typeof fetch): Promise<void>;
12
+ /** Drive the daemon with the same keys that drive the local player. */
13
+ export declare function commandFor(key: string): Command | null;
14
+ /** `nixamp attach` — the player, in front of whatever the daemon is doing. */
15
+ export declare function attach(argv: string[]): Promise<number>;
package/dist/attach.js ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Walking away from the player, and coming back to it.
3
+ *
4
+ * The daemon has always been able to outlive the terminal that started it.
5
+ * What was missing was the pair of moves that makes that worth having: `d` in
6
+ * the player hands the music to a daemon and gives you your terminal back, and
7
+ * `nixamp attach` puts the same player back in front of the same music.
8
+ *
9
+ * Attaching is not a second player. It is the same view, drawn from the
10
+ * daemon's snapshot instead of a local stream, with the keys sent as commands
11
+ * over the API a browser remote already uses. So the analyser moves, the track
12
+ * list is the daemon's, and nothing has to agree twice about what a player
13
+ * looks like.
14
+ *
15
+ * Quitting an attached player stops nothing. That is the whole point: it is
16
+ * tmux's detach, not a stop button.
17
+ */
18
+ import { createApp, themes } from "@profullstack/hqtui";
19
+ import { resolveTarget } from "./admin.js";
20
+ import { BAND_COUNT, createState, view } from "./main.js";
21
+ import { KEY_HEADER } from "./share.js";
22
+ /** How long to wait before trying the event stream again. */
23
+ const RECONNECT_MS = 1000;
24
+ /** A remote track has no path, because no filesystem path leaves the machine. */
25
+ export function applySnapshot(state, snapshot) {
26
+ state.tracks = snapshot.tracks.map((track) => ({ path: "", ...track }));
27
+ state.index = snapshot.index;
28
+ state.playing = snapshot.playing;
29
+ state.position = snapshot.position;
30
+ state.levels = snapshot.levels;
31
+ state.silent = snapshot.silent;
32
+ state.root = snapshot.root;
33
+ state.note = snapshot.note;
34
+ // The bars come down the wire; the peaks that hang above them do not, and
35
+ // are a local decoration either way. Falling at the same rate the local
36
+ // player uses keeps the two looking like one program.
37
+ const bars = new Array(BAND_COUNT).fill(0).map((_, index) => snapshot.bars[index] ?? 0);
38
+ state.bars = bars;
39
+ state.peakHold = state.peakHold.map((peak, index) => Math.max(bars[index], peak - 0.02));
40
+ }
41
+ /**
42
+ * Read the server's event stream, calling back with every snapshot.
43
+ *
44
+ * Reconnects for as long as it is wanted: a daemon restarting under an
45
+ * attached player should look like a pause, not a crash.
46
+ */
47
+ export async function follow(url, headers, onSnapshot, onTrouble, signal, send = fetch) {
48
+ while (!signal.aborted) {
49
+ try {
50
+ const answer = await send(`${url}/api/events`, { headers, signal });
51
+ if (!answer.ok || answer.body === null)
52
+ throw new Error(`${answer.status}`);
53
+ onTrouble("");
54
+ // SSE frames are separated by a blank line, and arrive split across
55
+ // chunks in whatever way the network felt like.
56
+ let buffered = "";
57
+ const reader = answer.body.getReader();
58
+ const decoder = new TextDecoder();
59
+ for (;;) {
60
+ const { done, value } = await reader.read();
61
+ if (done)
62
+ break;
63
+ buffered += decoder.decode(value, { stream: true });
64
+ let cut = buffered.indexOf("\n\n");
65
+ while (cut !== -1) {
66
+ const frame = buffered.slice(0, cut);
67
+ buffered = buffered.slice(cut + 2);
68
+ for (const line of frame.split("\n")) {
69
+ if (!line.startsWith("data:"))
70
+ continue;
71
+ try {
72
+ onSnapshot(JSON.parse(line.slice(5).trim()));
73
+ }
74
+ catch {
75
+ // A frame we cannot read is one frame, not a reason to hang up.
76
+ }
77
+ }
78
+ cut = buffered.indexOf("\n\n");
79
+ }
80
+ }
81
+ }
82
+ catch {
83
+ if (signal.aborted)
84
+ return;
85
+ onTrouble(`cannot reach ${url}`);
86
+ }
87
+ if (signal.aborted)
88
+ return;
89
+ await new Promise((done) => setTimeout(done, RECONNECT_MS));
90
+ }
91
+ }
92
+ /** Drive the daemon with the same keys that drive the local player. */
93
+ export function commandFor(key) {
94
+ switch (key) {
95
+ case "space":
96
+ return { type: "toggle" };
97
+ case "enter":
98
+ return { type: "play" };
99
+ case "s":
100
+ return { type: "stop" };
101
+ case "n":
102
+ case "right":
103
+ return { type: "next" };
104
+ case "p":
105
+ case "left":
106
+ return { type: "prev" };
107
+ default:
108
+ return null;
109
+ }
110
+ }
111
+ /** `nixamp attach` — the player, in front of whatever the daemon is doing. */
112
+ export async function attach(argv) {
113
+ let target;
114
+ try {
115
+ target = resolveTarget(argv);
116
+ }
117
+ catch (error) {
118
+ console.error(error.message);
119
+ console.error(" `nixamp daemon start ~/Music` starts one, or press d in the player to hand it one.");
120
+ return 1;
121
+ }
122
+ const headers = target.key ? { [KEY_HEADER]: target.key } : {};
123
+ const state = createState([], target.url, false);
124
+ state.note = `attaching to ${target.url}...`;
125
+ const app = await createApp({ theme: themes.matrix, title: "nixamp", quitKeys: ["ctrl+c"] });
126
+ const stop = new AbortController();
127
+ const tell = (command) => {
128
+ void fetch(`${target.url}/api/command`, {
129
+ method: "POST",
130
+ headers: { ...headers, "content-type": "application/json" },
131
+ body: JSON.stringify(command),
132
+ }).catch(() => {
133
+ // The next snapshot says whether it landed; a failed keypress is not
134
+ // worth a dialog in a player.
135
+ });
136
+ };
137
+ app.on("key", (event) => {
138
+ // q and d both leave, because both mean "I am done with this terminal".
139
+ // Neither stops the music, and the line printed on the way out says so.
140
+ if (event.key === "q" || event.key === "d") {
141
+ app.quit();
142
+ return;
143
+ }
144
+ if (event.key === "up" || event.key === "down") {
145
+ const at = state.index + (event.key === "down" ? 1 : -1);
146
+ if (at < 0 || at >= state.tracks.length)
147
+ return;
148
+ // Moved here as well as asked for, so the highlight does not wait for a
149
+ // round trip before it moves.
150
+ state.index = at;
151
+ app.invalidate();
152
+ tell({ type: "select", index: at });
153
+ return;
154
+ }
155
+ const command = commandFor(event.key);
156
+ if (command)
157
+ tell(command);
158
+ });
159
+ app.on("exit", () => stop.abort());
160
+ app.render((args) => view(args, state));
161
+ void follow(target.url, headers, (snapshot) => {
162
+ applySnapshot(state, snapshot);
163
+ app.invalidate();
164
+ }, (why) => {
165
+ state.note = why;
166
+ app.invalidate();
167
+ }, stop.signal);
168
+ await app.start();
169
+ stop.abort();
170
+ console.log(`Detached. ${target.url} is still playing.`);
171
+ console.log(" nixamp attach come back");
172
+ console.log(" nixamp daemon stop when you are done");
173
+ return 0;
174
+ }
@@ -0,0 +1,67 @@
1
+ /** Long enough to walk to another room, short enough that a stolen code is stale. */
2
+ export declare const GRANT_TTL_MS = 600000;
3
+ /** What the CLI is told to wait between polls, in seconds. */
4
+ export declare const POLL_INTERVAL_SECONDS = 5;
5
+ export interface Grant {
6
+ deviceCode: string;
7
+ userCode: string;
8
+ createdAt: number;
9
+ expiresAt: number;
10
+ lastPolledAt: number;
11
+ /** Set once somebody approved it in a browser. */
12
+ session: {
13
+ token: string;
14
+ email: string;
15
+ } | null;
16
+ denied: boolean;
17
+ }
18
+ export type PollStatus = {
19
+ status: "pending";
20
+ } | {
21
+ status: "slow_down";
22
+ } | {
23
+ status: "expired";
24
+ } | {
25
+ status: "denied";
26
+ } | {
27
+ status: "ok";
28
+ token: string;
29
+ email: string;
30
+ };
31
+ /** `WXYZ-4RTB`. Hyphenated because it is read aloud and typed by hand. */
32
+ export declare function makeUserCode(random: (size: number) => Uint8Array): string;
33
+ /** Accept what a person typed however they typed it: lower case, no hyphen. */
34
+ export declare function normalizeUserCode(value: unknown): string;
35
+ export interface DeviceOptions {
36
+ now?: () => number;
37
+ random?: (size: number) => Uint8Array;
38
+ /** How often a waiting terminal is told to ask. Five seconds is RFC 8628's. */
39
+ intervalSeconds?: number;
40
+ }
41
+ export declare class DeviceGrants {
42
+ private readonly byDevice;
43
+ private readonly byUser;
44
+ private readonly now;
45
+ private readonly random;
46
+ /** Told to the terminal, and enforced here: the two must be the same number. */
47
+ readonly interval: number;
48
+ constructor(options?: DeviceOptions);
49
+ start(): Grant;
50
+ /** The grant behind a code somebody typed, if it is still worth anything. */
51
+ find(userCode: string): Grant | null;
52
+ approve(userCode: string, session: {
53
+ token: string;
54
+ email: string;
55
+ }): boolean;
56
+ deny(userCode: string): boolean;
57
+ /**
58
+ * What the waiting terminal is told. A grant is forgotten the moment it
59
+ * answers with a session, so the same device code cannot be redeemed twice.
60
+ */
61
+ poll(deviceCode: string): PollStatus;
62
+ private forget;
63
+ sweep(): void;
64
+ get size(): number;
65
+ /** The grants nobody has answered yet, newest last. */
66
+ pending(): Grant[];
67
+ }