@zksecurity/slack-events 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/README.md +59 -0
- package/bin/slack-events.mjs +9 -0
- package/dist/cli.js +183 -0
- package/package.json +17 -0
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Slack Events Client
|
|
2
|
+
|
|
3
|
+
Requires Node.js 24 or newer. Run on the machine that will consume your events:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx @zksecurity/slack-events pair https://YOUR-EVENT-HOST
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Leave the command running and open its printed link in your laptop's browser
|
|
10
|
+
(or any browser with your Slack login). Confirm the matching terminal code and
|
|
11
|
+
authorize Slack. The waiting command saves the feed credential on the originating
|
|
12
|
+
machine, prints `Paired with ...`, and exits. No inbound port, SSH forwarding,
|
|
13
|
+
or copying a credential back is needed.
|
|
14
|
+
|
|
15
|
+
Pairing does not start a listener. On the same machine, run:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npx @zksecurity/slack-events listen
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`listen` stays running and prints one JSON object per event from your personal
|
|
22
|
+
feed, including available backlog and new arrivals. It uses the saved credential;
|
|
23
|
+
no URL or browser login is needed. Stop with Ctrl+C and run `listen` again later
|
|
24
|
+
without repeating pairing.
|
|
25
|
+
|
|
26
|
+
Printing events does not acknowledge them. For durable consumption, save all
|
|
27
|
+
events through a `sequence` cursor before acknowledging it:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npx @zksecurity/slack-events ack COMMITTED_CURSOR
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Unacknowledged events replay after restarting the listener, subject to server
|
|
34
|
+
retention. Deduplicate by `eventId`; never ACK merely because an event reached
|
|
35
|
+
stdout or a pipe.
|
|
36
|
+
|
|
37
|
+
Other commands:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
npx @zksecurity/slack-events list
|
|
41
|
+
npx @zksecurity/slack-events revoke SUBSCRIPTION_ID
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Credentials are stored with mode `0600` in
|
|
45
|
+
`$XDG_CONFIG_HOME/pi-mom/slack-events.json` or `~/.config/pi-mom/slack-events.json`.
|
|
46
|
+
|
|
47
|
+
For existing browser-issued pairing codes, `pair SERVER CODE` is also supported.
|
|
48
|
+
|
|
49
|
+
## Maintainers
|
|
50
|
+
|
|
51
|
+
From a pi-mom checkout with development dependencies installed:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
npm pack ./packages/slack-events
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The prepack step compiles only the dependency-free client source. Inspect and
|
|
58
|
+
test the tarball before publishing it with `npm publish TARBALL` under an
|
|
59
|
+
authorized npm account. Publishing is independent of the bot's npm package.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
export async function runSlackEventsCli(args) {
|
|
5
|
+
const usage = "Usage: slack-events pair <server> [code] | listen | ack <committed-cursor> | list | revoke <subscription-id>";
|
|
6
|
+
const command = args[0];
|
|
7
|
+
if (!command || command === "--help" || command === "-h") {
|
|
8
|
+
process.stdout.write(`${usage}\n`);
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
if (command === "pair") {
|
|
12
|
+
const server = requireArgument(args[1], "server URL");
|
|
13
|
+
const code = args[2];
|
|
14
|
+
const token = code
|
|
15
|
+
? requireString((await request(server, "/v1/events/pair", { method: "POST", body: { code } })).token, "pair response token")
|
|
16
|
+
: await pairDevice(server);
|
|
17
|
+
writeCredential({ server: normalizeServer(server), token });
|
|
18
|
+
process.stdout.write(`Paired with ${normalizeServer(server)}\n`);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const credential = readCredential();
|
|
22
|
+
if (command === "ack") {
|
|
23
|
+
const value = requireArgument(args[1], "committed cursor");
|
|
24
|
+
const cursor = Number(value);
|
|
25
|
+
if (!/^\d+$/.test(value) || !Number.isSafeInteger(cursor))
|
|
26
|
+
throw new Error("Invalid committed cursor");
|
|
27
|
+
await request(credential.server, "/v1/events/ack", { method: "POST", token: credential.token, body: { cursor } });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (command === "list") {
|
|
31
|
+
const response = await request(credential.server, "/v1/events/subscriptions", { token: credential.token });
|
|
32
|
+
process.stdout.write(`${JSON.stringify(response.subscriptions, null, 2)}\n`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (command === "revoke") {
|
|
36
|
+
const subscriptionId = requireArgument(args[1], "subscription id");
|
|
37
|
+
await request(credential.server, `/v1/events/subscriptions/${encodeURIComponent(subscriptionId)}`, {
|
|
38
|
+
method: "DELETE",
|
|
39
|
+
token: credential.token,
|
|
40
|
+
});
|
|
41
|
+
process.stdout.write(`Revoked ${subscriptionId}\n`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (command === "listen") {
|
|
45
|
+
await listen(credential);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
throw new Error(usage);
|
|
49
|
+
}
|
|
50
|
+
async function pairDevice(server) {
|
|
51
|
+
const pairing = await request(server, "/v1/events/pair/start", { method: "POST" });
|
|
52
|
+
const deviceCode = requireString(pairing.device_code, "device code");
|
|
53
|
+
const verificationUrl = new URL(requireString(pairing.verification_uri, "verification URL"));
|
|
54
|
+
if (verificationUrl.origin !== new URL(normalizeServer(server)).origin)
|
|
55
|
+
throw new Error("Unexpected verification URL origin");
|
|
56
|
+
const userCode = requireString(pairing.user_code, "verification code");
|
|
57
|
+
const expiresIn = requirePositiveInteger(pairing.expires_in, "pairing expiry");
|
|
58
|
+
const interval = requirePositiveInteger(pairing.interval, "poll interval");
|
|
59
|
+
const deadline = Date.now() + expiresIn * 1000;
|
|
60
|
+
process.stdout.write(`Open this link in a browser where you are logged into Slack:\n${verificationUrl}\n\nConfirm code: ${userCode}\nWaiting for authorization...\n`);
|
|
61
|
+
while (Date.now() < deadline) {
|
|
62
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(interval * 1000, deadline - Date.now())));
|
|
63
|
+
if (Date.now() >= deadline)
|
|
64
|
+
break;
|
|
65
|
+
const result = await request(server, "/v1/events/pair/poll", {
|
|
66
|
+
method: "POST", body: { device_code: deviceCode },
|
|
67
|
+
});
|
|
68
|
+
if (result.status === "approved")
|
|
69
|
+
return requireString(result.token, "pair response token");
|
|
70
|
+
if (result.status === "denied")
|
|
71
|
+
throw new Error("Authorization was denied. Run the pairing command again to retry.");
|
|
72
|
+
if (result.status === "expired")
|
|
73
|
+
break;
|
|
74
|
+
if (result.status !== "pending")
|
|
75
|
+
throw new Error("Invalid device pairing response");
|
|
76
|
+
}
|
|
77
|
+
throw new Error("Pairing expired. Run the pairing command again for a new link.");
|
|
78
|
+
}
|
|
79
|
+
async function listen(credential) {
|
|
80
|
+
let after;
|
|
81
|
+
for (;;) {
|
|
82
|
+
const path = `/v1/events?wait_ms=30000${after === undefined ? "" : `&after=${after}`}`;
|
|
83
|
+
const response = await request(credential.server, path, { token: credential.token });
|
|
84
|
+
const events = Array.isArray(response.events) ? response.events : [];
|
|
85
|
+
for (const event of events)
|
|
86
|
+
await writeStdout(`${JSON.stringify(event)}\n`);
|
|
87
|
+
if (events.length > 0) {
|
|
88
|
+
const cursor = events.at(-1).sequence;
|
|
89
|
+
if (!Number.isSafeInteger(cursor))
|
|
90
|
+
throw new Error("Event feed returned an invalid cursor");
|
|
91
|
+
// This is only an in-process read position. The recipient explicitly
|
|
92
|
+
// ACKs after its own durable commit; stdout never advances that checkpoint.
|
|
93
|
+
after = cursor;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function request(server, path, options = {}) {
|
|
98
|
+
const response = await fetch(new URL(path, `${normalizeServer(server)}/`), {
|
|
99
|
+
signal: AbortSignal.timeout(40_000),
|
|
100
|
+
method: options.method || "GET",
|
|
101
|
+
headers: {
|
|
102
|
+
...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
|
|
103
|
+
...(options.body ? { "content-type": "application/json" } : {}),
|
|
104
|
+
},
|
|
105
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
106
|
+
});
|
|
107
|
+
const body = await response.json();
|
|
108
|
+
if (!response.ok)
|
|
109
|
+
throw new Error(typeof body.error === "string" ? body.error : `Event server returned ${response.status}`);
|
|
110
|
+
return body;
|
|
111
|
+
}
|
|
112
|
+
function credentialPath() {
|
|
113
|
+
const configRoot = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
114
|
+
return join(configRoot, "pi-mom", "slack-events.json");
|
|
115
|
+
}
|
|
116
|
+
function writeCredential(credential) {
|
|
117
|
+
const path = credentialPath();
|
|
118
|
+
const directory = dirname(path);
|
|
119
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
120
|
+
chmodSync(directory, 0o700);
|
|
121
|
+
writeFileSync(path, `${JSON.stringify(credential, null, 2)}\n`, { mode: 0o600 });
|
|
122
|
+
chmodSync(path, 0o600);
|
|
123
|
+
}
|
|
124
|
+
function readCredential() {
|
|
125
|
+
let parsed;
|
|
126
|
+
try {
|
|
127
|
+
const path = credentialPath();
|
|
128
|
+
chmodSync(dirname(path), 0o700);
|
|
129
|
+
chmodSync(path, 0o600);
|
|
130
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
throw new Error("No Slack event feed credential. Run `slack-events pair <server>` first.");
|
|
134
|
+
}
|
|
135
|
+
if (!parsed || typeof parsed !== "object")
|
|
136
|
+
throw new Error("Invalid Slack event feed credential file");
|
|
137
|
+
const value = parsed;
|
|
138
|
+
return {
|
|
139
|
+
server: requireString(value.server, "credential server"),
|
|
140
|
+
token: requireString(value.token, "credential token"),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function normalizeServer(server) {
|
|
144
|
+
const url = new URL(server);
|
|
145
|
+
if (url.protocol !== "https:" && url.hostname !== "127.0.0.1" && url.hostname !== "localhost") {
|
|
146
|
+
throw new Error("Slack event server must use HTTPS except on localhost");
|
|
147
|
+
}
|
|
148
|
+
return url.toString().replace(/\/$/, "");
|
|
149
|
+
}
|
|
150
|
+
function requireArgument(value, name) {
|
|
151
|
+
if (!value)
|
|
152
|
+
throw new Error(`Missing ${name}`);
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
function requireString(value, name) {
|
|
156
|
+
if (typeof value !== "string" || !value)
|
|
157
|
+
throw new Error(`Invalid ${name}`);
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
function requirePositiveInteger(value, name) {
|
|
161
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 3600) {
|
|
162
|
+
throw new Error(`Invalid ${name}`);
|
|
163
|
+
}
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
function writeStdout(text) {
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
if (process.stdout.write(text))
|
|
169
|
+
resolve();
|
|
170
|
+
else {
|
|
171
|
+
const onDrain = () => {
|
|
172
|
+
process.stdout.off("error", onError);
|
|
173
|
+
resolve();
|
|
174
|
+
};
|
|
175
|
+
const onError = (error) => {
|
|
176
|
+
process.stdout.off("drain", onDrain);
|
|
177
|
+
reject(error);
|
|
178
|
+
};
|
|
179
|
+
process.stdout.once("drain", onDrain);
|
|
180
|
+
process.stdout.once("error", onError);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zksecurity/slack-events",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pair a terminal with a pi-mom Slack event feed",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "slack-events": "bin/slack-events.mjs" },
|
|
7
|
+
"files": ["bin", "dist"],
|
|
8
|
+
"engines": { "node": ">=24" },
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/zksecurity/pi-mom.git",
|
|
13
|
+
"directory": "packages/slack-events"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": { "access": "public" },
|
|
16
|
+
"scripts": { "prepack": "node ../../scripts/build-slack-events-client.mjs" }
|
|
17
|
+
}
|