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/src/session.ts 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.ts";
@@ -26,7 +35,24 @@ export function sessionPath(): string {
26
35
  return join(stateDir(), "session.json");
27
36
  }
28
37
 
29
- export function readSession(): Session | null {
38
+ /**
39
+ * The session on disk, or the one in the environment.
40
+ *
41
+ * `NIXAMP_TOKEN` wins, and is the whole answer for a build server: there is no
42
+ * `nixamp login` to run in a container, and a token pasted into a secret store
43
+ * is the thing a build server can actually hold. It is deliberately not
44
+ * written to disk -- the environment is where it came from and where it ends.
45
+ */
46
+ export function readSession(env: NodeJS.ProcessEnv = process.env): Session | null {
47
+ const fromEnv = env["NIXAMP_TOKEN"];
48
+ if (fromEnv) {
49
+ return {
50
+ site: (env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY).replace(/\/+$/, ""),
51
+ email: "",
52
+ token: fromEnv,
53
+ signedInAt: 0,
54
+ };
55
+ }
30
56
  try {
31
57
  return JSON.parse(readFileSync(sessionPath(), "utf8")) as Session;
32
58
  } catch {
@@ -117,6 +143,16 @@ export interface LoginOptions {
117
143
  email: string;
118
144
  /** Create the account rather than signing in to one. */
119
145
  signUp: boolean;
146
+ /** A provider id to sign in with, `""` for none named. */
147
+ with: string;
148
+ /** Approve in a browser without naming a provider, whoever it is signed in as. */
149
+ device: boolean;
150
+ /** Skip the menu and ask for a password, however the site is configured. */
151
+ password: boolean;
152
+ /** A token made with `nixamp token create`, to keep rather than earn. */
153
+ token: string;
154
+ /** Do not try to open a browser. */
155
+ noBrowser: boolean;
120
156
  fetcher?: typeof fetch;
121
157
  }
122
158
 
@@ -130,13 +166,193 @@ export function parseLoginArgs(argv: string[]): LoginOptions {
130
166
  site: (at("--site") ?? DEFAULT_DIRECTORY).replace(/\/+$/, ""),
131
167
  email: at("--email") ?? argv.find((a) => !a.startsWith("-") && a.includes("@")) ?? "",
132
168
  signUp: argv.includes("--signup") || argv.includes("--sign-up"),
169
+ // --with github, or the bare --github that people type anyway.
170
+ with:
171
+ at("--with") ??
172
+ at("--provider") ??
173
+ argv.find((a) => a === "--github" || a === "--google")?.slice(2) ??
174
+ "",
175
+ device: argv.includes("--device"),
176
+ password: argv.includes("--password"),
177
+ token: at("--token") ?? "",
178
+ noBrowser: argv.includes("--no-browser"),
133
179
  };
134
180
  }
135
181
 
182
+ export interface SiteWays {
183
+ password: boolean;
184
+ device: boolean;
185
+ providers: { id: string; name: string }[];
186
+ }
187
+
188
+ /**
189
+ * What this site will accept. An older nixamp has no such endpoint, and the
190
+ * answer for one is the way in it has always had.
191
+ */
192
+ export async function askWays(site: string, send: typeof fetch): Promise<SiteWays> {
193
+ const fallback: SiteWays = { password: true, device: false, providers: [] };
194
+ try {
195
+ const answer = await send(`${site}/api/v1/auth/providers`);
196
+ if (!answer.ok) return fallback;
197
+ const body = (await answer.json()) as Partial<SiteWays>;
198
+ return {
199
+ password: body.password !== false,
200
+ device: body.device === true,
201
+ providers: Array.isArray(body.providers) ? body.providers : [],
202
+ };
203
+ } catch {
204
+ return fallback;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Show a URL in a browser if there is one to show it in.
210
+ *
211
+ * Best effort by design: over ssh there is no browser and nothing should
212
+ * pretend otherwise, which is why the code and the URL are always printed
213
+ * whether this works or not.
214
+ */
215
+ export function openInBrowser(url: string): void {
216
+ const opener =
217
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
218
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
219
+ try {
220
+ spawn(opener, args, { stdio: "ignore", detached: true }).on("error", () => {}).unref();
221
+ } catch {
222
+ // No browser here. The printed URL is the fallback, and it is enough.
223
+ }
224
+ }
225
+
226
+ const sleep = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
227
+
228
+ export interface DeviceIo {
229
+ say: (line: string) => void;
230
+ wait: (ms: number) => Promise<void>;
231
+ open: (url: string) => void;
232
+ }
233
+
234
+ /**
235
+ * The device grant, from this side.
236
+ *
237
+ * Ask for a code, show it, then poll until somebody approves it in a browser.
238
+ * `slow_down` is obeyed rather than ignored: a server that says to back off is
239
+ * the only warning before it stops answering at all.
240
+ */
241
+ export async function deviceLogin(
242
+ site: string,
243
+ provider: string,
244
+ send: typeof fetch,
245
+ io: DeviceIo,
246
+ ): Promise<{ token: string; email: string } | string> {
247
+ let answer: Response;
248
+ try {
249
+ answer = await send(`${site}/api/v1/auth/device/code`, {
250
+ method: "POST",
251
+ headers: { "content-type": "application/json" },
252
+ body: "{}",
253
+ });
254
+ } catch (error) {
255
+ return `could not reach ${site}: ${(error as Error).message}`;
256
+ }
257
+ if (!answer.ok) return "this nixamp cannot sign a terminal in";
258
+ const grant = (await answer.json().catch(() => ({}))) as {
259
+ device_code?: string;
260
+ user_code?: string;
261
+ verification_uri?: string;
262
+ verification_uri_complete?: string;
263
+ expires_in?: number;
264
+ interval?: number;
265
+ };
266
+ if (!grant.device_code || !grant.user_code) return "this nixamp cannot sign a terminal in";
267
+
268
+ // Straight to the provider when one was named, so the only thing to do in
269
+ // the browser is approve. Otherwise the page asks which.
270
+ const where =
271
+ provider && grant.user_code
272
+ ? `${site}/api/v1/${provider}/oauth/start?device=${encodeURIComponent(grant.user_code)}`
273
+ : (grant.verification_uri_complete ?? grant.verification_uri ?? `${site}/api/v1/auth/device`);
274
+
275
+ io.say("");
276
+ io.say(` Open ${where}`);
277
+ io.say(` Code ${grant.user_code}`);
278
+ io.say("");
279
+ io.say("Waiting for you to approve it...");
280
+ io.open(where);
281
+
282
+ let interval = Math.max(1, grant.interval ?? 5) * 1000;
283
+ const until = Date.now() + Math.max(60, grant.expires_in ?? 600) * 1000;
284
+ while (Date.now() < until) {
285
+ await io.wait(interval);
286
+ let poll: Response;
287
+ try {
288
+ poll = await send(`${site}/api/v1/auth/device/token`, {
289
+ method: "POST",
290
+ headers: { "content-type": "application/json" },
291
+ body: JSON.stringify({ device_code: grant.device_code }),
292
+ });
293
+ } catch {
294
+ // A dropped network mid-wait is not a failed sign-in; keep asking.
295
+ continue;
296
+ }
297
+ const body = (await poll.json().catch(() => ({}))) as { token?: string; email?: string; error?: string };
298
+ if (poll.ok && body.token) return { token: body.token, email: body.email ?? "" };
299
+ if (body.error === "slow_down") {
300
+ interval += 5000;
301
+ continue;
302
+ }
303
+ if (body.error === "authorization_pending") continue;
304
+ if (body.error === "access_denied") return "that sign-in was refused";
305
+ if (body.error === "expired_token") break;
306
+ }
307
+ return "the code expired before it was approved";
308
+ }
309
+
136
310
  /** `nixamp login` / `nixamp signup`. */
137
- export async function login(argv: string[]): Promise<number> {
311
+ export async function login(argv: string[], fetcher: typeof fetch = fetch): Promise<number> {
138
312
  const options = parseLoginArgs(argv);
139
- const send = options.fetcher ?? fetch;
313
+ const send = options.fetcher ?? fetcher;
314
+
315
+ // A token is not a sign-in, it is a token somebody already made. It is
316
+ // checked before it is kept, so a typo fails here rather than at the next
317
+ // command with a message about something else.
318
+ if (options.token) {
319
+ const account = await accountFor(options.site, options.token, send);
320
+ if (account === null) {
321
+ console.error(`nixamp: ${options.site} does not accept that token`);
322
+ return 1;
323
+ }
324
+ writeSession({ site: options.site, email: account, token: options.token, signedInAt: Date.now() });
325
+ console.log(`Signed in to ${options.site} as ${account}.`);
326
+ return 0;
327
+ }
328
+
329
+ // Signing up is still an address and a password: a provider account that has
330
+ // never been here signs up by signing in, which is the point of it.
331
+ const ways = options.password || options.signUp ? null : await askWays(options.site, send);
332
+ const chosen = ways ? await chooseWay(ways, options) : "password";
333
+ if (chosen === null) {
334
+ const offered = (ways?.providers ?? []).map((provider) => provider.id).join(", ");
335
+ console.error(
336
+ offered
337
+ ? `nixamp: ${options.site} cannot sign you in with ${options.with}. It offers: ${offered}.`
338
+ : `nixamp: ${options.site} offers no providers to sign in with.`,
339
+ );
340
+ return 64;
341
+ }
342
+ if (chosen !== "password") {
343
+ const got = await deviceLogin(options.site, chosen === "device" ? "" : chosen, send, {
344
+ say: (line) => console.log(line),
345
+ wait: sleep,
346
+ open: options.noBrowser ? () => {} : openInBrowser,
347
+ });
348
+ if (typeof got === "string") {
349
+ console.error(`nixamp: ${got}`);
350
+ return 1;
351
+ }
352
+ writeSession({ site: options.site, email: got.email, token: got.token, signedInAt: Date.now() });
353
+ console.log(`Signed in to ${options.site} as ${got.email || "your account"}.`);
354
+ return 0;
355
+ }
140
356
 
141
357
  const email = options.email || (await ask("Email: "));
142
358
  if (!email) {
@@ -173,9 +389,135 @@ export async function login(argv: string[]): Promise<number> {
173
389
  return 0;
174
390
  }
175
391
 
392
+ /** The address a token belongs to, or null if the site will not have it. */
393
+ export async function accountFor(site: string, token: string, send: typeof fetch): Promise<string | null> {
394
+ try {
395
+ const answer = await send(`${site}/api/v1/auth/me`, { headers: { authorization: `Bearer ${token}` } });
396
+ if (!answer.ok) return null;
397
+ const body = (await answer.json()) as { account?: { email?: string } };
398
+ return body.account?.email ?? "";
399
+ } catch {
400
+ return null;
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Which way in to use.
406
+ *
407
+ * The menu only appears where it can be answered: a pipe gets the flow it has
408
+ * always had, so a script that feeds an address and a password still works.
409
+ * Null means what was asked for is not on offer here.
410
+ */
411
+ export async function chooseWay(ways: SiteWays, options: LoginOptions): Promise<string | null> {
412
+ if (options.with) {
413
+ return ways.providers.some((provider) => provider.id === options.with) ? options.with : null;
414
+ }
415
+ // Asking for the browser without naming a provider: the page will offer
416
+ // them, and a browser that is already signed in can approve on the spot.
417
+ if (options.device) return ways.device ? "device" : null;
418
+ // Naming an address is asking for the password flow by implication.
419
+ if (!ways.device || options.email || !process.stdin.isTTY) return "password";
420
+ if (ways.providers.length === 0) return "password";
421
+
422
+ console.log("How would you like to sign in?");
423
+ ways.providers.forEach((provider, index) => console.log(` ${index + 1}) ${provider.name}`));
424
+ console.log(` ${ways.providers.length + 1}) Email and password`);
425
+ const typed = await ask(`Choose [1]: `);
426
+ const picked = typed === "" ? 1 : Number(typed);
427
+ if (!Number.isInteger(picked) || picked < 1 || picked > ways.providers.length + 1) {
428
+ console.log("Not one of those, so: email and password.");
429
+ return "password";
430
+ }
431
+ return picked === ways.providers.length + 1 ? "password" : (ways.providers[picked - 1]?.id ?? "password");
432
+ }
433
+
434
+ const day = (at: number | null): string => (at ? new Date(at).toISOString().slice(0, 10) : "never");
435
+
436
+ /**
437
+ * `nixamp token create|list|revoke`, which is how a machine that cannot sign
438
+ * in gets to be signed in. The token is shown once, at creation, because the
439
+ * server keeps only its hash and has nothing to show a second time.
440
+ */
441
+ export async function tokens(argv: string[], fetcher: typeof fetch = fetch): Promise<number> {
442
+ const session = readSession();
443
+ if (session === null) {
444
+ console.error("nixamp: not signed in. Try `nixamp login`.");
445
+ return 1;
446
+ }
447
+ const [command = "list", ...rest] = argv;
448
+ const where = `${session.site}/api/v1/auth/tokens`;
449
+ const headers = { authorization: `Bearer ${session.token}`, "content-type": "application/json" };
450
+
451
+ try {
452
+ if (command === "create" || command === "new" || command === "add") {
453
+ const nameAt = rest.indexOf("--name");
454
+ const name = (nameAt === -1 ? rest.find((a) => !a.startsWith("-")) : rest[nameAt + 1]) ?? "";
455
+ const answer = await fetcher(where, { method: "POST", headers, body: JSON.stringify({ name }) });
456
+ const body = (await answer.json().catch(() => ({}))) as { token?: string; id?: string; error?: string };
457
+ if (!answer.ok || !body.token) {
458
+ console.error(`nixamp: ${body.error ?? `could not make a token (${answer.status})`}`);
459
+ return 1;
460
+ }
461
+ console.log(body.token);
462
+ console.error("");
463
+ console.error("Keep it somewhere safe: this is the only time it is shown.");
464
+ console.error("Use it with NIXAMP_TOKEN=... or `nixamp login --token ...`.");
465
+ return 0;
466
+ }
467
+
468
+ if (command === "revoke" || command === "rm" || command === "delete") {
469
+ const id = rest.find((a) => !a.startsWith("-")) ?? "";
470
+ if (!id) {
471
+ console.error("nixamp: which token? `nixamp token list` shows their ids.");
472
+ return 64;
473
+ }
474
+ const answer = await fetcher(`${where}/${encodeURIComponent(id)}`, { method: "DELETE", headers });
475
+ if (!answer.ok) {
476
+ console.error(`nixamp: ${answer.status === 404 ? "no token with that id" : "could not revoke it"}`);
477
+ return 1;
478
+ }
479
+ console.log(`Revoked ${id}.`);
480
+ return 0;
481
+ }
482
+
483
+ if (command === "list" || command === "ls") {
484
+ const answer = await fetcher(where, { headers });
485
+ const body = (await answer.json().catch(() => ({}))) as {
486
+ tokens?: { id: string; name: string; createdAt: number; lastUsedAt: number | null }[];
487
+ error?: string;
488
+ };
489
+ if (!answer.ok) {
490
+ console.error(`nixamp: ${body.error ?? `could not list them (${answer.status})`}`);
491
+ return 1;
492
+ }
493
+ const list = body.tokens ?? [];
494
+ if (list.length === 0) {
495
+ console.log("No tokens. `nixamp token create --name ci` makes one.");
496
+ return 0;
497
+ }
498
+ for (const token of list) {
499
+ console.log(`${token.id} ${day(token.createdAt)} last used ${day(token.lastUsedAt)} ${token.name}`);
500
+ }
501
+ return 0;
502
+ }
503
+
504
+ console.error(`nixamp: no such token command: ${command}`);
505
+ return 64;
506
+ } catch (error) {
507
+ console.error(`nixamp: could not reach ${session.site}: ${(error as Error).message}`);
508
+ return 69;
509
+ }
510
+ }
511
+
176
512
  export function logout(): number {
177
513
  const session = readSession();
178
514
  clearSession();
515
+ if (process.env["NIXAMP_TOKEN"]) {
516
+ // Deleting the file would not change anything while this is set, and
517
+ // saying nothing would leave somebody wondering why they are still in.
518
+ console.log("nixamp: NIXAMP_TOKEN is set in the environment, so you are still signed in with it.");
519
+ return 0;
520
+ }
179
521
  console.log(session ? `Signed out of ${session.site}.` : "nixamp: you were not signed in.");
180
522
  return 0;
181
523
  }
package/src/tokens.ts ADDED
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Tokens that are not passwords.
3
+ *
4
+ * Two things arrive at the same door and are the same kind of thing on the
5
+ * way in: the session a browser or a terminal gets after signing in, and the
6
+ * token a person makes on purpose to paste into a CI job. Both are opaque
7
+ * strings this server issued, both can be listed and revoked, and neither can
8
+ * be turned back into a password.
9
+ *
10
+ * They are deliberately NOT the JWT the auth module hands out. A JWT cannot be
11
+ * withdrawn before it expires -- nothing looks it up, that is the point of it
12
+ * -- and a token somebody pasted into a build server is exactly the one you
13
+ * want to be able to kill from a laptop. So the secret half is hashed like a
14
+ * password, the row is what makes the token real, and deleting the row is what
15
+ * makes it stop working.
16
+ *
17
+ * The shape is `nxa_<id>_<secret>`. The id is looked up; the secret is compared
18
+ * against its hash in constant time. Carrying the id means a token is one index
19
+ * hit rather than a scan over every hash on the site.
20
+ */
21
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
22
+ import type { Account } from "./accounts.ts";
23
+ import type { Queryable } from "./follows.ts";
24
+
25
+ /** Prefixed so a leaked token is greppable, and obvious in a log. */
26
+ export const TOKEN_PREFIX = "nxa_";
27
+
28
+ /** How long a sign-in lasts. Long, because signing in on a television is work. */
29
+ export const SESSION_DAYS = 90;
30
+
31
+ /** A session ends; a token a person made for a script does not, unless asked. */
32
+ export type TokenKind = "session" | "cli";
33
+
34
+ export interface TokenRecord {
35
+ id: string;
36
+ name: string;
37
+ kind: TokenKind;
38
+ createdAt: number;
39
+ expiresAt: number | null;
40
+ lastUsedAt: number | null;
41
+ }
42
+
43
+ export interface IssuedToken extends TokenRecord {
44
+ /** The only time the whole token exists. It is never stored, so never shown twice. */
45
+ token: string;
46
+ }
47
+
48
+ const TABLE = "nixamp_tokens";
49
+
50
+ const SCHEMA = `
51
+ CREATE TABLE IF NOT EXISTS ${TABLE} (
52
+ id TEXT PRIMARY KEY,
53
+ user_id TEXT NOT NULL,
54
+ email TEXT NOT NULL DEFAULT '',
55
+ kind TEXT NOT NULL DEFAULT 'session',
56
+ name TEXT NOT NULL DEFAULT '',
57
+ secret_hash TEXT NOT NULL,
58
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
59
+ expires_at TIMESTAMPTZ,
60
+ last_used_at TIMESTAMPTZ
61
+ );
62
+ CREATE INDEX IF NOT EXISTS ${TABLE}_user ON ${TABLE} (user_id);
63
+ `;
64
+
65
+ /** Make a token and the two halves it is made of. */
66
+ export function mintToken(): { id: string; secret: string; token: string } {
67
+ const id = randomBytes(8).toString("hex");
68
+ const secret = randomBytes(32).toString("base64url");
69
+ return { id, secret, token: `${TOKEN_PREFIX}${id}_${secret}` };
70
+ }
71
+
72
+ /** Is this one of ours, rather than a JWT from the auth module? */
73
+ export function looksLikeToken(value: string): boolean {
74
+ return value.startsWith(TOKEN_PREFIX);
75
+ }
76
+
77
+ /** Pull the id and the secret back out. Anything malformed is not a token. */
78
+ export function splitToken(value: string): { id: string; secret: string } | null {
79
+ if (!looksLikeToken(value)) return null;
80
+ const rest = value.slice(TOKEN_PREFIX.length);
81
+ const cut = rest.indexOf("_");
82
+ if (cut <= 0) return null;
83
+ const id = rest.slice(0, cut);
84
+ const secret = rest.slice(cut + 1);
85
+ if (!/^[0-9a-f]+$/.test(id) || secret.length < 16) return null;
86
+ return { id, secret };
87
+ }
88
+
89
+ export function hashSecret(secret: string): string {
90
+ // A token secret is 32 random bytes, not a password: there is nothing to
91
+ // guess by dictionary, so a slow KDF would only slow down every request.
92
+ return createHash("sha256").update(secret).digest("hex");
93
+ }
94
+
95
+ /** Compare without letting the time taken say how much of it matched. */
96
+ function sameHash(left: string, right: string): boolean {
97
+ const a = Buffer.from(left, "utf8");
98
+ const b = Buffer.from(right, "utf8");
99
+ return a.length === b.length && timingSafeEqual(a, b);
100
+ }
101
+
102
+ function asTime(value: unknown): number | null {
103
+ if (value instanceof Date) return value.getTime();
104
+ if (typeof value === "string") {
105
+ const at = Date.parse(value);
106
+ return Number.isNaN(at) ? null : at;
107
+ }
108
+ if (typeof value === "number") return value;
109
+ return null;
110
+ }
111
+
112
+ function toRecord(row: Record<string, unknown>): TokenRecord {
113
+ return {
114
+ id: String(row["id"] ?? ""),
115
+ name: String(row["name"] ?? ""),
116
+ kind: row["kind"] === "cli" ? "cli" : "session",
117
+ createdAt: asTime(row["created_at"]) ?? 0,
118
+ expiresAt: asTime(row["expires_at"]),
119
+ lastUsedAt: asTime(row["last_used_at"]),
120
+ };
121
+ }
122
+
123
+ export interface IssueOptions {
124
+ account: Account;
125
+ kind: TokenKind;
126
+ /** What it is for, shown by `nixamp token list`. */
127
+ name?: string;
128
+ /** Milliseconds from now. Null never expires, which is the point of a CLI token. */
129
+ ttlMs?: number | null;
130
+ }
131
+
132
+ export class Tokens {
133
+ private ready: Promise<void> | null = null;
134
+
135
+ constructor(
136
+ private readonly db: Queryable,
137
+ private readonly now: () => number = Date.now,
138
+ ) {}
139
+
140
+ /**
141
+ * Make the table, once per process, on first use. nixamp carries no
142
+ * migration runner, and asking anybody to run SQL by hand before they can
143
+ * sign in is a setup step too many.
144
+ */
145
+ private async ensure(): Promise<void> {
146
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
147
+ await this.ready;
148
+ }
149
+
150
+ async issue(options: IssueOptions): Promise<IssuedToken> {
151
+ await this.ensure();
152
+ const { id, secret, token } = mintToken();
153
+ const ttl = options.ttlMs === undefined ? SESSION_DAYS * 86_400_000 : options.ttlMs;
154
+ const createdAt = this.now();
155
+ const expiresAt = ttl === null ? null : createdAt + ttl;
156
+ await this.db.query(
157
+ `INSERT INTO ${TABLE} (id, user_id, email, kind, name, secret_hash, created_at, expires_at)
158
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
159
+ [
160
+ id,
161
+ options.account.id,
162
+ options.account.email,
163
+ options.kind,
164
+ options.name ?? "",
165
+ hashSecret(secret),
166
+ new Date(createdAt).toISOString(),
167
+ expiresAt === null ? null : new Date(expiresAt).toISOString(),
168
+ ],
169
+ );
170
+ return {
171
+ id,
172
+ token,
173
+ name: options.name ?? "",
174
+ kind: options.kind,
175
+ createdAt,
176
+ expiresAt,
177
+ lastUsedAt: null,
178
+ };
179
+ }
180
+
181
+ /** The account a token belongs to, or null for one this server will not accept. */
182
+ async verify(value: string): Promise<Account | null> {
183
+ const parts = splitToken(value);
184
+ if (parts === null) return null;
185
+ await this.ensure();
186
+ const { rows } = await this.db.query(
187
+ `SELECT id, user_id, email, secret_hash, expires_at FROM ${TABLE} WHERE id = $1`,
188
+ [parts.id],
189
+ );
190
+ const row = rows[0];
191
+ if (!row) return null;
192
+ if (!sameHash(String(row["secret_hash"] ?? ""), hashSecret(parts.secret))) return null;
193
+
194
+ const expiresAt = asTime(row["expires_at"]);
195
+ if (expiresAt !== null && expiresAt <= this.now()) {
196
+ // Tidy it away on the way past rather than running a sweeper: an expired
197
+ // token is only ever noticed when somebody tries to use it.
198
+ await this.db.query(`DELETE FROM ${TABLE} WHERE id = $1`, [parts.id]).catch(() => {});
199
+ return null;
200
+ }
201
+
202
+ // Last used is what makes `nixamp token list` worth reading -- it is how
203
+ // you tell the token you forgot about from the one CI depends on.
204
+ await this.db
205
+ .query(`UPDATE ${TABLE} SET last_used_at = $2 WHERE id = $1`, [
206
+ parts.id,
207
+ new Date(this.now()).toISOString(),
208
+ ])
209
+ .catch(() => {});
210
+
211
+ return { id: String(row["user_id"] ?? ""), email: String(row["email"] ?? "") };
212
+ }
213
+
214
+ /** Everything one account holds, newest first. Secrets are not in the table to leak. */
215
+ async list(userId: string, kind?: TokenKind): Promise<TokenRecord[]> {
216
+ await this.ensure();
217
+ const { rows } = await this.db.query(
218
+ `SELECT id, name, kind, created_at, expires_at, last_used_at FROM ${TABLE}
219
+ WHERE user_id = $1 ${kind ? "AND kind = $2" : ""}
220
+ ORDER BY created_at DESC`,
221
+ kind ? [userId, kind] : [userId],
222
+ );
223
+ return rows.map(toRecord);
224
+ }
225
+
226
+ /** Scoped to the owner, so an id from somebody else's list revokes nothing. */
227
+ async revoke(userId: string, id: string): Promise<boolean> {
228
+ await this.ensure();
229
+ const { rows } = await this.db.query(`DELETE FROM ${TABLE} WHERE user_id = $1 AND id = $2 RETURNING id`, [
230
+ userId,
231
+ id,
232
+ ]);
233
+ return rows.length > 0;
234
+ }
235
+
236
+ /** Signing out of one place should not sign you out of the build server. */
237
+ async revokeToken(value: string): Promise<boolean> {
238
+ const parts = splitToken(value);
239
+ if (parts === null) return false;
240
+ await this.ensure();
241
+ const { rows } = await this.db.query(
242
+ `DELETE FROM ${TABLE} WHERE id = $1 AND kind = 'session' RETURNING id`,
243
+ [parts.id],
244
+ );
245
+ return rows.length > 0;
246
+ }
247
+ }