tosijs-ui 1.16.31 → 1.16.33

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/bin/caddy-install.ts +13 -1
  3. package/bin/resolve-site-config.ts +86 -0
  4. package/bin/tunnel.ts +76 -7
  5. package/deploy/Caddyfile +53 -1
  6. package/deploy/build-index.sh +11 -0
  7. package/deploy/gate-page.html +178 -0
  8. package/dist/affix.d.ts +97 -0
  9. package/dist/affix.js +445 -0
  10. package/dist/crud.d.ts +22 -0
  11. package/dist/crud.js +68 -5
  12. package/dist/data-table.js +10 -0
  13. package/dist/doc-browser.d.ts +6 -0
  14. package/dist/doc-browser.js +155 -115
  15. package/dist/doc-system/doc-search.d.ts +43 -0
  16. package/dist/doc-system/doc-search.js +194 -0
  17. package/dist/doc-system/doc-system.js +5 -0
  18. package/dist/doc-system/site/dev-auth.d.ts +11 -0
  19. package/dist/doc-system/site/dev-auth.js +26 -0
  20. package/dist/doc-system/site/dev-server.js +32 -7
  21. package/dist/doc-system/site/device-revocation.d.ts +13 -1
  22. package/dist/doc-system/site/device-revocation.js +51 -13
  23. package/dist/doc-system/site/invite-page.d.ts +7 -1
  24. package/dist/doc-system/site/invite-page.js +18 -3
  25. package/dist/editable-rect.d.ts +1 -1
  26. package/dist/form.d.ts +1 -1
  27. package/dist/iife.js +118 -112
  28. package/dist/iife.js.map +17 -14
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.js +2 -0
  31. package/dist/live-example/component.d.ts +39 -15
  32. package/dist/live-example/component.js +42 -50
  33. package/dist/live-example/styles.d.ts +37 -14
  34. package/dist/live-example/styles.js +52 -37
  35. package/dist/notifications.js +11 -4
  36. package/dist/palette.d.ts +90 -0
  37. package/dist/palette.js +898 -0
  38. package/dist/schema-form.d.ts +2 -1
  39. package/dist/schema-form.js +23 -8
  40. package/dist/search-field.js +0 -12
  41. package/dist/segmented.d.ts +1 -1
  42. package/dist/tab-selector.d.ts +1 -1
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/llms.txt +5 -1
  46. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,130 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.33
4
+
5
+ Typing a link on a headset gets much shorter, and a mistyped code no longer sends you off to mint
6
+ a new one. Both are tunnel changes; nothing an adopter's product sees changes. **To get short
7
+ links, re-run `tosijs-caddy-install --go`** on your preview box: until you do, the tunnel says
8
+ the box does not serve them yet and prints the long link as before.
9
+
10
+ ### Tunnel
11
+
12
+ - **Short links.** `tosijs-tunnel --link --global`, and the links it prints at start, also print
13
+ `https://<preview domain>/<CODE>`, with the code in two groups of four (`RCQA MHFX`), which
14
+ are easier to hold in your head than eight letters. The tunnel records which workspace minted
15
+ the code on the box (`/srv/preview/_codes/<CODE>`), sweeps codes past their five minutes, and
16
+ reads it back through Caddy before printing it, so a box whose Caddyfile predates short links
17
+ gets a note instead of a dead link. The code is still redeemed, once and rate-limited, by the
18
+ dev server that minted it.
19
+ - **A box for a link or a code on the box's own pages.** The page a browser without the preview
20
+ cookie sees (now `deploy/gate-page.html`, embedded by `tosijs-caddy-install`) has a box that
21
+ takes a code, in any case and with spaces, or a whole link. A code finds the workspace that
22
+ minted it; a link is followed only to a host under the preview domain. The root list has the
23
+ same box, since a browser that still holds the view cookie lands there rather than on the gate.
24
+ On a headset, open `dev.example.com` (letters and dots only) and type the eight letters.
25
+ - **What short links cost.** The box answers "is this a live code?" without the dev server's
26
+ rate limit, so a guesser can test codes there faster: about 1 in 100,000 odds at a sustained
27
+ 2,000 requests a second for a code's five minutes, against 1 in 18 million at the workspace.
28
+ The maintainer accepted that for headset typing. `doc-site-system.md` has the details.
29
+ - **A mistyped code says so.** A code that matches no live link now gets the code box back,
30
+ naming the code and with it filled in, so a one-letter typo is a one-letter fix. That holds on
31
+ a workspace's own page and on the box's (a short link with a typo, or a code typed there). A spent code
32
+ still reads "has been used", and an expired one says it has expired; through 1.16.32 all three
33
+ read "has been used" (board #3505).
34
+
35
+ ## 1.16.32
36
+
37
+ Two new components, a doc search that ranks, and live examples whose toolbar no longer covers
38
+ what they draw. Nothing an adopter has to change: the toolbar and the search are doc-site
39
+ behaviour, and both components are new. One thing to know if you embed the doc browser in an
40
+ app: it now opens its search on Cmd/Ctrl-K. If your page owns that key, pass
41
+ `createDocBrowser({ searchShortcut: '' })` (or another key).
42
+
43
+ `dist/iife.js` grows by about 7 KB gzip (496.4 KB to 503.4 KB, +1.4%): the palette is 9.1 KB
44
+ gzip and the affix 5.0 KB. The ESM path pays only for what it imports.
45
+
46
+ ### New components
47
+
48
+ - **`<tosi-palette>`** (`tosiPalette`): a command palette. A compact search-field trigger
49
+ opens a centred modal of ranked results. Results come from `sources` (sync, or async with a
50
+ spinner and a `timeout`, 10s by default; each keystroke aborts the last search), are drawn
51
+ per `kind` by `renderers` (or a default row: icon, highlighted title, one-line description,
52
+ path), ranked by `score` and grouped by `group` (or by your own `rank`), with `quickLinks`
53
+ before anything is typed. Arrow keys, Enter and Escape drive it, `shortcut: 'mod+k'` opens it
54
+ from anywhere (a shortcut with an unknown modifier is ignored, with a console warning, rather
55
+ than binding the bare key), `multiline` makes the query a growing prompt, and choosing fires `action`.
56
+ `trigger` is `field` (the default, which shrinks to its icon below `compactWidth`, 160px),
57
+ `icon`, or `none` for a palette opened only by `open()` or its shortcut. Its colours come
58
+ from the theme's root `--tosi-text` / `--tosi-bg` / `--tosi-accent`, so a recoloured header
59
+ does not make its results invisible. The dialog sets its own line height, wrapping and font
60
+ size, so a header's do not reach it. The palette footer's Close is a button, for a phone,
61
+ which has no Escape key; on a touch screen the arrow/Enter cues and the Esc hint are hidden.
62
+ The ✕ that clears the query is hidden until something is typed.
63
+ - **`<tosi-affix>`** (`tosiAffix`): fixes a fixture (`slot="fixture"`) to an edge of its
64
+ content, in the FloatPosition vocabulary: the first letter is the edge, the second the
65
+ alignment, so `en` is the right edge, top-aligned. Outside (the default) it takes a grid cell
66
+ beside the content, so it never covers it and an ancestor's overflow cannot clip it; `inside`
67
+ puts it within the content against that edge. `fallback` (e.g. `"s inside"`) is used while
68
+ the fixture is longer than its edge. Setting `position`/`inside` sets where it is wanted,
69
+ reading them gives where it is, the `placed` attribute reflects it, and a `reposition` event
70
+ fires when it changes. It looks finished with no styling: a flex column on a side edge, a row
71
+ on top or bottom, the theme's inset background and spacing, outer corners rounded
72
+ (`--tosi-affix-bg/gap/padding/radius`, `::part(fixture)`).
73
+
74
+ ### Doc system
75
+
76
+ - **Search ranks pages, in the header.** The sidebar's nav filter (a substring test that hid
77
+ pages and ranked nothing) is replaced by a palette in the header bar: the site's search
78
+ field while there is room, its icon when the header is narrow, and Cmd/Ctrl-K on every page,
79
+ `{"header": false}` pages included. Pages are ranked by rough relevance (title, then
80
+ headings, keywords, description, opening paragraph, body), and a matching heading becomes a
81
+ deep link. A query word matches only the start of a word, camelCase split ("date" finds
82
+ `tosiDate`, not "update"), a whole word or its plural outranks a prefix, and a word used
83
+ often counts for more than one used once. A word matched in one heading no longer counts for
84
+ the next query word. The trigger looks like the search field it replaced (the site's
85
+ pill-shaped input), and the active row is a tint of the theme's accent. The ranking is
86
+ exported as `buildDocIndex` / `searchDocs` from `tosijs-ui/doc-system/doc-search`, with
87
+ section anchors taken from the renderer's own heading ids. `createDocBrowser({ searchShortcut })` sets the key
88
+ that opens the search, `''` for none.
89
+ - **A live example's toolbar sits beside the preview**, outside its outline, instead of a
90
+ pocket bar over the preview's top-right corner (which covered whatever the example drew
91
+ there, the crud example's New button for one). It moves inside along the bottom when the
92
+ column is taller than the preview, and when the example is maximized. The run-tests check
93
+ shows the test status; the owl handle is gone.
94
+ - Below 600px of header the link icons fold into the settings (⋮) menu, so an upright phone
95
+ has room for the title and the search.
96
+
97
+ ### Fixed
98
+
99
+ - `<tosi-crud>` has a Close button (and `close()`) that returns to the list. Under 720px (the
100
+ doc preview included) an open record replaces the list instead of squeezing into a 210px
101
+ column beside it, and the component fills a container with a height. A store that can only
102
+ list no longer shows New, Save and Delete where the page styles `button` with a `display`.
103
+ Without a schema, New starts from the fields the listed rows have instead of an empty form.
104
+ - Boolean columns from a schema asked for icons named "✓" and "✗", which do not exist; they
105
+ use `check` and `x`.
106
+ - `<tosi-table>` with no `columns` and data that arrived later showed no columns: it now
107
+ infers them once there is a row.
108
+ - `<tosi-schema-form>` with no schema kept the schema inferred from an empty value forever, and
109
+ threw "unobserve failed, listener not found" every time it left the page.
110
+
111
+ ### Dependencies
112
+
113
+ - The optional `happy-dom` peer is now `^21.0.4` (was `^20.7.0`), and tosijs-ui's own tests
114
+ run on 21.0.4. Every happy-dom from 17.0.0 to 21.0.3 is affected by
115
+ [GHSA-834v-8h66-3h79](https://github.com/advisories/GHSA-834v-8h66-3h79) (an unsanitized
116
+ import alias leads to host code execution), and there is no fixed 20.x. It matters only if
117
+ you use `tosijs-ui/test-setup` or build ePubs; then upgrade to 21.0.4. Its one breaking
118
+ change is that CSS and iframe loading are off by default, which neither relies on.
119
+
120
+ ### Tunnel
121
+
122
+ - A workspace no longer makes a request wait on the box for the revoked-device list. A
123
+ copy up to ten minutes old is used at once and refreshed behind, and when the list becomes
124
+ unreadable the dev server says so, and why. `tosijs-tunnel` refuses flag combinations it
125
+ used to ignore silently: `--share-ttl` with `--global` or without `--link --share`, and
126
+ `--label` anywhere but on a `--link --global` edit link.
127
+
3
128
  ## 1.16.31
4
129
 
5
130
  **Tunnel users: each browser needs one new `tosijs-tunnel --link --global`.** The edit cookie
@@ -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
@@ -422,6 +479,35 @@ export interface Argv {
422
479
  rest: string[]
423
480
  }
424
481
 
482
+ /**
483
+ * `tosijs-tunnel` flag combinations that would otherwise be silently ignored, each with what to
484
+ * do instead (1.16.31 review). A flag that changes nothing is a promise the bin does not keep.
485
+ */
486
+ export function tunnelFlagProblems(args: {
487
+ has: (name: string) => boolean
488
+ flag: (name: string) => string | undefined
489
+ }): string[] {
490
+ const problems: string[] = []
491
+ const link = args.has('link')
492
+ const share = args.has('share')
493
+ const global = args.has('global')
494
+ if (args.flag('share-ttl') !== undefined) {
495
+ if (global)
496
+ problems.push(
497
+ '--share-ttl does not apply to --global links: they always expire after 5 minutes. For a longer-lived view link, use --link --share --share-ttl=… without --global.'
498
+ )
499
+ else if (!(link && share))
500
+ problems.push('--share-ttl only applies to --link --share.')
501
+ }
502
+ if (args.flag('label') !== undefined) {
503
+ if (!(link && global) || share)
504
+ problems.push(
505
+ '--label names the device a --link --global EDIT link is for; it does nothing elsewhere.'
506
+ )
507
+ }
508
+ return problems
509
+ }
510
+
425
511
  /**
426
512
  * Parse `argv` against `spec`. Prints usage and exits on `--help` (0) or on an
427
513
  * unrecognised flag (1); returns a reader otherwise.
package/bin/tunnel.ts CHANGED
@@ -45,9 +45,12 @@ 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,
53
+ tunnelFlagProblems,
51
54
  } from './resolve-site-config.ts'
52
55
 
53
56
  const { has, flag } = parseArgv(process.argv.slice(2), {
@@ -81,6 +84,15 @@ const { has, flag } = parseArgv(process.argv.slice(2), {
81
84
  ` --port= --local-port= --remote-port= override the resolved ports`,
82
85
  })
83
86
 
87
+ {
88
+ const problems = tunnelFlagProblems({ has, flag })
89
+ if (problems.length) {
90
+ for (const problem of problems) console.error(`\n${problem}`)
91
+ console.error('')
92
+ process.exit(1)
93
+ }
94
+ }
95
+
84
96
  const siteConfig = await resolveSiteConfig()
85
97
 
86
98
  const preview = siteConfig.preview
@@ -161,7 +173,7 @@ holds too; `notes` says why any other is missing.
161
173
  async function globalLinks(
162
174
  kinds: ('edit' | 'view')[] = ['edit', 'view']
163
175
  ): Promise<{
164
- links: { kind: 'edit' | 'view'; url: string }[]
176
+ links: { kind: 'edit' | 'view'; url: string; short?: string }[]
165
177
  notes: string[]
166
178
  }> {
167
179
  const local = resolveGlobalKeys(preview?.tunnel?.globalKeys, {
@@ -173,7 +185,7 @@ async function globalLinks(
173
185
  const box =
174
186
  local.keys.edit || local.keys.view ? await previewBoxKeys(host!) : {}
175
187
  const plan = planGlobalLinks(local, box)
176
- const links: { kind: 'edit' | 'view'; url: string }[] = []
188
+ const links: { kind: 'edit' | 'view'; url: string; short?: string }[] = []
177
189
  for (const kind of plan.kinds.filter((k) => kinds.includes(k))) {
178
190
  const label = flag('label')
179
191
  const answer = await askForLink(
@@ -196,8 +208,16 @@ async function globalLinks(
196
208
  continue
197
209
  }
198
210
  }
199
- if (answer.url) links.push({ kind, url: answer.url })
200
- 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
201
221
  plan.notes.push(
202
222
  answer.error ??
203
223
  `the dev server on :${localPort} did not answer, so no ${kind} link: start it (\`bun start\`), then \`tosijs-tunnel --link\``
@@ -234,6 +254,41 @@ async function registerDevice(device: {
234
254
  return run.exitCode === 0
235
255
  }
236
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
+
237
292
  /*
238
293
  Say where this is going, and where the address came from, BEFORE acting on it.
239
294
 
@@ -368,10 +423,12 @@ if (has('link')) {
368
423
  break the promise `--share` makes to the person you send it to.
369
424
  */
370
425
  let url: string
426
+ let short: string | undefined
371
427
  if (has('global')) {
372
428
  const global = await globalLinks([share ? 'view' : 'edit'])
373
429
  for (const note of global.notes) console.log(`note: ${note}`)
374
430
  url = global.links[0]?.url ?? ''
431
+ short = global.links[0]?.short
375
432
  if (!url) {
376
433
  console.error('\nNo single-use global link (see the note above).\n')
377
434
  process.exit(1)
@@ -409,7 +466,14 @@ if (has('link')) {
409
466
  ? 'Share link (READ ONLY)'
410
467
  : 'Edit link'
411
468
  }:\n ${url}\n` +
412
- (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
+ : '')
413
477
  )
414
478
 
415
479
  /*
@@ -644,8 +708,13 @@ if (publicUrl) {
644
708
  `\n Single-use links: each opens this workspace, then keeps that browser in every` +
645
709
  `\n workspace on the box (and its root list) for 30 days:`
646
710
  )
647
- for (const { kind, url } of startLinks.links)
648
- 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
+ }
649
718
  }
650
719
  for (const note of startLinks?.notes ?? []) console.log(` note: ${note}`)
651
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>