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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Colin Hallett
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,46 @@
1
- # Temporary Holding Version
1
+ # message-cp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A small messaging service for AI agents, run by you. It is an [MCP](https://modelcontextprotocol.io) server: each agent registers a name and a one-line bio, finds the other agents, sends them messages, and can be woken when mail arrives instead of polling for it. All agents share one SQLite file.
4
+
5
+ It suits one person running several agents on one machine (stdio, no network port), or a team whose agents talk across machines (HTTP behind your own HTTPS proxy). A team shares one account: anyone with its person, full or read-only token sees every channel and every direct conversation in it, between agents or people, so a direct message is addressed to someone, not private. Someone who needs a private conversation uses their own account or server. Separate accounts share nothing: none sees another's agents or messages. It does one job: no shared memory, no task board, no agent supervisor.
6
+
7
+ message-cp does not sign people in. Over HTTP it accepts only bearer tokens it issued; if you want another kind of sign-in, you put it in front (see [Putting your own sign-in in front](https://message-cp.vercel.app/accounts.html#putting-your-own-sign-in-in-front) in the reference).
8
+
9
+ ## Quick start
10
+
11
+ Needs Node 22.14 or later and git.
12
+
13
+ ```sh
14
+ git clone https://github.com/colinhallett/message-cp.git
15
+ cd message-cp
16
+ npm ci --ignore-scripts
17
+ npm run build
18
+ npm test
19
+ npm install -g .
20
+ ```
21
+
22
+ `--ignore-scripts` skips compiling the SQLite driver, `better-sqlite3`, which ships prebuilt binaries for Linux, macOS and Windows on x64 and arm64; without it, `npm ci` needs `make` and a C++ compiler. That gives you `dist/index.js`, the stdio server, and `dist/cli.js`, the `message-cp` command for everything else (`message-cp serve`, `wait`, `account`, `token`; `message-cp help` lists them all). `npm install -g .` links that command onto your `PATH` from the clone, compiling and downloading nothing. On Linux or macOS, if npm's global folder needs root, that step fails with `EACCES`; `npm install -g . --prefix ~/.local` puts the command in `~/.local/bin` instead, which must be on your `PATH`. Without either, `node dist/cli.js` in the clone does the same, for example `node dist/cli.js account list`. Add the stdio server to an MCP client as [Add it to an MCP client](https://message-cp.vercel.app/clients.html#add-it-to-an-mcp-client) in the reference says (the agents are the client's sessions; there is no separate chat command), open two sessions, and ask one to "register as alice" and the other to "register as bob and send alice a message".
23
+
24
+ ## Documentation
25
+
26
+ The full documentation is a static site in [`site/`](site/), built with [Astro](https://astro.build). To build it yourself, run `cd site && npm ci && npm run build`, which needs Node 22.23.4 or later and writes the pages into `site/dist/`, and serve that folder with any static file server, for example `python3 -m http.server -d site/dist`. It is published at [https://message-cp.vercel.app](https://message-cp.vercel.app), and the links below go to its pages there:
27
+
28
+ - [Getting started](https://message-cp.vercel.app/getting-started.html): one machine, a team server, or Docker.
29
+ - [Reference](https://message-cp.vercel.app/reference.html): every detail, in four pages:
30
+ - [Add it to an MCP client](https://message-cp.vercel.app/clients.html): Claude Code and Cursor, over stdio or HTTP.
31
+ - [Running a server](https://message-cp.vercel.app/server.html): the HTTP server, the dashboard, waking remote agents, checking a server end to end, Docker, and every setting.
32
+ - [Accounts, people, agents and webhooks](https://message-cp.vercel.app/accounts.html): accounts and tokens for a team, people, agents, webhooks, and putting your own sign-in in front.
33
+ - [Tools, limits and the database](https://message-cp.vercel.app/tools.html): the tools, names and keys, channels, waking on messages, limits, and the database with its backup and restore.
34
+ - [Compared with the alternatives](https://message-cp.vercel.app/compare.html): Claude Code's cross-session messaging, AgentDM, MCP Agent Mail, claude-peers and agentchattr.
35
+
36
+ ## How the project is run
37
+
38
+ [DIRECTION.md](DIRECTION.md) says what the project is for and how it is run.
39
+
40
+ The code is written by a [Tessl](https://tessl.io) factory: agents that take issues from the owner's tracker, open pull requests, answer review and merge. [`tessl.json`](tessl.json) and [`tessl-factory/`](tessl-factory/) are that setup, and they work only in the owner's Tessl organisation. You need neither to use message-cp or to build it. [CONTRIBUTING.md](CONTRIBUTING.md) says how to report a problem or suggest a change, and [SECURITY.md](SECURITY.md) how to report a vulnerability.
41
+
42
+ `ops/` is the owner's own server setup and an example, not something you need: `ops/backup.sh` is the owner's nightly version of the backup copy in the reference's [Database](https://message-cp.vercel.app/tools.html#database) section, tied to one storage service (Oracle Object Storage).
43
+
44
+ ## Licence
45
+
46
+ MIT; see [LICENSE](LICENSE).
@@ -0,0 +1,279 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { endTokenAgents } from "./agents.js";
3
+ import { OWNER_ACCOUNT } from "./db.js";
4
+ // Accounts and their tokens. The operator manages them with `message-cp account` and `message-cp token` on the server.
5
+ // A token is shown once, when it is created; only its hash is stored.
6
+ export class AccountError extends Error {
7
+ }
8
+ const NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;
9
+ const hashToken = (token) => createHash("sha256").update(token).digest("hex");
10
+ /**
11
+ * Stores and returns a new token for the account of row id `account`, of the kind the flags say; with `registers`, an
12
+ * agent token bound to no agent that registers its own agents. The caller checks the account, agent and person exist,
13
+ * inside its own transaction; `addToken` and `redeemLoginCode` in login.ts do.
14
+ */
15
+ export function issueToken(db, account, readOnly = false, agentOnly = false, agent, person, registers = false) {
16
+ if (readOnly && agentOnly)
17
+ throw new AccountError("A token is read-only or agent-only, not both.");
18
+ if (agent !== undefined && !agentOnly)
19
+ throw new AccountError("Only an agent token is bound to an agent.");
20
+ if (person && (readOnly || agentOnly))
21
+ throw new AccountError("A person token is neither read-only nor agent-only.");
22
+ if (registers && (!agentOnly || agent !== undefined))
23
+ throw new AccountError("Only an agent token bound to no agent registers its own agents.");
24
+ const token = randomBytes(32).toString("hex");
25
+ // An agent or person token is stored read-only too, so a server from before it that shares the database refuses it
26
+ // everything but the read-only endpoints instead of taking it for a full token.
27
+ const { id } = db
28
+ .prepare("INSERT INTO tokens (account_id, hash, created_at, read_only, agent_only, agent_id, person_id, registers) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id")
29
+ .get(account, hashToken(token), Date.now(), readOnly || agentOnly || person ? 1 : 0, agentOnly ? 1 : 0, agent ?? null, person?.id ?? null, registers ? 1 : 0);
30
+ return { id, token, readOnly, agentOnly, ...(person && { person: person.name }) };
31
+ }
32
+ /** Creates the account `name` (unique in any case) with its first token. */
33
+ export const addAccount = (db, name) => db.transaction(() => {
34
+ if (!NAME_PATTERN.test(name))
35
+ throw new AccountError("Invalid account name: use 1-32 characters from letters, digits, '-' and '_'.");
36
+ if (db.prepare("SELECT 1 FROM accounts WHERE name = ?").get(name))
37
+ throw new AccountError(`Account '${name}' already exists.`);
38
+ const { id, name: stored } = db
39
+ .prepare("INSERT INTO accounts (name, created_at) VALUES (?, ?) RETURNING id, name")
40
+ .get(name, Date.now());
41
+ return { ...issueToken(db, id), account: stored };
42
+ }).immediate();
43
+ export function listAccounts(db) {
44
+ const rows = db.prepare("SELECT id, name, created_at FROM accounts ORDER BY id").all();
45
+ const tokens = db.prepare("SELECT id FROM tokens WHERE account_id = ? AND revoked_at IS NULL ORDER BY id").pluck();
46
+ return rows.map((r) => ({
47
+ id: r.id,
48
+ name: r.name,
49
+ createdAt: new Date(r.created_at).toISOString(),
50
+ tokenIds: tokens.all(r.id),
51
+ }));
52
+ }
53
+ /**
54
+ * Another token for the account named `account`; with `readOnly`, one that can only read; with `agentOnly`, one that
55
+ * cannot act as a person; with `agent` as well, one that acts only as the account's agent of that row id; with
56
+ * `person`, one that sends and posts only as the account's live person of that name (any case); with `agentOnly` and
57
+ * `registers`, one bound to no agent that registers its own agents.
58
+ */
59
+ export const addToken = (db, account, readOnly = false, agentOnly = false, agent, person, registers = false) => db.transaction(() => {
60
+ const row = db.prepare("SELECT id, name FROM accounts WHERE name = ?").get(account);
61
+ if (!row)
62
+ throw new AccountError(`No account named '${account}'.`);
63
+ if (agent !== undefined && !db.prepare("SELECT 1 FROM agents WHERE id = ? AND account_id = ? AND removed_at IS NULL AND person = 0").get(agent, row.id)) {
64
+ throw new AccountError(`No live agent with id ${agent} in account '${row.name}'.`);
65
+ }
66
+ let bound;
67
+ if (person !== undefined) {
68
+ const live = db
69
+ .prepare("SELECT id, name, person FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL")
70
+ .get(row.id, person);
71
+ if (live?.person === 0)
72
+ throw new AccountError(`The name '${live.name}' belongs to an agent, not a person.`);
73
+ if (!live)
74
+ throw new AccountError(`No person named '${person}' in account '${row.name}'.`);
75
+ bound = { id: live.id, name: live.name };
76
+ }
77
+ return { ...issueToken(db, row.id, readOnly, agentOnly, agent, bound, registers), account: row.name };
78
+ }).immediate();
79
+ /** Every token that still works, oldest first, with its account and kind, and for an OAuth token its client. */
80
+ export function listTokens(db) {
81
+ const rows = db
82
+ .prepare(`SELECT tokens.id, accounts.name, tokens.read_only, tokens.agent_only, tokens.person_id, tokens.created_at,
83
+ tokens.oauth, tokens.client_name, tokens.client_host, tokens.last_used_at
84
+ FROM tokens JOIN accounts ON accounts.id = tokens.account_id WHERE tokens.revoked_at IS NULL ORDER BY tokens.id`)
85
+ .all();
86
+ return rows.map((r) => ({
87
+ id: r.id,
88
+ account: r.name,
89
+ // A person token's row has read_only set too (see `issueToken`), so its person comes first.
90
+ kind: r.person_id !== null ? "person" : r.agent_only === 1 ? "agent" : r.read_only === 1 ? "read-only" : "full",
91
+ ...(r.oauth === 1 && { client: { name: r.client_name, host: r.client_host } }),
92
+ createdAt: new Date(r.created_at).toISOString(),
93
+ lastUsedAt: r.last_used_at === null ? null : new Date(r.last_used_at).toISOString(),
94
+ }));
95
+ }
96
+ /** Records on the token of row id `token` that the OAuth code exchange issued it to `client`. Run inside that exchange's transaction. */
97
+ export function recordClient(db, token, client) {
98
+ db.prepare("UPDATE tokens SET oauth = 1, client_name = ?, client_host = ? WHERE id = ?").run(client.name, client.host, token);
99
+ }
100
+ /**
101
+ * One page of the account's unrevoked tokens the OAuth code exchange issued, newest first, each with its client and
102
+ * either its agent or, for one that registers its own agents, the live agents of the account it registered, which may
103
+ * be none. A single-agent token whose agent was removed is left out, as `accountForToken` refuses it, and so is one
104
+ * issued before the exchange recorded its client.
105
+ */
106
+ export function listApprovals(db, account, limit, offset) {
107
+ // A token that registers is kept in the shape `accountForToken` accepts as registering. Its agents are looked for in
108
+ // its own account only; the unary + keeps SQLite finding them through agents_by_token rather than agents_live, which
109
+ // would read every live agent of the account for each token.
110
+ const rows = db
111
+ .prepare(`SELECT tokens.id, agents.name, tokens.client_name, tokens.client_host, tokens.created_at,
112
+ CASE WHEN agents.id IS NULL THEN
113
+ (SELECT json_group_array(own.name ORDER BY own.name) FROM agents own
114
+ WHERE own.token_id = tokens.id AND +own.account_id = tokens.account_id AND own.removed_at IS NULL AND own.person = 0)
115
+ END AS registered
116
+ FROM tokens LEFT JOIN agents ON agents.id = tokens.agent_id AND agents.account_id = tokens.account_id
117
+ AND agents.removed_at IS NULL AND agents.person = 0
118
+ WHERE tokens.account_id = ? AND tokens.oauth = 1 AND tokens.revoked_at IS NULL
119
+ AND (agents.id IS NOT NULL OR (tokens.registers = 1 AND tokens.agent_only = 1 AND tokens.agent_id IS NULL))
120
+ ORDER BY tokens.id DESC LIMIT ? OFFSET ?`)
121
+ .all(account, limit, offset);
122
+ return rows.map((r) => ({
123
+ id: r.id,
124
+ // Only a token stored as registering, with no agent, is kept without a live agent.
125
+ registers: r.name === null,
126
+ agent: r.name,
127
+ ...(r.name === null && { agents: JSON.parse(r.registered) }),
128
+ clientName: r.client_name,
129
+ clientHost: r.client_host,
130
+ issuedAt: new Date(r.created_at).toISOString(),
131
+ }));
132
+ }
133
+ /**
134
+ * Stops the token `id` working for every request from now on. For a token that registers its own agents, also ends
135
+ * each live agent it registered, as `endTokenAgents` does, in the same transaction. Returns the account it belonged to
136
+ * and the names of the agents it ended, in name order, none for any other token.
137
+ */
138
+ export const revokeToken = (db, id) => db.transaction(() => {
139
+ const row = db
140
+ .prepare(`UPDATE tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL
141
+ RETURNING account_id, (SELECT name FROM accounts WHERE accounts.id = tokens.account_id) AS account`)
142
+ .get(Date.now(), id);
143
+ if (!row)
144
+ throw new AccountError(`No working token with id ${id}.`);
145
+ return { account: row.account, agents: endTokenAgents(db, row.account_id, id) };
146
+ }).immediate();
147
+ /** The name of the account `account`. */
148
+ export const accountName = (db, account) => db.prepare("SELECT name FROM accounts WHERE id = ?").pluck().get(account);
149
+ /** How stale a token's `last_used_at` may get before a request that uses it writes it again: a busy token writes about once a minute. */
150
+ export const TOKEN_USE_RESOLUTION_MS = 60_000;
151
+ /**
152
+ * Records on the token of row id `token` that a request used it now, unless it was recorded within
153
+ * TOKEN_USE_RESOLUTION_MS. `lastUsedAt` is the value the caller's lookup read: when that is recent enough nothing is
154
+ * written, so such a request never waits for SQLite's write lock; otherwise the `UPDATE` checks the window again, so
155
+ * processes that read the same stale value write it once. Best effort, and outside any transaction: a lock held past the
156
+ * busy timeout skips it quietly, any other error logs one fixed line, never anything about the token, and the request
157
+ * goes on either way.
158
+ */
159
+ function recordUse(db, token, lastUsedAt) {
160
+ const now = Date.now();
161
+ if (lastUsedAt !== null && lastUsedAt >= now - TOKEN_USE_RESOLUTION_MS)
162
+ return;
163
+ try {
164
+ db.prepare("UPDATE tokens SET last_used_at = ? WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)").run(now, token, now - TOKEN_USE_RESOLUTION_MS);
165
+ }
166
+ catch (err) {
167
+ const code = err?.code;
168
+ if (typeof code !== "string" || !code.startsWith("SQLITE_BUSY"))
169
+ console.error("message-cp: could not record when a token was last used");
170
+ }
171
+ }
172
+ /**
173
+ * The account a bearer token belongs to: the owner's for `ownerToken` (MESSAGE_CP_HTTP_TOKEN, never read-only or
174
+ * agent-only), else the account of a token created with `message-cp`, as long as it has not been revoked. A token bound
175
+ * to an agent comes with that agent, and only while it is a live agent of the token's own account; a person token
176
+ * comes with its person, only while that row is a live person of the token's own account, and with whether that row is
177
+ * marked admin now. A token that registers its own agents comes with its row id. Undefined for anything else, a bound
178
+ * token whose agent or person has been removed included. A created token it accepts is recorded as used now (see
179
+ * `recordUse`), even if a later check refuses the request; nothing is recorded for `ownerToken` or a refused token.
180
+ *
181
+ * `token` may also be a dashboard sign-in session id (`dashboard_sessions`, made by `redeemLoginCode`). When no token's
182
+ * hash matches, the session is found by its hash and its token by `tokens.id`, then every check above applies to that
183
+ * token's row, so a session of a revoked token, or of a person token whose person was removed, gives undefined as the
184
+ * token itself would. Its answer is its token's, with `session` set to the session's row id.
185
+ */
186
+ export function accountForToken(db, token, ownerToken) {
187
+ const hash = hashToken(token);
188
+ // Constant-time against the owner's token, as before accounts; created tokens are found by their hash.
189
+ if (timingSafeEqual(Buffer.from(hash), Buffer.from(hashToken(ownerToken)))) {
190
+ return { account: OWNER_ACCOUNT, readOnly: false, agentOnly: false };
191
+ }
192
+ // The agent and the person are looked for in the token's account only, so a token never acts as another account's.
193
+ // The person is found by row, so a later person given a removed person's name is not theirs. A session is checked
194
+ // through its token's row by the same query, keyed by `tokens.id` instead of `tokens.hash`.
195
+ const select = (key, value) => db
196
+ .prepare(`SELECT tokens.id AS token_id, tokens.last_used_at, tokens.registers, tokens.oauth, tokens.account_id, tokens.read_only, tokens.agent_only, tokens.agent_id,
197
+ agents.id, agents.registered_at, agents.instance, agents.name,
198
+ tokens.person_id, people.id AS person_row, people.name AS person_name, people.admin AS person_admin
199
+ FROM tokens LEFT JOIN agents ON agents.id = tokens.agent_id AND agents.account_id = tokens.account_id
200
+ AND agents.removed_at IS NULL AND agents.person = 0
201
+ LEFT JOIN agents people ON people.id = tokens.person_id AND people.account_id = tokens.account_id
202
+ AND people.removed_at IS NULL AND people.person = 1
203
+ WHERE tokens.${key} = ? AND tokens.revoked_at IS NULL`)
204
+ .get(value);
205
+ let row = select("hash", hash);
206
+ let session;
207
+ if (!row) {
208
+ const found = db.prepare("SELECT id, token_id FROM dashboard_sessions WHERE hash = ?").get(hash);
209
+ if (found) {
210
+ row = select("id", found.token_id);
211
+ session = found.id;
212
+ }
213
+ else {
214
+ return listenAccess(db, hash);
215
+ }
216
+ }
217
+ if (!row || (row.agent_id !== null && row.id === null) || (row.person_id !== null && row.person_row === null))
218
+ return undefined;
219
+ recordUse(db, row.token_id, row.last_used_at);
220
+ if (row.person_row !== null && row.person_name !== null) {
221
+ const person = { id: row.person_row, name: row.person_name, admin: row.person_admin === 1 };
222
+ return { account: row.account_id, readOnly: false, agentOnly: false, person, ...(session !== undefined && { session }) };
223
+ }
224
+ // An agent token's row also has read_only set (see `issueToken`); it is agent-only here, never both.
225
+ const access = { account: row.account_id, readOnly: row.read_only === 1 && row.agent_only !== 1, agentOnly: row.agent_only === 1 };
226
+ if (row.id !== null)
227
+ access.agent = { id: row.id, registeredAt: row.registered_at, instance: row.instance, name: row.name };
228
+ // Only an agent-only token bound to no agent, as `issueToken` stores it; never a full, read-only or person token.
229
+ if (row.registers === 1 && row.agent_only === 1 && row.agent_id === null)
230
+ access.registers = row.token_id;
231
+ // Only an agent token, which is what the code exchange issues; a sign-in session is never one.
232
+ if (row.oauth === 1 && row.agent_only === 1 && session === undefined)
233
+ access.approval = row.token_id;
234
+ if (session !== undefined)
235
+ access.session = session;
236
+ return access;
237
+ }
238
+ // A listen token's agent, while it is a live agent of its approval's account that the approval still acts as: the
239
+ // single agent it is bound to, or one it registered. The approval must be an unrevoked agent token the code exchange
240
+ // issued.
241
+ export const LISTEN_AGENT = `FROM listen_tokens
242
+ JOIN tokens ON tokens.id = listen_tokens.token_id AND tokens.revoked_at IS NULL AND tokens.oauth = 1 AND tokens.agent_only = 1
243
+ JOIN agents ON agents.id = listen_tokens.agent_id AND agents.account_id = tokens.account_id AND agents.removed_at IS NULL
244
+ AND agents.person = 0 AND (tokens.agent_id = agents.id OR (tokens.registers = 1 AND tokens.agent_id IS NULL AND agents.token_id = tokens.id))`;
245
+ /** The access of the listen token with hash `hash`: its agent, and nothing else. Undefined once `LISTEN_AGENT` fails. */
246
+ function listenAccess(db, hash) {
247
+ const row = db
248
+ .prepare(`SELECT tokens.account_id, agents.id, agents.registered_at, agents.instance, agents.name ${LISTEN_AGENT} WHERE listen_tokens.hash = ?`)
249
+ .get(hash);
250
+ if (!row)
251
+ return undefined;
252
+ return {
253
+ account: row.account_id,
254
+ readOnly: false,
255
+ agentOnly: true,
256
+ agent: { id: row.id, registeredAt: row.registered_at, instance: row.instance, name: row.name },
257
+ listen: true,
258
+ };
259
+ }
260
+ /**
261
+ * A new listen token for the live agent `identity` of account `account`, through the approval token of row id
262
+ * `approval`: one that only waits for that agent's mail on GET /wait. It replaces the agent's earlier one, which stops
263
+ * working. Only its hash is stored. Undefined, storing nothing, unless `identity` is still that registration and
264
+ * `LISTEN_AGENT` holds for it, so a session never gets one for another approval's agent or another account's.
265
+ */
266
+ export const issueListenToken = (db, account, identity, approval) => db.transaction(() => {
267
+ const allowed = db
268
+ .prepare(`SELECT 1 FROM tokens JOIN agents ON agents.id = ? AND agents.account_id = tokens.account_id AND agents.removed_at IS NULL
269
+ AND agents.person = 0 AND agents.registered_at = ? AND agents.instance IS ?
270
+ AND (tokens.agent_id = agents.id OR (tokens.registers = 1 AND tokens.agent_id IS NULL AND agents.token_id = tokens.id))
271
+ WHERE tokens.id = ? AND tokens.account_id = ? AND tokens.revoked_at IS NULL AND tokens.oauth = 1 AND tokens.agent_only = 1`)
272
+ .get(identity.id, identity.registeredAt, identity.instance, approval, account);
273
+ if (!allowed)
274
+ return undefined;
275
+ const token = randomBytes(32).toString("hex");
276
+ db.prepare(`INSERT INTO listen_tokens (agent_id, token_id, hash, created_at) VALUES (?, ?, ?, ?)
277
+ ON CONFLICT(agent_id) DO UPDATE SET token_id = excluded.token_id, hash = excluded.hash, created_at = excluded.created_at`).run(identity.id, approval, hashToken(token), Date.now());
278
+ return token;
279
+ }).immediate();