tosijs-ui 1.8.0 → 1.9.0-beta.1

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,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.0-beta.1
4
+
5
+ > **Prerelease.** The preview-host system works end to end and is in daily use, but the
6
+ > plan it belongs to (`REMOTE-ACCESS-PLAN.md`) has two more phases. Published under the
7
+ > `beta` dist-tag — **install with `tosijs-ui@beta`**; `latest` stays on 1.8.0.
8
+ >
9
+ > It's a **minor**, not a patch, despite being unfinished: it adds public API
10
+ > (`SiteConfig.preview`), emits a new artifact into every adopter's output
11
+ > (`/version.json`), and changes what happens when a build fails. Semver tracks the
12
+ > shape of a change, not how done it feels.
13
+
14
+ - **New: preview hosting.** `bun run deploy` rsyncs the built site to a box you control,
15
+ so a phone, a client, or a reviewer can see it without your dev server — or your
16
+ laptop — being up. The whole artifact is a few MB, so this is a copy, not a pipeline.
17
+ - **`preview: { host, path?, url? }`** in the site config. Only `host` is required;
18
+ `path` defaults to `/srv/preview/<name>`. Flag > env > config, so a one-off push
19
+ elsewhere doesn't need a config edit.
20
+ - **Dry run by default.** It runs `rsync --delete` (the remote must mirror the build,
21
+ stale pages must not linger), so the destructive form has to be typed: `--go`. It
22
+ also refuses any target that isn't an absolute path at least two levels deep.
23
+ - **Projects self-register.** Each deploy writes its own Caddy fragment; the server
24
+ glob-imports them. Adding a project touches no shared config and needs no DNS
25
+ change. The deploy **validates before reloading** and refuses to reload on invalid
26
+ config, so one bad fragment can't take down every project on the box.
27
+ - `deploy/Caddyfile` and `deploy/build-index.sh` are committed — server config in git
28
+ beats server config in someone's shell history.
29
+ - **New: `/version.json`** in every build — `{ generator, site, commit, commitTime }`.
30
+ Nothing exposed which commit produced a given site before. It matters wherever a
31
+ deploy is a snapshot: a reviewer can otherwise report a bug you fixed this morning
32
+ with no way to tell from the page which of you is stale.
33
+ **Deliberately deterministic** — no build timestamp, no dirty flag — so a committed
34
+ output dir doesn't churn on every build. Git fields are omitted (never blank) when
35
+ git isn't available.
36
+ - **Fix: a failed rebuild no longer destroys the site.** `buildSite()` opens with
37
+ `rm -rf <outputDir>`, so a build failing after that left nothing to serve — save a
38
+ typo, and the next refresh was a dead site. The dev server now moves the working
39
+ build aside first and restores it on failure, **and says so on the page**: the
40
+ existing floating widget (which already means "the far end has something to tell
41
+ you") shows the failure until a good build clears it, with detail in the console.
42
+ Note it survives the "tests disabled" preference — otherwise the one person who
43
+ turned test indicators off is the one who never learns their build is broken.
44
+ - **New: `DEV_NO_WATCH=1`** serves the built site without watching, for automated
45
+ suites that want a server rather than a rebuilder.
46
+
3
47
  ## 1.8.0
4
48
 
5
49
  > **Read this first if you use `tosijs-ui/site`.** This release adds a **dependency-audit
@@ -47,6 +91,16 @@
47
91
  filename with a raw NUL byte, which makes macOS `grep` silently match **nothing** in that
48
92
  file (exit 1, no message) — so searching it for any symbol came back empty and the sort bug
49
93
  above was effectively unfindable. The comparator needs no delimiter; the NUL is gone.
94
+ - **Adopting the gate — budget for one upgrade, not one pin.** From the first real
95
+ adoption (`tosijs`, which went from 12 blocking advisories to zero): if the gate flags
96
+ **`brace-expansion`**, note that `GHSA-mh99-v99m-4gvg` marks *every* version below
97
+ **5.0.8** affected, so the override is mandatory — and `brace-expansion@5` breaks
98
+ **eslint 8**'s bundled `minimatch@3` with `expand is not a function`. In practice that
99
+ means **an eslint 8 → 10 migration** (flat config + typescript-eslint 8), not a one-line
100
+ pin. The upside is that it clears the whole `minimatch`/`js-yaml`/`flatted` cluster at
101
+ once — which is exactly what the advisories-per-package tally is telling you when it
102
+ fingers a stale toolchain. Expect "replace, don't patch" to be the right answer more
103
+ often than a targeted override.
50
104
  - **New: `openBrowser`** site-config option — opens the doc site on dev-server start, reusing
51
105
  an existing tab on macOS rather than piling up new ones.
52
106
  - **New: `DEV_NO_WATCH=1`** serves the built site without watching. The E2E lane used to run
@@ -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
+ }
@@ -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;
@@ -8,7 +8,7 @@ headless browser through the inline doc tests and exits with their pass/fail.
8
8
  Build-time only (Bun APIs). Never import this from browser code.
9
9
  */
10
10
  import * as path from 'path';
11
- import { statSync } from 'fs';
11
+ import { statSync, existsSync } from 'fs';
12
12
  import { watch } from 'chokidar';
13
13
  import { $, spawn } from 'bun';
14
14
  import { buildSite } from './orchestrator';
@@ -90,6 +90,33 @@ export function resolveLimitMb(configMb, envMb) {
90
90
  * once per test page (N redundant loads in throwaway frames). An agent only ever
91
91
  * drives the top page, so nested frames never need the channel.
92
92
  */
93
+ /**
94
+ * Is this request coming from THIS machine?
95
+ *
96
+ * The dev server binds every interface (`Bun.serve` with no `hostname`), which is
97
+ * deliberate — the mkcert dev cert covers `<host>.local` precisely so you can open the
98
+ * site on a phone or a second laptop. But "anyone on this network may READ the preview"
99
+ * and "anyone on this network may REWRITE my source files" are wildly different
100
+ * propositions, and the source endpoints were getting the first one's treatment.
101
+ *
102
+ * On any shared network — a café, a conference, a hotel — an unauthenticated
103
+ * `POST /__docstore/source` is remote code execution: it writes a file in the repo, the
104
+ * watcher rebuilds, and the build runs what was written. So those endpoints are
105
+ * loopback-only, while the site itself stays reachable from your other devices.
106
+ *
107
+ * IPv4-mapped IPv6 (`::ffff:127.0.0.1`) counts — that is how a v4 client shows up on a
108
+ * dual-stack listener, and missing it would lock out the local machine on some setups.
109
+ */
110
+ export function isLoopbackAddress(address) {
111
+ if (!address)
112
+ return false;
113
+ const a = address.trim().toLowerCase().replace(/^\[|\]$/g, '');
114
+ if (a === '::1' || a === 'localhost')
115
+ return true;
116
+ // ::ffff:127.0.0.1 — IPv4-mapped IPv6
117
+ const mapped = a.startsWith('::ffff:') ? a.slice(7) : a;
118
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(mapped);
119
+ }
93
120
  export function haltijaLoaderSnippet(httpsPort) {
94
121
  return (`<script>self===top&&/^localhost$|^127\\./.test(location.hostname)` +
95
122
  `&&import('https://localhost:${httpsPort}/dev.js')</script>`);
@@ -276,6 +303,13 @@ export async function devServer(config, opts = {}) {
276
303
  process.env.HALTIJA_DEV === '1' ||
277
304
  process.env.HALTIJA_DEV === 'true');
278
305
  const HALTIJA_SNIPPET = haltijaLoaderSnippet(HALTIJA_HTTPS_PORT);
306
+ /*
307
+ What the far end has to tell you. `ok: true` means nothing to report and nothing
308
+ is injected into served pages. A failed rebuild sets it; the next good build
309
+ clears it. Surfaced through the page's existing floating widget — see
310
+ statusSnippet() below and the `__tosiDevStatus` reader in doc-browser.ts.
311
+ */
312
+ let buildStatus = { ok: true };
279
313
  let testReportResolve;
280
314
  // Source read/write for in-browser "edit page source" (config.editableSources).
281
315
  // Local dev only — your machine, your files — so the lone guard is correctness:
@@ -342,15 +376,41 @@ export async function devServer(config, opts = {}) {
342
376
  }
343
377
  return null;
344
378
  }
345
- // Serve a resolved file, injecting the haltija dev-channel loader into HTML
346
- // pages when enabled. Serve-time only — the loader never touches the built
347
- // output on disk, and non-HTML assets are streamed untouched.
379
+ /*
380
+ Serve-time status for the page's floating widget.
381
+
382
+ The widget already exists and already means "the far end has something to tell
383
+ you" — it is how browser-test results surface. A failed rebuild is the same kind
384
+ of news, so it uses the same surface rather than inventing a second one.
385
+
386
+ Injected as a plain global rather than pushed over a socket: the case that matters
387
+ is "the build broke and I hit refresh", and a refresh re-reads this. (When Phase 2
388
+ of REMOTE-ACCESS-PLAN.md adds a socket, it can update the same global live.)
389
+
390
+ Only emitted when there IS something to say, so a healthy dev server injects
391
+ nothing at all.
392
+ */
393
+ function statusSnippet() {
394
+ if (buildStatus.ok)
395
+ return '';
396
+ const payload = JSON.stringify({
397
+ ok: false,
398
+ label: buildStatus.label,
399
+ detail: buildStatus.detail,
400
+ at: buildStatus.at,
401
+ });
402
+ return `<script>window.__tosiDevStatus=${payload}</script>`;
403
+ }
404
+ // Serve a resolved file, injecting the haltija dev-channel loader and/or a build
405
+ // status into HTML pages. Serve-time only — neither touches the built output on
406
+ // disk, and non-HTML assets are streamed untouched.
348
407
  async function respondFile(filePath) {
349
- if (haltijaDev && filePath.endsWith('.html')) {
408
+ const extras = (haltijaDev ? HALTIJA_SNIPPET : '') + (testMode ? '' : statusSnippet());
409
+ if (extras && filePath.endsWith('.html')) {
350
410
  const html = await Bun.file(filePath).text();
351
411
  const injected = html.includes('</body>')
352
- ? html.replace('</body>', `${HALTIJA_SNIPPET}</body>`)
353
- : html + HALTIJA_SNIPPET;
412
+ ? html.replace('</body>', `${extras}</body>`)
413
+ : html + extras;
354
414
  return new Response(injected, {
355
415
  headers: { 'Content-Type': 'text/html; charset=utf-8' },
356
416
  });
@@ -499,6 +559,63 @@ export async function devServer(config, opts = {}) {
499
559
  // network, and re-running it on every hot-reload save would add latency to the
500
560
  // edit loop and break offline dev. The audit ran once at launch (below).
501
561
  const runBuild = opts.build ?? (() => buildSite(config, { skipAudit: true }));
562
+ /*
563
+ A FAILED REBUILD MUST NOT DESTROY THE SITE YOU ARE LOOKING AT.
564
+
565
+ `buildSite()` begins with `rm -rf <outputDir>` — it has to, since it regenerates
566
+ the tree — so a build that fails anywhere after that leaves an empty or partial
567
+ output directory. Before this, the sequence was: save a typo, the rebuild throws,
568
+ the error scrolls past in a terminal you may not be watching, and the next refresh
569
+ serves nothing. The site was gone until you noticed and fixed it, and the page
570
+ gave you no clue why.
571
+
572
+ So: move the working site aside first (a rename — atomic and instant on the same
573
+ filesystem, and ~9MB of extra disk we will never notice), then build. On success,
574
+ drop the spare. On failure, put it back and record WHY, so the page keeps working
575
+ and the widget can say what broke.
576
+
577
+ Deliberately wraps `runBuild`, not `buildSite`: a consumer's custom `{ build }`
578
+ pipeline gets the same protection without knowing about it.
579
+ */
580
+ const LAST_GOOD = `${PUBLIC}.last-good`;
581
+ const runBuildPreservingLastGood = async () => {
582
+ const hadSite = existsSync(PUBLIC);
583
+ if (hadSite) {
584
+ await $ `rm -rf ${LAST_GOOD}`.nothrow().quiet();
585
+ await $ `mv ${PUBLIC} ${LAST_GOOD}`.nothrow().quiet();
586
+ }
587
+ const restore = async () => {
588
+ if (!hadSite)
589
+ return;
590
+ await $ `rm -rf ${PUBLIC}`.nothrow().quiet();
591
+ await $ `mv ${LAST_GOOD} ${PUBLIC}`.nothrow().quiet();
592
+ };
593
+ try {
594
+ // buildSite REPORTS failure by returning false (a blocking advisory, an
595
+ // output-dir overlap) and THROWS for everything else, so treat both as
596
+ // failure — a falsy return that we ignored would silently ship a wiped site.
597
+ const result = await runBuild();
598
+ if (result === false)
599
+ throw new Error('build reported failure');
600
+ if (hadSite)
601
+ await $ `rm -rf ${LAST_GOOD}`.nothrow().quiet();
602
+ if (!buildStatus.ok)
603
+ console.log('✅ build recovered — serving fresh output');
604
+ buildStatus = { ok: true, at: Date.now() };
605
+ }
606
+ catch (error) {
607
+ await restore();
608
+ buildStatus = {
609
+ ok: false,
610
+ label: 'Build failed',
611
+ detail: String(error instanceof Error ? error.message : error).slice(0, 2000),
612
+ at: Date.now(),
613
+ };
614
+ console.error(`\n🛑 Rebuild failed — still serving the last good build.\n` +
615
+ ` Fix the error above; the next successful build clears this.\n`);
616
+ throw error;
617
+ }
618
+ };
502
619
  // Rebuild-storm detector.
503
620
  //
504
621
  // The other way this process eats the machine is not a leak but a LOOP: if the
@@ -547,7 +664,7 @@ export async function devServer(config, opts = {}) {
547
664
  // Both look like: started again with no gap.
548
665
  const immediate = lastBuildEnd > 0 && Date.now() - lastBuildEnd < IMMEDIATE_MS;
549
666
  try {
550
- await runBuild();
667
+ await runBuildPreservingLastGood();
551
668
  }
552
669
  catch (error) {
553
670
  console.error('rebuild failed:', error);
@@ -627,7 +744,7 @@ export async function devServer(config, opts = {}) {
627
744
  key: Bun.file('./tls/key.pem'),
628
745
  cert: Bun.file('./tls/certificate.pem'),
629
746
  },
630
- async fetch(request) {
747
+ async fetch(request, srv) {
631
748
  touch();
632
749
  let reqPath = new URL(request.url).pathname;
633
750
  console.log(request.method, reqPath);
@@ -647,6 +764,26 @@ export async function devServer(config, opts = {}) {
647
764
  if (!config.editableSources) {
648
765
  return new Response('editableSources is not enabled in this doc-site config (set editableSources: true to edit/save source in dev)', { status: 501, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
649
766
  }
767
+ // LOOPBACK ONLY. Reading exposes any file in the repo; writing is remote code
768
+ // execution (write a file, the watcher rebuilds, the build runs it). Checked on
769
+ // the PEER ADDRESS, never on Origin/Host, which are attacker-supplied.
770
+ //
771
+ // There is deliberately NO override. An env var that re-enables this would have
772
+ // no legitimate use today — nothing authenticates in front of this server — and
773
+ // it is exactly the shape of guard that gets set once in a shell profile and
774
+ // stays off forever. Remote editing is not a flag to flip; it needs real
775
+ // authentication, and that is Phase 3 of REMOTE-ACCESS-PLAN.md, where the
776
+ // schema is the authorization boundary and a session (not a location, and not
777
+ // an env var) decides who may write. Until then: view remotely, edit locally.
778
+ const peer = srv?.requestIP?.(request)?.address;
779
+ if (!isLoopbackAddress(peer)) {
780
+ console.warn(`⚠️ refused ${request.method} /__docstore/source from ${peer ?? 'unknown'} — ` +
781
+ `source editing is local-only.`);
782
+ return new Response('source editing is restricted to this machine', {
783
+ status: 403,
784
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
785
+ });
786
+ }
650
787
  if (request.method === 'GET')
651
788
  return handleReadSource(request);
652
789
  if (request.method === 'POST')
@@ -23,6 +23,8 @@ import { generateSite } from './generate-site';
23
23
  import { findOutputDirOverlap } from './output-guard';
24
24
  import { preflight } from './preflight';
25
25
  import { auditDependencies, reportAudit } from './audit-guard';
26
+ import { gatherBuildStamp, serializeBuildStamp } from './build-stamp';
27
+ import { version } from '../../version';
26
28
  import { tjsEditorExternal, tjsEditorLeakedAsExternal, classicScriptSyntaxErrorInChild, } from './bundle-guard';
27
29
  // Module specifiers contain regex metacharacters (`/`, `.`, `@`, …), so escape
28
30
  // before interpolating into the require-shim detector below.
@@ -627,6 +629,10 @@ export async function buildSite(config, opts = {}) {
627
629
  scriptUrl: config.scriptUrl,
628
630
  basePath: config.basePath,
629
631
  });
632
+ // Build identity at /version.json — "what am I looking at?" for any deployed copy.
633
+ // Deterministic (commit-derived, no wall clock) so a committed outputDir doesn't
634
+ // churn on every build. See build-stamp.ts.
635
+ await Bun.write(`${PUBLIC}/version.json`, serializeBuildStamp(await gatherBuildStamp({ generator: version, site: config.name })));
630
636
  // Burn the theme into a static stylesheet (separate subprocess — see
631
637
  // generate-css.ts). Resolve the sibling relative to THIS module so it works
632
638
  // both in-repo (.ts) and when shipped (compiled .js).
@@ -291,6 +291,31 @@ export interface SiteConfig {
291
291
  * permission prompt ("<runtime> wants to control <Browser>") — approve it once.
292
292
  */
293
293
  openBrowser?: boolean | string;
294
+ /**
295
+ * Preview host for `bun bin/deploy-preview.ts` — rsync the built site to a box you
296
+ * control, so a phone, a client, or a reviewer can see it without your dev server
297
+ * (or your laptop) being up.
298
+ *
299
+ * Only `host` is required; everything else has a sensible default:
300
+ *
301
+ * ```ts
302
+ * preview: { host: 'root@203.0.113.10' }
303
+ * // → rsyncs <outputDir>/ to /srv/preview/<name>/ on that box
304
+ * ```
305
+ *
306
+ * The deploy is a plain `rsync --delete` of static files — the whole artifact is a
307
+ * few MB — so there is no pipeline, no build service, and (because static files
308
+ * have no write endpoint) no meaningful attack surface to design around. See
309
+ * REMOTE-ACCESS-PLAN.md.
310
+ */
311
+ preview?: {
312
+ /** ssh target, e.g. `root@203.0.113.10` or `deploy@preview.example.com` */
313
+ host: string;
314
+ /** remote directory; defaults to `/srv/preview/<name>` */
315
+ path?: string;
316
+ /** public URL, printed after a successful deploy (e.g. `https://dev.example.com`) */
317
+ url?: string;
318
+ };
294
319
  /**
295
320
  * Enable the dev-server source read/write endpoints (`/__docstore/source`) that
296
321
  * back in-browser "edit page source". Local dev only — the dev server runs on