tosijs-ui 1.16.25 → 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 +42 -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-system/site/dev-auth.d.ts +109 -0
- package/dist/doc-system/site/dev-auth.js +212 -18
- package/dist/doc-system/site/dev-server.js +80 -8
- 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 +20 -1
- package/dist/iife.js +2 -2
- package/dist/iife.js.map +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
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
|
+
|
|
3
45
|
## 1.16.25
|
|
4
46
|
|
|
5
47
|
### Dev server
|
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
|
|
|
@@ -156,6 +156,8 @@ export declare function resolveShareTtlMs(tunnel?: {
|
|
|
156
156
|
* the set before the lookup. Widening reuse must not widen lifetime.
|
|
157
157
|
*/
|
|
158
158
|
export declare function redeemLink(state: AuthState, token: string, now: number, policy?: LinkPolicy): string | null;
|
|
159
|
+
/** Mint a session token directly: what a redeemed link or a global key turns into. */
|
|
160
|
+
export declare function issueSession(state: AuthState, now: number, ttlMs: number, readOnly: boolean): string;
|
|
159
161
|
export declare const REDEEM_MIN_MS = 100;
|
|
160
162
|
/** Consecutive failures after which the slot widens. A human never reaches this. */
|
|
161
163
|
export declare const SLOW_AFTER_FAILURES = 10;
|
|
@@ -268,6 +270,14 @@ export declare function sessionCookie(token: string, maxAgeMs?: number): string;
|
|
|
268
270
|
* defeat the entire design.
|
|
269
271
|
*/
|
|
270
272
|
export declare function urlWithoutToken(rawUrl: string, param: string): string;
|
|
273
|
+
/**
|
|
274
|
+
* A request's path, query and hash, for a `Location` that cannot leave this origin.
|
|
275
|
+
*
|
|
276
|
+
* A path is not always same-origin as a Location: `GET //evil.example/x` has the pathname
|
|
277
|
+
* `//evil.example/x`, which a browser reads as a protocol-relative URL to another host. Runs of
|
|
278
|
+
* leading slashes collapse to one.
|
|
279
|
+
*/
|
|
280
|
+
export declare function sameOriginPath(rawUrl: string): string;
|
|
271
281
|
export declare function isSameOriginRequest(request: {
|
|
272
282
|
url?: string;
|
|
273
283
|
headers: {
|
|
@@ -361,6 +371,82 @@ export declare function sessionMayWrite(state: AuthState, token: string | null |
|
|
|
361
371
|
* runs — but that is a property of the CALL ORDER, so keep the two together at every call site.
|
|
362
372
|
*/
|
|
363
373
|
export declare function cookieMayWrite(state: AuthState, cookieValue: string | undefined | null): boolean;
|
|
374
|
+
/** Caddy's cookie for the view key; the static previews' invite gate already sets it. */
|
|
375
|
+
export declare const GLOBAL_VIEW_COOKIE = "tosi_preview";
|
|
376
|
+
/** Caddy's cookie for the edit key. */
|
|
377
|
+
export declare const GLOBAL_EDIT_COOKIE = "tosi_edit";
|
|
378
|
+
/** Shorter keys are refused: Caddy compares them with no rate limit in front. */
|
|
379
|
+
export declare const GLOBAL_KEY_MIN_LENGTH = 24;
|
|
380
|
+
/**
|
|
381
|
+
* A session minted from a global key lasts a day, or until this dev server stops (sessions
|
|
382
|
+
* live in memory). The key is NOT re-checked while the session lives, so rotating a key ends
|
|
383
|
+
* the sessions it already opened only when each workspace restarts.
|
|
384
|
+
*/
|
|
385
|
+
export declare const GLOBAL_KEY_SESSION_TTL_MS: number;
|
|
386
|
+
/**
|
|
387
|
+
* The most live sessions one server holds; past it the oldest goes. Only a key holder can mint
|
|
388
|
+
* one per page load, so this bounds a browser stuck reloading, not an attacker.
|
|
389
|
+
*/
|
|
390
|
+
export declare const MAX_SESSIONS = 1000;
|
|
391
|
+
export interface GlobalKeys {
|
|
392
|
+
edit?: string;
|
|
393
|
+
view?: string;
|
|
394
|
+
/**
|
|
395
|
+
* What the edit key grants here: `'edit'`, or `'view'` on a workspace that refuses edit
|
|
396
|
+
* (`globalKeys: 'view'`). The edit key always opens at least what the view key does, as it
|
|
397
|
+
* does on the box.
|
|
398
|
+
*/
|
|
399
|
+
editGrants?: 'edit' | 'view';
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* The keys this workspace accepts, from `tunnel.globalKeys` and the two secrets.
|
|
403
|
+
*
|
|
404
|
+
* `false` accepts neither; `'view'` lets either key browse and neither edit, so this workspace
|
|
405
|
+
* can be opened from the box's list and never edited through it. A key that is too short or has
|
|
406
|
+
* characters Caddy's cookie match cannot hold is not used, and `problems` says why, so the server
|
|
407
|
+
* can say so at launch rather than leave a key that silently opens nothing. So does a setting
|
|
408
|
+
* that is none of these, which accepts neither key: a misspelt `'off'` must not mean "both".
|
|
409
|
+
*/
|
|
410
|
+
export declare function resolveGlobalKeys(setting: unknown, secrets: {
|
|
411
|
+
PREVIEW_TOKEN?: string;
|
|
412
|
+
PREVIEW_EDIT_TOKEN?: string;
|
|
413
|
+
}): {
|
|
414
|
+
keys: GlobalKeys;
|
|
415
|
+
problems: string[];
|
|
416
|
+
};
|
|
417
|
+
/** What the global-key cookies on a request grant: the edit key wins over the view key. */
|
|
418
|
+
export declare function globalKeyAccess(cookieHeader: string | null | undefined, keys: GlobalKeys): 'edit' | 'view' | null;
|
|
419
|
+
/** Is this a browser loading a page (not a fetch, a script or a socket)? */
|
|
420
|
+
export declare function isNavigation(request: {
|
|
421
|
+
method: string;
|
|
422
|
+
headers: {
|
|
423
|
+
get(name: string): string | null;
|
|
424
|
+
};
|
|
425
|
+
}): boolean;
|
|
426
|
+
/**
|
|
427
|
+
* Should this request trade a global key for a session, and of which kind?
|
|
428
|
+
*
|
|
429
|
+
* Only over the tunnel, only on a page load, and only when it would add something: no valid
|
|
430
|
+
* session yet, or a read-only one when the edit key is held.
|
|
431
|
+
*/
|
|
432
|
+
export declare function globalKeyExchange(request: {
|
|
433
|
+
method: string;
|
|
434
|
+
headers: {
|
|
435
|
+
get(name: string): string | null;
|
|
436
|
+
};
|
|
437
|
+
}, opts: {
|
|
438
|
+
viaTunnel: boolean;
|
|
439
|
+
auth: AuthState;
|
|
440
|
+
keys: GlobalKeys;
|
|
441
|
+
now: number;
|
|
442
|
+
}): 'edit' | 'view' | null;
|
|
443
|
+
/**
|
|
444
|
+
* The origin allowed to read this workspace's liveness answer: the box's own list.
|
|
445
|
+
*
|
|
446
|
+
* Only an https origin whose host is a parent domain of this host, so `dev.example.com` may ask
|
|
447
|
+
* `ui.edit.dev.example.com` and nothing else may.
|
|
448
|
+
*/
|
|
449
|
+
export declare function liveProbeOrigin(origin: string | null, host: string | null): string | null;
|
|
364
450
|
/**
|
|
365
451
|
* Who may attach the haltija dev channel — i.e. let an agent DRIVE this page.
|
|
366
452
|
*
|
|
@@ -377,6 +463,29 @@ export declare function mayDriveWithAgent(opts: {
|
|
|
377
463
|
viaTunnel: boolean;
|
|
378
464
|
peer?: string | null;
|
|
379
465
|
hasValidSession: boolean;
|
|
466
|
+
/** a read-only session (a share link, the view key) may look, never drive */
|
|
467
|
+
sessionMayWrite?: boolean;
|
|
468
|
+
}): boolean;
|
|
469
|
+
/**
|
|
470
|
+
* The whole haltija-bridge gate for one request, as the dev server applies it.
|
|
471
|
+
*
|
|
472
|
+
* `mayDriveWithAgent` on the request's session (a read-only one may not drive), and, for the
|
|
473
|
+
* component and the socket (`sameOriginRequired`), a same-origin request over the tunnel: a
|
|
474
|
+
* sibling host under the preview domain is the same SITE, so its pages carry the cookie. The
|
|
475
|
+
* page load that injects the loader is a navigation, often from the box's list, so it is not
|
|
476
|
+
* held to same-origin.
|
|
477
|
+
*/
|
|
478
|
+
export declare function haltijaBridgeAdmission(request: {
|
|
479
|
+
url?: string;
|
|
480
|
+
headers: {
|
|
481
|
+
get(name: string): string | null;
|
|
482
|
+
};
|
|
483
|
+
}, opts: {
|
|
484
|
+
viaTunnel: boolean;
|
|
485
|
+
peer?: string | null;
|
|
486
|
+
auth: AuthState;
|
|
487
|
+
now: number;
|
|
488
|
+
sameOriginRequired: boolean;
|
|
380
489
|
}): boolean;
|
|
381
490
|
/**
|
|
382
491
|
* THE loopback test. dev-server re-exports this as `isLoopbackAddress`; it used to
|