tosijs-ui 1.16.32 → 1.16.34

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,68 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.34
4
+
5
+ Spinning icons keep spinning while the page is busy, which is the only time a loader is on
6
+ screen. Nothing to change unless your CSS or script reaches inside a spin icon: see the last
7
+ bullet.
8
+
9
+ ### Icons
10
+
11
+ - **`spinN<Icon>` no longer freezes on a busy page** (board #3508). The rotation was started
12
+ with `el.animate()` on the `<svg>`, and WebKit animates an SVG transform on the main thread,
13
+ so on Safari and every iOS browser a loader stood still exactly while the page worked. It is
14
+ now a CSS animation on an HTML element around a static svg, which the browser can run off the
15
+ main thread.
16
+ - **A spin icon turns as a clone and inside a shadow root.** The animation is in the markup
17
+ rather than started by script, so `cloneNode` and list templates keep it. The spinning
18
+ element is a `<tosi-icon-spin>`, which adds one shared stylesheet to the document or shadow
19
+ root it lands in, because a document-level `@keyframes` does not reach a shadow root in
20
+ Chromium or Firefox. Nothing is added inside the icon, so a button holding a spinner still
21
+ has its caption as its `textContent`.
22
+ - **Spin icons hold still under `prefers-reduced-motion: reduce`**, as the live-example spinner
23
+ already did. Shipped in a patch on the maintainer's call: it is an accessibility default. The
24
+ rules are wrapped in `:where()`, so a rule of yours that names `tosi-icon-spin` overrides
25
+ them; for an icon inside a component's shadow root, that rule has to be in the shadow root.
26
+ - **The DOM inside a spin icon changed.** It was `span.tosi-icon-composite > svg`; it is now
27
+ `span.tosi-icon-composite > tosi-icon-spin > svg`, with a span carrying the suffix transform
28
+ around `<tosi-icon-spin>` when the name has `s`/`x`/`y`/`r`/`f` suffixes
29
+ (`spin90Loader40s_30x`). That keeps an offset icon spinning about its own centre, as before,
30
+ and both hold the svg's size in a taller flex container so the icon does not orbit. Speed
31
+ and direction are the `--tosi-icon-spin-duration` and `--tosi-icon-spin-direction` custom
32
+ properties on `<tosi-icon-spin>`.
33
+
34
+ ## 1.16.33
35
+
36
+ Typing a link on a headset gets much shorter, and a mistyped code no longer sends you off to mint
37
+ a new one. Both are tunnel changes; nothing an adopter's product sees changes. **To get short
38
+ links, re-run `tosijs-caddy-install --go`** on your preview box: until you do, the tunnel says
39
+ the box does not serve them yet and prints the long link as before.
40
+
41
+ ### Tunnel
42
+
43
+ - **Short links.** `tosijs-tunnel --link --global`, and the links it prints at start, also print
44
+ `https://<preview domain>/<CODE>`, with the code in two groups of four (`RCQA MHFX`), which
45
+ are easier to hold in your head than eight letters. The tunnel records which workspace minted
46
+ the code on the box (`/srv/preview/_codes/<CODE>`), sweeps codes past their five minutes, and
47
+ reads it back through Caddy before printing it, so a box whose Caddyfile predates short links
48
+ gets a note instead of a dead link. The code is still redeemed, once and rate-limited, by the
49
+ dev server that minted it.
50
+ - **A box for a link or a code on the box's own pages.** The page a browser without the preview
51
+ cookie sees (now `deploy/gate-page.html`, embedded by `tosijs-caddy-install`) has a box that
52
+ takes a code, in any case and with spaces, or a whole link. A code finds the workspace that
53
+ minted it; a link is followed only to a host under the preview domain. The root list has the
54
+ same box, since a browser that still holds the view cookie lands there rather than on the gate.
55
+ On a headset, open `dev.example.com` (letters and dots only) and type the eight letters.
56
+ - **What short links cost.** The box answers "is this a live code?" without the dev server's
57
+ rate limit, so a guesser can test codes there faster: about 1 in 100,000 odds at a sustained
58
+ 2,000 requests a second for a code's five minutes, against 1 in 18 million at the workspace.
59
+ The maintainer accepted that for headset typing. `doc-site-system.md` has the details.
60
+ - **A mistyped code says so.** A code that matches no live link now gets the code box back,
61
+ naming the code and with it filled in, so a one-letter typo is a one-letter fix. That holds on
62
+ a workspace's own page and on the box's (a short link with a typo, or a code typed there). A spent code
63
+ still reads "has been used", and an expired one says it has expired; through 1.16.32 all three
64
+ read "has been used" (board #3505).
65
+
3
66
  ## 1.16.32
4
67
 
5
68
  Two new components, a doc search that ranks, and live examples whose toolbar no longer covers
@@ -37,6 +37,7 @@ Three deliberate properties:
37
37
 
38
38
  import { existsSync, readFileSync } from 'fs'
39
39
  import {
40
+ embedGatePage,
40
41
  parseArgv,
41
42
  resolvePreviewHost,
42
43
  resolveSiteConfig,
@@ -93,7 +94,18 @@ if (!existsSync(templatePath)) {
93
94
  process.exit(1)
94
95
  }
95
96
 
96
- const template = readFileSync(templatePath, 'utf8')
97
+ /*
98
+ `__GATE_PAGE__` is the page a browser without the preview cookie sees (deploy/gate-page.html,
99
+ with its box for a link or a code). It is not a secret, so it is embedded here rather than on the
100
+ box. A template of your own that names it gets the shipped page.
101
+ */
102
+ const template = embedGatePage(
103
+ readFileSync(templatePath, 'utf8'),
104
+ readFileSync(
105
+ new URL('../deploy/gate-page.html', import.meta.url).pathname,
106
+ 'utf8'
107
+ )
108
+ )
97
109
 
98
110
  // The template is delivered inside a quoted heredoc, so it is copied verbatim with no shell
99
111
  // expansion. A template containing the delimiter would truncate the file silently — refuse
@@ -191,6 +191,63 @@ export function deviceRegistryLine(device: {
191
191
  ].join('\t')
192
192
  }
193
193
 
194
+ /*
195
+ SHORT LINKS (board #3504). A single-use code is typed on a headset, where `/`, `=` and digits are
196
+ each a trip to another keyboard page, so the box also answers `https://<domain>/<CODE>` and a box
197
+ on its gate and root pages. For that it has to know which workspace minted the code:
198
+ `tosijs-tunnel` writes `<root>/_codes/<CODE>` holding the full link, and the gate page
199
+ (deploy/gate-page.html) reads it back. The code is still redeemed by the dev server that minted
200
+ it. Files older than the code's five-minute life are swept on the next write; one left over
201
+ only points at a code that no longer redeems.
202
+
203
+ Both values go into a shell command, so both are checked against narrow patterns, and a pair
204
+ that fails is not written (the long link still works).
205
+ */
206
+ const SHORT_CODE = /^[ABCDEFGHJKMNPQRSTVWXYZ]{8}$/
207
+ const SHORT_LINK_TARGET = /^https:\/\/[a-z0-9.-]+\/\?t=[A-Z]{8}$/
208
+
209
+ export function shortCodeScript(
210
+ root: string,
211
+ code: string,
212
+ link: string
213
+ ): string | null {
214
+ if (!SHORT_CODE.test(code) || !SHORT_LINK_TARGET.test(link)) return null
215
+ if (!link.endsWith(`?t=${code}`)) return null
216
+ if (!/^\/[\w./-]+$/.test(root)) return null
217
+ const dir = `${root}/_codes`
218
+ return (
219
+ `mkdir -p ${dir} && chmod 755 ${dir} && ` +
220
+ `find ${dir} -type f -mmin +5 -delete; ` +
221
+ `printf '%s\\n' '${link}' > ${dir}/${code} && chmod 644 ${dir}/${code}`
222
+ )
223
+ }
224
+
225
+ /** `ABCD EFGH`: two groups of four are easier to hold in your head than eight letters. */
226
+ export function groupCode(code: string): string {
227
+ return code.length === 8 ? `${code.slice(0, 4)} ${code.slice(4)}` : code
228
+ }
229
+
230
+ /*
231
+ The gate page is a file (deploy/gate-page.html) so it can be read, edited and tested as HTML, and
232
+ is embedded into the Caddyfile's `respond "__GATE_PAGE__"` at install time. Inside a Caddyfile
233
+ quoted string only `\"` is an escape, so a double quote is all that needs escaping; its HTML
234
+ comments are dropped (they are for whoever edits it, and they mention placeholder names Caddy
235
+ would expand).
236
+ */
237
+ export function embedGatePage(template: string, page: string): string {
238
+ const stripped = page.replace(/<!--[\s\S]*?-->\n?/g, '').trim()
239
+ /*
240
+ Embedded BEFORE the installer substitutes the keys, so a key placeholder here would be filled
241
+ with the real key and served to anyone without a cookie. The installer's leftover-placeholder
242
+ guard cannot see that: by then it is a key, not a placeholder.
243
+ */
244
+ if (/__PREVIEW_(EDIT_)?TOKEN__/.test(stripped))
245
+ throw new Error(
246
+ 'the gate page names a key placeholder (__PREVIEW_TOKEN__ / __PREVIEW_EDIT_TOKEN__), which would publish the key on a page anyone can load'
247
+ )
248
+ return template.split('__GATE_PAGE__').join(stripped.replace(/"/g, '\\"'))
249
+ }
250
+
194
251
  export interface DeviceRecord {
195
252
  id: string
196
253
  issued: string
package/bin/tunnel.ts CHANGED
@@ -45,6 +45,8 @@ import {
45
45
  resolveTunnelLocalPort,
46
46
  parseArgv,
47
47
  deviceRegistryLine,
48
+ groupCode,
49
+ shortCodeScript,
48
50
  parseDeviceListing,
49
51
  DEVICE_LISTING_SEPARATOR,
50
52
  DEVICE_ID_PATTERN,
@@ -171,7 +173,7 @@ holds too; `notes` says why any other is missing.
171
173
  async function globalLinks(
172
174
  kinds: ('edit' | 'view')[] = ['edit', 'view']
173
175
  ): Promise<{
174
- links: { kind: 'edit' | 'view'; url: string }[]
176
+ links: { kind: 'edit' | 'view'; url: string; short?: string }[]
175
177
  notes: string[]
176
178
  }> {
177
179
  const local = resolveGlobalKeys(preview?.tunnel?.globalKeys, {
@@ -183,7 +185,7 @@ async function globalLinks(
183
185
  const box =
184
186
  local.keys.edit || local.keys.view ? await previewBoxKeys(host!) : {}
185
187
  const plan = planGlobalLinks(local, box)
186
- const links: { kind: 'edit' | 'view'; url: string }[] = []
188
+ const links: { kind: 'edit' | 'view'; url: string; short?: string }[] = []
187
189
  for (const kind of plan.kinds.filter((k) => kinds.includes(k))) {
188
190
  const label = flag('label')
189
191
  const answer = await askForLink(
@@ -206,8 +208,16 @@ async function globalLinks(
206
208
  continue
207
209
  }
208
210
  }
209
- if (answer.url) links.push({ kind, url: answer.url })
210
- else
211
+ if (answer.url) {
212
+ const short = await registerShortCode(answer.url, plan.domain!)
213
+ if (!short)
214
+ plan.notes.push(
215
+ `no short link (${
216
+ shortLinkProblem || `could not record it on ${host}`
217
+ }); the full link still works`
218
+ )
219
+ links.push({ kind, url: answer.url, short })
220
+ } else
211
221
  plan.notes.push(
212
222
  answer.error ??
213
223
  `the dev server on :${localPort} did not answer, so no ${kind} link: start it (\`bun start\`), then \`tosijs-tunnel --link\``
@@ -244,6 +254,41 @@ async function registerDevice(device: {
244
254
  return run.exitCode === 0
245
255
  }
246
256
 
257
+ /*
258
+ SHORT LINKS (board #3504): record which workspace a code belongs to, so `https://<domain>/<CODE>`
259
+ (or the code typed into the box on the gate and root pages) finds it. See shortCodeScript.
260
+ Returns the short link, or undefined if it could not be recorded.
261
+ */
262
+ let shortLinkProblem = ''
263
+ async function registerShortCode(
264
+ url: string,
265
+ domain: string
266
+ ): Promise<string | undefined> {
267
+ const code = new URL(url).searchParams.get('t') ?? ''
268
+ const script = shortCodeScript(devicesRoot(), code, url)
269
+ if (!script) {
270
+ shortLinkProblem = `${url} is not a link the box can shorten`
271
+ return undefined
272
+ }
273
+ const run =
274
+ await $`ssh -o BatchMode=yes -o ConnectTimeout=10 ${host!} ${script}`
275
+ .nothrow()
276
+ .quiet()
277
+ if (run.exitCode !== 0) return undefined
278
+ // Read it back through Caddy: a box whose Caddyfile predates short links answers with its
279
+ // gate, and a short link printed over that would lead nowhere.
280
+ const served = await fetch(`https://${domain}/_codes/${code}`, {
281
+ signal: AbortSignal.timeout(5000),
282
+ })
283
+ .then((res) => (res.ok ? res.text() : ''))
284
+ .catch(() => '')
285
+ if (served.trim() !== url) {
286
+ shortLinkProblem = `the box's Caddyfile does not serve short links yet: run \`tosijs-caddy-install --go\``
287
+ return undefined
288
+ }
289
+ return `https://${domain}/${code}`
290
+ }
291
+
247
292
  /*
248
293
  Say where this is going, and where the address came from, BEFORE acting on it.
249
294
 
@@ -378,10 +423,12 @@ if (has('link')) {
378
423
  break the promise `--share` makes to the person you send it to.
379
424
  */
380
425
  let url: string
426
+ let short: string | undefined
381
427
  if (has('global')) {
382
428
  const global = await globalLinks([share ? 'view' : 'edit'])
383
429
  for (const note of global.notes) console.log(`note: ${note}`)
384
430
  url = global.links[0]?.url ?? ''
431
+ short = global.links[0]?.short
385
432
  if (!url) {
386
433
  console.error('\nNo single-use global link (see the note above).\n')
387
434
  process.exit(1)
@@ -419,7 +466,14 @@ if (has('link')) {
419
466
  ? 'Share link (READ ONLY)'
420
467
  : 'Edit link'
421
468
  }:\n ${url}\n` +
422
- (code ? `\n code: ${code} (case-insensitive)\n` : '')
469
+ (short ? ` ${short} (the same link, shorter)\n` : '') +
470
+ (code
471
+ ? `\n code: ${groupCode(code)} (case-insensitive${
472
+ short
473
+ ? `; or open ${new URL(short).host} and type it into the box`
474
+ : ''
475
+ })\n`
476
+ : '')
423
477
  )
424
478
 
425
479
  /*
@@ -654,8 +708,13 @@ if (publicUrl) {
654
708
  `\n Single-use links: each opens this workspace, then keeps that browser in every` +
655
709
  `\n workspace on the box (and its root list) for 30 days:`
656
710
  )
657
- for (const { kind, url } of startLinks.links)
658
- console.log(` ${kind}: ${url}${kind === 'view' ? ' (read-only)' : ''}`)
711
+ for (const { kind, url, short } of startLinks.links) {
712
+ const code = new URL(url).searchParams.get('t') ?? ''
713
+ console.log(
714
+ ` ${kind}: ${url}${kind === 'view' ? ' (read-only)' : ''}` +
715
+ (short ? `\n ${short} code ${groupCode(code)}` : '')
716
+ )
717
+ }
659
718
  }
660
719
  for (const note of startLinks?.notes ?? []) console.log(` note: ${note}`)
661
720
  }
package/deploy/Caddyfile CHANGED
@@ -93,10 +93,11 @@
93
93
  not header_regexp Cookie "(^|; *)tosi_preview=__PREVIEW_TOKEN__(;|$)"
94
94
  not header_regexp Cookie "(^|; *)tosi_edit=__PREVIEW_EDIT_TOKEN__(;|$)"
95
95
  }
96
+ # deploy/gate-page.html, embedded by the installer: a box that takes a link or a code.
96
97
  handle @unauthed {
97
98
  header Content-Type "text/html; charset=utf-8"
98
99
  header Cache-Control "no-store"
99
- respond "<!doctype html><meta charset=utf-8><title>Link required</title><style>body{font:16px/1.6 system-ui;margin:15vh auto;max-width:30rem;padding:0 1.5rem;color:#222}@media(prefers-color-scheme:dark){body{background:#16171a;color:#e8e8ea}}</style><h1>This preview needs an invite link</h1><p>Ask for one — it sets a cookie and then you can browse normally.</p>" 401
100
+ respond "__GATE_PAGE__" 401
100
101
  }
101
102
 
102
103
  file_server
@@ -191,6 +192,57 @@
191
192
  # send someone should still show what you meant an hour later.
192
193
  {{PREVIEW_DOMAIN}} {
193
194
  root * /srv/preview/_index
195
+
196
+ # SHORT LINKS (board #3504): `https://{{PREVIEW_DOMAIN}}/<CODE>` instead of the workspace's
197
+ # own `https://<label>.edit.{{PREVIEW_DOMAIN}}/?t=<CODE>`, typed on a headset keyboard.
198
+ # `tosijs-tunnel --link --global` writes /srv/preview/_codes/<CODE> holding the full link;
199
+ # the gate page (deploy/gate-page.html) reads it and goes there. Both are answered BEFORE the
200
+ # cookie gate, because a browser that needs a link has no cookie yet. Neither grants anything:
201
+ # the code is still redeemed by the dev server that minted it, once, rate-limited.
202
+ #
203
+ # What this does cost: the box answers "is this a live code?" without the dev server's rate
204
+ # limit. Codes are 8 of 22 letters (35.7 bits) and live five minutes, so even sustained
205
+ # hammering finds one with odds around 1 in 100,000; the maintainer accepted that for
206
+ # typing a short link on a headset (2026-10-25).
207
+ #
208
+ # A missing code is answered with an explicit 404 rather than by file_server, whose error would
209
+ # reach preview_site's handle_errors: that rewrites to /index.html, and served the cookie-gated
210
+ # root list to a visitor with no cookie would be the leak this gate exists to prevent.
211
+ @codefile {
212
+ path_regexp ^/_codes/[A-Z]{8}$
213
+ file {
214
+ root /srv/preview
215
+ }
216
+ }
217
+ handle @codefile {
218
+ root * /srv/preview
219
+ header Cache-Control "no-store"
220
+ header Content-Type "text/plain; charset=utf-8"
221
+ file_server
222
+ }
223
+ handle /_codes/* {
224
+ header Cache-Control "no-store"
225
+ respond "" 404
226
+ }
227
+ @code {
228
+ path_regexp ^/[A-Za-z2568-]{8,12}/?$
229
+ }
230
+ @codequery {
231
+ path /
232
+ query t=*
233
+ not query t=__PREVIEW_TOKEN__
234
+ }
235
+ handle @code {
236
+ header Content-Type "text/html; charset=utf-8"
237
+ header Cache-Control "no-store"
238
+ respond "__GATE_PAGE__" 200
239
+ }
240
+ handle @codequery {
241
+ header Content-Type "text/html; charset=utf-8"
242
+ header Cache-Control "no-store"
243
+ respond "__GATE_PAGE__" 200
244
+ }
245
+
194
246
  import preview_site
195
247
  }
196
248
 
@@ -139,10 +139,21 @@ cat > "$OUT/index.html" <<HTML
139
139
  .access.edit { color:#2a8a3e; }
140
140
  .access.none { font-weight:400; }
141
141
  footer { color:var(--dim); font-size:.8rem; margin-top:2.5rem; }
142
+ form.code { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; margin:0 0 2rem 3.75rem; }
143
+ form.code input, form.code button { font:inherit; padding:.35rem .6rem; border:1px solid var(--line); border-radius:6px; background:var(--card); color:inherit; }
144
+ form.code input { letter-spacing:.08em; width:11em; }
142
145
  </style>
143
146
  <main>
144
147
  <header>$ICON<h1>tosijs previews</h1></header>
145
148
  <p class="sub">Live workspaces, and the builds deployed here.</p>
149
+ <!-- A code from \`tosijs-tunnel --link --global\`: \`/?t=<code>\` is answered by the gate page,
150
+ which finds the workspace that minted it (deploy/Caddyfile, short links). A browser that
151
+ already holds the view cookie lands HERE, so this page needs the box as much as the gate. -->
152
+ <form class="code" action="/" method="get">
153
+ <label for="code">Have a code?</label>
154
+ <input id="code" name="t" placeholder="eight letters" inputmode="latin" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
155
+ <button>Go</button>
156
+ </form>
146
157
  $(if [ -n "$live" ]; then echo "<section id=\"live\"><h2>Live workspaces</h2><ul>$live
147
158
  </ul></section>"; fi)
148
159
  <h2>Deployed builds</h2>
@@ -0,0 +1,178 @@
1
+ <!DOCTYPE html>
2
+ <!--
3
+ The page a browser without the preview cookie sees, and the page that redeems a short link.
4
+
5
+ Embedded into the Caddyfile at install time (`__GATE_PAGE__`, by `tosijs-caddy-install`), so it
6
+ must survive being a Caddyfile string:
7
+ - a double quote is escaped for it, nothing else is, so never write a backslash before one;
8
+ - `{name}` with no space inside is read as a Caddy placeholder (`{path}`, `{host}`, `{query}`
9
+ are real ones), so every brace below has whitespace after it.
10
+ `bin/caddy-install.test.ts` checks both.
11
+
12
+ Short links (board #3504): `tosijs-tunnel --link --global` writes `/_codes/<CODE>` on the box,
13
+ holding the workspace link that code belongs to. `https://<domain>/<CODE>` (or `/?t=<CODE>`, or
14
+ the code typed into the box below) lands here, which looks the code up and goes there. The code
15
+ is still redeemed, single-use and rate-limited, by the dev server that minted it.
16
+ -->
17
+ <meta charset="utf-8" />
18
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
19
+ <meta name="referrer" content="no-referrer" />
20
+ <title>Link required</title>
21
+ <style>
22
+ body {
23
+ font: 16px/1.6 system-ui;
24
+ margin: 15vh auto;
25
+ max-width: 30rem;
26
+ padding: 0 1.5rem;
27
+ color: #222;
28
+ }
29
+ form {
30
+ display: flex;
31
+ gap: 0.5em;
32
+ margin: 1.5em 0;
33
+ }
34
+ input {
35
+ flex: 1;
36
+ font: inherit;
37
+ font-size: 1.25em;
38
+ letter-spacing: 0.08em;
39
+ padding: 0.4em 0.6em;
40
+ border: 1px solid #8888;
41
+ border-radius: 6px;
42
+ min-width: 0;
43
+ }
44
+ button {
45
+ font: inherit;
46
+ font-size: 1.25em;
47
+ padding: 0.4em 1em;
48
+ border: 0;
49
+ border-radius: 6px;
50
+ background: #2563eb;
51
+ color: white;
52
+ }
53
+ #msg.problem {
54
+ color: #b91c1c;
55
+ }
56
+ @media (prefers-color-scheme: dark) {
57
+ body {
58
+ background: #16171a;
59
+ color: #e8e8ea;
60
+ }
61
+ input {
62
+ background: #222;
63
+ color: inherit;
64
+ }
65
+ #msg.problem {
66
+ color: #f87171;
67
+ }
68
+ }
69
+ </style>
70
+ <h1>This preview needs an invite link</h1>
71
+ <p id="msg">
72
+ Open the link you were given, or type its code (or paste the whole link) here.
73
+ </p>
74
+ <form id="go">
75
+ <input
76
+ id="t"
77
+ aria-label="link or code"
78
+ placeholder="link or code"
79
+ inputmode="latin"
80
+ autocomplete="off"
81
+ autocapitalize="off"
82
+ autocorrect="off"
83
+ spellcheck="false"
84
+ />
85
+ <button>Go</button>
86
+ </form>
87
+ <script>
88
+ ;(() => {
89
+ const DOMAIN = '{{PREVIEW_DOMAIN}}'
90
+ const CODE = /^[ABCDEFGHJKMNPQRSTVWXYZ]{8}$/
91
+
92
+ // The same folding as normalizeLinkToken in tosijs-ui's dev-auth.ts (the test compares them).
93
+ const normalize = (typed) =>
94
+ typed
95
+ .replace(/[\s-]/g, '')
96
+ .toUpperCase()
97
+ .replace(/5/g, 'S')
98
+ .replace(/2/g, 'Z')
99
+ .replace(/8/g, 'B')
100
+ .replace(/6/g, 'G')
101
+
102
+ const ours = (host) => host === DOMAIN || host.endsWith('.' + DOMAIN)
103
+ const msg = document.getElementById('msg')
104
+ const say = (text) => {
105
+ msg.textContent = text
106
+ msg.className = 'problem'
107
+ }
108
+
109
+ async function redeem(code) {
110
+ // Codes are looked up at the root, so send any other preview host there.
111
+ if (location.hostname !== DOMAIN) {
112
+ location.href = 'https://' + DOMAIN + '/' + code
113
+ return
114
+ }
115
+ msg.textContent = 'Opening ' + code + '…'
116
+ let target = null
117
+ try {
118
+ const res = await fetch('/_codes/' + code, { cache: 'no-store' })
119
+ if (res.ok) target = new URL((await res.text()).trim())
120
+ } catch (e) {}
121
+ if (!target)
122
+ return say(
123
+ 'No live link has the code ' +
124
+ code +
125
+ '. Check it for a typo and try again: a code is eight letters, and case does not matter.'
126
+ )
127
+ if (target.protocol !== 'https:' || !ours(target.hostname))
128
+ return say(
129
+ 'That code points outside ' + DOMAIN + ', so it was not followed.'
130
+ )
131
+ location.replace(target.href)
132
+ }
133
+
134
+ function go(typed) {
135
+ typed = typed.trim()
136
+ if (!typed) return
137
+ // A link has a dot, slash or colon; a code never does.
138
+ if (/[./:]/.test(typed)) {
139
+ let url = null
140
+ try {
141
+ url = new URL(
142
+ /^https?:\/\//i.test(typed) ? typed : 'https://' + typed
143
+ )
144
+ } catch (e) {}
145
+ if (!url || !ours(url.hostname))
146
+ return say('That link is not under ' + DOMAIN + '.')
147
+ url.protocol = 'https:'
148
+ location.href = url.href
149
+ return
150
+ }
151
+ const code = normalize(typed)
152
+ if (!CODE.test(code))
153
+ return say(typed + ' is not a code: a code is eight letters.')
154
+ redeem(code)
155
+ }
156
+
157
+ document.getElementById('go').addEventListener('submit', (event) => {
158
+ event.preventDefault()
159
+ go(document.getElementById('t').value)
160
+ })
161
+
162
+ // Arrived with a code: /<CODE> at the root, or ?t=<CODE> on any preview host. Anything else
163
+ // (a page path on a static preview, a stale key) just shows the box.
164
+ const query = new URLSearchParams(location.search).get('t')
165
+ const path =
166
+ location.hostname === DOMAIN
167
+ ? location.pathname.replace(/^\/+|\/+$/g, '')
168
+ : ''
169
+ const arrived = normalize(query || path)
170
+ if (CODE.test(arrived)) {
171
+ // In the box too, so a typo in the URL is a one-letter fix (board #3505).
172
+ const box = document.getElementById('t')
173
+ box.value = arrived
174
+ box.focus()
175
+ redeem(arrived)
176
+ }
177
+ })()
178
+ </script>
@@ -114,7 +114,17 @@ export interface AuthState {
114
114
  sessionGrants: Map<string, GlobalGrant>;
115
115
  /** Sessions minted from a device pass → that device, so revoking it ends them too. */
116
116
  sessionDevices: Map<string, string>;
117
+ /**
118
+ * Link codes that have left `links`, folded, with why and until when they are remembered:
119
+ * so a refused code can be told apart as mistyped, spent or expired (see linkRejection).
120
+ */
121
+ retired: Map<string, {
122
+ why: 'used' | 'expired';
123
+ until: number;
124
+ }>;
117
125
  }
126
+ /** How long a spent or expired code is remembered, for the message only. */
127
+ export declare const RETIRED_LINK_MEMORY_MS: number;
118
128
  /**
119
129
  * What a single-use link hands over besides a session here: the box's global `key`, as a
120
130
  * cookie across `domain`, so this browser opens every workspace (and the box's root list)
@@ -192,6 +202,7 @@ export declare function resolveGrantTtlMs(tunnel?: {
192
202
  * the set before the lookup. Widening reuse must not widen lifetime.
193
203
  */
194
204
  export declare function redeemLink(state: AuthState, token: string, now: number, policy?: LinkPolicy): string | null;
205
+ export declare function linkRejection(state: AuthState, token: string, now: number): 'used' | 'expired' | 'unknown';
195
206
  /**
196
207
  * How many unspent granting links may exist at once. Each is a live code that, guessed, hands
197
208
  * over a global credential; a bound keeps the guessing odds where a handful of real links put them.
@@ -199,6 +199,8 @@ export function safeEqual(a, b) {
199
199
  return false;
200
200
  return timingSafeEqual(ab, bb);
201
201
  }
202
+ /** How long a spent or expired code is remembered, for the message only. */
203
+ export const RETIRED_LINK_MEMORY_MS = 60 * 60 * 1000;
202
204
  export function createAuthState() {
203
205
  return {
204
206
  links: new Map(),
@@ -207,6 +209,7 @@ export function createAuthState() {
207
209
  grants: new Map(),
208
210
  sessionGrants: new Map(),
209
211
  sessionDevices: new Map(),
212
+ retired: new Map(),
210
213
  };
211
214
  }
212
215
  /** Drop anything expired. Called on every use so the maps cannot grow without bound. */
@@ -216,7 +219,11 @@ export function prune(state, now) {
216
219
  state.links.delete(t);
217
220
  state.readOnly.delete(t);
218
221
  state.grants.delete(t);
222
+ retire(state, t, 'expired', now);
219
223
  }
224
+ for (const [t, { until }] of state.retired)
225
+ if (until <= now)
226
+ state.retired.delete(t);
220
227
  for (const [t, exp] of state.sessions)
221
228
  if (exp <= now) {
222
229
  state.sessions.delete(t);
@@ -313,6 +320,7 @@ export function redeemLink(state, token, now, policy = 'window') {
313
320
  state.links.delete(matched);
314
321
  state.readOnly.delete(matched);
315
322
  state.grants.delete(matched);
323
+ retire(state, matched, 'used', now);
316
324
  }
317
325
  /*
318
326
  The capability rides from the link to the session it mints, and so does the shorter horizon.
@@ -325,6 +333,24 @@ export function redeemLink(state, token, now, policy = 'window') {
325
333
  state.sessionGrants.set(session, grant);
326
334
  return session;
327
335
  }
336
+ function retire(state, token, why, now) {
337
+ state.retired.set(normalizeLinkToken(token), {
338
+ why,
339
+ until: now + RETIRED_LINK_MEMORY_MS,
340
+ });
341
+ }
342
+ /*
343
+ Why a code was refused, so the page can say the useful thing (board #3505). A headset user who
344
+ mistyped one letter was told the code "has been used", and went off to mint a new link when
345
+ retyping would have done. Mistyped, spent and expired now read differently.
346
+
347
+ This tells a guesser nothing useful: "used" and "expired" are said only of codes that can no
348
+ longer open anything, and everything else is "unknown", as before.
349
+ */
350
+ export function linkRejection(state, token, now) {
351
+ prune(state, now);
352
+ return state.retired.get(normalizeLinkToken(token))?.why ?? 'unknown';
353
+ }
328
354
  /**
329
355
  * How many unspent granting links may exist at once. Each is a live code that, guessed, hands
330
356
  * over a global credential; a bound keeps the guessing odds where a handful of real links put them.