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 +60 -0
- package/bin/resolve-site-config.ts +55 -1
- package/bin/tunnel.ts +135 -12
- package/deploy/Caddyfile +10 -18
- package/deploy/build-index.sh +5 -2
- package/dist/doc-system/site/dev-auth.d.ts +75 -17
- package/dist/doc-system/site/dev-auth.js +159 -36
- package/dist/doc-system/site/dev-server.js +92 -22
- package/dist/doc-system/site/device-revocation.d.ts +28 -0
- package/dist/doc-system/site/device-revocation.js +86 -0
- package/dist/iife.js +94 -94
- package/dist/iife.js.map +4 -4
- package/dist/notifications.d.ts +69 -4
- package/dist/notifications.js +266 -17
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,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 {
|
|
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: [
|
|
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
|
|
61
|
-
`
|
|
62
|
-
`
|
|
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!)}${
|
|
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
|
|
242
|
-
and the browser keeps every workspace for 30
|
|
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']
|
|
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
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
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
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
|
|
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.
|
package/deploy/build-index.sh
CHANGED
|
@@ -27,6 +27,9 @@ html() { printf '%s' "$1" | sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>
|
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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.
|
|
427
|
-
*
|
|
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
|
|
464
|
-
export
|
|
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 {};
|