tosijs-ui 1.16.30 → 1.16.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/bin/resolve-site-config.ts +84 -1
  3. package/bin/tunnel.ts +145 -12
  4. package/deploy/Caddyfile +10 -18
  5. package/deploy/build-index.sh +5 -2
  6. package/dist/affix.d.ts +97 -0
  7. package/dist/affix.js +445 -0
  8. package/dist/crud.d.ts +22 -0
  9. package/dist/crud.js +68 -5
  10. package/dist/data-table.js +10 -0
  11. package/dist/doc-browser.d.ts +6 -0
  12. package/dist/doc-browser.js +155 -115
  13. package/dist/doc-system/doc-search.d.ts +43 -0
  14. package/dist/doc-system/doc-search.js +194 -0
  15. package/dist/doc-system/doc-system.js +5 -0
  16. package/dist/doc-system/site/dev-auth.d.ts +75 -17
  17. package/dist/doc-system/site/dev-auth.js +159 -36
  18. package/dist/doc-system/site/dev-server.js +101 -22
  19. package/dist/doc-system/site/device-revocation.d.ts +40 -0
  20. package/dist/doc-system/site/device-revocation.js +124 -0
  21. package/dist/editable-rect.d.ts +1 -1
  22. package/dist/form.d.ts +1 -1
  23. package/dist/iife.js +114 -108
  24. package/dist/iife.js.map +17 -14
  25. package/dist/index.d.ts +2 -0
  26. package/dist/index.js +2 -0
  27. package/dist/live-example/component.d.ts +39 -15
  28. package/dist/live-example/component.js +42 -50
  29. package/dist/live-example/styles.d.ts +37 -14
  30. package/dist/live-example/styles.js +52 -37
  31. package/dist/notifications.d.ts +69 -4
  32. package/dist/notifications.js +273 -17
  33. package/dist/palette.d.ts +90 -0
  34. package/dist/palette.js +898 -0
  35. package/dist/schema-form.d.ts +2 -1
  36. package/dist/schema-form.js +23 -8
  37. package/dist/search-field.js +0 -12
  38. package/dist/segmented.d.ts +1 -1
  39. package/dist/tab-selector.d.ts +1 -1
  40. package/dist/version.d.ts +1 -1
  41. package/dist/version.js +1 -1
  42. package/llms.txt +5 -1
  43. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,158 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.32
4
+
5
+ Two new components, a doc search that ranks, and live examples whose toolbar no longer covers
6
+ what they draw. Nothing an adopter has to change: the toolbar and the search are doc-site
7
+ behaviour, and both components are new. One thing to know if you embed the doc browser in an
8
+ app: it now opens its search on Cmd/Ctrl-K. If your page owns that key, pass
9
+ `createDocBrowser({ searchShortcut: '' })` (or another key).
10
+
11
+ `dist/iife.js` grows by about 7 KB gzip (496.4 KB to 503.4 KB, +1.4%): the palette is 9.1 KB
12
+ gzip and the affix 5.0 KB. The ESM path pays only for what it imports.
13
+
14
+ ### New components
15
+
16
+ - **`<tosi-palette>`** (`tosiPalette`): a command palette. A compact search-field trigger
17
+ opens a centred modal of ranked results. Results come from `sources` (sync, or async with a
18
+ spinner and a `timeout`, 10s by default; each keystroke aborts the last search), are drawn
19
+ per `kind` by `renderers` (or a default row: icon, highlighted title, one-line description,
20
+ path), ranked by `score` and grouped by `group` (or by your own `rank`), with `quickLinks`
21
+ before anything is typed. Arrow keys, Enter and Escape drive it, `shortcut: 'mod+k'` opens it
22
+ from anywhere (a shortcut with an unknown modifier is ignored, with a console warning, rather
23
+ than binding the bare key), `multiline` makes the query a growing prompt, and choosing fires `action`.
24
+ `trigger` is `field` (the default, which shrinks to its icon below `compactWidth`, 160px),
25
+ `icon`, or `none` for a palette opened only by `open()` or its shortcut. Its colours come
26
+ from the theme's root `--tosi-text` / `--tosi-bg` / `--tosi-accent`, so a recoloured header
27
+ does not make its results invisible. The dialog sets its own line height, wrapping and font
28
+ size, so a header's do not reach it. The palette footer's Close is a button, for a phone,
29
+ which has no Escape key; on a touch screen the arrow/Enter cues and the Esc hint are hidden.
30
+ The ✕ that clears the query is hidden until something is typed.
31
+ - **`<tosi-affix>`** (`tosiAffix`): fixes a fixture (`slot="fixture"`) to an edge of its
32
+ content, in the FloatPosition vocabulary: the first letter is the edge, the second the
33
+ alignment, so `en` is the right edge, top-aligned. Outside (the default) it takes a grid cell
34
+ beside the content, so it never covers it and an ancestor's overflow cannot clip it; `inside`
35
+ puts it within the content against that edge. `fallback` (e.g. `"s inside"`) is used while
36
+ the fixture is longer than its edge. Setting `position`/`inside` sets where it is wanted,
37
+ reading them gives where it is, the `placed` attribute reflects it, and a `reposition` event
38
+ fires when it changes. It looks finished with no styling: a flex column on a side edge, a row
39
+ on top or bottom, the theme's inset background and spacing, outer corners rounded
40
+ (`--tosi-affix-bg/gap/padding/radius`, `::part(fixture)`).
41
+
42
+ ### Doc system
43
+
44
+ - **Search ranks pages, in the header.** The sidebar's nav filter (a substring test that hid
45
+ pages and ranked nothing) is replaced by a palette in the header bar: the site's search
46
+ field while there is room, its icon when the header is narrow, and Cmd/Ctrl-K on every page,
47
+ `{"header": false}` pages included. Pages are ranked by rough relevance (title, then
48
+ headings, keywords, description, opening paragraph, body), and a matching heading becomes a
49
+ deep link. A query word matches only the start of a word, camelCase split ("date" finds
50
+ `tosiDate`, not "update"), a whole word or its plural outranks a prefix, and a word used
51
+ often counts for more than one used once. A word matched in one heading no longer counts for
52
+ the next query word. The trigger looks like the search field it replaced (the site's
53
+ pill-shaped input), and the active row is a tint of the theme's accent. The ranking is
54
+ exported as `buildDocIndex` / `searchDocs` from `tosijs-ui/doc-system/doc-search`, with
55
+ section anchors taken from the renderer's own heading ids. `createDocBrowser({ searchShortcut })` sets the key
56
+ that opens the search, `''` for none.
57
+ - **A live example's toolbar sits beside the preview**, outside its outline, instead of a
58
+ pocket bar over the preview's top-right corner (which covered whatever the example drew
59
+ there, the crud example's New button for one). It moves inside along the bottom when the
60
+ column is taller than the preview, and when the example is maximized. The run-tests check
61
+ shows the test status; the owl handle is gone.
62
+ - Below 600px of header the link icons fold into the settings (⋮) menu, so an upright phone
63
+ has room for the title and the search.
64
+
65
+ ### Fixed
66
+
67
+ - `<tosi-crud>` has a Close button (and `close()`) that returns to the list. Under 720px (the
68
+ doc preview included) an open record replaces the list instead of squeezing into a 210px
69
+ column beside it, and the component fills a container with a height. A store that can only
70
+ list no longer shows New, Save and Delete where the page styles `button` with a `display`.
71
+ Without a schema, New starts from the fields the listed rows have instead of an empty form.
72
+ - Boolean columns from a schema asked for icons named "✓" and "✗", which do not exist; they
73
+ use `check` and `x`.
74
+ - `<tosi-table>` with no `columns` and data that arrived later showed no columns: it now
75
+ infers them once there is a row.
76
+ - `<tosi-schema-form>` with no schema kept the schema inferred from an empty value forever, and
77
+ threw "unobserve failed, listener not found" every time it left the page.
78
+
79
+ ### Dependencies
80
+
81
+ - The optional `happy-dom` peer is now `^21.0.4` (was `^20.7.0`), and tosijs-ui's own tests
82
+ run on 21.0.4. Every happy-dom from 17.0.0 to 21.0.3 is affected by
83
+ [GHSA-834v-8h66-3h79](https://github.com/advisories/GHSA-834v-8h66-3h79) (an unsanitized
84
+ import alias leads to host code execution), and there is no fixed 20.x. It matters only if
85
+ you use `tosijs-ui/test-setup` or build ePubs; then upgrade to 21.0.4. Its one breaking
86
+ change is that CSS and iframe loading are off by default, which neither relies on.
87
+
88
+ ### Tunnel
89
+
90
+ - A workspace no longer makes a request wait on the box for the revoked-device list. A
91
+ copy up to ten minutes old is used at once and refreshed behind, and when the list becomes
92
+ unreadable the dev server says so, and why. `tosijs-tunnel` refuses flag combinations it
93
+ used to ignore silently: `--share-ttl` with `--global` or without `--link --share`, and
94
+ `--label` anywhere but on a `--link --global` edit link.
95
+
96
+ ## 1.16.31
97
+
98
+ **Tunnel users: each browser needs one new `tosijs-tunnel --link --global`.** The edit cookie
99
+ from earlier versions held the box's edit key itself, and it no longer opens a workspace (see
100
+ below). Reinstall the box's Caddyfile (`tosijs-caddy-install --go`) to drop `?t=<edit key>`.
101
+
102
+ **If you used `--global` edit links or `?t=<edit key>` on 1.16.26–1.16.30, rotate the edit
103
+ key.** Those versions put its raw value in browser cookies (for 30 days) and URLs (so browser
104
+ history and anywhere one was pasted), and in 1.16.31 that same value signs every device pass:
105
+ anyone holding a copy could sign passes of their own that `--revoke` cannot see. Set a new
106
+ `PREVIEW_EDIT_TOKEN` in `/etc/caddy/preview.env` on the box and in
107
+ `~/local-secrets/tosijs-preview.env` on each machine, re-run `tosijs-caddy-install --go`, and
108
+ restart the dev servers. Upgrade every project that tunnels through the same box first: a new
109
+ edit link expires the old `tosi_edit` cookie, so a workspace still on tosijs-ui 1.16.30 or
110
+ earlier then opens read-only in that browser.
111
+
112
+ ### Tunnel
113
+
114
+ - **Each device gets its own edit pass, and you can revoke one.** A `--link --global` edit link
115
+ used to give the browser the box's edit key as its cookie, so every device held the same
116
+ credential, and the only way to cut one off was to rotate the key everywhere. Now the link
117
+ gives that browser a pass for that one device, signed with the edit key, which no longer
118
+ reaches a browser. `tosijs-tunnel --devices` lists every pass issued, `--revoke=<id>` cuts one device
119
+ off, and `--label=<name>` names the device a link is for. Revoking ends
120
+ edit access; the device keeps the shared view key, so it can still browse until you rotate
121
+ that. Every workspace checks the box's
122
+ revoked list before it honours a pass, at most a minute old, and ends a revoked device's open
123
+ sessions within that minute. A workspace that cannot read the list honours no pass at all.
124
+ An edit link also sets the view key, which opens the box's root list and the static previews.
125
+ - **A link that hands over a key lives five minutes.** The view `--global` link lasted a day;
126
+ now both kinds expire after five minutes, single use. The cookie it sets is what lasts (30
127
+ days), not the code that delivers it. `tunnel.linkTtlMinutes` can shorten the window, never
128
+ lengthen it, and `--share-ttl` no longer applies to these links; to give someone view access
129
+ later, send a plain `--link --share`.
130
+ - **The edit key has no URL form.** The box no longer accepts `?t=<edit key>`, which made the
131
+ key a permanent bearer token in browser history and anywhere the URL was pasted. The view key
132
+ still works that way for the static previews.
133
+ - **The box's workspace list opens each link in a new tab**, so the list stays open as the
134
+ place you pick the next one.
135
+
136
+ ### Notifications
137
+
138
+ - **A long message can no longer push the close button out of the notification.** The message
139
+ column cannot grow past the note, an unbroken string wraps anywhere, and a message is cut off
140
+ with an ellipsis after four lines (`--notification-max-lines`); the full text is the tooltip,
141
+ and screen readers get all of it.
142
+ - **Many notifications stack instead of scrolling.** The newest `TosiNotification.maxVisible`
143
+ (3) stay in view, newest on top; the lowest of them fronts a stack, with older notifications
144
+ behind it, each one's lower edge showing by `TosiNotification.stackOffsets` (`[16, 8, 4]` px),
145
+ and the rest hidden until the ones in front close. Stacked notifications show their lower
146
+ edges straight below the front card, with no sideways offset.
147
+ - **Dismiss all.** A button whenever more than one notification is open, which sits centred
148
+ over those edges at the bottom, outlined in the colour of the most severe one open (hidden ones included), and
149
+ `TosiNotification.dismissAll()`. Both fire each notification's `close` callback, as if the
150
+ user had closed it.
151
+ - **Smoother movement.** A closing notification fades where it is, then the rest move once,
152
+ straight to their new places; restacking used to slide cards in from wherever their last
153
+ offset left them. (A pre-release bug is fixed too: notes just pushed into the stack were
154
+ measured while still in the flow, so the stack sat one card too high.)
155
+
3
156
  ## 1.16.30
4
157
 
5
158
  ### Tunnel
@@ -158,7 +158,61 @@ export {
158
158
  server reads the global tunnel keys from the same file; it is re-exported here for the bins.
159
159
  */
160
160
  export { readLocalSecret } from '../dist/doc-system/site/local-secrets.js'
161
- export { resolveGlobalKeys } from '../dist/doc-system/site/dev-auth.js'
161
+ export {
162
+ resolveGlobalKeys,
163
+ DEVICE_ID_PATTERN,
164
+ } from '../dist/doc-system/site/dev-auth.js'
165
+ import {
166
+ DEVICE_ID_PATTERN,
167
+ cleanDeviceLabel,
168
+ } from '../dist/doc-system/site/dev-auth.js'
169
+
170
+ /*
171
+ The box's device registry (board #3416): one tab-separated line per edit pass issued, written
172
+ by `tosijs-tunnel` over ssh as `printf '%s\n' '<line>'`. So a line may hold no quote, tab or
173
+ newline of its own: every field is checked or cleaned here, and a line that cannot be made safe
174
+ is not written.
175
+ */
176
+ export const DEVICE_LISTING_SEPARATOR = '--revoked--'
177
+
178
+ export function deviceRegistryLine(device: {
179
+ id: string
180
+ issued: Date
181
+ project: string
182
+ label: string
183
+ }): string | null {
184
+ if (!DEVICE_ID_PATTERN.test(device.id)) return null
185
+ const project = device.project.replace(/[^\w.-]/g, '').slice(0, 60)
186
+ return [
187
+ device.id,
188
+ device.issued.toISOString(),
189
+ project,
190
+ cleanDeviceLabel(device.label),
191
+ ].join('\t')
192
+ }
193
+
194
+ export interface DeviceRecord {
195
+ id: string
196
+ issued: string
197
+ project: string
198
+ label: string
199
+ revoked: boolean
200
+ }
201
+
202
+ /** `cat registry.tsv; echo --revoked--; cat revoked.txt` → one record per device, newest first. */
203
+ export function parseDeviceListing(text: string): DeviceRecord[] {
204
+ const [registry = '', revokedText = ''] = text.split(
205
+ `${DEVICE_LISTING_SEPARATOR}\n`
206
+ )
207
+ const revoked = new Set(revokedText.split('\n').map((line) => line.trim()))
208
+ const devices: DeviceRecord[] = []
209
+ for (const line of registry.split('\n')) {
210
+ const [id = '', issued = '', project = '', label = ''] = line.split('\t')
211
+ if (!DEVICE_ID_PATTERN.test(id)) continue
212
+ devices.push({ id, issued, project, label, revoked: revoked.has(id) })
213
+ }
214
+ return devices.reverse()
215
+ }
162
216
  import type { GlobalKeys } from '../dist/doc-system/site/dev-auth.js'
163
217
  import { readLocalSecret } from '../dist/doc-system/site/local-secrets.js'
164
218
 
@@ -368,6 +422,35 @@ export interface Argv {
368
422
  rest: string[]
369
423
  }
370
424
 
425
+ /**
426
+ * `tosijs-tunnel` flag combinations that would otherwise be silently ignored, each with what to
427
+ * do instead (1.16.31 review). A flag that changes nothing is a promise the bin does not keep.
428
+ */
429
+ export function tunnelFlagProblems(args: {
430
+ has: (name: string) => boolean
431
+ flag: (name: string) => string | undefined
432
+ }): string[] {
433
+ const problems: string[] = []
434
+ const link = args.has('link')
435
+ const share = args.has('share')
436
+ const global = args.has('global')
437
+ if (args.flag('share-ttl') !== undefined) {
438
+ if (global)
439
+ problems.push(
440
+ '--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.'
441
+ )
442
+ else if (!(link && share))
443
+ problems.push('--share-ttl only applies to --link --share.')
444
+ }
445
+ if (args.flag('label') !== undefined) {
446
+ if (!(link && global) || share)
447
+ problems.push(
448
+ '--label names the device a --link --global EDIT link is for; it does nothing elsewhere.'
449
+ )
450
+ }
451
+ return problems
452
+ }
453
+
371
454
  /**
372
455
  * Parse `argv` against `spec`. Prints usage and exits on `--help` (0) or on an
373
456
  * unrecognised flag (1); returns a reader otherwise.
package/bin/tunnel.ts CHANGED
@@ -44,26 +44,53 @@ import {
44
44
  resolveSiteConfig,
45
45
  resolveTunnelLocalPort,
46
46
  parseArgv,
47
+ deviceRegistryLine,
48
+ parseDeviceListing,
49
+ DEVICE_LISTING_SEPARATOR,
50
+ DEVICE_ID_PATTERN,
51
+ tunnelFlagProblems,
47
52
  } from './resolve-site-config.ts'
48
53
 
49
54
  const { has, flag } = parseArgv(process.argv.slice(2), {
50
55
  bin: 'tosijs-tunnel',
51
56
  summary: 'expose the local dev server through the preview host',
52
- flags: ['close', 'link', 'status', 'share', 'global'],
53
- values: ['host', 'port', 'local-port', 'remote-port', 'url', 'share-ttl'],
57
+ flags: ['close', 'link', 'status', 'share', 'global', 'devices'],
58
+ values: [
59
+ 'host',
60
+ 'port',
61
+ 'local-port',
62
+ 'remote-port',
63
+ 'url',
64
+ 'share-ttl',
65
+ 'label',
66
+ 'revoke',
67
+ ],
54
68
  usage:
55
69
  ` tosijs-tunnel open the tunnel (foreground)\n` +
56
70
  ` --status report whether a tunnel is up\n` +
57
71
  ` --close close a running tunnel\n` +
58
72
  ` --link print a magic edit link\n` +
59
73
  ` --link --share print a READ-ONLY link to send to a person\n` +
60
- ` --link --global [--share] a SINGLE-USE link that also gives that browser the\n` +
61
- ` box's key for 30 days (every workspace, the root list)\n` +
62
- ` --share-ttl=<minutes> how long that share link lives (default 1440)\n` +
74
+ ` --link --global [--share] a SINGLE-USE 5-minute link that also keeps that\n` +
75
+ ` browser in every workspace and the root list for\n` +
76
+ ` 30 days (edit: a revocable device pass)\n` +
77
+ ` --label=<name> name the device a --global edit link is for\n` +
78
+ ` --devices list the devices given an edit pass, and which are revoked\n` +
79
+ ` --revoke=<device id> revoke one device's edit pass, on every workspace\n` +
80
+ ` --share-ttl=<minutes> how long a plain share link lives (default 1440)\n` +
63
81
  ` --host=user@box override the configured preview host\n` +
64
82
  ` --port= --local-port= --remote-port= override the resolved ports`,
65
83
  })
66
84
 
85
+ {
86
+ const problems = tunnelFlagProblems({ has, flag })
87
+ if (problems.length) {
88
+ for (const problem of problems) console.error(`\n${problem}`)
89
+ console.error('')
90
+ process.exit(1)
91
+ }
92
+ }
93
+
67
94
  const siteConfig = await resolveSiteConfig()
68
95
 
69
96
  const preview = siteConfig.preview
@@ -122,7 +149,7 @@ let routed = true
122
149
  /** Ask the dev server (loopback only) for a link; `query` selects the kind. */
123
150
  async function askForLink(
124
151
  query: string
125
- ): Promise<{ url?: string; error?: string }> {
152
+ ): Promise<{ url?: string; device?: string; error?: string }> {
126
153
  const res =
127
154
  await $`curl -sk --max-time 5 https://localhost:${localPort}/__devlink${query}`
128
155
  .nothrow()
@@ -142,8 +169,7 @@ carries a one-use code, never the key. Offered only for keys this workspace acce
142
169
  holds too; `notes` says why any other is missing.
143
170
  */
144
171
  async function globalLinks(
145
- kinds: ('edit' | 'view')[] = ['edit', 'view'],
146
- extraQuery = ''
172
+ kinds: ('edit' | 'view')[] = ['edit', 'view']
147
173
  ): Promise<{
148
174
  links: { kind: 'edit' | 'view'; url: string }[]
149
175
  notes: string[]
@@ -159,9 +185,27 @@ async function globalLinks(
159
185
  const plan = planGlobalLinks(local, box)
160
186
  const links: { kind: 'edit' | 'view'; url: string }[] = []
161
187
  for (const kind of plan.kinds.filter((k) => kinds.includes(k))) {
188
+ const label = flag('label')
162
189
  const answer = await askForLink(
163
- `?global=${kind}&domain=${encodeURIComponent(plan.domain!)}${extraQuery}`
190
+ `?global=${kind}&domain=${encodeURIComponent(plan.domain!)}${
191
+ label ? `&label=${encodeURIComponent(label)}` : ''
192
+ }`
164
193
  )
194
+ if (answer.url && answer.device) {
195
+ // Recorded before the link is shown, so every pass in a browser is on the box's list.
196
+ const recorded = await registerDevice({
197
+ id: answer.device,
198
+ project: projectName,
199
+ // The links printed at start are recorded too, used or not; say which they were.
200
+ label: label ?? (has('link') ? '' : 'printed at tunnel start'),
201
+ })
202
+ if (!recorded) {
203
+ plan.notes.push(
204
+ `could not record device ${answer.device} on ${host}, so no ${kind} link: it could not be listed or revoked`
205
+ )
206
+ continue
207
+ }
208
+ }
165
209
  if (answer.url) links.push({ kind, url: answer.url })
166
210
  else
167
211
  plan.notes.push(
@@ -173,6 +217,33 @@ async function globalLinks(
173
217
  }
174
218
  let startLinks: Awaited<ReturnType<typeof globalLinks>> | undefined
175
219
 
220
+ /*
221
+ DEVICES (board #3416). Each --global edit link is a pass for one device. The box keeps the
222
+ record: `_devices/registry.tsv` (not served) lists every pass issued, and `_index/revoked.txt`
223
+ (served beside the root list, behind its cookie gate) is what every workspace reads before it
224
+ honours a pass. Both live under the preview root, written over the same ssh as the rest.
225
+ */
226
+ const devicesRoot = () =>
227
+ previewRootFor(
228
+ preview?.path ?? '/srv/preview/placeholder',
229
+ preview?.caddySitesDir
230
+ )
231
+
232
+ async function registerDevice(device: {
233
+ id: string
234
+ project: string
235
+ label: string
236
+ }): Promise<boolean> {
237
+ const line = deviceRegistryLine({ ...device, issued: new Date() })
238
+ if (!line) return false
239
+ const root = devicesRoot()
240
+ const run =
241
+ await $`ssh -o BatchMode=yes -o ConnectTimeout=10 ${host!} ${`mkdir -p ${root}/_devices && printf '%s\\n' '${line}' >> ${root}/_devices/registry.tsv`}`
242
+ .nothrow()
243
+ .quiet()
244
+ return run.exitCode === 0
245
+ }
246
+
176
247
  /*
177
248
  Say where this is going, and where the address came from, BEFORE acting on it.
178
249
 
@@ -218,6 +289,67 @@ async function running(): Promise<number[]> {
218
289
  return out.trim().split('\n').filter(Boolean).map(Number)
219
290
  }
220
291
 
292
+ if (has('devices')) {
293
+ const root = devicesRoot()
294
+ const read =
295
+ await $`ssh -o BatchMode=yes -o ConnectTimeout=10 ${host!} ${`cat ${root}/_devices/registry.tsv 2>/dev/null; echo ${DEVICE_LISTING_SEPARATOR}; cat ${root}/_index/revoked.txt 2>/dev/null; true`}`
296
+ .nothrow()
297
+ .quiet()
298
+ if (read.exitCode !== 0) {
299
+ console.error(
300
+ `\nCould not read the device list on ${host}: ${read.stderr
301
+ .toString()
302
+ .trim()}\n`
303
+ )
304
+ process.exit(1)
305
+ }
306
+ const devices = parseDeviceListing(read.stdout.toString())
307
+ if (!devices.length) {
308
+ console.log(
309
+ '\nNo edit passes issued yet (`tosijs-tunnel --link --global`).\n'
310
+ )
311
+ } else {
312
+ console.log('')
313
+ for (const d of devices)
314
+ console.log(
315
+ ` ${d.id} ${d.revoked ? 'REVOKED' : 'active '} ${d.issued} ${
316
+ d.project
317
+ }${d.label ? ` "${d.label}"` : ''}`
318
+ )
319
+ console.log(
320
+ `\n Revoke one with \`tosijs-tunnel --revoke=<id>\`. A pass also expires 30 days after it was issued.\n`
321
+ )
322
+ }
323
+ process.exit(0)
324
+ }
325
+
326
+ if (flag('revoke') !== undefined) {
327
+ const id = flag('revoke')!
328
+ if (!DEVICE_ID_PATTERN.test(id)) {
329
+ console.error(
330
+ `\n${JSON.stringify(
331
+ id
332
+ )} is not a device id (ten letters and digits; see --devices).\n`
333
+ )
334
+ process.exit(1)
335
+ }
336
+ const root = devicesRoot()
337
+ const run =
338
+ await $`ssh -o BatchMode=yes -o ConnectTimeout=10 ${host!} ${`mkdir -p ${root}/_index && printf '%s\\n' ${id} >> ${root}/_index/revoked.txt && chmod 644 ${root}/_index/revoked.txt`}`
339
+ .nothrow()
340
+ .quiet()
341
+ if (run.exitCode !== 0) {
342
+ console.error(
343
+ `\nCould not revoke ${id} on ${host}: ${run.stderr.toString().trim()}\n`
344
+ )
345
+ process.exit(1)
346
+ }
347
+ console.log(
348
+ `\n🔒 ${id} revoked. Every workspace stops honouring its edit pass, and ends its edit sessions, within a minute. It keeps the shared view key until you rotate that.\n`
349
+ )
350
+ process.exit(0)
351
+ }
352
+
221
353
  if (has('link')) {
222
354
  /*
223
355
  Ask the dev server over loopback. The previous version ran
@@ -238,15 +370,16 @@ if (has('link')) {
238
370
  const shareTtl = flag('share-ttl')
239
371
  const ttl = shareTtl ? `&ttl=${encodeURIComponent(shareTtl)}` : ''
240
372
  /*
241
- `--global` asks for a link that also hands over the box's key (see globalLinks): single-use,
242
- and the browser keeps every workspace for 30 days. Opt-in, because it is a different promise
373
+ `--global` asks for a link that also hands over a global credential (see globalLinks): single-use,
374
+ valid five minutes whatever `--share-ttl` says, and the browser keeps every workspace for 30
375
+ days. Opt-in, because it is a different promise
243
376
  from the two links above, which are reusable for their window and open this workspace only.
244
377
  A share link spent by a chat app's link preview, or one that opens every workspace, would
245
378
  break the promise `--share` makes to the person you send it to.
246
379
  */
247
380
  let url: string
248
381
  if (has('global')) {
249
- const global = await globalLinks([share ? 'view' : 'edit'], ttl)
382
+ const global = await globalLinks([share ? 'view' : 'edit'])
250
383
  for (const note of global.notes) console.log(`note: ${note}`)
251
384
  url = global.links[0]?.url ?? ''
252
385
  if (!url) {
package/deploy/Caddyfile CHANGED
@@ -75,25 +75,17 @@
75
75
  redir * {tosi_link_target} 302
76
76
  }
77
77
 
78
- # THE GLOBAL EDIT KEY: `?t=<edit key>` on any preview host sets `tosi_edit` across the
79
- # whole preview domain for 30 days, the same cookie a workspace's single-use link from
80
- # `tosijs-tunnel` sets (that link is the usual way in: it never puts the key in a URL).
81
- # Every tunnelled workspace that holds the same key
82
- # (PREVIEW_EDIT_TOKEN in ~/local-secrets/tosijs-preview.env on the machine running it)
83
- # trades it for an editing session on the first page load, so the root's list opens them
84
- # all, across dev-server restarts. It also opens these static previews. The view key above
85
- # opens workspaces read-only the same way. See `globalKeys` in doc-site-system.md.
78
+ # THE GLOBAL EDIT KEY has NO URL form, and no browser holds it. A browser that opens a
79
+ # workspace's single-use link (`tosijs-tunnel --link --global`, a one-use code valid five
80
+ # minutes) gets a signed pass for that one device, plus the view key below; the cookie lasts
81
+ # 30 days, the code that delivers it does not (the maintainer's rule, 2026-10-20). Through
82
+ # 1.16.30 `?t=<edit key>` here set the raw key as a cookie, a permanent bearer token for
83
+ # writing source on every workspace that no one device could lose on its own.
86
84
  #
87
- # The cookie is the key itself: Caddy can compare a fixed value and cannot sign one. A
88
- # leaked edit cookie is write access to every workspace that accepts the key until it is
89
- # rotated (edit preview.env here and on each machine, re-run the installer).
90
- @editlink query t=__PREVIEW_EDIT_TOKEN__
91
- handle @editlink {
92
- header Set-Cookie "tosi_edit=__PREVIEW_EDIT_TOKEN__; Domain=.{{PREVIEW_DOMAIN}}; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax"
93
- header Referrer-Policy "no-referrer"
94
- header Cache-Control "no-store"
95
- redir * {tosi_link_target} 302
96
- }
85
+ # The gate below still accepts the raw edit key: the dev servers present it to read
86
+ # `revoked.txt` (the root's revoked-device list, which `tosijs-tunnel --revoke` appends to).
87
+ # Rotating it (edit preview.env here and on each machine, re-run the installer) invalidates
88
+ # every device pass at once.
97
89
 
98
90
  # Anchored at both ends: unanchored, `tosi_edit=<key>x` matched too. That needed the key
99
91
  # anyway, but the dev servers compare exactly, and the gate should agree with them.
@@ -27,6 +27,9 @@ html() { printf '%s' "$1" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;
27
27
  OUT="$ROOT/_index"
28
28
  mkdir -p "$OUT"
29
29
 
30
+ # Every link opens in its own tab, so this page stays open as the place you pick the next one.
31
+ # target=_blank implies noopener, so a workspace gets no handle on this page.
32
+
30
33
  # Collect deployed projects. A directory only counts if it has a version.json — a
31
34
  # half-finished rsync or a stray folder should not appear as a live preview.
32
35
  rows=""
@@ -64,7 +67,7 @@ for dir in "$ROOT"/*/; do
64
67
  count=$((count + 1))
65
68
  rows="$rows
66
69
  <li>
67
- <a href=\"//$host\">
70
+ <a href=\"//$host\" target=\"_blank\">
68
71
  <span class=\"name\">$site</span>
69
72
  <span class=\"meta\">
70
73
  <code>${commit:-unknown}</code>
@@ -87,7 +90,7 @@ for frag in "$ROOT"/_sites/*-tunnel.caddy; do
87
90
  [ -n "$host" ] || continue
88
91
  live="$live
89
92
  <li data-workspace=\"$host\">
90
- <a href=\"https://$host/\">
93
+ <a href=\"https://$host/\" target=\"_blank\">
91
94
  <span class=\"name\">$name</span>
92
95
  <span class=\"meta\"><span class=\"access\">checking…</span><code>$host</code></span>
93
96
  </a>
@@ -0,0 +1,97 @@
1
+ import { Component, ElementCreator, PartsMap } from 'tosijs';
2
+ export type AffixPosition = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' | 'en' | 'wn' | 'es' | 'ws';
3
+ export interface AffixPlacement {
4
+ position: AffixPosition;
5
+ inside: boolean;
6
+ }
7
+ /** `position` as written, or the default when it is not one. */
8
+ export declare function affixPosition(value: unknown): AffixPosition;
9
+ /** `"s inside"` → `{position: 's', inside: true}`; '' → null (no fallback). */
10
+ export declare function parsePlacement(value: unknown): AffixPlacement | null;
11
+ /** `{position: 's', inside: true}` → `"s inside"`. */
12
+ export declare const describePlacement: ({ position, inside }: AffixPlacement) => string;
13
+ type Track = 1 | 2 | 3;
14
+ type Align = 'start' | 'center' | 'end';
15
+ /**
16
+ * The grid cell and alignment for a placement. Outside, the fixture takes the cell on its
17
+ * edge, hugging the content; inside, it shares the content's cell, against that edge.
18
+ */
19
+ export declare function affixCell({ position, inside }: AffixPlacement): {
20
+ row: Track;
21
+ column: Track;
22
+ alignSelf: Align;
23
+ justifySelf: Align;
24
+ direction: string;
25
+ edge: "n" | "s" | "e" | "w";
26
+ };
27
+ interface AffixParts extends PartsMap {
28
+ content: HTMLSlotElement;
29
+ fixture: HTMLSlotElement;
30
+ }
31
+ export declare class TosiAffix extends Component<AffixParts> {
32
+ static initAttributes: Record<string, unknown>;
33
+ /** Where the fixture goes when the wanted place does not fit: `"s inside"`, or '' for nowhere. */
34
+ fallback: string;
35
+ static preferredTagName: string;
36
+ static shadowStyleSpec: {
37
+ ':host': {
38
+ display: string;
39
+ gridTemplateColumns: string;
40
+ gridTemplateRows: string;
41
+ '--tosi-affix-direction': string;
42
+ };
43
+ ':host([hidden])': {
44
+ display: string;
45
+ };
46
+ ':host > [part="content"]': {
47
+ display: string;
48
+ gridArea: string;
49
+ minWidth: string;
50
+ minHeight: string;
51
+ position: string;
52
+ };
53
+ ':host > [part="fixture"]': {
54
+ display: string;
55
+ flexDirection: string;
56
+ alignItems: string;
57
+ zIndex: string;
58
+ gap: string;
59
+ padding: string;
60
+ background: string;
61
+ color: string;
62
+ };
63
+ ':host > [part="fixture"][hidden]': {
64
+ display: string;
65
+ };
66
+ };
67
+ content: () => HTMLSlotElement[];
68
+ private _wanted;
69
+ private _placed;
70
+ private _wantedLength;
71
+ private _observer;
72
+ /** Where the fixture is (set: where it should be). */
73
+ get position(): AffixPosition;
74
+ set position(value: string);
75
+ /** Whether the fixture is inside the content (set: whether it should be). */
76
+ get inside(): boolean;
77
+ set inside(value: boolean | string);
78
+ /** Where the fixture should be, whatever fits. */
79
+ get wanted(): AffixPlacement;
80
+ /** An empty fixture slot is hidden, so it takes no room and draws no background. */
81
+ private syncEmpty;
82
+ handleSlotChange: () => void;
83
+ connectedCallback(): void;
84
+ private _observed;
85
+ observeContent: () => void;
86
+ disconnectedCallback(): void;
87
+ /** The fixture's and the content's lengths along `edge`, as laid out now. */
88
+ private lengths;
89
+ /** Choose where the fixture goes, lay it out there, and announce a change. */
90
+ place: () => void;
91
+ /** Lay the fixture out at `placement`, and announce it if that is a change. */
92
+ private layout;
93
+ private apply;
94
+ render(): void;
95
+ }
96
+ export declare const tosiAffix: ElementCreator<TosiAffix>;
97
+ export {};