hivecode-mcp 0.12.2 → 0.13.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.
Files changed (62) hide show
  1. package/HIVE_RULES.md +11 -40
  2. package/LICENSE +21 -21
  3. package/README.md +421 -167
  4. package/core.js +91 -91
  5. package/docs/ALWAYS_ON.md +310 -0
  6. package/docs/GITHUB_WORKFLOW.md +334 -0
  7. package/docs/HIVE_RULES.md +41 -41
  8. package/docs/MCP.md +157 -118
  9. package/docs/QUICKSTART.md +266 -0
  10. package/docs/SELF_HOSTING.md +256 -256
  11. package/docs/SLACK_SETUP.md +450 -0
  12. package/docs/TROUBLESHOOTING.md +87 -0
  13. package/hive-activity.js +336 -310
  14. package/hive-await.js +207 -0
  15. package/hive-branch.js +686 -0
  16. package/hive-busy.js +88 -88
  17. package/hive-coord.js +110 -110
  18. package/hive-gate.js +135 -135
  19. package/hive-github.js +1904 -0
  20. package/hive-hook-install.js +179 -103
  21. package/hive-instructions.js +74 -66
  22. package/hive-listen.js +81 -0
  23. package/hive-mailbox.js +520 -386
  24. package/hive-mcp-http.js +580 -0
  25. package/hive-mcp.js +579 -137
  26. package/hive-mux-proto.js +58 -58
  27. package/hive-mux.js +350 -350
  28. package/hive-notify.js +233 -0
  29. package/hive-obligations.js +247 -247
  30. package/hive-pr-staleness.js +189 -189
  31. package/hive-pr.js +350 -0
  32. package/hive-privacy.js +327 -288
  33. package/hive-receipts.js +233 -233
  34. package/hive-relay-health.js +230 -225
  35. package/hive-room-key.js +209 -0
  36. package/hive-secrets.js +89 -0
  37. package/hive-session.js +114 -0
  38. package/hive-setup.js +558 -445
  39. package/hive-slack-agent.js +396 -0
  40. package/hive-slack.js +1809 -0
  41. package/hive-stdio.js +172 -168
  42. package/hive-stop-hook.js +293 -241
  43. package/hive-throttle.js +118 -118
  44. package/hive-time.js +71 -0
  45. package/hive-update.js +58 -58
  46. package/hive-version.js +110 -59
  47. package/hive-webhook.js +54 -0
  48. package/icr-merge.js +58 -58
  49. package/icr.js +851 -745
  50. package/lang-json.js +211 -211
  51. package/lang-ruby.js +230 -230
  52. package/lang-toml.js +136 -136
  53. package/lang-yaml.js +183 -183
  54. package/limits.js +413 -394
  55. package/package.json +189 -164
  56. package/reconcile.cjs +614 -477
  57. package/slack-app-manifest-agent.json +61 -0
  58. package/slack-app-manifest-inbound.json +36 -0
  59. package/slack-app-manifest.json +27 -0
  60. package/substrate.js +252 -252
  61. package/sync.js +1956 -177
  62. package/grm-link.cjs +0 -141
package/hive-privacy.js CHANGED
@@ -1,288 +1,327 @@
1
- // WHICH RELAY IS THIS, AND DID ANYBODY CHOOSE IT?
2
- //
3
- // Every byte of a team's source leaves their machines through one URL: the relay. The
4
- // client has always printed that URL and stopped there -- a bare `relay: wss://...` line
5
- // that says nothing about what it MEANS. That is the whole defect this file exists for,
6
- // and it is the product's highest-stakes false success:
7
- //
8
- // An agent starts with no link and no .hive.json. resolveRoom() falls through to the
9
- // shipped default -- a relay the team does not run. The agent joins. hive_status says
10
- // connected. filesSynced climbs. Every signal is green, and the company's source code
11
- // is now on a stranger's server. Nothing crashed, so nothing reports it.
12
- //
13
- // Two separate things are needed and they are NOT the same thing:
14
- //
15
- // 1. A VERDICT on every join, so "am I private?" is answered without being asked.
16
- // Printing the URL is not a verdict. A person who has never run a relay cannot tell
17
- // wss://livecode-xoss.onrender.com from wss://relay.theircompany.com by looking.
18
- //
19
- // 2. A PIN that REFUSES. A verdict you have to read is a verdict that gets skimmed, and
20
- // an agent does not read banners at all. HIVE_RELAY_PIN says "this machine may only
21
- // ever talk to these relays" -- set once in the MCP config, and no stale link, no
22
- // typo'd host and no forgotten env var can send a file anywhere else. The refusal
23
- // happens BEFORE the socket opens, so a refused join publishes nothing.
24
- //
25
- // Comparison is on ORIGIN, never on the raw string. wss://r.co, wss://r.co/ and
26
- // wss://R.co:443 are one relay; a string compare would refuse a correct configuration,
27
- // and a guard that fires on the healthy path is a guard people disable.
28
-
29
- // The relay this build falls back to when nobody chose one -- THE ONE PLACE IT IS WRITTEN.
30
- //
31
- // It used to be written four times: here, hive-mcp.js, hive-agent.js and hive-say.js, each a
32
- // literal. On 23 Sep 2026 the hosted relay's Render account was suspended and a new one stood
33
- // up at a new address, and moving meant hunting down every copy -- any one missed would have
34
- // sent that client to a dead host, or made this module call the live default "YOUR RELAY".
35
- // Every client now imports it from here. The extension's JSON setting cannot import, so
36
- // extension-privacy-parity-test pins it equal to this value instead.
37
- export const PUBLIC_DEFAULT_RELAY = 'wss://hivecode-relay.onrender.com'
38
-
39
- // Relays this project has run publicly and retired. A .hive.json written before a move still
40
- // names the old one, and if that host ever answers again (a reactivated account, a squatter
41
- // on the name) the verdict must still say PUBLIC -- not "YOUR RELAY", which is what any host
42
- // outside this list is called.
43
- export const RETIRED_PUBLIC_RELAYS = ['wss://livecode-xoss.onrender.com']
44
-
45
- /**
46
- * The relay a client should actually dial for a URL it was handed.
47
- *
48
- * A RETIRED PUBLIC RELAY IS FOLLOWED TO ITS REPLACEMENT, and nothing else is ever rewritten.
49
- * Measured 23 Sep 2026 on 0.12.1 -- a build that already shipped the new default: a project
50
- * folder whose .hive.json was written before the move still named the retired host, the client
51
- * obeyed it, and a returning user got "NOT JOINED ... 503" exactly as if nothing had been fixed.
52
- * The same held for HIVE_RELAY set to the old host, which docs/MCP.md told people to do.
53
- *
54
- * That address was never the user's choice -- it was this project's default, copied into their
55
- * config by the client and by our own docs -- so following it to the current default honours
56
- * what they actually chose (the public relay), and moves nobody from anyone to anyone else.
57
- * A relay the user runs is never in RETIRED_PUBLIC_RELAYS and is returned untouched.
58
- *
59
- * Returns { relay, migratedFrom } -- migratedFrom is the retired URL when a move happened,
60
- * so the caller can say so on screen instead of changing someone's destination silently.
61
- */
62
- export function currentRelayFor(url) {
63
- const origin = relayOrigin(url)
64
- if (origin && RETIRED_PUBLIC_RELAYS.some((r) => relayOrigin(r) === origin)) {
65
- return { relay: PUBLIC_DEFAULT_RELAY, migratedFrom: String(url).trim() }
66
- }
67
- return { relay: url, migratedFrom: null }
68
- }
69
-
70
- const DEFAULT_PORT = { 'ws:': '80', 'wss:': '443', 'http:': '80', 'https:': '443' }
71
-
72
- /**
73
- * Normalise a relay URL to a comparable origin: scheme://host:port, lowercased, with the
74
- * scheme's default port made explicit. Returns null for anything that is not a URL --
75
- * callers must treat null as "unknown", never as "fine".
76
- */
77
- export function relayOrigin(url) {
78
- const s = String(url || '').trim()
79
- if (!s) return null
80
- let u
81
- try { u = new URL(s) } catch { return null }
82
- const proto = u.protocol.toLowerCase()
83
- if (!DEFAULT_PORT[proto]) return null
84
- const port = u.port || DEFAULT_PORT[proto]
85
- // ws:// and wss:// to the same host:port are the SAME relay reached two ways, and a pin
86
- // written as https:// (which is what a person copies out of a browser) must match the
87
- // wss:// the client actually dials. Scheme is reported separately by classifyRelay so
88
- // the plaintext warning still fires; it deliberately does not participate in identity.
89
- const secureScheme = proto === 'wss:' || proto === 'https:'
90
- return `${secureScheme ? 'wss' : 'ws'}://${u.hostname.toLowerCase()}:${port}`
91
- }
92
-
93
- const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '[::1]'])
94
-
95
- function isLoopback(host) {
96
- const h = String(host || '').toLowerCase().replace(/^\[|\]$/g, '')
97
- if (LOOPBACK.has(h)) return true
98
- return /^127\./.test(h)
99
- }
100
-
101
- function isPrivateLan(host) {
102
- const h = String(host || '').toLowerCase()
103
- if (/^10\./.test(h)) return true
104
- if (/^192\.168\./.test(h)) return true
105
- if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true
106
- if (/\.local$/.test(h) || /\.lan$/.test(h) || /\.internal$/.test(h)) return true
107
- // A PRIVATE MESH (Tailscale and friends) IS A PRIVATE NETWORK, and the first version of this
108
- // file called it "the public internet in plaintext". 100.64.0.0/10 is the shared address space
109
- // Tailscale hands out and *.ts.net is its MagicDNS; the transport underneath is WireGuard,
110
- // end-to-end encrypted between the two devices, and nothing outside the tailnet can even
111
- // route to the relay. Warning a remote team that chose the most private setup available
112
- // that their source was travelling in the clear would have taught them to ignore the verdict.
113
- if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(h)) return true
114
- if (/\.ts\.net$/.test(h)) return true
115
- return false
116
- }
117
-
118
- // PUBLIC TUNNELS decrypt at their own edge. A relay on a laptop behind cloudflared or ngrok is
119
- // the team's relay -- nobody else runs it -- but the tunnel provider terminates TLS and can read
120
- // every frame, and anyone holding the URL can reach the port. Both facts matter to a team that
121
- // picked a tunnel *for privacy*, and neither is visible in a URL that starts with wss://.
122
- const PUBLIC_TUNNEL = /\.(trycloudflare\.com|ngrok-free\.app|ngrok\.app|ngrok\.io|ngrok-free\.dev|loca\.lt|serveo\.net|localhost\.run)$/
123
-
124
- function isPublicTunnel(host) {
125
- return PUBLIC_TUNNEL.test(String(host || '').toLowerCase())
126
- }
127
-
128
- /**
129
- * What kind of relay is this, and does traffic to it leave the machine in the clear?
130
- *
131
- * kind: 'public-default' | 'loopback' | 'private-lan' | 'tunnel' | 'own' | 'unknown'
132
- *
133
- * 'own' does not mean audited -- it means "not the relay we ship". A team that pastes a
134
- * link from a blog post lands in 'own' too, which is exactly why the pin exists.
135
- */
136
- export function classifyRelay(url) {
137
- const origin = relayOrigin(url)
138
- if (!origin) return { kind: 'unknown', origin: null, host: null, encrypted: false, url: String(url || '') }
139
- const u = new URL(String(url).trim())
140
- const host = u.hostname.toLowerCase()
141
- const encrypted = u.protocol === 'wss:' || u.protocol === 'https:'
142
- let kind
143
- if (origin === relayOrigin(PUBLIC_DEFAULT_RELAY) ||
144
- RETIRED_PUBLIC_RELAYS.some((r) => relayOrigin(r) === origin)) kind = 'public-default'
145
- else if (isLoopback(host)) kind = 'loopback'
146
- else if (isPrivateLan(host)) kind = 'private-lan'
147
- else if (isPublicTunnel(host)) kind = 'tunnel'
148
- else kind = 'own'
149
- return { kind, origin, host, encrypted, url: String(url).trim() }
150
- }
151
-
152
- /**
153
- * The pin, from the environment or from <dir>/.hive.json's `pinnedRelay`. Comma-separated,
154
- * because a team legitimately has a staging relay and a production one.
155
- *
156
- * A pin that cannot be parsed is an ERROR, never an empty pin. That distinction is the
157
- * whole value of the feature: a typo silently collapsing to "no pin" leaves somebody
158
- * believing they are locked to their own relay while the client dials the public one --
159
- * the same absence-rendered-as-a-value shape the room has been bitten by before.
160
- */
161
- export function readPin({ env = process.env, dir = null, fs = null } = {}) {
162
- let raw = String((env && env.HIVE_RELAY_PIN) || '').trim()
163
- let from = 'HIVE_RELAY_PIN'
164
- if (!raw && dir && fs) {
165
- try {
166
- const cfg = JSON.parse(fs.readFileSync(`${dir}/.hive.json`, 'utf8'))
167
- if (cfg && cfg.pinnedRelay) { raw = String(cfg.pinnedRelay).trim(); from = '.hive.json pinnedRelay' }
168
- } catch { /* no config, or not JSON -- that is "no pin", not "bad pin" */ }
169
- }
170
- if (!raw) return { pinned: false, origins: [], from: null, raw: '' }
171
- const parts = raw.split(',').map((s) => s.trim()).filter(Boolean)
172
- const origins = []
173
- const bad = []
174
- for (const p of parts) {
175
- const o = relayOrigin(p)
176
- if (o) origins.push(o); else bad.push(p)
177
- }
178
- if (bad.length || !origins.length) {
179
- return { pinned: true, origins, from, raw, error: `not a relay URL: ${bad.join(', ') || raw}` }
180
- }
181
- return { pinned: true, origins, from, raw }
182
- }
183
-
184
- const HEADLINE = {
185
- 'public-default': 'PUBLIC RELAY — not yours',
186
- loopback: 'PRIVATE — relay on this machine',
187
- 'private-lan': 'PRIVATE — relay on your network',
188
- tunnel: 'YOUR RELAY — through a public tunnel',
189
- own: 'YOUR RELAY',
190
- unknown: 'UNKNOWN RELAY',
191
- }
192
-
193
- /**
194
- * The one call a client makes before opening a socket.
195
- *
196
- * Returns { ok, code, headline, detail, classification, pin }. `ok:false` means DO NOT
197
- * CONNECT -- the caller must not open the socket and must not publish anything, because
198
- * publishing is the leak this prevents. A warning (ok:true with `warn`) is printed and
199
- * proceeds: a team on a plaintext LAN relay has made a choice, and refusing it would be
200
- * this guard deciding policy it was not given.
201
- *
202
- * Q550 the relay is not one this machine is pinned to
203
- * Q551 HIVE_PRIVATE is set and this is the public relay we ship
204
- * Q552 the pin itself is malformed, so nothing is actually pinned
205
- */
206
- export function checkRelay({ relay, env = process.env, dir = null, fs = null } = {}) {
207
- const c = classifyRelay(relay)
208
- const pin = readPin({ env, dir, fs })
209
- const strict = String((env && env.HIVE_PRIVATE) || '') === '1'
210
-
211
- if (pin.pinned && pin.error) {
212
- return {
213
- ok: false, code: 'Q552', classification: c, pin,
214
- headline: 'Q552 THE RELAY PIN IS MALFORMED — REFUSING TO CONNECT',
215
- detail: [
216
- `${pin.from} = ${pin.raw}`,
217
- `problem: ${pin.error}`,
218
- '',
219
- 'A pin that cannot be parsed is refused rather than ignored. Ignoring it would leave',
220
- 'you believing this machine is locked to your relay while it dials whatever it likes.',
221
- 'Write it as a full URL, e.g. HIVE_RELAY_PIN=wss://relay.yourcompany.com',
222
- ].join('\n'),
223
- }
224
- }
225
-
226
- if (pin.pinned && c.origin && !pin.origins.includes(c.origin)) {
227
- return {
228
- ok: false, code: 'Q550', classification: c, pin,
229
- headline: 'Q550 THIS RELAY IS NOT THE ONE THIS MACHINE IS PINNED TO — REFUSING TO CONNECT',
230
- detail: [
231
- `asked to connect to : ${c.url} (${c.origin})`,
232
- `pinned to : ${pin.origins.join(', ')} (via ${pin.from})`,
233
- '',
234
- 'Nothing was opened and nothing was published, so no file has left this machine.',
235
- c.kind === 'public-default'
236
- ? 'The relay asked for is the PUBLIC one this build ships as a fallback — which is what\nyou get when a link is missing, a .hive.json was not found, or HIVE_RELAY is unset.'
237
- : 'Check the join link you were given, and HIVE_RELAY in this process\'s environment.',
238
- ].join('\n'),
239
- }
240
- }
241
-
242
- if (!pin.pinned && strict && c.kind === 'public-default') {
243
- return {
244
- ok: false, code: 'Q551', classification: c, pin,
245
- headline: 'Q551 HIVE_PRIVATE=1 AND THIS IS THE PUBLIC RELAY — REFUSING TO CONNECT',
246
- detail: [
247
- `relay: ${c.url}`,
248
- '',
249
- 'This is the relay this build falls back to when nobody chose one. Under HIVE_PRIVATE',
250
- 'that fallback is an error rather than a default, because it is reached by omission —',
251
- 'a missing link or an unset HIVE_RELAY — and never by anybody deciding on it.',
252
- '',
253
- 'Point HIVE_RELAY at your own relay, or unset HIVE_PRIVATE if you meant to use ours.',
254
- ].join('\n'),
255
- }
256
- }
257
-
258
- // Connecting. Say what that means, in one line, every time.
259
- let warn = null
260
- if (c.kind === 'public-default') {
261
- warn = 'Your files are being sent to a relay run by the Hivecode author, not by you. ' +
262
- 'Anyone who learns this room id can read them. For private work run your own relay ' +
263
- '(npx hivecode-relay) and set HIVE_RELAY.'
264
- } else if (c.kind === 'tunnel') {
265
- warn = `${c.host} is a public tunnel. The relay is yours, but the tunnel provider decrypts ` +
266
- 'traffic at its edge and can read it, and anyone with this URL can reach the relay. Fine for ' +
267
- 'a trial with --secret set; for private remote work use a private network (e.g. Tailscale) ' +
268
- 'or a server you control.'
269
- } else if (c.kind === 'own' && !c.encrypted) {
270
- warn = `ws:// to ${c.host} is NOT encrypted — your source travels the public internet in ` +
271
- 'plaintext. Put TLS in front of the relay and use wss://.'
272
- } else if (c.kind === 'unknown') {
273
- warn = `"${c.url}" does not parse as a relay URL — the client will almost certainly fail ` +
274
- 'to connect, and if it does connect you cannot tell where to.'
275
- }
276
-
277
- return {
278
- ok: true, code: null, classification: c, pin, warn,
279
- headline: `${HEADLINE[c.kind]}${c.encrypted ? '' : c.kind === 'loopback' ? '' : ' (UNENCRYPTED)'}`,
280
- detail: warn || '',
281
- }
282
- }
283
-
284
- /** One line for a join banner / status block: the URL plus what it means. */
285
- export function privacyLine(relay, opts = {}) {
286
- const v = checkRelay({ relay, ...opts })
287
- return `relay: ${relay} [${v.headline}]`
288
- }
1
+ // WHICH RELAY IS THIS, AND DID ANYBODY CHOOSE IT?
2
+ //
3
+ // Every byte of a team's source leaves their machines through one URL: the relay. The
4
+ // client has always printed that URL and stopped there -- a bare `relay: wss://...` line
5
+ // that says nothing about what it MEANS. That is the whole defect this file exists for,
6
+ // and it is the product's highest-stakes false success:
7
+ //
8
+ // An agent starts with no link and no .hive.json. resolveRoom() falls through to the
9
+ // shipped default -- a relay the team does not run. The agent joins. hive_status says
10
+ // connected. filesSynced climbs. Every signal is green, and the company's source code
11
+ // is now on a stranger's server. Nothing crashed, so nothing reports it.
12
+ //
13
+ // Two separate things are needed and they are NOT the same thing:
14
+ //
15
+ // 1. A VERDICT on every join, so "am I private?" is answered without being asked.
16
+ // Printing the URL is not a verdict. A person who has never run a relay cannot tell
17
+ // wss://livecode-xoss.onrender.com from wss://relay.theircompany.com by looking.
18
+ //
19
+ // 2. A PIN that REFUSES. A verdict you have to read is a verdict that gets skimmed, and
20
+ // an agent does not read banners at all. HIVE_RELAY_PIN says "this machine may only
21
+ // ever talk to these relays" -- set once in the MCP config, and no stale link, no
22
+ // typo'd host and no forgotten env var can send a file anywhere else. The refusal
23
+ // happens BEFORE the socket opens, so a refused join publishes nothing.
24
+ //
25
+ // Comparison is on ORIGIN, never on the raw string. wss://r.co, wss://r.co/ and
26
+ // wss://R.co:443 are one relay; a string compare would refuse a correct configuration,
27
+ // and a guard that fires on the healthy path is a guard people disable.
28
+
29
+ // The relay this build falls back to when nobody chose one -- THE ONE PLACE IT IS WRITTEN.
30
+ //
31
+ // It used to be written four times: here, hive-mcp.js, hive-agent.js and hive-say.js, each a
32
+ // literal. On 23 Sep 2026 the hosted relay's Render account was suspended and a new one stood
33
+ // up at a new address, and moving meant hunting down every copy -- any one missed would have
34
+ // sent that client to a dead host, or made this module call the live default "YOUR RELAY".
35
+ // Every client now imports it from here. The extension's JSON setting cannot import, so
36
+ // extension-privacy-parity-test pins it equal to this value instead.
37
+ export const PUBLIC_DEFAULT_RELAY = 'wss://hivecode-relay.onrender.com'
38
+
39
+ // Relays this project has run publicly and retired. A .hive.json written before a move still
40
+ // names the old one, and if that host ever answers again (a reactivated account, a squatter
41
+ // on the name) the verdict must still say PUBLIC -- not "YOUR RELAY", which is what any host
42
+ // outside this list is called.
43
+ export const RETIRED_PUBLIC_RELAYS = ['wss://livecode-xoss.onrender.com']
44
+
45
+ /**
46
+ * The relay a client should actually dial for a URL it was handed.
47
+ *
48
+ * A RETIRED PUBLIC RELAY IS FOLLOWED TO ITS REPLACEMENT, and nothing else is ever rewritten.
49
+ * Measured 23 Sep 2026 on 0.12.1 -- a build that already shipped the new default: a project
50
+ * folder whose .hive.json was written before the move still named the retired host, the client
51
+ * obeyed it, and a returning user got "NOT JOINED ... 503" exactly as if nothing had been fixed.
52
+ * The same held for HIVE_RELAY set to the old host, which docs/MCP.md told people to do.
53
+ *
54
+ * That address was never the user's choice -- it was this project's default, copied into their
55
+ * config by the client and by our own docs -- so following it to the current default honours
56
+ * what they actually chose (the public relay), and moves nobody from anyone to anyone else.
57
+ * A relay the user runs is never in RETIRED_PUBLIC_RELAYS and is returned untouched.
58
+ *
59
+ * Returns { relay, migratedFrom } -- migratedFrom is the retired URL when a move happened,
60
+ * so the caller can say so on screen instead of changing someone's destination silently.
61
+ */
62
+ export function currentRelayFor(url) {
63
+ const origin = relayOrigin(url)
64
+ if (origin && RETIRED_PUBLIC_RELAYS.some((r) => relayOrigin(r) === origin)) {
65
+ return { relay: PUBLIC_DEFAULT_RELAY, migratedFrom: String(url).trim() }
66
+ }
67
+ return { relay: url, migratedFrom: null }
68
+ }
69
+
70
+ const DEFAULT_PORT = { 'ws:': '80', 'wss:': '443', 'http:': '80', 'https:': '443' }
71
+
72
+ /**
73
+ * Normalise a relay URL to a comparable origin: scheme://host:port, lowercased, with the
74
+ * scheme's default port made explicit. Returns null for anything that is not a URL --
75
+ * callers must treat null as "unknown", never as "fine".
76
+ */
77
+ export function relayOrigin(url) {
78
+ const s = String(url || '').trim()
79
+ if (!s) return null
80
+ let u
81
+ try { u = new URL(s) } catch { return null }
82
+ const proto = u.protocol.toLowerCase()
83
+ if (!DEFAULT_PORT[proto]) return null
84
+ const port = u.port || DEFAULT_PORT[proto]
85
+ // ws:// and wss:// to the same host:port are the SAME relay reached two ways, and a pin
86
+ // written as https:// (which is what a person copies out of a browser) must match the
87
+ // wss:// the client actually dials. Scheme is reported separately by classifyRelay so
88
+ // the plaintext warning still fires; it deliberately does not participate in identity.
89
+ const secureScheme = proto === 'wss:' || proto === 'https:'
90
+ return `${secureScheme ? 'wss' : 'ws'}://${u.hostname.toLowerCase()}:${port}`
91
+ }
92
+
93
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '[::1]'])
94
+
95
+ function isLoopback(host) {
96
+ const h = String(host || '').toLowerCase().replace(/^\[|\]$/g, '')
97
+ if (LOOPBACK.has(h)) return true
98
+ return /^127\./.test(h)
99
+ }
100
+
101
+ function isPrivateLan(host) {
102
+ const h = String(host || '').toLowerCase()
103
+ if (/^10\./.test(h)) return true
104
+ if (/^192\.168\./.test(h)) return true
105
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true
106
+ if (/\.local$/.test(h) || /\.lan$/.test(h) || /\.internal$/.test(h)) return true
107
+ // A PRIVATE MESH (Tailscale and friends) IS A PRIVATE NETWORK, and the first version of this
108
+ // file called it "the public internet in plaintext". 100.64.0.0/10 is the shared address space
109
+ // Tailscale hands out and *.ts.net is its MagicDNS; the transport underneath is WireGuard,
110
+ // end-to-end encrypted between the two devices, and nothing outside the tailnet can even
111
+ // route to the relay. Warning a remote team that chose the most private setup available
112
+ // that their source was travelling in the clear would have taught them to ignore the verdict.
113
+ if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(h)) return true
114
+ if (/\.ts\.net$/.test(h)) return true
115
+ return false
116
+ }
117
+
118
+ // PUBLIC TUNNELS decrypt at their own edge. A relay on a laptop behind cloudflared or ngrok is
119
+ // the team's relay -- nobody else runs it -- but the tunnel provider terminates TLS and can read
120
+ // every frame, and anyone holding the URL can reach the port. Both facts matter to a team that
121
+ // picked a tunnel *for privacy*, and neither is visible in a URL that starts with wss://.
122
+ const PUBLIC_TUNNEL = /\.(trycloudflare\.com|ngrok-free\.app|ngrok\.app|ngrok\.io|ngrok-free\.dev|loca\.lt|serveo\.net|localhost\.run)$/
123
+
124
+ function isPublicTunnel(host) {
125
+ return PUBLIC_TUNNEL.test(String(host || '').toLowerCase())
126
+ }
127
+
128
+ /**
129
+ * What kind of relay is this, and does traffic to it leave the machine in the clear?
130
+ *
131
+ * kind: 'public-default' | 'loopback' | 'private-lan' | 'tunnel' | 'own' | 'unknown'
132
+ *
133
+ * 'own' does not mean audited -- it means "not the relay we ship". A team that pastes a
134
+ * link from a blog post lands in 'own' too, which is exactly why the pin exists.
135
+ */
136
+ // Where a PERSON opens a room: the relay's own Control Room, with the room's link in the hash.
137
+ // The host comes from the link, never from a default -- a self-hosted room printed with the
138
+ // public panel's address would send them to the wrong relay -- and a URL hash is never sent to
139
+ // any server, so a token in the link stays in the browser. null when there is no room to open.
140
+ export function controlUrl(link) {
141
+ const s = String(link || '').trim()
142
+ const [relay = '', room = ''] = s.split('|')
143
+ if (!/^wss?:\/\//i.test(relay.trim()) || !room.trim()) return null
144
+ return `${relay.trim().replace(/^ws/i, 'http').replace(/\/+$/, '')}/control#${encodeURIComponent(s)}`
145
+ }
146
+
147
+ export function classifyRelay(url) {
148
+ const origin = relayOrigin(url)
149
+ if (!origin) return { kind: 'unknown', origin: null, host: null, encrypted: false, url: String(url || '') }
150
+ const u = new URL(String(url).trim())
151
+ const host = u.hostname.toLowerCase()
152
+ const encrypted = u.protocol === 'wss:' || u.protocol === 'https:'
153
+ let kind
154
+ if (origin === relayOrigin(PUBLIC_DEFAULT_RELAY) ||
155
+ RETIRED_PUBLIC_RELAYS.some((r) => relayOrigin(r) === origin)) kind = 'public-default'
156
+ else if (isLoopback(host)) kind = 'loopback'
157
+ else if (isPrivateLan(host)) kind = 'private-lan'
158
+ else if (isPublicTunnel(host)) kind = 'tunnel'
159
+ else kind = 'own'
160
+ return { kind, origin, host, encrypted, url: String(url).trim() }
161
+ }
162
+
163
+ /**
164
+ * The pin, from the environment or from <dir>/.hive.json's `pinnedRelay`. Comma-separated,
165
+ * because a team legitimately has a staging relay and a production one.
166
+ *
167
+ * A pin that cannot be parsed is an ERROR, never an empty pin. That distinction is the
168
+ * whole value of the feature: a typo silently collapsing to "no pin" leaves somebody
169
+ * believing they are locked to their own relay while the client dials the public one --
170
+ * the same absence-rendered-as-a-value shape the room has been bitten by before.
171
+ */
172
+ export function readPin({ env = process.env, dir = null, fs = null } = {}) {
173
+ let raw = String((env && env.HIVE_RELAY_PIN) || '').trim()
174
+ let from = 'HIVE_RELAY_PIN'
175
+ if (!raw && dir && fs) {
176
+ try {
177
+ const cfg = JSON.parse(fs.readFileSync(`${dir}/.hive.json`, 'utf8'))
178
+ if (cfg && cfg.pinnedRelay) { raw = String(cfg.pinnedRelay).trim(); from = '.hive.json pinnedRelay' }
179
+ } catch { /* no config, or not JSON -- that is "no pin", not "bad pin" */ }
180
+ }
181
+ if (!raw) return { pinned: false, origins: [], from: null, raw: '' }
182
+ const parts = raw.split(',').map((s) => s.trim()).filter(Boolean)
183
+ const origins = []
184
+ const bad = []
185
+ for (const p of parts) {
186
+ const o = relayOrigin(p)
187
+ if (o) origins.push(o); else bad.push(p)
188
+ }
189
+ if (bad.length || !origins.length) {
190
+ return { pinned: true, origins, from, raw, error: `not a relay URL: ${bad.join(', ') || raw}` }
191
+ }
192
+ return { pinned: true, origins, from, raw }
193
+ }
194
+
195
+ const HEADLINE = {
196
+ 'public-default': 'PUBLIC RELAY — not yours',
197
+ loopback: 'PRIVATE — relay on this machine',
198
+ 'private-lan': 'PRIVATE — relay on your network',
199
+ tunnel: 'YOUR RELAY — through a public tunnel',
200
+ own: 'YOUR RELAY',
201
+ unknown: 'UNKNOWN RELAY',
202
+ }
203
+
204
+ /**
205
+ * A ROOM'S NAME IS ITS ONLY KEY ON A RELAY ANYONE CAN REACH (3 Oct 2026). Joining needs nothing but
206
+ * the name, and a relay creates whatever room it is asked for. A hosted room's id is 104 random bits;
207
+ * a name somebody typed -- "night", "acme-backend" -- can be guessed, and whoever guesses it reads
208
+ * every file and writes into every member's folder.
209
+ *
210
+ * A string cannot prove it is random, but the generator can be recognised: hosted rooms and branch
211
+ * rooms are always `room-` + exactly 18 random base64url characters, which switch between lower
212
+ * case, upper case and digits at least twice in all but 3 of 100,000 (measured). That form is
213
+ * trusted. Any other name was typed by someone, and passes only with a long run that keeps
214
+ * switching the way generated strings do. Measured: a switch threshold low enough to pass every
215
+ * random id also passed camel-case words ("teamAlphaBravo2026"), and one high enough to stop the
216
+ * words flagged 367 in 100,000 random hosted ids -- hence trusting the form, not the threshold.
217
+ */
218
+ const charClass = (ch) => (/[a-z]/.test(ch) ? 'l' : /[A-Z]/.test(ch) ? 'u' : /[0-9]/.test(ch) ? 'd' : '')
219
+ function classSwitches(run) { let n = 0, prev = ''; for (const ch of run) { const c = charClass(ch); if (!c) continue; if (prev && c !== prev) n++; prev = c } return n }
220
+ export function guessableRoom(room) {
221
+ const id = String(room || '')
222
+ if (/^hs_/.test(id)) return false // a secured room: joining it takes a token signed by its owner
223
+ if (/^room-[A-Za-z0-9_-]{18}$/.test(id) && new Set(id.slice(5)).size >= 8 && classSwitches(id.slice(5)) >= 2) return false // the hosted form
224
+ const runs = id.match(/[A-Za-z0-9_-]{16,}/g) || []
225
+ return !runs.some((r) => new Set(r).size >= 10 && classSwitches(r) >= 8)
226
+ }
227
+
228
+ /**
229
+ * The one call a client makes before opening a socket.
230
+ *
231
+ * Returns { ok, code, headline, detail, classification, pin }. `ok:false` means DO NOT
232
+ * CONNECT -- the caller must not open the socket and must not publish anything, because
233
+ * publishing is the leak this prevents. A warning (ok:true with `warn`) is printed and
234
+ * proceeds: a team on a plaintext LAN relay has made a choice, and refusing it would be
235
+ * this guard deciding policy it was not given.
236
+ *
237
+ * Q550 the relay is not one this machine is pinned to
238
+ * Q551 HIVE_PRIVATE is set and this is the public relay we ship
239
+ * Q552 the pin itself is malformed, so nothing is actually pinned
240
+ */
241
+ export function checkRelay({ relay, env = process.env, dir = null, fs = null, room = null } = {}) {
242
+ const c = classifyRelay(relay)
243
+ const pin = readPin({ env, dir, fs })
244
+ const strict = String((env && env.HIVE_PRIVATE) || '') === '1'
245
+
246
+ if (pin.pinned && pin.error) {
247
+ return {
248
+ ok: false, code: 'Q552', classification: c, pin,
249
+ headline: 'Q552 THE RELAY PIN IS MALFORMED — REFUSING TO CONNECT',
250
+ detail: [
251
+ `${pin.from} = ${pin.raw}`,
252
+ `problem: ${pin.error}`,
253
+ '',
254
+ 'A pin that cannot be parsed is refused rather than ignored. Ignoring it would leave',
255
+ 'you believing this machine is locked to your relay while it dials whatever it likes.',
256
+ 'Write it as a full URL, e.g. HIVE_RELAY_PIN=wss://relay.yourcompany.com',
257
+ ].join('\n'),
258
+ }
259
+ }
260
+
261
+ if (pin.pinned && c.origin && !pin.origins.includes(c.origin)) {
262
+ return {
263
+ ok: false, code: 'Q550', classification: c, pin,
264
+ headline: 'Q550 THIS RELAY IS NOT THE ONE THIS MACHINE IS PINNED TO — REFUSING TO CONNECT',
265
+ detail: [
266
+ `asked to connect to : ${c.url} (${c.origin})`,
267
+ `pinned to : ${pin.origins.join(', ')} (via ${pin.from})`,
268
+ '',
269
+ 'Nothing was opened and nothing was published, so no file has left this machine.',
270
+ c.kind === 'public-default'
271
+ ? 'The relay asked for is the PUBLIC one this build ships as a fallback — which is what\nyou get when a link is missing, a .hive.json was not found, or HIVE_RELAY is unset.'
272
+ : 'Check the join link you were given, and HIVE_RELAY in this process\'s environment.',
273
+ ].join('\n'),
274
+ }
275
+ }
276
+
277
+ if (!pin.pinned && strict && c.kind === 'public-default') {
278
+ return {
279
+ ok: false, code: 'Q551', classification: c, pin,
280
+ headline: 'Q551 HIVE_PRIVATE=1 AND THIS IS THE PUBLIC RELAY — REFUSING TO CONNECT',
281
+ detail: [
282
+ `relay: ${c.url}`,
283
+ '',
284
+ 'This is the relay this build falls back to when nobody chose one. Under HIVE_PRIVATE',
285
+ 'that fallback is an error rather than a default, because it is reached by omission —',
286
+ 'a missing link or an unset HIVE_RELAY — and never by anybody deciding on it.',
287
+ '',
288
+ 'Point HIVE_RELAY at your own relay, or unset HIVE_PRIVATE if you meant to use ours.',
289
+ ].join('\n'),
290
+ }
291
+ }
292
+
293
+ // Connecting. Say what that means, in one line, every time.
294
+ let warn = null
295
+ if (c.kind === 'public-default') {
296
+ warn = 'Your files are being sent to a relay run by the Hivecode author, not by you. ' +
297
+ 'Anyone who learns this room id can read them. For private work run your own relay ' +
298
+ '(npx hivecode-relay) and set HIVE_RELAY.'
299
+ if (room && guessableRoom(room)) {
300
+ warn += ` THE ROOM NAME "${room}" CAN BE GUESSED: anyone who tries it joins, reads every file, ` +
301
+ 'and writes into every folder in it. For real code, host a new room (its id is random) and share that link instead.'
302
+ }
303
+ } else if (c.kind === 'tunnel') {
304
+ warn = `${c.host} is a public tunnel. The relay is yours, but the tunnel provider decrypts ` +
305
+ 'traffic at its edge and can read it, and anyone with this URL can reach the relay. Fine for ' +
306
+ 'a trial with --secret set; for private remote work use a private network (e.g. Tailscale) ' +
307
+ 'or a server you control.'
308
+ } else if (c.kind === 'own' && !c.encrypted) {
309
+ warn = `ws:// to ${c.host} is NOT encrypted — your source travels the public internet in ` +
310
+ 'plaintext. Put TLS in front of the relay and use wss://.'
311
+ } else if (c.kind === 'unknown') {
312
+ warn = `"${c.url}" does not parse as a relay URL — the client will almost certainly fail ` +
313
+ 'to connect, and if it does connect you cannot tell where to.'
314
+ }
315
+
316
+ return {
317
+ ok: true, code: null, classification: c, pin, warn,
318
+ headline: `${HEADLINE[c.kind]}${c.encrypted ? '' : c.kind === 'loopback' ? '' : ' (UNENCRYPTED)'}`,
319
+ detail: warn || '',
320
+ }
321
+ }
322
+
323
+ /** One line for a join banner / status block: the URL plus what it means. */
324
+ export function privacyLine(relay, opts = {}) {
325
+ const v = checkRelay({ relay, ...opts })
326
+ return `relay: ${relay} [${v.headline}]`
327
+ }