@claudecollab/cli 0.1.2 → 0.2.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.
@@ -475,6 +475,7 @@ body {
475
475
 
476
476
  /* ── the terminal: full width, glass frame, gradient border ────────────────── */
477
477
  .workspace {
478
+ position: relative; /* the floating ask card anchors here (desktop) */
478
479
  flex: 1;
479
480
  min-height: 0;
480
481
  display: flex;
@@ -562,6 +563,117 @@ body {
562
563
  0 24px 60px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.05);
563
564
  }
564
565
  }
566
+ /* the ask CARD is the real affordance now — suppress the old "click to answer" pill
567
+ whenever it is showing so they never overlap (the amber pulse still frames it). */
568
+ #stage.ask.has-ask-card::after {
569
+ display: none;
570
+ }
571
+
572
+ /* ── permission ask card: touch-first Approve / Deny (all widths) ─────────────
573
+ On desktop it FLOATS over the top of the terminal (so the baseline, non-asking
574
+ layout is untouched); on narrow it leaves the flow and leads the card column. */
575
+ .ask-card {
576
+ position: absolute;
577
+ z-index: 9;
578
+ top: 22px;
579
+ left: 50%;
580
+ transform: translateX(-50%);
581
+ width: min(480px, calc(100% - 48px));
582
+ display: flex;
583
+ flex-direction: column;
584
+ gap: 10px;
585
+ padding: 14px 16px;
586
+ border-radius: 14px;
587
+ background: rgba(20, 15, 6, 0.92);
588
+ backdrop-filter: blur(14px);
589
+ -webkit-backdrop-filter: blur(14px);
590
+ box-shadow: 0 0 0 1.5px rgba(232, 163, 61, 0.6), 0 18px 44px rgba(0, 0, 0, 0.55);
591
+ color: var(--text);
592
+ }
593
+ .ask-card[hidden] {
594
+ display: none;
595
+ }
596
+ .ask-head {
597
+ display: flex;
598
+ align-items: center;
599
+ gap: 8px;
600
+ font-size: 13.5px;
601
+ color: var(--text);
602
+ }
603
+ .ask-head b {
604
+ color: var(--amber-hi);
605
+ font-weight: 700;
606
+ }
607
+ .ask-badge {
608
+ flex: 0 0 auto;
609
+ font-family: var(--mono);
610
+ font-size: 10.5px;
611
+ font-weight: 700;
612
+ text-transform: uppercase;
613
+ letter-spacing: 0.04em;
614
+ color: #1a1205;
615
+ background: var(--amber);
616
+ padding: 2px 8px;
617
+ border-radius: 999px;
618
+ }
619
+ .ask-summary {
620
+ font-family: var(--mono);
621
+ font-size: 12px;
622
+ color: var(--text-dim);
623
+ background: rgba(0, 0, 0, 0.32);
624
+ border-radius: 8px;
625
+ padding: 8px 10px;
626
+ max-height: 4.5em;
627
+ overflow: auto;
628
+ word-break: break-word;
629
+ white-space: pre-wrap;
630
+ }
631
+ .ask-summary[hidden] {
632
+ display: none;
633
+ }
634
+ .ask-actions {
635
+ display: flex;
636
+ gap: 10px;
637
+ }
638
+ .ask-actions[hidden] {
639
+ display: none;
640
+ }
641
+ .ask-btn {
642
+ flex: 1 1 0;
643
+ min-height: 44px; /* a comfortable touch target */
644
+ font: inherit;
645
+ font-weight: 700;
646
+ font-size: 14px;
647
+ border: none;
648
+ border-radius: 10px;
649
+ cursor: pointer;
650
+ transition: transform 0.12s var(--spring), filter 0.15s ease;
651
+ }
652
+ .ask-btn:active {
653
+ transform: scale(0.97);
654
+ }
655
+ .ask-btn.yes {
656
+ color: #05130c;
657
+ background: var(--green);
658
+ }
659
+ .ask-btn.yes:hover {
660
+ filter: brightness(1.08);
661
+ }
662
+ .ask-btn.no {
663
+ color: var(--red);
664
+ background: rgba(240, 140, 140, 0.12);
665
+ box-shadow: inset 0 0 0 1px var(--red-ring);
666
+ }
667
+ .ask-btn.no:hover {
668
+ background: rgba(240, 140, 140, 0.2);
669
+ }
670
+ .ask-viewnote {
671
+ font-size: 12px;
672
+ color: var(--text-faint);
673
+ }
674
+ .ask-viewnote[hidden] {
675
+ display: none;
676
+ }
565
677
 
566
678
  /* ── the per-user compose windows: a row beneath Claude's live terminal ──────
567
679
  Each column is one person's own space to write a prompt SEPARATELY; the
@@ -628,6 +740,9 @@ body {
628
740
  .uw-status.typing {
629
741
  color: var(--c, var(--amber));
630
742
  }
743
+ .uw-status.waiting {
744
+ color: var(--blue);
745
+ }
631
746
  .uw-body {
632
747
  flex: 1;
633
748
  min-height: 0;
@@ -637,6 +752,9 @@ body {
637
752
  font-size: 12px;
638
753
  line-height: 1.55;
639
754
  color: var(--text-dim);
755
+ /* selectable so prompts + responses can be copied out of the window */
756
+ user-select: text;
757
+ -webkit-user-select: text;
640
758
  }
641
759
  .uw-empty {
642
760
  color: var(--text-faint);
@@ -648,13 +766,73 @@ body {
648
766
  white-space: pre-wrap;
649
767
  word-break: break-word;
650
768
  }
651
- .uw-line.sent {
652
- color: var(--text-dim);
653
- }
654
769
  .uw-line .uw-n {
655
770
  color: var(--text-faint);
656
771
  margin-right: 6px;
657
772
  }
773
+
774
+ /* one turn: the person's prompt, then Claude's reply beneath it */
775
+ .uw-turn {
776
+ margin: 0 0 12px;
777
+ padding: 0 0 10px;
778
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
779
+ }
780
+ .uw-turn:last-child {
781
+ border-bottom: none;
782
+ }
783
+ .uw-prompt {
784
+ color: var(--c, var(--text));
785
+ font-weight: 600;
786
+ }
787
+ .uw-caret {
788
+ opacity: 0.7;
789
+ margin-right: 2px;
790
+ }
791
+ .uw-resp {
792
+ margin-top: 5px;
793
+ padding-left: 10px;
794
+ border-left: 2px solid rgba(255, 255, 255, 0.12);
795
+ color: var(--text-dim);
796
+ white-space: pre-wrap;
797
+ word-break: break-word;
798
+ }
799
+ .uw-resp.running {
800
+ color: var(--text-faint);
801
+ font-style: italic;
802
+ border-left-color: color-mix(in srgb, var(--blue) 50%, transparent);
803
+ }
804
+ .uw-more {
805
+ display: inline-block;
806
+ margin-left: 6px;
807
+ padding: 0;
808
+ font: inherit;
809
+ font-size: 11px;
810
+ color: var(--blue);
811
+ background: none;
812
+ border: none;
813
+ cursor: pointer;
814
+ text-decoration: underline;
815
+ text-underline-offset: 2px;
816
+ }
817
+ .uw-more:hover {
818
+ color: var(--text);
819
+ }
820
+ .uw-queued {
821
+ color: var(--text-faint);
822
+ display: flex;
823
+ align-items: baseline;
824
+ gap: 6px;
825
+ }
826
+ .uw-badge {
827
+ font-size: 9.5px;
828
+ text-transform: uppercase;
829
+ letter-spacing: 0.05em;
830
+ color: var(--amber);
831
+ border: 1px solid color-mix(in srgb, var(--amber) 40%, transparent);
832
+ border-radius: 999px;
833
+ padding: 1px 6px;
834
+ margin-left: auto;
835
+ }
658
836
  .uw-input-wrap {
659
837
  flex: 0 0 auto;
660
838
  padding: 9px 11px 11px;
@@ -1055,6 +1233,57 @@ body {
1055
1233
  }
1056
1234
  }
1057
1235
 
1236
+ /* ── phones (≤700px): card column is primary, the terminal is a scaled secondary
1237
+ surface below it. The cards fingers use — ask, your compose window, and the host's
1238
+ knock chips — lead; the mirror renders scaled-to-width (tap to zoom). ────────── */
1239
+ @media (max-width: 700px) {
1240
+ .workspace {
1241
+ padding: 8px;
1242
+ gap: 8px;
1243
+ }
1244
+ /* the terminal steps down from flex:1 to a shorter, scaled secondary panel */
1245
+ #stage {
1246
+ order: 3;
1247
+ flex: 0 0 auto;
1248
+ height: 40vh;
1249
+ min-height: 200px;
1250
+ }
1251
+ /* the ask card leaves the float and LEADS the column, full width */
1252
+ .ask-card {
1253
+ order: 0;
1254
+ position: static;
1255
+ transform: none;
1256
+ top: auto;
1257
+ left: auto;
1258
+ width: 100%;
1259
+ }
1260
+ /* your compose window(s) become the primary surface, stacked full width */
1261
+ .panels-grid {
1262
+ order: 2;
1263
+ height: auto;
1264
+ min-height: 34vh;
1265
+ grid-template-columns: 1fr;
1266
+ }
1267
+ /* bigger touch targets for the header controls fingers reach for */
1268
+ .ctrl {
1269
+ padding: 7px 14px;
1270
+ font-size: 12.5px;
1271
+ }
1272
+ .kbtn {
1273
+ padding: 8px 14px;
1274
+ font-size: 13px;
1275
+ }
1276
+ .av {
1277
+ width: 26px;
1278
+ height: 26px;
1279
+ font-size: 12px;
1280
+ margin-left: -4px;
1281
+ }
1282
+ .knock-chip {
1283
+ width: 100%;
1284
+ }
1285
+ }
1286
+
1058
1287
  /* Claude's real cursor in the mirror is the "you type HERE" indicator — but the
1059
1288
  mirror never holds browser focus, so xterm draws it as a hollow outline. Force
1060
1289
  it filled and blinking, like the terminal it mirrors. */
@@ -52,13 +52,16 @@ export function createRegistry(opts = {}) {
52
52
  return `${base}-${n}`;
53
53
  }
54
54
 
55
- function create() {
55
+ // A HELLO may request a room cap; it is clamped to [1, relayCap] so a client
56
+ // can't ask for more seats than the relay allows. Absent request = the relay cap.
57
+ function create(o = {}) {
58
+ const roomCap = Number.isFinite(o.cap) ? Math.max(1, Math.min(cap, Math.floor(o.cap))) : cap;
56
59
  const code = makeCode();
57
60
  const room = {
58
61
  code,
59
62
  guests: new Map(), // id -> {name, fp, role}
60
63
  banned: new Set(), // banned fingerprints
61
- cap,
64
+ cap: roomCap,
62
65
  hostPresent: true,
63
66
  createdAt: now(),
64
67
  _ttlTimer: null, // pending host-drop close timer
@@ -19,7 +19,7 @@ import { createRegistry } from './rooms.js';
19
19
  import { secretsMatch } from './auth.js';
20
20
  import { startWebDoor } from './web.js';
21
21
  import { sanitizeName } from './names.js';
22
- import { TYPES, encode, validate, Decoder } from '../shared/protocol.js';
22
+ import { TYPES, encode, validate, Decoder, PROTOCOL_V } from '../shared/protocol.js';
23
23
 
24
24
  const { Server, utils } = ssh2;
25
25
  const { parseKey } = utils;
@@ -27,6 +27,12 @@ const { parseKey } = utils;
27
27
  const DEFAULT_KNOCK_TIMEOUT_MS = 60 * 1000; // spec: 60s waiting screen
28
28
  const DEFAULT_TTL_MS = 10 * 60 * 1000; // spec: hold the code 10 min on host drop
29
29
 
30
+ // Per-room ceiling on PENDING (knocked-but-not-yet-admitted) guests. room.pending
31
+ // is otherwise unbounded, so a script could stack knock cards forever without ever
32
+ // being admitted. Sits well above the 8-guest room cap + normal churn; overflow is
33
+ // refused exactly like a deny. Shared by both doors (the web door gets it via ctx).
34
+ export const MAX_PENDING = 12;
35
+
30
36
  // Standard OpenSSH SHA256 fingerprint over the raw public-key blob.
31
37
  function fingerprint(keyData) {
32
38
  return 'SHA256:' + createHash('sha256').update(keyData).digest('base64').replace(/=+$/, '');
@@ -104,6 +110,10 @@ export function startRelay(opts = {}) {
104
110
  // ungated on purpose: it is already bound to the creating host's key
105
111
  // fingerprint, and must keep working mid-session if the secret rotates.
106
112
  roomSecret,
113
+ // Trust Fly's `Fly-Client-IP` header for per-IP knock limits. Only safe when
114
+ // the relay genuinely sits behind Fly's proxy (a direct-exposed relay could be
115
+ // spoofed by a client-sent header), so it is off unless the operator opts in.
116
+ trustProxy = false,
107
117
  registry: registryOpts = {},
108
118
  } = opts;
109
119
 
@@ -191,6 +201,15 @@ export function startRelay(opts = {}) {
191
201
  switch (msg.t) {
192
202
  case TYPES.HELLO: {
193
203
  if (code) return; // one room per host connection
204
+ // Version gate first — the cheapest refusal. A NEWER major than we
205
+ // speak is refused machine-readably (the CLI tells the user to update);
206
+ // an older or absent version is served (the relay stays back-compatible).
207
+ if (msg.v !== undefined && Math.floor(msg.v) > PROTOCOL_V) {
208
+ safeWrite(stream, encode({ t: TYPES.REFUSED, reason: 'version' }));
209
+ safeEnd(stream);
210
+ safeEnd(conn);
211
+ return;
212
+ }
194
213
  if (roomSecret && !secretsMatch(msg.secret, roomSecret)) {
195
214
  // Machine-readable refusal so the CLI can say "bad secret" instead
196
215
  // of mistaking this for a dead relay and reconnect-looping.
@@ -206,7 +225,7 @@ export function startRelay(opts = {}) {
206
225
  safeEnd(conn);
207
226
  return;
208
227
  }
209
- const room = registry.create();
228
+ const room = registry.create({ cap: msg.cap });
210
229
  code = room.code;
211
230
  live.set(code, {
212
231
  code,
@@ -397,6 +416,15 @@ export function startRelay(opts = {}) {
397
416
  return;
398
417
  }
399
418
 
419
+ // Card-spam lid: a full pending queue is refused exactly like a deny, so a
420
+ // script can't stack knock cards without ever being admitted.
421
+ if (room.pending.size >= MAX_PENDING) {
422
+ safeWrite(stream, COPY.denied);
423
+ safeEnd(stream);
424
+ safeEnd(conn);
425
+ return;
426
+ }
427
+
400
428
  const rec = {
401
429
  id: randomUUID(),
402
430
  code,
@@ -634,12 +662,15 @@ export function startRelay(opts = {}) {
634
662
  address,
635
663
  webPort: web ? web.port : null,
636
664
  webAddress: web ? web.address : null,
665
+ // How many rooms are currently live. Lets a caller assert that a lazy host
666
+ // (one started without --live) dialed nothing and created no room.
667
+ roomCount: () => live.size,
637
668
  });
638
669
  };
639
670
  // The browser door shares this relay's live rooms + registry, so web
640
671
  // participants are real room members subject to the same knock/ban/cap.
641
672
  if (webPort == null) return done(null);
642
- startWebDoor({ port: webPort, host, live, registry, knockTimeoutMs, onGuestGone, safeWrite }).then(done, (err) => {
673
+ startWebDoor({ port: webPort, host, live, registry, knockTimeoutMs, onGuestGone, safeWrite, trustProxy, maxPending: MAX_PENDING }).then(done, (err) => {
643
674
  try {
644
675
  close();
645
676
  } catch {
@@ -39,6 +39,10 @@ const here = path.dirname(fileURLToPath(import.meta.url));
39
39
  const PUBLIC_DIR = path.join(here, 'public');
40
40
  const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
41
41
 
42
+ // How long a passworded room waits for the client's first {t:'pass'} frame
43
+ // before closing the connection. Generous — a human may be prompted for it.
44
+ const PASS_TIMEOUT_MS = 10000;
45
+
42
46
  // Resolve the vendored xterm package dir through node resolution so monorepo
43
47
  // hoisting doesn't matter. `/assets/<rest>` maps to `<xtermDir>/<rest>`.
44
48
  const require = createRequire(import.meta.url);
@@ -97,6 +101,24 @@ function wsClose(ws) {
97
101
  }
98
102
  const sendErr = (ws, reason) => wsSendText(ws, JSON.stringify({ t: 'error', reason }));
99
103
 
104
+ /**
105
+ * The client IP the knock limiter keys on. Behind Fly's proxy every connection's
106
+ * `req.socket.remoteAddress` is the proxy, so all strangers share ONE lockout
107
+ * bucket. When the operator declares the deploy sits behind Fly
108
+ * (CLAUDE_SHARE_TRUST_PROXY=1 → trustProxy), honor the `Fly-Client-IP` header;
109
+ * otherwise ignore it (a direct-exposed relay must not trust a client-set header).
110
+ * @param {import('node:http').IncomingMessage} req
111
+ * @param {boolean} trustProxy
112
+ * @returns {string}
113
+ */
114
+ export function clientIp(req, trustProxy) {
115
+ if (trustProxy) {
116
+ const h = req.headers['fly-client-ip'];
117
+ if (typeof h === 'string' && h.length) return h;
118
+ }
119
+ return req.socket.remoteAddress || 'unknown';
120
+ }
121
+
100
122
  /**
101
123
  * Start the HTTP + WebSocket web door. Shares the live routing state and the
102
124
  * registry with the ssh relay so web participants are real room members.
@@ -109,10 +131,13 @@ const sendErr = (ws, reason) => wsSendText(ws, JSON.stringify({ t: 'error', reas
109
131
  * @param {number} ctx.knockTimeoutMs waiting-screen timeout
110
132
  * @param {(rec:object)=>void} ctx.onGuestGone shared guest-teardown (frees seat, LEFTs the host)
111
133
  * @param {(stream:any,data:any)=>void} ctx.safeWrite shared fail-soft writer (to the host stream)
134
+ * @param {boolean} [ctx.trustProxy] honor Fly-Client-IP for per-IP knock limits (Fly deploys only)
112
135
  * @returns {Promise<{close():void, port:number, address:object}>}
113
136
  */
114
137
  export function startWebDoor(ctx) {
115
- const { port = 0, host = '127.0.0.1', live, registry, knockTimeoutMs, onGuestGone, safeWrite } = ctx;
138
+ // maxPending's fallback mirrors server.js's MAX_PENDING startRelay always
139
+ // supplies it; the default only covers a direct startWebDoor caller (tests).
140
+ const { port = 0, host = '127.0.0.1', live, registry, knockTimeoutMs, onGuestGone, safeWrite, trustProxy = false, maxPending = 12 } = ctx;
116
141
 
117
142
  // ---- HTTP: index (SPA-style, room code in the path) + vendored xterm assets
118
143
 
@@ -218,7 +243,7 @@ export function startWebDoor(ctx) {
218
243
  // and the host writes it verbatim to its terminal / log / session.md and mirrors
219
244
  // it to every ssh guest — raw ESC/OSC/BEL bytes here would be escape injection.
220
245
  const name = sanitizeName(q.get('name'), isHostTab ? 'host' : 'guest');
221
- const ip = req.socket.remoteAddress || 'unknown';
246
+ const ip = clientIp(req, trustProxy);
222
247
 
223
248
  // Same gate as the ssh door (spec: respect bans/lockouts/caps identically).
224
249
  const room = code ? live.get(code) : null;
@@ -226,14 +251,16 @@ export function startWebDoor(ctx) {
226
251
  if (!room.hostPresent) return void (sendErr(ws, 'host-gone'), wsClose(ws));
227
252
  if (fp && registry.get(code).banned.has(fp)) return void (sendErr(ws, 'banned'), wsClose(ws));
228
253
  if (!registry.tryKnock(code, ip)) return void (sendErr(ws, 'lockout'), wsClose(ws));
229
- // Room password pre-gate (mirrors the ssh door's prompt). Host tabs are
230
- // exempt: their credential is the host token the brain itself minted — and a
231
- // forged `?host=` gains nothing, it just lands as an ordinary pending knock
232
- // the host will see and deny. Wrong guesses already burned a tryKnock slot
233
- // above, so brute force hits the per-ip lockout fast.
234
- if (!isHostTab && room.pass && !secretsMatch(q.get('pass') ?? '', room.pass)) {
235
- return void (sendErr(ws, 'password'), wsClose(ws));
236
- }
254
+
255
+ // Room password pre-gate. Instead of reading `?pass=` off the URL (where it
256
+ // leaks into proxy/access logs and browser history), a passworded room
257
+ // challenges over the WIRE: it answers with {t:'pass?'} and waits for the
258
+ // client's FIRST message, {t:'pass', pass}. Only a match proceeds to the knock.
259
+ // Host tabs are exempt: their credential is the host token the brain itself
260
+ // minted a forged `?host=` gains nothing, it just lands as an ordinary
261
+ // pending knock the host will deny. The tryKnock slot was already burned above,
262
+ // so brute force still hits the per-ip lockout fast.
263
+ const needsPass = !isHostTab && !!room.pass;
237
264
 
238
265
  const rec = {
239
266
  id: randomUUID(),
@@ -242,7 +269,9 @@ export function startWebDoor(ctx) {
242
269
  ws,
243
270
  fp,
244
271
  name,
245
- phase: 'knocking', // web skips the ssh name prompt: name arrives in the query
272
+ // web skips the ssh name prompt (name arrives in the query); a passworded
273
+ // room parks in 'pass' until the client answers the wire challenge.
274
+ phase: needsPass ? 'pass' : 'knocking',
246
275
  cols: 80, // spec floor; refined by the browser's first {t:'resize'}
247
276
  rows: 24,
248
277
  knockTimer: null,
@@ -254,24 +283,73 @@ export function startWebDoor(ctx) {
254
283
  conn: { end: () => wsClose(ws) },
255
284
  sendText: (s) => wsSendText(ws, s), // STATE + the 'joined' signal go as text
256
285
  };
257
- room.pending.set(rec.id, rec);
258
286
 
259
- // Knock the host (identical shape to the ssh door). A returning token carries
260
- // its prior name in `seen`; brand-new null.
261
- const priorName = room.seen.get(fp);
262
- const seen = priorName !== undefined ? priorName : null;
263
- room.seen.set(fp, name);
264
- rec.announced = true;
265
- safeWrite(room.hostStream, encode({ t: TYPES.KNOCK, id: rec.id, name: rec.name, fp: rec.fp, seen }));
266
- rec.knockTimer = setTimeout(() => {
287
+ // Register the pending rec, KNOCK the host, and arm the admit timer. Deferred
288
+ // behind the password gate so a passworded room never shows a pending card (or
289
+ // sends a KNOCK) for a guest that hasn't proven the password yet.
290
+ function proceedToKnock() {
267
291
  if (rec.gone) return;
268
- sendErr(ws, 'timeout');
269
- wsClose(ws); // 'close' onGuestGone: drops the seat, LEFTs the host
270
- }, knockTimeoutMs);
271
- rec.knockTimer.unref?.();
292
+ // The room may have ended (or its host dropped) while the guest sat at the
293
+ // password prompt a pass-phase rec is in neither guests nor pending, so
294
+ // closeRoom/onHostGone can't reach it. Refuse instead of knocking a dead room.
295
+ if (live.get(rec.code) !== room) return void (sendErr(ws, 'no-room'), wsClose(ws));
296
+ if (!room.hostPresent) return void (sendErr(ws, 'host-gone'), wsClose(ws));
297
+ if (rec.knockTimer) {
298
+ clearTimeout(rec.knockTimer); // clear the pass-challenge timer, if any
299
+ rec.knockTimer = null;
300
+ }
301
+ // Card-spam lid (mirrors the ssh door): a full pending queue is refused like
302
+ // a deny, so a script can't stack knock cards without ever being admitted.
303
+ if (room.pending.size >= maxPending) {
304
+ return void (sendErr(ws, 'busy'), wsClose(ws));
305
+ }
306
+ rec.phase = 'knocking';
307
+ room.pending.set(rec.id, rec);
308
+ // A returning token carries its prior name in `seen`; brand-new ⇒ null.
309
+ const priorName = room.seen.get(fp);
310
+ const seen = priorName !== undefined ? priorName : null;
311
+ room.seen.set(fp, name);
312
+ rec.announced = true;
313
+ safeWrite(room.hostStream, encode({ t: TYPES.KNOCK, id: rec.id, name: rec.name, fp: rec.fp, seen }));
314
+ rec.knockTimer = setTimeout(() => {
315
+ if (rec.gone) return;
316
+ sendErr(ws, 'timeout');
317
+ wsClose(ws); // 'close' → onGuestGone: drops the seat, LEFTs the host
318
+ }, knockTimeoutMs);
319
+ rec.knockTimer.unref?.();
320
+ }
321
+
322
+ if (needsPass) {
323
+ wsSendText(ws, '{"t":"pass?"}'); // ask on the wire; client replies {t:'pass'}
324
+ rec.knockTimer = setTimeout(() => {
325
+ if (rec.gone) return;
326
+ sendErr(ws, 'timeout');
327
+ wsClose(ws);
328
+ }, PASS_TIMEOUT_MS);
329
+ rec.knockTimer.unref?.();
330
+ } else {
331
+ proceedToKnock();
332
+ }
272
333
 
273
334
  ws.on('message', (data, isBinary) => {
274
335
  if (isBinary) return; // browsers only send JSON text; screen flows one way
336
+ // Pre-knock password phase: the FIRST text frame must be {t:'pass', pass}.
337
+ if (rec.phase === 'pass') {
338
+ let pm;
339
+ try {
340
+ pm = JSON.parse(data.toString('utf8'));
341
+ } catch {
342
+ return;
343
+ }
344
+ if (!isObj(pm) || pm.t !== 'pass') return;
345
+ if (secretsMatch(typeof pm.pass === 'string' ? pm.pass : '', room.pass)) {
346
+ proceedToKnock();
347
+ } else {
348
+ sendErr(ws, 'password');
349
+ wsClose(ws);
350
+ }
351
+ return;
352
+ }
275
353
  if (rec.phase !== 'live') return; // ignore input until admitted (ssh parity)
276
354
  const r = live.get(rec.code);
277
355
  if (!r || !r.hostPresent || !r.guests.has(rec.id)) return;
@@ -2,9 +2,15 @@
2
2
  // Guests speak no protocol — they exchange raw terminal bytes. Only the host<->relay
3
3
  // control channel uses these messages.
4
4
 
5
+ // Wire protocol version. Bump on breaking wire changes; the relay refuses NEWER
6
+ // majors with REFUSED reason 'version' (an older/absent v is served — the relay
7
+ // stays backward compatible). One source both sides import.
8
+ export const PROTOCOL_V = 1;
9
+
5
10
  /** Canonical message-type strings (the `t` field of every message). */
6
11
  export const TYPES = Object.freeze({
7
- HELLO: 'hello', // host->relay: {t:'hello', want:'room', secret?, pass?} — new room (secret = relay
12
+ HELLO: 'hello', // host->relay: {t:'hello', want:'room', v?, cap?, secret?, pass?} — new room
13
+ // (v = protocol version; cap = requested room size, clamped by the relay; secret = relay
8
14
  // room-creation credential; pass = optional per-room JOIN password guests must present)
9
15
  RECLAIM: 'reclaim', // host->relay: {t:'reclaim', code} — take back an existing room after a drop
10
16
  ROOM: 'room', // relay->host: {t:'room', code} — room granted (create OR reclaim)
@@ -137,8 +143,12 @@ export function validate(obj) {
137
143
  // the relay when configured; a plain relay ignores it.
138
144
  // pass (optional): a join password for THIS room — the relay stores it and
139
145
  // pre-gates every guest (ssh prompt / web form) before the knock is sent.
146
+ // v/cap (optional): v = the client's protocol version (absent = a
147
+ // pre-versioning client); cap = a requested room size the relay clamps.
140
148
  return (
141
149
  obj.want === 'room' &&
150
+ (obj.v === undefined || isNum(obj.v)) &&
151
+ (obj.cap === undefined || isNum(obj.cap)) &&
142
152
  (obj.secret === undefined || isStr(obj.secret)) &&
143
153
  (obj.pass === undefined || isStr(obj.pass))
144
154
  );