flowviant 0.102.2 → 0.104.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +184 -2396
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +34 -3
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/shipMerge.mjs +33 -2
  93. package/bin/lib/standDownExit.mjs +181 -0
  94. package/bin/lib/standDownGate.mjs +48 -0
  95. package/bin/lib/startProject.mjs +279 -0
  96. package/bin/lib/trace.mjs +3 -3
  97. package/bin/lib/turnLock.mjs +68 -0
  98. package/bin/lib/turnProfile.mjs +77 -0
  99. package/bin/lib/uninstall.mjs +55 -45
  100. package/bin/lib/uninstallCopies.mjs +112 -0
  101. package/bin/lib/update.mjs +30 -17
  102. package/bin/lib/uplinkScrub.mjs +265 -0
  103. package/bin/lib/vault.mjs +22 -151
  104. package/bin/lib/vaultDiff.mjs +183 -0
  105. package/bin/lib/versionOrder.mjs +42 -0
  106. package/bin/lib/views.mjs +35 -12
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3037
  109. package/bin/lib/workAdoptCarry.mjs +94 -0
  110. package/bin/lib/workAgentCheck.mjs +278 -0
  111. package/bin/lib/workAgentMerges.mjs +54 -142
  112. package/bin/lib/workAgentPlans.mjs +14 -12
  113. package/bin/lib/workAgentPrecheck.mjs +393 -0
  114. package/bin/lib/workAgentPublish.mjs +177 -0
  115. package/bin/lib/workAgentReview.mjs +8 -666
  116. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  117. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  118. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +92 -897
  121. package/bin/lib/workAttachments.mjs +93 -0
  122. package/bin/lib/workBrain.mjs +53 -0
  123. package/bin/lib/workChildren.mjs +110 -0
  124. package/bin/lib/workCommandAudit.mjs +62 -0
  125. package/bin/lib/workDiffs.mjs +2 -1
  126. package/bin/lib/workNarration.mjs +152 -0
  127. package/bin/lib/workPlaceReports.mjs +426 -0
  128. package/bin/lib/workPlaces.mjs +228 -0
  129. package/bin/lib/workPreviews.mjs +31 -9
  130. package/bin/lib/workProcesses.mjs +4 -5
  131. package/bin/lib/workPullRequests.mjs +52 -126
  132. package/bin/lib/workReportQueue.mjs +254 -0
  133. package/bin/lib/workRetire.mjs +185 -0
  134. package/bin/lib/workSessionGroups.mjs +166 -0
  135. package/bin/lib/workSessionRuntime.mjs +164 -0
  136. package/bin/lib/workSessionTokens.mjs +68 -0
  137. package/bin/lib/workSessionTurns.mjs +892 -0
  138. package/bin/lib/workShip.mjs +9 -29
  139. package/bin/lib/workToolLog.mjs +76 -0
  140. package/package.json +3 -2
  141. package/bin/lib/patch.mjs +0 -363
@@ -30,23 +30,12 @@
30
30
 
31
31
  import { spawn, execFileSync } from 'node:child_process';
32
32
  import { createHash } from 'node:crypto';
33
- import {
34
- readFileSync,
35
- writeFileSync,
36
- renameSync,
37
- existsSync,
38
- mkdirSync,
39
- chmodSync,
40
- openSync,
41
- closeSync,
42
- statSync,
43
- rmSync,
44
- unlinkSync,
45
- } from 'node:fs';
33
+ import { writeFileSync, renameSync, existsSync, mkdirSync, chmodSync, rmSync } from 'node:fs';
46
34
  import { join } from 'node:path';
47
35
  import { homedir, platform, arch } from 'node:os';
48
36
  import { startAuthProxy } from './authproxy.mjs';
49
37
  import { forgetInfraPid, isListening, noteInfraPid, originFor } from './listeners.mjs';
38
+ import { forgetPreviewPid, recordPreviewPid } from './previewRegistry.mjs';
50
39
 
51
40
  // ── cloudflared: pinned, verified, or not fetched at all ───────────────────
52
41
 
@@ -152,265 +141,6 @@ async function ensureCloudflared(log) {
152
141
  */
153
142
  export const TUNNEL_RE = /https:\/\/(?!api\.)[a-z0-9-]+\.trycloudflare\.com(?![a-z0-9.-])/i;
154
143
 
155
- // ── Orphan reaping ─────────────────────────────────────────────────────────
156
- // cloudflared is detached so we can kill its whole group — which also means it
157
- // SURVIVES an ungraceful daemon death (SIGKILL, crash, box sleep), leaving a
158
- // public hostname pointed at a worktree with nobody minding it. We record each
159
- // group's pid + a signature and reap ours at the next start.
160
- //
161
- // The registry is a read-modify-write over one file in a directory that TWO
162
- // daemons can legitimately share (the 0.51.2 instance lock is keyed on a
163
- // CREDENTIAL, so two daemons serving two different projects are fine and both
164
- // write here). It was unlocked, written back when previews were serial. A lost
165
- // entry is precisely the case reaping exists for.
166
-
167
- const FLOWVIANT_DIR = join(homedir(), '.flowviant');
168
- const PREVIEW_REGISTRY = join(FLOWVIANT_DIR, 'previews.json');
169
- const REGISTRY_LOCK = join(FLOWVIANT_DIR, 'previews.lock');
170
- const LOCK_STALE_MS = 15_000;
171
-
172
- /** Best-effort exclusive lock. Returns a release function; on failure returns
173
- * null and the caller proceeds unlocked — losing an entry is bad, but refusing
174
- * to record one at all is worse. */
175
- function acquireLock() {
176
- try {
177
- mkdirSync(FLOWVIANT_DIR, { recursive: true });
178
- } catch {
179
- return null;
180
- }
181
- for (let i = 0; i < 30; i++) {
182
- try {
183
- const fd = openSync(REGISTRY_LOCK, 'wx');
184
- closeSync(fd);
185
- return () => {
186
- try {
187
- unlinkSync(REGISTRY_LOCK);
188
- } catch {
189
- /* already released */
190
- }
191
- };
192
- } catch {
193
- // Held — unless it was left behind by something that died holding it.
194
- try {
195
- if (Date.now() - statSync(REGISTRY_LOCK).mtimeMs > LOCK_STALE_MS) {
196
- unlinkSync(REGISTRY_LOCK);
197
- continue;
198
- }
199
- } catch {
200
- continue;
201
- }
202
- // Spin briefly. This lock is held for one file write.
203
- const until = Date.now() + 20;
204
- while (Date.now() < until) {
205
- /* busy-wait: 20ms, 30 times, then give up entirely */
206
- }
207
- }
208
- }
209
- return null;
210
- }
211
-
212
- function readRegistry() {
213
- try {
214
- const v = JSON.parse(readFileSync(PREVIEW_REGISTRY, 'utf8'));
215
- return Array.isArray(v) ? v : [];
216
- } catch {
217
- return [];
218
- }
219
- }
220
-
221
- /** Atomic: write a sibling temp file and rename over the target, so a reader
222
- * never sees a half-written array. */
223
- function writeRegistry(list) {
224
- try {
225
- mkdirSync(FLOWVIANT_DIR, { recursive: true });
226
- const tmp = `${PREVIEW_REGISTRY}.${process.pid}.tmp`;
227
- writeFileSync(tmp, JSON.stringify(list));
228
- renameSync(tmp, PREVIEW_REGISTRY);
229
- } catch {
230
- /* best-effort */
231
- }
232
- }
233
-
234
- function mutateRegistry(fn) {
235
- const release = acquireLock();
236
- try {
237
- writeRegistry(fn(readRegistry()));
238
- } finally {
239
- release?.();
240
- }
241
- }
242
-
243
- /**
244
- * WHEN a process started, as the kernel reports it — or null when this box
245
- * will not say. Paired with a pid it names ONE process: a pid alone is
246
- * recycled, and the owner check below trusted signal 0 on a bare pid.
247
- */
248
- export function processStartOf(pid) {
249
- if (!Number.isInteger(pid) || pid <= 0) return null;
250
- if (platform() === 'linux') {
251
- try {
252
- const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
253
- // Field 22 (starttime), counted after the LAST ')' — the comm field in
254
- // parentheses may itself hold spaces and parentheses.
255
- const rest = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
256
- return rest[19] ? `l:${rest[19]}` : null;
257
- } catch {
258
- return null;
259
- }
260
- }
261
- if (platform() === 'darwin') {
262
- try {
263
- const out = execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], {
264
- encoding: 'utf8',
265
- timeout: 5_000,
266
- stdio: ['ignore', 'pipe', 'ignore'],
267
- }).trim();
268
- return out ? `d:${out}` : null;
269
- } catch {
270
- return null;
271
- }
272
- }
273
- return null;
274
- }
275
-
276
- /** Signal-0 liveness (EPERM = alive and not ours), for the OWNER check below. */
277
- function processAlive(pid) {
278
- if (!Number.isInteger(pid) || pid <= 0) return false;
279
- try {
280
- process.kill(pid, 0);
281
- return true;
282
- } catch (e) {
283
- return e.code === 'EPERM';
284
- }
285
- }
286
-
287
- function recordPreviewPid(pid, sig) {
288
- if (!pid) return;
289
- // `owner` is the DAEMON that spawned it. The registry is shared by design —
290
- // two daemons serving two projects both write here — so without an owner a
291
- // starting daemon reaped its PEER's live tunnels: killed them, wiped their
292
- // entries, and the peer kept heartbeating a URL that 530s (its probe watches
293
- // the origin port, which was still alive).
294
- //
295
- // `ownerStart` pins the owner to ONE process (audit 2026-09-24). A daemon that
296
- // is SIGKILLed with a share open leaves cloudflared up; if its pid is reused
297
- // by the next start — any process, another user's included, since EPERM
298
- // counts as alive — a pid-only check read the entry as a live peer's and
299
- // skipped it forever, leaving a public hostname aimed at a dead gate's
300
- // ephemeral port for anything that binds it next.
301
- const ownerStart = processStartOf(process.pid);
302
- mutateRegistry((list) => [...list, { pid, sig, owner: process.pid, ...(ownerStart ? { ownerStart } : {}) }]);
303
- }
304
-
305
- function forgetPreviewPid(pid) {
306
- if (!pid) return;
307
- mutateRegistry((list) => list.filter((e) => e.pid !== pid));
308
- }
309
-
310
- /**
311
- * Only kill a pid we can VERIFY is still one of ours — its command line must
312
- * still contain the signature we stored. A reused pid belonging to something
313
- * unrelated won't match, so we never kill a stranger.
314
- *
315
- * MACOS READS IT THROUGH `ps`, because "Linux-only, elsewhere just clear the
316
- * registry" was the worst of both. A macOS daemon killed ungracefully leaves
317
- * cloudflared running — it is spawned detached — so the public hostname keeps
318
- * resolving to this machine while the gate dies with the daemon. The reaper
319
- * then verified nothing, killed nothing, and DELETED the entry, so no later run
320
- * could ever find that process. The tunnel served 502 until anything else on
321
- * the box bound the gate's old port, which `startAuthProxy` obtained with
322
- * `listen(0)` and is therefore squarely inside the kernel's ephemeral reuse
323
- * pool — at which point a live public hostname forwarded straight to an
324
- * unrelated local service with no gate, no password and no grant check. Exactly
325
- * what this file's header says the reap exists to prevent.
326
- *
327
- * Three states, not two: `true` (ours), `false` (verified NOT ours, or gone),
328
- * and `null` (we could not look — the caller keeps the record rather than
329
- * dropping it).
330
- */
331
- function stillOurs(pid, sig) {
332
- if (typeof sig !== 'string' || sig.length === 0) return false;
333
- if (platform() === 'linux') {
334
- try {
335
- const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
336
- return cmd.includes(sig);
337
- } catch {
338
- return false; // process gone / unreadable
339
- }
340
- }
341
- if (platform() === 'darwin') {
342
- try {
343
- const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
344
- encoding: 'utf8',
345
- timeout: 5_000,
346
- });
347
- return cmd.includes(sig);
348
- } catch {
349
- // `ps` exits non-zero when the pid is gone — which is a real answer.
350
- return false;
351
- }
352
- }
353
- // Windows: no way to check from here. UNKNOWN, never "not ours" — see the
354
- // caller, which keeps the record so a later run on a platform that can look
355
- // is still able to.
356
- return null;
357
- }
358
-
359
- /** Reap tunnel process groups left behind by a previously-crashed daemon.
360
- * Call once at daemon startup, before any work begins.
361
- *
362
- * ORPHANS ONLY: an entry whose owning daemon is STILL ALIVE belongs to a
363
- * peer serving another project (or to the process we are replacing, whose
364
- * own teardown handles it) — killing those and wiping their entries was a
365
- * peer daemon's startup silently breaking every live share on the box. Only
366
- * the entries this pass handled are removed; a peer's records survive. */
367
- /** Is the daemon that recorded an entry still the process at that pid? With
368
- * a recorded start time, only when the pid's start time still matches (a
369
- * recycled pid is an orphaned entry); where this box cannot report a start
370
- * time now, or the entry predates the field, signal 0 is all there is. */
371
- function ownerStillRunning(owner, ownerStart) {
372
- if (!processAlive(owner)) return false;
373
- if (typeof ownerStart !== 'string') return true;
374
- const now = processStartOf(owner);
375
- return now == null ? true : now === ownerStart;
376
- }
377
-
378
- export function reapOrphanPreviews(log) {
379
- const list = readRegistry();
380
- if (list.length === 0) return;
381
- let killed = 0;
382
- const handled = new Set();
383
- for (const { pid, sig, owner, ownerStart } of list) {
384
- if (Number.isInteger(owner) && owner !== process.pid && ownerStillRunning(owner, ownerStart)) continue;
385
- const ours = stillOurs(pid, sig);
386
- /**
387
- * THE RECORD OUTLIVES A REAP THAT COULD NOT LOOK. `handled.add` ran BEFORE
388
- * this check, so an entry was dropped whether or not anything was killed —
389
- * and on any platform `stillOurs` could not read, that deleted the only
390
- * trace of a tunnel still serving the public internet. Kept on `null`
391
- * (unknown); removed on `true` (we killed it) and on `false` (the process
392
- * is gone, or the pid now belongs to somebody else and the entry is stale
393
- * either way).
394
- */
395
- if (ours === null) continue;
396
- handled.add(pid);
397
- if (!ours) continue;
398
- try {
399
- process.kill(-pid, 'SIGKILL'); // whole group
400
- killed++;
401
- } catch {
402
- try {
403
- process.kill(pid, 'SIGKILL');
404
- killed++;
405
- } catch {
406
- /* already gone */
407
- }
408
- }
409
- }
410
- if (handled.size) mutateRegistry((cur) => cur.filter((e) => !handled.has(e.pid)));
411
- if (killed) log?.(`reaped ${killed} orphaned preview tunnel${killed === 1 ? '' : 's'} from a previous run.`);
412
- }
413
-
414
144
  // ── The one thing this file does ───────────────────────────────────────────
415
145
 
416
146
  /** How long we wait for cloudflared to hand us a hostname. */
@@ -0,0 +1,112 @@
1
+ /**
2
+ * THE FRAME IS THE APP'S AND ONLY THE APP'S — the preview gate's frame rules,
3
+ * as pure functions over headers. Split out of `authproxy.mjs` (SOLID SRP,
4
+ * 2026-09-26): which browser may frame a share, and with which cookie
5
+ * attribute, changes for browser-engine reasons that have nothing to do with
6
+ * how the gate supervises sockets, so it gets one home the HTTP path imports
7
+ * and a test that reads headers in and headers out with no server running.
8
+ *
9
+ * Two rules live here (header rules 4a and 4b in `authproxy.mjs`):
10
+ *
11
+ * (a) THE COOKIE. `SameSite=Lax` is kept for a top-level visit — it is the
12
+ * CSRF boundary and works in every browser ever shipped. A FRAMED
13
+ * navigation (`Sec-Fetch-Dest: iframe`) instead gets `SameSite=None;
14
+ * Partitioned` (CHIPS): partitioning keys the cookie to the top-level
15
+ * site that framed it, so evil.com framing this hostname gets its OWN
16
+ * empty jar, never the viewer's grant — which is why None here is not
17
+ * the CSRF hole it would be unpartitioned. Branching on Sec-Fetch-Dest
18
+ * rather than always sending both attributes matters: Safari 18.5–26.1
19
+ * DROPPED any Set-Cookie carrying `Partitioned` outright, so stamping it
20
+ * unconditionally would break the working top-level path on those
21
+ * builds. A browser too old to send Sec-Fetch-Dest gets Lax and the
22
+ * frame fails exactly as it always did — Open remains the escape. The
23
+ * backstop for the browsers that ignore `Partitioned` is
24
+ * `crossSiteAbuse` in `previewGatePolicy.mjs`.
25
+ * (b) THE FRAME POLICY. In grant mode the forwarded response's frame policy
26
+ * is REWRITTEN to `frame-ancestors 'self' <app origin>` (origin taken
27
+ * from `authorizeUrl`, the one app fact this gate already holds). That
28
+ * permits exactly ONE cross-origin framer — the app the viewer is
29
+ * already authenticated to — instead of switching clickjacking
30
+ * protection off for everyone; enforced `frame-ancestors` outranks
31
+ * `X-Frame-Options` in every current engine, and the stripped XFO is
32
+ * the belt-and-braces for the rest. Password-only mode rewrites
33
+ * NOTHING: there is no app origin to allow and no cookie that could
34
+ * authenticate a frame, so the origin's own policy stands verbatim.
35
+ */
36
+
37
+ /** The one app fact this gate holds: the origin allowed to frame us. Derived
38
+ * from `authorizeUrl` (server-built from PUBLIC_APP_URL) rather than a new
39
+ * wire field, so an older server changes nothing and no floor is needed.
40
+ * Null when the gate runs password-only (`grants` false) or the URL does not
41
+ * parse — and a null origin rewrites nothing. */
42
+ export function appOriginOf(grants, authorizeUrl) {
43
+ let appOrigin = null;
44
+ try {
45
+ if (grants) appOrigin = new URL(authorizeUrl).origin;
46
+ } catch {
47
+ appOrigin = null;
48
+ }
49
+ return appOrigin;
50
+ }
51
+
52
+ /** FRAMED means Partitioned (rule (a) above). Sec-Fetch-Dest survives the
53
+ * whole redirect chain (it describes the navigation, not the hop), so the
54
+ * callback sees `iframe` exactly when the Workbench is the one asking. */
55
+ export function grantCookieSite(secFetchDest) {
56
+ const dest = String(secFetchDest || '').toLowerCase();
57
+ const framed = dest === 'iframe' || dest === 'frame' || dest === 'embed' || dest === 'object';
58
+ return framed ? 'SameSite=None; Partitioned' : 'SameSite=Lax';
59
+ }
60
+
61
+ /**
62
+ * Replace the origin's frame policy with ours: `frame-ancestors 'self'
63
+ * <app origin>`. Rules that must survive any edit:
64
+ * - REWRITE the directive inside an existing enforced CSP, never append a
65
+ * second policy beside it — multiple CSP headers intersect, so an origin
66
+ * `frame-ancestors 'none'` would still win over anything we added.
67
+ * - APPEND a policy holding only our directive when the origin stated no
68
+ * frame-ancestors at all — a public tunnel hostname deserves a frame
69
+ * policy even when localhost never needed one.
70
+ * - LEAVE Report-Only alone: browsers ignore frame-ancestors there, and
71
+ * rewriting a report channel would be editing the driver's telemetry.
72
+ * - DELETE X-Frame-Options: an enforced frame-ancestors makes every current
73
+ * engine ignore it anyway; deleting is for the stragglers.
74
+ */
75
+ export function rewriteFramePolicy(headers, appOrigin) {
76
+ if (!appOrigin) return headers;
77
+ const framePolicy = () => `frame-ancestors 'self' ${appOrigin}`;
78
+ const out = { ...headers };
79
+ delete out['x-frame-options'];
80
+ let sawDirective = false;
81
+ // Node joins duplicate response headers with ', ', so one string can hold
82
+ // SEVERAL policies (comma-separated), each holding several directives
83
+ // (semicolon-separated). Split on BOTH levels or replacing a directive
84
+ // eats the tail of a neighbouring policy — CSP source lists never contain
85
+ // a comma, so the outer split is safe.
86
+ const rewriteOne = (v) =>
87
+ String(v)
88
+ .split(',')
89
+ .map((policy) =>
90
+ policy
91
+ .split(';')
92
+ .map((part) => {
93
+ if (/^\s*frame-ancestors(\s|$)/i.test(part)) {
94
+ sawDirective = true;
95
+ return ` ${framePolicy()}`;
96
+ }
97
+ return part;
98
+ })
99
+ .join(';')
100
+ )
101
+ .join(',');
102
+ const csp = out['content-security-policy'];
103
+ if (csp !== undefined) {
104
+ out['content-security-policy'] = Array.isArray(csp) ? csp.map(rewriteOne) : rewriteOne(csp);
105
+ }
106
+ if (!sawDirective) {
107
+ const existing = out['content-security-policy'];
108
+ if (existing === undefined) out['content-security-policy'] = framePolicy();
109
+ else out['content-security-policy'] = Array.isArray(existing) ? [...existing, framePolicy()] : [existing, framePolicy()];
110
+ }
111
+ return out;
112
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * WHO GETS THROUGH THE PREVIEW GATE — the credential, abuse and cross-site
3
+ * decisions, with no server in sight. Split out of `authproxy.mjs` (SOLID SRP,
4
+ * 2026-09-26): these are security rules that change on their own schedule
5
+ * (a threshold, a Fetch Metadata shape, a credential class), and the HTTP
6
+ * request path and the WebSocket upgrade path must ask them IN THE SAME ORDER.
7
+ * Before the split each transport spelled the order out by hand; now both ask
8
+ * `admit(req)` and differ only in how they write the answer onto the wire
9
+ * (a response vs raw bytes on an upgrade socket).
10
+ *
11
+ * `authproxy.mjs` still owns the server, the forwarding and the sockets; it
12
+ * builds one `createGatePolicy` per share, which holds that share's failure
13
+ * counts and the last expired grant seen.
14
+ *
15
+ * Two things this file gets wrong easily, both fixed here and both worth
16
+ * keeping fixed:
17
+ * - the comparison is over a secret, so it is constant-time over a digest
18
+ * rather than `===` over a string (`sameSecret`, whose one home is
19
+ * `grant.mjs`).
20
+ * - ONLY a wrong Basic password counts as an attempt (see `credential`).
21
+ */
22
+
23
+ import { GRANT_COOKIE, cookieValues, sameSecret, verifyGrant } from './grant.mjs';
24
+
25
+ /** Wrong-password attempts from ONE source before that source's Basic
26
+ * attempts are refused outright. A quick tunnel's hostname is unguessable,
27
+ * so this is not the primary control — but it is KNOWN to every past
28
+ * member, tester and password recipient, whose access cannot be recalled.
29
+ * The threshold is per source because one global counter made 25 wrong
30
+ * guesses from any URL holder a kill switch on everyone else's share. */
31
+ export const MAX_FAILED = 25;
32
+
33
+ /** Counted failures across ALL sources before the whole share tears down.
34
+ * The per-source block above answers a single abuser; this answers a
35
+ * DISTRIBUTED guessing run (addresses rotating to stay under MAX_FAILED
36
+ * each) — the original self-closing-incident property, kept at a threshold
37
+ * ordinary use cannot reach: a blocked source stops counting, so getting
38
+ * here takes eight independent sources each burning their full allowance. */
39
+ export const MAX_FAILED_TOTAL = 200;
40
+
41
+ /** Bound on the per-source map — a rotating attacker must not grow daemon
42
+ * memory without limit. Eviction is oldest-first and CAN un-block an evicted
43
+ * source, but cycling 500 fresh sources costs at least 500 counted failures,
44
+ * and the global backstop closes the share long before that. */
45
+ export const MAX_SOURCES = 500;
46
+
47
+ /** An Authorization header in the Basic scheme — the only shape the gate's own
48
+ * password ever arrives in, and so the only one it grades. */
49
+ export const isBasic = (v) => typeof v === 'string' && /^\s*basic(\s|$)/i.test(v);
50
+
51
+ /** cloudflared forwards the real client address in Cf-Connecting-Ip; a
52
+ * direct local connection (the machine's own curl, the tests) has only the
53
+ * socket. The header is attacker-writable in principle, but lying in it
54
+ * only SPREADS one attacker across per-source counters — which is exactly
55
+ * the shape MAX_FAILED_TOTAL exists to answer. */
56
+ export const sourceOf = (req) =>
57
+ String(req.headers['cf-connecting-ip'] || req.socket?.remoteAddress || 'unknown');
58
+
59
+ /**
60
+ * The CSRF backstop for the partitioned cookie (header rule 4a): a browser
61
+ * old enough to ignore `Partitioned` while honouring `SameSite=None` will
62
+ * attach the grant to cross-site requests, so a request that FETCH METADATA
63
+ * says is cross-site is refused unless it is a plain navigation GET/HEAD —
64
+ * the two shapes this product legitimately serves cross-site (the Open
65
+ * link, the Workbench frame's own src; a rendered attacker frame is then
66
+ * stopped by the frame-ancestors rewrite). Applies ONLY to grant-cookie
67
+ * auth: a password in an Authorization header is never attached cross-site
68
+ * by a browser, and clients that send no Sec-Fetch headers never held a
69
+ * None cookie, so absence stays permitted.
70
+ *
71
+ * Why a backstop at all: Chromium 76–113 (and same-vintage webviews/forks)
72
+ * sends Sec-Fetch-Dest — so it takes the framed-cookie branch — while
73
+ * ignoring the `Partitioned` attribute it has never heard of, storing a PLAIN
74
+ * unpartitioned SameSite=None cookie. Every browser in that class sends
75
+ * Sec-Fetch-Site (it shipped alongside Dest), and a browser sending neither
76
+ * never got a None cookie in the first place.
77
+ */
78
+ export const crossSiteAbuse = (req) => {
79
+ if (String(req.headers['sec-fetch-site'] || '').toLowerCase() !== 'cross-site') return false;
80
+ const mode = String(req.headers['sec-fetch-mode'] || '').toLowerCase();
81
+ // Cross-site fetch/XHR/img/script with the cookie — nothing legitimate
82
+ // looks like this: the framed app's own subresources are same-origin.
83
+ if (mode && mode !== 'navigate') return true;
84
+ // A cross-site POST navigation is the classic auto-submitted CSRF form.
85
+ return !(req.method === 'GET' || req.method === 'HEAD');
86
+ };
87
+
88
+ /** A top-level browser navigation, and nothing else. A 302 is re-issued as
89
+ * GET and drops the body, so bouncing a POST would turn an unauthenticated
90
+ * write into a mystery GET; and this is what keeps curl and native clients
91
+ * on the password path. */
92
+ export const isBrowserNav = (req) =>
93
+ (req.method === 'GET' || req.method === 'HEAD') &&
94
+ /text\/html/.test(req.headers.accept || '');
95
+
96
+ /**
97
+ * One share's gate policy. `grants` says whether the grant door is installed
98
+ * (all three of secret, share id and authorize URL arrived); `expected` is the
99
+ * full `Basic …` header the automation password produces.
100
+ *
101
+ * `onAbuse` fires once, after MAX_FAILED_TOTAL rejected attempts across all
102
+ * sources, so the caller can tear the whole share down rather than leaving a
103
+ * URL under attack. A single source is blocked on its own, at MAX_FAILED,
104
+ * without ending anybody else's share.
105
+ */
106
+ export function createGatePolicy({ grants, grantSecret, shareId, expected, log, onAbuse }) {
107
+ let failedTotal = 0;
108
+ let abused = false;
109
+ /** source → wrong-password count, insertion-ordered so eviction below is
110
+ * oldest-first. A source at MAX_FAILED is BLOCKED: its Basic attempts are
111
+ * refused BEFORE the comparison and stop counting toward the total. */
112
+ const failedBySource = new Map();
113
+ /** The payload of the last validly-signed but EXPIRED grant seen, so a
114
+ * tester can be re-bounced with the token inside it. */
115
+ let lastExpired = null;
116
+
117
+ const sourceBlocked = (req) => (failedBySource.get(sourceOf(req)) ?? 0) >= MAX_FAILED;
118
+
119
+ const noteFailure = (req) => {
120
+ const src = sourceOf(req);
121
+ const count = (failedBySource.get(src) ?? 0) + 1;
122
+ // Delete-then-set so Map insertion order tracks recency, making the
123
+ // eviction below an LRU rather than "whoever failed first".
124
+ failedBySource.delete(src);
125
+ failedBySource.set(src, count);
126
+ if (failedBySource.size > MAX_SOURCES) {
127
+ failedBySource.delete(failedBySource.keys().next().value);
128
+ }
129
+ if (count === MAX_FAILED) {
130
+ log?.(`preview gate: ${count} failed attempts from ${src} — refusing that source.`);
131
+ }
132
+ // MONOTONE, never reset by a success: a distributed run has no successes
133
+ // to hide behind, and a legitimate share cannot reach the backstop —
134
+ // every source stops counting at MAX_FAILED, so 200 needs eight distinct
135
+ // sources each exhausting their own allowance.
136
+ failedTotal += 1;
137
+ if (failedTotal >= MAX_FAILED_TOTAL && !abused) {
138
+ abused = true;
139
+ log?.(`preview gate: ${failedTotal} failed attempts across sources — closing the share.`);
140
+ try {
141
+ onAbuse?.();
142
+ } catch {
143
+ /* the caller's teardown is best-effort */
144
+ }
145
+ }
146
+ };
147
+
148
+ /**
149
+ * A PURE CREDENTIAL PREDICATE — no method, no Accept, no path. That is what
150
+ * lets the websocket upgrade handler reuse it verbatim, and it is why routing
151
+ * decisions live in the request handler instead.
152
+ *
153
+ * Tristate-plus: 'ok' | 'none' | 'expired' | 'forged' | 'badpass' | 'blocked'.
154
+ */
155
+ const credential = (req) => {
156
+ if (abused) return 'badpass';
157
+ // ONLY A WRONG PASSWORD COUNTS AS AN ATTEMPT, and this is a reason rather
158
+ // than a preference. A forged HMAC is not brute-forceable, so counting it
159
+ // buys nothing — while counting it would hand any stranger who finds the
160
+ // hostname a kill switch on the owner's share, because onAbuse tears the
161
+ // whole thing down. An expired-but-validly-signed grant must never count
162
+ // either, or a viewer who left a tab open overnight closes the share on
163
+ // their own reload.
164
+ //
165
+ // AND ONLY A *BASIC* HEADER IS A PASSWORD ATTEMPT (audit 2026-09-24). Any
166
+ // Authorization header used to be graded against the gate password, so a
167
+ // previewed SPA calling its own `/api` with `Authorization: Bearer …` was
168
+ // answered 401 with a Basic challenge — over a valid grant cookie — and 25
169
+ // such calls blocked that viewer, eight viewers tore the share down, and
170
+ // the origin never saw its own header. A Bearer (or any other scheme) is
171
+ // the APP's credential: it is not ours to grade and it rides through.
172
+ //
173
+ // A VALID GRANT COOKIE IS ASKED FIRST, for the same reason: an app that
174
+ // itself speaks Basic sends a header that is not our password, and a
175
+ // viewer the app has vouched for must not be graded on it. A grant is
176
+ // HMAC-verified, so asking it first opens no brute-force path.
177
+ let grantVerdict = 'none';
178
+ if (grants) {
179
+ // EVERY value for our name, not the first — a duplicate must not shadow.
180
+ for (const raw of cookieValues(req.headers.cookie, GRANT_COOKIE)) {
181
+ const r = verifyGrant(raw, { secret: grantSecret, shareId });
182
+ if (r.ok) return 'ok';
183
+ if (r.reason === 'exp' && grantVerdict === 'none') {
184
+ lastExpired = r.payload;
185
+ grantVerdict = 'expired';
186
+ }
187
+ }
188
+ if (grantVerdict === 'none' && String(req.headers.cookie ?? '').includes(GRANT_COOKIE)) {
189
+ grantVerdict = 'forged';
190
+ }
191
+ }
192
+
193
+ if (isBasic(req.headers['authorization'])) {
194
+ // A blocked source is refused BEFORE the comparison — a block that
195
+ // still grades guesses would let the brute force run to a correct hit.
196
+ if (sourceBlocked(req)) return 'blocked';
197
+ if (sameSecret(req.headers['authorization'], expected)) {
198
+ // Per-source only: a shared NAT recovers when one person behind it
199
+ // gets the password right; failedTotal stays monotone (see above).
200
+ failedBySource.delete(sourceOf(req));
201
+ return 'ok';
202
+ }
203
+ noteFailure(req);
204
+ return 'badpass';
205
+ }
206
+ return grantVerdict;
207
+ };
208
+
209
+ /** True when THIS request authenticated with the password rather than the
210
+ * cookie — the credential class CSRF cannot ride. */
211
+ const viaPassword = (req) => sameSecret(req.headers['authorization'], expected);
212
+
213
+ /**
214
+ * THE ONE ADMISSION ORDER both transports ask: the credential first, then —
215
+ * only for a caller who HAS one — the cross-site backstop. The grant is a
216
+ * cookie, and a cookie can be ridden (header rule 4a's backstop); a
217
+ * refusal there is never a bounce, because the caller has a credential and
218
+ * the request SHAPE is what is refused.
219
+ *
220
+ * Returns the credential verdict ('blocked', 'none', 'expired', 'forged',
221
+ * 'badpass'), 'cross-site', or 'ok'.
222
+ */
223
+ const admit = (req) => {
224
+ const verdict = credential(req);
225
+ if (verdict !== 'ok') return verdict;
226
+ if (grants && crossSiteAbuse(req) && !viaPassword(req)) return 'cross-site';
227
+ return 'ok';
228
+ };
229
+
230
+ return {
231
+ admit,
232
+ viaPassword,
233
+ /** The payload of the last validly-signed but expired grant seen. */
234
+ lastExpired: () => lastExpired,
235
+ };
236
+ }