tosijs-ui 1.16.24 → 1.16.26

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,92 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.26
4
+
5
+ ### Tunnel
6
+
7
+ - **Global keys: one link opens every workspace, and survives restarts.** The preview host can
8
+ hold two machine-wide keys: `PREVIEW_EDIT_TOKEN` (new, optional) opens every workspace for
9
+ editing, and `PREVIEW_TOKEN` (the static previews' invite secret) opens them read-only.
10
+ Put them in `/etc/caddy/preview.env` on the box (re-run `tosijs-caddy-install`) and in
11
+ `~/local-secrets/tosijs-preview.env` on each machine running a dev server. Visit the box's
12
+ root once with `?t=<key>`: Caddy sets a cookie across the preview domain (24 hours for the
13
+ edit key), and the root page lists every workspace that is up and whether you can edit or
14
+ only view it. Opening one starts a session there on the first page load, and after a
15
+ dev-server restart a reload is all it takes. Keys must be at least 24 letters, digits, `-` or
16
+ `_`, because the box compares them with no rate limit; the installer refuses a short edit
17
+ key, and a dev server ignores one and says so. A workspace can refuse them with
18
+ `tunnel.globalKeys: false`, or with `'view'` let either key browse and neither edit; any other
19
+ value refuses both and says so. The eight-letter `--link` codes are unchanged.
20
+ - **The view key is your preview invite.** Everyone you have given a static-preview link can,
21
+ by default, also read every workspace that accepts the view key while it is up, even with
22
+ `requireToken`. Set `tunnel.globalKeys: false` on a project that should stay yours, or rotate
23
+ the invite secret.
24
+ - **Rotating a key** means editing both files, re-running the installer and restarting the dev
25
+ servers: a session a key already opened lasts up to 24 hours and is not re-checked against
26
+ the key until its server restarts.
27
+ - **`tosijs-tunnel` refreshes the box's root page** when it registers a workspace, so a newly
28
+ tunnelled project appears in the list straight away. The box's root page escapes project
29
+ names, which it inserted into the page as they were.
30
+ - The edit key opens a `globalKeys: 'view'` workspace read-only, as the view key does.
31
+
32
+ ### Security
33
+
34
+ - **Source and data writes over the tunnel must come from the workspace's own origin**
35
+ (affected: 1.9.0 to 1.16.25). They relied on the session cookie's `SameSite=Lax`, but every
36
+ host under the preview domain is the same *site*, so a page on another project's preview (its
37
+ live examples included) could POST to your workspace with your session attached. The haltija
38
+ bridge's component and socket over the tunnel check the same (affected: 1.12.2 to 1.16.25).
39
+ - **A read-only session can no longer attach the haltija agent bridge** (affected: 1.15.0 to
40
+ 1.16.25, with `haltijaDev: 'tunnel'`). A share link's session passed the bridge's gate, which
41
+ asked only whether the session was valid.
42
+ - **A redirect after redeeming a link stays on the workspace.** A link at a path starting `//`
43
+ redirected to another host.
44
+
45
+ ## 1.16.25
46
+
47
+ ### Dev server
48
+
49
+ - **`editableData`: folders an editor on the dev page may save to** (#3365). A level or
50
+ scene editor running on a dev-server page can list, read and save the project's data files
51
+ through `/__docstore/data`, behind the same gate as source: this machine, an edit session
52
+ over the tunnel, or the LAN under `trustLan`. Share links stay read-only.
53
+ `editableData: ['static/levels']`, or `{ dirs, extensions, commit }`. Only data formats may
54
+ be allowed (`.json` by default; also `.jsonc` `.json5` `.ndjson` `.txt` `.csv` `.tsv`
55
+ `.yaml` `.yml` `.toml`), so nothing saved there runs or renders. Dot-files and symlinks are
56
+ never written, nor are common configuration names (`package.json`, `tsconfig.json`,
57
+ `firebase.json`, `*.config.*`), though the folders you name are the real boundary. A `.json`
58
+ save must parse, and a save replaces the file whole. A folder outside the project, the
59
+ project itself, one in or around `.git` or `node_modules`, or one overlapping the build
60
+ output stops the server at launch. `tosijs-ui/site` exports the `EditableDataOptions` type
61
+ and the `DATA_EXTENSIONS` list.
62
+ - **`commit: true` makes every save a version.** Each save is its own commit
63
+ (`data: save <file>`, naming who saved it), and other staged work is left alone. The
64
+ repository's hooks run, and each git step gets 20 seconds. Nothing is committed while the
65
+ repository is mid-rebase, mid-merge, cherry-picking, reverting, bisecting or on a detached
66
+ HEAD. `?history=<file>` lists a file's latest 100 versions and `?file=<file>&at=<commit>` reads
67
+ one back. A save that wrote the file but did not commit answers with `commitError` instead of
68
+ reporting a clean save. Each committed save waits for git, so it suits an explicit Save, not
69
+ autosave.
70
+ - **`tosijs-ui/doc-system/data-client`** wraps the endpoint for an editor: `listData`,
71
+ `readData` (text, or `{ json: true }`, or `{ at }`), `saveData` (a string as is, anything
72
+ else as formatted JSON) and `dataHistory`. A refusal rejects with a `DataStoreError` carrying
73
+ the status, and so does a page answering for the endpoint (a static host's catch-all), so
74
+ `listData()` tells an editor whether to offer saving.
75
+ - **`trustLan` can name your network**, so a committed setting trusts nobody on anyone else's:
76
+ `trustLan: '192.168.1.0/24'` or a list, IPv4 or IPv6. Only private addresses inside it are
77
+ trusted. **`TOSIJS_TRUST_LAN`** sets it for one run and wins over the config either way: `1`,
78
+ a network, or `0` to turn a committed setting off. A network that cannot be parsed stops the
79
+ server at launch, and the launch warning names who is trusted and where the setting came from.
80
+ A refused device outside the named networks is told so in the log, and the messages readers
81
+ see when the LAN is refused now suggest these forms rather than `trustLan: true`.
82
+ - **The SPA fallback shows the page the URL names.** Was: a path with no file of its own got
83
+ the root page, which then asked for its assets relative to that path, got 404s and never
84
+ hydrated; with `home` set, a reader saw the home doc. Now the dev server serves the root page
85
+ with its relative URLs made root-relative (in-page `#` links still stay on the page), and
86
+ hydration adopts a pre-rendered article only when it is the doc being shown. Generated pages
87
+ stamp the article with `data-doc` for that. The asset fix is the dev server's; a static host's
88
+ own catch-all rewrite is unchanged.
89
+
3
90
  ## 1.16.24
4
91
 
5
92
  `dist/iife.js` grows by about 0.2 kB gzip.
@@ -24,9 +24,11 @@ Three deliberate properties:
24
24
  anything else through Caddy, that is destructive — so the dry run prints a diff against
25
25
  the live file and you get to look at it before `--go`.
26
26
 
27
- THE SECRET NEVER LEAVES THE BOX. Substitution happens remotely, reading
28
- `/etc/caddy/preview.env`. `PREVIEW_TOKEN` is never transmitted, never appears in argv on
29
- this machine, and is redacted out of the diff before it is printed.
27
+ THIS COMMAND NEVER CARRIES THE SECRETS. Substitution happens remotely, reading
28
+ `/etc/caddy/preview.env`. `PREVIEW_TOKEN` and `PREVIEW_EDIT_TOKEN` are never transmitted by
29
+ it, never appear in argv on this machine, and are redacted out of the diff before it is
30
+ printed. (Global keys are a separate choice: to use them, you copy the same values into
31
+ ~/local-secrets on the machines that run dev servers.)
30
32
 
31
33
  VALIDATE BEFORE SWAP. `caddy fmt` then `caddy validate` run against a scratch file; the
32
34
  live config is replaced by an atomic `mv` only once it is known to parse. A rejected
@@ -121,14 +123,28 @@ set -a; . /etc/caddy/preview.env; set +a
121
123
  : "\${ACME_EMAIL:?set ACME_EMAIL in /etc/caddy/preview.env}"
122
124
  : "\${PREVIEW_DOMAIN:?set PREVIEW_DOMAIN in /etc/caddy/preview.env}"
123
125
 
126
+ # The global EDIT key is optional. Unset, the template still needs a value, so it gets one
127
+ # nobody knows and the edit cookie opens nothing. Set, it is compared by Caddy with no rate
128
+ # limit in front, so it must be long, and the cookie match needs a plain alphabet.
129
+ if [ -z "\${PREVIEW_EDIT_TOKEN:-}" ]; then
130
+ PREVIEW_EDIT_TOKEN="unset-$(od -An -tx1 -N24 /dev/urandom | tr -d ' \\n')"
131
+ elif ! printf '%s' "$PREVIEW_EDIT_TOKEN" | grep -qE '^[A-Za-z0-9_-]{24,}$'; then
132
+ echo "PREVIEW_EDIT_TOKEN must be at least 24 letters, digits, - or _ — refusing to install." >&2
133
+ exit 1
134
+ elif [ "$PREVIEW_EDIT_TOKEN" = "$PREVIEW_TOKEN" ]; then
135
+ echo "PREVIEW_EDIT_TOKEN equals PREVIEW_TOKEN, so every preview link could edit — refusing to install." >&2
136
+ exit 1
137
+ fi
138
+
124
139
  sed -e "s|__PREVIEW_TOKEN__|$PREVIEW_TOKEN|g" \\
140
+ -e "s|__PREVIEW_EDIT_TOKEN__|$PREVIEW_EDIT_TOKEN|g" \\
125
141
  -e "s|{{ACME_EMAIL}}|$ACME_EMAIL|g" \\
126
142
  -e "s|{{PREVIEW_DOMAIN}}|$PREVIEW_DOMAIN|g" \\
127
143
  /etc/caddy/Caddyfile.tpl > /etc/caddy/Caddyfile.new
128
144
 
129
- if grep -qE '\\{\\{|__PREVIEW_TOKEN__' /etc/caddy/Caddyfile.new; then
145
+ if grep -qE '\\{\\{|__PREVIEW_(EDIT_)?TOKEN__' /etc/caddy/Caddyfile.new; then
130
146
  echo "Unsubstituted placeholders remain — refusing to install:" >&2
131
- grep -nE '\\{\\{|__PREVIEW_TOKEN__' /etc/caddy/Caddyfile.new >&2
147
+ grep -nE '\\{\\{|__PREVIEW_(EDIT_)?TOKEN__' /etc/caddy/Caddyfile.new >&2
132
148
  rm -f /etc/caddy/Caddyfile.new
133
149
  exit 1
134
150
  fi
@@ -150,7 +166,8 @@ else
150
166
  echo "--- would change /etc/caddy/Caddyfile (token redacted) ---"
151
167
  if [ -f /etc/caddy/Caddyfile ]; then
152
168
  diff -u /etc/caddy/Caddyfile /etc/caddy/Caddyfile.new \\
153
- | sed "s|$PREVIEW_TOKEN|***REDACTED***|g" || true
169
+ | sed -e "s|$PREVIEW_TOKEN|***REDACTED***|g" \\
170
+ -e "s|$PREVIEW_EDIT_TOKEN|***REDACTED***|g" || true
154
171
  else
155
172
  echo "(no existing /etc/caddy/Caddyfile — this would create it)"
156
173
  fi
@@ -31,6 +31,7 @@ import { existsSync } from 'fs'
31
31
  import {
32
32
  previewRootFor,
33
33
  registerCaddyFragment,
34
+ refreshHostIndex,
34
35
  resolvePreviewHost,
35
36
  parseArgv,
36
37
  } from './resolve-site-config.ts'
@@ -205,51 +206,10 @@ async function registerSite(): Promise<boolean> {
205
206
  return ok
206
207
  }
207
208
 
208
- /** Refresh the host's index so this deploy shows up immediately. */
209
- async function refreshIndex(): Promise<void> {
210
- const remote = `/tmp/tosi-build-index-${process.pid}.sh`
211
- /*
212
- Resolve the script against THIS PACKAGE, not the adopter's cwd.
213
-
214
- `deploy/` is in `files`, so the asset ships at node_modules/tosijs-ui/deploy/ — only
215
- the resolution was wrong, which meant every adopter's scp failed, the failure was
216
- swallowed by a bare `return`, and the deploy still printed "✅ Deployed". Exactly the
217
- #37 class of bug, one cycle later: a package asset located relative to whoever called us.
218
- */
219
- const script = `${import.meta.dir}/../deploy/build-index.sh`
220
- const put = await $`scp -q ${script} ${`${host}:${remote}`}`.nothrow().quiet()
221
- if (put.exitCode !== 0) {
222
- // Loudly: the deploy itself succeeded, so this must not read as total failure —
223
- // but a silently stale index is how you conclude a deploy did not land.
224
- console.warn(
225
- `⚠️ Could not refresh the host index (scp of ${script} failed).\n` +
226
- ` The deploy landed; the listing at the root will be stale until the next one.\n` +
227
- ` ${put.stderr.toString().trim().slice(0, 200)}`
228
- )
229
- return
230
- }
231
- const run = await $`ssh ${host} bash ${remote}`.nothrow().quiet()
232
- await $`ssh ${host} rm -f ${remote}`.nothrow().quiet()
233
- // build-index.sh is `set -euo pipefail`; a non-root user on a root-owned tree dies at
234
- // mkdir. Discarding this exit code meant the deploy printed ✅ over a failed index.
235
- if (run.exitCode !== 0) {
236
- console.warn(
237
- `⚠️ the host index was not refreshed (exit ${run.exitCode}). The deploy landed;\n` +
238
- ` the listing at the root will be stale until the next successful one.\n` +
239
- ` ${(run.stderr.toString() || run.stdout.toString())
240
- .trim()
241
- .slice(0, 200)}`
242
- )
243
- return
244
- }
245
- const out = run.stdout.toString().trim()
246
- if (out) console.log(` ${out}`)
247
- }
248
-
249
209
  let routed = true
250
210
  if (go) {
251
211
  routed = await registerSite()
252
- await refreshIndex()
212
+ await refreshHostIndex(host!)
253
213
  }
254
214
 
255
215
  /*
@@ -19,7 +19,8 @@ silently picking up a parent directory's config would mean publishing the wrong
19
19
  and "it found something" is not the same as "it found yours".
20
20
  */
21
21
 
22
- import { existsSync, readFileSync } from 'fs'
22
+ import { $ } from 'bun'
23
+ import { existsSync } from 'fs'
23
24
  import * as path from 'path'
24
25
 
25
26
  export interface PreviewConfig {
@@ -151,47 +152,11 @@ export {
151
152
  } from '../dist/doc-system/site/site-config.js'
152
153
 
153
154
  /*
154
- Where the preview host comes from — ONE implementation, and a fallback that works for
155
- things that are not a human at an interactive prompt.
156
-
157
- `PREVIEW_HOST` lives in `~/local-secrets/tosijs-preview.env`: mode 700, a sibling of the
158
- repos rather than inside one, so it is structurally impossible to commit. That is a stronger
159
- guarantee than a rule saying "don't commit the host", which this project wrote, believed, and
160
- then violated in its own `tosijs-site.config.ts` for months.
161
-
162
- The fallback exists because the previous advice — "export it from your shell profile" — is
163
- present for a human and ABSENT for every tool: non-interactive shells inherit no interactive
164
- profile, so an agent or a CI step sees nothing and reports a missing credential rather than a
165
- missing `export`. Reading the file directly makes the resolution order true for both.
166
-
167
- Parses only `KEY=value` / `export KEY=value`, strips one layer of quotes, ignores the rest.
168
- Not a shell — a credentials file that needs evaluating is a credentials file that can run
169
- code.
155
+ `readLocalSecret` lives in the package (`src/doc-system/site/local-secrets.ts`) because the dev
156
+ server reads the global tunnel keys from the same file; it is re-exported here for the bins.
170
157
  */
171
- export function readLocalSecret(
172
- name: string,
173
- file = path.join(
174
- process.env.HOME ?? '',
175
- 'local-secrets',
176
- 'tosijs-preview.env'
177
- )
178
- ): string | undefined {
179
- if (!process.env.HOME || !existsSync(file)) return undefined
180
- let text: string
181
- try {
182
- text = readFileSync(file, 'utf8')
183
- } catch {
184
- return undefined
185
- }
186
- for (const line of text.split('\n')) {
187
- const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(
188
- line
189
- )
190
- if (!m || m[1] !== name) continue
191
- return m[2].trim().replace(/^(['"])(.*)\1$/, '$2')
192
- }
193
- return undefined
194
- }
158
+ export { readLocalSecret } from '../dist/doc-system/site/local-secrets.js'
159
+ import { readLocalSecret } from '../dist/doc-system/site/local-secrets.js'
195
160
 
196
161
  /**
197
162
  * `--host=` > `PREVIEW_HOST` > `PREVIEW_SSH` (legacy) > **site config** > `~/local-secrets`.
@@ -434,3 +399,47 @@ export function parseArgv(argv: string[], spec: ArgvSpec): Argv {
434
399
  rest: argv.filter((a) => !a.startsWith('--')),
435
400
  }
436
401
  }
402
+
403
+ /**
404
+ * Refresh the host's index so a deploy, or a newly registered tunnel, shows up immediately.
405
+ * Shared by `tosijs-deploy` and `tosijs-tunnel`.
406
+ */
407
+ export async function refreshHostIndex(host: string): Promise<void> {
408
+ const remote = `/tmp/tosi-build-index-${process.pid}.sh`
409
+ /*
410
+ Resolve the script against THIS PACKAGE, not the adopter's cwd.
411
+
412
+ `deploy/` is in `files`, so the asset ships at node_modules/tosijs-ui/deploy/ — only
413
+ the resolution was wrong, which meant every adopter's scp failed, the failure was
414
+ swallowed by a bare `return`, and the deploy still printed "✅ Deployed". Exactly the
415
+ #37 class of bug, one cycle later: a package asset located relative to whoever called us.
416
+ */
417
+ const script = `${import.meta.dir}/../deploy/build-index.sh`
418
+ const put = await $`scp -q ${script} ${`${host}:${remote}`}`.nothrow().quiet()
419
+ if (put.exitCode !== 0) {
420
+ // Loudly: the deploy or tunnel itself succeeded, so this must not read as total
421
+ // failure — but a silently stale index is how you conclude it did not land.
422
+ console.warn(
423
+ `⚠️ Could not refresh the host index (scp of ${script} failed).\n` +
424
+ ` The listing at the root will be stale until the next refresh.\n` +
425
+ ` ${put.stderr.toString().trim().slice(0, 200)}`
426
+ )
427
+ return
428
+ }
429
+ const run = await $`ssh ${host} bash ${remote}`.nothrow().quiet()
430
+ await $`ssh ${host} rm -f ${remote}`.nothrow().quiet()
431
+ // build-index.sh is `set -euo pipefail`; a non-root user on a root-owned tree dies at
432
+ // mkdir. Discarding this exit code meant the deploy printed ✅ over a failed index.
433
+ if (run.exitCode !== 0) {
434
+ console.warn(
435
+ `⚠️ the host index was not refreshed (exit ${run.exitCode}). The listing at\n` +
436
+ ` the root will be stale until the next successful refresh.\n` +
437
+ ` ${(run.stderr.toString() || run.stdout.toString())
438
+ .trim()
439
+ .slice(0, 200)}`
440
+ )
441
+ return
442
+ }
443
+ const out = run.stdout.toString().trim()
444
+ if (out) console.log(` ${out}`)
445
+ }
package/bin/tunnel.ts CHANGED
@@ -34,6 +34,7 @@ import { $ } from 'bun'
34
34
  import {
35
35
  previewRootFor,
36
36
  registerCaddyFragment,
37
+ refreshHostIndex,
37
38
  resolveDevPort,
38
39
  resolvePreviewHost,
39
40
  resolveSiteConfig,
@@ -418,6 +419,8 @@ if (publicUrl) {
418
419
  describe: `${hostname} → this workspace`,
419
420
  body: fragment,
420
421
  })
422
+ // The root's list of live workspaces is read from these fragments.
423
+ if (routed) await refreshHostIndex(host!)
421
424
  }
422
425
  }
423
426
 
package/deploy/Caddyfile CHANGED
@@ -2,8 +2,10 @@
2
2
  #
3
3
  # {{ACME_EMAIL}} your Let's Encrypt account email
4
4
  # {{PREVIEW_DOMAIN}} the domain your preview hosts live under (e.g. dev.example.com)
5
- # __PREVIEW_TOKEN__ the shared invite secret, substituted at install time from
6
- # /etc/caddy/preview.env — NEVER commit a real value here
5
+ # __PREVIEW_TOKEN__ the shared invite secret (the global VIEW key), substituted at
6
+ # install time from /etc/caddy/preview.env — NEVER commit a real value
7
+ # __PREVIEW_EDIT_TOKEN__ the global EDIT key, from the same file; optional (unset, the
8
+ # installer fills in a value nobody knows)
7
9
  #
8
10
  # Installing this file with the placeholders intact gives you a preview host whose entire
9
11
  # invite gate is a literal string published in a public repo and in every npm tarball, and
@@ -64,7 +66,28 @@
64
66
  redir * / 302
65
67
  }
66
68
 
67
- @unauthed not header_regexp Cookie "tosi_preview=__PREVIEW_TOKEN__"
69
+ # THE GLOBAL EDIT KEY: `?t=<edit key>` on any preview host sets `tosi_edit` across the
70
+ # whole preview domain for 24 hours. Every tunnelled workspace that holds the same key
71
+ # (PREVIEW_EDIT_TOKEN in ~/local-secrets/tosijs-preview.env on the machine running it)
72
+ # trades it for an editing session on the first page load, so the root's list opens them
73
+ # all, across dev-server restarts. It also opens these static previews. The view key above
74
+ # opens workspaces read-only the same way. See `globalKeys` in doc-site-system.md.
75
+ #
76
+ # The cookie is the key itself: Caddy can compare a fixed value and cannot sign one. A
77
+ # leaked edit cookie is write access to every workspace that accepts the key until it is
78
+ # rotated (edit preview.env here and on each machine, re-run the installer).
79
+ @editlink query t=__PREVIEW_EDIT_TOKEN__
80
+ handle @editlink {
81
+ header Set-Cookie "tosi_edit=__PREVIEW_EDIT_TOKEN__; Domain=.{{PREVIEW_DOMAIN}}; Path=/; Max-Age=86400; HttpOnly; Secure; SameSite=Lax"
82
+ header Referrer-Policy "no-referrer"
83
+ header Cache-Control "no-store"
84
+ redir * / 302
85
+ }
86
+
87
+ @unauthed {
88
+ not header_regexp Cookie "tosi_preview=__PREVIEW_TOKEN__"
89
+ not header_regexp Cookie "tosi_edit=__PREVIEW_EDIT_TOKEN__"
90
+ }
68
91
  handle @unauthed {
69
92
  header Content-Type "text/html; charset=utf-8"
70
93
  header Cache-Control "no-store"
@@ -21,6 +21,9 @@
21
21
  set -euo pipefail
22
22
 
23
23
  ROOT=/srv/preview
24
+
25
+ # Names come from config and file names; nothing reaches the page unescaped.
26
+ html() { printf '%s' "$1" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g' -e 's/"/\&quot;/g'; }
24
27
  OUT="$ROOT/_index"
25
28
  mkdir -p "$OUT"
26
29
 
@@ -39,6 +42,7 @@ for dir in "$ROOT"/*/; do
39
42
  gen=$(sed -n 's/.*"generator": *"\([^"]*\)".*/\1/p' "$vj" | head -1)
40
43
  site=$(sed -n 's/.*"site": *"\([^"]*\)".*/\1/p' "$vj" | head -1)
41
44
  [ -n "$site" ] || site="$name"
45
+ site=$(html "$site")
42
46
 
43
47
  # The hostname comes from the project's OWN Caddy fragment — the same file the
44
48
  # router reads — so this listing cannot disagree with what actually serves. An
@@ -71,6 +75,24 @@ for dir in "$ROOT"/*/; do
71
75
  </li>"
72
76
  done
73
77
 
78
+ # Tunnelled workspaces: every registered `-tunnel` fragment. Whether one is UP, and what
79
+ # the reader may do there, changes by the minute, so the page asks each one when it loads
80
+ # (see the script below); this only lists where to ask.
81
+ live=""
82
+ for frag in "$ROOT"/_sites/*-tunnel.caddy; do
83
+ [ -f "$frag" ] || continue
84
+ name="$(html "$(basename "$frag" -tunnel.caddy)")"
85
+ host=$(sed -n 's/^\([A-Za-z0-9.-]*\) *{.*/\1/p' "$frag" | head -1)
86
+ [ -n "$host" ] || continue
87
+ live="$live
88
+ <li data-workspace=\"$host\" hidden>
89
+ <a href=\"https://$host/\">
90
+ <span class=\"name\">$name</span>
91
+ <span class=\"meta\"><span class=\"access\"></span><code>$host</code></span>
92
+ </a>
93
+ </li>"
94
+ done
95
+
74
96
  # The REAL tosiPlatform icon, lifted verbatim from src/icons/data/tosiPlatform.ts.
75
97
  # (The first version of this file hand-drew a rough approximation — a blue box with a
76
98
  # stroke in it — which is exactly the sort of thing nobody notices in review and
@@ -108,6 +130,9 @@ cat > "$OUT/index.html" <<HTML
108
130
  code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
109
131
  .gen { display:block; color:var(--dim); font-size:.75rem; margin-top:.3rem; }
110
132
  .empty { color:var(--dim); }
133
+ h2 { font-size:1rem; margin:2rem 0 .75rem; font-weight:600; }
134
+ .access { font-weight:600; }
135
+ .access.edit { color:#2a8a3e; }
111
136
  footer { color:var(--dim); font-size:.8rem; margin-top:2.5rem; }
112
137
  </style>
113
138
  <main>
@@ -116,6 +141,8 @@ cat > "$OUT/index.html" <<HTML
116
141
  $(if [ "$count" -eq 0 ]; then echo '<p class="empty">Nothing deployed yet.</p>'; else echo "<ul>$rows
117
142
  </ul>"; fi)
118
143
  $(if [ -n "$unlinked" ]; then echo "<p class=\"empty\">Deployed but not routed:$unlinked</p>"; fi)
144
+ $(if [ -n "$live" ]; then echo "<section id=\"live\" hidden><h2>Live workspaces</h2><ul>$live
145
+ </ul></section>"; fi)
119
146
  <footer>Previews only — not production. Each entry updates when that project deploys (<code>bun run deploy --go</code>), not when it builds.</footer>
120
147
  </main>
121
148
  <script>
@@ -133,6 +160,24 @@ cat > "$OUT/index.html" <<HTML
133
160
  t.textContent = rel
134
161
  t.title = d.toLocaleString()
135
162
  }
163
+
164
+ // Ask each tunnelled workspace whether it is up and what this reader may do there. Only
165
+ // this origin may read the answer, and it reflects the key cookie this page set; a
166
+ // workspace that is down (or older than this probe) shows nothing. Opening one trades the
167
+ // key for a session there, so these are plain links.
168
+ for (const li of document.querySelectorAll('[data-workspace]')) {
169
+ fetch('https://' + li.dataset.workspace + '/__alive', { credentials: 'include' })
170
+ .then((r) => (r.ok && r.status === 200 ? r.json() : null))
171
+ .then((info) => {
172
+ if (!info || !info.access) return
173
+ const label = li.querySelector('.access')
174
+ label.textContent = info.access === 'edit' ? 'edit' : 'view'
175
+ label.classList.add(info.access)
176
+ li.hidden = false
177
+ document.getElementById('live').hidden = false
178
+ })
179
+ .catch(() => {})
180
+ }
136
181
  </script>
137
182
  HTML
138
183
 
@@ -111,6 +111,7 @@ import { tosiLocalized } from './localize.js';
111
111
  import { popMenu } from './menu.js';
112
112
  import { codeEditor } from './code-editor.js';
113
113
  import { tosiDiff } from './diff.js';
114
+ import { LAN_REFUSAL_ADVICE } from './live-example/source-access.js';
114
115
  /*
115
116
  Does this doc contain a REAL ` ```test ` block, as opposed to mentioning one?
116
117
 
@@ -954,8 +955,18 @@ export function createDocBrowser(options) {
954
955
  };
955
956
  let adoptInitialContent = contentElement !== undefined && !memoryRouting;
956
957
  const showDoc = (doc) => {
957
- if (adoptInitialContent) {
958
- adoptInitialContent = false; // leave the pre-rendered HTML untouched
958
+ /*
959
+ Adopt the pre-render only if it IS this doc. The dev server's SPA fallback serves the root
960
+ page for a path with no file of its own, so `/guide/` can arrive holding the home page's
961
+ article (#3346). The generator stamps `data-doc`; a page without it predates the stamp
962
+ and is adopted as before.
963
+ */
964
+ const prerendered = contentElement?.dataset.doc;
965
+ const adopt = adoptInitialContent &&
966
+ (prerendered === undefined || prerendered === doc.filename);
967
+ adoptInitialContent = false;
968
+ if (adopt) {
969
+ // leave the pre-rendered HTML untouched
959
970
  }
960
971
  else {
961
972
  // Pass the doc's build-time tjs bakes so this client-rendered page embeds the
@@ -1291,7 +1302,7 @@ export function createDocBrowser(options) {
1291
1302
  window.alert('This workspace needs an invite link before it can save.\n\n' +
1292
1303
  'Run `tosijs-tunnel --link` on the machine hosting it, open the link once, ' +
1293
1304
  'then try again. Your edit is still here.\n\n' +
1294
- 'If you opened this over the LAN (a `.local` or local IP address), invite links will not help: set `trustLan: true` in the site config, or use its tunnel URL instead.');
1305
+ LAN_REFUSAL_ADVICE);
1295
1306
  }
1296
1307
  else {
1297
1308
  // Genuinely no write endpoint (a deployed static site) — hand the file back.
@@ -1306,7 +1317,7 @@ export function createDocBrowser(options) {
1306
1317
  window.alert(sourceUnauthorized
1307
1318
  ? 'This workspace needs an invite link before you can edit.\n\n' +
1308
1319
  'Run `tosijs-tunnel --link` on the machine hosting it and open the link once.\n\n' +
1309
- 'If you opened this over the LAN (a `.local` or local IP address), invite links will not help: set `trustLan: true` in the site config, or use its tunnel URL instead.'
1320
+ LAN_REFUSAL_ADVICE
1310
1321
  : `Could not load source for ${doc.path}`);
1311
1322
  return;
1312
1323
  }
@@ -0,0 +1,35 @@
1
+ export declare class DataStoreError extends Error {
2
+ status: number;
3
+ constructor(message: string, status: number);
4
+ }
5
+ /** Every editable file, project-relative, newest first. */
6
+ export declare function listData(): Promise<{
7
+ files: {
8
+ file: string;
9
+ size: number;
10
+ modified: string;
11
+ }[];
12
+ }>;
13
+ /** A file's text, or parsed JSON with `json: true`; `at` reads it as of a commit. */
14
+ export declare function readData(file: string, options: {
15
+ at?: string;
16
+ json: true;
17
+ }): Promise<any>;
18
+ export declare function readData(file: string, options?: {
19
+ at?: string;
20
+ json?: false;
21
+ }): Promise<string>;
22
+ /** Save a file: a string as it is, anything else as formatted JSON. */
23
+ export declare function saveData(file: string, content: unknown): Promise<{
24
+ ok: true;
25
+ file: string;
26
+ commit?: string | null;
27
+ commitError?: string;
28
+ }>;
29
+ /** A file's committed versions, newest first. */
30
+ export declare function dataHistory(file: string): Promise<{
31
+ commit: string;
32
+ date: string;
33
+ author: string;
34
+ subject: string;
35
+ }[]>;
@@ -0,0 +1,105 @@
1
+ /*{ "parent": "Documentation System", "order": 85 }*/
2
+ /*#
3
+ # data client
4
+
5
+ For an editor running on a dev-server page (a level editor, a scene editor) to open and save
6
+ the project's data files through `/__docstore/data`. Turn it on by naming the folders in the
7
+ site config; [`editableData`](/doc-site-system/) in the doc-site system reference has the
8
+ rules.
9
+
10
+ ```typescript
11
+ // tosijs-site.config.ts
12
+ editableData: { dirs: ['static/levels'], commit: true }
13
+ ```
14
+
15
+ ```typescript
16
+ import { listData, readData, saveData, dataHistory } from 'tosijs-ui/doc-system/data-client'
17
+
18
+ const { files } = await listData()
19
+ const level = await readData('static/levels/ocean.json', { json: true })
20
+ level.spawn = [0, 2, 0]
21
+ const { commit } = await saveData('static/levels/ocean.json', level)
22
+ const versions = await dataHistory('static/levels/ocean.json')
23
+ if (versions.length > 1) {
24
+ const before = await readData('static/levels/ocean.json', { at: versions[1].commit })
25
+ }
26
+ ```
27
+
28
+ `saveData` writes an object as JSON (two-space indent, trailing newline) and a string as it
29
+ is. With `commit: true` in the config each save is its own commit, and `commit` in the result
30
+ is its hash (`null` when the save changed nothing). A save that wrote but could not commit
31
+ (a failing hook, or a repository mid-rebase) resolves with `commitError` set rather than
32
+ rejecting, because the file was saved. Each committed save runs git and the repository's
33
+ hooks before it answers, so it suits an explicit Save, not autosave.
34
+
35
+ **Dev server only.** Everything else rejects with a `DataStoreError` whose `status` says why:
36
+ `501` when the server has no `editableData`, `403` when this page may not edit files here or
37
+ the file is not editable, `400` for content it refuses (JSON that does not parse). A response
38
+ that is not the endpoint's (a deployed site's catch-all answering with a page) rejects too, so
39
+ `listData()` is the probe for whether to offer saving at all.
40
+
41
+ File names may use letters, digits, `-`, `_`, `.`, `/` and spaces. `dataHistory` returns the
42
+ latest 100 versions.
43
+ */
44
+ const ENDPOINT = '/__docstore/data';
45
+ export class DataStoreError extends Error {
46
+ status;
47
+ constructor(message, status) {
48
+ super(message);
49
+ this.status = status;
50
+ this.name = 'DataStoreError';
51
+ }
52
+ }
53
+ async function ask(query, init) {
54
+ const response = await fetch(`${ENDPOINT}${query}`, {
55
+ credentials: 'same-origin',
56
+ ...init,
57
+ });
58
+ /*
59
+ The endpoint answers JSON or the file's text, never a page. An HTML answer is something
60
+ else's: a static host's catch-all serving its index for every path.
61
+ */
62
+ const type = response.headers.get('content-type') ?? '';
63
+ if (response.ok && type.includes('text/html')) {
64
+ throw new DataStoreError(`${init?.method ?? 'GET'} ${ENDPOINT}${query}: answered with a page, not the data endpoint (no dev server here?)`, response.status);
65
+ }
66
+ if (!response.ok) {
67
+ const text = await response.text();
68
+ let message = text;
69
+ try {
70
+ message = JSON.parse(text).error ?? text;
71
+ }
72
+ catch {
73
+ // plain text
74
+ }
75
+ throw new DataStoreError(`${init?.method ?? 'GET'} ${ENDPOINT}${query}: ${response.status} ${message}`, response.status);
76
+ }
77
+ return response;
78
+ }
79
+ const q = (params) => '?' + new URLSearchParams(params).toString();
80
+ /** Every editable file, project-relative, newest first. */
81
+ export async function listData() {
82
+ return (await ask('?list')).json();
83
+ }
84
+ export async function readData(file, options = {}) {
85
+ const params = { file };
86
+ if (options.at)
87
+ params.at = options.at;
88
+ const text = await (await ask(q(params))).text();
89
+ return options.json ? JSON.parse(text) : text;
90
+ }
91
+ /** Save a file: a string as it is, anything else as formatted JSON. */
92
+ export async function saveData(file, content) {
93
+ const text = typeof content === 'string'
94
+ ? content
95
+ : JSON.stringify(content, null, 2) + '\n';
96
+ return (await ask('', {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json' },
99
+ body: JSON.stringify({ file, content: text }),
100
+ })).json();
101
+ }
102
+ /** A file's committed versions, newest first. */
103
+ export async function dataHistory(file) {
104
+ return (await (await ask(q({ history: file }))).json()).versions;
105
+ }