tosijs-ui 1.16.26 → 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 CHANGED
@@ -1,5 +1,62 @@
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
+
40
+ ## 1.16.27
41
+
42
+ ### Deploy
43
+
44
+ - **`tosijs-caddy-install --go` installs a Caddyfile Caddy can read** (affected: 1.10.0 to
45
+ 1.16.26). It installed the file readable by root only, so a Caddy running as its own user
46
+ (Debian's package does) refused the reload. Caddy kept serving the old config, the new file
47
+ stayed on disk, and the bin said "Nothing was installed", so the next restart would have
48
+ taken down every site on the box. The file is now mode `640` with the group systemd runs
49
+ Caddy as, or `600` when Caddy runs as root; it holds both keys, so never world-readable.
50
+ **If you ran `--go` with an affected version, re-run it.**
51
+ - **The installer starts a stopped Caddy** instead of failing to reload it, and if Caddy
52
+ refuses the new config it puts the previous one back and exits non-zero.
53
+ - **The installer refuses a `PREVIEW_TOKEN`** containing anything but letters, digits, `-` and
54
+ `_`. It goes into the gate's cookie regex, where a `.` loosened the match.
55
+ - **The preview gate matches a key cookie exactly** (affected: 1.9.0 to 1.16.26; the edit
56
+ cookie since 1.16.26). It matched by prefix, so `tosi_edit=<key>x` passed. That still
57
+ required the whole key, but the dev servers compare exactly and the gate now agrees with
58
+ them. Re-run the installer to pick it up.
59
+
3
60
  ## 1.16.26
4
61
 
5
62
  ### Tunnel
@@ -120,6 +120,12 @@ if [ ! -f /etc/caddy/preview.env ]; then
120
120
  fi
121
121
  set -a; . /etc/caddy/preview.env; set +a
122
122
  : "\${PREVIEW_TOKEN:?set PREVIEW_TOKEN in /etc/caddy/preview.env}"
123
+ # It goes into a sed replacement and into the gate's cookie regex, where \`.\` would loosen the
124
+ # match and \`|\` would break the substitution.
125
+ if ! printf '%s' "$PREVIEW_TOKEN" | grep -qE '^[A-Za-z0-9_-]+$'; then
126
+ echo "PREVIEW_TOKEN may contain only letters, digits, - and _ — refusing to install." >&2
127
+ exit 1
128
+ fi
123
129
  : "\${ACME_EMAIL:?set ACME_EMAIL in /etc/caddy/preview.env}"
124
130
  : "\${PREVIEW_DOMAIN:?set PREVIEW_DOMAIN in /etc/caddy/preview.env}"
125
131
 
@@ -155,12 +161,53 @@ caddy validate --config /etc/caddy/Caddyfile.new
155
161
  if [ "${go ? '1' : '0'}" = "1" ]; then
156
162
  # Keep the outgoing config. This replaces a file that may be serving OTHER sites, and
157
163
  # "validated" only means it parses — it does not mean it is the config you wanted.
164
+ had_config=0
158
165
  if [ -f /etc/caddy/Caddyfile ]; then
159
166
  cp -p /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak
167
+ had_config=1
168
+ fi
169
+ # Readable by the group Caddy runs as, and by nobody else: the file holds both keys.
170
+ # umask 077 left it root-only, and Debian's Caddy runs as its own user, so the reload failed
171
+ # reading it with the new file already in place (the maintainer's box, 1.16.26). Ask systemd
172
+ # who that is rather than guess; a Caddy run as root needs no group.
173
+ caddy_group=$(systemctl show -p Group --value caddy)
174
+ if [ -z "$caddy_group" ]; then
175
+ caddy_user=$(systemctl show -p User --value caddy)
176
+ if [ -n "$caddy_user" ] && [ "$caddy_user" != root ]; then
177
+ caddy_group=$(id -gn "$caddy_user")
178
+ fi
179
+ fi
180
+ if [ -n "$caddy_group" ]; then
181
+ chgrp "$caddy_group" /etc/caddy/Caddyfile.new
182
+ chmod 640 /etc/caddy/Caddyfile.new
183
+ else
184
+ chmod 600 /etc/caddy/Caddyfile.new
160
185
  fi
161
186
  mv /etc/caddy/Caddyfile.new /etc/caddy/Caddyfile
162
- systemctl reload caddy
163
- echo "Installed /etc/caddy/Caddyfile and reloaded caddy."
187
+ # A stopped Caddy cannot reload. Start it instead, or a box left down by the bug above could
188
+ # never be repaired by re-running this.
189
+ if systemctl is-active --quiet caddy; then
190
+ verb=reload
191
+ else
192
+ verb=start
193
+ fi
194
+ if ! systemctl "$verb" caddy; then
195
+ # A refused reload leaves Caddy serving what it had, but its next restart reads this file.
196
+ if [ "$had_config" = 1 ]; then
197
+ # Not \`cp -p\`: copying ONTO the installed file keeps its mode and group, and the
198
+ # previous file may be one an affected version left root-only.
199
+ cp /etc/caddy/Caddyfile.bak /etc/caddy/Caddyfile
200
+ echo "caddy refused to $verb with the new config; restored the previous /etc/caddy/Caddyfile." >&2
201
+ else
202
+ rm -f /etc/caddy/Caddyfile
203
+ echo "caddy refused to $verb with the new config; removed it." >&2
204
+ fi
205
+ if [ "$verb" = start ]; then
206
+ echo "caddy is not running. \`systemctl status caddy\` says why." >&2
207
+ fi
208
+ exit 1
209
+ fi
210
+ echo "Installed /etc/caddy/Caddyfile (caddy: $verb)."
164
211
  echo "Previous config saved as /etc/caddy/Caddyfile.bak"
165
212
  else
166
213
  echo "--- would change /etc/caddy/Caddyfile (token redacted) ---"
@@ -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 query = share
180
- ? `?share=1${shareTtl ? `&ttl=${encodeURIComponent(shareTtl)}` : ''}`
181
- : ''
182
- const res =
183
- await $`curl -sk --max-time 5 https://localhost:${localPort}/__devlink${query}`
184
- .nothrow()
185
- .quiet()
186
- let url = ''
187
- try {
188
- url = JSON.parse(res.stdout.toString()).url
189
- } catch {
190
- /* fall through to the error below */
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🔗 ${share ? 'Share link (READ ONLY)' : 'Edit link'}:\n ${url}\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
- // The root's list of live workspaces is read from these fragments.
423
- if (routed) await refreshHostIndex(host!)
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 * / 302
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 24 hours. Every tunnelled workspace that holds the same key
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,15 +89,17 @@
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=86400; HttpOnly; Secure; SameSite=Lax"
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 * / 302
95
+ redir * {tosi_link_target} 302
85
96
  }
86
97
 
98
+ # Anchored at both ends: unanchored, `tosi_edit=<key>x` matched too. That needed the key
99
+ # anyway, but the dev servers compare exactly, and the gate should agree with them.
87
100
  @unauthed {
88
- not header_regexp Cookie "tosi_preview=__PREVIEW_TOKEN__"
89
- not header_regexp Cookie "tosi_edit=__PREVIEW_EDIT_TOKEN__"
101
+ not header_regexp Cookie "(^|; *)tosi_preview=__PREVIEW_TOKEN__(;|$)"
102
+ not header_regexp Cookie "(^|; *)tosi_edit=__PREVIEW_EDIT_TOKEN__(;|$)"
90
103
  }
91
104
  handle @unauthed {
92
105
  header Content-Type "text/html; charset=utf-8"
@@ -149,6 +162,17 @@
149
162
  roll_size 10MiB
150
163
  roll_keep 3
151
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
+ }
152
176
  }
153
177
  }
154
178
 
@@ -196,6 +220,17 @@
196
220
  roll_size 10MiB
197
221
  roll_keep 3
198
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
+ }
199
234
  }
200
235
  }
201
236
 
@@ -75,9 +75,10 @@ for dir in "$ROOT"/*/; do
75
75
  </li>"
76
76
  done
77
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.
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\" hidden>
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\"></span><code>$host</code></span>
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">Deployed builds. Each shows the commit it was built from.</p>
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; 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
+ // 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.ok && r.status === 200 ? r.json() : null))
180
+ .then((r) => (r.status === 200 ? r.json() : null))
171
181
  .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
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;