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 +251 -0
- package/bin/make-icon-data.js +11 -0
- package/dist/doc-browser.js +26 -53
- package/dist/doc-system/doc-system.js +17 -0
- package/dist/doc-system/site/dev-auth.d.ts +6 -0
- package/dist/doc-system/site/dev-auth.js +82 -0
- package/dist/doc-system/site/dev-server.d.ts +13 -0
- package/dist/doc-system/site/dev-server.js +184 -4
- package/dist/doc-system/site/epub.d.ts +1 -0
- package/dist/doc-system/site/epub.js +73 -1
- package/dist/doc-system/site/orchestrator.d.ts +15 -0
- package/dist/doc-system/site/orchestrator.js +129 -2
- package/dist/doc-system/site/site-config.d.ts +9 -0
- package/dist/icon-data.js +6 -0
- package/dist/iife.js +88 -88
- package/dist/iife.js.map +10 -10
- package/dist/live-example/component.d.ts +5 -1
- package/dist/live-example/component.js +50 -7
- package/dist/live-example/styles.d.ts +3 -0
- package/dist/live-example/styles.js +14 -0
- package/dist/live-theme.d.ts +1 -1
- package/dist/localize.d.ts +8 -8
- package/dist/pocket-bar.d.ts +13 -0
- package/dist/pocket-bar.js +58 -6
- package/dist/router.d.ts +1 -1
- package/dist/theme.d.ts +2 -2
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +4 -3
- package/dist/.metadata_never_index +0 -0
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
|
package/bin/make-icon-data.js
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
|
|
package/dist/doc-browser.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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;
|