tosijs-ui 1.16.32 → 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 +32 -0
- package/bin/caddy-install.ts +13 -1
- package/bin/resolve-site-config.ts +57 -0
- package/bin/tunnel.ts +66 -7
- package/deploy/Caddyfile +53 -1
- package/deploy/build-index.sh +11 -0
- package/deploy/gate-page.html +178 -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 +22 -6
- package/dist/doc-system/site/invite-page.d.ts +7 -1
- package/dist/doc-system/site/invite-page.js +18 -3
- package/dist/iife.js +2 -2
- package/dist/iife.js.map +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
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
|
+
|
|
3
35
|
## 1.16.32
|
|
4
36
|
|
|
5
37
|
Two new components, a doc search that ranks, and live examples whose toolbar no longer covers
|
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
|
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)
|
|
210
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
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 "
|
|
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>
|
|
@@ -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.
|
|
@@ -22,7 +22,7 @@ import { resolveTunnelLocalPort } from './site-config.js';
|
|
|
22
22
|
import { acquireBuildLock, describeHolder } from './build-lock.js';
|
|
23
23
|
import { sinkPathFor, appendToSink } from './debug-sink.js';
|
|
24
24
|
import { invitePageHtml } from './invite-page.js';
|
|
25
|
-
import { TUNNEL_LINK_CMD, resolveLinkArrival, isLockedDown, hasTunnel, isLoopbackAddressForAuth as isLoopbackAddress, mayReadSite, shouldInterceptLinkToken, createAuthState, issueLink, globalKeyCookie, grantCookies, readPass, revokeDeviceSessions, mintDeviceId, cleanDeviceLabel, grantDomainFor, takeSessionGrant, outstandingGrants, MAX_OUTSTANDING_GRANTS, resolveShareTtlMs, resolveGrantTtlMs, readCookie, redeemThroughGate, createRedemptionGate, resolveLinkSettings, sessionCookie, urlWithoutToken, sameOriginPath, isSameOriginRequest, validSessionCookie, sessionRejection, haltijaBridgeAdmission, isProxiedRequest, sourceRequestAdmission, trustLanRefusalHint, trustLanWriteRecord, resolveLanTrust, describeLanTrust, resolveGlobalKeys, globalKeyAccess, globalKeyExchange, globalKeyRefresh, liveProbeOrigin, issueSession, cookieMayWrite, GLOBAL_KEY_SESSION_TTL_MS, SESSION_COOKIE, } from './dev-auth.js';
|
|
25
|
+
import { TUNNEL_LINK_CMD, resolveLinkArrival, linkRejection, isLockedDown, hasTunnel, isLoopbackAddressForAuth as isLoopbackAddress, mayReadSite, shouldInterceptLinkToken, createAuthState, issueLink, globalKeyCookie, grantCookies, readPass, revokeDeviceSessions, mintDeviceId, cleanDeviceLabel, grantDomainFor, takeSessionGrant, outstandingGrants, MAX_OUTSTANDING_GRANTS, resolveShareTtlMs, resolveGrantTtlMs, readCookie, redeemThroughGate, createRedemptionGate, resolveLinkSettings, sessionCookie, urlWithoutToken, sameOriginPath, isSameOriginRequest, validSessionCookie, sessionRejection, haltijaBridgeAdmission, isProxiedRequest, sourceRequestAdmission, trustLanRefusalHint, trustLanWriteRecord, resolveLanTrust, describeLanTrust, resolveGlobalKeys, globalKeyAccess, globalKeyExchange, globalKeyRefresh, liveProbeOrigin, issueSession, cookieMayWrite, GLOBAL_KEY_SESSION_TTL_MS, SESSION_COOKIE, } from './dev-auth.js';
|
|
26
26
|
import { readLocalSecret } from './local-secrets.js';
|
|
27
27
|
import { revocationList, REVOCATION_FRESH_MS } from './device-revocation.js';
|
|
28
28
|
import { resolveDataStore, resolveDataFile, dataContentProblem, listDataFiles, saveDataFile, dataHistory, readDataAt, isGitWorkTree, } from './data-store.js';
|
|
@@ -2123,14 +2123,30 @@ export async function devServer(config, opts = {}) {
|
|
|
2123
2123
|
unfurlers as a reason for single-use without handling the consequence: you click
|
|
2124
2124
|
a dead link, get silently redirected, and the next signal is a save failing.
|
|
2125
2125
|
*/
|
|
2126
|
-
|
|
2126
|
+
/*
|
|
2127
|
+
Which refusal (board #3505). A code nobody minted is almost always a typo, and saying
|
|
2128
|
+
"has been used" sent a headset user off to mint a new link when retyping one letter
|
|
2129
|
+
would have done: so an unknown code gets the box back, with what was typed in it.
|
|
2130
|
+
*/
|
|
2131
|
+
const why = linkRejection(auth, linkToken, Date.now());
|
|
2132
|
+
const noStore = {
|
|
2133
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
2134
|
+
'Cache-Control': 'no-store',
|
|
2135
|
+
'Referrer-Policy': 'no-referrer',
|
|
2136
|
+
};
|
|
2137
|
+
if (why === 'unknown') {
|
|
2138
|
+
return new Response(invitePageHtml('none', TUNNEL_LINK_CMD, globalKeys.keys.edit ? `${TUNNEL_LINK_CMD} --global` : undefined, linkToken), { status: 401, headers: noStore });
|
|
2139
|
+
}
|
|
2140
|
+
const spent = `<!doctype html><meta charset=utf-8><title>${why === 'used' ? 'Link already used' : 'Link expired'}</title>` +
|
|
2127
2141
|
`<style>body{font:16px/1.6 system-ui;margin:15vh auto;max-width:32rem;padding:0 1.5rem;color:#222}` +
|
|
2128
2142
|
`@media(prefers-color-scheme:dark){body{background:#16171a;color:#e8e8ea}}` +
|
|
2129
2143
|
`code{background:#8881;padding:.1em .4em;border-radius:4px}</style>` +
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2144
|
+
(why === 'used'
|
|
2145
|
+
? `<h1>That invite link has been used</h1>` +
|
|
2146
|
+
`<p>Each link opens one browser. If you pasted this ` +
|
|
2147
|
+
`link into a chat app, its link preview may have spent it before you clicked.</p>`
|
|
2148
|
+
: `<h1>That invite link has expired</h1>` +
|
|
2149
|
+
`<p>Every link has a time limit; the browser that opens one in time stays in.</p>`) +
|
|
2134
2150
|
`<p>Ask for a fresh one: <code>${TUNNEL_LINK_CMD}</code></p>`;
|
|
2135
2151
|
return new Response(spent, {
|
|
2136
2152
|
status: 401,
|