message-cp 0.0.0-stage → 0.1.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/login.js ADDED
@@ -0,0 +1,530 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { accountName, issueToken } from "./accounts.js";
3
+ import { KeyResetError, PersonError, PurgeError, addPerson, endTokenAgents, purgeAgent, removePerson, resetAgentKey, setPersonAdmin } from "./agents.js";
4
+ import { publicUrl } from "./connect.js";
5
+ import { addWebhook, formatOn } from "./webhooks.js";
6
+ // `message-cp login-link`: a dashboard link carrying a one-time code, made on the server, that signs a browser in as the
7
+ // account's owner (a full token) or as one of its people (a person token), so nobody copies a token. The code is
8
+ // printed once, by the CLI, and stored only as a hash; POST /api/login redeems it once for a new token, which
9
+ // `token list` shows and `token revoke` stops like any other, and gives the browser a sign-in session for that token
10
+ // rather than the token itself. Nothing here logs anything.
11
+ //
12
+ // An invite is the same kind of code, for a person, made by an admin person's token over HTTP (POST /api/people and
13
+ // /api/invites) rather than on the server. It lasts INVITE_TTL_MS, is listed (never its code) until it is used, cancelled
14
+ // or expires, and can be cancelled.
15
+ export class LoginError extends Error {
16
+ }
17
+ /** The admin person a token acts as was removed, or is no longer an admin, by the time their request was carried out. */
18
+ export class NotAdminError extends Error {
19
+ constructor() {
20
+ super("This person is no longer an admin of the account");
21
+ }
22
+ }
23
+ /**
24
+ * Inside the caller's transaction: refuses with `NotAdminError` unless `admin`, an admin person token's person, is
25
+ * still a live admin of `account`, found by row. The token was checked when the request began, but its body is read
26
+ * after, and the person may have been removed or unmarked meanwhile.
27
+ */
28
+ function requireAdmin(db, account, admin) {
29
+ const live = db
30
+ .prepare("SELECT 1 FROM agents WHERE id = ? AND account_id = ? AND person = 1 AND admin = 1 AND removed_at IS NULL")
31
+ .get(admin, account);
32
+ if (!live)
33
+ throw new NotAdminError();
34
+ }
35
+ /** Why a code gave no token: used once already, an invite cancelled, or unknown, expired, or for a person no longer live. */
36
+ export class LoginRefused extends Error {
37
+ reason;
38
+ constructor(reason) {
39
+ super(reason === "used" ? "This link was already used." : reason === "cancelled" ? "This link was cancelled." : "This link has expired or is not valid.");
40
+ this.reason = reason;
41
+ }
42
+ }
43
+ /** `login-link` codes not yet used or expired, kept at once across the server; past this a new one is refused. */
44
+ export const MAX_LOGIN_CODES = 1000;
45
+ /**
46
+ * Invites one account has made that have not expired, used and cancelled ones included; past this its next is refused.
47
+ * Counted per account, apart from MAX_LOGIN_CODES, so no account's invites refuse another's or `login-link`, and counting
48
+ * cancelled ones too keeps a loop of making and cancelling from growing the table.
49
+ */
50
+ export const MAX_INVITES_PER_ACCOUNT = 100;
51
+ /** How long a link works once it is made. */
52
+ export const LOGIN_CODE_TTL_MS = 5 * 60 * 1000;
53
+ /** How long an invite works once it is made. */
54
+ export const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
55
+ const CODE = /^[0-9a-f]{64}$/;
56
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
57
+ /**
58
+ * The dashboard's address for the public URL `raw`, checked by `publicUrl` as `connect --url` and `serve --public-url`
59
+ * are: its path with a trailing `/`, a trailing `/mcp` being the same as none, as for `mcpEndpoint`. Set on the parsed
60
+ * URL, never appended to the string.
61
+ */
62
+ export function dashboardUrl(raw) {
63
+ const url = publicUrl(raw);
64
+ const dashboard = new URL(url.href);
65
+ dashboard.pathname = `${url.pathname.replace(/\/+$/, "").replace(/\/mcp$/, "")}/`;
66
+ return dashboard;
67
+ }
68
+ /** The link for `code` at the dashboard `base`: the code in the fragment, which a browser never sends in a request. */
69
+ export function loginLink(base, code) {
70
+ const link = new URL(base.href);
71
+ link.hash = `code=${code}`;
72
+ return link;
73
+ }
74
+ /**
75
+ * A new code for the account named `account`, signing in as its live person `person` (any case) if given, else with a
76
+ * full token of the account. One transaction: deletes expired codes, refuses past MAX_LOGIN_CODES, an unknown account,
77
+ * and a person name that is unknown, removed or an agent's, then stores the code's hash with its expiry.
78
+ */
79
+ export const makeLoginCode = (db, account, person, now = Date.now()) => db.transaction(() => {
80
+ const acct = db.prepare("SELECT id, name FROM accounts WHERE name = ?").get(account);
81
+ if (!acct)
82
+ throw new LoginError(`No account named '${account}'.`);
83
+ let bound;
84
+ if (person !== undefined) {
85
+ const live = db
86
+ .prepare("SELECT id, name, person FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL")
87
+ .get(acct.id, person);
88
+ if (live?.person === 0)
89
+ throw new LoginError(`The name '${live.name}' belongs to an agent, not a person.`);
90
+ if (!live)
91
+ throw new LoginError(`No person named '${person}' in account '${acct.name}'.`);
92
+ bound = { id: live.id, name: live.name };
93
+ }
94
+ const { code, expiresAt } = storeCode(db, acct.id, bound?.id ?? null, false, now);
95
+ return { code, account: acct.name, ...(bound && { person: bound.name }), expiresAt };
96
+ }).immediate();
97
+ /**
98
+ * Inside the caller's transaction: deletes expired codes, then stores the hash of a new code for `account` (and
99
+ * `person`, if not null): with `invite`, an invite lasting INVITE_TTL_MS, refused past MAX_INVITES_PER_ACCOUNT; else a
100
+ * `login-link` code lasting LOGIN_CODE_TTL_MS, refused past MAX_LOGIN_CODES.
101
+ */
102
+ function storeCode(db, account, person, invite, now) {
103
+ db.prepare("DELETE FROM login_codes WHERE expires_at <= ?").run(now);
104
+ if (invite) {
105
+ const made = db.prepare("SELECT count(*) FROM login_codes WHERE invite = 1 AND account_id = ?").pluck().get(account);
106
+ if (made >= MAX_INVITES_PER_ACCOUNT) {
107
+ throw new LoginError(`This account has made ${MAX_INVITES_PER_ACCOUNT} invites in the last 7 days; try again once some expire.`);
108
+ }
109
+ }
110
+ else {
111
+ const pending = db.prepare("SELECT count(*) FROM login_codes WHERE invite = 0 AND used_at IS NULL").pluck().get();
112
+ if (pending >= MAX_LOGIN_CODES)
113
+ throw new LoginError(`There are already ${MAX_LOGIN_CODES} unused links; try again once some expire.`);
114
+ }
115
+ const code = randomBytes(32).toString("hex");
116
+ const expiresAt = now + (invite ? INVITE_TTL_MS : LOGIN_CODE_TTL_MS);
117
+ const id = db
118
+ .prepare("INSERT INTO login_codes (code_hash, account_id, person_id, expires_at, invite, created_at) VALUES (?, ?, ?, ?, ?, ?) RETURNING id")
119
+ .pluck()
120
+ .get(sha256(code), account, person, expiresAt, invite ? 1 : 0, now);
121
+ return { id, code, expiresAt };
122
+ }
123
+ /**
124
+ * For POST /api/people: adds the person `name`, an admin with `admin`, to the account `account` as `addPerson` does,
125
+ * and makes their first invite, in one transaction, so a refused name or a refused invite stores neither. `by` is the
126
+ * row of the admin person asking. Throws `NotAdminError` unless they are still a live admin of the account, `PersonError`
127
+ * as `addPerson` does and `LoginError` past MAX_INVITES_PER_ACCOUNT.
128
+ */
129
+ export const addInvitedPerson = (db, account, by, name, admin, now = Date.now()) => db.transaction(() => {
130
+ requireAdmin(db, account, by);
131
+ const acct = db.prepare("SELECT name FROM accounts WHERE id = ?").pluck().get(account);
132
+ if (acct === undefined)
133
+ throw new PersonError("No such account.");
134
+ // A nested transaction is a savepoint inside this one.
135
+ const person = addPerson(db, acct, name, admin);
136
+ const id = db
137
+ .prepare("SELECT id FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL AND person = 1")
138
+ .pluck()
139
+ .get(account, person.name);
140
+ const { code, expiresAt, id: invite } = storeCode(db, account, id, true, now);
141
+ return { name: person.name, admin: person.admin, invite: { id: invite, code, expiresAt } };
142
+ }).immediate();
143
+ /**
144
+ * For POST /api/invites: a new invite for the live person `person` (any case) of the account `account`, or undefined,
145
+ * storing nothing, for a name no live person of the account has, an agent's included. Earlier invites stay as they are.
146
+ * `by` is the row of the admin person asking. Throws `NotAdminError` unless they are still a live admin of the account,
147
+ * and `LoginError` past MAX_INVITES_PER_ACCOUNT.
148
+ */
149
+ export const makeInvite = (db, account, by, person, now = Date.now()) => db.transaction(() => {
150
+ requireAdmin(db, account, by);
151
+ const live = db
152
+ .prepare("SELECT id, name, admin FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL AND person = 1")
153
+ .get(account, person);
154
+ if (!live)
155
+ return undefined;
156
+ const { id, code, expiresAt } = storeCode(db, account, live.id, true, now);
157
+ return { name: live.name, admin: live.admin === 1, invite: { id, code, expiresAt } };
158
+ }).immediate();
159
+ /** An admin person unmarking or removing their own person, which they leave to another admin or the server. */
160
+ export class OwnPersonError extends Error {
161
+ constructor() {
162
+ super("An admin cannot unmark or remove themselves. Ask another admin, or run message-cp person on the server");
163
+ }
164
+ }
165
+ /**
166
+ * Inside the caller's transaction: the account's name, after refusing with `NotAdminError` unless `by` is still a live
167
+ * admin of `account`, and the row of its live person `name` (any case), or undefined for a name no live person of the
168
+ * account has, an agent's included.
169
+ */
170
+ function personOf(db, account, by, name) {
171
+ requireAdmin(db, account, by);
172
+ const id = db
173
+ .prepare("SELECT id FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL AND person = 1")
174
+ .pluck()
175
+ .get(account, name);
176
+ if (id === undefined)
177
+ return undefined;
178
+ return { account: db.prepare("SELECT name FROM accounts WHERE id = ?").pluck().get(account), id };
179
+ }
180
+ /**
181
+ * For PATCH /api/people: marks the live person `name` (any case) of the account `account` an admin, or unmarks them
182
+ * with `admin` false, as `setPersonAdmin` does, or answers undefined, changing nothing, for a name no live person of the
183
+ * account has. `by` is the row of the admin person asking. Throws `NotAdminError` unless they are still a live admin of
184
+ * the account, and `OwnPersonError` if they would unmark themselves.
185
+ */
186
+ export const setAdminBy = (db, account, by, name, admin) => db.transaction(() => {
187
+ const person = personOf(db, account, by, name);
188
+ if (person === undefined)
189
+ return undefined;
190
+ if (person.id === by && !admin)
191
+ throw new OwnPersonError();
192
+ // A nested transaction is a savepoint inside this one.
193
+ const marked = setPersonAdmin(db, person.account, name, admin);
194
+ return { name: marked.name, admin: marked.admin };
195
+ }).immediate();
196
+ // A removed person's tokens and sessions are kept: `accountForToken` and LIVE_SIGNIN_TOKEN refuse them once their person
197
+ // is gone. Counted while the person is still live, so these are the ones the removal stops. Found through
198
+ // `tokens_live_by_person`, then each token's sessions through `dashboard_sessions_by_token`.
199
+ export const COUNT_PERSON_TOKENS = "SELECT COUNT(*) FROM tokens WHERE person_id = ? AND account_id = ? AND revoked_at IS NULL";
200
+ export const COUNT_PERSON_SIGNINS = `SELECT COUNT(*) FROM tokens JOIN dashboard_sessions ON dashboard_sessions.token_id = tokens.id
201
+ WHERE tokens.person_id = ? AND tokens.account_id = ? AND tokens.revoked_at IS NULL`;
202
+ /**
203
+ * For DELETE /api/people: removes the live person `name` (any case) of the account `account` as `removePerson` does,
204
+ * and answers its counts with `tokens` and `signins`, how many of the person's tokens and dashboard sign-ins were
205
+ * accepted just before and are refused from now on, or answers undefined, changing nothing, for a name no live person
206
+ * of the account has. Nothing is revoked or deleted for the counts. `by` is the row of the admin person asking. Throws
207
+ * `NotAdminError` unless they are still a live admin of the account, and `OwnPersonError` if they would remove themselves.
208
+ */
209
+ export const removePersonBy = (db, account, by, name) => db.transaction(() => {
210
+ const person = personOf(db, account, by, name);
211
+ if (person === undefined)
212
+ return undefined;
213
+ if (person.id === by)
214
+ throw new OwnPersonError();
215
+ const tokens = db.prepare(COUNT_PERSON_TOKENS).pluck().get(person.id, account);
216
+ const signins = db.prepare(COUNT_PERSON_SIGNINS).pluck().get(person.id, account);
217
+ return { ...removePerson(db, person.account, name), tokens, signins };
218
+ }).immediate();
219
+ /** One page of the account's live people, by name. Found by the account's id only, so it never names another account's. */
220
+ export function listAccountPeople(db, account, limit, offset) {
221
+ const rows = db
222
+ .prepare("SELECT name, admin, registered_at FROM agents WHERE account_id = ? AND person = 1 AND removed_at IS NULL ORDER BY name LIMIT ? OFFSET ?")
223
+ .all(account, limit, offset);
224
+ return rows.map((r) => ({ name: r.name, admin: r.admin === 1, addedAt: new Date(r.registered_at).toISOString() }));
225
+ }
226
+ // A removed agent's tokens are kept: `accountForToken` refuses them once their agent is gone. Counted while the agent is
227
+ // still live, so these are the ones the removal stops. Found through `tokens_live_by_agent`.
228
+ export const COUNT_AGENT_TOKENS = "SELECT COUNT(*) FROM tokens WHERE agent_id = ? AND account_id = ? AND revoked_at IS NULL";
229
+ /**
230
+ * For DELETE /api/agents: removes the agent `name` (any case) of the account `account`, live or only left as removed
231
+ * rows, as `purgeAgent` does, and answers its counts with `tokens`, how many of the live agent's tokens were accepted
232
+ * just before and are refused from now on (0 for an agent left only as removed rows), or answers undefined, changing
233
+ * nothing, for a name no agent of the account has had, a live person's included. Nothing is revoked for the count.
234
+ * `by` is the row of the admin person asking. Throws `NotAdminError` unless they are still a live admin of the account.
235
+ */
236
+ export const removeAgentBy = (db, account, by, name) => db.transaction(() => {
237
+ requireAdmin(db, account, by);
238
+ const acct = db.prepare("SELECT name FROM accounts WHERE id = ?").pluck().get(account);
239
+ if (acct === undefined)
240
+ return undefined;
241
+ const live = db
242
+ .prepare("SELECT id FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL AND person = 0")
243
+ .pluck()
244
+ .get(account, name);
245
+ const tokens = live === undefined ? 0 : db.prepare(COUNT_AGENT_TOKENS).pluck().get(live, account);
246
+ try {
247
+ // A nested transaction is a savepoint inside this one.
248
+ const purged = purgeAgent(db, acct, name);
249
+ return { name: purged.name, messages: purged.messages, mentions: purged.mentions, tokens };
250
+ }
251
+ catch (err) {
252
+ if (err instanceof PurgeError)
253
+ return undefined;
254
+ throw err;
255
+ }
256
+ }).immediate();
257
+ /**
258
+ * For POST /api/agents/key: gives the live agent `name` (any case) of the account `account` a new key as
259
+ * `resetAgentKey` does, ending its sessions, and answers its name and the key, or undefined, changing nothing, for a
260
+ * name no live agent of the account has, a person's included. `by` is the row of the admin person asking. Throws
261
+ * `NotAdminError` unless they are still a live admin of the account.
262
+ */
263
+ export const resetAgentKeyBy = (db, account, by, name) => db.transaction(() => {
264
+ requireAdmin(db, account, by);
265
+ const acct = db.prepare("SELECT name FROM accounts WHERE id = ?").pluck().get(account);
266
+ if (acct === undefined)
267
+ return undefined;
268
+ try {
269
+ const reset = resetAgentKey(db, acct, name);
270
+ return { name: reset.name, key: reset.key };
271
+ }
272
+ catch (err) {
273
+ if (err instanceof KeyResetError)
274
+ return undefined;
275
+ throw err;
276
+ }
277
+ }).immediate();
278
+ /**
279
+ * For GET /api/webhooks: one page of the account's webhooks, oldest first. `by` is the row of the admin person asking.
280
+ * Throws `NotAdminError` unless they are still a live admin of the account.
281
+ */
282
+ export const listWebhooksBy = (db, account, by, limit, offset) => db.transaction(() => {
283
+ requireAdmin(db, account, by);
284
+ const rows = db
285
+ .prepare(`SELECT id, url, event, name, include_text, header_name, max_per_hour, fixed_body IS NOT NULL AS fixed
286
+ FROM webhooks WHERE account_id = ? ORDER BY id LIMIT ? OFFSET ?`)
287
+ .all(account, limit, offset);
288
+ return rows.map((r) => ({
289
+ id: r.id,
290
+ on: formatOn(r),
291
+ origin: new URL(r.url).origin,
292
+ includeText: r.include_text === 1,
293
+ headerName: r.header_name,
294
+ maxPerHour: r.max_per_hour,
295
+ fixedBody: r.fixed === 1,
296
+ }));
297
+ }).immediate();
298
+ /**
299
+ * For POST /api/webhooks: adds a webhook to the account as `addWebhook` does, with its checks, and answers it as GET
300
+ * /api/webhooks lists it plus its secret, shown this once. `by` is the row of the admin person asking. Throws
301
+ * `NotAdminError` unless they are still a live admin of the account, and `WebhookError` for anything `addWebhook`
302
+ * refuses, adding nothing either way.
303
+ */
304
+ export const addWebhookBy = (db, account, by, w) => db.transaction(() => {
305
+ requireAdmin(db, account, by);
306
+ // Inside this transaction addWebhook's own runs as a savepoint.
307
+ const added = addWebhook(db, accountName(db, account), w.url, w.on, w.includeText, w.header, w.maxPerHour, w.body);
308
+ return {
309
+ id: added.id,
310
+ on: formatOn(added),
311
+ origin: new URL(added.url).origin,
312
+ includeText: added.includeText,
313
+ headerName: added.headerName,
314
+ maxPerHour: added.maxPerHour,
315
+ fixedBody: added.fixedBody,
316
+ secret: added.secret,
317
+ };
318
+ }).immediate();
319
+ /**
320
+ * For DELETE /api/webhooks: deletes the account's webhook `id`, its queued and past deliveries going with it through
321
+ * `ON DELETE CASCADE`, and answers its id and how many deliveries went, or undefined, changing nothing, for an id the
322
+ * account has no webhook with, another account's included. `by` is the row of the admin person asking. Throws
323
+ * `NotAdminError` unless they are still a live admin of the account.
324
+ */
325
+ export const removeWebhookBy = (db, account, by, id) => db.transaction(() => {
326
+ requireAdmin(db, account, by);
327
+ if (!db.prepare("SELECT 1 FROM webhooks WHERE id = ? AND account_id = ?").get(id, account))
328
+ return undefined;
329
+ const deliveries = db.prepare("SELECT count(*) FROM webhook_deliveries WHERE webhook_id = ?").pluck().get(id);
330
+ db.prepare("DELETE FROM webhooks WHERE id = ? AND account_id = ?").run(id, account);
331
+ return { id, deliveries };
332
+ }).immediate();
333
+ /**
334
+ * For DELETE /api/approvals: revokes the account's unrevoked token `id` that the OAuth code exchange issued, as `token
335
+ * revoke` does, and answers its id with `agents`, the names of the agents it ended: for a token that registers its own
336
+ * agents, each live agent it registered, ended as `endTokenAgents` ends them, in name order; none for a single-agent
337
+ * approval, whose agent is kept. Undefined, changing nothing, for any other id: another account's token, one not issued
338
+ * through OAuth, one already revoked, or an unknown one. `by` is the row of the admin person asking. Throws
339
+ * `NotAdminError` unless they are still a live admin of the account.
340
+ */
341
+ export const revokeApprovalBy = (db, account, by, id) => db.transaction(() => {
342
+ requireAdmin(db, account, by);
343
+ const changed = db
344
+ .prepare("UPDATE tokens SET revoked_at = ? WHERE id = ? AND account_id = ? AND oauth = 1 AND revoked_at IS NULL")
345
+ .run(Date.now(), id, account).changes;
346
+ return changed === 0 ? undefined : { id, agents: endTokenAgents(db, account, id) };
347
+ }).immediate();
348
+ /**
349
+ * For GET /api/tokens: one page of the account's unrevoked tokens, oldest first, each with its kind as `token list`
350
+ * gives it. `MESSAGE_CP_HTTP_TOKEN` has no row, so it is never listed. `by` is the row of the admin person asking.
351
+ * Throws `NotAdminError` unless they are still a live admin of the account.
352
+ */
353
+ export const listTokensBy = (db, account, by, limit, offset) => db.transaction(() => {
354
+ requireAdmin(db, account, by);
355
+ // The bound agent and person are looked for in the token's own account, and only while live, as `accountForToken`
356
+ // accepts them.
357
+ const rows = db
358
+ .prepare(`SELECT tokens.id, tokens.read_only, tokens.agent_only, tokens.agent_id, tokens.person_id, tokens.registers,
359
+ tokens.created_at, tokens.oauth, tokens.client_name, tokens.client_host, tokens.last_used_at, agents.name AS agent,
360
+ people.name AS person
361
+ FROM tokens LEFT JOIN agents ON agents.id = tokens.agent_id AND agents.account_id = tokens.account_id
362
+ AND agents.removed_at IS NULL AND agents.person = 0
363
+ LEFT JOIN agents people ON people.id = tokens.person_id AND people.account_id = tokens.account_id
364
+ AND people.removed_at IS NULL AND people.person = 1
365
+ WHERE tokens.account_id = ? AND tokens.revoked_at IS NULL ORDER BY tokens.id LIMIT ? OFFSET ?`)
366
+ .all(account, limit, offset);
367
+ return rows.map((r) => ({
368
+ id: r.id,
369
+ // As `listTokens`: a person token's row has read_only set too (see `issueToken`), so its person comes first.
370
+ kind: r.person_id !== null ? "person" : r.agent_only === 1 ? "agent" : r.read_only === 1 ? "read-only" : "full",
371
+ ...(r.person_id !== null && { person: r.person }),
372
+ ...(r.agent_id !== null && { agent: r.agent }),
373
+ // Only in the shape `accountForToken` accepts as registering.
374
+ ...(r.registers === 1 && r.agent_only === 1 && r.agent_id === null && { registers: true }),
375
+ ...(r.oauth === 1 && { client: { name: r.client_name, host: r.client_host } }),
376
+ createdAt: new Date(r.created_at).toISOString(),
377
+ lastUsedAt: r.last_used_at === null ? null : new Date(r.last_used_at).toISOString(),
378
+ }));
379
+ }).immediate();
380
+ /**
381
+ * For POST /api/tokens: makes a new read-only or agent token for `account`, unbound, as `token add --read-only` and
382
+ * `token add --agent-only` make them, and answers it once. `by` is the row of the admin person asking. Throws
383
+ * `NotAdminError`, making nothing, unless they are still a live admin of the account.
384
+ */
385
+ export const addTokenBy = (db, account, by, kind) => db.transaction(() => {
386
+ requireAdmin(db, account, by);
387
+ const { id, token } = issueToken(db, account, kind === "read-only", kind === "agent");
388
+ return { id, kind, token };
389
+ }).immediate();
390
+ /**
391
+ * For DELETE /api/tokens: revokes the account's unrevoked token `id` of any kind, as `token revoke` does, the token the
392
+ * request carries included, and answers its id with `signins`, how many of its dashboard sign-ins (`dashboard_sessions`)
393
+ * `accountForToken` still accepted just before and refuses from now on, and `agents`, the names of the agents it ended
394
+ * (for a token that registers its own agents, each live agent it registered, as `endTokenAgents` ends them; none for
395
+ * any other token), or undefined, changing and counting nothing, for
396
+ * another account's token, one already revoked, or an unknown one. A person token whose person is no longer a live
397
+ * person of its account counts 0, as its sign-ins were already refused. Its session rows are kept: `accountForToken`
398
+ * refuses them through the revoked token. `by` is the row of the admin person asking. Throws `NotAdminError` unless
399
+ * they are still a live admin of the account.
400
+ */
401
+ export const revokeTokenBy = (db, account, by, id) => db.transaction(() => {
402
+ requireAdmin(db, account, by);
403
+ const changed = db
404
+ .prepare("UPDATE tokens SET revoked_at = ? WHERE id = ? AND account_id = ? AND revoked_at IS NULL")
405
+ .run(Date.now(), id, account).changes;
406
+ if (changed === 0)
407
+ return undefined;
408
+ return { id, signins: db.prepare(COUNT_SIGNINS).pluck().get(id), agents: endTokenAgents(db, account, id) };
409
+ }).immediate();
410
+ /**
411
+ * Counts the dashboard sign-ins of the token with the bound id that `accountForToken` would accept but for its revoke:
412
+ * none for a single-agent token whose agent is gone or a person token whose person is gone, as `accountForToken`
413
+ * refuses those, with the agent and the person found in the token's own account under the same conditions. Searched
414
+ * through the token's primary key and `dashboard_sessions_by_token`, never by scanning sessions.
415
+ */
416
+ export const COUNT_SIGNINS = `SELECT COUNT(*) FROM tokens JOIN dashboard_sessions ON dashboard_sessions.token_id = tokens.id
417
+ LEFT JOIN agents ON agents.id = tokens.agent_id AND agents.account_id = tokens.account_id
418
+ AND agents.removed_at IS NULL AND agents.person = 0
419
+ LEFT JOIN agents people ON people.id = tokens.person_id AND people.account_id = tokens.account_id
420
+ AND people.removed_at IS NULL AND people.person = 1
421
+ WHERE tokens.id = ? AND (tokens.agent_id IS NULL OR agents.id IS NOT NULL) AND (tokens.person_id IS NULL OR people.id IS NOT NULL)`;
422
+ // A sign-in is live while `accountForToken` would accept it: its token is unrevoked and, for a person token, its person
423
+ // is still a live person of the token's own account, found by row. Login codes only make full and person tokens.
424
+ const LIVE_SIGNIN_TOKEN = `tokens.account_id = ? AND tokens.revoked_at IS NULL AND (tokens.person_id IS NULL OR people.id IS NOT NULL)`;
425
+ const SIGNIN_PEOPLE = `LEFT JOIN agents people ON people.id = tokens.person_id AND people.account_id = tokens.account_id
426
+ AND people.removed_at IS NULL AND people.person = 1`;
427
+ /**
428
+ * For GET /api/signins: one page of the account's live dashboard sign-ins, oldest first, with `current` on the one whose
429
+ * row id is `current`, the request's own. `by` is the row of the admin person asking. Throws `NotAdminError` unless they
430
+ * are still a live admin of the account.
431
+ */
432
+ export const listSigninsBy = (db, account, by, current, limit, offset) => db.transaction(() => {
433
+ requireAdmin(db, account, by);
434
+ const rows = db
435
+ .prepare(`SELECT dashboard_sessions.id, dashboard_sessions.token_id, dashboard_sessions.created_at, tokens.person_id, people.name AS person
436
+ FROM dashboard_sessions JOIN tokens ON tokens.id = dashboard_sessions.token_id ${SIGNIN_PEOPLE}
437
+ WHERE ${LIVE_SIGNIN_TOKEN} ORDER BY dashboard_sessions.id LIMIT ? OFFSET ?`)
438
+ .all(account, limit, offset);
439
+ return rows.map((r) => ({
440
+ id: r.id,
441
+ tokenId: r.token_id,
442
+ kind: r.person_id !== null ? "person" : "full",
443
+ ...(r.person !== null && { person: r.person }),
444
+ createdAt: new Date(r.created_at).toISOString(),
445
+ ...(r.id === current && { current: true }),
446
+ }));
447
+ }).immediate();
448
+ /**
449
+ * For DELETE /api/signins: ends the account's live dashboard sign-in `id` by deleting its row, the request's own
450
+ * included, so its next request gets 401 while its token and the token's other sign-ins keep working, and answers its
451
+ * id, or undefined, changing nothing, for an id that is no live sign-in of the account, another account's included.
452
+ * Nothing references `dashboard_sessions`, so the delete cascades nowhere. `by` is the row of the admin person asking.
453
+ * Throws `NotAdminError` unless they are still a live admin of the account.
454
+ */
455
+ export const endSigninBy = (db, account, by, id) => db.transaction(() => {
456
+ requireAdmin(db, account, by);
457
+ const changed = db
458
+ .prepare(`DELETE FROM dashboard_sessions WHERE id = ? AND EXISTS (SELECT 1 FROM tokens ${SIGNIN_PEOPLE}
459
+ WHERE tokens.id = dashboard_sessions.token_id AND ${LIVE_SIGNIN_TOKEN})`)
460
+ .run(id, account).changes;
461
+ return changed === 0 ? undefined : { id };
462
+ }).immediate();
463
+ /**
464
+ * One page of the account's invites that are not used, cancelled or expired, and whose person is still live, newest
465
+ * first. `login-link` codes are not invites and are never listed.
466
+ */
467
+ export function listInvites(db, account, limit, offset, now = Date.now()) {
468
+ const rows = db
469
+ .prepare(`SELECT login_codes.id, agents.name, login_codes.created_at, login_codes.expires_at
470
+ FROM login_codes JOIN agents ON agents.id = login_codes.person_id AND agents.account_id = login_codes.account_id
471
+ AND agents.removed_at IS NULL AND agents.person = 1
472
+ WHERE login_codes.account_id = ? AND login_codes.invite = 1 AND login_codes.used_at IS NULL
473
+ AND login_codes.cancelled_at IS NULL AND login_codes.expires_at > ?
474
+ ORDER BY login_codes.id DESC LIMIT ? OFFSET ?`)
475
+ .all(account, now, limit, offset);
476
+ return rows.map((r) => ({
477
+ id: r.id,
478
+ person: r.name,
479
+ createdAt: new Date(r.created_at).toISOString(),
480
+ expiresAt: new Date(r.expires_at).toISOString(),
481
+ }));
482
+ }
483
+ /**
484
+ * Cancels the account's invite `id` while `listInvites` would list it: not used, cancelled or expired, and its person
485
+ * live. Looked up by id and account together; false for any other id, another account's included. It sets `used_at` as
486
+ * well (see `migrate` in db.ts), so a server from before `cancelled_at` refuses it too.
487
+ */
488
+ export const cancelInvite = (db, account, id, now = Date.now()) => db
489
+ .prepare(`UPDATE login_codes SET cancelled_at = ?, used_at = ?
490
+ WHERE id = ? AND account_id = ? AND invite = 1 AND used_at IS NULL AND cancelled_at IS NULL AND expires_at > ?
491
+ AND EXISTS (SELECT 1 FROM agents WHERE agents.id = login_codes.person_id AND agents.account_id = login_codes.account_id
492
+ AND agents.removed_at IS NULL AND agents.person = 1)`)
493
+ .run(now, now, id, account, now).changes === 1;
494
+ /**
495
+ * Redeems `code` for a new token of its account: a person token of its person, found by row and only while it is a
496
+ * live person of the code's own account, else a full token. Answers the id of a new dashboard sign-in session for that
497
+ * token, stored only as a hash in `dashboard_sessions`; the token itself is returned to nobody. One transaction finds
498
+ * the code, marks it used, issues the token and starts the session, so a code gives at most one, from any number of
499
+ * processes. Throws `LoginRefused` for a cancelled invite, a used code, and for an unknown or expired one or a person
500
+ * since removed; the message never quotes the code.
501
+ */
502
+ export const redeemLoginCode = (db, code, now = Date.now()) => db.transaction(() => {
503
+ if (!CODE.test(code))
504
+ throw new LoginRefused("invalid");
505
+ const row = db
506
+ .prepare("SELECT id, account_id, person_id, expires_at, used_at, cancelled_at FROM login_codes WHERE code_hash = ?")
507
+ .get(sha256(code));
508
+ if (!row)
509
+ throw new LoginRefused("invalid");
510
+ // A cancelled invite has `used_at` set too, so this comes first.
511
+ if (row.cancelled_at !== null)
512
+ throw new LoginRefused("cancelled");
513
+ if (row.used_at !== null)
514
+ throw new LoginRefused("used");
515
+ if (row.expires_at <= now)
516
+ throw new LoginRefused("invalid");
517
+ let person;
518
+ if (row.person_id !== null) {
519
+ person = db
520
+ .prepare("SELECT id, name FROM agents WHERE id = ? AND account_id = ? AND person = 1 AND removed_at IS NULL")
521
+ .get(row.person_id, row.account_id);
522
+ if (!person)
523
+ throw new LoginRefused("invalid");
524
+ }
525
+ db.prepare("UPDATE login_codes SET used_at = ? WHERE id = ?").run(now, row.id);
526
+ const { id } = issueToken(db, row.account_id, false, false, undefined, person);
527
+ const session = randomBytes(32).toString("hex");
528
+ db.prepare("INSERT INTO dashboard_sessions (hash, token_id, created_at) VALUES (?, ?, ?)").run(sha256(session), id, now);
529
+ return session;
530
+ }).immediate();