tosijs-ui 1.9.0-beta.1 → 1.9.0-beta.2

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,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.0-beta.2
4
+
5
+ > Prerelease — install with `tosijs-ui@beta`. `latest` stays on 1.8.0.
6
+
7
+ **Remote editing, authenticated.** `bun run tunnel` exposes this machine's dev server at
8
+ a public URL; a single-use invite link is exchanged for a durable session cookie, and
9
+ editing through it is indistinguishable from sitting at the keyboard — same dev server,
10
+ same watcher, same files.
11
+
12
+ - **`bun run tunnel`** (`--status` / `--close` / `--link`). An SSH reverse tunnel to your
13
+ preview box. **The box does no compute** — it terminates TLS and checks a credential —
14
+ which is what lets one small VPS serve many projects. The work stays where the data is.
15
+ - **Magic-link auth.** Two tokens on purpose: the **link** rides in a URL so it is the
16
+ one that leaks (history, `Referer`, proxy logs, chat-app link previews) and is
17
+ therefore *single-use and 15 minutes*; the **session** never appears in a URL, arrives
18
+ as an `HttpOnly` cookie, and is durable. A durable token pasted into a URL would be
19
+ strictly worse than basic auth. `SameSite=Lax` gives the write endpoint free CSRF
20
+ protection.
21
+ - **`tunnel.requireToken`** — default `false`: the workspace renders for anyone with the
22
+ URL but **writes always need a session**, so a stranger gets a read-only page. Set
23
+ `true` to require a session even to view. The default means an expired link degrades
24
+ to a readable page rather than a wall — which is what you hit when you already hold a
25
+ session and open a second window.
26
+ - **Security fix: source endpoints are no longer exposed to your LAN.** `Bun.serve` binds
27
+ every interface, so on any shared network an unauthenticated `POST /__docstore/source`
28
+ was **remote code execution** — write a repo file, the watcher rebuilds, the build runs
29
+ it. Now: loopback, or a valid session.
30
+ - **Multi-project preview host.** Projects **self-register** — each deploy writes its own
31
+ Caddy fragment, so adding one touches no shared config and needs no DNS change. The
32
+ deploy validates before reloading and refuses on invalid config, so one bad fragment
33
+ can't break routing for everyone. The root serves a **generated index** of what's
34
+ deployed and which commit each is serving.
35
+ - **Invite links replace basic-auth dialogs** on the static preview hosts too. Knowingly
36
+ weaker than the workspace's auth (a shared bearer secret in a cookie, not a per-client
37
+ session) — Caddy serving static files has no session store — but equivalent to the
38
+ basicauth it replaces, with no dialog.
39
+
40
+
3
41
  ## 1.9.0-beta.1
4
42
 
5
43
  > **Prerelease.** The preview-host system works end to end and is in daily use, but the
package/README.md CHANGED
@@ -16,14 +16,14 @@
16
16
 
17
17
  Copyright ©2023-2025 Tonio Loewald
18
18
 
19
- ## ⚠️ Breaking change in 1.7.0 — `<tosi-code>` (ACE → CodeMirror 6)
20
-
21
- **1.7.0 is a breaking release shipping under a minor version, deliberately.** `<tosi-code>`
22
- moved from ACE to CodeMirror 6. `value`, `mode`, the `change` event, `disabled`, and
23
- `undo`/`redo` are unchanged; the ACE-era **`theme`** and **`options`** props are **removed** with
24
- no shim. Dark mode is now automatic (`body.darkmode`) and styling comes from `--code-bg` /
25
- `--text-color`. The `2.0` name is reserved for the tjs-native tosijs port, so this ships as
26
- 1.7.0 — **pin `tosijs-ui@1.6` to defer.** Full detail and rationale in
19
+ > ## ⚠️ Breaking change in 1.7.0 — `<tosi-code>` (ACE → CodeMirror 6)
20
+ >
21
+ > **1.7.0 is a breaking release shipping under a minor version, deliberately.** `<tosi-code>`
22
+ > moved from ACE to CodeMirror 6. `value`, `mode`, the `change` event, `disabled`, and
23
+ > `undo`/`redo` are unchanged; the ACE-era **`theme`** and **`options`** props are **removed** with
24
+ > no shim. Dark mode is now automatic (`body.darkmode`) and styling comes from `--code-bg` /
25
+ > `--text-color`. The `2.0` name is reserved for the tjs-native tosijs port, so this ships as
26
+ > 1.7.0 — **pin `tosijs-ui@1.6` to defer.** Full detail and rationale in
27
27
  [CHANGELOG.md](./CHANGELOG.md).
28
28
 
29
29
  ## the tosijs-ui library
@@ -0,0 +1,41 @@
1
+ /** A URL token is spent on first use, but must also age out if never used. */
2
+ export declare const LINK_TOKEN_TTL_MS: number;
3
+ /** Sessions are the durable half — long enough that you are not re-linking daily. */
4
+ export declare const SESSION_TTL_MS: number;
5
+ export declare const SESSION_COOKIE = "tosi_dev_session";
6
+ /** 128 bits, base64url — long enough that guessing is not a threat model. */
7
+ export declare function mintToken(): string;
8
+ /** Constant-time compare that tolerates unequal lengths without throwing. */
9
+ export declare function safeEqual(a: string, b: string): boolean;
10
+ export interface AuthState {
11
+ /** unspent link tokens → when they expire */
12
+ links: Map<string, number>;
13
+ /** live session tokens → when they expire */
14
+ sessions: Map<string, number>;
15
+ }
16
+ export declare function createAuthState(): AuthState;
17
+ /** Drop anything expired. Called on every use so the maps cannot grow without bound. */
18
+ export declare function prune(state: AuthState, now: number): void;
19
+ /** Issue a link token to put in a URL. */
20
+ export declare function issueLink(state: AuthState, now: number): string;
21
+ /**
22
+ * Spend a link token for a session token, or return null.
23
+ *
24
+ * Deleting BEFORE returning is what makes it single-use — and the delete happens
25
+ * whether or not the token had expired, so a replay of an expired token cannot linger.
26
+ */
27
+ export declare function redeemLink(state: AuthState, token: string, now: number): string | null;
28
+ /** Is this session token live? */
29
+ export declare function validSession(state: AuthState, token: string | undefined | null, now: number): boolean;
30
+ /** Pull one cookie out of a Cookie header. */
31
+ export declare function readCookie(header: string | null | undefined, name: string): string | undefined;
32
+ /** The Set-Cookie value for a freshly minted session. See the header comment. */
33
+ export declare function sessionCookie(token: string, maxAgeMs?: number): string;
34
+ /**
35
+ * Strip the link token from a URL so the redirect target is clean.
36
+ *
37
+ * The point of the exchange is that the token stops existing in the address bar, in
38
+ * history, and in anything the browser sends onward. Leaving it on the redirect would
39
+ * defeat the entire design.
40
+ */
41
+ export declare function urlWithoutToken(rawUrl: string, param: string): string;
@@ -0,0 +1,134 @@
1
+ /*
2
+ Magic-link auth for the dev server, so a workspace exposed through `bun run tunnel`
3
+ can be edited from anywhere.
4
+
5
+ TWO TOKENS, ON PURPOSE.
6
+
7
+ LINK token — travels in a URL (`https://…/?t=…`). Therefore it is the one that
8
+ LEAKS: browser history, Referer headers, reverse-proxy access logs,
9
+ link previews in chat apps that fetch what you paste, and anyone
10
+ reading over your shoulder. So it is SINGLE-USE and short-lived: the
11
+ first request spends it, and a copy scraped out of a log later is
12
+ worthless.
13
+
14
+ SESSION token — never appears in a URL. Set as an HttpOnly cookie by the exchange
15
+ and sent automatically thereafter, so it can be durable without being
16
+ exposed. This is the one that actually authorises writes.
17
+
18
+ That asymmetry is the whole design. A single durable token pasted into a URL would be
19
+ strictly WORSE than basic auth: it would sit in five different logs forever. Exchanging
20
+ it immediately for a cookie, then redirecting to a clean URL, is what makes "just send
21
+ me a link" both convenient and defensible.
22
+
23
+ Cookie flags, and why each:
24
+ HttpOnly — script cannot read it, so an XSS in a rendered doc cannot exfil it
25
+ Secure — never sent over plaintext
26
+ SameSite=Lax — sent on top-level GET navigation (so clicking your link works),
27
+ NOT on cross-site POST. That last part is free CSRF protection for
28
+ the write endpoint: another origin cannot make your browser POST
29
+ to /__docstore/source with your cookie attached.
30
+ Path=/ — the whole workspace, since editing spans the site
31
+
32
+ Build-time only. Never import this from browser code.
33
+ */
34
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
35
+ /** A URL token is spent on first use, but must also age out if never used. */
36
+ export const LINK_TOKEN_TTL_MS = 15 * 60 * 1000;
37
+ /** Sessions are the durable half — long enough that you are not re-linking daily. */
38
+ export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
39
+ export const SESSION_COOKIE = 'tosi_dev_session';
40
+ /** 128 bits, base64url — long enough that guessing is not a threat model. */
41
+ export function mintToken() {
42
+ return randomBytes(16).toString('base64url');
43
+ }
44
+ /** Constant-time compare that tolerates unequal lengths without throwing. */
45
+ export function safeEqual(a, b) {
46
+ const ab = Buffer.from(a);
47
+ const bb = Buffer.from(b);
48
+ if (ab.length !== bb.length)
49
+ return false;
50
+ return timingSafeEqual(ab, bb);
51
+ }
52
+ export function createAuthState() {
53
+ return { links: new Map(), sessions: new Map() };
54
+ }
55
+ /** Drop anything expired. Called on every use so the maps cannot grow without bound. */
56
+ export function prune(state, now) {
57
+ for (const [t, exp] of state.links)
58
+ if (exp <= now)
59
+ state.links.delete(t);
60
+ for (const [t, exp] of state.sessions)
61
+ if (exp <= now)
62
+ state.sessions.delete(t);
63
+ }
64
+ /** Issue a link token to put in a URL. */
65
+ export function issueLink(state, now) {
66
+ prune(state, now);
67
+ const token = mintToken();
68
+ state.links.set(token, now + LINK_TOKEN_TTL_MS);
69
+ return token;
70
+ }
71
+ /**
72
+ * Spend a link token for a session token, or return null.
73
+ *
74
+ * Deleting BEFORE returning is what makes it single-use — and the delete happens
75
+ * whether or not the token had expired, so a replay of an expired token cannot linger.
76
+ */
77
+ export function redeemLink(state, token, now) {
78
+ prune(state, now);
79
+ // Constant-time lookup over the (tiny) set rather than Map.get, so a timing signal
80
+ // can't distinguish "no such token" from "wrong token".
81
+ let matched = null;
82
+ for (const candidate of state.links.keys()) {
83
+ if (safeEqual(candidate, token))
84
+ matched = candidate;
85
+ }
86
+ if (matched === null)
87
+ return null;
88
+ state.links.delete(matched);
89
+ const session = mintToken();
90
+ state.sessions.set(session, now + SESSION_TTL_MS);
91
+ return session;
92
+ }
93
+ /** Is this session token live? */
94
+ export function validSession(state, token, now) {
95
+ if (!token)
96
+ return false;
97
+ prune(state, now);
98
+ for (const candidate of state.sessions.keys()) {
99
+ if (safeEqual(candidate, token))
100
+ return true;
101
+ }
102
+ return false;
103
+ }
104
+ /** Pull one cookie out of a Cookie header. */
105
+ export function readCookie(header, name) {
106
+ if (!header)
107
+ return undefined;
108
+ for (const part of header.split(';')) {
109
+ const eq = part.indexOf('=');
110
+ if (eq < 0)
111
+ continue;
112
+ if (part.slice(0, eq).trim() === name)
113
+ return part.slice(eq + 1).trim();
114
+ }
115
+ return undefined;
116
+ }
117
+ /** The Set-Cookie value for a freshly minted session. See the header comment. */
118
+ export function sessionCookie(token, maxAgeMs = SESSION_TTL_MS) {
119
+ return (`${SESSION_COOKIE}=${token}; Max-Age=${Math.floor(maxAgeMs / 1000)}; ` +
120
+ `Path=/; HttpOnly; Secure; SameSite=Lax`);
121
+ }
122
+ /**
123
+ * Strip the link token from a URL so the redirect target is clean.
124
+ *
125
+ * The point of the exchange is that the token stops existing in the address bar, in
126
+ * history, and in anything the browser sends onward. Leaving it on the redirect would
127
+ * defeat the entire design.
128
+ */
129
+ export function urlWithoutToken(rawUrl, param) {
130
+ const url = new URL(rawUrl);
131
+ url.searchParams.delete(param);
132
+ const qs = url.searchParams.toString();
133
+ return url.pathname + (qs ? `?${qs}` : '') + url.hash;
134
+ }
@@ -15,6 +15,7 @@ import { buildSite } from './orchestrator';
15
15
  import { preflight } from './preflight';
16
16
  import { auditDependencies, reportAudit } from './audit-guard';
17
17
  import { openDevBrowser } from './open-browser';
18
+ import { createAuthState, issueLink, readCookie, redeemLink, sessionCookie, urlWithoutToken, validSession, SESSION_COOKIE, } from './dev-auth';
18
19
  const TEST_RESULTS_FILE = '.browser-tests.json';
19
20
  const DEFAULT_IDLE_HOURS = 8;
20
21
  /**
@@ -310,6 +311,28 @@ export async function devServer(config, opts = {}) {
310
311
  statusSnippet() below and the `__tosiDevStatus` reader in doc-browser.ts.
311
312
  */
312
313
  let buildStatus = { ok: true };
314
+ /*
315
+ Magic-link auth, so a workspace exposed via `bun run tunnel` can be edited from
316
+ anywhere. A LINK token rides in a URL and is spent on first use; it is exchanged for
317
+ a durable SESSION cookie that never appears in a URL. See dev-auth.ts for why that
318
+ asymmetry is the whole design.
319
+
320
+ In memory on purpose: a dev server restart invalidating sessions is a feature, not a
321
+ gap — the credential's lifetime should not outlive the process that granted it, and
322
+ re-linking is one command.
323
+ */
324
+ const auth = createAuthState();
325
+ const LINK_PARAM = 't';
326
+ /** Print a fresh single-use link. Called on demand (SIGUSR2) and by --link. */
327
+ const printLink = () => {
328
+ const token = issueLink(auth, Date.now());
329
+ const base = config.preview?.tunnel?.url ?? `https://localhost:${PORT}`;
330
+ const url = `${base}/?${LINK_PARAM}=${token}`;
331
+ console.log(`\n🔗 Single-use edit link (valid 15 min, then it is spent):\n ${url}\n`);
332
+ return url;
333
+ };
334
+ // `kill -USR2 <pid>` prints a fresh link without restarting the server.
335
+ process.on('SIGUSR2', () => printLink());
313
336
  let testReportResolve;
314
337
  // Source read/write for in-browser "edit page source" (config.editableSources).
315
338
  // Local dev only — your machine, your files — so the lone guard is correctness:
@@ -746,6 +769,88 @@ export async function devServer(config, opts = {}) {
746
769
  },
747
770
  async fetch(request, srv) {
748
771
  touch();
772
+ /*
773
+ TWO POSTURES, because projects differ.
774
+
775
+ DEFAULT — read open, write gated. Without a session the site RENDERS: read the
776
+ docs, click around, run live examples. You just cannot change anything. This is
777
+ usually what you want, for two reasons: the read-only view is the thing you most
778
+ often hand someone, and an expired link degrades to a readable page rather than a
779
+ wall — which matters when you already hold a session and open a second window or
780
+ a second device.
781
+
782
+ `tunnel.requireToken: true` — locked. No session, nothing at all, including the
783
+ page. For work that must not be readable by whoever finds the hostname.
784
+
785
+ Either way WRITES always need a session (see /__docstore/source below). The
786
+ option only moves where READING sits.
787
+ */
788
+ /*
789
+ Magic-link exchange. A `?t=` on ANY path is spent immediately for a session
790
+ cookie, then we 302 to the same URL with the token stripped.
791
+
792
+ Redirecting is not cosmetic: it is what stops the token existing in the address
793
+ bar, in history, and in the Referer of anything the page subsequently loads.
794
+ `Referrer-Policy: no-referrer` covers the one request that DID carry it.
795
+
796
+ An invalid or already-spent token is not an error — it just falls through
797
+ unauthenticated, so a stale link behaves like no link rather than leaking whether
798
+ that token was ever real.
799
+ */
800
+ const reqUrl = new URL(request.url);
801
+ const linkToken = reqUrl.searchParams.get(LINK_PARAM);
802
+ /*
803
+ PROXIED REQUESTS NEED A SESSION FOR EVERYTHING, not just for writing.
804
+
805
+ The workspace mirrors dev, so it carries unreleased work — viewing it is as
806
+ private as editing it. Gating only the write endpoint would have left the whole
807
+ site readable to anyone who found the hostname.
808
+
809
+ This is also what removes the basic-auth dialog. Putting basicauth in front
810
+ defeated the point of a magic link: you clicked the link and the browser asked
811
+ for a password anyway. One credential, one prompt-free click.
812
+
813
+ Redemption itself must stay reachable without a session — it is how you GET one.
814
+ Direct (unproxied) requests are unaffected: at this keyboard, the site is just
815
+ the dev server it has always been.
816
+ */
817
+ const viaProxy = request.headers.get('x-forwarded-for') !== null ||
818
+ request.headers.get('x-forwarded-host') !== null;
819
+ const lockedDown = config.preview?.tunnel?.requireToken === true;
820
+ if (lockedDown && viaProxy && !linkToken) {
821
+ const cookie = readCookie(request.headers.get('cookie'), SESSION_COOKIE);
822
+ if (!validSession(auth, cookie, Date.now())) {
823
+ return new Response(`<!doctype html><meta charset=utf-8>` +
824
+ `<title>Link required</title>` +
825
+ `<style>body{font:16px/1.6 system-ui;margin:15vh auto;max-width:30rem;padding:0 1.5rem;color:#222}` +
826
+ `@media(prefers-color-scheme:dark){body{background:#16171a;color:#e8e8ea}}` +
827
+ `code{background:#8881;padding:.1em .4em;border-radius:4px}</style>` +
828
+ `<h1>This workspace needs an invite link</h1>` +
829
+ `<p>Ask for a fresh one — they are single-use and expire after 15 minutes.</p>` +
830
+ `<p><code>bun run tunnel --link</code></p>`, {
831
+ status: 401,
832
+ headers: {
833
+ 'Content-Type': 'text/html; charset=utf-8',
834
+ 'Cache-Control': 'no-store',
835
+ 'Referrer-Policy': 'no-referrer',
836
+ },
837
+ });
838
+ }
839
+ }
840
+ if (linkToken) {
841
+ const session = redeemLink(auth, linkToken, Date.now());
842
+ const clean = urlWithoutToken(request.url, LINK_PARAM);
843
+ const headers = {
844
+ Location: clean,
845
+ 'Referrer-Policy': 'no-referrer',
846
+ 'Cache-Control': 'no-store',
847
+ };
848
+ if (session) {
849
+ headers['Set-Cookie'] = sessionCookie(session);
850
+ console.log('🔓 edit link redeemed — session issued');
851
+ }
852
+ return new Response(null, { status: 302, headers });
853
+ }
749
854
  let reqPath = new URL(request.url).pathname;
750
855
  console.log(request.method, reqPath);
751
856
  if (request.method === 'POST' && reqPath === '/report') {
@@ -764,22 +869,52 @@ export async function devServer(config, opts = {}) {
764
869
  if (!config.editableSources) {
765
870
  return new Response('editableSources is not enabled in this doc-site config (set editableSources: true to edit/save source in dev)', { status: 501, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
766
871
  }
767
- // LOOPBACK ONLY. Reading exposes any file in the repo; writing is remote code
768
- // execution (write a file, the watcher rebuilds, the build runs it). Checked on
769
- // the PEER ADDRESS, never on Origin/Host, which are attacker-supplied.
770
- //
771
- // There is deliberately NO override. An env var that re-enables this would have
772
- // no legitimate use today — nothing authenticates in front of this server — and
773
- // it is exactly the shape of guard that gets set once in a shell profile and
774
- // stays off forever. Remote editing is not a flag to flip; it needs real
775
- // authentication, and that is Phase 3 of REMOTE-ACCESS-PLAN.md, where the
776
- // schema is the authorization boundary and a session (not a location, and not
777
- // an env var) decides who may write. Until then: view remotely, edit locally.
872
+ /*
873
+ LOOPBACK **OR** AN AUTHENTICATED SESSION.
874
+
875
+ Reading serves any file in the repo; writing is remote code execution (write a
876
+ file, the watcher rebuilds, the build runs it). So the caller must be one of:
877
+
878
+ - physically local (you, at this keyboard), or
879
+ - holding a session cookie earned by spending a single-use link.
880
+
881
+ Note there is still no env-var override and no token read from a URL or a
882
+ header a page could forge: the session arrives as an HttpOnly cookie the
883
+ browser attaches itself, and SameSite=Lax means another origin cannot make
884
+ your browser POST here with it. A location check alone was a stopgap; this is
885
+ the actual authorization.
886
+ */
778
887
  const peer = srv?.requestIP?.(request)?.address;
779
- if (!isLoopbackAddress(peer)) {
888
+ const session = readCookie(request.headers.get('cookie'), SESSION_COOKIE);
889
+ /*
890
+ A TUNNELLED REQUEST LOOKS LOOPBACK, so the peer address alone cannot be trusted
891
+ here. `ssh -R` delivers to localhost, so every remote request arriving through
892
+ the tunnel has a 127.0.0.1 peer — meaning a naive `loopback OR session` lets
893
+ anyone past the proxy write files WITHOUT a session, and the cookie buys
894
+ nothing. (Caught by a test that did exactly that.)
895
+
896
+ So split on whether the request was PROXIED. Caddy's reverse_proxy sets
897
+ X-Forwarded-*; a browser on this machine talking to the dev server directly
898
+ does not. Therefore:
899
+
900
+ proxied → a valid session is REQUIRED (loopback is meaningless here)
901
+ direct → loopback peer is sufficient (you are at this keyboard)
902
+
903
+ Trusting a header is normally a mistake, and it is safe here only because of
904
+ the topology: the tunnel's remote end is bound to the box's loopback
905
+ (`GatewayPorts no`), so nothing reaches this socket from outside except through
906
+ Caddy — which sets the header itself. A remote caller cannot arrive without it.
907
+ And forging it can only ever make the check STRICTER, never weaker.
908
+ */
909
+ const proxied = request.headers.get('x-forwarded-for') !== null ||
910
+ request.headers.get('x-forwarded-host') !== null;
911
+ const authorized = proxied
912
+ ? validSession(auth, session, Date.now())
913
+ : isLoopbackAddress(peer);
914
+ if (!authorized) {
780
915
  console.warn(`⚠️ refused ${request.method} /__docstore/source from ${peer ?? 'unknown'} — ` +
781
- `source editing is local-only.`);
782
- return new Response('source editing is restricted to this machine', {
916
+ `no session (use \`bun run tunnel --link\` for an edit link).`);
917
+ return new Response('not authorized to edit source', {
783
918
  status: 403,
784
919
  headers: { 'Content-Type': 'text/plain; charset=utf-8' },
785
920
  });
@@ -315,6 +315,38 @@ export interface SiteConfig {
315
315
  path?: string;
316
316
  /** public URL, printed after a successful deploy (e.g. `https://dev.example.com`) */
317
317
  url?: string;
318
+ /**
319
+ * Expose THIS machine's dev server at an authenticated public URL, via an SSH
320
+ * reverse tunnel to `host` (`bun run tunnel`).
321
+ *
322
+ * The box does no compute — it terminates TLS and checks a password. The work
323
+ * stays where the data is, which is what lets one small VPS serve many projects.
324
+ *
325
+ * Safe to expose despite the dev server's source endpoints being loopback-only:
326
+ * the box's sshd runs `GatewayPorts no`, so the forwarded port is bound to its
327
+ * loopback and only the authenticating proxy can reach it — and `ssh -R` delivers
328
+ * to `localhost` here, so the dev server sees a loopback peer. Reaching that
329
+ * socket at all required passing the front door.
330
+ */
331
+ tunnel?: {
332
+ /** port to bind on the remote box (loopback there); default 9787 */
333
+ remotePort?: number;
334
+ /** the authenticated public URL that fronts it */
335
+ url?: string;
336
+ /**
337
+ * Require a valid session even to VIEW the tunnelled workspace.
338
+ *
339
+ * Default `false`: the site renders for anyone who has the URL, but **writes
340
+ * always need a session** — so a stranger sees a read-only page and cannot
341
+ * change anything. That is usually right, and it means an expired link degrades
342
+ * to a readable page instead of a wall (which matters when you already hold a
343
+ * session and open a second window or another device).
344
+ *
345
+ * Set `true` for maximum security: no session, nothing at all — not even the
346
+ * page. Use it when the work must not be readable by whoever finds the hostname.
347
+ */
348
+ requireToken?: boolean;
349
+ };
318
350
  };
319
351
  /**
320
352
  * Enable the dev-server source read/write endpoints (`/__docstore/source`) that