@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 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.
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import { runSlackEventsCli } from "../dist/cli.js";
3
+
4
+ try {
5
+ await runSlackEventsCli(process.argv.slice(2));
6
+ } catch (error) {
7
+ console.error(error instanceof Error ? error.message : String(error));
8
+ process.exitCode = 1;
9
+ }
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
+ }