threadwire 0.1.26 → 0.1.28

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/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Add an optional loopback-only TensorBuzz-to-Telegram queue bridge. It verifies
6
+ bounded raw-body HMAC-SHA-256 requests before JSON parsing, admits a closed
7
+ `build_group.completed` schema, builds a bounded canonical `/queue` prompt,
8
+ and routes through a reviewed read-only event-class mapping. A dedicated
9
+ MTProto user sender uses durable `node:sqlite` receipts, stable send random
10
+ IDs, per-topic FIFO, classified transient retry, audit state, and persisted
11
+ Telegram message IDs. The profile-gated container receives only its dedicated
12
+ secret files and includes liveness/readiness endpoints plus activation,
13
+ rotation, revocation, pause, and rollback documentation.
14
+
15
+ - Require `THREADWIRE_KIMI_E2E_OUTER_BROKER_HOST` for
16
+ `npm run test:kimi-isolated-runtime-e2e`, publish the outer broker on a
17
+ Docker-assigned host port, and verify that exact endpoint from a task-DinD
18
+ container before launching the isolated runtime.
19
+
5
20
  - Enforce per-run worker mutation boundaries in isolated runtime. `threadwire run
6
21
  --relay-write` accepts a closed `--mutation-policy <json>` with
7
22
  `{worktreeEdit, commit, push, githubWrite}` booleans; omitted capabilities
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+
4
+ import {loadBridgeConfig} from "../src/tensorbuzz-queue-bridge/config.js"
5
+ import {TeleprotoUserSender} from "../src/tensorbuzz-queue-bridge/mtproto-sender.js"
6
+ import {TensorBuzzQueueBridgeService} from "../src/tensorbuzz-queue-bridge/service.js"
7
+
8
+ const config = await loadBridgeConfig(process.env)
9
+ const sender = await TeleprotoUserSender.connect(config)
10
+ const service = new TensorBuzzQueueBridgeService({...config, sender})
11
+ await service.listen(config.host, config.port)
12
+
13
+ let stopping = false
14
+ for (const signal of ["SIGINT", "SIGTERM"]) {
15
+ process.once(signal, () => {
16
+ if (stopping) return
17
+ stopping = true
18
+ void service.close().then(() => sender.close()).then(() => process.exit(0), (error) => {
19
+ process.stderr.write(`TensorBuzz queue bridge shutdown failed: ${error instanceof Error ? error.message : "unknown error"}\n`)
20
+ process.exit(1)
21
+ })
22
+ })
23
+ }
@@ -0,0 +1,3 @@
1
+ const port = process.env.THREADWIRE_TENSORBUZZ_PORT ?? "8790"
2
+ const response = await fetch(`http://127.0.0.1:${port}/readyz`, {signal: AbortSignal.timeout(2_000)})
3
+ if (!response.ok) process.exitCode = 1
@@ -315,7 +315,19 @@ state makes health and preflight fail closed with redacted errors.
315
315
  ### Kimi validation and rollback
316
316
 
317
317
  Run `npm run test:kimi-isolated-runtime-e2e` from an approved Docker supervisor
318
- boundary after supplying the documented immutable image variables. A real
318
+ boundary after supplying the documented immutable image variables and an
319
+ explicit outer broker address:
320
+
321
+ ```sh
322
+ THREADWIRE_KIMI_E2E_OUTER_BROKER_HOST='<outer-host-ipv4>' \
323
+ npm run test:kimi-isolated-runtime-e2e
324
+ ```
325
+
326
+ Replace `<outer-host-ipv4>` with an IPv4 address on the outer Docker host that
327
+ the outer daemon can bind and containers created by the task DinD can reach.
328
+ The E2E asks Docker to allocate the published broker host port, inspects that
329
+ exact binding, and probes it from a throwaway task-DinD container; do not infer
330
+ the address from a default gateway or reserve a fixed host port. A real
319
331
  account smoke must then cover fresh execution, capture of exactly one native
320
332
  session ID, exact resume with that ID, an approved non-default alias if used,
321
333
  Telegram DM/topic routing, cancellation, and confirmation that service logs and
@@ -0,0 +1,133 @@
1
+ # TensorBuzz queue bridge
2
+
3
+ The optional `threadwire-tensorbuzz-queue-bridge` service admits one closed
4
+ TensorBuzz event class, durably records it, and sends a real MTProto **user**
5
+ message to a statically reviewed Telegram destination. Its only outbound text is
6
+ `/queue <canonical prompt>`. Event payloads cannot select a route, sender,
7
+ method, command, or arbitrary prompt fields.
8
+
9
+ The bridge is deliberately separate from Telegram bot ingress and from worker
10
+ execution. It does not synthesize Telegram updates, call a Hermes API or
11
+ adapter, impersonate another Telegram user, or execute queued work itself.
12
+
13
+ ## Security and delivery model
14
+
15
+ - HTTP binds only to IPv4 loopback (`127.0.0.1`, default port `8790`). Compose
16
+ uses host networking so a host-local TensorBuzz webhook producer can reach
17
+ that loopback socket without publishing it externally.
18
+ - `POST /webhook` accepts at most 65,536 raw bytes. The lowercase-hex
19
+ HMAC-SHA-256 over `<unix-seconds>.<exact raw body>` is checked before JSON
20
+ parsing. Required headers are `X-Webhook-Timestamp`,
21
+ `X-TensorBuzz-Delivery`, and `X-Webhook-Signature-V2`. Timestamps have a
22
+ five-minute freshness window. Delivery IDs use a bounded 128-character
23
+ identifier for durable idempotency only and are not part of the signature.
24
+ - Only `build_group.completed` is accepted. Its exact fields are `event`,
25
+ `project`, `buildGroupId`, `status`, `commitSha`, `branch`, and `summary`;
26
+ values are bounded and controls/newlines are rejected. Unknown or missing
27
+ fields fail closed.
28
+ - The reviewed route file must contain exactly one mapping:
29
+
30
+ ```json
31
+ {
32
+ "build_group.completed": {
33
+ "chatId": "-100REVIEWED_SUPERGROUP_ID",
34
+ "messageThreadId": 123
35
+ }
36
+ }
37
+ ```
38
+
39
+ Replace both example values. Review this file like code and mount it
40
+ read-only; never derive it from a webhook payload.
41
+ - Built-in `node:sqlite` stores receipts, a bounded audit ledger, a stable
42
+ MTProto `randomId`, retry state, and the returned Telegram message ID. Startup
43
+ resumes an interrupted send with the same `randomId`, allowing Telegram's
44
+ MTProto idempotency to prevent a second message if the process stopped after
45
+ Telegram accepted the first send but before SQLite recorded it. Topic order
46
+ is FIFO. Only structured flood-wait and transport failures retry; other
47
+ failures become terminal and surface.
48
+ - `GET /healthz` is liveness. `GET /readyz` becomes ready only after the
49
+ authorized MTProto client, SQLite store, and loopback listener are active.
50
+
51
+ ## Install and provision
52
+
53
+ Create four root/operator-readable files outside the repository and one
54
+ reviewed non-secret route file. Do not put their contents in shell exports,
55
+ Compose values, source, images, logs, prompts, or command output:
56
+
57
+ - webhook HMAC secret (at least 32 random bytes);
58
+ - Telegram API ID;
59
+ - Telegram API hash;
60
+ - serialized Teleproto user session;
61
+ - reviewed route JSON shown above.
62
+
63
+ Provision the serialized session interactively outside this service, using a
64
+ dedicated automation Telegram user. Phone number and 2FA password are
65
+ provisioning inputs only and must not be stored in this repository, Compose, or
66
+ the runtime container. Give that user access only to the reviewed destination.
67
+
68
+ Set only file paths in the operator environment:
69
+
70
+ ```sh
71
+ THREADWIRE_TENSORBUZZ_WEBHOOK_SECRET_FILE=/secure/threadwire/tensorbuzz-hmac
72
+ THREADWIRE_MTPROTO_API_ID_FILE=/secure/threadwire/mtproto-api-id
73
+ THREADWIRE_MTPROTO_API_HASH_FILE=/secure/threadwire/mtproto-api-hash
74
+ THREADWIRE_MTPROTO_SESSION_FILE=/secure/threadwire/mtproto-session
75
+ THREADWIRE_TENSORBUZZ_ROUTES_FILE=/secure/threadwire/tensorbuzz-routes.json
76
+ export THREADWIRE_TENSORBUZZ_WEBHOOK_SECRET_FILE THREADWIRE_MTPROTO_API_ID_FILE
77
+ export THREADWIRE_MTPROTO_API_HASH_FILE THREADWIRE_MTPROTO_SESSION_FILE
78
+ export THREADWIRE_TENSORBUZZ_ROUTES_FILE
79
+ ```
80
+
81
+ These variables contain paths, not credentials. Build without activating:
82
+
83
+ ```sh
84
+ docker compose --profile tensorbuzz-queue-bridge build tensorbuzz-queue-bridge
85
+ ```
86
+
87
+ ## Activate and verify
88
+
89
+ Start only after the route and dedicated user access have been reviewed:
90
+
91
+ ```sh
92
+ docker compose --profile tensorbuzz-queue-bridge up --detach tensorbuzz-queue-bridge
93
+ docker compose --profile tensorbuzz-queue-bridge exec -T tensorbuzz-queue-bridge \
94
+ node docker/tensorbuzz-queue-bridge-healthcheck.mjs
95
+ ```
96
+
97
+ Configure TensorBuzz to POST to `http://127.0.0.1:8790/webhook` with the signing
98
+ contract above. Never place the shared HMAC secret in the URL.
99
+
100
+ ## Rotate, revoke, pause, and roll back
101
+
102
+ HMAC rotation is a coordinated cutover: pause TensorBuzz delivery, atomically
103
+ replace the secret file, recreate the bridge, update TensorBuzz's signer, then
104
+ resume and verify readiness. There is intentionally no old-secret fallback.
105
+
106
+ MTProto rotation uses a newly provisioned session for the same dedicated user:
107
+ pause delivery, atomically replace the session file, recreate, verify readiness,
108
+ then revoke the old Telegram session in Telegram's active-sessions control.
109
+ Rotate API hash files with the same pause/recreate/readiness sequence.
110
+
111
+ To revoke immediately, pause TensorBuzz delivery and stop the profile service,
112
+ then revoke the dedicated Telegram session and remove its destination access.
113
+ Keep the SQLite volume for audit and idempotent recovery unless its retention
114
+ policy explicitly authorizes deletion.
115
+
116
+ To pause without revocation:
117
+
118
+ ```sh
119
+ docker compose --profile tensorbuzz-queue-bridge stop tensorbuzz-queue-bridge
120
+ ```
121
+
122
+ For rollback, keep TensorBuzz paused, select the prior immutable Threadwire
123
+ image, recreate the bridge against the existing SQLite volume, verify
124
+ `/readyz`, then resume. Do not roll back the database by deleting or replacing
125
+ the volume; retained receipts are what prevent duplicate sends.
126
+
127
+ ## Live-proof blocker
128
+
129
+ Automated tests use only an injected fake MTProto sender. Live end-to-end proof
130
+ is blocked until a dedicated automation Telegram user is provisioned with its
131
+ own MTProto session and access to the reviewed destination chat/topic. No
132
+ production Telegram session, phone number, 2FA secret, API hash, or message was
133
+ used while implementing this service.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "threadwire",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "Stream Codex, Claude, Kimi Code, and OpenCode worker progress to an explicit Telegram destination",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -17,6 +17,7 @@
17
17
  "files": [
18
18
  "CHANGELOG.md",
19
19
  "bin/",
20
+ "docker/tensorbuzz-queue-bridge-healthcheck.mjs",
20
21
  "docs/",
21
22
  "scripts/",
22
23
  "src/",
@@ -26,6 +27,7 @@
26
27
  "threadwire-isolated-runtime": "bin/isolated-runtime.js",
27
28
  "threadwire-kimi-model-broker": "bin/kimi-model-broker.js",
28
29
  "threadwire-model-broker": "bin/model-broker.js",
30
+ "threadwire-tensorbuzz-queue-bridge": "bin/tensorbuzz-queue-bridge.js",
29
31
  "threadwire": "bin/threadwire.js",
30
32
  "threadwire-telegram-webhook": "bin/telegram-webhook.js"
31
33
  },
@@ -69,5 +71,10 @@
69
71
  "release-patch": "1.0.3",
70
72
  "typescript": "6.0.2"
71
73
  },
72
- "license": "AGPL-3.0-only"
74
+ "license": "AGPL-3.0-only",
75
+ "dependencies": {
76
+ "big-integer": "1.6.52",
77
+ "teleproto": "1.228.5",
78
+ "typanic": "1.0.9"
79
+ }
73
80
  }
@@ -0,0 +1,32 @@
1
+ // @ts-check
2
+
3
+ import {execFile} from "node:child_process"
4
+ import {promisify} from "node:util"
5
+
6
+ const execFileAsync = promisify(execFile)
7
+
8
+ export const PRODUCTION_PACKAGE_SPECS = Object.freeze([
9
+ "big-integer@1.6.52",
10
+ "graceful-fs@4.2.11",
11
+ "imurmurhash@0.1.4",
12
+ "ip-address@10.5.0",
13
+ "mime@3.0.0",
14
+ "node-localstorage@2.2.1",
15
+ "slide@1.1.6",
16
+ "smart-buffer@4.2.0",
17
+ "socks@2.8.9",
18
+ "store2@2.14.4",
19
+ "teleproto@1.228.5",
20
+ "typanic@1.0.9",
21
+ "write-file-atomic@1.3.4"
22
+ ])
23
+
24
+ /** @param {string} cacheDirectory @param {NodeJS.ProcessEnv} environment */
25
+ export async function seedProductionPackageCache(cacheDirectory, environment) {
26
+ for (const packageSpec of PRODUCTION_PACKAGE_SPECS) {
27
+ await execFileAsync("npm", ["cache", "add", "--cache", cacheDirectory, packageSpec], {
28
+ env: environment,
29
+ maxBuffer: 10 * 1024 * 1024
30
+ })
31
+ }
32
+ }
@@ -6,6 +6,7 @@ import {mkdtemp, rm} from "node:fs/promises"
6
6
  import {tmpdir} from "node:os"
7
7
  import {join} from "node:path"
8
8
  import {promisify} from "node:util"
9
+ import {seedProductionPackageCache} from "./seed-production-package-cache.js"
9
10
 
10
11
  const execFileAsync = promisify(execFile)
11
12
  const npmEnvironment = {
@@ -24,6 +25,7 @@ const EXPECTED_FILES = [
24
25
  "bin/isolated-runtime.js",
25
26
  "bin/kimi-model-broker.js",
26
27
  "bin/model-broker.js",
28
+ "bin/tensorbuzz-queue-bridge.js",
27
29
  "docs/capacity-admission.md",
28
30
  "docs/card-10520-plan.md",
29
31
  "docs/container-runtime.md",
@@ -31,6 +33,8 @@ const EXPECTED_FILES = [
31
33
  "docs/development-container.md",
32
34
  "docs/evidence-artifacts.md",
33
35
  "docs/isolated-provider-runtime.md",
36
+ "docs/tensorbuzz-queue-bridge.md",
37
+ "docker/tensorbuzz-queue-bridge-healthcheck.mjs",
34
38
  "package.json",
35
39
  "scripts/atomic-install.js",
36
40
  "scripts/install-local-launcher.js",
@@ -39,6 +43,7 @@ const EXPECTED_FILES = [
39
43
  "scripts/provider-shims/codex.adapter.sh",
40
44
  "scripts/provider-shims/front-door.sh.template",
41
45
  "scripts/provider-shims/opencode-local-fleet.adapter.sh",
46
+ "scripts/seed-production-package-cache.js",
42
47
  "scripts/verify-package.js",
43
48
  "src/absolute-deadline.js",
44
49
  "src/activity-log.js",
@@ -89,6 +94,11 @@ const EXPECTED_FILES = [
89
94
  "src/telegram-ingress/http.js",
90
95
  "src/telegram-ingress/update-guard.js",
91
96
  "src/telegram-webhook.js",
97
+ "src/tensorbuzz-queue-bridge/bridge.js",
98
+ "src/tensorbuzz-queue-bridge/config.js",
99
+ "src/tensorbuzz-queue-bridge/http.js",
100
+ "src/tensorbuzz-queue-bridge/mtproto-sender.js",
101
+ "src/tensorbuzz-queue-bridge/service.js",
92
102
  "src/threadwire-binding.js",
93
103
  "src/types.js",
94
104
  "src/worker-control.js"
@@ -121,6 +131,7 @@ async function main() {
121
131
  )
122
132
 
123
133
  const tarballPath = join(temporaryDirectory, packResult.filename)
134
+ await seedProductionPackageCache(cacheDirectory, npmEnvironment)
124
135
  const {stdout: helpOutput} = await execFileAsync("npm", [
125
136
  "exec",
126
137
  "--dry-run=false",
@@ -0,0 +1,249 @@
1
+ // @ts-check
2
+
3
+ import {randomBytes} from "node:crypto"
4
+ import {DatabaseSync} from "node:sqlite"
5
+ import {forcedInteger, forcedString} from "typanic"
6
+
7
+ const EVENT_FIELDS = Object.freeze(["branch", "buildGroupId", "commitSha", "event", "project", "status", "summary"])
8
+ const STATUSES = new Set(["passed", "failed", "cancelled"])
9
+
10
+ /**
11
+ * @typedef {{chatId: string, messageThreadId: number}} Route
12
+ * @typedef {{chatId: string, messageThreadId: number, text: string, randomId: string}} OutboundMessage
13
+ * @typedef {{sendMessage: (message: OutboundMessage) => Promise<{messageId: number}>}} MtProtoSender
14
+ */
15
+
16
+ export class TensorBuzzQueueBridge {
17
+ /** @type {DatabaseSync} */
18
+ #database
19
+ /** @type {Readonly<Record<string, Route>>} */
20
+ #routes
21
+ /** @type {MtProtoSender} */
22
+ #sender
23
+ /** @type {() => number} */
24
+ #now
25
+
26
+ /** @param {{databasePath: string, routes: Readonly<Record<string, Route>>, sender: MtProtoSender, now?: () => number}} options */
27
+ constructor(options) {
28
+ this.#routes = options.routes
29
+ this.#sender = options.sender
30
+ this.#now = options.now ?? Date.now
31
+ this.#database = new DatabaseSync(options.databasePath)
32
+ this.#database.exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA busy_timeout = 5000")
33
+ this.#database.exec(`
34
+ CREATE TABLE IF NOT EXISTS receipts (
35
+ delivery_id TEXT PRIMARY KEY,
36
+ event_class TEXT NOT NULL,
37
+ topic_key TEXT NOT NULL,
38
+ chat_id TEXT NOT NULL,
39
+ message_thread_id INTEGER NOT NULL,
40
+ prompt TEXT NOT NULL,
41
+ random_id TEXT NOT NULL UNIQUE,
42
+ state TEXT NOT NULL CHECK (state IN ('pending', 'sending', 'sent', 'failed')),
43
+ attempts INTEGER NOT NULL DEFAULT 0,
44
+ next_attempt_at INTEGER NOT NULL,
45
+ telegram_message_id INTEGER,
46
+ last_error_class TEXT,
47
+ created_at INTEGER NOT NULL,
48
+ updated_at INTEGER NOT NULL
49
+ );
50
+ CREATE TABLE IF NOT EXISTS audit (
51
+ id INTEGER PRIMARY KEY,
52
+ delivery_id TEXT NOT NULL,
53
+ action TEXT NOT NULL,
54
+ at INTEGER NOT NULL,
55
+ detail TEXT,
56
+ FOREIGN KEY (delivery_id) REFERENCES receipts(delivery_id)
57
+ );
58
+ `)
59
+ const now = this.#now()
60
+ this.#database.prepare("UPDATE receipts SET state = 'pending', next_attempt_at = ?, updated_at = ? WHERE state = 'sending'").run(now, now)
61
+ }
62
+
63
+ /** @param {unknown} candidate @param {string} deliveryId */
64
+ acceptEvent(candidate, deliveryId) {
65
+ const event = validateEvent(candidate)
66
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(deliveryId)) throw new Error("Invalid delivery ID")
67
+ const route = this.#routes[event.event]
68
+ if (!route) throw new Error(`Unreviewed event class: ${event.event}`)
69
+ const prompt = canonicalPrompt(event)
70
+ const now = this.#now()
71
+ const randomId = randomBytes(8).readBigInt64BE().toString()
72
+ this.#database.exec("BEGIN IMMEDIATE")
73
+ try {
74
+ const result = this.#database.prepare(`
75
+ INSERT OR IGNORE INTO receipts
76
+ (delivery_id, event_class, topic_key, chat_id, message_thread_id, prompt, random_id, state, next_attempt_at, created_at, updated_at)
77
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)
78
+ `).run(deliveryId, event.event, `${route.chatId}:${route.messageThreadId}`, route.chatId, route.messageThreadId, prompt, randomId, now, now, now)
79
+ if (result.changes === 1) {
80
+ this.#database.prepare("INSERT INTO audit (delivery_id, action, at) VALUES (?, 'accepted', ?)").run(deliveryId, now)
81
+ } else {
82
+ const existing = this.#database.prepare("SELECT event_class, chat_id, message_thread_id, prompt FROM receipts WHERE delivery_id = ?").get(deliveryId)
83
+ if (!existing ||
84
+ forcedString(existing.event_class, "stored event_class") !== event.event ||
85
+ forcedString(existing.chat_id, "stored chat_id") !== route.chatId ||
86
+ forcedInteger(existing.message_thread_id, "stored message_thread_id") !== route.messageThreadId ||
87
+ forcedString(existing.prompt, "stored prompt") !== prompt) {
88
+ throw new Error("Authenticated delivery ID conflict")
89
+ }
90
+ }
91
+ this.#database.exec("COMMIT")
92
+ return result.changes === 1
93
+ } catch (error) {
94
+ this.#database.exec("ROLLBACK")
95
+ throw error
96
+ }
97
+ }
98
+
99
+ async drainOnce() {
100
+ const now = this.#now()
101
+ const candidate = this.#database.prepare(`
102
+ SELECT r.* FROM receipts r
103
+ WHERE r.state = 'pending' AND r.next_attempt_at <= ?
104
+ AND NOT EXISTS (
105
+ SELECT 1 FROM receipts earlier
106
+ WHERE earlier.topic_key = r.topic_key AND earlier.state IN ('pending', 'sending')
107
+ AND earlier.rowid < r.rowid
108
+ )
109
+ ORDER BY r.created_at, r.rowid LIMIT 1
110
+ `).get(now)
111
+ if (!candidate) return false
112
+ const row = readQueuedReceipt(candidate)
113
+ this.#database.exec("BEGIN IMMEDIATE")
114
+ try {
115
+ const claimed = this.#database.prepare("UPDATE receipts SET state = 'sending', attempts = attempts + 1, updated_at = ? WHERE delivery_id = ? AND state = 'pending'").run(now, row.deliveryId)
116
+ if (claimed.changes !== 1) {
117
+ this.#database.exec("ROLLBACK")
118
+ return false
119
+ }
120
+ this.#database.prepare("INSERT INTO audit (delivery_id, action, at) VALUES (?, 'sending', ?)").run(row.deliveryId, now)
121
+ this.#database.exec("COMMIT")
122
+ } catch (error) {
123
+ this.#database.exec("ROLLBACK")
124
+ throw error
125
+ }
126
+
127
+ try {
128
+ const result = await this.#sender.sendMessage({
129
+ chatId: row.chatId,
130
+ messageThreadId: row.messageThreadId,
131
+ text: `/queue ${row.prompt}`,
132
+ randomId: row.randomId
133
+ })
134
+ this.#finish(row.deliveryId, "sent", result.messageId, null, now)
135
+ return true
136
+ } catch (error) {
137
+ const retry = classifyTransient(error)
138
+ if (retry) {
139
+ const retryAt = now + retry.delayMs
140
+ this.#database.exec("BEGIN IMMEDIATE")
141
+ try {
142
+ this.#database.prepare("UPDATE receipts SET state = 'pending', next_attempt_at = ?, last_error_class = ?, updated_at = ? WHERE delivery_id = ?").run(retryAt, retry.errorClass, now, row.deliveryId)
143
+ this.#database.prepare("INSERT INTO audit (delivery_id, action, at, detail) VALUES (?, 'retry', ?, ?)").run(row.deliveryId, now, retry.errorClass)
144
+ this.#database.exec("COMMIT")
145
+ } catch (databaseError) {
146
+ this.#database.exec("ROLLBACK")
147
+ throw databaseError
148
+ }
149
+ return false
150
+ }
151
+ this.#finish(row.deliveryId, "failed", null, "permanent", now)
152
+ throw error
153
+ }
154
+ }
155
+
156
+ /** @param {string} deliveryId */
157
+ receipt(deliveryId) {
158
+ const row = this.#database.prepare("SELECT state, telegram_message_id FROM receipts WHERE delivery_id = ?").get(deliveryId)
159
+ if (!row) return null
160
+ const state = forcedString(row.state, "receipt state")
161
+ if (state !== "pending" && state !== "sending" && state !== "sent" && state !== "failed") throw new Error("Receipt has invalid state")
162
+ const telegramMessageId = row.telegram_message_id === null ? null : forcedInteger(row.telegram_message_id, "receipt telegram_message_id")
163
+ return {state, telegramMessageId}
164
+ }
165
+
166
+ close() {
167
+ this.#database.close()
168
+ }
169
+
170
+ /** @param {string} deliveryId @param {"sent" | "failed"} state @param {number | null} messageId @param {string | null} detail @param {number} now */
171
+ #finish(deliveryId, state, messageId, detail, now) {
172
+ this.#database.exec("BEGIN IMMEDIATE")
173
+ try {
174
+ this.#database.prepare("UPDATE receipts SET state = ?, telegram_message_id = ?, last_error_class = ?, updated_at = ? WHERE delivery_id = ?").run(state, messageId, detail, now, deliveryId)
175
+ this.#database.prepare("INSERT INTO audit (delivery_id, action, at, detail) VALUES (?, ?, ?, ?)").run(deliveryId, state, now, detail)
176
+ this.#database.exec("COMMIT")
177
+ } catch (error) {
178
+ this.#database.exec("ROLLBACK")
179
+ throw error
180
+ }
181
+ }
182
+ }
183
+
184
+ /** @param {Record<string, import("node:sqlite").SQLOutputValue>} row */
185
+ function readQueuedReceipt(row) {
186
+ const deliveryId = forcedString(row.delivery_id, "receipt delivery_id")
187
+ const chatId = forcedString(row.chat_id, "receipt chat_id")
188
+ const messageThreadId = forcedInteger(row.message_thread_id, "receipt message_thread_id")
189
+ const prompt = forcedString(row.prompt, "receipt prompt")
190
+ const randomId = forcedString(row.random_id, "receipt random_id")
191
+ return {deliveryId, chatId, messageThreadId, prompt, randomId}
192
+ }
193
+
194
+ /** @param {unknown} candidate */
195
+ function validateEvent(candidate) {
196
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate) || Object.getPrototypeOf(candidate) !== Object.prototype) {
197
+ throw new Error("Event must be a plain object")
198
+ }
199
+ const keys = Object.keys(candidate).sort()
200
+ if (keys.length !== EVENT_FIELDS.length || keys.some((key, index) => key !== EVENT_FIELDS[index])) throw new Error("Event has an unknown or missing field")
201
+ const record = /** @type {Record<string, unknown>} */ (candidate)
202
+ const event = forcedString(record.event, "event")
203
+ if (event !== "build_group.completed") throw new Error("Unsupported event class")
204
+ const project = bounded(record.project, "project", 1, 200)
205
+ const buildGroupId = bounded(record.buildGroupId, "buildGroupId", 1, 128)
206
+ const status = forcedString(record.status, "status")
207
+ if (!STATUSES.has(status)) throw new Error("Unsupported build status")
208
+ const commitSha = forcedString(record.commitSha, "commitSha")
209
+ if (!/^[a-f0-9]{40}$/u.test(commitSha)) throw new Error("commitSha must be a full lowercase Git SHA")
210
+ const branch = bounded(record.branch, "branch", 1, 200)
211
+ const summary = bounded(record.summary, "summary", 1, 500)
212
+ return {event, project, buildGroupId, status, commitSha, branch, summary}
213
+ }
214
+
215
+ /** @param {unknown} value @param {string} label @param {number} minimum @param {number} maximum */
216
+ function bounded(value, label, minimum, maximum) {
217
+ const string = forcedString(value, label)
218
+ let hasControl = false
219
+ for (const character of string) {
220
+ const codePoint = character.codePointAt(0)
221
+ if (codePoint !== undefined && (codePoint < 32 || codePoint === 127)) hasControl = true
222
+ }
223
+ if (string.length < minimum || string.length > maximum || hasControl) throw new Error(`${label} has invalid length or characters`)
224
+ return string
225
+ }
226
+
227
+ /** @param {ReturnType<typeof validateEvent>} event */
228
+ function canonicalPrompt(event) {
229
+ return [
230
+ "TensorBuzz build group completed",
231
+ `Project: ${event.project}`,
232
+ `Build group: ${event.buildGroupId}`,
233
+ `Status: ${event.status}`,
234
+ `Commit: ${event.commitSha}`,
235
+ `Branch: ${event.branch}`,
236
+ `Summary: ${event.summary}`
237
+ ].join("\n")
238
+ }
239
+
240
+ /** @param {unknown} error */
241
+ function classifyTransient(error) {
242
+ if (!error || typeof error !== "object") return null
243
+ const record = /** @type {{errorMessage?: unknown, seconds?: unknown, code?: unknown}} */ (error)
244
+ if (typeof record.errorMessage === "string" && /^FLOOD_WAIT_\d+$/u.test(record.errorMessage) && Number.isSafeInteger(record.seconds) && Number(record.seconds) > 0) {
245
+ return {errorClass: "flood-wait", delayMs: Math.min(Number(record.seconds) * 1_000, 3_600_000)}
246
+ }
247
+ if (record.code === "ECONNRESET" || record.code === "ETIMEDOUT" || record.code === 500) return {errorClass: "transport", delayMs: 5_000}
248
+ return null
249
+ }
@@ -0,0 +1,61 @@
1
+ // @ts-check
2
+
3
+ import {readFile} from "node:fs/promises"
4
+ import {isAbsolute} from "node:path"
5
+ import {forcedInteger, forcedString} from "typanic"
6
+
7
+ const LOOPBACK_HOST = "127.0.0.1"
8
+
9
+ /** @param {NodeJS.ProcessEnv} environment */
10
+ export async function loadBridgeConfig(environment) {
11
+ const secret = await readSecretFile(environment.THREADWIRE_TENSORBUZZ_WEBHOOK_SECRET_FILE, "THREADWIRE_TENSORBUZZ_WEBHOOK_SECRET_FILE")
12
+ if (Buffer.byteLength(secret) < 32) throw new Error("TensorBuzz webhook secret must contain at least 32 bytes")
13
+ const apiIdText = await readSecretFile(environment.THREADWIRE_MTPROTO_API_ID_FILE, "THREADWIRE_MTPROTO_API_ID_FILE")
14
+ if (!/^\d+$/u.test(apiIdText)) throw new Error("MTProto API ID file must contain a positive integer")
15
+ const apiId = Number(apiIdText)
16
+ if (!Number.isSafeInteger(apiId) || apiId <= 0) throw new Error("MTProto API ID must be a positive safe integer")
17
+ const apiHash = await readSecretFile(environment.THREADWIRE_MTPROTO_API_HASH_FILE, "THREADWIRE_MTPROTO_API_HASH_FILE")
18
+ const session = await readSecretFile(environment.THREADWIRE_MTPROTO_SESSION_FILE, "THREADWIRE_MTPROTO_SESSION_FILE")
19
+ const routesPath = requiredAbsolutePath(environment.THREADWIRE_TENSORBUZZ_ROUTES_FILE, "THREADWIRE_TENSORBUZZ_ROUTES_FILE")
20
+ let routes
21
+ try {
22
+ routes = parseRoutes(JSON.parse(await readFile(routesPath, "utf8")))
23
+ } catch (error) {
24
+ throw new Error("TensorBuzz routes configuration could not be loaded", {cause: error})
25
+ }
26
+ const databasePath = requiredAbsolutePath(environment.THREADWIRE_TENSORBUZZ_DATABASE_PATH, "THREADWIRE_TENSORBUZZ_DATABASE_PATH")
27
+ const port = environment.THREADWIRE_TENSORBUZZ_PORT === undefined ? 8790 : Number(environment.THREADWIRE_TENSORBUZZ_PORT)
28
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) throw new Error("THREADWIRE_TENSORBUZZ_PORT must be a valid TCP port")
29
+ return {host: LOOPBACK_HOST, port, secret, apiId, apiHash, session, routes, databasePath}
30
+ }
31
+
32
+ /** @param {string | undefined} path @param {string} label */
33
+ async function readSecretFile(path, label) {
34
+ const value = (await readFile(requiredAbsolutePath(path, label), "utf8")).trim()
35
+ if (!value) throw new Error(`${label} must reference a nonempty file`)
36
+ return value
37
+ }
38
+
39
+ /** @param {string | undefined} value @param {string} label */
40
+ function requiredAbsolutePath(value, label) {
41
+ const path = forcedString(value, label)
42
+ if (!isAbsolute(path)) throw new Error(`${label} must be absolute`)
43
+ return path
44
+ }
45
+
46
+ /** @param {unknown} candidate */
47
+ function parseRoutes(candidate) {
48
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate) || Object.getPrototypeOf(candidate) !== Object.prototype) throw new Error("Routes must be a plain object")
49
+ const keys = Object.keys(candidate)
50
+ if (keys.length !== 1 || keys[0] !== "build_group.completed") throw new Error("Routes must contain only build_group.completed")
51
+ const route = /** @type {Record<string, unknown>} */ (candidate)["build_group.completed"]
52
+ if (!route || typeof route !== "object" || Array.isArray(route) || Object.getPrototypeOf(route) !== Object.prototype) throw new Error("Route must be a plain object")
53
+ const record = /** @type {Record<string, unknown>} */ (route)
54
+ const routeKeys = Object.keys(record).sort()
55
+ if (routeKeys.join(",") !== "chatId,messageThreadId") throw new Error("Route has an unknown or missing field")
56
+ const chatId = forcedString(record.chatId, "route chatId")
57
+ if (!/^-100[1-9]\d+$/u.test(chatId)) throw new Error("Route chatId must be a Telegram supergroup ID")
58
+ const messageThreadId = forcedInteger(record.messageThreadId, "route messageThreadId")
59
+ if (messageThreadId <= 0) throw new Error("Route messageThreadId must be positive")
60
+ return Object.freeze({"build_group.completed": Object.freeze({chatId, messageThreadId})})
61
+ }
@@ -0,0 +1,134 @@
1
+ // @ts-check
2
+
3
+ import {createHmac, timingSafeEqual} from "node:crypto"
4
+
5
+ export const MAX_BODY_BYTES = 65_536
6
+ export const MAX_CLOCK_SKEW_SECONDS = 300
7
+
8
+ /**
9
+ * @typedef {{sendMessage: (message: {chatId: string, messageThreadId: number, text: string, randomId: string}) => Promise<{messageId: number}>}} MtProtoSender
10
+ * @typedef {{secret: string, now?: () => number, sender?: MtProtoSender, acceptEvent?: (event: unknown, deliveryId: string) => Promise<void> | void, isReady?: () => boolean}} HandlerOptions
11
+ */
12
+
13
+ /**
14
+ * Create the loopback bridge HTTP handler.
15
+ * @param {HandlerOptions} options
16
+ */
17
+ export function createTensorBuzzQueueBridgeHandler(options) {
18
+ const now = options.now ?? Date.now
19
+ return (/** @type {import("node:http").IncomingMessage} */ request, /** @type {import("node:http").ServerResponse} */ response) => {
20
+ void handleRequest(request, response, options, now).catch(() => {
21
+ if (!response.headersSent) send(response, 500)
22
+ })
23
+ }
24
+ }
25
+
26
+ /**
27
+ * @param {import("node:http").IncomingMessage} request
28
+ * @param {import("node:http").ServerResponse} response
29
+ * @param {HandlerOptions} options
30
+ * @param {() => number} now
31
+ */
32
+ async function handleRequest(request, response, options, now) {
33
+ if (request.method === "GET" && request.url === "/healthz") return send(response, 200, "ok\n")
34
+ if (request.method === "GET" && request.url === "/readyz") {
35
+ return options.isReady?.() === false ? send(response, 503, "not ready\n") : send(response, 200, "ready\n")
36
+ }
37
+ if (request.method !== "POST" || request.url !== "/webhook") return reject(request, response, 404)
38
+ if (request.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") {
39
+ return reject(request, response, 415)
40
+ }
41
+
42
+ let rawBody
43
+ try {
44
+ rawBody = await readBoundedBody(request)
45
+ } catch (error) {
46
+ return send(response, error instanceof BodyTooLargeError ? 413 : 400)
47
+ }
48
+
49
+ const deliveryId = singleHeader(request.headers["x-tensorbuzz-delivery"])
50
+ const timestamp = singleHeader(request.headers["x-webhook-timestamp"])
51
+ const signature = singleHeader(request.headers["x-webhook-signature-v2"])
52
+ if (!deliveryId || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(deliveryId) ||
53
+ !timestamp || !/^\d{10}$/u.test(timestamp) ||
54
+ Math.abs(Math.floor(now() / 1000) - Number(timestamp)) > MAX_CLOCK_SKEW_SECONDS ||
55
+ !signatureMatches(options.secret, timestamp, rawBody, signature)) {
56
+ return send(response, 401)
57
+ }
58
+
59
+ let event
60
+ try {
61
+ event = JSON.parse(rawBody.toString("utf8"))
62
+ } catch {
63
+ return send(response, 400)
64
+ }
65
+ if (options.acceptEvent) await options.acceptEvent(event, deliveryId)
66
+ return send(response, 202)
67
+ }
68
+
69
+ /** @param {import("node:http").IncomingMessage} request */
70
+ function readBoundedBody(request) {
71
+ return new Promise((resolve, reject) => {
72
+ /** @type {Buffer[]} */
73
+ const chunks = []
74
+ let bytes = 0
75
+ let settled = false
76
+ /** @param {Buffer | string} chunk */
77
+ const onData = (chunk) => {
78
+ if (settled) return
79
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
80
+ bytes += buffer.length
81
+ if (bytes > MAX_BODY_BYTES) {
82
+ settled = true
83
+ request.removeListener("data", onData)
84
+ request.removeListener("end", onEnd)
85
+ request.removeListener("error", onError)
86
+ reject(new BodyTooLargeError())
87
+ request.resume()
88
+ } else {
89
+ chunks.push(buffer)
90
+ }
91
+ }
92
+ const onEnd = () => {
93
+ if (settled) return
94
+ settled = true
95
+ resolve(Buffer.concat(chunks, bytes))
96
+ }
97
+ /** @param {Error} error */
98
+ const onError = (error) => {
99
+ if (settled) return
100
+ settled = true
101
+ reject(error)
102
+ }
103
+ request.on("data", onData)
104
+ request.once("end", onEnd)
105
+ request.once("error", onError)
106
+ })
107
+ }
108
+
109
+ class BodyTooLargeError extends Error {}
110
+
111
+ /** @param {string} secret @param {string} timestamp @param {Buffer} body @param {string | null} provided */
112
+ function signatureMatches(secret, timestamp, body, provided) {
113
+ if (!provided || !/^[a-f0-9]{64}$/u.test(provided)) return false
114
+ const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(body).digest()
115
+ const actual = Buffer.from(provided, "hex")
116
+ return timingSafeEqual(expected, actual)
117
+ }
118
+
119
+ /** @param {string | string[] | undefined} value */
120
+ function singleHeader(value) {
121
+ return typeof value === "string" ? value : null
122
+ }
123
+
124
+ /** @param {import("node:http").IncomingMessage} request @param {import("node:http").ServerResponse} response @param {number} status */
125
+ function reject(request, response, status) {
126
+ request.resume()
127
+ send(response, status)
128
+ }
129
+
130
+ /** @param {import("node:http").ServerResponse} response @param {number} status @param {string} [body] */
131
+ function send(response, status, body = "") {
132
+ response.writeHead(status, {"cache-control": "no-store", "content-type": "text/plain; charset=utf-8"})
133
+ response.end(body)
134
+ }
@@ -0,0 +1,61 @@
1
+ // @ts-check
2
+
3
+ import bigInteger from "big-integer"
4
+ import {Api, TelegramClient, sessions} from "teleproto"
5
+
6
+ export class TeleprotoUserSender {
7
+ /** @type {TelegramClient} */
8
+ #client
9
+
10
+ /** @param {TelegramClient} client */
11
+ constructor(client) {
12
+ this.#client = client
13
+ }
14
+
15
+ /** @param {{apiId: number, apiHash: string, session: string}} config */
16
+ static async connect(config) {
17
+ const client = new TelegramClient(new sessions.StringSession(config.session), config.apiId, config.apiHash, {connectionRetries: 3})
18
+ await client.connect()
19
+ if (!await client.checkAuthorization()) {
20
+ await client.disconnect()
21
+ throw new Error("The dedicated MTProto automation user session is not authorized")
22
+ }
23
+ return new TeleprotoUserSender(client)
24
+ }
25
+
26
+ /** @param {{chatId: string, messageThreadId: number, text: string, randomId: string}} outbound */
27
+ async sendMessage(outbound) {
28
+ const peer = await this.#client.getInputEntity(outbound.chatId)
29
+ const updates = await this.#client.invoke(new Api.messages.SendMessage({
30
+ peer,
31
+ message: outbound.text,
32
+ randomId: bigInteger(outbound.randomId),
33
+ replyTo: new Api.InputReplyToMessage({
34
+ replyToMsgId: outbound.messageThreadId,
35
+ topMsgId: outbound.messageThreadId
36
+ })
37
+ }))
38
+ const messageId = extractMessageId(updates)
39
+ if (messageId === null) throw new Error("MTProto send response did not contain a Telegram message ID")
40
+ return {messageId}
41
+ }
42
+
43
+ async close() {
44
+ await this.#client.disconnect()
45
+ }
46
+ }
47
+
48
+ /** @param {unknown} response */
49
+ function extractMessageId(response) {
50
+ if (!response || typeof response !== "object") return null
51
+ const updates = /** @type {{updates?: unknown}} */ (response).updates
52
+ if (!Array.isArray(updates)) return null
53
+ for (const update of updates) {
54
+ if (!update || typeof update !== "object") continue
55
+ const message = /** @type {{message?: unknown}} */ (update).message
56
+ if (!message || typeof message !== "object") continue
57
+ const id = /** @type {{id?: unknown}} */ (message).id
58
+ if (Number.isSafeInteger(id) && Number(id) > 0) return Number(id)
59
+ }
60
+ return null
61
+ }
@@ -0,0 +1,72 @@
1
+ // @ts-check
2
+
3
+ import {createServer} from "node:http"
4
+ import {TensorBuzzQueueBridge} from "./bridge.js"
5
+ import {createTensorBuzzQueueBridgeHandler} from "./http.js"
6
+
7
+ export class TensorBuzzQueueBridgeService {
8
+ #bridge
9
+ #server
10
+ #draining = false
11
+ #ready = false
12
+ /** @type {NodeJS.Timeout | null} */
13
+ #retryTimer = null
14
+
15
+ /** @param {{host: string, port: number, secret: string, databasePath: string, routes: Readonly<Record<string, {chatId: string, messageThreadId: number}>>, sender: import("./bridge.js").MtProtoSender}} options */
16
+ constructor(options) {
17
+ this.#bridge = new TensorBuzzQueueBridge(options)
18
+ this.#server = createServer(createTensorBuzzQueueBridgeHandler({
19
+ secret: options.secret,
20
+ sender: options.sender,
21
+ isReady: () => this.#ready,
22
+ acceptEvent: (event, deliveryId) => {
23
+ this.#bridge.acceptEvent(event, deliveryId)
24
+ this.#scheduleDrain(0)
25
+ }
26
+ }))
27
+ }
28
+
29
+ /** @param {string} host @param {number} port */
30
+ async listen(host, port) {
31
+ if (host !== "127.0.0.1") throw new Error("TensorBuzz queue bridge must bind IPv4 loopback")
32
+ await new Promise((resolve, reject) => {
33
+ this.#server.once("error", reject)
34
+ this.#server.listen(port, host, () => {
35
+ this.#server.removeListener("error", reject)
36
+ resolve(undefined)
37
+ })
38
+ })
39
+ this.#ready = true
40
+ this.#scheduleDrain(0)
41
+ }
42
+
43
+ async close() {
44
+ this.#ready = false
45
+ if (this.#retryTimer) clearTimeout(this.#retryTimer)
46
+ await new Promise((resolve, reject) => this.#server.close((error) => error ? reject(error) : resolve(undefined)))
47
+ this.#bridge.close()
48
+ }
49
+
50
+ /** @param {number} delayMs */
51
+ #scheduleDrain(delayMs) {
52
+ if (this.#draining || this.#retryTimer) return
53
+ this.#retryTimer = setTimeout(() => {
54
+ this.#retryTimer = null
55
+ void this.#drain()
56
+ }, delayMs)
57
+ }
58
+
59
+ async #drain() {
60
+ if (this.#draining) return
61
+ this.#draining = true
62
+ try {
63
+ let drained
64
+ do {
65
+ drained = await this.#bridge.drainOnce()
66
+ } while (drained)
67
+ } finally {
68
+ this.#draining = false
69
+ if (this.#ready) this.#scheduleDrain(1_000)
70
+ }
71
+ }
72
+ }