tosijs-ui 1.12.8 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,256 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.13.0
4
+
5
+ ### tosijs `^1.9.1` — and a console with nothing in it
6
+
7
+ The peer floor moves to `^1.9.1`. **The binding reason is a type-surface change, not the console
8
+ cleanup** — our emitted declarations now reference `TosiProps` and `TosiStyleSheet` (renamed
9
+ upstream from `XinProps`/`XinStyleSheet`), so `dist/*.d.ts` _hard-requires_ a tosijs that exports
10
+ them. An adopter on an older tosijs gets type errors, not warnings. The floor also carries
11
+ tosijs's **agent-surface security fix**, and the deprecation cleanup below.
12
+
13
+ **Migration note if you are on tosijs 1.7.x:** this floor takes you through **tosijs 1.8.0**,
14
+ which its own notes describe as deviating from semver — it removed `data-ref` and `<xin-slot>`.
15
+ Read tosijs's 1.8.0 entry before upgrading; that step is not automatic.
16
+
17
+ Before 1.9.1, every page of every doc site logged warnings a consumer could not act on:
18
+
19
+ - `xinValue, tosiValue, xinPath…` — tosijs reading its own deprecated proxy property
20
+ ([tosijs#31](https://github.com/tonioloewald/tosijs/issues/31)). Nothing in any consumer's
21
+ code caused it.
22
+ - `bindText` / `bindList` — deprecated in 1.8, then **withdrawn as a category error**
23
+ ([tosijs#33](https://github.com/tonioloewald/tosijs/issues/33)). They are a pair: `bindList`
24
+ supplies the per-item template and `bindText` fills it, and `{ textContent: proxy }` was
25
+ never a replacement for the path form (measured: `textContent: '^.name'` inside a template
26
+ renders the literal string, `bindText: '^.name'` binds). `bindText`'s runtime warning
27
+ outlived its typings by one release and is gone in 1.9.1.
28
+
29
+ Verified on a from-scratch build rather than assumed: **the three deprecation warnings above are
30
+ gone.** An earlier draft of these notes claimed _zero console warnings_, and that was false — one warning remains and it is ours, not tosijs's. Instantiating `<tosi-tabs>` still logs an
31
+ on-event collision from our deprecated `onCloseTab` alias, which the elements factory reads as
32
+ event-handler sugar.
33
+ `onCloseTab` is a deprecated alias we keep for compatibility, so removing it is a breaking change
34
+ and waits for the next major. Stating it rather than claiming a clean console we do not have.
35
+
36
+ ### Live examples: pick a side by clicking, and see what actually changed
37
+
38
+ Both diff views — the resolvable one and the read-only one behind `<tosi-code>`'s review
39
+ overlay — now let you **click a coloured line** to take that side, and mark **the words that
40
+ actually differ** more strongly than the line containing them. Dragging to select text does not
41
+ change anything; those lines are also what you copy from.
42
+
43
+ `<tosi-example>`'s widget bar used to stay **open after every action** — you clicked "maximize"
44
+ and it sat there covering the corner of the thing you had just maximised, until you remembered
45
+ to click the handle again. It now closes once you have actually done something, its controls are ordered by reach (maximize nearest the handle), and the handle is the
46
+ tosi owl on a status-coloured badge — orange running, green passed, red failed.
47
+
48
+ **Tapping that handle on a touch device no longer flashes the menu open and shut.** A touch
49
+ device has no hover, but the browser still fires `pointerenter`/`pointerleave` around a tap, so
50
+ the hover _peek_ opened the bar and then undid itself before the click pinned it — you tapped
51
+ again believing it had failed, and that second tap closed it for real.
52
+
53
+ ### Source moved into the app menu
54
+
55
+ The floating `<> Source` chip that sat over the content is gone; Source is a submenu of the app
56
+ menu beside Language and Color Theme, and it disappears entirely on a page with no source file.
57
+
58
+ ### Security: cross-site requests can no longer reach the dev server's write endpoints
59
+
60
+ `POST /__docstore/source` and `POST /__build` were gated on the **peer address alone** — no
61
+ credential — and the handlers parse JSON regardless of `Content-Type`. That made a cross-site
62
+ `fetch(…, {mode:'no-cors'})` a _simple_ request: no preflight, peer is `127.0.0.1`, gate passes.
63
+ Any page you happened to visit while `bun start` was running could write any file under the repo
64
+ root, `.git/hooks/*` included — code execution at your next git operation
65
+ ([#90](https://github.com/tonioloewald/tosijs-ui/issues/90)).
66
+
67
+ All three state-changing endpoints — `/__docstore/source`, `/__build` and `/report` — now
68
+ require the request to be same-origin, via one shared predicate rather than a term repeated per
69
+ call site. It checks **both** `Sec-Fetch-Site` **and** `Origin`: the first is unforgeable but
70
+ absent on Safari < 16.4, Firefox < 90 and older WKWebViews, and those browsers send the second.
71
+ An earlier pass checked only the first and left exactly that population unprotected. Applied
72
+ **on the non-tunnel path only** — the tunnel is legitimately cross-site and
73
+ is gated by a session cookie instead. Non-browser callers (the CLI, `curl`) send no fetch
74
+ metadata and are unaffected; the attack this closes is specifically a _browser_ being induced to
75
+ make the request, and a browser always tells us.
76
+
77
+ The prose in `dev-auth.ts` had claimed `SameSite=Lax` gave "free CSRF protection". That was true
78
+ only of the tunnel path, which needs a cookie — the default loopback path needed nothing, so the
79
+ stated defence never covered its own primary case.
80
+
81
+ ### New dev-server endpoint: `POST /__build` (loopback only)
82
+
83
+ Called out under its own heading because it is a new HTTP surface on a tool people run all day.
84
+
85
+ It exists so `bun run build` can ask a running dev server to build instead of refusing (below).
86
+ It is gated exactly like `/__devlink`: **loopback and NOT via the tunnel**. Verified against a
87
+ real server rather than reasoned about — an anonymous tunnel request gets `401` from the session
88
+ gate, and an **authenticated** one gets `404` from this gate, which is the case that matters. A
89
+ remote visitor triggering builds would be a denial of service at best, and runs the project's own
90
+ build code at worst. `GET` does nothing.
91
+
92
+ Nothing else about the build lock changed: two builders racing `rm -rf` on one output tree is
93
+ still refused, and a stale lock still gets the old message.
94
+
95
+ ### ePub dates, and an `epub.modified` option
96
+
97
+ `dcterms:modified` is the only date an ePub reader can surface, and it is baked into a
98
+ committed, redistributed file — so it has to be deterministic _and_ coherent. The
99
+ version-derived encoding introduced in this release **wrapped** — twice. First `1.13.24` and
100
+ `1.14.0` produced the same instant; the repack then made `1.13.1-beta.1` byte-identical to
101
+ `1.13.0`, collided `1.0.1000` with `1.1.0`, and sent a CalVer `2026.9.3` to the year 5872. Each
102
+ attempt shipped a docblock asserting the ordering that execution disproved.
103
+
104
+ So the promise is narrowed to what is actually needed and can actually be held: **the same
105
+ version always yields the same instant, and different versions yield different ones.** It is no
106
+ longer ordered, because nothing reads these dates as a sequence — a reader sees one date on one
107
+ book — and a claim nobody needs, made three times and false three times, is worse than no claim.
108
+ The test sweep is generated rather than hand-picked, which is what would have caught the
109
+ adjacent-version collision.
110
+
111
+ New: **`epub.modified`** in the site config. The derived date is synthetic — it looks like a
112
+ date without being one — and until now an adopter had no way to publish the real one.
113
+
114
+ That option shipped broken for a few hours and is worth recording: `modified` was written
115
+ **above** a `...epubOpts` spread, and `epubOpts` _is_ `config.epub` — so the adopter's raw value
116
+ overwrote the sanitised one. `'2026-09-03'` reached the OPF unnormalised and `'not a date'`
117
+ reached it verbatim, and EPUB 3 requires exactly `CCYY-MM-DDThh:mm:ssZ`, so the obvious spelling
118
+ of the new option produced a file EPUBCheck rejects. The option assembly is now an exported
119
+ **`epubOptionsFor()`** with tests that drive **the call site**, not just the date function beside it
120
+ — the third time in this release that a green test asserted a pure function whose only consumer
121
+ threw the result away.
122
+
123
+ ### The book this project ships carries a real date again
124
+
125
+ `docs/tosijs-ui.epub` is redistributed, and its OPF has no `dc:date` — so `dcterms:modified` is
126
+ the only date a reader ever sees. The version-derived scheme hashed 1.13.0 to **2012**, fourteen
127
+ years adrift, where v1.12.8's book carried a correct contemporary date. This repo now sets
128
+ `epub.modified` explicitly, which is exactly what that new option is for; adopters who care
129
+ about the date should do the same, and adopters who don't still get a deterministic one.
130
+
131
+ A related tidy: a docblock asserting the monotonicity this function does not have had survived
132
+ three rewrites by sitting forty lines above it in a different file, and was shipping in `dist/`.
133
+ The behaviour is described in one place now.
134
+
135
+ ### One place decides the ePub date
136
+
137
+ `buildEpub` — the public export, and what `bun book` calls — now defaults and validates
138
+ `dcterms:modified` itself. It previously used `new Date()` with no validation, so the
139
+ determinism fix and the `epub.modified` handling lived at a single `buildSite` call site that
140
+ `bun book` never executed: that path still stamped a wall clock, re-dirtying the committed ePub,
141
+ and still passed a date-only string into an OPF that EPUBCheck rejects.
142
+
143
+ That is the fourth instance in this release of one shape: **a green test asserting a function
144
+ whose only consumer discards the result.** The date function was correct and tested; the
145
+ assembly threw its return value away. The predicate was correct and tested; two of three
146
+ endpoints used it. The touch guard was correct; the test retyped it. And the fix for the
147
+ _first_ three introduced the fourth, by testing a helper that one of two shipped paths called.
148
+
149
+ The answer is not another helper — it is a single choke point every path already goes through,
150
+ tested from the public entry point by asserting the OPF text.
151
+
152
+ ### Tests that can actually fail
153
+
154
+ Two "regression tests" added earlier in this release were **tautologies**: each re-declared the
155
+ production predicate inside the test file and asserted on the copy, so both passed forever and
156
+ stayed green when the real code was broken. A mutation test in the pre-release review killed
157
+ zero of them. They now mount the real `<tosi-pocket-bar>` and import the exported delegation
158
+ predicate, and were re-mutated to confirm the opposite — deleting the touch guards and the
159
+ pin-clear turns two tests red.
160
+
161
+ A re-review then found the same shape one layer down: the delegated-build queue —
162
+ `buildNow`/`buildWaiters` — had **zero** coverage, and an unconditional infinite loop passed the
163
+ whole green suite. The loop is fixed (it started a build on every pass instead of once, spinning
164
+ the dev server into ~30 builds and a `process.exit(1)` that blamed the user's watcher config),
165
+ and the decision is now a pure exported function, `nextBuildStep()`, with tests for the
166
+ contended orderings. `POST /report` was also found never to have received the CSRF gate — the
167
+ term had been spliced into two call sites by hand and the third missed, which is why all three
168
+ now share one named predicate.
169
+
170
+ Recorded here rather than buried because it is the same failure this project's notes already
171
+ describe from 1.9.0: work asserted in prose that the code did not do. A test that retypes the
172
+ condition it is testing is that failure wearing a lab coat.
173
+
174
+ ### What delegation guarantees (and what it refuses)
175
+
176
+ The delegation path above was hardened after a pre-release review found several ways it could
177
+ report a success nobody got. Worth stating, because "my build tool said it built" is a claim you
178
+ rely on:
179
+
180
+ - **It proves the build was yours.** `holder.port` is validated before it reaches a URL, the
181
+ request carries `?root=`, and the reply carries `pid`/`root`/`outputDir` which must match the
182
+ lock and the tree you asked for. A planted lock plus any listener answering `{"ok":true}`
183
+ previously printed "rebuilt this tree" and exited 0 over an untouched tree — stale locks from
184
+ other projects are ordinary on a dev machine, one of them usually on the shared default port.
185
+ - **It waits for the tree to settle**, not for whichever build finishes next — starting one
186
+ build and then parking, with a cap so a watcher that keeps firing degrades to a message
187
+ instead of a build storm. (The first attempt called `rebuild()` on **every pass**, and
188
+ `rebuild()` sets `pending` when a build is in flight — so the settled check could never pass
189
+ under contention and it re-armed forever, spinning the dev server into ~30 builds and a
190
+ `process.exit(1)` blaming the user's watcher config. It was correct when idle, which is why
191
+ it looked fine. A re-review caught it; the decision is now a pure function with tests for
192
+ the contended orderings, which had no coverage at all.) A reply used to be
193
+ released by an already-running build one line before a queued rebuild began wiping the output
194
+ directory — success reported while the tree was being emptied, right before the documented
195
+ `git add`.
196
+ - **It does not believe a reply it does not recognise.** A dev server predating this endpoint
197
+ answers the POST with the SPA shell; that now reports "restart your dev server" instead of
198
+ accusing your build of failing. Dev servers here live for days running the code they loaded,
199
+ so that is the normal upgrade path.
200
+ - **Its timeout is the server's real one.** The client promised ten minutes while `Bun.serve`
201
+ idles connections at 120s, so a _successful_ long build was reported as a failure. "Did not
202
+ answer in time — the build may still be running" is now distinguished from "unreachable".
203
+
204
+ `<tosi-pocket-bar>` also gained a public `close()`. The live-example bar was setting `open =
205
+ false` directly, which left the component's internal pin set — so it re-opened on hover, stuck
206
+ open over the content, and the next handle tap did nothing. On touch, where this release removes
207
+ the hover path, there was no way back at all.
208
+
209
+ ### Builds stopped fighting the dev server
210
+
211
+ `bun run build` with a dev server running now **asks it to build** instead of refusing, and
212
+ `bun run test-browser` uses its **own port (8798)** instead of reclaiming 8787 by killing
213
+ whatever holds it. Both produced the same workflow — kill the server, build, forget to restart
214
+ it — and the second twice took a live tunnel offline, where the symptom is a page reporting
215
+ "offline" with no hint which half died.
216
+
217
+ The generated ePub is **reproducible** now. It differed on every build, so it landed in every
218
+ diff and made a clean tree impossible after building — two independent causes:
219
+ `dcterms:modified` defaulted to `new Date()`, and the zip stored each file's mtime, and those
220
+ files had just been written. Entry mtimes are normalised to a constant before zipping (zip has
221
+ no flag for it; normalising the inputs is the standard fix), and the date is anchored to the
222
+ last commit that touched `package.json` rather than to a clock or to HEAD. After
223
+ commit-then-rebuild the tree carries one changed file (`docs/version.json`, which records the
224
+ commit by design) where it used to carry 68 plus a binary.
225
+
226
+ ### Icons
227
+
228
+ `shift` (⇧) — the standard shift-key outline, asked for by tosijs-3d and general enough to live
229
+ here rather than in one consumer. `tab` (⇥) — an arrow pointing at a vertical bar, built on
230
+ `arrowRight`'s proportions. And `fatArrowUp`/`fatArrowRight`/`fatArrowDown`/`fatArrowLeft`: one
231
+ drawing and four rotations via the same redirect mechanism the arrow and chevron families use.
232
+
233
+ The shift glyph is centred on 12,12 (apex y=4, base y=20; it was 3..20, half a pixel high) —
234
+ invisible head-on, and what makes each rotation land square instead of drifting off-centre.
235
+
236
+ With `delete`, `cornerDownLeft` and `command` already present, an on-screen keyboard now has
237
+ everything except caps-lock.
238
+
239
+ ### Smaller things
240
+
241
+ - **`<tosi-pocket-bar>` handles are brandable**: `--tosi-pocket-handle-bg`,
242
+ `--tosi-pocket-handle-radius` and `--tosi-pocket-handle-size`, all defaulting to exactly their
243
+ previous values, so nothing changes for an existing consumer. A circle needs all three —
244
+ `border-radius: 50%` on a content-sized flex button is an ellipse.
245
+ - The live-example handle uses them for the owl badge, and its **controls were reordered** so
246
+ maximize sits nearest the handle and tests furthest.
247
+ - The dead `.view-source` CSS went with the button it styled, rather than leaving rules for an
248
+ element nothing creates.
249
+ - **`bindText` in `<tosi-table>` briefly moved to `textContent`, then reverted back to
250
+ `bindText`.** `data-table.ts:2179` holds a proxy, which is the only shape the replacement
251
+ accepts — but with the deprecation withdrawn upstream, keeping it would have left the file
252
+ using two idioms for the same job with nothing gained. Net change: zero lines.
253
+
3
254
  ## 1.12.8
4
255
 
5
256
  ### Diffs: pick a side by clicking it, and see what actually changed
@@ -334,6 +334,17 @@ const manualRedirects = {
334
334
  arrowDownRight: 'arrowUpRight90r',
335
335
  arrowDownLeft: 'arrowUpRight180r',
336
336
  arrowUpLeft: 'arrowUpRight270r',
337
+
338
+ // `shift` is the ⇧ key glyph and points UP, which is the wrong base for the
339
+ // `up-down-right-left/` folder convention (that expects a RIGHT-pointing base).
340
+ // Rather than redraw it sideways and make the keyboard glyph a rotation of an
341
+ // arrow nobody asked for, the four directions are declared here — the escape
342
+ // hatch this block exists for. The drawing is centred on 12,12 so each rotation
343
+ // lands square.
344
+ fatArrowUp: 'shift',
345
+ fatArrowRight: 'shift90r',
346
+ fatArrowDown: 'shift180r',
347
+ fatArrowLeft: 'shift270r',
337
348
  }
338
349
  Object.assign(iconRedirects, manualRedirects)
339
350
 
@@ -190,8 +190,6 @@ const testTextColor = {
190
190
  };
191
191
  // Test indicator styles - widget inherits button styles from base stylesheet
192
192
  const testIndicatorStyleSpec = {
193
- '.view-source': { opacity: '0.9' },
194
- '.view-source:hover': { opacity: '1' },
195
193
  '@keyframes test-pulse': {
196
194
  '0%, 100%': { opacity: '1' },
197
195
  '50%': { opacity: '0.7' },
@@ -1116,10 +1114,18 @@ export function createDocBrowser(options) {
1116
1114
  editUI = { editor, doc, view: 'edit', original: content };
1117
1115
  setSourceView('edit');
1118
1116
  };
1119
- const openSourceMenu = (target) => {
1117
+ /*
1118
+ The Source items, separated from the popup that used to be their only home.
1119
+
1120
+ They now hang off the app menu as a submenu, so this returns the ITEMS and lets the
1121
+ caller decide where they appear — an empty array when the current doc has no source
1122
+ file, which is how the host knows to omit the submenu entirely rather than showing an
1123
+ empty one.
1124
+ */
1125
+ const sourceMenuItems = () => {
1120
1126
  const doc = docs.find((d) => String(d.filename) === String(app.currentDoc.filename));
1121
- if (!doc)
1122
- return;
1127
+ if (!doc || !doc.path)
1128
+ return [];
1123
1129
  const fileName = (path) => path.split('/').pop() || path;
1124
1130
  // While editing, the Source menu becomes the editor's controls (no separate
1125
1131
  // toolbar). Otherwise it offers entry points: edit, view on GitHub, download.
@@ -1189,13 +1195,7 @@ export function createDocBrowser(options) {
1189
1195
  }),
1190
1196
  },
1191
1197
  ];
1192
- popMenu({
1193
- target,
1194
- // The trigger is position:fixed at the top-right; `auto` placement opens
1195
- // upward into zero space and collapses the menu, so open below-left.
1196
- position: 'sw',
1197
- menuItems,
1198
- });
1198
+ return menuItems;
1199
1199
  };
1200
1200
  // ── Hierarchical nav (path routing) ───────────────────────────────────────
1201
1201
  // Build nested <details> from the doc tree; current-highlight, test status,
@@ -1365,47 +1365,7 @@ export function createDocBrowser(options) {
1365
1365
  overflowY: 'scroll',
1366
1366
  height: '100%',
1367
1367
  },
1368
- }, button({
1369
- class: 'view-source',
1370
- style: {
1371
- display: 'flex',
1372
- alignItems: 'center',
1373
- gap: '6px',
1374
- position: 'fixed',
1375
- top: 'calc(var(--xin-header-height, 60px) + 5px)',
1376
- right: '5px',
1377
- // Above the code editor (which fills the content area); below the
1378
- // popped menu (z 1002).
1379
- zIndex: '100',
1380
- fontSize: '0.875em',
1381
- color: 'var(--brand-color, inherit)',
1382
- // Solid, theme-aware chip so it stays legible over the editor.
1383
- background: 'var(--background, white)',
1384
- border: 'none',
1385
- borderRadius: '4px',
1386
- padding: '3px 8px',
1387
- boxShadow: '0 1px 4px #0003',
1388
- cursor: 'pointer',
1389
- transition: 'opacity 0.2s ease',
1390
- },
1391
- // Opacity + :hover live in CSS (.view-source) — a JS hover handler
1392
- // here would fade the delegation root (the whole page), not the button.
1393
- onClick(event) {
1394
- // tosijs delegates clicks, so event.currentTarget is the delegation
1395
- // root, not the button — resolve the real trigger for menu anchoring.
1396
- const btn = event.target.closest('.view-source');
1397
- if (btn)
1398
- openSourceMenu(btn);
1399
- },
1400
- bind: {
1401
- value: app.currentDoc,
1402
- binding(element, doc) {
1403
- // Show when there's a source file to edit/view (any doc with a
1404
- // path); the menu handles dev-vs-GitHub availability per item.
1405
- element.style.display = doc.path ? 'flex' : 'none';
1406
- },
1407
- },
1408
- }, icons.code({ style: { _xinIconSize: 16 } }), 'Source'), docContent)));
1368
+ }, docContent)));
1409
1369
  // Render the landing doc (adopts pre-rendered HTML when hydrating).
1410
1370
  showDoc(currentDoc);
1411
1371
  // Inject test indicator styles
@@ -1813,6 +1773,19 @@ export function createDocBrowser(options) {
1813
1773
  startBackgroundTests();
1814
1774
  testManager.enabled.observe(startBackgroundTests);
1815
1775
  }
1776
+ /*
1777
+ The host assembles the Source submenu from these.
1778
+
1779
+ It used to be a floating `<> Source` chip pinned over the content — a second, separate
1780
+ affordance for something the app menu was already the home of. Handing the ITEMS to the
1781
+ host instead means one menu, and the host decides placement; an empty array (a doc with
1782
+ no source file) tells it to omit the submenu rather than show an empty one.
1783
+
1784
+ Same seam `navigate` uses below: a property on the returned element, because the host is
1785
+ a different component that already holds this reference.
1786
+ */
1787
+ ;
1788
+ container.sourceMenuItems = sourceMenuItems;
1816
1789
  // Memory routing: let the host drive navigation programmatically (by slug) and
1817
1790
  // read the current slug back, so the browser can live in a floating panel etc.
1818
1791
  if (memoryRouting) {
@@ -337,6 +337,23 @@ export class TosiDocSystem extends Component {
337
337
  })),
338
338
  });
339
339
  }
340
+ /*
341
+ The Source submenu, contributed by the doc browser.
342
+
343
+ It replaces a floating `<> Source` chip that sat over the content — a second
344
+ affordance for something this menu was already the natural home of. The browser
345
+ returns an empty array when the current doc has no source file, and an empty
346
+ submenu is worse than none, so this omits it entirely in that case.
347
+ */
348
+ const sourceItems = this.browser?.sourceMenuItems?.() ?? [];
349
+ if (sourceItems.length > 0) {
350
+ menuItems.push({
351
+ caption: 'Source',
352
+ icon: 'code',
353
+ menuItems: sourceItems,
354
+ });
355
+ menuItems.push(null);
356
+ }
340
357
  menuItems.push({
341
358
  caption: 'Color Theme',
342
359
  icon: 'rgb',
@@ -221,6 +221,12 @@ export declare function sessionCookie(token: string, maxAgeMs?: number): string;
221
221
  * defeat the entire design.
222
222
  */
223
223
  export declare function urlWithoutToken(rawUrl: string, param: string): string;
224
+ export declare function isSameOriginRequest(request: {
225
+ url?: string;
226
+ headers: {
227
+ get(name: string): string | null;
228
+ };
229
+ }): boolean;
224
230
  export declare function mayWriteSource(opts: {
225
231
  /** did this arrive on the loopback listener dedicated to the tunnel? */
226
232
  viaTunnel: boolean;
@@ -479,6 +479,88 @@ anything arriving there is treated as remote no matter what it claims.
479
479
  because "local" is exactly what a tunnel counterfeits.
480
480
  direct → a loopback peer is sufficient; you are at this keyboard.
481
481
  */
482
+ /*
483
+ CSRF: a loopback peer is not a same-origin caller (#90).
484
+
485
+ `mayWriteSource`'s direct path is peer-address-only — no credential — and the handlers parse
486
+ JSON regardless of `Content-Type`. That makes a cross-site `fetch(…, {mode:'no-cors'})` with a
487
+ CORS-safelisted type a SIMPLE request: no preflight, peer is 127.0.0.1, gate passes. Any page
488
+ the developer happens to visit could therefore write any file under the repo root — including
489
+ `.git/hooks/*`, i.e. code execution at the next git operation. `POST /__build` then turns that
490
+ into "and run it now".
491
+
492
+ The file already claimed CSRF protection from `SameSite=Lax`, and that is true only of the
493
+ TUNNEL path, which needs a cookie. The default loopback path needs nothing, so the stated
494
+ defence never covered the primary case.
495
+
496
+ Fetch metadata is the fix, and it is cheap: browsers send `Sec-Fetch-Site` on every request and
497
+ scripts cannot forge it. A same-origin page sends `same-origin`; a page on evil.example sends
498
+ `cross-site`. Non-browser callers (our own CLI, curl) send neither header — so the rule must
499
+ accept ABSENCE, or it breaks every legitimate tool. That is not a hole: the attack this closes
500
+ is specifically "a browser the developer is using is induced to make the request", and a
501
+ browser always tells us.
502
+
503
+ Applied ONLY on the non-tunnel branch. UPSTREAM.md records that rejecting cross-site outright
504
+ breaks the tunnel case, where the widget legitimately calls :8700 from a page on :3000 — that
505
+ path is gated by a session cookie instead.
506
+ */
507
+ export function isSameOriginRequest(request) {
508
+ /*
509
+ TWO signals, because neither alone covers the population.
510
+
511
+ `Sec-Fetch-Site` is the strong one — unforgeable by script, sent on every request by any
512
+ engine that has it. But Safari < 16.4, Firefox < 90 and older WKWebViews send NO fetch
513
+ metadata, and an earlier version of this function allowed absence outright with a comment
514
+ claiming "a browser always sends it". That was false for exactly the browsers a developer
515
+ might have open, leaving them in the pre-fix position: a forged simple request writing
516
+ `.git/hooks/pre-commit`.
517
+
518
+ `Origin` closes that gap, because those same browsers DO send it on cross-origin POSTs. So:
519
+ refuse a cross-site `Sec-Fetch-Site`, and independently refuse an `Origin` that is not ours.
520
+
521
+ Absence of BOTH is still allowed, and that is deliberate rather than an oversight: our own
522
+ CLI and `curl` send neither, and the threat model here is a browser being induced to make
523
+ the request. A request carrying no browser fingerprint at all is not that.
524
+ */
525
+ const site = request.headers.get('sec-fetch-site');
526
+ if (site !== null && site !== 'same-origin' && site !== 'none')
527
+ return false;
528
+ const origin = request.headers.get('origin');
529
+ /*
530
+ NOTE there is no `origin !== 'null'` exemption, deliberately.
531
+
532
+ An earlier version had one, and it was the single worst value to allow: `Origin: null` is
533
+ exactly what a sandboxed iframe, `srcdoc`, a `data:` document and a cross-origin-redirected
534
+ POST send — i.e. the one Origin an attacker can choose. Modern engines stayed protected only
535
+ because they ALSO send `Sec-Fetch-Site: cross-site`; the unprotected set was precisely the
536
+ old-browser population this Origin check exists for. Nothing we ship requests from an opaque
537
+ origin, so the exemption bought nothing. It now falls through to the unparseable-origin
538
+ refusal below.
539
+ */
540
+ if (origin !== null) {
541
+ // Compare against the origin the request was actually addressed to, which is what the
542
+ // browser would have had to match. `Host` is what the client dialled.
543
+ const host = request.headers.get('host');
544
+ let ours = null;
545
+ try {
546
+ ours = request.url !== undefined ? new URL(request.url).host : null;
547
+ }
548
+ catch {
549
+ ours = null;
550
+ }
551
+ const expected = ours ?? host;
552
+ let originHost = null;
553
+ try {
554
+ originHost = new URL(origin).host;
555
+ }
556
+ catch {
557
+ return false; // an Origin we cannot parse is not one we can accept
558
+ }
559
+ if (expected === null || originHost !== expected)
560
+ return false;
561
+ }
562
+ return true;
563
+ }
482
564
  export function mayWriteSource(opts) {
483
565
  if (opts.viaTunnel)
484
566
  return opts.hasValidSession;
@@ -40,6 +40,19 @@ export declare function resolveWatchPaths(config: {
40
40
  declare global {
41
41
  var Bun: any;
42
42
  }
43
+ /**
44
+ * What a delegated build should do next, given the queue's state.
45
+ *
46
+ * Extracted and exported because the loop that used to be inline shipped an unbounded rebuild
47
+ * storm — `rebuild()` on every iteration re-queued the very build being waited on — and no test
48
+ * could reach it. This is the whole decision, and it is pure.
49
+ */
50
+ export declare function nextBuildStep(state: {
51
+ attempt: number;
52
+ building: boolean;
53
+ pending: boolean;
54
+ maxAttempts: number;
55
+ }): 'start' | 'wait' | 'settled' | 'gave-up';
43
56
  export declare function resolveHaltijaChannel(cwd?: string, env?: Record<string, string | undefined>): {
44
57
  argv: string[];
45
58
  describe: string;