realtimeclipboard 0.7.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,16 +26,16 @@ proven.
26
26
 
27
27
  ## What it does
28
28
 
29
- - **Sync clipboard text between devices** — Windows, macOS, Android, ChromeOS and Linux
30
- - **Works across different networks**, not just the same Wi-Fi, and not just the same LAN
31
- - **No account, no sign-up, no email** a short key is the whole identity of a session
29
+ - Syncs clipboard text between Windows, macOS, Android, ChromeOS and Linux
30
+ - Works across different networks, not just the same Wi-Fi and not just the same LAN
31
+ - No account, no sign-up, no email. A short key is the whole identity of a session
32
32
  (ten characters on the web, sixteen in the installed apps, and either works on both)
33
- - **End-to-end encrypted** in the browser with AES-GCM; one `PBKDF2` derivation produces both the key and the room address
34
- - **Peer-to-peer file transfer** over a WebRTC data channel, 5 MB per file
35
- - **Copy and paste images** a screenshot copied on one machine previews on the other
36
- - **Installable progressive web app** own window, own icon, works offline
37
- - **Nothing written to disk**, on your machine or the server
38
- - **Self-hostable relay** — it is one small FastAPI service
33
+ - End-to-end encrypted in the browser with AES-GCM; one `PBKDF2` derivation produces both the key and the room address
34
+ - Peer-to-peer file transfer over a WebRTC data channel, 5 MB per file
35
+ - Copy and paste images: a screenshot copied on one machine previews on the other
36
+ - Installable progressive web app, with its own window and icon, and it works offline
37
+ - Nothing written to disk, on your machine or the server
38
+ - Self-hostable relay, one small FastAPI service
39
39
 
40
40
  ## Why
41
41
 
@@ -138,7 +138,7 @@ desktop/ Tauri shell around this very src/ — no second implemen
138
138
  assets/ icons/ (precached whole) + social/ (the OG card, never)
139
139
  tests/ unit/ needs nothing · dom/ needs jsdom · live/ needs a relay
140
140
  tools/ build/ · check/ · release/ · seo/
141
- docs/ PRD, architecture, clipboard design, P2P design, SEO
141
+ docs/ PRD, architecture, clipboard design, P2P design, threat model
142
142
  ```
143
143
 
144
144
  A directory **at the root** is served at its own path — `assets/icons/icon.svg` is
package/cli/CLAUDE.md CHANGED
@@ -25,4 +25,11 @@ end up subtly and silently disagreeing.
25
25
  already wrong — the package said 0.2.1 while `--version` said 0.1.0.
26
26
  - `package.json` `files:` ships `cli/`, `src/core/` and `src/transport/`. Importing anything outside
27
27
  those three from here breaks the published package, and only after publish.
28
+ - **`session.mjs` acts on its own initiative in exactly one place**, and that place needs the
29
+ caller's permission. Everything else in it runs because a surface asked — it sends when told,
30
+ closes when told. The lock-verification responder answers a frame off the wire with nobody in the
31
+ loop, so it takes a `sharing` predicate (VS Code has an Off rung; the other three do not) and
32
+ carries a session generation, because producing an answer spans two awaits and the surface can
33
+ leave, be evicted, or rejoin on another key inside either. Anything else added here that
34
+ transmits unprompted needs both. `tests/unit/shared-session.mjs`.
28
35
  - `tests/live/cli.mjs` runs this against a real relay, and `prepublishOnly` runs that suite.
@@ -47,6 +47,13 @@ import { INVISIBLE_SOURCE } from "../src/core/text.js";
47
47
  * itself and never goes through the bundler, so its depth in the tree is fixed.
48
48
  * npm always includes package.json in a tarball, so this resolves after install.
49
49
  */
50
+ /**
51
+ * This process's id in the room. One per run, not one per clip: it is how peers
52
+ * recognise our own frames coming back, and how a locked room's verification
53
+ * answer is attributed.
54
+ */
55
+ const ORIGIN = `cli-${Date.now().toString(36)}`;
56
+
50
57
  const VERSION = JSON.parse(
51
58
  readFileSync(new URL("../package.json", import.meta.url), "utf8"),
52
59
  ).version;
@@ -158,6 +165,9 @@ const forTerminal = text => (stdout.isTTY ? text.replace(TERMINAL_UNSAFE, "") :
158
165
 
159
166
  const note = (msg, opts) => { if (!opts.quiet) stderr.write(`${msg}\n`); };
160
167
 
168
+ /** How long EOF waits for queued lines to finish leaving. */
169
+ const DRAIN_MS = 5_000;
170
+
161
171
  function die(msg, code = 1) {
162
172
  stderr.write(`realtimeclipboard: ${msg}\n`);
163
173
  exit(code);
@@ -187,6 +197,7 @@ function connect(sess, opts, onClip) {
187
197
  return room.open({
188
198
  session: sess,
189
199
  name: `cli@${hostname()}`,
200
+ originId: ORIGIN,
190
201
  url: url ? normaliseRelay(url) : undefined,
191
202
  timeoutMs: opts.timeout || 0,
192
203
  onClip,
@@ -195,14 +206,17 @@ function connect(sess, opts, onClip) {
195
206
  });
196
207
  }
197
208
 
198
- async function sendText(sess, text, opts) {
199
- if (!text) die("nothing on stdin to send", 2);
209
+ /**
210
+ * Throws rather than exiting. `send` is one shot and dies on a failure, but
211
+ * two-way mode has a session to keep: killing the process on one refused line
212
+ * would drop the reader half too.
213
+ */
214
+ async function sendText(sess, text) {
215
+ if (!text) throw new Error("nothing on stdin to send");
200
216
  if (text.length > TEXT.MAX_CHARS) {
201
- die(`that is ${text.length} characters; the limit is ${TEXT.MAX_CHARS}`, 2);
217
+ throw new Error(`that is ${text.length} characters; the limit is ${TEXT.MAX_CHARS}`);
202
218
  }
203
- try {
204
- await room.send(sess, text, `cli-${Date.now().toString(36)}`);
205
- } catch (err) { die(err.message, 2); }
219
+ await room.send(sess, text, ORIGIN);
206
220
  // The frame is handed to a socket, not delivered. Give it a moment to flush
207
221
  // before the process exits out from under it.
208
222
  await new Promise(r => setTimeout(r, 250));
@@ -283,7 +297,9 @@ try {
283
297
  note(" connected", opts);
284
298
 
285
299
  if (cmd === "send") {
286
- await sendText(session, await readStdin(), opts);
300
+ try {
301
+ await sendText(session, await readStdin());
302
+ } catch (err) { die(err.message, 2); }
287
303
  relay.close();
288
304
  exit(0);
289
305
  }
@@ -297,11 +313,30 @@ if (cmd === "both") {
297
313
  // A pasted block of lines could reach the relay out of order and be stamped
298
314
  // with inverted `seq`, which is what receivers order and dedupe by.
299
315
  let sending = Promise.resolve();
316
+ let failed = false;
300
317
  rl.on("line", line => {
301
318
  if (!line.length) return;
302
- sending = sending.then(() => sendText(session, line, opts)).catch(() => {});
319
+ sending = sending
320
+ .then(() => sendText(session, line))
321
+ .catch(err => { failed = true; note(` ${err.message}`, opts); });
322
+ });
323
+
324
+ // Drained, not abandoned. `close` fires the moment stdin ends, and calling
325
+ // exit() there killed the chain mid-flight: piping three lines in sent none of
326
+ // them, because the first was still being encrypted — and the exit status said
327
+ // it had worked. Bounded, so a wedged relay cannot hold the process open.
328
+ rl.on("close", async () => {
329
+ // Which of the two won matters: the timeout winning means lines are still
330
+ // unsent, and exiting 0 there reports a success that did not happen — the
331
+ // same overstatement as the send path that ignored its own return value.
332
+ const drained = await Promise.race([
333
+ sending.then(() => true, () => true),
334
+ new Promise(done => setTimeout(() => done(false), DRAIN_MS)),
335
+ ]);
336
+ relay.close();
337
+ if (!drained) note(" timed out with lines still unsent", opts);
338
+ exit(failed || !drained ? 4 : 0);
303
339
  });
304
- rl.on("close", () => { relay.close(); exit(0); });
305
340
  }
306
341
 
307
342
  for (const sig of ["SIGINT", "SIGTERM"]) {
package/cli/session.mjs CHANGED
@@ -26,6 +26,19 @@ import { LOCK, TEXT, textBytes, NET } from "../src/core/config.js";
26
26
  import { on, EV } from "../src/core/bus.js";
27
27
  import * as relay from "../src/transport/relay.js";
28
28
  import * as proto from "../src/transport/protocol.js";
29
+ import { sealWith, openWith } from "../src/core/frames.js";
30
+
31
+ /**
32
+ * Which room this module is currently in. Bumped by open() and close(), and
33
+ * therefore by eviction, which goes through close().
34
+ *
35
+ * It exists for the one thing here that outlives the frame it started from: a
36
+ * verification answer takes two awaits to produce, and both of them are long
37
+ * enough for the surface to have left the room, rejoined on another key, or
38
+ * been evicted from it. Sealed with the old key and sent into the new room,
39
+ * that answer is undecryptable noise attributed to this device.
40
+ */
41
+ let epoch = 0;
29
42
 
30
43
  /**
31
44
  * Everything needed to talk in a room, derived exactly as the browser derives
@@ -41,8 +54,14 @@ export async function derive(rawKey, pin) {
41
54
  if (refused) throw new Error(`"${rawKey}" cannot be used — ${refused}`);
42
55
 
43
56
  if (pin) {
57
+ // Length, not truthiness. A one-character PIN passed this and derived a real
58
+ // locked room that the web PIN dialog then refused to join, because it
59
+ // enforces the floor on every mode — so these clients could create sessions
60
+ // no browser could open, with a secret worth a couple of bits.
44
61
  const clean = cryptoBox.normalisePin(pin);
45
- if (!clean) throw new Error(`a PIN needs at least ${LOCK.MIN_PIN} characters`);
62
+ if (clean.length < LOCK.MIN_PIN) {
63
+ throw new Error(`a PIN needs at least ${LOCK.MIN_PIN} characters`);
64
+ }
46
65
  const d = await cryptoBox.deriveLocked(key, clean);
47
66
  return { key, roomHash: d.roomHash, aesKey: d.aesKey, auth: d.authToken, locked: true };
48
67
  }
@@ -58,10 +77,29 @@ export async function derive(rawKey, pin) {
58
77
  * failures, and a script handed one when it expected the other has been told a
59
78
  * lie about its own network.
60
79
  *
61
- * onClip(text, frame) decrypted, beacon already dropped
80
+ * onClip(text, frame) decrypted, control frames already dropped
81
+ * onEvicted() the room was abandoned; the connection is ALREADY shut
62
82
  * onUndecryptable() someone in the room on another secret — not an error
83
+ *
84
+ * `originId` is this surface's own id — the same one it passes to send(). It is
85
+ * wanted here because a locked room's verification is answered from inside this
86
+ * handler, with no caller to ask.
87
+ *
88
+ * `sharing` is that answer's rung gate. Defaulted to "yes" rather than made
89
+ * required, and the default is correct rather than convenient: the CLI, the MCP
90
+ * server and the browser extension have no Off rung to consult — every one of
91
+ * their sessions exists because somebody asked for it. VS Code does have one,
92
+ * and passes it. See vscode/src/room.mjs.
63
93
  */
64
- export function open({ session, name, url, onClip, onUndecryptable, onState, timeoutMs = 0 }) {
94
+ export function open({
95
+ session, name, url, originId, sharing = () => true,
96
+ onClip, onEvicted, onUndecryptable, onState, timeoutMs = 0,
97
+ }) {
98
+ // A rejoin is a different room, even on the same key — re-PINning keeps the
99
+ // key and moves the room. Anything still in flight for the last one is now
100
+ // answering a question nobody asked.
101
+ const mine = ++epoch;
102
+
65
103
  return new Promise((resolve, reject) => {
66
104
  let off = null;
67
105
  const timer = setTimeout(() => {
@@ -78,6 +116,9 @@ export function open({ session, name, url, onClip, onUndecryptable, onState, tim
78
116
  });
79
117
 
80
118
  relay.setFrameHandler(async (msg) => {
119
+ if (msg.t === proto.T.VERIFY) {
120
+ return answerVerify({ session, msg, originId, sharing, mine });
121
+ }
81
122
  if (msg.t !== proto.T.CLIP || !msg.payload) return;
82
123
  let text;
83
124
  try {
@@ -93,6 +134,21 @@ export function open({ session, name, url, onClip, onUndecryptable, onState, tim
93
134
  // "looks like a control character", which would also swallow a legitimate
94
135
  // clip that happened to start with NUL.
95
136
  if (text === LOCK.BEACON) return;
137
+
138
+ // The other control frame, and it was not filtered — it reached callers as
139
+ // clip text, so the sentinel was printed to stdout by the CLI, recorded as
140
+ // a clip for the model by the MCP server, and put on the extension's badge.
141
+ // Every one of them then stayed connected to a room the owner had left.
142
+ //
143
+ // Closed here rather than left to each surface: "stop talking to a room
144
+ // nobody is in" is not a per-client decision, and three of the four
145
+ // clients did not know they had to make it.
146
+ if (text === LOCK.EVICT) {
147
+ close();
148
+ onEvicted?.();
149
+ return;
150
+ }
151
+
96
152
  onClip?.(text, msg);
97
153
  });
98
154
 
@@ -106,6 +162,42 @@ export function open({ session, name, url, onClip, onUndecryptable, onState, tim
106
162
  });
107
163
  }
108
164
 
165
+ /**
166
+ * Prove a locked room to whoever asked.
167
+ *
168
+ * These four surfaces are frequently the ONLY other device in a room — a phone
169
+ * on the web app plus a terminal, plus nothing else — and a locked session
170
+ * cannot confirm its PIN without a peer that answers. Silent here, and the
171
+ * browser at the other end sits on "the PIN may not match theirs" for the whole
172
+ * session while the CLI it is talking to reads every clip perfectly.
173
+ *
174
+ * `probe !== true` is the loop guard, and it is the same one the app uses: an
175
+ * answer is never answered.
176
+ *
177
+ * Both gates are re-read at every step rather than once at the top, because
178
+ * there are two awaits here and each is a window: the rung can come down and
179
+ * the room can change inside either.
180
+ *
181
+ * The rung is checked BEFORE the decryption, which is where this deliberately
182
+ * differs from the app (src/main.js, case proto.T.VERIFY). There, opening the
183
+ * frame is what sets `verified`, so an Off device is entitled to do it — the
184
+ * rung governs what reaches the user, not what the session knows. Here nothing
185
+ * is learned from opening it: the only thing this function produces is a frame
186
+ * on the wire, so a surface that has said nothing leaves has no reason to start
187
+ * decrypting.
188
+ */
189
+ async function answerVerify({ session, msg, originId, sharing, mine }) {
190
+ if (!session.locked || !sharing() || mine !== epoch) return;
191
+
192
+ const frame = await openWith(session.aesKey, msg);
193
+ if (!sharing() || mine !== epoch) return;
194
+ if (!frame || frame.probe !== true) return;
195
+
196
+ const answer = await sealWith(session.aesKey, proto.verify({ probe: false, originId }));
197
+ if (!sharing() || mine !== epoch) return;
198
+ relay.send(answer);
199
+ }
200
+
109
201
  /** Bytes, not characters: the frame cap is what the relay actually enforces. */
110
202
  export async function send(session, text, originId) {
111
203
  if (!text) throw new Error("there is nothing to send");
@@ -113,7 +205,15 @@ export async function send(session, text, originId) {
113
205
  throw new Error(`that is ${textBytes(text)} bytes; the limit is ${TEXT.MAX_BYTES}`);
114
206
  }
115
207
  const { payload, iv } = await cryptoBox.encrypt(session.aesKey, text);
116
- return relay.send(proto.clip({ payload, iv, originId }));
208
+ // The relay's boolean means "handed to an open socket", and every caller
209
+ // ignored it: the CLI exited 0 having sent nothing, the MCP server told the
210
+ // model the clip was delivered, and the extension said "Sent N characters."
211
+ // — all while disconnected. A throw is the one result none of them can drop
212
+ // by accident, and each already reports a thrown error.
213
+ if (!relay.send(proto.clip({ payload, iv, originId }))) {
214
+ throw new Error("not connected to the relay — nothing was sent");
215
+ }
216
+ return true;
117
217
  }
118
218
 
119
219
  /** Announce a room being abandoned, so nobody is left connected to nothing. */
@@ -129,6 +229,10 @@ export async function evict(session, originId) {
129
229
  export const isOpen = () => relay.isOpen();
130
230
 
131
231
  export function close() {
232
+ // Before the socket, not after: a response mid-seal must be abandoned even
233
+ // though relay.send() would refuse it anyway, because the next open() may
234
+ // have handed the transport a new room by the time that seal resolves.
235
+ epoch++;
132
236
  relay.setFrameHandler(() => {});
133
237
  relay.close();
134
238
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "realtimeclipboard",
3
- "version": "0.7.0",
3
+ "version": "1.0.0",
4
4
  "description": "Live clipboard sharing. Static frontend of native ES modules — development needs no build, and `npm run build` exists only to assemble the deploy. Nothing here is needed to READ the app.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,6 +26,7 @@
26
26
  "files": [
27
27
  "cli/",
28
28
  "src/core/",
29
+ "!src/core/lang/",
29
30
  "src/transport/",
30
31
  "LICENSE",
31
32
  "README.md"
@@ -34,7 +35,7 @@
34
35
  "node": ">=22"
35
36
  },
36
37
  "scripts": {
37
- "verify": "node tests/unit/static-check.mjs && node tests/unit/relay-url.mjs && node tests/unit/lock.mjs && node tests/unit/files.mjs && node tests/unit/transfer.mjs && node tests/unit/clipsize.mjs && node tests/unit/syncmode.mjs && node tests/unit/pasteguard.mjs && node tests/unit/sharelink.mjs && node tests/unit/keyfloor.mjs && node tests/unit/adpolicy.mjs && node tests/unit/vscode-host.mjs && node tests/unit/browser-worker.mjs && node tests/dom/dialog.mjs && node tests/dom/theme.mjs && node tests/dom/whatsnew.mjs && node tests/dom/tiles.mjs && node tests/dom/guide.mjs && node tests/dom/links.mjs && node tests/dom/editor.mjs && node tests/dom/capture.mjs && node tests/dom/offer.mjs",
38
+ "verify": "node tests/unit/static-check.mjs && node tools/i18n/build-pages.mjs --check && node tools/i18n/app-strings.mjs --check && node tests/unit/i18n.mjs && node tests/unit/relay-url.mjs && node tests/unit/lock.mjs && node tests/unit/session-boundaries.mjs && node tests/unit/verify.mjs && node tests/unit/shared-session.mjs && node tests/unit/files.mjs && node tests/unit/transfer.mjs && node tests/unit/clipsize.mjs && node tests/unit/syncmode.mjs && node tests/unit/pasteguard.mjs && node tests/unit/sharelink.mjs && node tests/unit/keyfloor.mjs && node tests/unit/adpolicy.mjs && node tests/unit/vscode-host.mjs && node tests/unit/browser-worker.mjs && node tests/unit/browser-popup.mjs && node tests/dom/dialog.mjs && node tests/dom/theme.mjs && node tests/dom/whatsnew.mjs && node tests/dom/tiles.mjs && node tests/dom/guide.mjs && node tests/dom/links.mjs && node tests/dom/editor.mjs && node tests/dom/capture.mjs && node tests/dom/offer.mjs",
38
39
  "test": "npm run verify && npm run build:vscode && npm run build:mcp && npm run build:browser && node tests/dom/bundle.mjs && node tests/dom/extension.mjs && node tests/live/vscode.mjs && node tests/live/mcp.mjs && node tests/live/cli.mjs && node tests/live/e2e.mjs && node tests/live/boot.mjs && node tests/live/boot.mjs --locked && node tests/live/boot.mjs --qr && node tests/live/boot.mjs --locked --qr && node tests/live/fallback.mjs",
39
40
  "test:static": "node tests/unit/static-check.mjs",
40
41
  "test:lock": "node tests/unit/lock.mjs && node tests/dom/dialog.mjs",
@@ -49,6 +50,8 @@
49
50
  "test:editor": "node tests/editor/host.mjs",
50
51
  "build": "node tools/build/build.mjs",
51
52
  "build:site": "node tools/build/build.mjs _site && node tools/check/site-check.mjs _site",
53
+ "i18n:pages": "node tools/i18n/build-pages.mjs",
54
+ "i18n:app": "node tools/i18n/app-strings.mjs",
52
55
  "size:record": "node tools/build/build.mjs _site --record-size",
53
56
  "build:desktop": "node tools/build/build.mjs _desktop --desktop && node tools/check/desktop-check.mjs _desktop",
54
57
  "build:vscode": "node tools/build/build-vscode.mjs",
@@ -36,3 +36,7 @@ runs these exact modules. Two consequences:
36
36
  there. The active session lives in `sessionStorage` and dies with the tab.
37
37
  - `text.js` is a security character class with three consumers that may not import each other. It
38
38
  belongs here for that reason, and the ranges in it are not a style preference — see the comment.
39
+ - **`verify.js` and `frames.js` take their seams injected, not imported.** A frame shape and the
40
+ transport are both rank 10, so neither is reachable from here — but the reason to keep it that
41
+ way is that every rule in them is then exercisable from a plain node test. Both were four lines
42
+ in the composition root first, and four lines in `main.js` can only be checked by reading them.
package/src/core/bus.js CHANGED
@@ -52,10 +52,10 @@ export const EV = {
52
52
  PEERS_CHANGED: "peers:changed", // {count, list}
53
53
  INSTANCE_CHANGED:"conn:instance", // {from, to} — split-brain warning (OI-3)
54
54
  KEY_COLLISION: "session:collision",// generated key was taken (OI-2)
55
- ROOM_STATE: "conn:room", // {existing, hasLast} — what `welcome` said
55
+ ROOM_STATE: "conn:room", // {existing} — what `welcome` said
56
56
 
57
57
  // session
58
- KEY_CHANGED: "session:key", // {key, locked}
58
+ KEY_CHANGED: "session:key", // {key, locked, roomHash}
59
59
  // Announcements are past tense, commands are bare verbs, and the extra
60
60
  // syllable here is load-bearing — see core/CLAUDE.md. The bus does no
61
61
  // namespacing of its own, so a collision between the two is silent.
@@ -63,6 +63,40 @@ export const RELAY_URL =
63
63
  /** True when the app is not talking to the relay it was built against. */
64
64
  export const RELAY_IS_CUSTOM = RELAY_URL !== DEFAULT_RELAY_URL && RELAY_URL !== LOCAL_RELAY_URL;
65
65
 
66
+ /**
67
+ * The organisation token for a relay that runs with REALTIMECLIPBOARD_JOIN_TOKEN set.
68
+ *
69
+ * Such a relay refuses every join that does not present `?org=`, and no client
70
+ * had any way to send one — so turning the setting on, which SELF-HOSTING.md
71
+ * tells operators to do to stop their relay being open to anyone who learns the
72
+ * hostname, bricked every shipped client instead of restricting it.
73
+ *
74
+ * Deliberately NOT in the share link: a link is pasted into chats and read off
75
+ * screens, and this is the credential that admits a device to the deployment.
76
+ * It is configured once per device — `?org=` on first load, which is persisted,
77
+ * or REALTIMECLIPBOARD_ORG for the CLI and MCP server.
78
+ */
79
+ const ORG_KEY = "orgToken";
80
+
81
+ /** The query parameter that carries it. Named once — safeSearch() strips it. */
82
+ export const ORG_PARAM = "org";
83
+ const storedOrg = () => {
84
+ try { return JSON.parse(localStorage.getItem(STORAGE_PREFIX + ORG_KEY)); }
85
+ catch { return null; }
86
+ };
87
+ const orgFromQuery = () => {
88
+ try { return new URLSearchParams(location.search).get(ORG_PARAM); }
89
+ catch { return null; }
90
+ };
91
+ const orgFromEnv = () => {
92
+ try { return globalThis.process?.env?.REALTIMECLIPBOARD_ORG || null; }
93
+ catch { return null; }
94
+ };
95
+ const cleanOrg = v => (typeof v === "string" && v.trim() ? v.trim() : null);
96
+
97
+ export const ORG_TOKEN = cleanOrg(orgFromQuery()) ?? cleanOrg(orgFromEnv()) ?? cleanOrg(storedOrg());
98
+ export const ORG_STORAGE_KEY = ORG_KEY;
99
+
66
100
  /**
67
101
  * The same relay over plain HTTP, for the SSE+POST fallback and /stats. One
68
102
  * hostname so IT allowlists one domain (PRD §5.4); derived so the two cannot drift.
@@ -146,8 +180,13 @@ export const PASTE_GUARD = {
146
180
  ENABLED: true,
147
181
 
148
182
  /**
149
- * Past this, do not scan. The patterns are anchored per line, so a pasted
150
- * logfile is a lot of backtracking for a case this does not defend against.
183
+ * The longest LINE the scanner will look at. Every pattern is anchored to the
184
+ * start of a line and none spans one, so the scan runs per line and this
185
+ * bounds the work any single regex does.
186
+ *
187
+ * A line past it is treated as risky rather than safe. This was once a cap on
188
+ * the whole clip, above which nothing was scanned at all — and it sat below
189
+ * the clip size limit, so padding a command past it turned the guard off.
151
190
  */
152
191
  MAX_SCAN_CHARS: 8_192,
153
192
  };
@@ -188,6 +227,17 @@ export const FILES = {
188
227
  * wedged association must not hold a transfer open.
189
228
  */
190
229
  CHANNEL_CLOSE_MS: 1_000,
230
+
231
+ /**
232
+ * How long a sender waits for the receiver to confirm it has the file, whole
233
+ * and verified, before reporting what it actually knows.
234
+ *
235
+ * "Sent" used to mean "the last frame was handed to a socket". A digest
236
+ * failure at the far end was reported to nobody, so the holder's tile said
237
+ * the transfer had worked while the other device showed an error. Bounded,
238
+ * because a receiver that vanishes must not leave a progress bar up forever.
239
+ */
240
+ RECEIPT_MS: 10_000,
191
241
  };
192
242
 
193
243
  export const KEY = {
@@ -291,8 +341,21 @@ export const LOCK = {
291
341
  SIGIL: "!",
292
342
 
293
343
  /**
294
- * Sent on creating a locked room and replayed to joiners, so a joiner can tell
295
- * "wrong PIN" from "first one here" by whether it decrypts. Receivers drop it.
344
+ * READ, never written. Locked sessions used to prove themselves by planting
345
+ * this as a clip: the relay retains one clip per room and replays it, so a
346
+ * joiner that decrypted it had proved its PIN before any peer was awake.
347
+ *
348
+ * It worked and it cost too much. The proof took the room's single retained
349
+ * slot, so it either destroyed the last thing the user copied or, once the
350
+ * plant started honouring the sync rung, silently never happened — and the
351
+ * two guards that stopped those cases could not both hold. Verification is
352
+ * now its own frame (`proto.verify`), forwarded and not retained.
353
+ *
354
+ * Still recognised on arrival, because a client older than that change is
355
+ * still planting one, and a sentinel that reached the editor would be
356
+ * printed, put on the OS clipboard and written to history as a clip.
357
+ * Compared against the constant, never "starts with NUL", which would also
358
+ * swallow a legitimate clip.
296
359
  */
297
360
  BEACON: String.fromCharCode(0) + "realtimeclipboard-lock-v1",
298
361
 
@@ -314,10 +377,30 @@ export const LOCK = {
314
377
  * that needs the fallback.
315
378
  */
316
379
  EVICT_FLUSH_MS: 250,
380
+
381
+ /**
382
+ * Floor between two verification probes. Not a retry timer — every trigger
383
+ * is an event worth asking on (a peer arrived, the rung came off Off) and
384
+ * roster churn can fire several in a second. One question per interval is
385
+ * plenty: a peer that can answer answers immediately, and a peer that cannot
386
+ * will not answer the second one either.
387
+ */
388
+ VERIFY_MIN_INTERVAL_MS: 3_000,
317
389
  };
318
390
 
319
391
  export const NET = {
320
392
  HEARTBEAT_MS: 30_000, // must beat proxy idle reaping (PRD 5.4, FR-3.6)
393
+
394
+ /**
395
+ * How long a ping may go unanswered before the connection is treated as dead.
396
+ *
397
+ * A socket can sit at readyState OPEN with nothing at the other end: no
398
+ * error, no close, and the session reads "connected" for as long as the tab
399
+ * is left open. The ping had no deadline at all, so nothing ever noticed.
400
+ * Generous against a slow phone network, short enough that a dead session
401
+ * reconnects rather than sits.
402
+ */
403
+ PONG_DEADLINE_MS: 10_000,
321
404
  BACKOFF_MIN_MS: 1_000,
322
405
  BACKOFF_MAX_MS: 30_000,
323
406
  ICE_TIMEOUT_MS: 5_000, // then fall back to relay chunks (FR-7.6)
@@ -482,7 +565,8 @@ export const LAYOUT = {
482
565
  * removed 2026-08-09). The ad tag reports the page URL itself with no override,
483
566
  * so what survives is TIMING: ui/features/ads.js must never load it while the
484
567
  * share key is still in `location.hash`, and waits for keys.clearUrl(). gtag
485
- * takes `page_location` from `pageLocation()` below, which strips it too. !!
568
+ * takes `page_location` from `pageLocation()` below, which strips it too — and
569
+ * strips `?org=` with it, a credential rather than a room name. !!
486
570
  *
487
571
  * Adding an origin means adding it to the CSP in _headers AND every page's meta
488
572
  * tag — app.html's included — which tools/check/site-check.mjs asserts agree.
@@ -570,6 +654,32 @@ export const CONSENT_REGIONS = [
570
654
  * `page_location` is `location.href` — the unmodified tag would send the key to
571
655
  * Google on the first page_view. Every gtag config passes this instead. !!
572
656
  */
657
+ /**
658
+ * `location.search` with the deployment credential taken out.
659
+ *
660
+ * `?org=` admits a device to a self-hosted relay, and it rides in the query
661
+ * because a browser cannot set a header on a WebSocket or an EventSource. That
662
+ * put it in `location.search` — which is exactly what gtag's `page_location`
663
+ * sends, and what AdSense reads off the page for itself. A deployment that
664
+ * turned the token on was handing it to Google on the first page_view.
665
+ *
666
+ * Fails CLOSED: anything unparseable yields no query at all rather than the
667
+ * original string, because the string is the thing under suspicion.
668
+ */
669
+ export function safeSearch(search) {
670
+ const raw = search ?? (typeof location === "undefined" ? "" : location.search);
671
+ if (!raw) return "";
672
+ try {
673
+ const q = new URLSearchParams(raw);
674
+ if (!q.has(ORG_PARAM)) return raw;
675
+ q.delete(ORG_PARAM);
676
+ const rest = q.toString();
677
+ return rest ? `?${rest}` : "";
678
+ } catch {
679
+ return "";
680
+ }
681
+ }
682
+
573
683
  export const pageLocation = () => {
574
684
  if (typeof location === "undefined") return "";
575
685
  /* The desktop webview answers at `tauri.localhost` on Windows and
@@ -580,7 +690,7 @@ export const pageLocation = () => {
580
690
  on-disk name is not the name a report should show. Which surface a hit came
581
691
  from is `rtc_surface`, not the hostname. */
582
692
  if (IS_DESKTOP) return `${SITE.ORIGIN}/app`;
583
- return location.origin + location.pathname + location.search;
693
+ return location.origin + location.pathname + safeSearch();
584
694
  };
585
695
 
586
696
  export const GOOGLE_SRC = {