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 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 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,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=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
 
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
 
@@ -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;