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 +87 -0
- package/bin/caddy-install.ts +23 -6
- package/bin/deploy-preview.ts +2 -42
- package/bin/resolve-site-config.ts +50 -41
- package/bin/tunnel.ts +3 -0
- package/deploy/Caddyfile +26 -3
- package/deploy/build-index.sh +45 -0
- package/dist/doc-browser.js +15 -4
- package/dist/doc-system/data-client.d.ts +35 -0
- package/dist/doc-system/data-client.js +105 -0
- package/dist/doc-system/site/data-store.d.ts +71 -0
- package/dist/doc-system/site/data-store.js +352 -0
- package/dist/doc-system/site/dev-auth.d.ts +147 -4
- package/dist/doc-system/site/dev-auth.js +334 -22
- package/dist/doc-system/site/dev-server.d.ts +11 -0
- package/dist/doc-system/site/dev-server.js +252 -50
- package/dist/doc-system/site/generate-site.js +1 -1
- package/dist/doc-system/site/index.d.ts +2 -0
- package/dist/doc-system/site/index.js +1 -0
- package/dist/doc-system/site/local-secrets.d.ts +1 -0
- package/dist/doc-system/site/local-secrets.js +39 -0
- package/dist/doc-system/site/site-config.d.ts +70 -9
- package/dist/iife.js +97 -89
- package/dist/iife.js.map +6 -6
- package/dist/live-example/component.js +2 -2
- package/dist/live-example/source-access.d.ts +6 -0
- package/dist/live-example/source-access.js +8 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +3 -1
- package/package.json +1 -1
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.
|
package/bin/caddy-install.ts
CHANGED
|
@@ -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
|
-
|
|
28
|
-
`/etc/caddy/preview.env`. `PREVIEW_TOKEN`
|
|
29
|
-
this machine, and
|
|
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 '\\{\\{|
|
|
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 '\\{\\{|
|
|
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"
|
|
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
|
package/bin/deploy-preview.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
155
|
-
|
|
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
|
|
172
|
-
|
|
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
|
|
6
|
-
# /etc/caddy/preview.env — NEVER commit a real value
|
|
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
|
-
|
|
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"
|
package/deploy/build-index.sh
CHANGED
|
@@ -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/&/\&/g' -e 's/</\</g' -e 's/>/\>/g' -e 's/"/\"/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
|
|
package/dist/doc-browser.js
CHANGED
|
@@ -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
|
-
|
|
958
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|