tosijs-ui 1.16.25 → 1.16.26

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,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.26
4
+
5
+ ### Tunnel
6
+
7
+ - **Global keys: one link opens every workspace, and survives restarts.** The preview host can
8
+ hold two machine-wide keys: `PREVIEW_EDIT_TOKEN` (new, optional) opens every workspace for
9
+ editing, and `PREVIEW_TOKEN` (the static previews' invite secret) opens them read-only.
10
+ Put them in `/etc/caddy/preview.env` on the box (re-run `tosijs-caddy-install`) and in
11
+ `~/local-secrets/tosijs-preview.env` on each machine running a dev server. Visit the box's
12
+ root once with `?t=<key>`: Caddy sets a cookie across the preview domain (24 hours for the
13
+ edit key), and the root page lists every workspace that is up and whether you can edit or
14
+ only view it. Opening one starts a session there on the first page load, and after a
15
+ dev-server restart a reload is all it takes. Keys must be at least 24 letters, digits, `-` or
16
+ `_`, because the box compares them with no rate limit; the installer refuses a short edit
17
+ key, and a dev server ignores one and says so. A workspace can refuse them with
18
+ `tunnel.globalKeys: false`, or with `'view'` let either key browse and neither edit; any other
19
+ value refuses both and says so. The eight-letter `--link` codes are unchanged.
20
+ - **The view key is your preview invite.** Everyone you have given a static-preview link can,
21
+ by default, also read every workspace that accepts the view key while it is up, even with
22
+ `requireToken`. Set `tunnel.globalKeys: false` on a project that should stay yours, or rotate
23
+ the invite secret.
24
+ - **Rotating a key** means editing both files, re-running the installer and restarting the dev
25
+ servers: a session a key already opened lasts up to 24 hours and is not re-checked against
26
+ the key until its server restarts.
27
+ - **`tosijs-tunnel` refreshes the box's root page** when it registers a workspace, so a newly
28
+ tunnelled project appears in the list straight away. The box's root page escapes project
29
+ names, which it inserted into the page as they were.
30
+ - The edit key opens a `globalKeys: 'view'` workspace read-only, as the view key does.
31
+
32
+ ### Security
33
+
34
+ - **Source and data writes over the tunnel must come from the workspace's own origin**
35
+ (affected: 1.9.0 to 1.16.25). They relied on the session cookie's `SameSite=Lax`, but every
36
+ host under the preview domain is the same *site*, so a page on another project's preview (its
37
+ live examples included) could POST to your workspace with your session attached. The haltija
38
+ bridge's component and socket over the tunnel check the same (affected: 1.12.2 to 1.16.25).
39
+ - **A read-only session can no longer attach the haltija agent bridge** (affected: 1.15.0 to
40
+ 1.16.25, with `haltijaDev: 'tunnel'`). A share link's session passed the bridge's gate, which
41
+ asked only whether the session was valid.
42
+ - **A redirect after redeeming a link stays on the workspace.** A link at a path starting `//`
43
+ redirected to another host.
44
+
3
45
  ## 1.16.25
4
46
 
5
47
  ### Dev server
@@ -24,9 +24,11 @@ Three deliberate properties:
24
24
  anything else through Caddy, that is destructive — so the dry run prints a diff against
25
25
  the live file and you get to look at it before `--go`.
26
26
 
27
- THE SECRET NEVER LEAVES THE BOX. Substitution happens remotely, reading
28
- `/etc/caddy/preview.env`. `PREVIEW_TOKEN` is never transmitted, never appears in argv on
29
- this machine, and is redacted out of the diff before it is printed.
27
+ THIS COMMAND NEVER CARRIES THE SECRETS. Substitution happens remotely, reading
28
+ `/etc/caddy/preview.env`. `PREVIEW_TOKEN` and `PREVIEW_EDIT_TOKEN` are never transmitted by
29
+ it, never appear in argv on this machine, and are redacted out of the diff before it is
30
+ printed. (Global keys are a separate choice: to use them, you copy the same values into
31
+ ~/local-secrets on the machines that run dev servers.)
30
32
 
31
33
  VALIDATE BEFORE SWAP. `caddy fmt` then `caddy validate` run against a scratch file; the
32
34
  live config is replaced by an atomic `mv` only once it is known to parse. A rejected
@@ -121,14 +123,28 @@ set -a; . /etc/caddy/preview.env; set +a
121
123
  : "\${ACME_EMAIL:?set ACME_EMAIL in /etc/caddy/preview.env}"
122
124
  : "\${PREVIEW_DOMAIN:?set PREVIEW_DOMAIN in /etc/caddy/preview.env}"
123
125
 
126
+ # The global EDIT key is optional. Unset, the template still needs a value, so it gets one
127
+ # nobody knows and the edit cookie opens nothing. Set, it is compared by Caddy with no rate
128
+ # limit in front, so it must be long, and the cookie match needs a plain alphabet.
129
+ if [ -z "\${PREVIEW_EDIT_TOKEN:-}" ]; then
130
+ PREVIEW_EDIT_TOKEN="unset-$(od -An -tx1 -N24 /dev/urandom | tr -d ' \\n')"
131
+ elif ! printf '%s' "$PREVIEW_EDIT_TOKEN" | grep -qE '^[A-Za-z0-9_-]{24,}$'; then
132
+ echo "PREVIEW_EDIT_TOKEN must be at least 24 letters, digits, - or _ — refusing to install." >&2
133
+ exit 1
134
+ elif [ "$PREVIEW_EDIT_TOKEN" = "$PREVIEW_TOKEN" ]; then
135
+ echo "PREVIEW_EDIT_TOKEN equals PREVIEW_TOKEN, so every preview link could edit — refusing to install." >&2
136
+ exit 1
137
+ fi
138
+
124
139
  sed -e "s|__PREVIEW_TOKEN__|$PREVIEW_TOKEN|g" \\
140
+ -e "s|__PREVIEW_EDIT_TOKEN__|$PREVIEW_EDIT_TOKEN|g" \\
125
141
  -e "s|{{ACME_EMAIL}}|$ACME_EMAIL|g" \\
126
142
  -e "s|{{PREVIEW_DOMAIN}}|$PREVIEW_DOMAIN|g" \\
127
143
  /etc/caddy/Caddyfile.tpl > /etc/caddy/Caddyfile.new
128
144
 
129
- if grep -qE '\\{\\{|__PREVIEW_TOKEN__' /etc/caddy/Caddyfile.new; then
145
+ if grep -qE '\\{\\{|__PREVIEW_(EDIT_)?TOKEN__' /etc/caddy/Caddyfile.new; then
130
146
  echo "Unsubstituted placeholders remain — refusing to install:" >&2
131
- grep -nE '\\{\\{|__PREVIEW_TOKEN__' /etc/caddy/Caddyfile.new >&2
147
+ grep -nE '\\{\\{|__PREVIEW_(EDIT_)?TOKEN__' /etc/caddy/Caddyfile.new >&2
132
148
  rm -f /etc/caddy/Caddyfile.new
133
149
  exit 1
134
150
  fi
@@ -150,7 +166,8 @@ else
150
166
  echo "--- would change /etc/caddy/Caddyfile (token redacted) ---"
151
167
  if [ -f /etc/caddy/Caddyfile ]; then
152
168
  diff -u /etc/caddy/Caddyfile /etc/caddy/Caddyfile.new \\
153
- | sed "s|$PREVIEW_TOKEN|***REDACTED***|g" || true
169
+ | sed -e "s|$PREVIEW_TOKEN|***REDACTED***|g" \\
170
+ -e "s|$PREVIEW_EDIT_TOKEN|***REDACTED***|g" || true
154
171
  else
155
172
  echo "(no existing /etc/caddy/Caddyfile — this would create it)"
156
173
  fi
@@ -31,6 +31,7 @@ import { existsSync } from 'fs'
31
31
  import {
32
32
  previewRootFor,
33
33
  registerCaddyFragment,
34
+ refreshHostIndex,
34
35
  resolvePreviewHost,
35
36
  parseArgv,
36
37
  } from './resolve-site-config.ts'
@@ -205,51 +206,10 @@ async function registerSite(): Promise<boolean> {
205
206
  return ok
206
207
  }
207
208
 
208
- /** Refresh the host's index so this deploy shows up immediately. */
209
- async function refreshIndex(): Promise<void> {
210
- const remote = `/tmp/tosi-build-index-${process.pid}.sh`
211
- /*
212
- Resolve the script against THIS PACKAGE, not the adopter's cwd.
213
-
214
- `deploy/` is in `files`, so the asset ships at node_modules/tosijs-ui/deploy/ — only
215
- the resolution was wrong, which meant every adopter's scp failed, the failure was
216
- swallowed by a bare `return`, and the deploy still printed "✅ Deployed". Exactly the
217
- #37 class of bug, one cycle later: a package asset located relative to whoever called us.
218
- */
219
- const script = `${import.meta.dir}/../deploy/build-index.sh`
220
- const put = await $`scp -q ${script} ${`${host}:${remote}`}`.nothrow().quiet()
221
- if (put.exitCode !== 0) {
222
- // Loudly: the deploy itself succeeded, so this must not read as total failure —
223
- // but a silently stale index is how you conclude a deploy did not land.
224
- console.warn(
225
- `⚠️ Could not refresh the host index (scp of ${script} failed).\n` +
226
- ` The deploy landed; the listing at the root will be stale until the next one.\n` +
227
- ` ${put.stderr.toString().trim().slice(0, 200)}`
228
- )
229
- return
230
- }
231
- const run = await $`ssh ${host} bash ${remote}`.nothrow().quiet()
232
- await $`ssh ${host} rm -f ${remote}`.nothrow().quiet()
233
- // build-index.sh is `set -euo pipefail`; a non-root user on a root-owned tree dies at
234
- // mkdir. Discarding this exit code meant the deploy printed ✅ over a failed index.
235
- if (run.exitCode !== 0) {
236
- console.warn(
237
- `⚠️ the host index was not refreshed (exit ${run.exitCode}). The deploy landed;\n` +
238
- ` the listing at the root will be stale until the next successful one.\n` +
239
- ` ${(run.stderr.toString() || run.stdout.toString())
240
- .trim()
241
- .slice(0, 200)}`
242
- )
243
- return
244
- }
245
- const out = run.stdout.toString().trim()
246
- if (out) console.log(` ${out}`)
247
- }
248
-
249
209
  let routed = true
250
210
  if (go) {
251
211
  routed = await registerSite()
252
- await refreshIndex()
212
+ await refreshHostIndex(host!)
253
213
  }
254
214
 
255
215
  /*
@@ -19,7 +19,8 @@ silently picking up a parent directory's config would mean publishing the wrong
19
19
  and "it found something" is not the same as "it found yours".
20
20
  */
21
21
 
22
- import { existsSync, readFileSync } from 'fs'
22
+ import { $ } from 'bun'
23
+ import { existsSync } from 'fs'
23
24
  import * as path from 'path'
24
25
 
25
26
  export interface PreviewConfig {
@@ -151,47 +152,11 @@ export {
151
152
  } from '../dist/doc-system/site/site-config.js'
152
153
 
153
154
  /*
154
- Where the preview host comes from — ONE implementation, and a fallback that works for
155
- things that are not a human at an interactive prompt.
156
-
157
- `PREVIEW_HOST` lives in `~/local-secrets/tosijs-preview.env`: mode 700, a sibling of the
158
- repos rather than inside one, so it is structurally impossible to commit. That is a stronger
159
- guarantee than a rule saying "don't commit the host", which this project wrote, believed, and
160
- then violated in its own `tosijs-site.config.ts` for months.
161
-
162
- The fallback exists because the previous advice — "export it from your shell profile" — is
163
- present for a human and ABSENT for every tool: non-interactive shells inherit no interactive
164
- profile, so an agent or a CI step sees nothing and reports a missing credential rather than a
165
- missing `export`. Reading the file directly makes the resolution order true for both.
166
-
167
- Parses only `KEY=value` / `export KEY=value`, strips one layer of quotes, ignores the rest.
168
- Not a shell — a credentials file that needs evaluating is a credentials file that can run
169
- code.
155
+ `readLocalSecret` lives in the package (`src/doc-system/site/local-secrets.ts`) because the dev
156
+ server reads the global tunnel keys from the same file; it is re-exported here for the bins.
170
157
  */
171
- export function readLocalSecret(
172
- name: string,
173
- file = path.join(
174
- process.env.HOME ?? '',
175
- 'local-secrets',
176
- 'tosijs-preview.env'
177
- )
178
- ): string | undefined {
179
- if (!process.env.HOME || !existsSync(file)) return undefined
180
- let text: string
181
- try {
182
- text = readFileSync(file, 'utf8')
183
- } catch {
184
- return undefined
185
- }
186
- for (const line of text.split('\n')) {
187
- const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(
188
- line
189
- )
190
- if (!m || m[1] !== name) continue
191
- return m[2].trim().replace(/^(['"])(.*)\1$/, '$2')
192
- }
193
- return undefined
194
- }
158
+ export { readLocalSecret } from '../dist/doc-system/site/local-secrets.js'
159
+ import { readLocalSecret } from '../dist/doc-system/site/local-secrets.js'
195
160
 
196
161
  /**
197
162
  * `--host=` > `PREVIEW_HOST` > `PREVIEW_SSH` (legacy) > **site config** > `~/local-secrets`.
@@ -434,3 +399,47 @@ export function parseArgv(argv: string[], spec: ArgvSpec): Argv {
434
399
  rest: argv.filter((a) => !a.startsWith('--')),
435
400
  }
436
401
  }
402
+
403
+ /**
404
+ * Refresh the host's index so a deploy, or a newly registered tunnel, shows up immediately.
405
+ * Shared by `tosijs-deploy` and `tosijs-tunnel`.
406
+ */
407
+ export async function refreshHostIndex(host: string): Promise<void> {
408
+ const remote = `/tmp/tosi-build-index-${process.pid}.sh`
409
+ /*
410
+ Resolve the script against THIS PACKAGE, not the adopter's cwd.
411
+
412
+ `deploy/` is in `files`, so the asset ships at node_modules/tosijs-ui/deploy/ — only
413
+ the resolution was wrong, which meant every adopter's scp failed, the failure was
414
+ swallowed by a bare `return`, and the deploy still printed "✅ Deployed". Exactly the
415
+ #37 class of bug, one cycle later: a package asset located relative to whoever called us.
416
+ */
417
+ const script = `${import.meta.dir}/../deploy/build-index.sh`
418
+ const put = await $`scp -q ${script} ${`${host}:${remote}`}`.nothrow().quiet()
419
+ if (put.exitCode !== 0) {
420
+ // Loudly: the deploy or tunnel itself succeeded, so this must not read as total
421
+ // failure — but a silently stale index is how you conclude it did not land.
422
+ console.warn(
423
+ `⚠️ Could not refresh the host index (scp of ${script} failed).\n` +
424
+ ` The listing at the root will be stale until the next refresh.\n` +
425
+ ` ${put.stderr.toString().trim().slice(0, 200)}`
426
+ )
427
+ return
428
+ }
429
+ const run = await $`ssh ${host} bash ${remote}`.nothrow().quiet()
430
+ await $`ssh ${host} rm -f ${remote}`.nothrow().quiet()
431
+ // build-index.sh is `set -euo pipefail`; a non-root user on a root-owned tree dies at
432
+ // mkdir. Discarding this exit code meant the deploy printed ✅ over a failed index.
433
+ if (run.exitCode !== 0) {
434
+ console.warn(
435
+ `⚠️ the host index was not refreshed (exit ${run.exitCode}). The listing at\n` +
436
+ ` the root will be stale until the next successful refresh.\n` +
437
+ ` ${(run.stderr.toString() || run.stdout.toString())
438
+ .trim()
439
+ .slice(0, 200)}`
440
+ )
441
+ return
442
+ }
443
+ const out = run.stdout.toString().trim()
444
+ if (out) console.log(` ${out}`)
445
+ }
package/bin/tunnel.ts CHANGED
@@ -34,6 +34,7 @@ import { $ } from 'bun'
34
34
  import {
35
35
  previewRootFor,
36
36
  registerCaddyFragment,
37
+ refreshHostIndex,
37
38
  resolveDevPort,
38
39
  resolvePreviewHost,
39
40
  resolveSiteConfig,
@@ -418,6 +419,8 @@ if (publicUrl) {
418
419
  describe: `${hostname} → this workspace`,
419
420
  body: fragment,
420
421
  })
422
+ // The root's list of live workspaces is read from these fragments.
423
+ if (routed) await refreshHostIndex(host!)
421
424
  }
422
425
  }
423
426
 
package/deploy/Caddyfile CHANGED
@@ -2,8 +2,10 @@
2
2
  #
3
3
  # {{ACME_EMAIL}} your Let's Encrypt account email
4
4
  # {{PREVIEW_DOMAIN}} the domain your preview hosts live under (e.g. dev.example.com)
5
- # __PREVIEW_TOKEN__ the shared invite secret, substituted at install time from
6
- # /etc/caddy/preview.env — NEVER commit a real value here
5
+ # __PREVIEW_TOKEN__ the shared invite secret (the global VIEW key), substituted at
6
+ # install time from /etc/caddy/preview.env — NEVER commit a real value
7
+ # __PREVIEW_EDIT_TOKEN__ the global EDIT key, from the same file; optional (unset, the
8
+ # installer fills in a value nobody knows)
7
9
  #
8
10
  # Installing this file with the placeholders intact gives you a preview host whose entire
9
11
  # invite gate is a literal string published in a public repo and in every npm tarball, and
@@ -64,7 +66,28 @@
64
66
  redir * / 302
65
67
  }
66
68
 
67
- @unauthed not header_regexp Cookie "tosi_preview=__PREVIEW_TOKEN__"
69
+ # THE GLOBAL EDIT KEY: `?t=<edit key>` on any preview host sets `tosi_edit` across the
70
+ # whole preview domain for 24 hours. Every tunnelled workspace that holds the same key
71
+ # (PREVIEW_EDIT_TOKEN in ~/local-secrets/tosijs-preview.env on the machine running it)
72
+ # trades it for an editing session on the first page load, so the root's list opens them
73
+ # all, across dev-server restarts. It also opens these static previews. The view key above
74
+ # opens workspaces read-only the same way. See `globalKeys` in doc-site-system.md.
75
+ #
76
+ # The cookie is the key itself: Caddy can compare a fixed value and cannot sign one. A
77
+ # leaked edit cookie is write access to every workspace that accepts the key until it is
78
+ # rotated (edit preview.env here and on each machine, re-run the installer).
79
+ @editlink query t=__PREVIEW_EDIT_TOKEN__
80
+ handle @editlink {
81
+ header Set-Cookie "tosi_edit=__PREVIEW_EDIT_TOKEN__; Domain=.{{PREVIEW_DOMAIN}}; Path=/; Max-Age=86400; HttpOnly; Secure; SameSite=Lax"
82
+ header Referrer-Policy "no-referrer"
83
+ header Cache-Control "no-store"
84
+ redir * / 302
85
+ }
86
+
87
+ @unauthed {
88
+ not header_regexp Cookie "tosi_preview=__PREVIEW_TOKEN__"
89
+ not header_regexp Cookie "tosi_edit=__PREVIEW_EDIT_TOKEN__"
90
+ }
68
91
  handle @unauthed {
69
92
  header Content-Type "text/html; charset=utf-8"
70
93
  header Cache-Control "no-store"
@@ -21,6 +21,9 @@
21
21
  set -euo pipefail
22
22
 
23
23
  ROOT=/srv/preview
24
+
25
+ # Names come from config and file names; nothing reaches the page unescaped.
26
+ html() { printf '%s' "$1" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g' -e 's/"/\&quot;/g'; }
24
27
  OUT="$ROOT/_index"
25
28
  mkdir -p "$OUT"
26
29
 
@@ -39,6 +42,7 @@ for dir in "$ROOT"/*/; do
39
42
  gen=$(sed -n 's/.*"generator": *"\([^"]*\)".*/\1/p' "$vj" | head -1)
40
43
  site=$(sed -n 's/.*"site": *"\([^"]*\)".*/\1/p' "$vj" | head -1)
41
44
  [ -n "$site" ] || site="$name"
45
+ site=$(html "$site")
42
46
 
43
47
  # The hostname comes from the project's OWN Caddy fragment — the same file the
44
48
  # router reads — so this listing cannot disagree with what actually serves. An
@@ -71,6 +75,24 @@ for dir in "$ROOT"/*/; do
71
75
  </li>"
72
76
  done
73
77
 
78
+ # Tunnelled workspaces: every registered `-tunnel` fragment. Whether one is UP, and what
79
+ # the reader may do there, changes by the minute, so the page asks each one when it loads
80
+ # (see the script below); this only lists where to ask.
81
+ live=""
82
+ for frag in "$ROOT"/_sites/*-tunnel.caddy; do
83
+ [ -f "$frag" ] || continue
84
+ name="$(html "$(basename "$frag" -tunnel.caddy)")"
85
+ host=$(sed -n 's/^\([A-Za-z0-9.-]*\) *{.*/\1/p' "$frag" | head -1)
86
+ [ -n "$host" ] || continue
87
+ live="$live
88
+ <li data-workspace=\"$host\" hidden>
89
+ <a href=\"https://$host/\">
90
+ <span class=\"name\">$name</span>
91
+ <span class=\"meta\"><span class=\"access\"></span><code>$host</code></span>
92
+ </a>
93
+ </li>"
94
+ done
95
+
74
96
  # The REAL tosiPlatform icon, lifted verbatim from src/icons/data/tosiPlatform.ts.
75
97
  # (The first version of this file hand-drew a rough approximation — a blue box with a
76
98
  # stroke in it — which is exactly the sort of thing nobody notices in review and
@@ -108,6 +130,9 @@ cat > "$OUT/index.html" <<HTML
108
130
  code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
109
131
  .gen { display:block; color:var(--dim); font-size:.75rem; margin-top:.3rem; }
110
132
  .empty { color:var(--dim); }
133
+ h2 { font-size:1rem; margin:2rem 0 .75rem; font-weight:600; }
134
+ .access { font-weight:600; }
135
+ .access.edit { color:#2a8a3e; }
111
136
  footer { color:var(--dim); font-size:.8rem; margin-top:2.5rem; }
112
137
  </style>
113
138
  <main>
@@ -116,6 +141,8 @@ cat > "$OUT/index.html" <<HTML
116
141
  $(if [ "$count" -eq 0 ]; then echo '<p class="empty">Nothing deployed yet.</p>'; else echo "<ul>$rows
117
142
  </ul>"; fi)
118
143
  $(if [ -n "$unlinked" ]; then echo "<p class=\"empty\">Deployed but not routed:$unlinked</p>"; fi)
144
+ $(if [ -n "$live" ]; then echo "<section id=\"live\" hidden><h2>Live workspaces</h2><ul>$live
145
+ </ul></section>"; fi)
119
146
  <footer>Previews only — not production. Each entry updates when that project deploys (<code>bun run deploy --go</code>), not when it builds.</footer>
120
147
  </main>
121
148
  <script>
@@ -133,6 +160,24 @@ cat > "$OUT/index.html" <<HTML
133
160
  t.textContent = rel
134
161
  t.title = d.toLocaleString()
135
162
  }
163
+
164
+ // Ask each tunnelled workspace whether it is up and what this reader may do there. Only
165
+ // this origin may read the answer, and it reflects the key cookie this page set; a
166
+ // workspace that is down (or older than this probe) shows nothing. Opening one trades the
167
+ // key for a session there, so these are plain links.
168
+ for (const li of document.querySelectorAll('[data-workspace]')) {
169
+ fetch('https://' + li.dataset.workspace + '/__alive', { credentials: 'include' })
170
+ .then((r) => (r.ok && r.status === 200 ? r.json() : null))
171
+ .then((info) => {
172
+ if (!info || !info.access) return
173
+ const label = li.querySelector('.access')
174
+ label.textContent = info.access === 'edit' ? 'edit' : 'view'
175
+ label.classList.add(info.access)
176
+ li.hidden = false
177
+ document.getElementById('live').hidden = false
178
+ })
179
+ .catch(() => {})
180
+ }
136
181
  </script>
137
182
  HTML
138
183
 
@@ -156,6 +156,8 @@ export declare function resolveShareTtlMs(tunnel?: {
156
156
  * the set before the lookup. Widening reuse must not widen lifetime.
157
157
  */
158
158
  export declare function redeemLink(state: AuthState, token: string, now: number, policy?: LinkPolicy): string | null;
159
+ /** Mint a session token directly: what a redeemed link or a global key turns into. */
160
+ export declare function issueSession(state: AuthState, now: number, ttlMs: number, readOnly: boolean): string;
159
161
  export declare const REDEEM_MIN_MS = 100;
160
162
  /** Consecutive failures after which the slot widens. A human never reaches this. */
161
163
  export declare const SLOW_AFTER_FAILURES = 10;
@@ -268,6 +270,14 @@ export declare function sessionCookie(token: string, maxAgeMs?: number): string;
268
270
  * defeat the entire design.
269
271
  */
270
272
  export declare function urlWithoutToken(rawUrl: string, param: string): string;
273
+ /**
274
+ * A request's path, query and hash, for a `Location` that cannot leave this origin.
275
+ *
276
+ * A path is not always same-origin as a Location: `GET //evil.example/x` has the pathname
277
+ * `//evil.example/x`, which a browser reads as a protocol-relative URL to another host. Runs of
278
+ * leading slashes collapse to one.
279
+ */
280
+ export declare function sameOriginPath(rawUrl: string): string;
271
281
  export declare function isSameOriginRequest(request: {
272
282
  url?: string;
273
283
  headers: {
@@ -361,6 +371,82 @@ export declare function sessionMayWrite(state: AuthState, token: string | null |
361
371
  * runs — but that is a property of the CALL ORDER, so keep the two together at every call site.
362
372
  */
363
373
  export declare function cookieMayWrite(state: AuthState, cookieValue: string | undefined | null): boolean;
374
+ /** Caddy's cookie for the view key; the static previews' invite gate already sets it. */
375
+ export declare const GLOBAL_VIEW_COOKIE = "tosi_preview";
376
+ /** Caddy's cookie for the edit key. */
377
+ export declare const GLOBAL_EDIT_COOKIE = "tosi_edit";
378
+ /** Shorter keys are refused: Caddy compares them with no rate limit in front. */
379
+ export declare const GLOBAL_KEY_MIN_LENGTH = 24;
380
+ /**
381
+ * A session minted from a global key lasts a day, or until this dev server stops (sessions
382
+ * live in memory). The key is NOT re-checked while the session lives, so rotating a key ends
383
+ * the sessions it already opened only when each workspace restarts.
384
+ */
385
+ export declare const GLOBAL_KEY_SESSION_TTL_MS: number;
386
+ /**
387
+ * The most live sessions one server holds; past it the oldest goes. Only a key holder can mint
388
+ * one per page load, so this bounds a browser stuck reloading, not an attacker.
389
+ */
390
+ export declare const MAX_SESSIONS = 1000;
391
+ export interface GlobalKeys {
392
+ edit?: string;
393
+ view?: string;
394
+ /**
395
+ * What the edit key grants here: `'edit'`, or `'view'` on a workspace that refuses edit
396
+ * (`globalKeys: 'view'`). The edit key always opens at least what the view key does, as it
397
+ * does on the box.
398
+ */
399
+ editGrants?: 'edit' | 'view';
400
+ }
401
+ /**
402
+ * The keys this workspace accepts, from `tunnel.globalKeys` and the two secrets.
403
+ *
404
+ * `false` accepts neither; `'view'` lets either key browse and neither edit, so this workspace
405
+ * can be opened from the box's list and never edited through it. A key that is too short or has
406
+ * characters Caddy's cookie match cannot hold is not used, and `problems` says why, so the server
407
+ * can say so at launch rather than leave a key that silently opens nothing. So does a setting
408
+ * that is none of these, which accepts neither key: a misspelt `'off'` must not mean "both".
409
+ */
410
+ export declare function resolveGlobalKeys(setting: unknown, secrets: {
411
+ PREVIEW_TOKEN?: string;
412
+ PREVIEW_EDIT_TOKEN?: string;
413
+ }): {
414
+ keys: GlobalKeys;
415
+ problems: string[];
416
+ };
417
+ /** What the global-key cookies on a request grant: the edit key wins over the view key. */
418
+ export declare function globalKeyAccess(cookieHeader: string | null | undefined, keys: GlobalKeys): 'edit' | 'view' | null;
419
+ /** Is this a browser loading a page (not a fetch, a script or a socket)? */
420
+ export declare function isNavigation(request: {
421
+ method: string;
422
+ headers: {
423
+ get(name: string): string | null;
424
+ };
425
+ }): boolean;
426
+ /**
427
+ * Should this request trade a global key for a session, and of which kind?
428
+ *
429
+ * Only over the tunnel, only on a page load, and only when it would add something: no valid
430
+ * session yet, or a read-only one when the edit key is held.
431
+ */
432
+ export declare function globalKeyExchange(request: {
433
+ method: string;
434
+ headers: {
435
+ get(name: string): string | null;
436
+ };
437
+ }, opts: {
438
+ viaTunnel: boolean;
439
+ auth: AuthState;
440
+ keys: GlobalKeys;
441
+ now: number;
442
+ }): 'edit' | 'view' | null;
443
+ /**
444
+ * The origin allowed to read this workspace's liveness answer: the box's own list.
445
+ *
446
+ * Only an https origin whose host is a parent domain of this host, so `dev.example.com` may ask
447
+ * `ui.edit.dev.example.com` and nothing else may.
448
+ */
449
+ export declare function liveProbeOrigin(origin: string | null, host: string | null): string | null;
364
450
  /**
365
451
  * Who may attach the haltija dev channel — i.e. let an agent DRIVE this page.
366
452
  *
@@ -377,6 +463,29 @@ export declare function mayDriveWithAgent(opts: {
377
463
  viaTunnel: boolean;
378
464
  peer?: string | null;
379
465
  hasValidSession: boolean;
466
+ /** a read-only session (a share link, the view key) may look, never drive */
467
+ sessionMayWrite?: boolean;
468
+ }): boolean;
469
+ /**
470
+ * The whole haltija-bridge gate for one request, as the dev server applies it.
471
+ *
472
+ * `mayDriveWithAgent` on the request's session (a read-only one may not drive), and, for the
473
+ * component and the socket (`sameOriginRequired`), a same-origin request over the tunnel: a
474
+ * sibling host under the preview domain is the same SITE, so its pages carry the cookie. The
475
+ * page load that injects the loader is a navigation, often from the box's list, so it is not
476
+ * held to same-origin.
477
+ */
478
+ export declare function haltijaBridgeAdmission(request: {
479
+ url?: string;
480
+ headers: {
481
+ get(name: string): string | null;
482
+ };
483
+ }, opts: {
484
+ viaTunnel: boolean;
485
+ peer?: string | null;
486
+ auth: AuthState;
487
+ now: number;
488
+ sameOriginRequired: boolean;
380
489
  }): boolean;
381
490
  /**
382
491
  * THE loopback test. dev-server re-exports this as `isLoopbackAddress`; it used to