tosijs-ui 1.16.29 → 1.16.31

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,83 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.31
4
+
5
+ **Tunnel users: each browser needs one new `tosijs-tunnel --link --global`.** The edit cookie
6
+ from earlier versions held the box's edit key itself, and it no longer opens a workspace (see
7
+ below). Reinstall the box's Caddyfile (`tosijs-caddy-install --go`) to drop `?t=<edit key>`.
8
+
9
+ **If you used `--global` edit links or `?t=<edit key>` on 1.16.26–1.16.30, rotate the edit
10
+ key.** Those versions put its raw value in browser cookies (for 30 days) and URLs (so browser
11
+ history and anywhere one was pasted), and in 1.16.31 that same value signs every device pass:
12
+ anyone holding a copy could sign passes of their own that `--revoke` cannot see. Set a new
13
+ `PREVIEW_EDIT_TOKEN` in `/etc/caddy/preview.env` on the box and in
14
+ `~/local-secrets/tosijs-preview.env` on each machine, re-run `tosijs-caddy-install --go`, and
15
+ restart the dev servers. Upgrade every project that tunnels through the same box first: a new
16
+ edit link expires the old `tosi_edit` cookie, so a workspace still on tosijs-ui 1.16.30 or
17
+ earlier then opens read-only in that browser.
18
+
19
+ ### Tunnel
20
+
21
+ - **Each device gets its own edit pass, and you can revoke one.** A `--link --global` edit link
22
+ used to give the browser the box's edit key as its cookie, so every device held the same
23
+ credential, and the only way to cut one off was to rotate the key everywhere. Now the link
24
+ gives that browser a pass for that one device, signed with the edit key, which no longer
25
+ reaches a browser. `tosijs-tunnel --devices` lists every pass issued, `--revoke=<id>` cuts one device
26
+ off, and `--label=<name>` names the device a link is for. Revoking ends
27
+ edit access; the device keeps the shared view key, so it can still browse until you rotate
28
+ that. Every workspace checks the box's
29
+ revoked list before it honours a pass, at most a minute old, and ends a revoked device's open
30
+ sessions within that minute. A workspace that cannot read the list honours no pass at all.
31
+ An edit link also sets the view key, which opens the box's root list and the static previews.
32
+ - **A link that hands over a key lives five minutes.** The view `--global` link lasted a day;
33
+ now both kinds expire after five minutes, single use. The cookie it sets is what lasts (30
34
+ days), not the code that delivers it. `tunnel.linkTtlMinutes` can shorten the window, never
35
+ lengthen it, and `--share-ttl` no longer applies to these links; to give someone view access
36
+ later, send a plain `--link --share`.
37
+ - **The edit key has no URL form.** The box no longer accepts `?t=<edit key>`, which made the
38
+ key a permanent bearer token in browser history and anywhere the URL was pasted. The view key
39
+ still works that way for the static previews.
40
+ - **The box's workspace list opens each link in a new tab**, so the list stays open as the
41
+ place you pick the next one.
42
+
43
+ ### Notifications
44
+
45
+ - **A long message can no longer push the close button out of the notification.** The message
46
+ column cannot grow past the note, an unbroken string wraps anywhere, and a message is cut off
47
+ with an ellipsis after four lines (`--notification-max-lines`); the full text is the tooltip,
48
+ and screen readers get all of it.
49
+ - **Many notifications stack instead of scrolling.** The newest `TosiNotification.maxVisible`
50
+ (3) stay in view, newest on top; the lowest of them fronts a stack, with older notifications
51
+ behind it, each one's lower edge showing by `TosiNotification.stackOffsets` (`[16, 8, 4]` px),
52
+ and the rest hidden until the ones in front close. Stacked notifications show their lower
53
+ edges straight below the front card, with no sideways offset.
54
+ - **Dismiss all.** A button whenever more than one notification is open, which sits centred
55
+ over those edges at the bottom, outlined in the colour of the most severe one open (hidden ones included), and
56
+ `TosiNotification.dismissAll()`. Both fire each notification's `close` callback, as if the
57
+ user had closed it.
58
+ - **Smoother movement.** A closing notification fades where it is, then the rest move once,
59
+ straight to their new places; restacking used to slide cards in from wherever their last
60
+ offset left them. (A pre-release bug is fixed too: notes just pushed into the stack were
61
+ measured while still in the flow, so the stack sat one card too high.)
62
+
63
+ ## 1.16.30
64
+
65
+ ### Tunnel
66
+
67
+ - **A dev-server restart no longer costs a key holder their next save.** `bun start` restarts
68
+ the server whenever a file in its import graph changes, and that includes every component
69
+ source, so editing an example restarts it. Save to source writes such a file, so the save
70
+ itself restarted the server, and the NEXT save from the same page was told "This workspace
71
+ needs an invite link". Reloading recovered the session but lost the unsaved edit. Now a
72
+ request from the workspace's own page that holds the global key cookie is given a fresh
73
+ session in place, and the haltija agent bridge's reconnection is too. "Own page" means
74
+ `Sec-Fetch-Site: same-origin` or an `Origin` that is this host; a sibling workspace's page,
75
+ which receives the same cookie, gets nothing, nor does a request with neither header. A
76
+ session opened from a plain `--link` holds no key, so a restart still ends it.
77
+ - **For a key holder, the key cookie now bounds access, not the session.** A key opens a
78
+ one-day session, but the cookie renews it, so access lasts as long as the cookie (30 days for
79
+ the edit key) or until you rotate the key and restart the dev servers.
80
+
3
81
  ## 1.16.29
4
82
 
5
83
  ### Doc system
@@ -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
 
package/bin/tunnel.ts CHANGED
@@ -44,22 +44,39 @@ import {
44
44
  resolveSiteConfig,
45
45
  resolveTunnelLocalPort,
46
46
  parseArgv,
47
+ deviceRegistryLine,
48
+ parseDeviceListing,
49
+ DEVICE_LISTING_SEPARATOR,
50
+ DEVICE_ID_PATTERN,
47
51
  } from './resolve-site-config.ts'
48
52
 
49
53
  const { has, flag } = parseArgv(process.argv.slice(2), {
50
54
  bin: 'tosijs-tunnel',
51
55
  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'],
56
+ flags: ['close', 'link', 'status', 'share', 'global', 'devices'],
57
+ values: [
58
+ 'host',
59
+ 'port',
60
+ 'local-port',
61
+ 'remote-port',
62
+ 'url',
63
+ 'share-ttl',
64
+ 'label',
65
+ 'revoke',
66
+ ],
54
67
  usage:
55
68
  ` tosijs-tunnel open the tunnel (foreground)\n` +
56
69
  ` --status report whether a tunnel is up\n` +
57
70
  ` --close close a running tunnel\n` +
58
71
  ` --link print a magic edit link\n` +
59
72
  ` --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` +
73
+ ` --link --global [--share] a SINGLE-USE 5-minute link that also keeps that\n` +
74
+ ` browser in every workspace and the root list for\n` +
75
+ ` 30 days (edit: a revocable device pass)\n` +
76
+ ` --label=<name> name the device a --global edit link is for\n` +
77
+ ` --devices list the devices given an edit pass, and which are revoked\n` +
78
+ ` --revoke=<device id> revoke one device's edit pass, on every workspace\n` +
79
+ ` --share-ttl=<minutes> how long a plain share link lives (default 1440)\n` +
63
80
  ` --host=user@box override the configured preview host\n` +
64
81
  ` --port= --local-port= --remote-port= override the resolved ports`,
65
82
  })
@@ -122,7 +139,7 @@ let routed = true
122
139
  /** Ask the dev server (loopback only) for a link; `query` selects the kind. */
123
140
  async function askForLink(
124
141
  query: string
125
- ): Promise<{ url?: string; error?: string }> {
142
+ ): Promise<{ url?: string; device?: string; error?: string }> {
126
143
  const res =
127
144
  await $`curl -sk --max-time 5 https://localhost:${localPort}/__devlink${query}`
128
145
  .nothrow()
@@ -142,8 +159,7 @@ carries a one-use code, never the key. Offered only for keys this workspace acce
142
159
  holds too; `notes` says why any other is missing.
143
160
  */
144
161
  async function globalLinks(
145
- kinds: ('edit' | 'view')[] = ['edit', 'view'],
146
- extraQuery = ''
162
+ kinds: ('edit' | 'view')[] = ['edit', 'view']
147
163
  ): Promise<{
148
164
  links: { kind: 'edit' | 'view'; url: string }[]
149
165
  notes: string[]
@@ -159,9 +175,27 @@ async function globalLinks(
159
175
  const plan = planGlobalLinks(local, box)
160
176
  const links: { kind: 'edit' | 'view'; url: string }[] = []
161
177
  for (const kind of plan.kinds.filter((k) => kinds.includes(k))) {
178
+ const label = flag('label')
162
179
  const answer = await askForLink(
163
- `?global=${kind}&domain=${encodeURIComponent(plan.domain!)}${extraQuery}`
180
+ `?global=${kind}&domain=${encodeURIComponent(plan.domain!)}${
181
+ label ? `&label=${encodeURIComponent(label)}` : ''
182
+ }`
164
183
  )
184
+ if (answer.url && answer.device) {
185
+ // Recorded before the link is shown, so every pass in a browser is on the box's list.
186
+ const recorded = await registerDevice({
187
+ id: answer.device,
188
+ project: projectName,
189
+ // The links printed at start are recorded too, used or not; say which they were.
190
+ label: label ?? (has('link') ? '' : 'printed at tunnel start'),
191
+ })
192
+ if (!recorded) {
193
+ plan.notes.push(
194
+ `could not record device ${answer.device} on ${host}, so no ${kind} link: it could not be listed or revoked`
195
+ )
196
+ continue
197
+ }
198
+ }
165
199
  if (answer.url) links.push({ kind, url: answer.url })
166
200
  else
167
201
  plan.notes.push(
@@ -173,6 +207,33 @@ async function globalLinks(
173
207
  }
174
208
  let startLinks: Awaited<ReturnType<typeof globalLinks>> | undefined
175
209
 
210
+ /*
211
+ DEVICES (board #3416). Each --global edit link is a pass for one device. The box keeps the
212
+ record: `_devices/registry.tsv` (not served) lists every pass issued, and `_index/revoked.txt`
213
+ (served beside the root list, behind its cookie gate) is what every workspace reads before it
214
+ honours a pass. Both live under the preview root, written over the same ssh as the rest.
215
+ */
216
+ const devicesRoot = () =>
217
+ previewRootFor(
218
+ preview?.path ?? '/srv/preview/placeholder',
219
+ preview?.caddySitesDir
220
+ )
221
+
222
+ async function registerDevice(device: {
223
+ id: string
224
+ project: string
225
+ label: string
226
+ }): Promise<boolean> {
227
+ const line = deviceRegistryLine({ ...device, issued: new Date() })
228
+ if (!line) return false
229
+ const root = devicesRoot()
230
+ const run =
231
+ await $`ssh -o BatchMode=yes -o ConnectTimeout=10 ${host!} ${`mkdir -p ${root}/_devices && printf '%s\\n' '${line}' >> ${root}/_devices/registry.tsv`}`
232
+ .nothrow()
233
+ .quiet()
234
+ return run.exitCode === 0
235
+ }
236
+
176
237
  /*
177
238
  Say where this is going, and where the address came from, BEFORE acting on it.
178
239
 
@@ -218,6 +279,67 @@ async function running(): Promise<number[]> {
218
279
  return out.trim().split('\n').filter(Boolean).map(Number)
219
280
  }
220
281
 
282
+ if (has('devices')) {
283
+ const root = devicesRoot()
284
+ const read =
285
+ 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`}`
286
+ .nothrow()
287
+ .quiet()
288
+ if (read.exitCode !== 0) {
289
+ console.error(
290
+ `\nCould not read the device list on ${host}: ${read.stderr
291
+ .toString()
292
+ .trim()}\n`
293
+ )
294
+ process.exit(1)
295
+ }
296
+ const devices = parseDeviceListing(read.stdout.toString())
297
+ if (!devices.length) {
298
+ console.log(
299
+ '\nNo edit passes issued yet (`tosijs-tunnel --link --global`).\n'
300
+ )
301
+ } else {
302
+ console.log('')
303
+ for (const d of devices)
304
+ console.log(
305
+ ` ${d.id} ${d.revoked ? 'REVOKED' : 'active '} ${d.issued} ${
306
+ d.project
307
+ }${d.label ? ` "${d.label}"` : ''}`
308
+ )
309
+ console.log(
310
+ `\n Revoke one with \`tosijs-tunnel --revoke=<id>\`. A pass also expires 30 days after it was issued.\n`
311
+ )
312
+ }
313
+ process.exit(0)
314
+ }
315
+
316
+ if (flag('revoke') !== undefined) {
317
+ const id = flag('revoke')!
318
+ if (!DEVICE_ID_PATTERN.test(id)) {
319
+ console.error(
320
+ `\n${JSON.stringify(
321
+ id
322
+ )} is not a device id (ten letters and digits; see --devices).\n`
323
+ )
324
+ process.exit(1)
325
+ }
326
+ const root = devicesRoot()
327
+ const run =
328
+ 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`}`
329
+ .nothrow()
330
+ .quiet()
331
+ if (run.exitCode !== 0) {
332
+ console.error(
333
+ `\nCould not revoke ${id} on ${host}: ${run.stderr.toString().trim()}\n`
334
+ )
335
+ process.exit(1)
336
+ }
337
+ console.log(
338
+ `\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`
339
+ )
340
+ process.exit(0)
341
+ }
342
+
221
343
  if (has('link')) {
222
344
  /*
223
345
  Ask the dev server over loopback. The previous version ran
@@ -238,15 +360,16 @@ if (has('link')) {
238
360
  const shareTtl = flag('share-ttl')
239
361
  const ttl = shareTtl ? `&ttl=${encodeURIComponent(shareTtl)}` : ''
240
362
  /*
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
363
+ `--global` asks for a link that also hands over a global credential (see globalLinks): single-use,
364
+ valid five minutes whatever `--share-ttl` says, and the browser keeps every workspace for 30
365
+ days. Opt-in, because it is a different promise
243
366
  from the two links above, which are reusable for their window and open this workspace only.
244
367
  A share link spent by a chat app's link preview, or one that opens every workspace, would
245
368
  break the promise `--share` makes to the person you send it to.
246
369
  */
247
370
  let url: string
248
371
  if (has('global')) {
249
- const global = await globalLinks([share ? 'view' : 'edit'], ttl)
372
+ const global = await globalLinks([share ? 'view' : 'edit'])
250
373
  for (const note of global.notes) console.log(`note: ${note}`)
251
374
  url = global.links[0]?.url ?? ''
252
375
  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>
@@ -112,6 +112,8 @@ export interface AuthState {
112
112
  grants: Map<string, GlobalGrant>;
113
113
  /** Sessions minted from a granting link, until the redirect that sets the key takes them. */
114
114
  sessionGrants: Map<string, GlobalGrant>;
115
+ /** Sessions minted from a device pass → that device, so revoking it ends them too. */
116
+ sessionDevices: Map<string, string>;
115
117
  }
116
118
  /**
117
119
  * What a single-use link hands over besides a session here: the box's global `key`, as a
@@ -122,6 +124,9 @@ export interface AuthState {
122
124
  export interface GlobalGrant {
123
125
  key: 'edit' | 'view';
124
126
  domain: string;
127
+ /** An edit grant's device id and label, which the pass it becomes carries. */
128
+ device?: string;
129
+ label?: string;
125
130
  }
126
131
  export declare function createAuthState(): AuthState;
127
132
  /** Drop anything expired. Called on every use so the maps cannot grow without bound. */
@@ -164,6 +169,18 @@ export declare function resolveLinkSettings(tunnel?: {
164
169
  export declare function resolveShareTtlMs(tunnel?: {
165
170
  shareTtlMinutes?: number;
166
171
  }, override?: number): number;
172
+ /**
173
+ * How long a link that hands over a global key (edit or view) is redeemable: the edit-link
174
+ * window, and never more than five minutes.
175
+ *
176
+ * The maintainer's rule: the cookie is what lasts, the code that delivers it does not. A view
177
+ * grant used to live as long as a share link (a day), on the grounds that a person opens it
178
+ * when they get to it; but what it hands over is a 30-day key to every workspace, so the code
179
+ * gets the short window whatever it opens. `linkTtlMinutes` may shorten it, never lengthen it.
180
+ */
181
+ export declare function resolveGrantTtlMs(tunnel?: {
182
+ linkTtlMinutes?: number;
183
+ }): number;
167
184
  /**
168
185
  * Redeem a link token for a session token, or return null.
169
186
  *
@@ -177,7 +194,7 @@ export declare function resolveShareTtlMs(tunnel?: {
177
194
  export declare function redeemLink(state: AuthState, token: string, now: number, policy?: LinkPolicy): string | null;
178
195
  /**
179
196
  * How many unspent granting links may exist at once. Each is a live code that, guessed, hands
180
- * over the box's key; a bound keeps the guessing odds where a handful of real links put them.
197
+ * over a global credential; a bound keeps the guessing odds where a handful of real links put them.
181
198
  */
182
199
  export declare const MAX_OUTSTANDING_GRANTS = 32;
183
200
  /** Unspent, unexpired granting links. */
@@ -188,11 +205,37 @@ export declare function takeSessionGrant(state: AuthState, session: string): Glo
188
205
  export declare const GLOBAL_KEY_COOKIE_MAX_AGE_S: number;
189
206
  /**
190
207
  * The cookie that gives this browser the box's global key across the preview domain, the same
191
- * one Caddy sets for `?t=<key>`, so the box's root and every workspace holding the key accept
208
+ * one Caddy sets for `?t=<view key>` and checks for both keys, so the box's root and every workspace holding the key accept
192
209
  * it. `null` when this server does not hold that key, or would not honour it: a link must not
193
210
  * hand over a key that opens less than it claims.
194
211
  */
195
- export declare function globalKeyCookie(grant: GlobalGrant, keys: GlobalKeys): string | null;
212
+ export declare function globalKeyCookie(grant: GlobalGrant, keys: GlobalKeys, now?: number): string | null;
213
+ /**
214
+ * Every cookie a redeemed granting link sets: the key cookie itself, and for an edit grant the
215
+ * view key too (Caddy can only compare a fixed value, so the box's root list and the static
216
+ * previews still open on the shared view key), and an expiry for any raw edit key cookie an
217
+ * earlier version left in the browser.
218
+ */
219
+ export declare function grantCookies(grant: GlobalGrant, keys: GlobalKeys, now?: number): string[];
220
+ /** The cookie holding an edit device pass, across the preview domain. */
221
+ export declare const GLOBAL_PASS_COOKIE = "tosi_pass";
222
+ export interface DevicePass {
223
+ device: string;
224
+ domain: string;
225
+ label: string;
226
+ /** ms since the epoch */
227
+ exp: number;
228
+ }
229
+ /** Device ids: ten lowercase letters and digits, safe in a shell line and a file name. */
230
+ export declare const DEVICE_ID_PATTERN: RegExp;
231
+ export declare function mintDeviceId(): string;
232
+ /** A device label: what `--label` may say, kept to characters any listing can print. */
233
+ export declare function cleanDeviceLabel(label: unknown): string;
234
+ export declare function signPass(pass: DevicePass, key: string): string;
235
+ /** The pass in this cookie header, if it is signed with `key` and unexpired; else null. */
236
+ export declare function readPass(cookieHeader: string | null | undefined, key: string | undefined, now: number): DevicePass | null;
237
+ /** End every session minted from a pass for a device that is now revoked. */
238
+ export declare function revokeDeviceSessions(state: AuthState, revoked: ReadonlySet<string>): number;
196
239
  /**
197
240
  * The preview domain a granting link may set its cookie on: a plain hostname that the
198
241
  * workspace's own host sits under. Anything else would set a cookie this browser drops (a
@@ -416,19 +459,26 @@ export declare function sessionMayWrite(state: AuthState, token: string | null |
416
459
  export declare function cookieMayWrite(state: AuthState, cookieValue: string | undefined | null): boolean;
417
460
  /** Caddy's cookie for the view key; the static previews' invite gate already sets it. */
418
461
  export declare const GLOBAL_VIEW_COOKIE = "tosi_preview";
419
- /** Caddy's cookie for the edit key. */
462
+ /**
463
+ * Caddy's cookie for the raw edit key. Browsers no longer get it (they get a device pass); a
464
+ * dev server presents it to read the revoked list, and a redeemed edit link expires any copy an
465
+ * earlier version left in a browser.
466
+ */
420
467
  export declare const GLOBAL_EDIT_COOKIE = "tosi_edit";
421
468
  /** Shorter keys are refused: Caddy compares them with no rate limit in front. */
422
469
  export declare const GLOBAL_KEY_MIN_LENGTH = 24;
423
470
  /**
424
471
  * A session minted from a global key lasts a day, or until this dev server stops (sessions
425
- * live in memory). The key is NOT re-checked while the session lives, so rotating a key ends
426
- * the sessions it already opened only when each workspace restarts.
472
+ * live in memory). While the key cookie lasts, the next page load or same-origin request mints
473
+ * another, so for a key holder it is the COOKIE's lifetime that ends access, not this. Revoking a
474
+ * device ends the sessions its pass opened within a minute (revokeDeviceSessions); rotating a
475
+ * key ends the sessions it already opened only when each workspace restarts.
427
476
  */
428
477
  export declare const GLOBAL_KEY_SESSION_TTL_MS: number;
429
478
  /**
430
479
  * The most live sessions one server holds; past it the oldest goes. Only a key holder can mint
431
- * one per page load, so this bounds a browser stuck reloading, not an attacker.
480
+ * one: per page load, and per request from a page left open across a restart until its first
481
+ * renewed cookie lands. So this bounds a browser stuck reloading, not an attacker.
432
482
  */
433
483
  export declare const MAX_SESSIONS = 1000;
434
484
  export interface GlobalKeys {
@@ -457,8 +507,17 @@ export declare function resolveGlobalKeys(setting: unknown, secrets: {
457
507
  keys: GlobalKeys;
458
508
  problems: string[];
459
509
  };
460
- /** What the global-key cookies on a request grant: the edit key wins over the view key. */
461
- export declare function globalKeyAccess(cookieHeader: string | null | undefined, keys: GlobalKeys): 'edit' | 'view' | null;
510
+ /** What a device pass needs checked besides its signature: the time, and the revoked list. */
511
+ export interface PassCheck {
512
+ now: number;
513
+ /** Revoked device ids, or null when the list could not be read: then no pass is honoured. */
514
+ revoked: ReadonlySet<string> | null;
515
+ }
516
+ /**
517
+ * What the global-key cookies on a request grant: an edit pass wins over the view key. The raw
518
+ * edit key in a cookie is no longer accepted: it could not be revoked per device.
519
+ */
520
+ export declare function globalKeyAccess(cookieHeader: string | null | undefined, keys: GlobalKeys, check?: PassCheck): 'edit' | 'view' | null;
462
521
  /** Is this a browser loading a page (not a fetch, a script or a socket)? */
463
522
  export declare function isNavigation(request: {
464
523
  method: string;
@@ -472,16 +531,52 @@ export declare function isNavigation(request: {
472
531
  * Only over the tunnel, only on a page load, and only when it would add something: no valid
473
532
  * session yet, or a read-only one when the edit key is held.
474
533
  */
534
+ interface KeyOpts {
535
+ auth: AuthState;
536
+ keys: GlobalKeys;
537
+ now: number;
538
+ /** See PassCheck: omitted or null, no edit pass is honoured. */
539
+ revoked?: ReadonlySet<string> | null;
540
+ }
475
541
  export declare function globalKeyExchange(request: {
476
542
  method: string;
477
543
  headers: {
478
544
  get(name: string): string | null;
479
545
  };
480
- }, opts: {
546
+ }, opts: KeyOpts & {
481
547
  viaTunnel: boolean;
482
- auth: AuthState;
483
- keys: GlobalKeys;
484
- now: number;
548
+ }): 'edit' | 'view' | null;
549
+ /**
550
+ * Should a request from this workspace's OWN page, that is not a page load, trade a global key
551
+ * for a session in place?
552
+ *
553
+ * Sessions live in memory, and `bun start` restarts the process whenever a file in its import
554
+ * graph changes, which includes every component source and so every doc-comment example. Save
555
+ * to source writes exactly such a file. So the save restarts the server, and the next save from
556
+ * the same page arrived holding a dead session and was told "This workspace needs an invite
557
+ * link". A reload would have exchanged the key, and lost the unsaved edit (the maintainer,
558
+ * 2026-10-20: a restart must not reset a key holder).
559
+ *
560
+ * The answer is the page-load exchange's, with two extra conditions, because no redirect is
561
+ * involved: the session is issued and used on this same request.
562
+ * - Positive evidence that the request comes from this origin: `Sec-Fetch-Site: same-origin`,
563
+ * or, from a browser that sends no fetch metadata, an `Origin` that is this host. Absence of
564
+ * both is NOT enough here (unlike `isSameOriginRequest`, which allows it for curl and our
565
+ * own CLI, whose requests are not a browser spending an ambient cookie): every host under the preview domain
566
+ * is the same site and receives the key cookie, so a sibling workspace's page must not be
567
+ * able to spend it here.
568
+ *
569
+ * A WebSocket upgrade (the haltija bridge) qualifies too; browsers send `Origin` and
570
+ * `Sec-Fetch-Site` on the handshake, and the 101 carries the cookie.
571
+ */
572
+ export declare function globalKeyRefresh(request: {
573
+ method: string;
574
+ headers: {
575
+ get(name: string): string | null;
576
+ };
577
+ }, opts: KeyOpts & {
578
+ viaTunnel: boolean;
579
+ host: string;
485
580
  }): 'edit' | 'view' | null;
486
581
  /**
487
582
  * The origin allowed to read this workspace's liveness answer: the box's own list.
@@ -600,3 +695,4 @@ export declare function resolveLinkArrival(opts: {
600
695
  redeemed: string | null;
601
696
  hasValidSession: boolean;
602
697
  }): LinkArrival;
698
+ export {};