tosijs-ui 1.16.34 → 1.16.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,84 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.36
4
+
5
+ One additive site option. Nothing changes for a site that does not set it.
6
+
7
+ - **A page can be served at a second path (#3513).** `aliases: { readme: '' }` in the site
8
+ config also writes the root page to `/readme/`, with its stylesheet, bundle and `docs.json`
9
+ references worked out for that depth, and the browser opens it as the README. Before this a
10
+ host rewrite was the only way, and it served the page one level down with every relative
11
+ reference 404ing, so the page rendered but never hydrated. An alias page's canonical link
12
+ names the page's own path, aliases stay out of the sitemap and the nav, and relative images
13
+ and links in the page are rewritten to reach the same files. An alias is one lowercase path
14
+ segment with no dot; the build fails on one that is not, that takes a page's own path or a
15
+ static folder's, or that names no page.
16
+
17
+ ## 1.16.35
18
+
19
+ Board follow-ups from 1.16.32 and 1.16.33, plus one tunnel feature. Nothing to change in an
20
+ adopter's product. **Tunnel users:** a plain `--link` / `--share` now keeps its browser in
21
+ across a dev-server restart for 24 hours, and the dev server writes a signing secret to
22
+ `.tosijs-dev/` in your project (it gitignores itself). **Box operators:** re-run
23
+ `tosijs-caddy-install --go` to tighten the access-log permissions; the dry run no longer
24
+ prints old keys.
25
+
26
+ ### Tunnel
27
+
28
+ - **A link survives a restart (#3416).** Redeeming a plain `--link` or `--share` link also
29
+ sets a signed pass (`__Host-tosi_link_pass`: kind, workspace host, expiry 24 hours after
30
+ redemption, HMAC-SHA256 with a per-project secret). After a restart, the next page load or
31
+ same-origin request trades it for a fresh session of the same kind, the way a `--global`
32
+ key is traded. A share link's pass stays read-only, a pass works only on the host it was
33
+ issued for, and a session it mints never outlives it.
34
+ - **What that costs.** Until now nothing about a link session outlived the process, so a
35
+ stolen session cookie died with the server. A stolen pass now works for the rest of its 24
36
+ hours, across restarts. To revoke every pass at once, delete
37
+ `.tosijs-dev/session-secret`. The secret is created 0600 in a 0700 folder. The dev server
38
+ refuses to start if it sits inside the output folder; if group or others have any access to
39
+ it, links still open but stop surviving restarts, and the console says why. A filesystem that keeps no modes (a Windows
40
+ drive under WSL, FAT) cannot hold the secret: plain links there end on restart, as before.
41
+ Revoking a device does not end a link pass.
42
+ - **The "dev server restarted" page** now says a link keeps its browser in for 24 hours,
43
+ instead of claiming sessions are never written to disk.
44
+
45
+ ### Preview box
46
+
47
+ - **The dry run no longer prints old keys (#3502).** `tosijs-caddy-install` redacted only the
48
+ current keys from preview.env, but the diff's removed lines come from the live Caddyfile, so
49
+ after a rotation the previous `PREVIEW_TOKEN` / `PREVIEW_EDIT_TOKEN` printed in clear. Every
50
+ value in a key position (`tosi_preview=`, `tosi_edit=`, `query t=`) on either side is now
51
+ blanked wherever it appears, comments included, and so is the refusal that names leftover
52
+ placeholders.
53
+ - **Access logs.** `--go` takes group and other access off the existing preview access logs
54
+ (`/var/log/caddy/preview*.log*`, symlinks skipped), which record short-link codes. A log
55
+ shipper that reads them through group membership loses access; give it root or the caddy
56
+ user. Caddy 2.6 has no log mode, so a log Caddy creates after the install gets Caddy's own
57
+ mode; doc-site-system.md says exactly what is covered.
58
+ - **Paths with a space.** The installer found neither its template nor the gate page when the
59
+ package lived under a path with a space (`new URL().pathname` kept `%20`).
60
+ - **Gate page.** The installer reads `deploy/gate-page.html` only when the template names
61
+ `__GATE_PAGE__`, and refuses a missing one with a message instead of a stack trace.
62
+ - **Custom templates.** doc-site-system.md now says a custom `--template` keeps its old gate
63
+ page and never serves short links, and lists the lines to copy from `deploy/Caddyfile`.
64
+
65
+ ### Components
66
+
67
+ - **`<tosi-table>` with no data.** `columns` returned a new `[]` on every read, and
68
+ `table.value = { ...table.value, array }` stored that `[]` as the columns, fixing the table
69
+ at zero columns for the data that followed. The empty list is now one per table, and handing
70
+ it back means "infer".
71
+ - **`<tosi-palette>`.** An unknown `trigger` value now behaves exactly as `field` (before, it
72
+ showed the full field but never compacted), and warns once per value, naming it and the
73
+ fallback. Setting `value` from code was ignored once
74
+ the query field existed; it now updates the field, and searches again if the palette is
75
+ open.
76
+ - **Doc browser.** Choosing a palette section result while the source editor held unsaved
77
+ edits, and dismissing the confirm, still rewrote the URL to the target's `#anchor` and
78
+ scrolled. Now nothing moves when navigation is refused.
79
+ - **`<tosi-crud>` docs** describe `close()`, the Close button, the one-column narrow layout and
80
+ `TosiCrud.NARROW_WIDTH`.
81
+
3
82
  ## 1.16.34
4
83
 
5
84
  Spinning icons keep spinning while the page is busy, which is the only time a loader is on
@@ -36,6 +36,7 @@ Three deliberate properties:
36
36
  */
37
37
 
38
38
  import { existsSync, readFileSync } from 'fs'
39
+ import { fileURLToPath } from 'url'
39
40
  import {
40
41
  embedGatePage,
41
42
  parseArgv,
@@ -82,8 +83,14 @@ The template ships inside the package, so the default is resolved relative to TH
82
83
  rather than to the cwd — an adopter runs the bin from their own project, where `deploy/` is
83
84
  their business and not ours.
84
85
  */
86
+ /*
87
+ `fileURLToPath`, not `new URL(...).pathname`: the latter keeps percent-encoding, so a package
88
+ installed under a path with a space (`/Users/me/My Projects/…`) resolved to `My%20Projects` and
89
+ the shipped template was reported missing.
90
+ */
85
91
  const templatePath =
86
- flag('template') ?? new URL('../deploy/Caddyfile', import.meta.url).pathname
92
+ flag('template') ??
93
+ fileURLToPath(new URL('../deploy/Caddyfile', import.meta.url))
87
94
 
88
95
  if (!existsSync(templatePath)) {
89
96
  console.error(
@@ -97,15 +104,26 @@ if (!existsSync(templatePath)) {
97
104
  /*
98
105
  `__GATE_PAGE__` is the page a browser without the preview cookie sees (deploy/gate-page.html,
99
106
  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.
107
+ box. A template of your own that names it gets the shipped page; one that does not never needs
108
+ the file, so it is read only when named.
101
109
  */
102
- const template = embedGatePage(
103
- readFileSync(templatePath, 'utf8'),
104
- readFileSync(
105
- new URL('../deploy/gate-page.html', import.meta.url).pathname,
106
- 'utf8'
110
+ const rawTemplate = readFileSync(templatePath, 'utf8')
111
+ let template = rawTemplate
112
+ if (rawTemplate.includes('__GATE_PAGE__')) {
113
+ const gatePagePath = fileURLToPath(
114
+ new URL('../deploy/gate-page.html', import.meta.url)
107
115
  )
108
- )
116
+ if (!existsSync(gatePagePath)) {
117
+ console.error(
118
+ `\nNo gate page at ${gatePagePath}\n\n` +
119
+ ` ${templatePath} names __GATE_PAGE__, which is filled from that file.\n` +
120
+ ` The package ships it at node_modules/tosijs-ui/deploy/gate-page.html;\n` +
121
+ ` reinstall the package, or drop __GATE_PAGE__ from your template.\n`
122
+ )
123
+ process.exit(1)
124
+ }
125
+ template = embedGatePage(rawTemplate, readFileSync(gatePagePath, 'utf8'))
126
+ }
109
127
 
110
128
  // The template is delivered inside a quoted heredoc, so it is copied verbatim with no shell
111
129
  // expansion. A template containing the delimiter would truncate the file silently — refuse
@@ -154,6 +172,40 @@ elif [ "$PREVIEW_EDIT_TOKEN" = "$PREVIEW_TOKEN" ]; then
154
172
  exit 1
155
173
  fi
156
174
 
175
+ # Everything this script prints from a Caddyfile goes through \`redact <files>\`. Redacting only
176
+ # the CURRENT keys was not enough: a dry-run diff's removed lines come from the live file, which
177
+ # after a rotation holds the PREVIOUS keys (board #3502). So the values are harvested from every
178
+ # position the template puts a key, in the files being shown: the cookie names in matchers and
179
+ # Set-Cookie (\`tosi_preview=\`, \`tosi_edit=\`) and the link matchers (\`query t=\`). Each value,
180
+ # and each current key, is then blanked wherever it appears, comments included (the installer's
181
+ # substitution reaches the comments that name the placeholders), longest first so one key cannot
182
+ # leave part of another showing. Finally anything still in a key position is blanked by shape.
183
+ # Here the values travel in the environment, not argv, where \`ps\` would show them. (The
184
+ # install's own sed below still passes the keys in argv, for the moment it runs.)
185
+ KEY_POSITION='(tosi_(preview|edit)=|query[[:space:]]+t=)'
186
+ KEY_VALUE='[^[:space:];"()|*]+'
187
+ key_values() {
188
+ { grep -ohE "$KEY_POSITION$KEY_VALUE" "$@" || [ $? -eq 1 ]; } | sed -E "s/^$KEY_POSITION//"
189
+ printf '%s\\n' "$PREVIEW_TOKEN" "$PREVIEW_EDIT_TOKEN"
190
+ }
191
+ redact() {
192
+ REDACT_KEYS="$(key_values "$@" | sort -u | awk 'length { print length, $0 }' | sort -rn | cut -d' ' -f2-)" \\
193
+ awk 'BEGIN { n = split(ENVIRON["REDACT_KEYS"], key, "\\n") }
194
+ {
195
+ line = $0
196
+ for (i = 1; i <= n; i++) {
197
+ out = ""
198
+ while (key[i] != "" && (at = index(line, key[i])) > 0) {
199
+ out = out substr(line, 1, at - 1) "***REDACTED***"
200
+ line = substr(line, at + length(key[i]))
201
+ }
202
+ line = out line
203
+ }
204
+ print line
205
+ }' \\
206
+ | sed -E "s/$KEY_POSITION$KEY_VALUE/\\1***REDACTED***/g"
207
+ }
208
+
157
209
  sed -e "s|__PREVIEW_TOKEN__|$PREVIEW_TOKEN|g" \\
158
210
  -e "s|__PREVIEW_EDIT_TOKEN__|$PREVIEW_EDIT_TOKEN|g" \\
159
211
  -e "s|{{ACME_EMAIL}}|$ACME_EMAIL|g" \\
@@ -162,7 +214,7 @@ sed -e "s|__PREVIEW_TOKEN__|$PREVIEW_TOKEN|g" \\
162
214
 
163
215
  if grep -qE '\\{\\{|__PREVIEW_(EDIT_)?TOKEN__' /etc/caddy/Caddyfile.new; then
164
216
  echo "Unsubstituted placeholders remain — refusing to install:" >&2
165
- grep -nE '\\{\\{|__PREVIEW_(EDIT_)?TOKEN__' /etc/caddy/Caddyfile.new >&2
217
+ grep -nE '\\{\\{|__PREVIEW_(EDIT_)?TOKEN__' /etc/caddy/Caddyfile.new | redact /etc/caddy/Caddyfile.new >&2
166
218
  rm -f /etc/caddy/Caddyfile.new
167
219
  exit 1
168
220
  fi
@@ -195,6 +247,14 @@ if [ "${go ? '1' : '0'}" = "1" ]; then
195
247
  else
196
248
  chmod 600 /etc/caddy/Caddyfile.new
197
249
  fi
250
+ # The access log records short-link codes as paths, unredacted (a code is spent or expired within
251
+ # five minutes). Caddy 2.6 has no log \`mode\`, so take group and other access off the preview logs
252
+ # that exist; Caddy keeps owning them. \`-type f\` skips symlinks, so a link planted in a directory
253
+ # Caddy can write cannot point this chmod at another file. Done before the swap, so a failure
254
+ # here leaves the running config untouched, like every other refusal.
255
+ if [ -d /var/log/caddy ]; then
256
+ find /var/log/caddy -maxdepth 1 -type f -name 'preview*.log*' -exec chmod go-rwx {} +
257
+ fi
198
258
  mv /etc/caddy/Caddyfile.new /etc/caddy/Caddyfile
199
259
  # A stopped Caddy cannot reload. Start it instead, or a box left down by the bug above could
200
260
  # never be repaired by re-running this.
@@ -222,11 +282,11 @@ if [ "${go ? '1' : '0'}" = "1" ]; then
222
282
  echo "Installed /etc/caddy/Caddyfile (caddy: $verb)."
223
283
  echo "Previous config saved as /etc/caddy/Caddyfile.bak"
224
284
  else
225
- echo "--- would change /etc/caddy/Caddyfile (token redacted) ---"
285
+ echo "--- would change /etc/caddy/Caddyfile (keys redacted) ---"
226
286
  if [ -f /etc/caddy/Caddyfile ]; then
227
- diff -u /etc/caddy/Caddyfile /etc/caddy/Caddyfile.new \\
228
- | sed -e "s|$PREVIEW_TOKEN|***REDACTED***|g" \\
229
- -e "s|$PREVIEW_EDIT_TOKEN|***REDACTED***|g" || true
287
+ # diff exits 1 when the files differ, which is the expected case; 2 is a real failure.
288
+ { diff -u /etc/caddy/Caddyfile /etc/caddy/Caddyfile.new || [ $? -eq 1 ]; } \\
289
+ | redact /etc/caddy/Caddyfile /etc/caddy/Caddyfile.new
230
290
  else
231
291
  echo "(no existing /etc/caddy/Caddyfile — this would create it)"
232
292
  fi
@@ -0,0 +1,208 @@
1
+ /*
2
+ The links `tosijs-tunnel` prints: single-use global links (`--link --global`, and the ones
3
+ printed at tunnel start), the device record behind each edit pass, and the short link the box
4
+ can answer for a code.
5
+
6
+ Split out of bin/tunnel.ts so it can be tested without a box (board #3416 item 2, #3506): the
7
+ bin is a script that runs on import, so everything that reaches the network is passed in here
8
+ (`ssh`, `fetchText`, `askForLink`) and the bin wires the real ones.
9
+ */
10
+
11
+ import { $ } from 'bun'
12
+ import {
13
+ deviceRegistryLine,
14
+ groupCode,
15
+ planGlobalLinks,
16
+ shortCodeScript,
17
+ type BoxKeys,
18
+ } from './resolve-site-config.ts'
19
+ import type { GlobalKeys } from '../dist/doc-system/site/dev-auth.js'
20
+
21
+ export type LinkKind = 'edit' | 'view'
22
+
23
+ export interface DevLinkAnswer {
24
+ url?: string
25
+ device?: string
26
+ error?: string
27
+ }
28
+
29
+ export interface GlobalLink {
30
+ kind: LinkKind
31
+ url: string
32
+ short?: string
33
+ }
34
+
35
+ export interface TunnelLinkDeps {
36
+ /** The preview box, as ssh names it. */
37
+ host: string
38
+ /** The preview root on the box (`/srv/preview`), where `_devices/` and `_codes/` live. */
39
+ root: string
40
+ projectName: string
41
+ /** The dev server's TLS port, for messages. */
42
+ localPort: number
43
+ /** `--label=`, naming the device a `--global` edit link is for. */
44
+ label?: string
45
+ /** True for `--link`; false for the links printed at tunnel start. */
46
+ onRequest: boolean
47
+ /** This machine's global keys, as `resolveGlobalKeys` read them. */
48
+ local: { keys: GlobalKeys; problems: string[] }
49
+ /** The box's domain and key fingerprints (`previewBoxKeys`). */
50
+ boxKeys: () => Promise<BoxKeys>
51
+ /** Run one command on the box; only the exit code is used. */
52
+ ssh: (command: string) => Promise<{ exitCode: number }>
53
+ /** Ask the dev server (loopback only) for a link; `query` selects the kind. */
54
+ askForLink: (query: string) => Promise<DevLinkAnswer>
55
+ /** GET a URL and return its body, or '' for anything but a 2xx. Never throws. */
56
+ fetchText: (url: string) => Promise<string>
57
+ }
58
+
59
+ /*
60
+ SHORT LINKS (board #3504): record which workspace a code belongs to, so `https://<domain>/<CODE>`
61
+ (or the code typed into the box on the gate and root pages) finds it. See shortCodeScript.
62
+ Returns the short link, or the reason there is none (the full link still works either way).
63
+ */
64
+ export async function registerShortCode(
65
+ deps: Pick<TunnelLinkDeps, 'host' | 'root' | 'ssh' | 'fetchText'>,
66
+ url: string,
67
+ domain: string
68
+ ): Promise<{ short?: string; problem?: string }> {
69
+ const code = new URL(url).searchParams.get('t') ?? ''
70
+ const script = shortCodeScript(deps.root, code, url)
71
+ if (!script) return { problem: `${url} is not a link the box can shorten` }
72
+ const run = await deps.ssh(script)
73
+ if (run.exitCode !== 0)
74
+ return { problem: `could not record it on ${deps.host}` }
75
+ // Read it back through Caddy: a box whose Caddyfile predates short links answers with its
76
+ // gate, and a short link printed over that would lead nowhere.
77
+ const served = await deps.fetchText(`https://${domain}/_codes/${code}`)
78
+ if (served.trim() !== url)
79
+ return {
80
+ problem: `the box's Caddyfile does not serve short links yet: run \`tosijs-caddy-install --go\``,
81
+ }
82
+ return { short: `https://${domain}/${code}` }
83
+ }
84
+
85
+ /** Append one line to the box's device registry. False if it could not be recorded. */
86
+ export async function registerDevice(
87
+ deps: Pick<TunnelLinkDeps, 'root' | 'ssh'>,
88
+ device: { id: string; project: string; label: string },
89
+ issued = new Date()
90
+ ): Promise<boolean> {
91
+ const line = deviceRegistryLine({ ...device, issued })
92
+ if (!line) return false
93
+ const run = await deps.ssh(
94
+ `mkdir -p ${deps.root}/_devices && printf '%s\\n' '${line}' >> ${deps.root}/_devices/registry.tsv`
95
+ )
96
+ return run.exitCode === 0
97
+ }
98
+
99
+ /*
100
+ Single-use links that also give the browser the box's global key (see GlobalGrant in
101
+ dev-auth.ts): one click opens this workspace, and from then on that browser opens every
102
+ workspace holding the key, and the box's root list, for 30 days, across restarts. The link
103
+ carries a one-use code, never the key. Offered only for keys this workspace accepts and the box
104
+ holds too; `notes` says why any other is missing.
105
+ */
106
+ export async function globalLinks(
107
+ deps: TunnelLinkDeps,
108
+ kinds: LinkKind[] = ['edit', 'view']
109
+ ): Promise<{ links: GlobalLink[]; notes: string[] }> {
110
+ const { local, label } = deps
111
+ const box: BoxKeys =
112
+ local.keys.edit || local.keys.view ? await deps.boxKeys() : {}
113
+ const plan = planGlobalLinks(local, box)
114
+ const links: GlobalLink[] = []
115
+ for (const kind of plan.kinds.filter((k) => kinds.includes(k))) {
116
+ const answer = await deps.askForLink(
117
+ `?global=${kind}&domain=${encodeURIComponent(plan.domain!)}${
118
+ label ? `&label=${encodeURIComponent(label)}` : ''
119
+ }`
120
+ )
121
+ if (answer.url && answer.device) {
122
+ // Recorded before the link is shown, so every pass in a browser is on the box's list.
123
+ const recorded = await registerDevice(deps, {
124
+ id: answer.device,
125
+ project: deps.projectName,
126
+ // The links printed at start are recorded too, used or not; say which they were.
127
+ label: label ?? (deps.onRequest ? '' : 'printed at tunnel start'),
128
+ })
129
+ if (!recorded) {
130
+ plan.notes.push(
131
+ `could not record device ${answer.device} on ${deps.host}, so no ${kind} link: it could not be listed or revoked`
132
+ )
133
+ continue
134
+ }
135
+ }
136
+ if (answer.url) {
137
+ const { short, problem } = await registerShortCode(
138
+ deps,
139
+ answer.url,
140
+ plan.domain!
141
+ )
142
+ if (!short)
143
+ plan.notes.push(`no short link (${problem}); the full link still works`)
144
+ links.push({ kind, url: answer.url, short })
145
+ } else
146
+ plan.notes.push(
147
+ answer.error ??
148
+ `the dev server on :${deps.localPort} did not answer, so no ${kind} link: start it (\`bun start\`), then \`tosijs-tunnel --link\``
149
+ )
150
+ }
151
+ return { links, notes: plan.notes }
152
+ }
153
+
154
+ /** What the tunnel prints under its public URL at start: the links, then the notes. */
155
+ export function startLinkLines(start: {
156
+ links: GlobalLink[]
157
+ notes: string[]
158
+ }): string[] {
159
+ const lines: string[] = []
160
+ if (start.links.length) {
161
+ lines.push(
162
+ `\n Single-use links: each opens this workspace, then keeps that browser in every` +
163
+ `\n workspace on the box (and its root list) for 30 days:`
164
+ )
165
+ for (const { kind, url, short } of start.links) {
166
+ const code = new URL(url).searchParams.get('t') ?? ''
167
+ lines.push(
168
+ ` ${kind}: ${url}${kind === 'view' ? ' (read-only)' : ''}` +
169
+ (short ? `\n ${short} code ${groupCode(code)}` : '')
170
+ )
171
+ }
172
+ }
173
+ for (const note of start.notes) lines.push(` note: ${note}`)
174
+ return lines
175
+ }
176
+
177
+ /** The real `askForLink`: the dev server's `/__devlink`, on loopback, over its TLS port. */
178
+ export async function askDevServer(
179
+ localPort: number,
180
+ query: string
181
+ ): Promise<DevLinkAnswer> {
182
+ const res =
183
+ await $`curl -sk --max-time 5 https://localhost:${localPort}/__devlink${query}`
184
+ .nothrow()
185
+ .quiet()
186
+ try {
187
+ return JSON.parse(res.stdout.toString())
188
+ } catch {
189
+ return {}
190
+ }
191
+ }
192
+
193
+ /** The real `ssh`: never prompts or hangs, since a link is waiting on it. */
194
+ export async function sshTo(
195
+ host: string,
196
+ command: string
197
+ ): Promise<{ exitCode: number }> {
198
+ return $`ssh -o BatchMode=yes -o ConnectTimeout=10 ${host} ${command}`
199
+ .nothrow()
200
+ .quiet()
201
+ }
202
+
203
+ /** The real `fetchText`. */
204
+ export async function fetchText(url: string): Promise<string> {
205
+ return fetch(url, { signal: AbortSignal.timeout(5000) })
206
+ .then((res) => (res.ok ? res.text() : ''))
207
+ .catch(() => '')
208
+ }
package/bin/tunnel.ts CHANGED
@@ -32,7 +32,14 @@ always requires a session.
32
32
 
33
33
  import { $ } from 'bun'
34
34
  import {
35
- planGlobalLinks,
35
+ askDevServer,
36
+ fetchText,
37
+ globalLinks as globalLinksFor,
38
+ sshTo,
39
+ startLinkLines,
40
+ type LinkKind,
41
+ } from './tunnel-links.ts'
42
+ import {
36
43
  previewBoxKeys,
37
44
  previewRootFor,
38
45
  readLocalSecret,
@@ -44,9 +51,7 @@ import {
44
51
  resolveSiteConfig,
45
52
  resolveTunnelLocalPort,
46
53
  parseArgv,
47
- deviceRegistryLine,
48
54
  groupCode,
49
- shortCodeScript,
50
55
  parseDeviceListing,
51
56
  DEVICE_LISTING_SEPARATOR,
52
57
  DEVICE_ID_PATTERN,
@@ -148,84 +153,11 @@ const publicUrl = flag('url') ?? preview?.tunnel?.url
148
153
  // Set by the registration step below; no public URL means there is no route to fail.
149
154
  let routed = true
150
155
 
151
- /** Ask the dev server (loopback only) for a link; `query` selects the kind. */
152
- async function askForLink(
153
- query: string
154
- ): Promise<{ url?: string; device?: string; error?: string }> {
155
- const res =
156
- await $`curl -sk --max-time 5 https://localhost:${localPort}/__devlink${query}`
157
- .nothrow()
158
- .quiet()
159
- try {
160
- return JSON.parse(res.stdout.toString())
161
- } catch {
162
- return {}
163
- }
164
- }
165
-
166
156
  /*
167
- Single-use links that also give the browser the box's global key (see GlobalGrant in
168
- dev-auth.ts): one click opens this workspace, and from then on that browser opens every
169
- workspace holding the key, and the box's root list, for 30 days, across restarts. The link
170
- carries a one-use code, never the key. Offered only for keys this workspace accepts and the box
171
- holds too; `notes` says why any other is missing.
157
+ The link logic lives in tunnel-links.ts, with everything that reaches the network passed in, so
158
+ it can be tested without a box. These are the real ones.
172
159
  */
173
- async function globalLinks(
174
- kinds: ('edit' | 'view')[] = ['edit', 'view']
175
- ): Promise<{
176
- links: { kind: 'edit' | 'view'; url: string; short?: string }[]
177
- notes: string[]
178
- }> {
179
- const local = resolveGlobalKeys(preview?.tunnel?.globalKeys, {
180
- PREVIEW_TOKEN:
181
- process.env.PREVIEW_TOKEN ?? readLocalSecret('PREVIEW_TOKEN'),
182
- PREVIEW_EDIT_TOKEN:
183
- process.env.PREVIEW_EDIT_TOKEN ?? readLocalSecret('PREVIEW_EDIT_TOKEN'),
184
- })
185
- const box =
186
- local.keys.edit || local.keys.view ? await previewBoxKeys(host!) : {}
187
- const plan = planGlobalLinks(local, box)
188
- const links: { kind: 'edit' | 'view'; url: string; short?: string }[] = []
189
- for (const kind of plan.kinds.filter((k) => kinds.includes(k))) {
190
- const label = flag('label')
191
- const answer = await askForLink(
192
- `?global=${kind}&domain=${encodeURIComponent(plan.domain!)}${
193
- label ? `&label=${encodeURIComponent(label)}` : ''
194
- }`
195
- )
196
- if (answer.url && answer.device) {
197
- // Recorded before the link is shown, so every pass in a browser is on the box's list.
198
- const recorded = await registerDevice({
199
- id: answer.device,
200
- project: projectName,
201
- // The links printed at start are recorded too, used or not; say which they were.
202
- label: label ?? (has('link') ? '' : 'printed at tunnel start'),
203
- })
204
- if (!recorded) {
205
- plan.notes.push(
206
- `could not record device ${answer.device} on ${host}, so no ${kind} link: it could not be listed or revoked`
207
- )
208
- continue
209
- }
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
221
- plan.notes.push(
222
- answer.error ??
223
- `the dev server on :${localPort} did not answer, so no ${kind} link: start it (\`bun start\`), then \`tosijs-tunnel --link\``
224
- )
225
- }
226
- return { links, notes: plan.notes }
227
- }
228
- let startLinks: Awaited<ReturnType<typeof globalLinks>> | undefined
160
+ const askForLink = (query: string) => askDevServer(localPort, query)
229
161
 
230
162
  /*
231
163
  DEVICES (board #3416). Each --global edit link is a pass for one device. The box keeps the
@@ -239,55 +171,30 @@ const devicesRoot = () =>
239
171
  preview?.caddySitesDir
240
172
  )
241
173
 
242
- async function registerDevice(device: {
243
- id: string
244
- project: string
245
- label: string
246
- }): Promise<boolean> {
247
- const line = deviceRegistryLine({ ...device, issued: new Date() })
248
- if (!line) return false
249
- const root = devicesRoot()
250
- const run =
251
- await $`ssh -o BatchMode=yes -o ConnectTimeout=10 ${host!} ${`mkdir -p ${root}/_devices && printf '%s\\n' '${line}' >> ${root}/_devices/registry.tsv`}`
252
- .nothrow()
253
- .quiet()
254
- return run.exitCode === 0
255
- }
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
- }
174
+ const globalLinks = (kinds?: LinkKind[]) =>
175
+ globalLinksFor(
176
+ {
177
+ host: host!,
178
+ root: devicesRoot(),
179
+ projectName,
180
+ localPort,
181
+ label: flag('label'),
182
+ onRequest: has('link'),
183
+ local: resolveGlobalKeys(preview?.tunnel?.globalKeys, {
184
+ PREVIEW_TOKEN:
185
+ process.env.PREVIEW_TOKEN ?? readLocalSecret('PREVIEW_TOKEN'),
186
+ PREVIEW_EDIT_TOKEN:
187
+ process.env.PREVIEW_EDIT_TOKEN ??
188
+ readLocalSecret('PREVIEW_EDIT_TOKEN'),
189
+ }),
190
+ boxKeys: () => previewBoxKeys(host!),
191
+ ssh: (command) => sshTo(host!, command),
192
+ askForLink,
193
+ fetchText,
194
+ },
195
+ kinds
196
+ )
197
+ let startLinks: Awaited<ReturnType<typeof globalLinks>> | undefined
291
198
 
292
199
  /*
293
200
  Say where this is going, and where the address came from, BEFORE acting on it.
@@ -703,20 +610,8 @@ if (publicUrl) {
703
610
  ? ` ${publicUrl}`
704
611
  : ` ${publicUrl} ⚠️ NOT routed — registration failed above; this URL will not reach here`
705
612
  )
706
- if (startLinks?.links.length) {
707
- console.log(
708
- `\n Single-use links: each opens this workspace, then keeps that browser in every` +
709
- `\n workspace on the box (and its root list) for 30 days:`
710
- )
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
- }
718
- }
719
- for (const note of startLinks?.notes ?? []) console.log(` note: ${note}`)
613
+ if (startLinks)
614
+ for (const line of startLinkLines(startLinks)) console.log(line)
720
615
  }
721
616
  console.log(` Ctrl-C to close.\n`)
722
617
 
@@ -145,6 +145,7 @@ export declare class TosiCarousel extends TosiCarousel_base {
145
145
  handleDotClick: (event: Event) => void;
146
146
  private snapTimer;
147
147
  private animationFrame;
148
+ private cancelAnimation;
148
149
  animateScroll(position: number, startingPosition?: number, timestamp?: number): void;
149
150
  content: () => HTMLDivElement[];
150
151
  connectedCallback(): void;