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 +38 -0
- package/README.md +8 -8
- package/dist/doc-system/site/dev-auth.d.ts +41 -0
- package/dist/doc-system/site/dev-auth.js +134 -0
- package/dist/doc-system/site/dev-server.js +149 -14
- package/dist/doc-system/site/site-config.d.ts +32 -0
- package/dist/hydrate/hydrate.js +2 -2
- package/dist/hydrate/hydrate.js.map +2 -2
- package/dist/iife.js +2 -2
- package/dist/iife.js.map +2 -2
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +3 -2
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
782
|
-
return new Response('
|
|
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
|