tosijs-ui 1.16.30 → 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,65 @@
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
+
3
63
  ## 1.16.30
4
64
 
5
65
  ### 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
 
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,16 +459,20 @@ 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
472
  * live in memory). While the key cookie lasts, the next page load or same-origin request mints
426
- * another, so for a key holder it is the COOKIE's lifetime that ends access, not this. The key
427
- * is NOT re-checked while a session lives, so rotating a key ends the sessions it already opened
428
- * only when each workspace restarts.
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.
429
476
  */
430
477
  export declare const GLOBAL_KEY_SESSION_TTL_MS: number;
431
478
  /**
@@ -460,8 +507,17 @@ export declare function resolveGlobalKeys(setting: unknown, secrets: {
460
507
  keys: GlobalKeys;
461
508
  problems: string[];
462
509
  };
463
- /** What the global-key cookies on a request grant: the edit key wins over the view key. */
464
- 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;
465
521
  /** Is this a browser loading a page (not a fetch, a script or a socket)? */
466
522
  export declare function isNavigation(request: {
467
523
  method: string;
@@ -475,16 +531,20 @@ export declare function isNavigation(request: {
475
531
  * Only over the tunnel, only on a page load, and only when it would add something: no valid
476
532
  * session yet, or a read-only one when the edit key is held.
477
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
+ }
478
541
  export declare function globalKeyExchange(request: {
479
542
  method: string;
480
543
  headers: {
481
544
  get(name: string): string | null;
482
545
  };
483
- }, opts: {
546
+ }, opts: KeyOpts & {
484
547
  viaTunnel: boolean;
485
- auth: AuthState;
486
- keys: GlobalKeys;
487
- now: number;
488
548
  }): 'edit' | 'view' | null;
489
549
  /**
490
550
  * Should a request from this workspace's OWN page, that is not a page load, trade a global key
@@ -514,12 +574,9 @@ export declare function globalKeyRefresh(request: {
514
574
  headers: {
515
575
  get(name: string): string | null;
516
576
  };
517
- }, opts: {
577
+ }, opts: KeyOpts & {
518
578
  viaTunnel: boolean;
519
579
  host: string;
520
- auth: AuthState;
521
- keys: GlobalKeys;
522
- now: number;
523
580
  }): 'edit' | 'view' | null;
524
581
  /**
525
582
  * The origin allowed to read this workspace's liveness answer: the box's own list.
@@ -638,3 +695,4 @@ export declare function resolveLinkArrival(opts: {
638
695
  redeemed: string | null;
639
696
  hasValidSession: boolean;
640
697
  }): LinkArrival;
698
+ export {};