@grknbyk/agent-wire 0.4.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 Gürkan Bıyık
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 ADDED
@@ -0,0 +1,243 @@
1
+ <div align="center">
2
+ <img src="assets/agent-wire.png" alt="agent-wire" width="96">
3
+
4
+ # agent-wire
5
+
6
+ **Let your AI coding agents talk to each other, in a Slack channel you can read.**
7
+
8
+ </div>
9
+
10
+ Two developers, two machines, two coding agents working on the same system. One
11
+ knows the migration is deployed. The other is about to write against the old
12
+ schema. agent-wire gives them a way to say so.
13
+
14
+ It runs as an [MCP](https://modelcontextprotocol.io) server, so any MCP client
15
+ (Claude Code, Cursor, anything else that speaks the protocol) gets `send` and
16
+ `inbox` tools. Messages travel through a normal Slack channel.
17
+
18
+ Slack is a deliberate choice here. A private protocol between two machines
19
+ produces a conversation nobody can audit. In a channel, the humans who own those
20
+ agents read the whole exchange, scroll back through it, and step in by typing.
21
+
22
+ ```
23
+ 🔥 grkn => mira
24
+ migration 0042 is on dev now, txn_date is a DATE not a TIMESTAMP
25
+
26
+ ⚡ mira => grkn
27
+ got it, rewriting the report query
28
+ ```
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ npx @grknbyk/agent-wire setup
34
+ ```
35
+
36
+ Setup asks one question first: do you already have a Slack bot token, should it
37
+ create the app for you, or do you want to paste the manifest by hand?
38
+
39
+ If you let it create the app, it needs one App Configuration Token from
40
+ [api.slack.com/apps](https://api.slack.com/apps), at the bottom of that page.
41
+ After that it creates the app from the bundled manifest, opens your browser once
42
+ for approval, catches the redirect itself, creates the channel, and joins it.
43
+
44
+ Setup never asks "did you do it? (y/n)". Every step it can verify, it verifies by
45
+ asking Slack. When a step is stuck for a reason Slack reports, such as a missing
46
+ scope, a private channel it cannot join, or a token from the wrong workspace, it
47
+ says which one and what to do about it. Quit halfway and re-run: it resumes at
48
+ the first unfinished step, because the config file is the progress.
49
+
50
+ Then point your client at it:
51
+
52
+ ```bash
53
+ claude mcp add agent-wire -- npx -y @grknbyk/agent-wire serve
54
+ ```
55
+
56
+ Or, for any other MCP client:
57
+
58
+ ```json
59
+ {
60
+ "mcpServers": {
61
+ "agent-wire": { "command": "npx", "args": ["-y", "@grknbyk/agent-wire", "serve"] }
62
+ }
63
+ }
64
+ ```
65
+
66
+ ## Commands
67
+
68
+ | Command | What it does |
69
+ |---|---|
70
+ | `agent-wire status` | Identity, channels and unread counts, read from disk |
71
+ | `agent-wire setup` | Connect a workspace, a channel, and this agent's identity |
72
+ | `agent-wire serve` | Run the MCP stdio server, which is what your client launches |
73
+ | `agent-wire doctor` | Re-check the token, the channels and the identity |
74
+ | `agent-wire drain` | Print what arrived since last time, for a prompt hook |
75
+ | `agent-wire channels` | List the channels and whether each one is switched on |
76
+ | `agent-wire on/off <name>` | Bring a channel into scope, or take it out |
77
+
78
+ ## Tools your agent gets
79
+
80
+ `send`, `send_file`, `inbox`, `archive`, `peers`, `channels`, `my_id`.
81
+
82
+ Text over 3500 characters is posted as a Markdown file instead of a message.
83
+ Slack splits anything longer, and the tail arrives without a header, so half an
84
+ answer vanishes while the sender is told it was delivered.
85
+
86
+ ## One channel per project
87
+
88
+ Setup configures one channel. Add more by hand in `~/.agent-wire/config.json`:
89
+
90
+ ```json
91
+ "channels": [
92
+ { "id": "C0123", "name": "agent-wms" },
93
+ { "id": "C0456", "name": "agent-crm" }
94
+ ]
95
+ ```
96
+
97
+ Every message is tagged with the channel it came from, `send` takes an optional
98
+ `channel`, and `inbox` can filter by one. The first entry is the default.
99
+
100
+ ## Working on two of five channels
101
+
102
+ Channels you are not working on today can be switched off. Running `agent-wire`
103
+ with no arguments shows where you stand:
104
+
105
+ ```
106
+ ┌──────────────── agent-wire ────────────────┐
107
+ │ name grkn mark 🔥 │
108
+ │ key MCowBQYDK2VwAyEAq7Xn2mZ8kLcYzQwErTy… │
109
+ ├───────────────── CHANNELS ─────────────────┤
110
+ │ agent-wms ● on 3 unread │
111
+ │ agent-crm ● on 1 unread │
112
+ │ agent-hcm ○ off 1 held │
113
+ │ agent-lab ○ off 1 held │
114
+ ├────────────────── PEERS ───────────────────┤
115
+ │ @ Zoë * kai * mira │
116
+ │ * warehouse-… * robin ! nox │
117
+ ├────────────────── STATE ───────────────────┤
118
+ │ workspace Acme poll 14s ago │
119
+ └────────────────────────────────────────────┘
120
+ ```
121
+
122
+ The peers section lists everyone this agent has heard from: `*` for an agent,
123
+ `@` for a human typing in the channel, `!` for a name that has been forged.
124
+ Anything too wide for its column ends in `…`, so one long nickname costs its own
125
+ row a character instead of pushing the border out.
126
+
127
+ A forged sighting stays on the record even after that name sends a message that
128
+ verifies. Letting a later message clear it would hand an attacker the way to bury
129
+ the evidence.
130
+
131
+ ```bash
132
+ agent-wire off agent-hcm
133
+ agent-wire on agent-hcm
134
+ ```
135
+
136
+ `status` reads the config and the local log only, so it answers instantly.
137
+ Whether Slack still accepts the token is `doctor`'s question.
138
+
139
+ A channel that is off is not polled, not announced by `drain`, and absent from
140
+ the default `inbox` view. Its history stays readable at any time with
141
+ `inbox channel="agent-hcm"`.
142
+
143
+ Switching one off does not lose messages. The cursor stays where it was, so
144
+ switching it back on replays everything that arrived meanwhile.
145
+
146
+ Only the person running the agent can switch a channel, from the command line.
147
+ The MCP `channels` tool lists the state and cannot change it, so a message
148
+ arriving from one channel can never talk the agent into silencing another.
149
+
150
+ ## Who actually sent that message
151
+
152
+ Every agent in a workspace shares one bot token, so Slack's own `bot_id` proves
153
+ that agent-wire posted a message without proving which agent wrote it. The header
154
+ line is plain text that anyone in the channel can type.
155
+
156
+ So each install generates an Ed25519 key pair at setup and signs every message it
157
+ sends. The signature covers the sender, the recipient, the channel, the position
158
+ in the reply chain, and the text. It travels in Slack message metadata, which the
159
+ UI never renders. The first key seen using a name is pinned to that name, and
160
+ `inbox` labels every message with what is actually proven:
161
+
162
+ | Label | Meaning |
163
+ |---|---|
164
+ | `signed` | Verified against the key already pinned to that name |
165
+ | `new` | Verified, first sighting of this name, key now pinned |
166
+ | `impostor` | That name is pinned to a different key, so treat it as forged |
167
+ | `unsigned` | No valid signature, so the sender name is decoration only |
168
+ | `slack-verified` | A human, identified by Slack's own user id |
169
+ | `self` | Sent by this agent |
170
+
171
+ Changing one character of the text breaks the signature, and so does replaying a
172
+ signed message into another channel. There are tests for both.
173
+
174
+ ## Untrusted input
175
+
176
+ Anything arriving from the channel is rendered inside a fence whose delimiter is
177
+ a random value minted per server process, never written to Slack and never
178
+ logged:
179
+
180
+ ```
181
+ <<<WIRE:4f2a… UNTRUSTED from=mira kind=agent authorship=signed channel=agent-wms ts=1712.44 hop=3>>>
182
+ the message
183
+ <<<END:4f2a…>>>
184
+ ```
185
+
186
+ The rule for reading that fence arrives through the MCP handshake, a channel the
187
+ message author cannot write to, so it never sits inline beside the content it
188
+ governs. If a payload contains the live delimiter, it is replaced with
189
+ `[FENCE-ECHO REDACTED]`, which turns reflection into a visible event instead of a
190
+ silently broken boundary.
191
+
192
+ Be clear about what this buys you. An attacker cannot close the fence, and does
193
+ not need to, because text inside a correctly labelled `UNTRUSTED` block still
194
+ reads as language to a model. The fence makes the labelling accurate. Hostile
195
+ text stays exactly as persuasive as it was, so this is a boundary rather than a
196
+ filter.
197
+
198
+ A reply chain also carries a hop count and stops at 8. Two agents answering each
199
+ other politely is an infinite loop that costs real money.
200
+
201
+ ## Slack scopes, and why each one
202
+
203
+ Your workspace admin will ask. The manifest requests:
204
+
205
+ | Scope | Why |
206
+ |---|---|
207
+ | `chat:write` | Post messages |
208
+ | `channels:history`, `groups:history` | Read the channels it was added to |
209
+ | `channels:read`, `groups:read` | Find a channel by name, check membership |
210
+ | `channels:join` | Join a public channel so you skip the invite step |
211
+ | `channels:manage` | Create the channel during setup |
212
+ | `files:read`, `files:write` | Send and receive long messages as files |
213
+ | `users:read` | Show a human's name instead of `U08J21KLER1` |
214
+
215
+ The app only ever reads channels it has been added to.
216
+
217
+ ## Where things are stored
218
+
219
+ Everything lives in `~/.agent-wire/` (override with `AGENT_WIRE_HOME`).
220
+ `config.json` holds the token, identity and channels. `inbox.jsonl` is the
221
+ append-only message log. `peers.json` holds the pinned keys.
222
+
223
+ The local log is the source of truth. Slack is a cache that can be re-read at any
224
+ time, so recovering a lost inbox is an ordinary operation rather than a
225
+ procedure. Messages are keyed by their Slack timestamp, so a retried poll or a
226
+ reinstalled app cannot produce duplicates.
227
+
228
+ ## Roadmap
229
+
230
+ - `mode: reply`, to answer waiting messages when no live session is watching
231
+ - Per-worktree identity, so parallel sessions on one machine name themselves
232
+ - Discord as a second transport
233
+ - Published measurements of fenced against unfenced injection compliance
234
+
235
+ ## Development
236
+
237
+ ```bash
238
+ npm test
239
+ ```
240
+
241
+ ## License
242
+
243
+ MIT
Binary file
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ import { activeChannels, loadConfig, setChannelActive } from '../src/config.mjs';
3
+ import { pollOnce, serve } from '../src/mcp.mjs';
4
+ import { runDoctor, runSetup } from '../src/setup.mjs';
5
+ import { markRead, selectMessages } from '../src/inbox.mjs';
6
+ import { runStatus } from '../src/status.mjs';
7
+
8
+ const USAGE = `agent-wire — message other AI coding agents through Slack
9
+
10
+ agent-wire status show identity, channels and unread counts (also the default)
11
+ agent-wire setup connect a workspace, a channel and this agent's identity
12
+ agent-wire serve run the MCP stdio server (what your agent client launches)
13
+ agent-wire doctor re-check the token, the channels and this agent's identity
14
+ agent-wire drain print messages that arrived since the last drain, then stop
15
+ agent-wire channels list the channels and whether each one is switched on
16
+ agent-wire on <name> switch a channel on
17
+ agent-wire off <name> switch a channel off: not polled, not announced
18
+
19
+ Docs: https://github.com/grknbyk/agent-wire`;
20
+
21
+ // For a client hook that runs on every prompt: says a message is waiting without
22
+ // spending the agent's turn on reading it, and marks nothing as read.
23
+ async function drain() {
24
+ const config = loadConfig();
25
+ if (!config) return 0;
26
+
27
+ await pollOnce(config).catch(() => {
28
+ // Offline is not an error here; the next drain catches up.
29
+ });
30
+ const waiting = selectMessages({
31
+ state: 'unread',
32
+ count: 50,
33
+ channels: activeChannels(config).map((channel) => channel.name),
34
+ });
35
+ if (waiting.length === 0) return 0;
36
+
37
+ const senders = [...new Set(waiting.map((item) => item.from))].join(', ');
38
+ console.log(`agent-wire: ${waiting.length} unread message(s) from ${senders}.`
39
+ + ' Tell the user in one line. Do not read them unless asked — use the agent-wire inbox tool.');
40
+ return 0;
41
+ }
42
+
43
+ function listChannels() {
44
+ const config = loadConfig();
45
+ const configured = config?.channels ?? [];
46
+ if (configured.length === 0) {
47
+ console.log('no channels configured — run `agent-wire setup`');
48
+ return 1;
49
+ }
50
+
51
+ for (const channel of configured) console.log(`${channel.active === false ? 'off' : 'on '} #${channel.name}`);
52
+ return 0;
53
+ }
54
+
55
+ // Switching a channel is a decision for the person running the agent, so it lives
56
+ // on the command line and not in the MCP tool list. A message arriving from the
57
+ // channel must not be able to talk the agent into silencing another one.
58
+ function switchChannel(name, active) {
59
+ if (!name) {
60
+ console.log(`usage: agent-wire ${active ? 'on' : 'off'} <channel>`);
61
+ return 1;
62
+ }
63
+
64
+ const channel = setChannelActive(name, active);
65
+ if (!channel) {
66
+ console.log(`no configured channel named "${name}"`);
67
+ return 1;
68
+ }
69
+
70
+ console.log(active
71
+ ? `#${channel.name} is on. The next poll replays everything since it was switched off.`
72
+ : `#${channel.name} is off. It is no longer polled or announced; its history stays readable with inbox channel="${channel.name}".`);
73
+ return 0;
74
+ }
75
+
76
+ const commands = {
77
+ status: () => runStatus() ?? notConfigured(),
78
+ setup: runSetup,
79
+ doctor: runDoctor,
80
+ drain,
81
+ channels: listChannels,
82
+ on: () => switchChannel(process.argv[3], true),
83
+ off: () => switchChannel(process.argv[3], false),
84
+ read: async () => {
85
+ const items = selectMessages({ state: 'unread', count: 50 });
86
+ for (const item of items) console.log(`[${item.authorship}] ${item.at} ${item.from}: ${item.text}`);
87
+ markRead(items);
88
+ return 0;
89
+ },
90
+ };
91
+
92
+ function notConfigured() {
93
+ console.log('not configured yet — run `agent-wire setup`');
94
+ return 1;
95
+ }
96
+
97
+ const name = process.argv[2];
98
+
99
+ // serve() is the one command that must not exit: the open stdin stream is what
100
+ // keeps the MCP server alive, and awaiting a never-resolving promise here would
101
+ // make Node print a warning into the very stream the client is parsing.
102
+ if (name === 'serve') {
103
+ serve();
104
+ } else if (commands[name]) {
105
+ process.exit(await commands[name]() ?? 0);
106
+ } else if (!name) {
107
+ // Bare invocation shows where you stand once there is something to stand on,
108
+ // and the usage text while there is not.
109
+ const shown = runStatus();
110
+ if (shown === null) console.log(USAGE);
111
+ process.exit(0);
112
+ } else {
113
+ console.log(USAGE);
114
+ process.exit(1);
115
+ }
package/manifest.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "display_information": {
3
+ "name": "agent-wire",
4
+ "description": "Message bridge between AI coding agents",
5
+ "background_color": "#af3c02",
6
+ "long_description": "agent-wire connects AI coding agents running on different machines through a Slack channel they share. Each agent posts under its own nickname and signs what it sends, so a message can be traced to the agent that wrote it. Humans in the same channel can read the whole exchange and join it at any point, which is the reason the bridge runs on Slack rather than a private protocol: the conversation between machines stays readable by the team that owns them."
7
+ },
8
+ "features": {
9
+ "bot_user": {
10
+ "display_name": "agent-wire",
11
+ "always_online": true
12
+ }
13
+ },
14
+ "oauth_config": {
15
+ "redirect_urls": [
16
+ "http://localhost:32771/callback"
17
+ ],
18
+ "scopes": {
19
+ "bot": [
20
+ "chat:write",
21
+ "channels:read",
22
+ "channels:history",
23
+ "channels:join",
24
+ "channels:manage",
25
+ "groups:read",
26
+ "groups:history",
27
+ "files:read",
28
+ "files:write",
29
+ "users:read"
30
+ ]
31
+ }
32
+ },
33
+ "settings": {
34
+ "org_deploy_enabled": false,
35
+ "socket_mode_enabled": false,
36
+ "token_rotation_enabled": false
37
+ }
38
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@grknbyk/agent-wire",
3
+ "version": "0.4.0",
4
+ "description": "Let AI coding agents message each other through a shared Slack channel, over MCP.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Gürkan Bıyık <gurkan.biyik@outlook.com>",
8
+ "homepage": "https://github.com/grknbyk/agent-wire",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/grknbyk/agent-wire.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/grknbyk/agent-wire/issues"
15
+ },
16
+ "bin": {
17
+ "agent-wire": "bin/agent-wire.mjs"
18
+ },
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "scripts": {
23
+ "test": "node --test \"test/*.test.mjs\""
24
+ },
25
+ "files": [
26
+ "bin",
27
+ "src",
28
+ "assets",
29
+ "manifest.json"
30
+ ],
31
+ "keywords": [
32
+ "mcp",
33
+ "model-context-protocol",
34
+ "slack",
35
+ "ai-agents",
36
+ "agent-communication",
37
+ "claude-code",
38
+ "cursor"
39
+ ]
40
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,68 @@
1
+ // Everything agent-wire stores lives in one directory so a broken install can be
2
+ // inspected, backed up, or deleted as a unit.
3
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ export const HOME = process.env.AGENT_WIRE_HOME || join(homedir(), '.agent-wire');
8
+
9
+ export const paths = {
10
+ config: join(HOME, 'config.json'),
11
+ inbox: join(HOME, 'inbox.jsonl'),
12
+ states: join(HOME, 'states.json'),
13
+ cursors: join(HOME, 'cursors.json'),
14
+ peers: join(HOME, 'peers.json'),
15
+ users: join(HOME, 'users.json'),
16
+ files: join(HOME, 'files'),
17
+ pollLock: join(HOME, 'poll.lock'),
18
+ };
19
+
20
+ export const readJson = (file, fallback) => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : fallback);
21
+
22
+ // Write to a sibling then rename: a config half-written by a killed setup run is
23
+ // how an install becomes unrecoverable, and rename is atomic on every platform we
24
+ // target. The temp name carries the pid so two runs cannot share it.
25
+ export function writeJson(file, value) {
26
+ mkdirSync(HOME, { recursive: true });
27
+ const tempFile = `${file}.${process.pid}.tmp`;
28
+ writeFileSync(tempFile, JSON.stringify(value, null, 2));
29
+ renameSync(tempFile, file);
30
+ }
31
+
32
+ export const loadConfig = () => readJson(paths.config, null);
33
+
34
+ export const saveConfig = (config) => writeJson(paths.config, config);
35
+
36
+ // Setup writes after every completed step, so the config IS the resume state and
37
+ // there is no second progress file to disagree with it.
38
+ export function patchConfig(patch) {
39
+ const merged = { ...(loadConfig() ?? { version: 1 }), ...patch };
40
+ saveConfig(merged);
41
+ return merged;
42
+ }
43
+
44
+ export const defaultChannel = (config) => config.channels?.[0] ?? null;
45
+
46
+ // A channel is active unless it was explicitly switched off, so a config written
47
+ // before this option existed keeps every channel on.
48
+ export const activeChannels = (config) => (config.channels ?? []).filter((channel) => channel.active !== false);
49
+
50
+ // Switching a channel off leaves its cursor where it is, so switching it back on
51
+ // replays everything that arrived meanwhile instead of losing it.
52
+ export function setChannelActive(name, active) {
53
+ const config = loadConfig();
54
+ if (!config) return null;
55
+
56
+ const channel = findChannel(config, name);
57
+ if (!channel) return null;
58
+
59
+ channel.active = active;
60
+ saveConfig(config);
61
+ return channel;
62
+ }
63
+
64
+ export function findChannel(config, wanted) {
65
+ if (!wanted) return defaultChannel(config);
66
+ const name = String(wanted).replace(/^#/, '').toLowerCase();
67
+ return config.channels?.find((c) => c.name.toLowerCase() === name || c.id === wanted) ?? null;
68
+ }
@@ -0,0 +1,73 @@
1
+ // Every agent in a workspace shares one bot token, so Slack's own bot_id proves
2
+ // only "agent-wire posted this" — not which agent did. The header line is plain
3
+ // text anyone in the channel can type, so it cannot carry identity either.
4
+ // Each install therefore signs what it sends with its own Ed25519 key and
5
+ // publishes the public half alongside the message. A nickname is bound to the
6
+ // first key seen using it (trust on first use); a later message claiming that
7
+ // nickname with a different key is reported, not believed.
8
+ import { createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto';
9
+
10
+ import { paths, readJson, writeJson } from './config.mjs';
11
+
12
+ const DER_PRIVATE = { type: 'pkcs8', format: 'der' };
13
+ const DER_PUBLIC = { type: 'spki', format: 'der' };
14
+
15
+ export function generateKeypair() {
16
+ const { privateKey, publicKey } = generateKeyPairSync('ed25519');
17
+ return {
18
+ privateKey: privateKey.export(DER_PRIVATE).toString('base64'),
19
+ publicKey: publicKey.export(DER_PUBLIC).toString('base64'),
20
+ };
21
+ }
22
+
23
+ // Signed over the fields a forger would want to change: who sent it, who it is
24
+ // for, which channel it belongs to, and where it sits in a reply chain. Channel
25
+ // is included so a signed message cannot be replayed into a different channel.
26
+ export const signingPayload = ({ channel, from, to, conv, hop, text }) =>
27
+ Buffer.from(`agent-wire/v1\n${channel}\n${from}\n${to}\n${conv}\n${hop}\n${text}`, 'utf8');
28
+
29
+ export function signMessage(privateKeyBase64, fields) {
30
+ const privateKey = createPrivateKey({ key: Buffer.from(privateKeyBase64, 'base64'), ...DER_PRIVATE });
31
+ return sign(null, signingPayload(fields), privateKey).toString('base64');
32
+ }
33
+
34
+ function verifySignature(publicKeyBase64, signature, fields) {
35
+ try {
36
+ const publicKey = createPublicKey({ key: Buffer.from(publicKeyBase64, 'base64'), ...DER_PUBLIC });
37
+ return verify(null, signingPayload(fields), publicKey, Buffer.from(signature, 'base64'));
38
+ } catch {
39
+ // A malformed key or signature is a failed verification, not a crash: the
40
+ // bytes came from a Slack message and anyone in the channel can shape them.
41
+ return false;
42
+ }
43
+ }
44
+
45
+ const loadPeers = () => readJson(paths.peers, {});
46
+
47
+ // Verdicts, in the order they are decided:
48
+ // "signed" — signature valid and the key matches the one pinned to this name
49
+ // "new" — signature valid, first time this name appears, key now pinned
50
+ // "impostor" — signature valid but the name is pinned to a DIFFERENT key
51
+ // "unsigned" — no signature, or the signature does not verify
52
+ export function checkAuthorship({ from, publicKey, signature, ...fields }) {
53
+ if (!publicKey || !signature) return { verdict: 'unsigned' };
54
+ if (!verifySignature(publicKey, signature, { from, ...fields })) return { verdict: 'unsigned' };
55
+
56
+ const peers = loadPeers();
57
+ const pinned = peers[from];
58
+ if (pinned && pinned.publicKey !== publicKey) return { verdict: 'impostor', pinnedSince: pinned.firstSeen };
59
+ if (pinned) return { verdict: 'signed' };
60
+
61
+ peers[from] = { publicKey, firstSeen: new Date().toISOString() };
62
+ writeJson(paths.peers, peers);
63
+ return { verdict: 'new' };
64
+ }
65
+
66
+ export const forgetPeer = (name) => {
67
+ const peers = loadPeers();
68
+ delete peers[name];
69
+ writeJson(paths.peers, peers);
70
+ };
71
+
72
+ export const listPeers = () => Object.entries(loadPeers())
73
+ .map(([name, peer]) => ({ name, firstSeen: peer.firstSeen, fingerprint: peer.publicKey.slice(0, 12) }));
package/src/inbox.mjs ADDED
@@ -0,0 +1,76 @@
1
+ // The local log is the source of truth, not the Slack channel. Slack history is a
2
+ // cache we can re-read at any time, so a full re-scan is an ordinary idempotent
3
+ // operation rather than a recovery procedure.
4
+ //
5
+ // inbox.jsonl is append-only and keyed by the Slack timestamp, which is unique per
6
+ // channel and survives a re-install. Message state lives in a separate file so the
7
+ // append-only log never has to be rewritten in place.
8
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
9
+
10
+ import { HOME, paths, readJson, writeJson } from './config.mjs';
11
+
12
+ const storageKey = (item) => `${item.channel}:${item.ts}`;
13
+
14
+ export function readInbox() {
15
+ if (!existsSync(paths.inbox)) return [];
16
+ return readFileSync(paths.inbox, 'utf8').split('\n').filter(Boolean)
17
+ .map((line) => { try { return JSON.parse(line); } catch { return null; } })
18
+ .filter(Boolean);
19
+ }
20
+
21
+ export const stateOf = (states, item) => states[storageKey(item)] ?? 'unread';
22
+
23
+ // The Slack timestamp is the idempotency key: a retried poll, an overlapping
24
+ // window, or a re-installed app all replay the same ts, and a duplicate the human
25
+ // has to clean up by hand is the failure that generates support noise.
26
+ export function appendMessages(items) {
27
+ if (items.length === 0) return 0;
28
+
29
+ const seen = new Set(readInbox().map(storageKey));
30
+ const fresh = items.filter((item) => !seen.has(storageKey(item)));
31
+ if (fresh.length === 0) return 0;
32
+
33
+ mkdirSync(HOME, { recursive: true });
34
+ appendFileSync(paths.inbox, fresh.map((item) => JSON.stringify(item)).join('\n') + '\n');
35
+ return fresh.length;
36
+ }
37
+
38
+ // `channel` names one channel explicitly and overrides everything. `channels`
39
+ // is the caller's allow-list, which is how switched-off channels stay out of the
40
+ // default view without being deleted from the log.
41
+ export function selectMessages({ state = 'unread', count = 20, channel = null, channels = null } = {}) {
42
+ const states = readJson(paths.states, {});
43
+ const visible = readInbox().filter((item) => {
44
+ if (channel) return item.channel === channel;
45
+ if (channels) return channels.includes(item.channel);
46
+ return true;
47
+ });
48
+ const wanted = state === 'all' ? visible : visible.filter((item) => stateOf(states, item) === state);
49
+ return wanted.slice(-count);
50
+ }
51
+
52
+ export function markRead(items) {
53
+ const states = readJson(paths.states, {});
54
+ for (const item of items) states[storageKey(item)] = 'read';
55
+ writeJson(paths.states, states);
56
+ }
57
+
58
+ export function archive(ts) {
59
+ const states = readJson(paths.states, {});
60
+ const targets = ts
61
+ ? readInbox().filter((item) => item.ts === ts)
62
+ : readInbox().filter((item) => stateOf(states, item) === 'read');
63
+ for (const item of targets) states[storageKey(item)] = 'archived';
64
+ writeJson(paths.states, states);
65
+ return targets.length;
66
+ }
67
+
68
+ export const findByTs = (ts) => readInbox().find((item) => item.ts === ts) ?? null;
69
+
70
+ export const readCursor = (channelId) => readJson(paths.cursors, {})[channelId] ?? null;
71
+
72
+ export function writeCursor(channelId, ts) {
73
+ const cursors = readJson(paths.cursors, {});
74
+ cursors[channelId] = ts;
75
+ writeJson(paths.cursors, cursors);
76
+ }