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
@@ -36,41 +36,23 @@
36
36
  * old "cannot be embedded, the Workbench must not try" rule deliberately —
37
37
  * the Workbench now DOES frame the share, so both halves are re-argued
38
38
  * here). Two changes, one per blocker:
39
- * (a) THE COOKIE. `SameSite=Lax` is kept for a top-level visit — it is the
40
- * CSRF boundary and works in every browser ever shipped. A FRAMED
41
- * navigation (`Sec-Fetch-Dest: iframe`) instead gets `SameSite=None;
42
- * Partitioned` (CHIPS): partitioning keys the cookie to the top-level
43
- * site that framed it, so evil.com framing this hostname gets its OWN
44
- * empty jar, never the viewer's grant — which is why None here is not
45
- * the CSRF hole it would be unpartitioned. Branching on Sec-Fetch-Dest
46
- * rather than always sending both attributes matters: Safari 18.5–26.1
47
- * DROPPED any Set-Cookie carrying `Partitioned` outright, so stamping it
48
- * unconditionally would have broken the working top-level path on those
49
- * builds. A browser too old to send Sec-Fetch-Dest gets Lax and the
50
- * frame fails exactly as it always did — Open remains the escape.
51
- * AND THE PARTITIONING CLAIM HAS A BACKSTOP, because one browser class
52
- * breaks it: Chromium 76–113 (and same-vintage webviews/forks) sends
53
- * Sec-Fetch-Dest — so it takes this branch — while ignoring the
54
- * `Partitioned` attribute it has never heard of, storing a PLAIN
55
- * unpartitioned SameSite=None cookie: the exact CSRF hole the sentence
56
- * above says partitioning prevents. So `crossSiteAbuse` refuses a
57
- * GRANT-authenticated request that Fetch Metadata marks cross-site
58
- * unless it is a top-level GET/HEAD navigation (the Open link, the
59
- * frame's own src). Every browser in the vulnerable class sends
60
- * Sec-Fetch-Site (it shipped alongside Dest), a browser sending
61
- * neither never got a None cookie in the first place, and the password
62
- * path is exempt — Basic auth is never attached cross-site by a
63
- * browser, so curl and Playwright feel nothing.
39
+ * (a) THE COOKIE. A top-level visit's grant cookie stays `SameSite=Lax`;
40
+ * a framed navigation gets a partitioned one. The attribute and its
41
+ * argument (CHIPS, the Safari Partitioned drop) live in
42
+ * `previewFramePolicy.mjs` (`grantCookieSite`); its CSRF backstop is
43
+ * `crossSiteAbuse` in `previewGatePolicy.mjs`. What stays here is only
44
+ * the server's own callback, which asks `grantCookieSite` with the
45
+ * request's `Sec-Fetch-Dest` when it sets the cookie.
64
46
  * (b) THE FRAME POLICY. In grant mode the forwarded response's frame policy
65
- * is REWRITTEN to `frame-ancestors 'self' <app origin>` (origin taken
66
- * from `authorizeUrl`, the one app fact this gate already holds). That
67
- * permits exactly ONE cross-origin framer — the app the viewer is
68
- * already authenticated to — instead of switching clickjacking
69
- * protection off for everyone; enforced `frame-ancestors` outranks
70
- * `X-Frame-Options` in every current engine, and the stripped XFO is
71
- * the belt-and-braces for the rest. Password-only mode rewrites
72
- * NOTHING: there is no app origin to allow and no cookie that could
73
- * authenticate a frame, so the origin's own policy stands verbatim.
47
+ * is REWRITTEN to `frame-ancestors 'self' <app origin>`; password-only
48
+ * mode rewrites NOTHING. The rule and its argument live in
49
+ * `previewFramePolicy.mjs` (`rewriteFramePolicy`).
50
+ *
51
+ * WHO GETS IN — the credential predicate, the per-source and global failure
52
+ * counts, the cross-site backstop and the admission order both transports ask
53
+ * — is `previewGatePolicy.mjs` (split out 2026-09-26, SOLID SRP). This file is
54
+ * the server: it answers the callback, forwards HTTP, pipes upgrades and owns
55
+ * every socket's lifetime.
74
56
  *
75
57
  * Three things this file gets wrong easily, all of them fixed here and all of
76
58
  * them worth keeping fixed:
@@ -78,7 +60,8 @@
78
60
  * `authorization` verbatim, handing the gate password to whatever code the
79
61
  * branch happens to be running. It is stripped now.
80
62
  * - the comparison is over a secret, so it is constant-time over a digest
81
- * rather than `===` over a string.
63
+ * rather than `===` over a string (`sameSecret`, `grant.mjs`, asked by
64
+ * `previewGatePolicy.mjs`).
82
65
  * - the grant cookie must be stripped from the forwarded request too, and
83
66
  * ONLY ours: deleting the whole `cookie` header breaks the driver's own app,
84
67
  * which legitimately owns its session cookies.
@@ -89,46 +72,10 @@
89
72
  */
90
73
 
91
74
  import { createServer, request } from 'node:http';
92
- import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
93
- import { GRANT_COOKIE, cookieValues, safePathname, safeRelative, stripCookie, verifyGrant } from './grant.mjs';
94
-
95
- /** Wrong-password attempts from ONE source before that source's Basic
96
- * attempts are refused outright. A quick tunnel's hostname is unguessable,
97
- * so this is not the primary control — but it is KNOWN to every past
98
- * member, tester and password recipient, whose access cannot be recalled.
99
- * The threshold is per source because one global counter made 25 wrong
100
- * guesses from any URL holder a kill switch on everyone else's share. */
101
- const MAX_FAILED = 25;
102
-
103
- /** Counted failures across ALL sources before the whole share tears down.
104
- * The per-source block above answers a single abuser; this answers a
105
- * DISTRIBUTED guessing run (addresses rotating to stay under MAX_FAILED
106
- * each) — the original self-closing-incident property, kept at a threshold
107
- * ordinary use cannot reach: a blocked source stops counting, so getting
108
- * here takes eight independent sources each burning their full allowance. */
109
- const MAX_FAILED_TOTAL = 200;
110
-
111
- /** Bound on the per-source map — a rotating attacker must not grow daemon
112
- * memory without limit. Eviction is oldest-first and CAN un-block an evicted
113
- * source, but cycling 500 fresh sources costs at least 500 counted failures,
114
- * and the global backstop closes the share long before that. */
115
- const MAX_SOURCES = 500;
116
-
117
- const digest = (s) => createHash('sha256').update(String(s)).digest();
118
-
119
- /** An Authorization header in the Basic scheme — the only shape the gate's own
120
- * password ever arrives in, and so the only one it grades. */
121
- const isBasic = (v) => typeof v === 'string' && /^\s*basic(\s|$)/i.test(v);
122
-
123
- /** Constant-time over sha256 digests, so length never leaks and a missing
124
- * header costs the same as a wrong one. */
125
- function sameSecret(a, b) {
126
- try {
127
- return timingSafeEqual(digest(a ?? ''), digest(b ?? ''));
128
- } catch {
129
- return false;
130
- }
131
- }
75
+ import { randomBytes } from 'node:crypto';
76
+ import { GRANT_COOKIE, safePathname, safeRelative, stripCookie, verifyGrant } from './grant.mjs';
77
+ import { createGatePolicy, isBrowserNav } from './previewGatePolicy.mjs';
78
+ import { appOriginOf, grantCookieSite, rewriteFramePolicy } from './previewFramePolicy.mjs';
132
79
 
133
80
  /**
134
81
  * Start the gate in front of a dev server on `targetPort`. Resolves
@@ -139,7 +86,7 @@ function sameSecret(a, b) {
139
86
  * `onAbuse` fires once, after MAX_FAILED_TOTAL rejected attempts across all
140
87
  * sources, so the caller can tear the whole share down rather than leaving a
141
88
  * URL under attack. A single source is blocked on its own, at MAX_FAILED,
142
- * without ending anybody else's share.
89
+ * without ending anybody else's share (`previewGatePolicy.mjs`).
143
90
  */
144
91
  export function startAuthProxy({
145
92
  targetPort,
@@ -167,204 +114,11 @@ export function startAuthProxy({
167
114
  const password = randomBytes(24).toString('base64url');
168
115
  const expected = 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
169
116
 
170
- let failedTotal = 0;
171
- let abused = false;
172
- /** source → wrong-password count, insertion-ordered so eviction below is
173
- * oldest-first. A source at MAX_FAILED is BLOCKED: its Basic attempts are
174
- * refused BEFORE the comparison and stop counting toward the total. */
175
- const failedBySource = new Map();
176
- /** The payload of the last validly-signed but EXPIRED grant seen, so a
177
- * tester can be re-bounced with the token inside it. */
178
- let lastExpired = null;
179
-
180
- /** cloudflared forwards the real client address in Cf-Connecting-Ip; a
181
- * direct local connection (the machine's own curl, the tests) has only the
182
- * socket. The header is attacker-writable in principle, but lying in it
183
- * only SPREADS one attacker across per-source counters — which is exactly
184
- * the shape MAX_FAILED_TOTAL exists to answer. */
185
- const sourceOf = (req) =>
186
- String(req.headers['cf-connecting-ip'] || req.socket?.remoteAddress || 'unknown');
187
-
188
- const sourceBlocked = (req) => (failedBySource.get(sourceOf(req)) ?? 0) >= MAX_FAILED;
189
-
190
- const noteFailure = (req) => {
191
- const src = sourceOf(req);
192
- const count = (failedBySource.get(src) ?? 0) + 1;
193
- // Delete-then-set so Map insertion order tracks recency, making the
194
- // eviction below an LRU rather than "whoever failed first".
195
- failedBySource.delete(src);
196
- failedBySource.set(src, count);
197
- if (failedBySource.size > MAX_SOURCES) {
198
- failedBySource.delete(failedBySource.keys().next().value);
199
- }
200
- if (count === MAX_FAILED) {
201
- log?.(`preview gate: ${count} failed attempts from ${src} — refusing that source.`);
202
- }
203
- // MONOTONE, never reset by a success: a distributed run has no successes
204
- // to hide behind, and a legitimate share cannot reach the backstop —
205
- // every source stops counting at MAX_FAILED, so 200 needs eight distinct
206
- // sources each exhausting their own allowance.
207
- failedTotal += 1;
208
- if (failedTotal >= MAX_FAILED_TOTAL && !abused) {
209
- abused = true;
210
- log?.(`preview gate: ${failedTotal} failed attempts across sources — closing the share.`);
211
- try {
212
- onAbuse?.();
213
- } catch {
214
- /* the caller's teardown is best-effort */
215
- }
216
- }
217
- };
218
-
219
- /**
220
- * A PURE CREDENTIAL PREDICATE — no method, no Accept, no path. That is what
221
- * lets the websocket upgrade handler reuse it verbatim, and it is why routing
222
- * decisions live in the request handler instead.
223
- *
224
- * Tristate-plus: 'ok' | 'none' | 'expired' | 'forged' | 'badpass' | 'blocked'.
225
- */
226
- const credential = (req) => {
227
- if (abused) return 'badpass';
228
- // ONLY A WRONG PASSWORD COUNTS AS AN ATTEMPT, and this is a reason rather
229
- // than a preference. A forged HMAC is not brute-forceable, so counting it
230
- // buys nothing — while counting it would hand any stranger who finds the
231
- // hostname a kill switch on the owner's share, because onAbuse tears the
232
- // whole thing down. An expired-but-validly-signed grant must never count
233
- // either, or a viewer who left a tab open overnight closes the share on
234
- // their own reload.
235
- //
236
- // AND ONLY A *BASIC* HEADER IS A PASSWORD ATTEMPT (audit 2026-09-24). Any
237
- // Authorization header used to be graded against the gate password, so a
238
- // previewed SPA calling its own `/api` with `Authorization: Bearer …` was
239
- // answered 401 with a Basic challenge — over a valid grant cookie — and 25
240
- // such calls blocked that viewer, eight viewers tore the share down, and
241
- // the origin never saw its own header. A Bearer (or any other scheme) is
242
- // the APP's credential: it is not ours to grade and it rides through.
243
- //
244
- // A VALID GRANT COOKIE IS ASKED FIRST, for the same reason: an app that
245
- // itself speaks Basic sends a header that is not our password, and a
246
- // viewer the app has vouched for must not be graded on it. A grant is
247
- // HMAC-verified, so asking it first opens no brute-force path.
248
- let grantVerdict = 'none';
249
- if (grants) {
250
- // EVERY value for our name, not the first — a duplicate must not shadow.
251
- for (const raw of cookieValues(req.headers.cookie, GRANT_COOKIE)) {
252
- const r = verifyGrant(raw, { secret: grantSecret, shareId });
253
- if (r.ok) return 'ok';
254
- if (r.reason === 'exp' && grantVerdict === 'none') {
255
- lastExpired = r.payload;
256
- grantVerdict = 'expired';
257
- }
258
- }
259
- if (grantVerdict === 'none' && String(req.headers.cookie ?? '').includes(GRANT_COOKIE)) {
260
- grantVerdict = 'forged';
261
- }
262
- }
263
-
264
- if (isBasic(req.headers['authorization'])) {
265
- // A blocked source is refused BEFORE the comparison — a block that
266
- // still grades guesses would let the brute force run to a correct hit.
267
- if (sourceBlocked(req)) return 'blocked';
268
- if (sameSecret(req.headers['authorization'], expected)) {
269
- // Per-source only: a shared NAT recovers when one person behind it
270
- // gets the password right; failedTotal stays monotone (see above).
271
- failedBySource.delete(sourceOf(req));
272
- return 'ok';
273
- }
274
- noteFailure(req);
275
- return 'badpass';
276
- }
277
- return grantVerdict;
278
- };
279
-
280
- /**
281
- * The CSRF backstop for the partitioned cookie (header rule 4a): a browser
282
- * old enough to ignore `Partitioned` while honouring `SameSite=None` will
283
- * attach the grant to cross-site requests, so a request that FETCH METADATA
284
- * says is cross-site is refused unless it is a plain navigation GET/HEAD —
285
- * the two shapes this product legitimately serves cross-site (the Open
286
- * link, the Workbench frame's own src; a rendered attacker frame is then
287
- * stopped by the frame-ancestors rewrite). Applies ONLY to grant-cookie
288
- * auth: a password in an Authorization header is never attached cross-site
289
- * by a browser, and clients that send no Sec-Fetch headers never held a
290
- * None cookie, so absence stays permitted.
291
- */
292
- const crossSiteAbuse = (req) => {
293
- if (String(req.headers['sec-fetch-site'] || '').toLowerCase() !== 'cross-site') return false;
294
- const mode = String(req.headers['sec-fetch-mode'] || '').toLowerCase();
295
- // Cross-site fetch/XHR/img/script with the cookie — nothing legitimate
296
- // looks like this: the framed app's own subresources are same-origin.
297
- if (mode && mode !== 'navigate') return true;
298
- // A cross-site POST navigation is the classic auto-submitted CSRF form.
299
- return !(req.method === 'GET' || req.method === 'HEAD');
300
- };
301
-
302
- /** True when THIS request authenticated with the password rather than the
303
- * cookie — the credential class CSRF cannot ride. */
304
- const viaPassword = (req) => sameSecret(req.headers['authorization'], expected);
305
-
306
- /** The one app fact this gate holds: the origin allowed to frame us. Derived
307
- * from `authorizeUrl` (server-built from PUBLIC_APP_URL) rather than a new
308
- * wire field, so an older server changes nothing and no floor is needed. */
309
- let appOrigin = null;
310
- try {
311
- if (grants) appOrigin = new URL(authorizeUrl).origin;
312
- } catch {
313
- appOrigin = null;
314
- }
315
-
316
- /**
317
- * Replace the origin's frame policy with ours: `frame-ancestors 'self'
318
- * <app origin>`. Rules that must survive any edit:
319
- * - REWRITE the directive inside an existing enforced CSP, never append a
320
- * second policy beside it — multiple CSP headers intersect, so an origin
321
- * `frame-ancestors 'none'` would still win over anything we added.
322
- * - APPEND a policy holding only our directive when the origin stated no
323
- * frame-ancestors at all — a public tunnel hostname deserves a frame
324
- * policy even when localhost never needed one.
325
- * - LEAVE Report-Only alone: browsers ignore frame-ancestors there, and
326
- * rewriting a report channel would be editing the driver's telemetry.
327
- * - DELETE X-Frame-Options: an enforced frame-ancestors makes every current
328
- * engine ignore it anyway; deleting is for the stragglers.
329
- */
330
- const framePolicy = () => `frame-ancestors 'self' ${appOrigin}`;
331
- const rewriteFramePolicy = (headers) => {
332
- if (!grants || !appOrigin) return headers;
333
- const out = { ...headers };
334
- delete out['x-frame-options'];
335
- let sawDirective = false;
336
- // Node joins duplicate response headers with ', ', so one string can hold
337
- // SEVERAL policies (comma-separated), each holding several directives
338
- // (semicolon-separated). Split on BOTH levels or replacing a directive
339
- // eats the tail of a neighbouring policy — CSP source lists never contain
340
- // a comma, so the outer split is safe.
341
- const rewriteOne = (v) =>
342
- String(v)
343
- .split(',')
344
- .map((policy) =>
345
- policy
346
- .split(';')
347
- .map((part) => {
348
- if (/^\s*frame-ancestors(\s|$)/i.test(part)) {
349
- sawDirective = true;
350
- return ` ${framePolicy()}`;
351
- }
352
- return part;
353
- })
354
- .join(';')
355
- )
356
- .join(',');
357
- const csp = out['content-security-policy'];
358
- if (csp !== undefined) {
359
- out['content-security-policy'] = Array.isArray(csp) ? csp.map(rewriteOne) : rewriteOne(csp);
360
- }
361
- if (!sawDirective) {
362
- const existing = out['content-security-policy'];
363
- if (existing === undefined) out['content-security-policy'] = framePolicy();
364
- else out['content-security-policy'] = Array.isArray(existing) ? [...existing, framePolicy()] : [existing, framePolicy()];
365
- }
366
- return out;
367
- };
117
+ // Who gets in, and this share's failure counts (`previewGatePolicy.mjs`).
118
+ const policy = createGatePolicy({ grants, grantSecret, shareId, expected, log, onAbuse });
119
+ // The one origin allowed to frame us, null in password-only mode
120
+ // (`previewFramePolicy.mjs`).
121
+ const appOrigin = appOriginOf(grants, authorizeUrl);
368
122
 
369
123
  // The gate credential is OURS and stops here. Everything else is passed
370
124
  // through untouched: the origin is the driver's own dev server and rewriting
@@ -374,7 +128,7 @@ export function startAuthProxy({
374
128
  // Only when it IS the gate credential. Anything else in Authorization is
375
129
  // the previewed app's own (a Bearer to its API, its own Basic realm) and
376
130
  // the app breaks without it; ours must still never reach branch code.
377
- if (sameSecret(headers.authorization, expected)) delete headers.authorization;
131
+ if (policy.viaPassword(req)) delete headers.authorization;
378
132
  delete headers['proxy-authorization'];
379
133
  // ONLY OURS. The driver's app owns its own cookies and breaks without them;
380
134
  // our grant is a signed bearer token and must not reach branch code.
@@ -408,14 +162,6 @@ export function startAuthProxy({
408
162
  // Every live socket, so stop() can actually end the ones already talking.
409
163
  const sockets = new Set();
410
164
 
411
- /** A top-level browser navigation, and nothing else. A 302 is re-issued as
412
- * GET and drops the body, so bouncing a POST would turn an unauthenticated
413
- * write into a mystery GET; and this is what keeps curl and native clients
414
- * on the password path. */
415
- const isBrowserNav = (req) =>
416
- (req.method === 'GET' || req.method === 'HEAD') &&
417
- /text\/html/.test(req.headers.accept || '');
418
-
419
165
  const noStore = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' };
420
166
 
421
167
  /** Send them to the app to be vouched for. The gate never names its own
@@ -429,6 +175,7 @@ export function startAuthProxy({
429
175
  // Re-bounce a tester with the token that was inside their expired grant —
430
176
  // they no longer hold the original link, and the server re-checks the hash
431
177
  // ONLINE, which is what makes a tester link revocable at all.
178
+ const lastExpired = policy.lastExpired();
432
179
  if (verdict === 'expired' && lastExpired?.k === 't' && lastExpired?.r) {
433
180
  u.searchParams.set('t', String(lastExpired.r));
434
181
  u.searchParams.set('x', '1');
@@ -448,12 +195,8 @@ export function startAuthProxy({
448
195
  if (!r.ok) return challenge(res);
449
196
  const to = safeRelative(u.searchParams.get('to'));
450
197
  const maxAge = Math.max(0, r.payload.exp - Math.floor(Date.now() / 1000));
451
- // FRAMED means Partitioned (header rule 4a). Sec-Fetch-Dest survives the
452
- // whole redirect chain (it describes the navigation, not the hop), so the
453
- // callback sees `iframe` exactly when the Workbench is the one asking.
454
- const dest = String(req.headers['sec-fetch-dest'] || '').toLowerCase();
455
- const framed = dest === 'iframe' || dest === 'frame' || dest === 'embed' || dest === 'object';
456
- const site = framed ? 'SameSite=None; Partitioned' : 'SameSite=Lax';
198
+ // FRAMED means Partitioned (header rule 4a, `previewFramePolicy.mjs`).
199
+ const site = grantCookieSite(req.headers['sec-fetch-dest']);
457
200
  // The immediate 302 to a clean path is MANDATORY, not cosmetic: it takes
458
201
  // `?g=` out of the address bar, out of the Referer every subresource would
459
202
  // carry, and out of browser history. It cannot take it out of cloudflared's
@@ -476,8 +219,8 @@ export function startAuthProxy({
476
219
  res.end('not found');
477
220
  return;
478
221
  }
479
- // 3. One predicate.
480
- const verdict = credential(req);
222
+ // 3. One admission, the same one the upgrade path asks.
223
+ const verdict = policy.admit(req);
481
224
  // A blocked source gets 429, not the challenge — a WWW-Authenticate here
482
225
  // would invite the retry the block exists to end. Only that source's
483
226
  // Basic attempts are refused; every other viewer's share is untouched.
@@ -486,22 +229,22 @@ export function startAuthProxy({
486
229
  res.end('too many failed attempts from this address');
487
230
  return;
488
231
  }
489
- if (verdict !== 'ok') {
490
- if (grants && verdict !== 'badpass' && isBrowserNav(req)) return bounce(req, res, verdict);
491
- return challenge(res);
492
- }
493
232
  // 4. The grant is a cookie, and a cookie can be ridden (header rule 4a's
494
233
  // backstop). Never a bounce — the caller HAS a credential; the request
495
234
  // SHAPE is what is refused.
496
- if (grants && crossSiteAbuse(req) && !viaPassword(req)) {
235
+ if (verdict === 'cross-site') {
497
236
  res.writeHead(403, { 'Content-Type': 'text/plain', ...noStore });
498
237
  res.end('cross-site request refused');
499
238
  return;
500
239
  }
240
+ if (verdict !== 'ok') {
241
+ if (grants && verdict !== 'badpass' && isBrowserNav(req)) return bounce(req, res, verdict);
242
+ return challenge(res);
243
+ }
501
244
  const proxyReq = request(forwardOpts(req), (proxyRes) => {
502
245
  // The one RESPONSE rewrite this gate performs (header rule 4b): the
503
246
  // frame policy. Everything else is the driver's own app talking.
504
- res.writeHead(proxyRes.statusCode || 502, rewriteFramePolicy(proxyRes.headers));
247
+ res.writeHead(proxyRes.statusCode || 502, rewriteFramePolicy(proxyRes.headers, appOrigin));
505
248
  proxyRes.pipe(res);
506
249
  });
507
250
  proxyReq.on('error', () => {
@@ -537,25 +280,25 @@ export function startAuthProxy({
537
280
  // upgrade stays a 401 and the page's HMR client reconnects once the human
538
281
  // has re-authenticated in the main document. A blocked source's Basic
539
282
  // handshake gets the same 429 the request path sends, and no challenge.
540
- const upgradeVerdict = credential(req);
283
+ const upgradeVerdict = policy.admit(req);
541
284
  if (upgradeVerdict === 'blocked') {
542
285
  socket.write('HTTP/1.1 429 Too Many Requests\r\n\r\n');
543
286
  socket.destroy();
544
287
  return;
545
288
  }
546
- if (upgradeVerdict !== 'ok') {
547
- socket.write('HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="Flowviant preview"\r\n\r\n');
548
- socket.destroy();
549
- return;
550
- }
551
289
  // The same cross-site backstop as the request path: a cookie-ridden
552
290
  // cross-site handshake (Sec-Fetch-Mode: websocket) is refused; the framed
553
291
  // app's own HMR socket is same-origin and never trips it.
554
- if (grants && crossSiteAbuse(req) && !viaPassword(req)) {
292
+ if (upgradeVerdict === 'cross-site') {
555
293
  socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
556
294
  socket.destroy();
557
295
  return;
558
296
  }
297
+ if (upgradeVerdict !== 'ok') {
298
+ socket.write('HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="Flowviant preview"\r\n\r\n');
299
+ socket.destroy();
300
+ return;
301
+ }
559
302
  const proxyReq = request(forwardOpts(req));
560
303
  proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
561
304
  const headerLines = Object.entries(proxyRes.headers).map(([k, v]) => `${k}: ${v}`);