tosijs-ui 1.16.27 → 1.16.28
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 +37 -0
- package/bin/resolve-site-config.ts +101 -0
- package/bin/tunnel.ts +103 -16
- package/deploy/Caddyfile +37 -4
- package/deploy/build-index.sh +27 -19
- package/dist/doc-system/site/dev-auth.d.ts +43 -0
- package/dist/doc-system/site/dev-auth.js +67 -5
- package/dist/doc-system/site/dev-server.js +84 -6
- package/dist/doc-system/site/site-config.d.ts +4 -3
- 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,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.16.28
|
|
4
|
+
|
|
5
|
+
### Tunnel
|
|
6
|
+
|
|
7
|
+
- **`tosijs-tunnel` prints a single-use link that keeps you signed in.** Once the route is
|
|
8
|
+
registered, it prints one link per global key, such as
|
|
9
|
+
`https://ui.edit.dev.tosijs.net/?t=KFMRPWQT`. The link carries a one-use code, never the key.
|
|
10
|
+
Opening it starts a session in that workspace and gives the browser the box's key as a
|
|
11
|
+
cookie across the preview domain for 30 days. From then on that browser opens every
|
|
12
|
+
workspace holding the key, and the box's root list, without another link, across dev-server
|
|
13
|
+
restarts. The edit link expires after the usual link window (5 minutes,
|
|
14
|
+
`tunnel.linkTtlMinutes`); the view link is read-only and lasts a day (`--share-ttl`).
|
|
15
|
+
`--link --global` and `--link --global --share` print fresh ones. Plain `--link` and
|
|
16
|
+
`--link --share` are unchanged.
|
|
17
|
+
- **It only offers a link that means what it says.** It reads the box's `PREVIEW_DOMAIN` and a
|
|
18
|
+
sha256 of each key over ssh (the keys stay on the box). It prints no link for a key this
|
|
19
|
+
workspace refuses or the box does not share, and says why instead.
|
|
20
|
+
- **The box's root page lists every workspace** and says what your key opens in each: `edit`,
|
|
21
|
+
`view`, or nothing. A workspace that is offline, or whose dev server is on a tosijs-ui before
|
|
22
|
+
1.16.26, shows as not answering. In 1.16.26 those rows were hidden, so a workspace on an
|
|
23
|
+
older tosijs-ui vanished from the page meant for picking one. Workspaces are now listed above
|
|
24
|
+
the deployed builds.
|
|
25
|
+
- **`?t=<key>` on any preview host takes `&to=<label>`** and sends you on to
|
|
26
|
+
`<label>.<preview domain>`, and the edit key's cookie lasts 30 days there too, as the view
|
|
27
|
+
key's always has.
|
|
28
|
+
|
|
29
|
+
### Security
|
|
30
|
+
|
|
31
|
+
- **The box's access log recorded every link credential** (affected: 1.9.0 to 1.16.27). Caddy
|
|
32
|
+
logs the request URI, so each `?t=<key>` visit wrote that key, and each link redemption its
|
|
33
|
+
code, to `/var/log/caddy/preview.log`. The template now redacts `t` and `token` there.
|
|
34
|
+
**Re-run `tosijs-caddy-install`, then scrub the old logs** (`/var/log/caddy/preview*.log*`) **or
|
|
35
|
+
rotate your keys.** Caddy already kept cookies out of the log.
|
|
36
|
+
- **The `to` redirect stays under the preview domain.** `to` must be DNS labels (lowercase
|
|
37
|
+
letters, digits, `-`, separated by `.`); anything else, including a path, a second `to` or
|
|
38
|
+
encoded line breaks, falls back to `/`. Checked against Caddy 2.6.2 and 2.11.7.
|
|
39
|
+
|
|
3
40
|
## 1.16.27
|
|
4
41
|
|
|
5
42
|
### Deploy
|
|
@@ -34,6 +34,8 @@ export interface PreviewConfig {
|
|
|
34
34
|
localPort?: number
|
|
35
35
|
url?: string
|
|
36
36
|
requireToken?: boolean
|
|
37
|
+
/** see `tunnel.globalKeys` in site-config.ts */
|
|
38
|
+
globalKeys?: boolean | 'view'
|
|
37
39
|
}
|
|
38
40
|
}
|
|
39
41
|
|
|
@@ -156,6 +158,8 @@ export {
|
|
|
156
158
|
server reads the global tunnel keys from the same file; it is re-exported here for the bins.
|
|
157
159
|
*/
|
|
158
160
|
export { readLocalSecret } from '../dist/doc-system/site/local-secrets.js'
|
|
161
|
+
export { resolveGlobalKeys } from '../dist/doc-system/site/dev-auth.js'
|
|
162
|
+
import type { GlobalKeys } from '../dist/doc-system/site/dev-auth.js'
|
|
159
163
|
import { readLocalSecret } from '../dist/doc-system/site/local-secrets.js'
|
|
160
164
|
|
|
161
165
|
/**
|
|
@@ -443,3 +447,100 @@ export async function refreshHostIndex(host: string): Promise<void> {
|
|
|
443
447
|
const out = run.stdout.toString().trim()
|
|
444
448
|
if (out) console.log(` ${out}`)
|
|
445
449
|
}
|
|
450
|
+
|
|
451
|
+
/** What the box says about its global keys: its domain, and a fingerprint of each key. */
|
|
452
|
+
export interface BoxKeys {
|
|
453
|
+
domain?: string
|
|
454
|
+
/** sha256 hex of the box's PREVIEW_EDIT_TOKEN / PREVIEW_TOKEN, '' when it has none */
|
|
455
|
+
editSha?: string
|
|
456
|
+
viewSha?: string
|
|
457
|
+
problem?: string
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Read the box's preview domain, and fingerprints of its two keys, in one ssh call.
|
|
462
|
+
*
|
|
463
|
+
* The file is SOURCED, as the installer and Caddy's own install read it, so all three agree on
|
|
464
|
+
* quoting, `export` and comments. The keys never leave the box: only their sha256 does, which is
|
|
465
|
+
* enough to say "this machine's key is not the box's", the one mismatch that makes a link
|
|
466
|
+
* open this workspace and nothing else.
|
|
467
|
+
*/
|
|
468
|
+
export async function previewBoxKeys(host: string): Promise<BoxKeys> {
|
|
469
|
+
const script =
|
|
470
|
+
// Checked first: bash as a login shell carries on past a failed `.`, which read as "sets
|
|
471
|
+
// no PREVIEW_DOMAIN" when the file was missing or unreadable.
|
|
472
|
+
'[ -r /etc/caddy/preview.env ] || { echo "/etc/caddy/preview.env is missing or unreadable" >&2; exit 1; }; ' +
|
|
473
|
+
'set -a; . /etc/caddy/preview.env; set +a; ' +
|
|
474
|
+
'printf "%s\\n" "${PREVIEW_DOMAIN:-}"; ' +
|
|
475
|
+
'for k in "${PREVIEW_EDIT_TOKEN:-}" "${PREVIEW_TOKEN:-}"; do ' +
|
|
476
|
+
'if [ -n "$k" ]; then printf %s "$k" | { sha256sum 2>/dev/null || shasum -a 256; } | cut -d" " -f1; else echo; fi; done'
|
|
477
|
+
// Never prompt or hang: `--link --global` waits on this.
|
|
478
|
+
const read =
|
|
479
|
+
await $`ssh -o BatchMode=yes -o ConnectTimeout=10 ${host} ${script}`
|
|
480
|
+
.nothrow()
|
|
481
|
+
.quiet()
|
|
482
|
+
if (read.exitCode !== 0) {
|
|
483
|
+
return {
|
|
484
|
+
problem: `could not read /etc/caddy/preview.env on ${host}: ${read.stderr
|
|
485
|
+
.toString()
|
|
486
|
+
.trim()
|
|
487
|
+
.slice(0, 160)}`,
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const [domain = '', editSha = '', viewSha = ''] = read.stdout
|
|
491
|
+
.toString()
|
|
492
|
+
.split('\n')
|
|
493
|
+
.map((line) => line.trim())
|
|
494
|
+
if (!/^[a-z0-9.-]+$/i.test(domain)) {
|
|
495
|
+
return {
|
|
496
|
+
problem: `/etc/caddy/preview.env on ${host} sets no PREVIEW_DOMAIN`,
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return { domain, editSha, viewSha }
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const sha256 = (text: string) =>
|
|
503
|
+
new Bun.CryptoHasher('sha256').update(text).digest('hex')
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Which single-use links `tosijs-tunnel` can honestly offer, and why not the rest.
|
|
507
|
+
*
|
|
508
|
+
* A link hands the browser this machine's key as a cookie across the box's domain, so it is
|
|
509
|
+
* only worth printing when this workspace accepts the key AND the box holds the same one; with a
|
|
510
|
+
* different key on the box, the cookie would open this workspace and not the box's root or any
|
|
511
|
+
* other workspace, which is not what the link says.
|
|
512
|
+
*/
|
|
513
|
+
export function planGlobalLinks(
|
|
514
|
+
local: { keys: GlobalKeys; problems: string[] },
|
|
515
|
+
box: BoxKeys
|
|
516
|
+
): { kinds: ('edit' | 'view')[]; domain?: string; notes: string[] } {
|
|
517
|
+
const notes = [...local.problems]
|
|
518
|
+
const { keys } = local
|
|
519
|
+
const wanted: ('edit' | 'view')[] = []
|
|
520
|
+
if (keys.edit && (keys.editGrants ?? 'edit') === 'edit') wanted.push('edit')
|
|
521
|
+
if (keys.view) wanted.push('view')
|
|
522
|
+
if (wanted.length === 0) {
|
|
523
|
+
if (notes.length === 0)
|
|
524
|
+
notes.push(
|
|
525
|
+
'no global key on this machine (PREVIEW_EDIT_TOKEN / PREVIEW_TOKEN in ~/local-secrets/tosijs-preview.env), or tunnel.globalKeys turns them off'
|
|
526
|
+
)
|
|
527
|
+
return { kinds: [], notes }
|
|
528
|
+
}
|
|
529
|
+
if (!box.domain) {
|
|
530
|
+
notes.push(box.problem ?? 'the box did not say what its preview domain is')
|
|
531
|
+
return { kinds: [], notes }
|
|
532
|
+
}
|
|
533
|
+
const kinds = wanted.filter((kind) => {
|
|
534
|
+
const mine = kind === 'edit' ? keys.edit! : keys.view!
|
|
535
|
+
const theirs = kind === 'edit' ? box.editSha : box.viewSha
|
|
536
|
+
if (theirs === sha256(mine)) return true
|
|
537
|
+
const name = kind === 'edit' ? 'PREVIEW_EDIT_TOKEN' : 'PREVIEW_TOKEN'
|
|
538
|
+
notes.push(
|
|
539
|
+
theirs
|
|
540
|
+
? `${name} here is not the box's, so no ${kind} link: put the same value in both files`
|
|
541
|
+
: `the box has no ${name}, so no ${kind} link: add it to /etc/caddy/preview.env and re-run tosijs-caddy-install`
|
|
542
|
+
)
|
|
543
|
+
return false
|
|
544
|
+
})
|
|
545
|
+
return { kinds, domain: box.domain, notes }
|
|
546
|
+
}
|
package/bin/tunnel.ts
CHANGED
|
@@ -32,9 +32,13 @@ always requires a session.
|
|
|
32
32
|
|
|
33
33
|
import { $ } from 'bun'
|
|
34
34
|
import {
|
|
35
|
+
planGlobalLinks,
|
|
36
|
+
previewBoxKeys,
|
|
35
37
|
previewRootFor,
|
|
38
|
+
readLocalSecret,
|
|
36
39
|
registerCaddyFragment,
|
|
37
40
|
refreshHostIndex,
|
|
41
|
+
resolveGlobalKeys,
|
|
38
42
|
resolveDevPort,
|
|
39
43
|
resolvePreviewHost,
|
|
40
44
|
resolveSiteConfig,
|
|
@@ -45,7 +49,7 @@ import {
|
|
|
45
49
|
const { has, flag } = parseArgv(process.argv.slice(2), {
|
|
46
50
|
bin: 'tosijs-tunnel',
|
|
47
51
|
summary: 'expose the local dev server through the preview host',
|
|
48
|
-
flags: ['close', 'link', 'status', 'share'],
|
|
52
|
+
flags: ['close', 'link', 'status', 'share', 'global'],
|
|
49
53
|
values: ['host', 'port', 'local-port', 'remote-port', 'url', 'share-ttl'],
|
|
50
54
|
usage:
|
|
51
55
|
` tosijs-tunnel open the tunnel (foreground)\n` +
|
|
@@ -53,6 +57,8 @@ const { has, flag } = parseArgv(process.argv.slice(2), {
|
|
|
53
57
|
` --close close a running tunnel\n` +
|
|
54
58
|
` --link print a magic edit link\n` +
|
|
55
59
|
` --link --share print a READ-ONLY link to send to a person\n` +
|
|
60
|
+
` --link --global [--share] a SINGLE-USE link that also gives that browser the\n` +
|
|
61
|
+
` box's key for 30 days (every workspace, the root list)\n` +
|
|
56
62
|
` --share-ttl=<minutes> how long that share link lives (default 1440)\n` +
|
|
57
63
|
` --host=user@box override the configured preview host\n` +
|
|
58
64
|
` --port= --local-port= --remote-port= override the resolved ports`,
|
|
@@ -113,6 +119,60 @@ const publicUrl = flag('url') ?? preview?.tunnel?.url
|
|
|
113
119
|
// Set by the registration step below; no public URL means there is no route to fail.
|
|
114
120
|
let routed = true
|
|
115
121
|
|
|
122
|
+
/** Ask the dev server (loopback only) for a link; `query` selects the kind. */
|
|
123
|
+
async function askForLink(
|
|
124
|
+
query: string
|
|
125
|
+
): Promise<{ url?: string; error?: string }> {
|
|
126
|
+
const res =
|
|
127
|
+
await $`curl -sk --max-time 5 https://localhost:${localPort}/__devlink${query}`
|
|
128
|
+
.nothrow()
|
|
129
|
+
.quiet()
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(res.stdout.toString())
|
|
132
|
+
} catch {
|
|
133
|
+
return {}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/*
|
|
138
|
+
Single-use links that also give the browser the box's global key (see GlobalGrant in
|
|
139
|
+
dev-auth.ts): one click opens this workspace, and from then on that browser opens every
|
|
140
|
+
workspace holding the key, and the box's root list, for 30 days, across restarts. The link
|
|
141
|
+
carries a one-use code, never the key. Offered only for keys this workspace accepts and the box
|
|
142
|
+
holds too; `notes` says why any other is missing.
|
|
143
|
+
*/
|
|
144
|
+
async function globalLinks(
|
|
145
|
+
kinds: ('edit' | 'view')[] = ['edit', 'view'],
|
|
146
|
+
extraQuery = ''
|
|
147
|
+
): Promise<{
|
|
148
|
+
links: { kind: 'edit' | 'view'; url: string }[]
|
|
149
|
+
notes: string[]
|
|
150
|
+
}> {
|
|
151
|
+
const local = resolveGlobalKeys(preview?.tunnel?.globalKeys, {
|
|
152
|
+
PREVIEW_TOKEN:
|
|
153
|
+
process.env.PREVIEW_TOKEN ?? readLocalSecret('PREVIEW_TOKEN'),
|
|
154
|
+
PREVIEW_EDIT_TOKEN:
|
|
155
|
+
process.env.PREVIEW_EDIT_TOKEN ?? readLocalSecret('PREVIEW_EDIT_TOKEN'),
|
|
156
|
+
})
|
|
157
|
+
const box =
|
|
158
|
+
local.keys.edit || local.keys.view ? await previewBoxKeys(host!) : {}
|
|
159
|
+
const plan = planGlobalLinks(local, box)
|
|
160
|
+
const links: { kind: 'edit' | 'view'; url: string }[] = []
|
|
161
|
+
for (const kind of plan.kinds.filter((k) => kinds.includes(k))) {
|
|
162
|
+
const answer = await askForLink(
|
|
163
|
+
`?global=${kind}&domain=${encodeURIComponent(plan.domain!)}${extraQuery}`
|
|
164
|
+
)
|
|
165
|
+
if (answer.url) links.push({ kind, url: answer.url })
|
|
166
|
+
else
|
|
167
|
+
plan.notes.push(
|
|
168
|
+
answer.error ??
|
|
169
|
+
`the dev server on :${localPort} did not answer, so no ${kind} link: start it (\`bun start\`), then \`tosijs-tunnel --link\``
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
return { links, notes: plan.notes }
|
|
173
|
+
}
|
|
174
|
+
let startLinks: Awaited<ReturnType<typeof globalLinks>> | undefined
|
|
175
|
+
|
|
116
176
|
/*
|
|
117
177
|
Say where this is going, and where the address came from, BEFORE acting on it.
|
|
118
178
|
|
|
@@ -176,18 +236,25 @@ if (has('link')) {
|
|
|
176
236
|
*/
|
|
177
237
|
const share = has('share')
|
|
178
238
|
const shareTtl = flag('share-ttl')
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
239
|
+
const ttl = shareTtl ? `&ttl=${encodeURIComponent(shareTtl)}` : ''
|
|
240
|
+
/*
|
|
241
|
+
`--global` asks for a link that also hands over the box's key (see globalLinks): single-use,
|
|
242
|
+
and the browser keeps every workspace for 30 days. Opt-in, because it is a different promise
|
|
243
|
+
from the two links above, which are reusable for their window and open this workspace only.
|
|
244
|
+
A share link spent by a chat app's link preview, or one that opens every workspace, would
|
|
245
|
+
break the promise `--share` makes to the person you send it to.
|
|
246
|
+
*/
|
|
247
|
+
let url: string
|
|
248
|
+
if (has('global')) {
|
|
249
|
+
const global = await globalLinks([share ? 'view' : 'edit'], ttl)
|
|
250
|
+
for (const note of global.notes) console.log(`note: ${note}`)
|
|
251
|
+
url = global.links[0]?.url ?? ''
|
|
252
|
+
if (!url) {
|
|
253
|
+
console.error('\nNo single-use global link (see the note above).\n')
|
|
254
|
+
process.exit(1)
|
|
255
|
+
}
|
|
256
|
+
} else {
|
|
257
|
+
url = (await askForLink(share ? `?share=1${ttl}` : '')).url ?? ''
|
|
191
258
|
}
|
|
192
259
|
if (!url) {
|
|
193
260
|
console.error(
|
|
@@ -210,7 +277,15 @@ if (has('link')) {
|
|
|
210
277
|
*/
|
|
211
278
|
const code = new URL(url).searchParams.get('t')
|
|
212
279
|
console.log(
|
|
213
|
-
`\n🔗 ${
|
|
280
|
+
`\n🔗 ${
|
|
281
|
+
has('global')
|
|
282
|
+
? `Single-use ${
|
|
283
|
+
share ? 'READ-ONLY ' : ''
|
|
284
|
+
}link (that browser keeps every workspace for 30 days)`
|
|
285
|
+
: share
|
|
286
|
+
? 'Share link (READ ONLY)'
|
|
287
|
+
: 'Edit link'
|
|
288
|
+
}:\n ${url}\n` +
|
|
214
289
|
(code ? `\n code: ${code} (case-insensitive)\n` : '')
|
|
215
290
|
)
|
|
216
291
|
|
|
@@ -419,8 +494,11 @@ if (publicUrl) {
|
|
|
419
494
|
describe: `${hostname} → this workspace`,
|
|
420
495
|
body: fragment,
|
|
421
496
|
})
|
|
422
|
-
|
|
423
|
-
|
|
497
|
+
if (routed) {
|
|
498
|
+
// The root's list of live workspaces is read from these fragments.
|
|
499
|
+
await refreshHostIndex(host!)
|
|
500
|
+
startLinks = await globalLinks()
|
|
501
|
+
}
|
|
424
502
|
}
|
|
425
503
|
}
|
|
426
504
|
|
|
@@ -438,6 +516,15 @@ if (publicUrl) {
|
|
|
438
516
|
? ` ${publicUrl}`
|
|
439
517
|
: ` ${publicUrl} ⚠️ NOT routed — registration failed above; this URL will not reach here`
|
|
440
518
|
)
|
|
519
|
+
if (startLinks?.links.length) {
|
|
520
|
+
console.log(
|
|
521
|
+
`\n Single-use links: each opens this workspace, then keeps that browser in every` +
|
|
522
|
+
`\n workspace on the box (and its root list) for 30 days:`
|
|
523
|
+
)
|
|
524
|
+
for (const { kind, url } of startLinks.links)
|
|
525
|
+
console.log(` ${kind}: ${url}${kind === 'view' ? ' (read-only)' : ''}`)
|
|
526
|
+
}
|
|
527
|
+
for (const note of startLinks?.notes ?? []) console.log(` note: ${note}`)
|
|
441
528
|
}
|
|
442
529
|
console.log(` Ctrl-C to close.\n`)
|
|
443
530
|
|
package/deploy/Caddyfile
CHANGED
|
@@ -36,6 +36,15 @@
|
|
|
36
36
|
# `root` is the only thing that varies, keeping it in the caller costs one line per
|
|
37
37
|
# project and makes this file work on any Caddy 2.x.
|
|
38
38
|
(preview_site) {
|
|
39
|
+
# Where a key link lands: `&to=<label>` sends it to `<label>.{{PREVIEW_DOMAIN}}` (so
|
|
40
|
+
# `tosijs-tunnel` can hand out ONE link that sets the cookie here and opens its workspace),
|
|
41
|
+
# anything else to this host's root. Only DNS labels match, and the target is always under
|
|
42
|
+
# the preview domain, so this cannot redirect off-site.
|
|
43
|
+
map {query.to} {tosi_link_target} {
|
|
44
|
+
~^([a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*)$ "https://${1}.{{PREVIEW_DOMAIN}}/"
|
|
45
|
+
default "/"
|
|
46
|
+
}
|
|
47
|
+
|
|
39
48
|
# THE PREVIEW HOST IS PRIVATE — but gated by a LINK, not a password dialog.
|
|
40
49
|
#
|
|
41
50
|
# basicauth used to guard these hosts, and it worked, but it makes the browser throw
|
|
@@ -63,11 +72,13 @@
|
|
|
63
72
|
header Set-Cookie "tosi_preview=__PREVIEW_TOKEN__; Domain=.{{PREVIEW_DOMAIN}}; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax"
|
|
64
73
|
header Referrer-Policy "no-referrer"
|
|
65
74
|
header Cache-Control "no-store"
|
|
66
|
-
redir *
|
|
75
|
+
redir * {tosi_link_target} 302
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
# THE GLOBAL EDIT KEY: `?t=<edit key>` on any preview host sets `tosi_edit` across the
|
|
70
|
-
# whole preview domain for
|
|
79
|
+
# whole preview domain for 30 days, the same cookie a workspace's single-use link from
|
|
80
|
+
# `tosijs-tunnel` sets (that link is the usual way in: it never puts the key in a URL).
|
|
81
|
+
# Every tunnelled workspace that holds the same key
|
|
71
82
|
# (PREVIEW_EDIT_TOKEN in ~/local-secrets/tosijs-preview.env on the machine running it)
|
|
72
83
|
# trades it for an editing session on the first page load, so the root's list opens them
|
|
73
84
|
# all, across dev-server restarts. It also opens these static previews. The view key above
|
|
@@ -78,10 +89,10 @@
|
|
|
78
89
|
# rotated (edit preview.env here and on each machine, re-run the installer).
|
|
79
90
|
@editlink query t=__PREVIEW_EDIT_TOKEN__
|
|
80
91
|
handle @editlink {
|
|
81
|
-
header Set-Cookie "tosi_edit=__PREVIEW_EDIT_TOKEN__; Domain=.{{PREVIEW_DOMAIN}}; Path=/; Max-Age=
|
|
92
|
+
header Set-Cookie "tosi_edit=__PREVIEW_EDIT_TOKEN__; Domain=.{{PREVIEW_DOMAIN}}; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax"
|
|
82
93
|
header Referrer-Policy "no-referrer"
|
|
83
94
|
header Cache-Control "no-store"
|
|
84
|
-
redir *
|
|
95
|
+
redir * {tosi_link_target} 302
|
|
85
96
|
}
|
|
86
97
|
|
|
87
98
|
# Anchored at both ends: unanchored, `tosi_edit=<key>x` matched too. That needed the key
|
|
@@ -151,6 +162,17 @@
|
|
|
151
162
|
roll_size 10MiB
|
|
152
163
|
roll_keep 3
|
|
153
164
|
}
|
|
165
|
+
# Links carry credentials in the query: `t` (a global key, or a single-use code) and
|
|
166
|
+
# `token`. Caddy already keeps cookies out of this log; this keeps those out too.
|
|
167
|
+
format filter {
|
|
168
|
+
wrap json
|
|
169
|
+
fields {
|
|
170
|
+
request>uri query {
|
|
171
|
+
replace t REDACTED
|
|
172
|
+
replace token REDACTED
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
154
176
|
}
|
|
155
177
|
}
|
|
156
178
|
|
|
@@ -198,6 +220,17 @@
|
|
|
198
220
|
roll_size 10MiB
|
|
199
221
|
roll_keep 3
|
|
200
222
|
}
|
|
223
|
+
# Links carry credentials in the query: `t` (a global key, or a single-use code) and
|
|
224
|
+
# `token`. Caddy already keeps cookies out of this log; this keeps those out too.
|
|
225
|
+
format filter {
|
|
226
|
+
wrap json
|
|
227
|
+
fields {
|
|
228
|
+
request>uri query {
|
|
229
|
+
replace t REDACTED
|
|
230
|
+
replace token REDACTED
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
201
234
|
}
|
|
202
235
|
}
|
|
203
236
|
|
package/deploy/build-index.sh
CHANGED
|
@@ -75,9 +75,10 @@ for dir in "$ROOT"/*/; do
|
|
|
75
75
|
</li>"
|
|
76
76
|
done
|
|
77
77
|
|
|
78
|
-
# Tunnelled workspaces: every registered `-tunnel` fragment
|
|
79
|
-
#
|
|
80
|
-
# (see the script below)
|
|
78
|
+
# Tunnelled workspaces: every registered `-tunnel` fragment, ALWAYS listed, so this page is
|
|
79
|
+
# where you pick one. Whether it is up, and what the reader may do there, changes by the
|
|
80
|
+
# minute, so the page asks each one when it loads (see the script below). Hiding the rows
|
|
81
|
+
# that did not answer made a workspace on an older tosijs-ui simply vanish.
|
|
81
82
|
live=""
|
|
82
83
|
for frag in "$ROOT"/_sites/*-tunnel.caddy; do
|
|
83
84
|
[ -f "$frag" ] || continue
|
|
@@ -85,10 +86,10 @@ for frag in "$ROOT"/_sites/*-tunnel.caddy; do
|
|
|
85
86
|
host=$(sed -n 's/^\([A-Za-z0-9.-]*\) *{.*/\1/p' "$frag" | head -1)
|
|
86
87
|
[ -n "$host" ] || continue
|
|
87
88
|
live="$live
|
|
88
|
-
<li data-workspace=\"$host\"
|
|
89
|
+
<li data-workspace=\"$host\">
|
|
89
90
|
<a href=\"https://$host/\">
|
|
90
91
|
<span class=\"name\">$name</span>
|
|
91
|
-
<span class=\"meta\"><span class=\"access\"
|
|
92
|
+
<span class=\"meta\"><span class=\"access\">checking…</span><code>$host</code></span>
|
|
92
93
|
</a>
|
|
93
94
|
</li>"
|
|
94
95
|
done
|
|
@@ -133,16 +134,18 @@ cat > "$OUT/index.html" <<HTML
|
|
|
133
134
|
h2 { font-size:1rem; margin:2rem 0 .75rem; font-weight:600; }
|
|
134
135
|
.access { font-weight:600; }
|
|
135
136
|
.access.edit { color:#2a8a3e; }
|
|
137
|
+
.access.none { font-weight:400; }
|
|
136
138
|
footer { color:var(--dim); font-size:.8rem; margin-top:2.5rem; }
|
|
137
139
|
</style>
|
|
138
140
|
<main>
|
|
139
141
|
<header>$ICON<h1>tosijs previews</h1></header>
|
|
140
|
-
<p class="sub">
|
|
142
|
+
<p class="sub">Live workspaces, and the builds deployed here.</p>
|
|
143
|
+
$(if [ -n "$live" ]; then echo "<section id=\"live\"><h2>Live workspaces</h2><ul>$live
|
|
144
|
+
</ul></section>"; fi)
|
|
145
|
+
<h2>Deployed builds</h2>
|
|
141
146
|
$(if [ "$count" -eq 0 ]; then echo '<p class="empty">Nothing deployed yet.</p>'; else echo "<ul>$rows
|
|
142
147
|
</ul>"; fi)
|
|
143
148
|
$(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)
|
|
146
149
|
<footer>Previews only — not production. Each entry updates when that project deploys (<code>bun run deploy --go</code>), not when it builds.</footer>
|
|
147
150
|
</main>
|
|
148
151
|
<script>
|
|
@@ -162,21 +165,26 @@ cat > "$OUT/index.html" <<HTML
|
|
|
162
165
|
}
|
|
163
166
|
|
|
164
167
|
// 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
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
+
// this origin may read the answer, and it reflects the key cookie this page set. Opening
|
|
169
|
+
// one trades the key for a session there, so these are plain links. A workspace that is
|
|
170
|
+
// down and one whose dev server predates this probe (tosijs-ui 1.16.26) look the same
|
|
171
|
+
// from here: neither gives an answer this page may read.
|
|
172
|
+
const silent = 'not answering (offline, or tosijs-ui before 1.16.26)'
|
|
173
|
+
const say = (li, text, kind) => {
|
|
174
|
+
const label = li.querySelector('.access')
|
|
175
|
+
label.textContent = text
|
|
176
|
+
label.className = 'access ' + kind
|
|
177
|
+
}
|
|
168
178
|
for (const li of document.querySelectorAll('[data-workspace]')) {
|
|
169
179
|
fetch('https://' + li.dataset.workspace + '/__alive', { credentials: 'include' })
|
|
170
|
-
.then((r) => (r.
|
|
180
|
+
.then((r) => (r.status === 200 ? r.json() : null))
|
|
171
181
|
.then((info) => {
|
|
172
|
-
if (!info
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
li.hidden = false
|
|
177
|
-
document.getElementById('live').hidden = false
|
|
182
|
+
if (!info) say(li, silent, 'none')
|
|
183
|
+
else if (info.access === 'edit') say(li, 'edit', 'edit')
|
|
184
|
+
else if (info.access === 'view') say(li, 'view', 'view')
|
|
185
|
+
else say(li, 'up, but your key does not open it', 'none')
|
|
178
186
|
})
|
|
179
|
-
.catch(() =>
|
|
187
|
+
.catch(() => say(li, silent, 'none'))
|
|
180
188
|
}
|
|
181
189
|
</script>
|
|
182
190
|
HTML
|
|
@@ -104,6 +104,24 @@ export interface AuthState {
|
|
|
104
104
|
* asked of it is the same either way: "may whoever holds this write?"
|
|
105
105
|
*/
|
|
106
106
|
readOnly: Set<string>;
|
|
107
|
+
/**
|
|
108
|
+
* Link tokens that also hand over a global key (see {@link GlobalGrant}). Such a link is
|
|
109
|
+
* spent on first use, whatever `linkPolicy` says: it gives the browser a key that outlives
|
|
110
|
+
* this server.
|
|
111
|
+
*/
|
|
112
|
+
grants: Map<string, GlobalGrant>;
|
|
113
|
+
/** Sessions minted from a granting link, until the redirect that sets the key takes them. */
|
|
114
|
+
sessionGrants: Map<string, GlobalGrant>;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* What a single-use link hands over besides a session here: the box's global `key`, as a
|
|
118
|
+
* cookie across `domain`, so this browser opens every workspace (and the box's root list)
|
|
119
|
+
* without another link. The link carries a one-use code; the key itself never appears in a
|
|
120
|
+
* URL, a terminal or a log.
|
|
121
|
+
*/
|
|
122
|
+
export interface GlobalGrant {
|
|
123
|
+
key: 'edit' | 'view';
|
|
124
|
+
domain: string;
|
|
107
125
|
}
|
|
108
126
|
export declare function createAuthState(): AuthState;
|
|
109
127
|
/** Drop anything expired. Called on every use so the maps cannot grow without bound. */
|
|
@@ -119,6 +137,7 @@ export declare function prune(state: AuthState, now: number): void;
|
|
|
119
137
|
*/
|
|
120
138
|
export declare function issueLink(state: AuthState, now: number, ttlMs?: number, opts?: {
|
|
121
139
|
readOnly?: boolean;
|
|
140
|
+
grant?: GlobalGrant;
|
|
122
141
|
}): string;
|
|
123
142
|
/**
|
|
124
143
|
* Read the link settings off a site config, with the defaults applied.
|
|
@@ -156,6 +175,30 @@ export declare function resolveShareTtlMs(tunnel?: {
|
|
|
156
175
|
* the set before the lookup. Widening reuse must not widen lifetime.
|
|
157
176
|
*/
|
|
158
177
|
export declare function redeemLink(state: AuthState, token: string, now: number, policy?: LinkPolicy): string | null;
|
|
178
|
+
/**
|
|
179
|
+
* How many unspent granting links may exist at once. Each is a live code that, guessed, hands
|
|
180
|
+
* over the box's key; a bound keeps the guessing odds where a handful of real links put them.
|
|
181
|
+
*/
|
|
182
|
+
export declare const MAX_OUTSTANDING_GRANTS = 32;
|
|
183
|
+
/** Unspent, unexpired granting links. */
|
|
184
|
+
export declare function outstandingGrants(state: AuthState, now: number): number;
|
|
185
|
+
/** The global key a just-redeemed link hands over with `session`, once. */
|
|
186
|
+
export declare function takeSessionGrant(state: AuthState, session: string): GlobalGrant | undefined;
|
|
187
|
+
/** How long a browser keeps a global key it was given by a link: 30 days. */
|
|
188
|
+
export declare const GLOBAL_KEY_COOKIE_MAX_AGE_S: number;
|
|
189
|
+
/**
|
|
190
|
+
* The cookie that gives this browser the box's global key across the preview domain, the same
|
|
191
|
+
* one Caddy sets for `?t=<key>`, so the box's root and every workspace holding the key accept
|
|
192
|
+
* it. `null` when this server does not hold that key, or would not honour it: a link must not
|
|
193
|
+
* hand over a key that opens less than it claims.
|
|
194
|
+
*/
|
|
195
|
+
export declare function globalKeyCookie(grant: GlobalGrant, keys: GlobalKeys): string | null;
|
|
196
|
+
/**
|
|
197
|
+
* The preview domain a granting link may set its cookie on: a plain hostname that the
|
|
198
|
+
* workspace's own host sits under. Anything else would set a cookie this browser drops (a
|
|
199
|
+
* domain the host is not under), or one far wider than the preview host (`net`).
|
|
200
|
+
*/
|
|
201
|
+
export declare function grantDomainFor(domain: string | null | undefined, workspaceHost: string): string | null;
|
|
159
202
|
/** Mint a session token directly: what a redeemed link or a global key turns into. */
|
|
160
203
|
export declare function issueSession(state: AuthState, now: number, ttlMs: number, readOnly: boolean): string;
|
|
161
204
|
export declare const REDEEM_MIN_MS = 100;
|