tosijs-ui 1.8.0 → 1.9.0-beta.2

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,87 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.0-beta.2
4
+
5
+ > Prerelease — install with `tosijs-ui@beta`. `latest` stays on 1.8.0.
6
+
7
+ **Remote editing, authenticated.** `bun run tunnel` exposes this machine's dev server at
8
+ a public URL; a single-use invite link is exchanged for a durable session cookie, and
9
+ editing through it is indistinguishable from sitting at the keyboard — same dev server,
10
+ same watcher, same files.
11
+
12
+ - **`bun run tunnel`** (`--status` / `--close` / `--link`). An SSH reverse tunnel to your
13
+ preview box. **The box does no compute** — it terminates TLS and checks a credential —
14
+ which is what lets one small VPS serve many projects. The work stays where the data is.
15
+ - **Magic-link auth.** Two tokens on purpose: the **link** rides in a URL so it is the
16
+ one that leaks (history, `Referer`, proxy logs, chat-app link previews) and is
17
+ therefore *single-use and 15 minutes*; the **session** never appears in a URL, arrives
18
+ as an `HttpOnly` cookie, and is durable. A durable token pasted into a URL would be
19
+ strictly worse than basic auth. `SameSite=Lax` gives the write endpoint free CSRF
20
+ protection.
21
+ - **`tunnel.requireToken`** — default `false`: the workspace renders for anyone with the
22
+ URL but **writes always need a session**, so a stranger gets a read-only page. Set
23
+ `true` to require a session even to view. The default means an expired link degrades
24
+ to a readable page rather than a wall — which is what you hit when you already hold a
25
+ session and open a second window.
26
+ - **Security fix: source endpoints are no longer exposed to your LAN.** `Bun.serve` binds
27
+ every interface, so on any shared network an unauthenticated `POST /__docstore/source`
28
+ was **remote code execution** — write a repo file, the watcher rebuilds, the build runs
29
+ it. Now: loopback, or a valid session.
30
+ - **Multi-project preview host.** Projects **self-register** — each deploy writes its own
31
+ Caddy fragment, so adding one touches no shared config and needs no DNS change. The
32
+ deploy validates before reloading and refuses on invalid config, so one bad fragment
33
+ can't break routing for everyone. The root serves a **generated index** of what's
34
+ deployed and which commit each is serving.
35
+ - **Invite links replace basic-auth dialogs** on the static preview hosts too. Knowingly
36
+ weaker than the workspace's auth (a shared bearer secret in a cookie, not a per-client
37
+ session) — Caddy serving static files has no session store — but equivalent to the
38
+ basicauth it replaces, with no dialog.
39
+
40
+
41
+ ## 1.9.0-beta.1
42
+
43
+ > **Prerelease.** The preview-host system works end to end and is in daily use, but the
44
+ > plan it belongs to (`REMOTE-ACCESS-PLAN.md`) has two more phases. Published under the
45
+ > `beta` dist-tag — **install with `tosijs-ui@beta`**; `latest` stays on 1.8.0.
46
+ >
47
+ > It's a **minor**, not a patch, despite being unfinished: it adds public API
48
+ > (`SiteConfig.preview`), emits a new artifact into every adopter's output
49
+ > (`/version.json`), and changes what happens when a build fails. Semver tracks the
50
+ > shape of a change, not how done it feels.
51
+
52
+ - **New: preview hosting.** `bun run deploy` rsyncs the built site to a box you control,
53
+ so a phone, a client, or a reviewer can see it without your dev server — or your
54
+ laptop — being up. The whole artifact is a few MB, so this is a copy, not a pipeline.
55
+ - **`preview: { host, path?, url? }`** in the site config. Only `host` is required;
56
+ `path` defaults to `/srv/preview/<name>`. Flag > env > config, so a one-off push
57
+ elsewhere doesn't need a config edit.
58
+ - **Dry run by default.** It runs `rsync --delete` (the remote must mirror the build,
59
+ stale pages must not linger), so the destructive form has to be typed: `--go`. It
60
+ also refuses any target that isn't an absolute path at least two levels deep.
61
+ - **Projects self-register.** Each deploy writes its own Caddy fragment; the server
62
+ glob-imports them. Adding a project touches no shared config and needs no DNS
63
+ change. The deploy **validates before reloading** and refuses to reload on invalid
64
+ config, so one bad fragment can't take down every project on the box.
65
+ - `deploy/Caddyfile` and `deploy/build-index.sh` are committed — server config in git
66
+ beats server config in someone's shell history.
67
+ - **New: `/version.json`** in every build — `{ generator, site, commit, commitTime }`.
68
+ Nothing exposed which commit produced a given site before. It matters wherever a
69
+ deploy is a snapshot: a reviewer can otherwise report a bug you fixed this morning
70
+ with no way to tell from the page which of you is stale.
71
+ **Deliberately deterministic** — no build timestamp, no dirty flag — so a committed
72
+ output dir doesn't churn on every build. Git fields are omitted (never blank) when
73
+ git isn't available.
74
+ - **Fix: a failed rebuild no longer destroys the site.** `buildSite()` opens with
75
+ `rm -rf <outputDir>`, so a build failing after that left nothing to serve — save a
76
+ typo, and the next refresh was a dead site. The dev server now moves the working
77
+ build aside first and restores it on failure, **and says so on the page**: the
78
+ existing floating widget (which already means "the far end has something to tell
79
+ you") shows the failure until a good build clears it, with detail in the console.
80
+ Note it survives the "tests disabled" preference — otherwise the one person who
81
+ turned test indicators off is the one who never learns their build is broken.
82
+ - **New: `DEV_NO_WATCH=1`** serves the built site without watching, for automated
83
+ suites that want a server rather than a rebuilder.
84
+
3
85
  ## 1.8.0
4
86
 
5
87
  > **Read this first if you use `tosijs-ui/site`.** This release adds a **dependency-audit
@@ -47,6 +129,16 @@
47
129
  filename with a raw NUL byte, which makes macOS `grep` silently match **nothing** in that
48
130
  file (exit 1, no message) — so searching it for any symbol came back empty and the sort bug
49
131
  above was effectively unfindable. The comparator needs no delimiter; the NUL is gone.
132
+ - **Adopting the gate — budget for one upgrade, not one pin.** From the first real
133
+ adoption (`tosijs`, which went from 12 blocking advisories to zero): if the gate flags
134
+ **`brace-expansion`**, note that `GHSA-mh99-v99m-4gvg` marks *every* version below
135
+ **5.0.8** affected, so the override is mandatory — and `brace-expansion@5` breaks
136
+ **eslint 8**'s bundled `minimatch@3` with `expand is not a function`. In practice that
137
+ means **an eslint 8 → 10 migration** (flat config + typescript-eslint 8), not a one-line
138
+ pin. The upside is that it clears the whole `minimatch`/`js-yaml`/`flatted` cluster at
139
+ once — which is exactly what the advisories-per-package tally is telling you when it
140
+ fingers a stale toolchain. Expect "replace, don't patch" to be the right answer more
141
+ often than a targeted override.
50
142
  - **New: `openBrowser`** site-config option — opens the doc site on dev-server start, reusing
51
143
  an existing tab on macOS rather than piling up new ones.
52
144
  - **New: `DEV_NO_WATCH=1`** serves the built site without watching. The E2E lane used to run
package/README.md CHANGED
@@ -16,14 +16,14 @@
16
16
 
17
17
  Copyright ©2023-2025 Tonio Loewald
18
18
 
19
- ## ⚠️ Breaking change in 1.7.0 — `<tosi-code>` (ACE → CodeMirror 6)
20
-
21
- **1.7.0 is a breaking release shipping under a minor version, deliberately.** `<tosi-code>`
22
- moved from ACE to CodeMirror 6. `value`, `mode`, the `change` event, `disabled`, and
23
- `undo`/`redo` are unchanged; the ACE-era **`theme`** and **`options`** props are **removed** with
24
- no shim. Dark mode is now automatic (`body.darkmode`) and styling comes from `--code-bg` /
25
- `--text-color`. The `2.0` name is reserved for the tjs-native tosijs port, so this ships as
26
- 1.7.0 — **pin `tosijs-ui@1.6` to defer.** Full detail and rationale in
19
+ > ## ⚠️ Breaking change in 1.7.0 — `<tosi-code>` (ACE → CodeMirror 6)
20
+ >
21
+ > **1.7.0 is a breaking release shipping under a minor version, deliberately.** `<tosi-code>`
22
+ > moved from ACE to CodeMirror 6. `value`, `mode`, the `change` event, `disabled`, and
23
+ > `undo`/`redo` are unchanged; the ACE-era **`theme`** and **`options`** props are **removed** with
24
+ > no shim. Dark mode is now automatic (`body.darkmode`) and styling comes from `--code-bg` /
25
+ > `--text-color`. The `2.0` name is reserved for the tjs-native tosijs port, so this ships as
26
+ > 1.7.0 — **pin `tosijs-ui@1.6` to defer.** Full detail and rationale in
27
27
  [CHANGELOG.md](./CHANGELOG.md).
28
28
 
29
29
  ## the tosijs-ui library
@@ -191,6 +191,10 @@ const testIndicatorStyleSpec = {
191
191
  'body:not(.tests-enabled) .doc-link::after, body:not(.tests-enabled) .test-widget': {
192
192
  display: 'none !important',
193
193
  },
194
+ // ...EXCEPT a far-end status (a failed build). That is not a test result, so the
195
+ // "show test indicators" preference must not suppress it — otherwise the one
196
+ // person who turned tests off is the one who never learns their build is broken.
197
+ 'body .test-widget.-dev-status': { display: 'flex !important' },
194
198
  // Nav link dot indicators
195
199
  '.doc-link.-test-passed::after, .doc-link.-test-failed::after': {
196
200
  content: "''",
@@ -1210,8 +1214,48 @@ export function createDocBrowser(options) {
1210
1214
  onClick: showTestMenu,
1211
1215
  }, span({ part: 'label' }, 'Tests'), span({ class: 'count', part: 'count' }, '0'));
1212
1216
  container.appendChild(testWidget);
1217
+ /*
1218
+ The far end has something to tell you.
1219
+
1220
+ The dev server injects `window.__tosiDevStatus` at serve time when a rebuild has
1221
+ FAILED — see statusSnippet() in site/dev-server.ts. That is the same kind of news
1222
+ as a failing browser test (something broke; here is what), so it uses the same
1223
+ floating widget rather than a second overlay competing for the same corner.
1224
+
1225
+ Two differences from a test run, both deliberate:
1226
+ - it does NOT auto-fade. A passing test run is over; a broken build is a standing
1227
+ condition you have to act on, and it stays until a good build clears it.
1228
+ - it wins ties. If tests also failed, the build failure is the more useful thing
1229
+ to read first — the tests probably failed BECAUSE the build did.
1230
+
1231
+ Nothing is injected when the server is healthy, so this is inert in production and
1232
+ in a normal session.
1233
+ */
1234
+ const devStatus = globalThis.__tosiDevStatus;
1235
+ let devStatusShown = false;
1236
+ if (devStatus && devStatus.ok === false) {
1237
+ devStatusShown = true;
1238
+ const labelEl = testWidget.querySelector('[part="label"]');
1239
+ const countEl = testWidget.querySelector('[part="count"]');
1240
+ if (labelEl)
1241
+ labelEl.textContent = devStatus.label || 'Build failed';
1242
+ if (countEl)
1243
+ countEl.hidden = true;
1244
+ testWidget.classList.add('-failed', '-dev-status');
1245
+ testWidget.hidden = false;
1246
+ testWidget.title = devStatus.detail || '';
1247
+ // The page you are reading is the LAST GOOD build — the dev server keeps serving
1248
+ // it rather than the wreckage of the failed one — so say so plainly in the
1249
+ // console, where the detail is readable and copyable.
1250
+ console.error(`[tosi] ${devStatus.label || 'Build failed'} — showing the last good build.\n` +
1251
+ (devStatus.detail || ''));
1252
+ }
1213
1253
  let testsRunning = false;
1214
1254
  function setTestWidgetRunning() {
1255
+ // A broken build outranks a test run: the tests very likely failed BECAUSE the
1256
+ // build did, so replacing "Build failed" with "Tests" would hide the cause.
1257
+ if (devStatusShown)
1258
+ return;
1215
1259
  testsRunning = true;
1216
1260
  testWidget.hidden = false;
1217
1261
  testWidget.classList.remove('-passed', '-failed');
@@ -0,0 +1,23 @@
1
+ export interface BuildStamp {
2
+ /** the tosijs-ui version that generated this site */
3
+ generator: string;
4
+ /** the project's name, from site config */
5
+ site?: string;
6
+ /** short commit hash the site was built from, when available */
7
+ commit?: string;
8
+ /** ISO-8601 commit timestamp, when available */
9
+ commitTime?: string;
10
+ }
11
+ /** Injectable git seam for tests. Returns '' when the command can't run. */
12
+ export type GitReader = (args: string[]) => Promise<string>;
13
+ /**
14
+ * Collect build identity. Git fields are omitted (not blank) when unavailable, so
15
+ * `version.json` never asserts something it doesn't know.
16
+ */
17
+ export declare function gatherBuildStamp(opts?: {
18
+ generator: string;
19
+ site?: string;
20
+ git?: GitReader;
21
+ }): Promise<BuildStamp>;
22
+ /** Serialize for `/version.json` (trailing newline so the file is diff-friendly). */
23
+ export declare function serializeBuildStamp(stamp: BuildStamp): string;
@@ -0,0 +1,65 @@
1
+ /*
2
+ Build identity for a generated site: `/version.json`.
3
+
4
+ Answers one question — **what am I looking at?** — for any deployed copy of the site.
5
+ Nothing in the build exposed this before: `src/version.ts` is the *library* version,
6
+ which says nothing about which commit of the CONSUMER's project produced a given
7
+ build.
8
+
9
+ That question is easy to shrug at locally and genuinely expensive elsewhere. A static
10
+ deploy is a snapshot, so a preview host serves whatever was last pushed to it; someone
11
+ reviews it, reports a bug you fixed this morning, and there is no way to tell from the
12
+ page which of you is out of date. Same for a published doc site after a partial deploy.
13
+
14
+ DELIBERATELY DETERMINISTIC — no build timestamp.
15
+
16
+ `docs/` is committed in this repo (and in the other tosijs-* projects), so anything
17
+ that changes on every build shows up as a diff on every commit. A wall-clock stamp
18
+ would churn the tree forever and train everyone to ignore it — the same nuisance the
19
+ non-reproducible ePub already causes. Identity comes from the COMMIT instead, so
20
+ rebuilding the same source twice produces byte-identical output. That is also the more
21
+ useful answer: you want to know which source this is, not when someone happened to run
22
+ a build.
23
+
24
+ There is no `dirty` flag for the same reason. A build made from a dirty tree reports
25
+ its last commit, which may not describe what was actually built — so the warning
26
+ belongs at DEPLOY time, where a human can act on it, not baked into a committed file
27
+ that would then be permanently wrong.
28
+
29
+ Best-effort by construction: a consumer of `tosijs-ui/site` may not be in a git repo
30
+ at all, and a build must never fail because it couldn't read git metadata.
31
+ */
32
+ import { $ } from 'bun';
33
+ const defaultGit = async (args) => {
34
+ try {
35
+ const r = await $ `git ${args}`.nothrow().quiet();
36
+ return r.exitCode === 0 ? r.stdout.toString().trim() : '';
37
+ }
38
+ catch {
39
+ return ''; // git absent, not a repo, whatever — never fatal
40
+ }
41
+ };
42
+ /**
43
+ * Collect build identity. Git fields are omitted (not blank) when unavailable, so
44
+ * `version.json` never asserts something it doesn't know.
45
+ */
46
+ export async function gatherBuildStamp(opts = {
47
+ generator: 'unknown',
48
+ }) {
49
+ const git = opts.git ?? defaultGit;
50
+ const stamp = { generator: opts.generator };
51
+ if (opts.site)
52
+ stamp.site = opts.site;
53
+ const commit = await git(['rev-parse', '--short', 'HEAD']);
54
+ if (commit)
55
+ stamp.commit = commit;
56
+ // %cI is the committer date, strict ISO-8601 — stable for a given commit.
57
+ const commitTime = await git(['log', '-1', '--format=%cI']);
58
+ if (commitTime)
59
+ stamp.commitTime = commitTime;
60
+ return stamp;
61
+ }
62
+ /** Serialize for `/version.json` (trailing newline so the file is diff-friendly). */
63
+ export function serializeBuildStamp(stamp) {
64
+ return JSON.stringify(stamp, null, 2) + '\n';
65
+ }
@@ -0,0 +1,41 @@
1
+ /** A URL token is spent on first use, but must also age out if never used. */
2
+ export declare const LINK_TOKEN_TTL_MS: number;
3
+ /** Sessions are the durable half — long enough that you are not re-linking daily. */
4
+ export declare const SESSION_TTL_MS: number;
5
+ export declare const SESSION_COOKIE = "tosi_dev_session";
6
+ /** 128 bits, base64url — long enough that guessing is not a threat model. */
7
+ export declare function mintToken(): string;
8
+ /** Constant-time compare that tolerates unequal lengths without throwing. */
9
+ export declare function safeEqual(a: string, b: string): boolean;
10
+ export interface AuthState {
11
+ /** unspent link tokens → when they expire */
12
+ links: Map<string, number>;
13
+ /** live session tokens → when they expire */
14
+ sessions: Map<string, number>;
15
+ }
16
+ export declare function createAuthState(): AuthState;
17
+ /** Drop anything expired. Called on every use so the maps cannot grow without bound. */
18
+ export declare function prune(state: AuthState, now: number): void;
19
+ /** Issue a link token to put in a URL. */
20
+ export declare function issueLink(state: AuthState, now: number): string;
21
+ /**
22
+ * Spend a link token for a session token, or return null.
23
+ *
24
+ * Deleting BEFORE returning is what makes it single-use — and the delete happens
25
+ * whether or not the token had expired, so a replay of an expired token cannot linger.
26
+ */
27
+ export declare function redeemLink(state: AuthState, token: string, now: number): string | null;
28
+ /** Is this session token live? */
29
+ export declare function validSession(state: AuthState, token: string | undefined | null, now: number): boolean;
30
+ /** Pull one cookie out of a Cookie header. */
31
+ export declare function readCookie(header: string | null | undefined, name: string): string | undefined;
32
+ /** The Set-Cookie value for a freshly minted session. See the header comment. */
33
+ export declare function sessionCookie(token: string, maxAgeMs?: number): string;
34
+ /**
35
+ * Strip the link token from a URL so the redirect target is clean.
36
+ *
37
+ * The point of the exchange is that the token stops existing in the address bar, in
38
+ * history, and in anything the browser sends onward. Leaving it on the redirect would
39
+ * defeat the entire design.
40
+ */
41
+ export declare function urlWithoutToken(rawUrl: string, param: string): string;
@@ -0,0 +1,134 @@
1
+ /*
2
+ Magic-link auth for the dev server, so a workspace exposed through `bun run tunnel`
3
+ can be edited from anywhere.
4
+
5
+ TWO TOKENS, ON PURPOSE.
6
+
7
+ LINK token — travels in a URL (`https://…/?t=…`). Therefore it is the one that
8
+ LEAKS: browser history, Referer headers, reverse-proxy access logs,
9
+ link previews in chat apps that fetch what you paste, and anyone
10
+ reading over your shoulder. So it is SINGLE-USE and short-lived: the
11
+ first request spends it, and a copy scraped out of a log later is
12
+ worthless.
13
+
14
+ SESSION token — never appears in a URL. Set as an HttpOnly cookie by the exchange
15
+ and sent automatically thereafter, so it can be durable without being
16
+ exposed. This is the one that actually authorises writes.
17
+
18
+ That asymmetry is the whole design. A single durable token pasted into a URL would be
19
+ strictly WORSE than basic auth: it would sit in five different logs forever. Exchanging
20
+ it immediately for a cookie, then redirecting to a clean URL, is what makes "just send
21
+ me a link" both convenient and defensible.
22
+
23
+ Cookie flags, and why each:
24
+ HttpOnly — script cannot read it, so an XSS in a rendered doc cannot exfil it
25
+ Secure — never sent over plaintext
26
+ SameSite=Lax — sent on top-level GET navigation (so clicking your link works),
27
+ NOT on cross-site POST. That last part is free CSRF protection for
28
+ the write endpoint: another origin cannot make your browser POST
29
+ to /__docstore/source with your cookie attached.
30
+ Path=/ — the whole workspace, since editing spans the site
31
+
32
+ Build-time only. Never import this from browser code.
33
+ */
34
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
35
+ /** A URL token is spent on first use, but must also age out if never used. */
36
+ export const LINK_TOKEN_TTL_MS = 15 * 60 * 1000;
37
+ /** Sessions are the durable half — long enough that you are not re-linking daily. */
38
+ export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
39
+ export const SESSION_COOKIE = 'tosi_dev_session';
40
+ /** 128 bits, base64url — long enough that guessing is not a threat model. */
41
+ export function mintToken() {
42
+ return randomBytes(16).toString('base64url');
43
+ }
44
+ /** Constant-time compare that tolerates unequal lengths without throwing. */
45
+ export function safeEqual(a, b) {
46
+ const ab = Buffer.from(a);
47
+ const bb = Buffer.from(b);
48
+ if (ab.length !== bb.length)
49
+ return false;
50
+ return timingSafeEqual(ab, bb);
51
+ }
52
+ export function createAuthState() {
53
+ return { links: new Map(), sessions: new Map() };
54
+ }
55
+ /** Drop anything expired. Called on every use so the maps cannot grow without bound. */
56
+ export function prune(state, now) {
57
+ for (const [t, exp] of state.links)
58
+ if (exp <= now)
59
+ state.links.delete(t);
60
+ for (const [t, exp] of state.sessions)
61
+ if (exp <= now)
62
+ state.sessions.delete(t);
63
+ }
64
+ /** Issue a link token to put in a URL. */
65
+ export function issueLink(state, now) {
66
+ prune(state, now);
67
+ const token = mintToken();
68
+ state.links.set(token, now + LINK_TOKEN_TTL_MS);
69
+ return token;
70
+ }
71
+ /**
72
+ * Spend a link token for a session token, or return null.
73
+ *
74
+ * Deleting BEFORE returning is what makes it single-use — and the delete happens
75
+ * whether or not the token had expired, so a replay of an expired token cannot linger.
76
+ */
77
+ export function redeemLink(state, token, now) {
78
+ prune(state, now);
79
+ // Constant-time lookup over the (tiny) set rather than Map.get, so a timing signal
80
+ // can't distinguish "no such token" from "wrong token".
81
+ let matched = null;
82
+ for (const candidate of state.links.keys()) {
83
+ if (safeEqual(candidate, token))
84
+ matched = candidate;
85
+ }
86
+ if (matched === null)
87
+ return null;
88
+ state.links.delete(matched);
89
+ const session = mintToken();
90
+ state.sessions.set(session, now + SESSION_TTL_MS);
91
+ return session;
92
+ }
93
+ /** Is this session token live? */
94
+ export function validSession(state, token, now) {
95
+ if (!token)
96
+ return false;
97
+ prune(state, now);
98
+ for (const candidate of state.sessions.keys()) {
99
+ if (safeEqual(candidate, token))
100
+ return true;
101
+ }
102
+ return false;
103
+ }
104
+ /** Pull one cookie out of a Cookie header. */
105
+ export function readCookie(header, name) {
106
+ if (!header)
107
+ return undefined;
108
+ for (const part of header.split(';')) {
109
+ const eq = part.indexOf('=');
110
+ if (eq < 0)
111
+ continue;
112
+ if (part.slice(0, eq).trim() === name)
113
+ return part.slice(eq + 1).trim();
114
+ }
115
+ return undefined;
116
+ }
117
+ /** The Set-Cookie value for a freshly minted session. See the header comment. */
118
+ export function sessionCookie(token, maxAgeMs = SESSION_TTL_MS) {
119
+ return (`${SESSION_COOKIE}=${token}; Max-Age=${Math.floor(maxAgeMs / 1000)}; ` +
120
+ `Path=/; HttpOnly; Secure; SameSite=Lax`);
121
+ }
122
+ /**
123
+ * Strip the link token from a URL so the redirect target is clean.
124
+ *
125
+ * The point of the exchange is that the token stops existing in the address bar, in
126
+ * history, and in anything the browser sends onward. Leaving it on the redirect would
127
+ * defeat the entire design.
128
+ */
129
+ export function urlWithoutToken(rawUrl, param) {
130
+ const url = new URL(rawUrl);
131
+ url.searchParams.delete(param);
132
+ const qs = url.searchParams.toString();
133
+ return url.pathname + (qs ? `?${qs}` : '') + url.hash;
134
+ }
@@ -38,6 +38,24 @@ export declare function resolveLimitMb(configMb: number | undefined, envMb: stri
38
38
  * once per test page (N redundant loads in throwaway frames). An agent only ever
39
39
  * drives the top page, so nested frames never need the channel.
40
40
  */
41
+ /**
42
+ * Is this request coming from THIS machine?
43
+ *
44
+ * The dev server binds every interface (`Bun.serve` with no `hostname`), which is
45
+ * deliberate — the mkcert dev cert covers `<host>.local` precisely so you can open the
46
+ * site on a phone or a second laptop. But "anyone on this network may READ the preview"
47
+ * and "anyone on this network may REWRITE my source files" are wildly different
48
+ * propositions, and the source endpoints were getting the first one's treatment.
49
+ *
50
+ * On any shared network — a café, a conference, a hotel — an unauthenticated
51
+ * `POST /__docstore/source` is remote code execution: it writes a file in the repo, the
52
+ * watcher rebuilds, and the build runs what was written. So those endpoints are
53
+ * loopback-only, while the site itself stays reachable from your other devices.
54
+ *
55
+ * IPv4-mapped IPv6 (`::ffff:127.0.0.1`) counts — that is how a v4 client shows up on a
56
+ * dual-stack listener, and missing it would lock out the local machine on some setups.
57
+ */
58
+ export declare function isLoopbackAddress(address: string | undefined | null): boolean;
41
59
  export declare function haltijaLoaderSnippet(httpsPort: number): string;
42
60
  export declare function devServer(config: SiteConfig, opts?: {
43
61
  test?: boolean;