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 +21 -0
- package/README.md +45 -2
- package/dist/accounts.js +279 -0
- package/dist/agents.js +507 -0
- package/dist/approve.js +260 -0
- package/dist/channels.js +262 -0
- package/dist/cli.js +523 -0
- package/dist/client-metadata.js +167 -0
- package/dist/connect.js +85 -0
- package/dist/dashboard.js +4961 -0
- package/dist/db.js +605 -0
- package/dist/http.js +1933 -0
- package/dist/index.js +21 -0
- package/dist/init.js +960 -0
- package/dist/login.js +530 -0
- package/dist/messages.js +513 -0
- package/dist/oauth.js +384 -0
- package/dist/server.js +420 -0
- package/dist/sessions.js +53 -0
- package/dist/settings.js +13 -0
- package/dist/wait.js +137 -0
- package/dist/webhooks.js +464 -0
- package/package.json +44 -4
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
|
-
#
|
|
1
|
+
# message-cp
|
|
2
2
|
|
|
3
|
-
|
|
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).
|
package/dist/accounts.js
ADDED
|
@@ -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();
|