realtimeclipboard 0.4.0 → 0.5.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.
@@ -5,41 +5,31 @@
5
5
 
6
6
  import { IS_DESKTOP } from "./native.js";
7
7
 
8
- /**
9
- * Relay endpoint.
10
- *
11
- * Must be wss:// in production: the site is served over HTTPS from GitHub
12
- * Pages, and a browser refuses a ws:// connection from an https:// page as
13
- * mixed content. Localhost is exempt from that rule, which is why the dev
14
- * branch can stay ws://.
15
- */
16
- // Guarded: this module is imported by node-based tests where `location` does
17
- // not exist, and a bare reference would throw at import time and take the whole
18
- // graph down.
8
+ // Guarded: imported by node tests where `location` does not exist, and a bare
9
+ // reference would throw at import time and take the whole graph down.
19
10
  const IS_LOCAL = typeof location !== "undefined" &&
20
11
  ["localhost", "127.0.0.1", "[::1]"].includes(location.hostname);
21
12
 
22
- /** The relay this build ships pointed at. Self-hosters change this one line. */
13
+ /**
14
+ * Must be wss:// in production — a browser refuses ws:// from an https:// page
15
+ * as mixed content. Localhost is exempt, which is why dev can stay ws://.
16
+ * Self-hosters change this one line.
17
+ */
23
18
  export const DEFAULT_RELAY_URL = "wss://realtimeclipboard.fastapicloud.dev";
24
19
  const LOCAL_RELAY_URL = "ws://127.0.0.1:8000";
25
20
 
26
21
  /**
27
- * Key under which the chosen relay is remembered.
28
- *
29
- * The storage PREFIX lives here rather than in core/storage.js so that this
30
- * module can read a setting without importing storage.js — which imports this
31
- * one, and a cycle between "every constant" and "the thing that persists them"
32
- * is the sort that works until someone moves a line to module scope.
22
+ * The storage prefix lives here rather than in core/storage.js so this module
23
+ * can read a setting without importing storage.js — which imports this one, and
24
+ * a cycle between "every constant" and "the thing that persists them" works
25
+ * until someone moves a line to module scope.
33
26
  */
34
27
  export const STORAGE_PREFIX = "realtimeclipboard.";
35
28
  const RELAY_KEY = "relayUrl";
36
29
 
37
30
  /**
38
- * Accept only something that can actually be a relay, and normalise it.
39
- *
40
31
  * `http(s)://` is accepted and converted, because that is what a person copies
41
- * out of a browser bar when their IT department gives them an address, and
42
- * rejecting it would be pedantry with a support ticket attached.
32
+ * out of a browser bar when IT gives them an address.
43
33
  */
44
34
  export function normaliseRelay(raw) {
45
35
  if (!raw || typeof raw !== "string") return null;
@@ -59,20 +49,14 @@ const stored = () => {
59
49
  };
60
50
 
61
51
  /**
62
- * `?relay=` in the address, for builds that are deployed rather than visited:
63
- * a corporate MSI transform, a macOS config profile, or a desktop shell that
64
- * launches the webview at its own relay.
65
- *
66
- * It wins over the stored setting on purpose — the deployment is more current
67
- * than whatever the machine remembers — and main.js persists it, so the switch
68
- * survives the next launch without the flag.
52
+ * `?relay=` for builds that are deployed rather than visited: an MSI transform,
53
+ * a config profile, a desktop shell launching the webview at its own relay. It
54
+ * wins over the stored setting because the deployment is more current, and
55
+ * main.js persists it so the switch survives the next launch.
69
56
  *
70
- * !! This is NOT an attack surface on the hosted site, and the reason is worth
71
- * knowing: the CSP pins `connect-src` to this origin and the default relay, so
72
- * a link carrying `?relay=` to anywhere else cannot open a connection at all.
73
- * The override only does anything on a build whose operator also edited that
74
- * meta tag — which is precisely the self-hosting case it exists for. The CSP is
75
- * the enforcement; this is only the plumbing. !!
57
+ * NOT an attack surface on the hosted site: the CSP pins `connect-src` to this
58
+ * origin and the default relay, so a link carrying `?relay=` elsewhere cannot
59
+ * open a connection. The CSP is the enforcement; this is only the plumbing.
76
60
  */
77
61
  const fromQuery = () => {
78
62
  try { return new URLSearchParams(location.search).get("relay"); }
@@ -88,21 +72,17 @@ export const RELAY_URL =
88
72
  export const RELAY_IS_CUSTOM = RELAY_URL !== DEFAULT_RELAY_URL && RELAY_URL !== LOCAL_RELAY_URL;
89
73
 
90
74
  /**
91
- * The same relay over plain HTTP, for the SSE+POST fallback and /stats.
92
- *
93
- * One hostname, two ways in — which is what makes the fallback worth having:
94
- * IT allowlists a single domain (PRD §5.4) and both transports are covered by
95
- * it. Derived rather than written twice so the two can never drift.
75
+ * The same relay over plain HTTP, for the SSE+POST fallback and /stats. One
76
+ * hostname, two ways in, so IT allowlists a single domain (PRD §5.4) and both
77
+ * transports are covered. Derived so the two can never drift.
96
78
  */
97
79
  export const RELAY_HTTP_URL = RELAY_URL.replace(/^ws/i, "http");
98
80
 
99
81
  /**
100
- * How we talk to the relay.
101
- *
102
82
  * ws — WebSocket. Lower latency, one connection, the default.
103
- * sse — Server-Sent Events downstream + fetch POST upstream (PRD §4.3 R3).
104
- * Plain HTTP with no Upgrade, so it survives the TLS-inspecting
105
- * proxies that eat WebSockets on corporate networks (§5.4).
83
+ * sse — Server-Sent Events down + fetch POST up (PRD §4.3 R3). Plain HTTP
84
+ * with no Upgrade, so it survives the TLS-inspecting proxies that eat
85
+ * WebSockets on corporate networks (§5.4).
106
86
  *
107
87
  * Both carry the identical envelopes from §6 — see transport/protocol.js.
108
88
  */
@@ -111,29 +91,15 @@ export const TRANSPORT = { WS: "ws", SSE: "sse" };
111
91
  /**
112
92
  * Clip size, derived — not chosen.
113
93
  *
114
- * A clip does not go on the wire as text. It is UTF-8 encoded, sealed with
115
- * AES-GCM (which appends a 16-byte tag), base64'd, and dropped into a JSON
116
- * envelope — and the relay rejects any frame over MAX_FRAME_BYTES
117
- * (backend/main.py, 32 KB). Base64 alone inflates by a third, so the real
118
- * limit is a BYTE budget on the wire and a character count is only an
119
- * approximation of it.
94
+ * A clip is UTF-8 encoded, sealed with AES-GCM (16-byte tag), base64'd and put
95
+ * in a JSON envelope, and the relay rejects any frame over MAX_FRAME_BYTES. So
96
+ * the real limit is a BYTE budget and a character count only approximates it.
120
97
  *
121
- * This is the same arithmetic files/chunker.js does for RELAY_CHUNK_BYTES, and
122
- * it is here for the same reason: a hand-tuned second number drifts out of
123
- * sync with the relay's cap, and the failure mode is silent.
124
- *
125
- * It had. MAX_CHARS was 50,000 — 66 KB on the wire against a 32 KB frame — and
126
- * the constant cited FR-2.8 while contradicting it, because "max payload 32 KB"
127
- * had been transcribed into a character count once and never recomputed. Every
128
- * clip over ~24 KB was accepted by the editor, encrypted, sent, and dropped by
129
- * the relay, with the rejection arriving as an async `error` frame long after
130
- * the UI had said it went. Deriving the number is what stops that recurring;
131
- * tests/unit/clipsize.mjs is what proves the derivation.
132
- *
133
- * MAX_CHARS is what the counter shows and what the editor guards on, because
134
- * users think in characters and one ASCII character is one byte. MAX_BYTES is
135
- * the truth, and is what the send path enforces: 24,000 CJK characters are
136
- * 72,000 bytes and will not fit, whatever the counter says.
98
+ * MAX_CHARS was once 50,000 — 66 KB on the wire against a 32 KB frame — because
99
+ * "max payload 32 KB" had been transcribed into a character count and never
100
+ * recomputed. Every clip over ~24 KB was accepted, encrypted, sent, and dropped
101
+ * by the relay, the rejection arriving long after the UI said it went.
102
+ * tests/unit/clipsize.mjs proves the derivation.
137
103
  */
138
104
  const RELAY_FRAME_BYTES = 32 * 1024; // backend/main.py MAX_FRAME_BYTES
139
105
  const CLIP_ENVELOPE_BYTES = 512; // {"t","originId","iv","payload"} + slack
@@ -145,27 +111,24 @@ const CLIP_MAX_BYTES = Math.floor(
145
111
  );
146
112
 
147
113
  /**
148
- * Wire size of a string. Exported beside the limit it is measured against so
149
- * the editor and the send path cannot disagree about what "too big" means —
150
- * `String.length` counts UTF-16 units, which is not what the relay counts.
114
+ * Wire size of a string, exported beside the limit it is measured against so the
115
+ * editor and the send path cannot disagree — `String.length` counts UTF-16
116
+ * units, which is not what the relay counts.
151
117
  */
152
118
  export const textBytes = (s) => new TextEncoder().encode(s).length;
153
119
 
154
120
  export const TEXT = {
121
+ // MAX_CHARS is what the counter shows, because users think in characters.
122
+ // MAX_BYTES is the truth: 24,000 CJK characters are 72,000 bytes.
155
123
  MAX_BYTES: CLIP_MAX_BYTES, // the wire limit — authoritative
156
124
  MAX_CHARS: Math.floor(CLIP_MAX_BYTES / 1000) * 1000, // the friendly one, for the counter
157
125
  SUPPRESS_MS: 1500, // loop-suppression window after applying a remote clip (FR-2.6)
158
126
 
159
127
  /**
160
- * Typing is streamed for the far editor to render; a CLIP is still a discrete
161
- * thing, made when the text settles. The split is what keeps history, the OS
162
- * clipboard write, the dedupe and the size cap resting on whole thoughts
163
- * instead of on `h`, `he`, `hel`.
164
- *
165
- * COMMIT_IDLE_MS is generous because the commit is invisible: the text is
166
- * already on the far screen via the stream, so this only decides when it
167
- * becomes a clip. A mid-sentence thinking pause is 300-500 ms, and anything
168
- * under that would fragment one sentence into six history entries.
128
+ * Generous because the commit is invisible — the text is already on the far
129
+ * screen via the stream, so this only decides when it becomes a clip. A
130
+ * mid-sentence pause is 300-500 ms, and less would fragment one sentence into
131
+ * six history entries.
169
132
  */
170
133
  COMMIT_IDLE_MS: 800,
171
134
 
@@ -173,72 +136,87 @@ export const TEXT = {
173
136
  STREAM_THROTTLE_MS: 100,
174
137
 
175
138
  /**
176
- * Above this, typing is not streamed at all and the text syncs on commit only.
139
+ * Above this, typing is not streamed and the text syncs on commit only.
177
140
  *
178
141
  * A stream frame carries the WHOLE text, which is what lets this feature exist
179
- * without diffs, positions or operational transform. That trade only holds
180
- * while the text is small: re-sending 20 KB per keystroke would be 200 KB/s
181
- * through the relay for one person editing a stack trace.
182
- *
183
- * The degradation is invisible because big text is pasted, never typed —
184
- * nobody collaboratively types a stack trace — so the case streaming is for
185
- * (a URL, a token, a sentence) is entirely inside this bound.
142
+ * without diffs or operational transform — a trade that only holds while the
143
+ * text is small. The degradation is invisible because big text is pasted,
144
+ * never typed.
186
145
  */
187
146
  STREAM_MAX_BYTES: 4096,
188
147
 
189
148
  /**
190
- * How long a local copy outranks an arriving clip.
191
- *
192
- * Two devices in Clipboard mode both being used by a human means every copy on
193
- * one overwrites the other's system clipboard, and the moment that actually
194
- * hurts is "I copied something, went to paste it, and it was gone". Inside
149
+ * How long a local copy outranks an arriving clip. The moment that actually
150
+ * hurts is "I copied something, went to paste it, and it was gone", so inside
195
151
  * this window the arriving clip is queued and offered instead of written.
196
152
  */
197
153
  LOCAL_COPY_GRACE_MS: 10_000,
198
154
  };
199
155
 
156
+ /**
157
+ * Pastejacking — see clipboard/guard.js for what is actually checked.
158
+ *
159
+ * The room is joinable by anyone holding the key, and an arriving clip is
160
+ * written to the OS clipboard with no gesture from the person receiving it. So
161
+ * a peer chooses what you paste into your next terminal.
162
+ *
163
+ * `ENABLED` governs only the heuristic that demands a click. Stripping the
164
+ * trailing newline and the invisible characters is unconditional and has no
165
+ * switch: it is not a policy, it is the difference between a clip that pastes
166
+ * and a clip that runs.
167
+ */
168
+ export const PASTE_GUARD = {
169
+ ENABLED: true,
170
+
171
+ /**
172
+ * Past this, do not scan. The patterns are anchored per line, so a pasted
173
+ * logfile is a lot of backtracking for a case that is not what this defends
174
+ * against — nobody is tricked into pasting 200 KB into a shell.
175
+ */
176
+ MAX_SCAN_CHARS: 8_192,
177
+ };
178
+
200
179
  export const FILES = {
201
180
  MAX_BYTES: 5 * 1024 * 1024, // 5 MB per file (FR-7.1)
202
181
  MAX_COUNT: 20, // per session, memory only (FR-7.7)
182
+
183
+ /**
184
+ * A filename is chosen by whoever sent the file, so it is bounded like every
185
+ * other piece of peer-supplied metadata. Long enough that no real name is
186
+ * truncated; short enough that a hostile one is a tile, not a layout.
187
+ * files/registry.js `safeName` applies it, along with the character rules.
188
+ */
189
+ MAX_NAME_CHARS: 120,
203
190
  THUMB_PX: 160, // longest edge (FR-7.2)
204
191
  THUMB_QUALITY: 0.7,
192
+
205
193
  /**
206
- * Chunk size for the P2P data channel, which carries raw binary.
207
- *
208
- * The relay fallback CANNOT use this value directly: a chunk there is
209
- * base64'd inside a JSON frame, and base64 (plus the AES-GCM tag and the
210
- * envelope fields) inflates it by roughly a third — a 32 KB chunk becomes a
211
- * ~44 KB frame and is rejected by the relay's own 32 KB cap. The relay chunk
212
- * size is therefore DERIVED from this, in files/chunker.js as
213
- * RELAY_CHUNK_BYTES, rather than being a second hand-tuned number that could
214
- * drift out of sync with it.
194
+ * For the P2P data channel, which carries raw binary. The relay fallback
195
+ * cannot use this directly — base64 plus the tag and envelope inflate a 32 KB
196
+ * chunk into a ~44 KB frame, past the relay's own 32 KB cap — so
197
+ * RELAY_CHUNK_BYTES is DERIVED from this in files/chunker.js rather than being
198
+ * a second hand-tuned number.
215
199
  */
216
200
  CHUNK_BYTES: 32 * 1024,
217
201
 
218
202
  /**
219
- * How long a file request lives before both ends give up on it.
203
+ * How long a file request lives before BOTH ends give up, on the same number,
204
+ * so neither is left believing in a transfer the other abandoned. Approving
205
+ * into a peer that gave up thirty seconds ago sends 5 MB nowhere.
220
206
  *
221
- * Both ends, deliberately: the holder's prompt counts down to a denial and
222
- * the requester stops waiting, on the same number, so neither is left
223
- * believing in a transfer the other has already abandoned. Approving into a
224
- * peer that gave up thirty seconds ago sends 5 MB nowhere.
225
- *
226
- * Its own constant rather than a multiple of the ICE timeout, which is what
227
- * it used to be. That coupling meant shortening the ICE race in a test also
228
- * shortened how long a human had to answer a dialog, and tuning the network
229
- * silently retuned the UI.
207
+ * Its own constant rather than a multiple of the ICE timeout: that coupling
208
+ * meant shortening the ICE race in a test also shortened how long a human had
209
+ * to answer a dialog.
230
210
  */
231
211
  REQUEST_TIMEOUT_MS: 15_000,
232
212
 
233
213
  /**
234
- * How long the sender waits for a data channel it has closed to confirm the
235
- * close, before dropping the peer connection out from under it.
214
+ * How long a closed data channel gets to confirm the close.
236
215
  *
237
216
  * bufferedAmount reaching zero only means SCTP accepted the bytes, not that
238
217
  * the peer has them. The stream reset dc.close() sends is ordered behind the
239
- * data already queued on that stream, so the close event is the nearest thing
240
- * the transport offers to a delivery signal — worth one round trip. Bounded,
241
- * because a wedged association must not hold a finished transfer open.
218
+ * queued data, so the close event is the nearest thing to a delivery signal.
219
+ * Bounded, because a wedged association must not hold a transfer open.
242
220
  */
243
221
  CHANNEL_CLOSE_MS: 1_000,
244
222
  };
@@ -246,17 +224,32 @@ export const FILES = {
246
224
  export const KEY = {
247
225
  // Crockford-ish: no 0/O, no 1/I/L. Ambiguity here becomes a support ticket.
248
226
  ALPHABET: "23456789ABCDEFGHJKMNPQRSTVWXYZ",
249
- LENGTH: 6, // PRD D3 — the web default
250
- LONG_LENGTH: 10, // "high security" option, PRD §7.3
251
-
252
227
  /**
253
- * Installed builds default to the long key; the web keeps six.
228
+ * Ten, not six. PRD D3 chose six for the phone keyboard and that argument was
229
+ * sound about typing and wrong about arithmetic.
230
+ *
231
+ * The open room hash is derived through the same 250k PBKDF2 as the AES key
232
+ * (crypto.js `deriveOpen`), so a guess costs a full derivation rather than one
233
+ * SHA-256. But `SALT` is a single global constant, so the table an attacker
234
+ * builds is built ONCE and then opens every unlocked session of that length,
235
+ * for every user, for as long as the salt stands. That is the number that
236
+ * matters, and at six characters it is ~12 minutes on 100 rented GPUs:
254
237
  *
255
- * PRD §7.3's argument for six characters is convenience, and the convenience
256
- * is a phone keyboard. An installed app links a device by QR or by a copied
257
- * link, so it is the one surface that does not pay the typing cost the short
258
- * key buys — and it is the surface running unattended all day reading every
259
- * copy, which is the case §7.3 says should take the 10-character option.
238
+ * 6 chars 7.3e8 keys 12 min on 100 GPUs 29.4 bits
239
+ * 10 chars 5.9e14 keys 19 years on 100 GPUs 49.1 bits
240
+ * 16 chars 5.3e23 keys out of reach 78.5 bits
241
+ *
242
+ * Six is therefore not a preference, it is a defect, and no iteration count
243
+ * fixes a keyspace. Typing cost is paid once per session and only when someone
244
+ * declines the QR code and the copied link, both of which are in front of it.
245
+ */
246
+ LENGTH: 10,
247
+ LONG_LENGTH: 16, // "high security" option, PRD §7.3
248
+
249
+ /**
250
+ * An installed app links a device by QR or copied link, so it does not pay the
251
+ * typing cost at all — and it is the surface running all day reading every
252
+ * copy, which is the one that should take the longer option.
260
253
  */
261
254
  DEFAULT_LONG: IS_DESKTOP,
262
255
  };
@@ -267,29 +260,34 @@ export const CRYPTO = {
267
260
  ROOM_HASH_BYTES: 16,
268
261
 
269
262
  /**
270
- * Locked sessions — see LOCK below and core/crypto.js `deriveLocked`.
263
+ * One PBKDF2 run, two outputs — see crypto.js `deriveOpen`.
271
264
  *
272
- * The salt is a PREFIX, completed with the share key: the key is random per
273
- * session, which is exactly what a salt is for. The open-session salt above
274
- * is one global constant, so a single precomputed table covers every user on
275
- * earth; here an attacker has to build one per key.
276
- *
277
- * 600k rather than 250k because the threat is different. An open session's
278
- * secret is a 29-bit key an attacker has to guess; a locked session's secret
279
- * is a PIN held by someone who may ALREADY have the link, so the PIN is the
280
- * whole defence and every doubling of the iteration count is a doubling of
281
- * their cost. It is paid once per session, behind the "unlocking" state.
265
+ * The room hash used to be `SHA-256("realtimeclipboard:" + KEY)`: unsalted,
266
+ * unstretched, and the one value the relay holds by construction. Anyone with
267
+ * it could sweep the whole 6-character keyspace in 0.07s and read back the
268
+ * key, which made the 250k iterations on the AES key worth exactly one
269
+ * derivation to an attacker. Deriving both from the same PRK costs nothing —
270
+ * that PBKDF2 was already being awaited before the connection opened.
282
271
  */
283
- LOCK_SALT: "realtimeclipboard-lock-v1:",
284
- LOCK_ITERATIONS: 600_000,
272
+ OPEN_INFO: {
273
+ AES: "realtimeclipboard/aes",
274
+ ROOM: "realtimeclipboard/room",
275
+ },
285
276
 
286
277
  /**
287
- * HKDF info strings: one PBKDF2 run, three independent outputs.
278
+ * The lock salt is a PREFIX, completed with the share key — random per
279
+ * session, which is what a salt is for. The open-session salt above is one
280
+ * global constant, so a single precomputed table covers every user on earth;
281
+ * KEY.LENGTH is what has to make that table too large to build.
288
282
  *
289
- * Asking PBKDF2 itself for 64 bytes would cost DOUBLE — it reruns the full
290
- * iteration count per 32-byte output block, and OI-8 already flags PBKDF2
291
- * cost on a low-end Android. HKDF expansion is a couple of HMACs.
283
+ * 600k rather than 250k because the threat differs: a locked session's secret
284
+ * is a PIN held by someone who may already have the link, so the PIN is the
285
+ * whole defence and every doubling doubles their cost. Paid once per session.
292
286
  */
287
+ LOCK_SALT: "realtimeclipboard-lock-v1:",
288
+ LOCK_ITERATIONS: 600_000,
289
+
290
+ /** One PBKDF2 run, three independent outputs — see crypto.js `deriveLocked`. */
293
291
  LOCK_INFO: {
294
292
  AES: "realtimeclipboard-lock/aes",
295
293
  ROOM: "realtimeclipboard-lock/room",
@@ -300,66 +298,46 @@ export const CRYPTO = {
300
298
  /**
301
299
  * Locked sessions: the share key in the link, a PIN that never travels with it.
302
300
  *
303
- * The key is a bearer credential (PRD §7.1) and a link is a leaky thing — it
304
- * gets forwarded, screenshotted, pasted into a group chat. A locked session
305
- * adds a second secret that is never in the URL, never on disk, and never sent
306
- * to the relay, so holding the link is not sufficient to read the clipboard.
307
- *
308
- * MIN_PIN is 6 and the PIN is free-form rather than 4-6 digits, because against
309
- * someone who already has the link the key contributes nothing and the PIN is
310
- * the entire secret: a 4-digit PIN is ~13 bits, which is minutes of offline
311
- * guessing. The dialog states the number rather than an adjective.
301
+ * The key is a bearer credential (PRD §7.1) and links get forwarded,
302
+ * screenshotted and pasted into group chats. MIN_PIN is 6 and free-form rather
303
+ * than 4-6 digits because against someone who already has the link the key
304
+ * contributes nothing: a 4-digit PIN is ~13 bits, minutes of offline guessing.
312
305
  */
313
306
  export const LOCK = {
314
307
  MIN_PIN: 6,
315
308
 
316
309
  /**
317
- * Fragment marker: `#!ABCDEF`. That a session is locked is not a secret — the
318
- * PIN is — and the app has to know before it connects, so the flag rides in
319
- * the link. Leading rather than trailing: chat clients that trim punctuation
320
- * off a pasted URL trim the END of it.
310
+ * Fragment marker: `#!ABCDEF`. Leading rather than trailing — chat clients
311
+ * that trim punctuation off a pasted URL trim the END of it.
321
312
  */
322
313
  SIGIL: "!",
323
314
 
324
315
  /**
325
- * Sent once on creating a locked room, retained by the relay as the room's
326
- * last clip and replayed to every joiner — so a joiner can tell "wrong PIN"
327
- * from "first one here" by whether it decrypts. Receivers drop it instead of
328
- * rendering it. See core/crypto.js and the beacon note in main.js.
316
+ * Sent once on creating a locked room and replayed to every joiner, so a
317
+ * joiner can tell "wrong PIN" from "first one here" by whether it decrypts.
318
+ * Receivers drop it instead of rendering it.
329
319
  */
330
320
  BEACON: String.fromCharCode(0) + "realtimeclipboard-lock-v1",
331
321
 
332
322
  /**
333
- * Sent into the room being ABANDONED when the session is locked.
334
- *
335
- * Locking is a room change (see main.js): the lock flag is part of the room
336
- * name, so the locking device leaves for a new, locked room and everyone
337
- * else is simply left behind in the old one — connected, in sync with
338
- * nobody, with no way to tell that from a quiet afternoon. This sentinel is
339
- * the goodbye. A device that decrypts it closes its connection and says what
340
- * happened, which is the difference between being removed and being
341
- * mysteriously alone.
323
+ * Sent into the room being ABANDONED when a session is locked.
342
324
  *
343
- * A clip rather than a new frame type, for the same reason BEACON is one: it
344
- * needs no relay change, so it works against a deployed relay, and it is
345
- * sealed with the room key — the relay forwards a sentinel it cannot read.
325
+ * Locking is a room change: the locking device leaves for a new room and
326
+ * everyone else is left behind, connected, in sync with nobody, unable to tell
327
+ * that from a quiet afternoon. This sentinel is the goodbye.
346
328
  *
347
- * It does overwrite the room's retained last clip (backend `room.last`), and
348
- * that is deliberate: the retained copy is what a late joiner to the dead
349
- * room receives, so they are told the session moved instead of being handed
350
- * a clip from a session they are no longer part of.
329
+ * A clip rather than a new frame type, like BEACON — it needs no relay change
330
+ * and is sealed with the room key, so the relay forwards what it cannot read.
331
+ * It overwrites the room's retained last clip deliberately, so a late joiner
332
+ * to the dead room is told the session moved.
351
333
  */
352
334
  EVICT: String.fromCharCode(0) + "realtimeclipboard-lock-evict-v1",
353
335
 
354
336
  /**
355
- * How long the goodbye gets to leave the machine before the socket is torn
356
- * down under it.
357
- *
358
- * Not paranoia about WebSocket buffering — the SSE fallback batches upstream
359
- * frames into a POST and its close() drops whatever is still queued
360
- * (transport/sse.js), so closing in the same tick would send the sentinel to
361
- * nobody on exactly the network where the fallback is in use. A quarter of a
362
- * second, once, in a flow that is already opening a dialog.
337
+ * How long the goodbye gets to leave before the socket is torn down. Not
338
+ * paranoia about buffering: the SSE fallback batches upstream frames into a
339
+ * POST and its close() drops whatever is queued, so closing in the same tick
340
+ * would send the sentinel to nobody on exactly the network using the fallback.
363
341
  */
364
342
  EVICT_FLUSH_MS: 250,
365
343
  };
@@ -371,25 +349,18 @@ export const NET = {
371
349
  ICE_TIMEOUT_MS: 5_000, // then fall back to relay chunks (FR-7.6)
372
350
 
373
351
  /**
374
- * How long a transport gets to become usable before we give up on it.
375
- *
376
352
  * A blocked WebSocket frequently does NOT fail: an intercepting proxy accepts
377
- * the TCP connection, swallows the Upgrade, and leaves the socket hanging
378
- * with no open, no close and no error — forever. Without this timer the app
379
- * sits on "Connecting…" indefinitely and never tries the fallback, which is
380
- * the exact failure this whole path exists for.
353
+ * the TCP connection, swallows the Upgrade, and leaves the socket hanging with
354
+ * no open, no close and no error. Without this timer the app sits on
355
+ * "Connecting…" forever and never tries the fallback.
381
356
  *
382
- * Generous enough to survive a scale-to-zero cold start (PRD R2), which is
383
- * seconds rather than milliseconds.
357
+ * Generous enough to survive a scale-to-zero cold start (PRD R2).
384
358
  */
385
359
  PROBE_MS: 8_000,
386
360
 
387
361
  /**
388
- * Consecutive attempts that never became usable before switching transport.
389
- *
390
- * Two, not one: a single failure is far more often a cold start or a flaky
391
- * moment than a policy, and switching on it would put users on the slower
392
- * path for no reason. Two failures in a row is a proxy.
362
+ * Two, not one: a single failure is more often a cold start than a policy, and
363
+ * switching on it would put users on the slower path for no reason.
393
364
  */
394
365
  SWITCH_AFTER: 2,
395
366
 
@@ -405,23 +376,18 @@ export const POLL_OPTIONS = { "Off": 0, "500ms": 500, "1s": 1000, "2s": 2000 };
405
376
 
406
377
  /**
407
378
  * How far the sync reaches on THIS device. One ladder, three rungs, each adding
408
- * exactly one thing to the one below it:
379
+ * exactly one thing to the one below:
409
380
  *
410
381
  * off — nothing leaves and nothing arrives. The editor is private again.
411
- * manual — the session syncs in the app window. The OS clipboard is neither
412
- * read nor written; the app is a window onto the room.
413
- * live — the OS clipboard is wired to the room in both directions. What you
414
- * copy anywhere goes out; what arrives is copied here.
382
+ * manual — the session syncs in the app window; the OS clipboard is neither
383
+ * read nor written.
384
+ * live — the OS clipboard is wired to the room in both directions.
415
385
  *
416
- * The room itself is unconditional: every connected device sees every clip in
417
- * the app view whatever rung it is on. The rung only decides how deep into the
418
- * machine the connection goes, which is why the labels name the destination
419
- * (Off / App / Clipboard) rather than a manner of working.
420
- *
421
- * The stored strings are a compatibility surface. "live" and "manual" are on
422
- * disk for every existing user under STORAGE_PREFIX, and relabelling the UI is
423
- * free while renaming these silently resets the one preference where being
424
- * wrong matters most. Change the labels in ui/features/syncMode.js instead.
386
+ * The stored strings are a compatibility surface: "live" and "manual" are on
387
+ * disk for every existing user, and relabelling the UI is free while renaming
388
+ * these silently resets the one preference where being wrong matters most.
389
+ * Change the labels in ui/features/syncMode.js instead — they read
390
+ * Off / App / Clipboard, naming the destination rather than a manner of working.
425
391
  */
426
392
  export const SYNC_MODES = {
427
393
  OFF: "off",
@@ -437,18 +403,12 @@ export const bindsClipboard = (mode) => mode === SYNC_MODES.LIVE;
437
403
  export const sharesSession = (mode) => mode !== SYNC_MODES.OFF;
438
404
 
439
405
  /**
440
- * The project's own addresses: where the code is, and where it asks for help.
441
- *
442
- * Derived from OWNER/NAME rather than written out four times, for the same
443
- * reason RELAY_HTTP_URL is derived from RELAY_URL — a fork, a rename, or a
444
- * moved account is then one edit, and the four links cannot drift into pointing
445
- * at three different projects.
406
+ * Derived from OWNER/NAME rather than written out four times, so a fork, rename
407
+ * or moved account is one edit and the links cannot drift apart.
446
408
  *
447
- * Every link below was checked against a live GitHub on 2026-08-07 and returned
448
- * 200: the repo, the issue chooser, and the sponsors page. Re-check the sponsors
449
- * page if the account ever moves — a donate link that 404s costs more goodwill
450
- * than no donate link. The .github/FUNDING.yml button is the same destination
451
- * and has to move with it.
409
+ * Re-check the sponsors page if the account ever moves — a donate link that 404s
410
+ * costs more goodwill than no donate link, and .github/FUNDING.yml is the same
411
+ * destination and has to move with it.
452
412
  */
453
413
  export const REPO = {
454
414
  OWNER: "akshaynikhare",
@@ -459,34 +419,85 @@ export const LINKS = {
459
419
  REPO: `https://github.com/${REPO.OWNER}/${REPO.NAME}`,
460
420
  ISSUES: `https://github.com/${REPO.OWNER}/${REPO.NAME}/issues`,
461
421
  /**
462
- * The chooser, not a blank issue: "report" that lands on a list of other
463
- * people's bugs asks the user to find the button themselves, and a blank box
464
- * asks them to guess what we need.
465
- *
466
- * `/new/choose` picks between the bug and feature forms in
467
- * .github/ISSUE_TEMPLATE/ — which is also where the "do not paste your share
468
- * key" warning lives, and that warning is the single most valuable thing on
469
- * the page for this product.
422
+ * The chooser, not a blank issue: `/new/choose` picks between the forms in
423
+ * .github/ISSUE_TEMPLATE/, which is where the "do not paste your share key"
424
+ * warning lives — the single most valuable thing on the page for this product.
470
425
  */
471
426
  NEW_ISSUE: `https://github.com/${REPO.OWNER}/${REPO.NAME}/issues/new/choose`,
472
427
  SPONSOR: `https://github.com/sponsors/${REPO.OWNER}`,
473
428
  };
474
429
 
475
- /**
476
- * The app offer in the header — the desktop build and the PWA install, sharing
477
- * one quiet row (ui/features/install.js).
478
- *
479
- * A minute is long enough to read a sentence you did not ask for, finish the
480
- * paste you came here for, and come back to it; short enough that the header is
481
- * the header again by the time you next look up. Only the × is remembered — a
482
- * row that timed out has not been declined, so the offer returns on a later
483
- * visit.
484
- */
485
- export const OFFER = { DISMISS_MS: 60_000 };
486
-
487
430
  export const IMAGES = {
488
431
  /** Clipboard image types we will read and share. */
489
432
  TYPES: ["image/png", "image/jpeg", "image/webp", "image/gif"],
490
433
  /** Named so a received screenshot does not land as "blob" on disk. */
491
434
  NAME_PREFIX: "clipboard-image",
492
435
  };
436
+
437
+ /**
438
+ * Google Analytics and AdSense.
439
+ *
440
+ * Empty by default and every consumer no-ops on empty, so a fork, a local
441
+ * checkout and a self-hosted deploy load no third-party script at all. Filling
442
+ * these in is the switch that turns tracking and ads on for a build.
443
+ *
444
+ * !! These IDs put third-party script in EVERY document, app.html included.
445
+ * That was a deliberate call and it costs the app a security property it used
446
+ * to have — docs/ARCHITECTURE.md §5 and src/ui/features/ads.js record what.
447
+ * Anything running in app.html can read location.hash and the decrypted
448
+ * clipboard in the DOM; contextual ad targeting reads page content by design.
449
+ *
450
+ * Adding an origin here means adding it to the CSP in _headers AND in every
451
+ * page's meta tag, which tools/check/site-check.mjs asserts agree.
452
+ */
453
+ export const GOOGLE = {
454
+ /** GA4 measurement ID, "G-XXXXXXXXXX". Admin → Data streams → your stream. */
455
+ GA4_ID: "G-259V6H3K5M",
456
+ /** AdSense publisher ID, "ca-pub-################". Account → Settings. */
457
+ ADSENSE_CLIENT: "ca-pub-6053041142492498",
458
+ /**
459
+ * Per-unit slot IDs, the 10-digit number AdSense prints as `data-ad-slot`
460
+ * when you create a unit. One per placement, and a unit renders nothing
461
+ * until its own ID is filled in.
462
+ */
463
+ ADSENSE_SLOTS: {
464
+ /** index.html, the 728×90 after "how it works". */
465
+ LEADERBOARD: "7038244690",
466
+ /** index.html, the 300×600 rail beside the FAQ. */
467
+ RAIL: "8299355473",
468
+ /** app.html, below the editor. */
469
+ APP: "6948680552",
470
+ },
471
+ };
472
+
473
+ /** Nothing loads unless the ID that drives it is present. */
474
+ export const analyticsEnabled = () => Boolean(GOOGLE.GA4_ID);
475
+ export const adsEnabled = () => Boolean(GOOGLE.ADSENSE_CLIENT);
476
+
477
+ /**
478
+ * Consent Mode v2 starts denied in these jurisdictions and stays denied until
479
+ * the CMP says otherwise; everywhere else the tags behave normally. EEA, plus
480
+ * the UK and Switzerland.
481
+ */
482
+ export const CONSENT_REGIONS = [
483
+ "AT", "BE", "BG", "CH", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GB",
484
+ "GR", "HR", "HU", "IE", "IS", "IT", "LI", "LT", "LU", "LV", "MT", "NL", "NO",
485
+ "PL", "PT", "RO", "SE", "SI", "SK",
486
+ ];
487
+
488
+ /**
489
+ * The page URL with the fragment removed, for `page_location`.
490
+ *
491
+ * !! app.html carries the share key in `location.hash`, and gtag's default
492
+ * `page_location` is `location.href`. The unmodified tag would therefore send
493
+ * the key to Google on the first page_view — the one thing this project
494
+ * promises never leaves the browser. Every gtag config passes this instead. !!
495
+ */
496
+ export const pageLocation = () =>
497
+ typeof location === "undefined" ? "" : location.origin + location.pathname + location.search;
498
+
499
+ export const GOOGLE_SRC = {
500
+ gtag: (id) => `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`,
501
+ adsense: (client) =>
502
+ `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${encodeURIComponent(client)}`,
503
+ };