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.
- package/CHANGELOG.md +125 -0
- package/bin/caddy-install.ts +13 -1
- package/bin/resolve-site-config.ts +86 -0
- package/bin/tunnel.ts +76 -7
- package/deploy/Caddyfile +53 -1
- package/deploy/build-index.sh +11 -0
- package/deploy/gate-page.html +178 -0
- package/dist/affix.d.ts +97 -0
- package/dist/affix.js +445 -0
- package/dist/crud.d.ts +22 -0
- package/dist/crud.js +68 -5
- package/dist/data-table.js +10 -0
- package/dist/doc-browser.d.ts +6 -0
- package/dist/doc-browser.js +155 -115
- package/dist/doc-system/doc-search.d.ts +43 -0
- package/dist/doc-system/doc-search.js +194 -0
- package/dist/doc-system/doc-system.js +5 -0
- package/dist/doc-system/site/dev-auth.d.ts +11 -0
- package/dist/doc-system/site/dev-auth.js +26 -0
- package/dist/doc-system/site/dev-server.js +32 -7
- package/dist/doc-system/site/device-revocation.d.ts +13 -1
- package/dist/doc-system/site/device-revocation.js +51 -13
- package/dist/doc-system/site/invite-page.d.ts +7 -1
- package/dist/doc-system/site/invite-page.js +18 -3
- package/dist/editable-rect.d.ts +1 -1
- package/dist/form.d.ts +1 -1
- package/dist/iife.js +118 -112
- package/dist/iife.js.map +17 -14
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/live-example/component.d.ts +39 -15
- package/dist/live-example/component.js +42 -50
- package/dist/live-example/styles.d.ts +37 -14
- package/dist/live-example/styles.js +52 -37
- package/dist/notifications.js +11 -4
- package/dist/palette.d.ts +90 -0
- package/dist/palette.js +898 -0
- package/dist/schema-form.d.ts +2 -1
- package/dist/schema-form.js +23 -8
- package/dist/search-field.js +0 -12
- package/dist/segmented.d.ts +1 -1
- package/dist/tab-selector.d.ts +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +5 -1
- 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
|
package/bin/caddy-install.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
200
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
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 "
|
|
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
|
|
package/deploy/build-index.sh
CHANGED
|
@@ -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>
|