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/session.d.ts CHANGED
@@ -5,7 +5,15 @@ export interface Session {
5
5
  signedInAt: number;
6
6
  }
7
7
  export declare function sessionPath(): string;
8
- export declare function readSession(): Session | null;
8
+ /**
9
+ * The session on disk, or the one in the environment.
10
+ *
11
+ * `NIXAMP_TOKEN` wins, and is the whole answer for a build server: there is no
12
+ * `nixamp login` to run in a container, and a token pasted into a secret store
13
+ * is the thing a build server can actually hold. It is deliberately not
14
+ * written to disk -- the environment is where it came from and where it ends.
15
+ */
16
+ export declare function readSession(env?: NodeJS.ProcessEnv): Session | null;
9
17
  export declare function writeSession(session: Session): void;
10
18
  export declare function clearSession(): void;
11
19
  /**
@@ -18,12 +26,75 @@ export interface LoginOptions {
18
26
  email: string;
19
27
  /** Create the account rather than signing in to one. */
20
28
  signUp: boolean;
29
+ /** A provider id to sign in with, `""` for none named. */
30
+ with: string;
31
+ /** Approve in a browser without naming a provider, whoever it is signed in as. */
32
+ device: boolean;
33
+ /** Skip the menu and ask for a password, however the site is configured. */
34
+ password: boolean;
35
+ /** A token made with `nixamp token create`, to keep rather than earn. */
36
+ token: string;
37
+ /** Do not try to open a browser. */
38
+ noBrowser: boolean;
21
39
  fetcher?: typeof fetch;
22
40
  }
23
41
  /** Read the flags `nixamp login` accepts. */
24
42
  export declare function parseLoginArgs(argv: string[]): LoginOptions;
43
+ export interface SiteWays {
44
+ password: boolean;
45
+ device: boolean;
46
+ providers: {
47
+ id: string;
48
+ name: string;
49
+ }[];
50
+ }
51
+ /**
52
+ * What this site will accept. An older nixamp has no such endpoint, and the
53
+ * answer for one is the way in it has always had.
54
+ */
55
+ export declare function askWays(site: string, send: typeof fetch): Promise<SiteWays>;
56
+ /**
57
+ * Show a URL in a browser if there is one to show it in.
58
+ *
59
+ * Best effort by design: over ssh there is no browser and nothing should
60
+ * pretend otherwise, which is why the code and the URL are always printed
61
+ * whether this works or not.
62
+ */
63
+ export declare function openInBrowser(url: string): void;
64
+ export interface DeviceIo {
65
+ say: (line: string) => void;
66
+ wait: (ms: number) => Promise<void>;
67
+ open: (url: string) => void;
68
+ }
69
+ /**
70
+ * The device grant, from this side.
71
+ *
72
+ * Ask for a code, show it, then poll until somebody approves it in a browser.
73
+ * `slow_down` is obeyed rather than ignored: a server that says to back off is
74
+ * the only warning before it stops answering at all.
75
+ */
76
+ export declare function deviceLogin(site: string, provider: string, send: typeof fetch, io: DeviceIo): Promise<{
77
+ token: string;
78
+ email: string;
79
+ } | string>;
25
80
  /** `nixamp login` / `nixamp signup`. */
26
- export declare function login(argv: string[]): Promise<number>;
81
+ export declare function login(argv: string[], fetcher?: typeof fetch): Promise<number>;
82
+ /** The address a token belongs to, or null if the site will not have it. */
83
+ export declare function accountFor(site: string, token: string, send: typeof fetch): Promise<string | null>;
84
+ /**
85
+ * Which way in to use.
86
+ *
87
+ * The menu only appears where it can be answered: a pipe gets the flow it has
88
+ * always had, so a script that feeds an address and a password still works.
89
+ * Null means what was asked for is not on offer here.
90
+ */
91
+ export declare function chooseWay(ways: SiteWays, options: LoginOptions): Promise<string | null>;
92
+ /**
93
+ * `nixamp token create|list|revoke`, which is how a machine that cannot sign
94
+ * in gets to be signed in. The token is shown once, at creation, because the
95
+ * server keeps only its hash and has nothing to show a second time.
96
+ */
97
+ export declare function tokens(argv: string[], fetcher?: typeof fetch): Promise<number>;
27
98
  export declare function logout(): number;
28
99
  /** `nixamp whoami`, which asks the server rather than trusting the file. */
29
100
  export declare function whoami(fetcher?: typeof fetch): Promise<number>;
package/dist/session.js CHANGED
@@ -1,15 +1,24 @@
1
1
  /**
2
2
  * Being signed in, from a terminal.
3
3
  *
4
- * `nixamp login` asks for an address and a password, and keeps the token it
5
- * gets back beside the daemon's state. The desktop app bundles this same CLI,
6
- * so signing in there and signing in here are the same thing on disk.
4
+ * There are three ways in, and they exist because a terminal is a bad place to
5
+ * be asked for a password and a worse place to click a link:
7
6
  *
8
- * The password is read with the echo turned off and is never written down: the
9
- * token is what is kept, and it can be revoked without changing anything the
10
- * person has to remember.
7
+ * - **A provider**, through the device grant. The terminal shows a short code,
8
+ * you approve it in a browser on whatever device has a keyboard, and the
9
+ * terminal ends up holding a session it can use. It never sees the password
10
+ * or the provider's token. This is what `nixamp login` offers first.
11
+ * - **An address and a password**, as before, for anyone who has one.
12
+ * - **A token**, made once with `nixamp token create` and pasted into a build
13
+ * server. `NIXAMP_TOKEN` in the environment is a signed-in nixamp with no
14
+ * login at all, which is the only thing that works in CI.
15
+ *
16
+ * Whichever way in, what is kept on disk is a token beside the daemon's state.
17
+ * The desktop app bundles this same CLI, so signing in there and signing in
18
+ * here are the same thing on disk.
11
19
  */
12
20
  import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
21
+ import { spawn } from "node:child_process";
13
22
  import { createInterface } from "node:readline/promises";
14
23
  import { dirname, join } from "node:path";
15
24
  import { stateDir } from "./daemon.js";
@@ -17,7 +26,24 @@ import { DEFAULT_DIRECTORY } from "./directory.js";
17
26
  export function sessionPath() {
18
27
  return join(stateDir(), "session.json");
19
28
  }
20
- export function readSession() {
29
+ /**
30
+ * The session on disk, or the one in the environment.
31
+ *
32
+ * `NIXAMP_TOKEN` wins, and is the whole answer for a build server: there is no
33
+ * `nixamp login` to run in a container, and a token pasted into a secret store
34
+ * is the thing a build server can actually hold. It is deliberately not
35
+ * written to disk -- the environment is where it came from and where it ends.
36
+ */
37
+ export function readSession(env = process.env) {
38
+ const fromEnv = env["NIXAMP_TOKEN"];
39
+ if (fromEnv) {
40
+ return {
41
+ site: (env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY).replace(/\/+$/, ""),
42
+ email: "",
43
+ token: fromEnv,
44
+ signedInAt: 0,
45
+ };
46
+ }
21
47
  try {
22
48
  return JSON.parse(readFileSync(sessionPath(), "utf8"));
23
49
  }
@@ -110,12 +136,165 @@ export function parseLoginArgs(argv) {
110
136
  site: (at("--site") ?? DEFAULT_DIRECTORY).replace(/\/+$/, ""),
111
137
  email: at("--email") ?? argv.find((a) => !a.startsWith("-") && a.includes("@")) ?? "",
112
138
  signUp: argv.includes("--signup") || argv.includes("--sign-up"),
139
+ // --with github, or the bare --github that people type anyway.
140
+ with: at("--with") ??
141
+ at("--provider") ??
142
+ argv.find((a) => a === "--github" || a === "--google")?.slice(2) ??
143
+ "",
144
+ device: argv.includes("--device"),
145
+ password: argv.includes("--password"),
146
+ token: at("--token") ?? "",
147
+ noBrowser: argv.includes("--no-browser"),
113
148
  };
114
149
  }
150
+ /**
151
+ * What this site will accept. An older nixamp has no such endpoint, and the
152
+ * answer for one is the way in it has always had.
153
+ */
154
+ export async function askWays(site, send) {
155
+ const fallback = { password: true, device: false, providers: [] };
156
+ try {
157
+ const answer = await send(`${site}/api/v1/auth/providers`);
158
+ if (!answer.ok)
159
+ return fallback;
160
+ const body = (await answer.json());
161
+ return {
162
+ password: body.password !== false,
163
+ device: body.device === true,
164
+ providers: Array.isArray(body.providers) ? body.providers : [],
165
+ };
166
+ }
167
+ catch {
168
+ return fallback;
169
+ }
170
+ }
171
+ /**
172
+ * Show a URL in a browser if there is one to show it in.
173
+ *
174
+ * Best effort by design: over ssh there is no browser and nothing should
175
+ * pretend otherwise, which is why the code and the URL are always printed
176
+ * whether this works or not.
177
+ */
178
+ export function openInBrowser(url) {
179
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
180
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
181
+ try {
182
+ spawn(opener, args, { stdio: "ignore", detached: true }).on("error", () => { }).unref();
183
+ }
184
+ catch {
185
+ // No browser here. The printed URL is the fallback, and it is enough.
186
+ }
187
+ }
188
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
189
+ /**
190
+ * The device grant, from this side.
191
+ *
192
+ * Ask for a code, show it, then poll until somebody approves it in a browser.
193
+ * `slow_down` is obeyed rather than ignored: a server that says to back off is
194
+ * the only warning before it stops answering at all.
195
+ */
196
+ export async function deviceLogin(site, provider, send, io) {
197
+ let answer;
198
+ try {
199
+ answer = await send(`${site}/api/v1/auth/device/code`, {
200
+ method: "POST",
201
+ headers: { "content-type": "application/json" },
202
+ body: "{}",
203
+ });
204
+ }
205
+ catch (error) {
206
+ return `could not reach ${site}: ${error.message}`;
207
+ }
208
+ if (!answer.ok)
209
+ return "this nixamp cannot sign a terminal in";
210
+ const grant = (await answer.json().catch(() => ({})));
211
+ if (!grant.device_code || !grant.user_code)
212
+ return "this nixamp cannot sign a terminal in";
213
+ // Straight to the provider when one was named, so the only thing to do in
214
+ // the browser is approve. Otherwise the page asks which.
215
+ const where = provider && grant.user_code
216
+ ? `${site}/api/v1/${provider}/oauth/start?device=${encodeURIComponent(grant.user_code)}`
217
+ : (grant.verification_uri_complete ?? grant.verification_uri ?? `${site}/api/v1/auth/device`);
218
+ io.say("");
219
+ io.say(` Open ${where}`);
220
+ io.say(` Code ${grant.user_code}`);
221
+ io.say("");
222
+ io.say("Waiting for you to approve it...");
223
+ io.open(where);
224
+ let interval = Math.max(1, grant.interval ?? 5) * 1000;
225
+ const until = Date.now() + Math.max(60, grant.expires_in ?? 600) * 1000;
226
+ while (Date.now() < until) {
227
+ await io.wait(interval);
228
+ let poll;
229
+ try {
230
+ poll = await send(`${site}/api/v1/auth/device/token`, {
231
+ method: "POST",
232
+ headers: { "content-type": "application/json" },
233
+ body: JSON.stringify({ device_code: grant.device_code }),
234
+ });
235
+ }
236
+ catch {
237
+ // A dropped network mid-wait is not a failed sign-in; keep asking.
238
+ continue;
239
+ }
240
+ const body = (await poll.json().catch(() => ({})));
241
+ if (poll.ok && body.token)
242
+ return { token: body.token, email: body.email ?? "" };
243
+ if (body.error === "slow_down") {
244
+ interval += 5000;
245
+ continue;
246
+ }
247
+ if (body.error === "authorization_pending")
248
+ continue;
249
+ if (body.error === "access_denied")
250
+ return "that sign-in was refused";
251
+ if (body.error === "expired_token")
252
+ break;
253
+ }
254
+ return "the code expired before it was approved";
255
+ }
115
256
  /** `nixamp login` / `nixamp signup`. */
116
- export async function login(argv) {
257
+ export async function login(argv, fetcher = fetch) {
117
258
  const options = parseLoginArgs(argv);
118
- const send = options.fetcher ?? fetch;
259
+ const send = options.fetcher ?? fetcher;
260
+ // A token is not a sign-in, it is a token somebody already made. It is
261
+ // checked before it is kept, so a typo fails here rather than at the next
262
+ // command with a message about something else.
263
+ if (options.token) {
264
+ const account = await accountFor(options.site, options.token, send);
265
+ if (account === null) {
266
+ console.error(`nixamp: ${options.site} does not accept that token`);
267
+ return 1;
268
+ }
269
+ writeSession({ site: options.site, email: account, token: options.token, signedInAt: Date.now() });
270
+ console.log(`Signed in to ${options.site} as ${account}.`);
271
+ return 0;
272
+ }
273
+ // Signing up is still an address and a password: a provider account that has
274
+ // never been here signs up by signing in, which is the point of it.
275
+ const ways = options.password || options.signUp ? null : await askWays(options.site, send);
276
+ const chosen = ways ? await chooseWay(ways, options) : "password";
277
+ if (chosen === null) {
278
+ const offered = (ways?.providers ?? []).map((provider) => provider.id).join(", ");
279
+ console.error(offered
280
+ ? `nixamp: ${options.site} cannot sign you in with ${options.with}. It offers: ${offered}.`
281
+ : `nixamp: ${options.site} offers no providers to sign in with.`);
282
+ return 64;
283
+ }
284
+ if (chosen !== "password") {
285
+ const got = await deviceLogin(options.site, chosen === "device" ? "" : chosen, send, {
286
+ say: (line) => console.log(line),
287
+ wait: sleep,
288
+ open: options.noBrowser ? () => { } : openInBrowser,
289
+ });
290
+ if (typeof got === "string") {
291
+ console.error(`nixamp: ${got}`);
292
+ return 1;
293
+ }
294
+ writeSession({ site: options.site, email: got.email, token: got.token, signedInAt: Date.now() });
295
+ console.log(`Signed in to ${options.site} as ${got.email || "your account"}.`);
296
+ return 0;
297
+ }
119
298
  const email = options.email || (await ask("Email: "));
120
299
  if (!email) {
121
300
  console.error("nixamp: no email given");
@@ -148,9 +327,129 @@ export async function login(argv) {
148
327
  console.log(`Signed in to ${options.site} as ${email}.`);
149
328
  return 0;
150
329
  }
330
+ /** The address a token belongs to, or null if the site will not have it. */
331
+ export async function accountFor(site, token, send) {
332
+ try {
333
+ const answer = await send(`${site}/api/v1/auth/me`, { headers: { authorization: `Bearer ${token}` } });
334
+ if (!answer.ok)
335
+ return null;
336
+ const body = (await answer.json());
337
+ return body.account?.email ?? "";
338
+ }
339
+ catch {
340
+ return null;
341
+ }
342
+ }
343
+ /**
344
+ * Which way in to use.
345
+ *
346
+ * The menu only appears where it can be answered: a pipe gets the flow it has
347
+ * always had, so a script that feeds an address and a password still works.
348
+ * Null means what was asked for is not on offer here.
349
+ */
350
+ export async function chooseWay(ways, options) {
351
+ if (options.with) {
352
+ return ways.providers.some((provider) => provider.id === options.with) ? options.with : null;
353
+ }
354
+ // Asking for the browser without naming a provider: the page will offer
355
+ // them, and a browser that is already signed in can approve on the spot.
356
+ if (options.device)
357
+ return ways.device ? "device" : null;
358
+ // Naming an address is asking for the password flow by implication.
359
+ if (!ways.device || options.email || !process.stdin.isTTY)
360
+ return "password";
361
+ if (ways.providers.length === 0)
362
+ return "password";
363
+ console.log("How would you like to sign in?");
364
+ ways.providers.forEach((provider, index) => console.log(` ${index + 1}) ${provider.name}`));
365
+ console.log(` ${ways.providers.length + 1}) Email and password`);
366
+ const typed = await ask(`Choose [1]: `);
367
+ const picked = typed === "" ? 1 : Number(typed);
368
+ if (!Number.isInteger(picked) || picked < 1 || picked > ways.providers.length + 1) {
369
+ console.log("Not one of those, so: email and password.");
370
+ return "password";
371
+ }
372
+ return picked === ways.providers.length + 1 ? "password" : (ways.providers[picked - 1]?.id ?? "password");
373
+ }
374
+ const day = (at) => (at ? new Date(at).toISOString().slice(0, 10) : "never");
375
+ /**
376
+ * `nixamp token create|list|revoke`, which is how a machine that cannot sign
377
+ * in gets to be signed in. The token is shown once, at creation, because the
378
+ * server keeps only its hash and has nothing to show a second time.
379
+ */
380
+ export async function tokens(argv, fetcher = fetch) {
381
+ const session = readSession();
382
+ if (session === null) {
383
+ console.error("nixamp: not signed in. Try `nixamp login`.");
384
+ return 1;
385
+ }
386
+ const [command = "list", ...rest] = argv;
387
+ const where = `${session.site}/api/v1/auth/tokens`;
388
+ const headers = { authorization: `Bearer ${session.token}`, "content-type": "application/json" };
389
+ try {
390
+ if (command === "create" || command === "new" || command === "add") {
391
+ const nameAt = rest.indexOf("--name");
392
+ const name = (nameAt === -1 ? rest.find((a) => !a.startsWith("-")) : rest[nameAt + 1]) ?? "";
393
+ const answer = await fetcher(where, { method: "POST", headers, body: JSON.stringify({ name }) });
394
+ const body = (await answer.json().catch(() => ({})));
395
+ if (!answer.ok || !body.token) {
396
+ console.error(`nixamp: ${body.error ?? `could not make a token (${answer.status})`}`);
397
+ return 1;
398
+ }
399
+ console.log(body.token);
400
+ console.error("");
401
+ console.error("Keep it somewhere safe: this is the only time it is shown.");
402
+ console.error("Use it with NIXAMP_TOKEN=... or `nixamp login --token ...`.");
403
+ return 0;
404
+ }
405
+ if (command === "revoke" || command === "rm" || command === "delete") {
406
+ const id = rest.find((a) => !a.startsWith("-")) ?? "";
407
+ if (!id) {
408
+ console.error("nixamp: which token? `nixamp token list` shows their ids.");
409
+ return 64;
410
+ }
411
+ const answer = await fetcher(`${where}/${encodeURIComponent(id)}`, { method: "DELETE", headers });
412
+ if (!answer.ok) {
413
+ console.error(`nixamp: ${answer.status === 404 ? "no token with that id" : "could not revoke it"}`);
414
+ return 1;
415
+ }
416
+ console.log(`Revoked ${id}.`);
417
+ return 0;
418
+ }
419
+ if (command === "list" || command === "ls") {
420
+ const answer = await fetcher(where, { headers });
421
+ const body = (await answer.json().catch(() => ({})));
422
+ if (!answer.ok) {
423
+ console.error(`nixamp: ${body.error ?? `could not list them (${answer.status})`}`);
424
+ return 1;
425
+ }
426
+ const list = body.tokens ?? [];
427
+ if (list.length === 0) {
428
+ console.log("No tokens. `nixamp token create --name ci` makes one.");
429
+ return 0;
430
+ }
431
+ for (const token of list) {
432
+ console.log(`${token.id} ${day(token.createdAt)} last used ${day(token.lastUsedAt)} ${token.name}`);
433
+ }
434
+ return 0;
435
+ }
436
+ console.error(`nixamp: no such token command: ${command}`);
437
+ return 64;
438
+ }
439
+ catch (error) {
440
+ console.error(`nixamp: could not reach ${session.site}: ${error.message}`);
441
+ return 69;
442
+ }
443
+ }
151
444
  export function logout() {
152
445
  const session = readSession();
153
446
  clearSession();
447
+ if (process.env["NIXAMP_TOKEN"]) {
448
+ // Deleting the file would not change anything while this is set, and
449
+ // saying nothing would leave somebody wondering why they are still in.
450
+ console.log("nixamp: NIXAMP_TOKEN is set in the environment, so you are still signed in with it.");
451
+ return 0;
452
+ }
154
453
  console.log(session ? `Signed out of ${session.site}.` : "nixamp: you were not signed in.");
155
454
  return 0;
156
455
  }
@@ -0,0 +1,63 @@
1
+ import type { Account } from "./accounts.ts";
2
+ import type { Queryable } from "./follows.ts";
3
+ /** Prefixed so a leaked token is greppable, and obvious in a log. */
4
+ export declare const TOKEN_PREFIX = "nxa_";
5
+ /** How long a sign-in lasts. Long, because signing in on a television is work. */
6
+ export declare const SESSION_DAYS = 90;
7
+ /** A session ends; a token a person made for a script does not, unless asked. */
8
+ export type TokenKind = "session" | "cli";
9
+ export interface TokenRecord {
10
+ id: string;
11
+ name: string;
12
+ kind: TokenKind;
13
+ createdAt: number;
14
+ expiresAt: number | null;
15
+ lastUsedAt: number | null;
16
+ }
17
+ export interface IssuedToken extends TokenRecord {
18
+ /** The only time the whole token exists. It is never stored, so never shown twice. */
19
+ token: string;
20
+ }
21
+ /** Make a token and the two halves it is made of. */
22
+ export declare function mintToken(): {
23
+ id: string;
24
+ secret: string;
25
+ token: string;
26
+ };
27
+ /** Is this one of ours, rather than a JWT from the auth module? */
28
+ export declare function looksLikeToken(value: string): boolean;
29
+ /** Pull the id and the secret back out. Anything malformed is not a token. */
30
+ export declare function splitToken(value: string): {
31
+ id: string;
32
+ secret: string;
33
+ } | null;
34
+ export declare function hashSecret(secret: string): string;
35
+ export interface IssueOptions {
36
+ account: Account;
37
+ kind: TokenKind;
38
+ /** What it is for, shown by `nixamp token list`. */
39
+ name?: string;
40
+ /** Milliseconds from now. Null never expires, which is the point of a CLI token. */
41
+ ttlMs?: number | null;
42
+ }
43
+ export declare class Tokens {
44
+ private readonly db;
45
+ private readonly now;
46
+ private ready;
47
+ constructor(db: Queryable, now?: () => number);
48
+ /**
49
+ * Make the table, once per process, on first use. nixamp carries no
50
+ * migration runner, and asking anybody to run SQL by hand before they can
51
+ * sign in is a setup step too many.
52
+ */
53
+ private ensure;
54
+ issue(options: IssueOptions): Promise<IssuedToken>;
55
+ /** The account a token belongs to, or null for one this server will not accept. */
56
+ verify(value: string): Promise<Account | null>;
57
+ /** Everything one account holds, newest first. Secrets are not in the table to leak. */
58
+ list(userId: string, kind?: TokenKind): Promise<TokenRecord[]>;
59
+ /** Scoped to the owner, so an id from somebody else's list revokes nothing. */
60
+ revoke(userId: string, id: string): Promise<boolean>;
61
+ /** Signing out of one place should not sign you out of the build server. */
62
+ revokeToken(value: string): Promise<boolean>;
63
+ }