tosijs-ui 1.8.0 → 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 +92 -0
- package/README.md +8 -8
- package/dist/doc-browser.js +44 -0
- package/dist/doc-system/site/build-stamp.d.ts +23 -0
- package/dist/doc-system/site/build-stamp.js +65 -0
- 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.d.ts +18 -0
- package/dist/doc-system/site/dev-server.js +281 -9
- package/dist/doc-system/site/orchestrator.js +6 -0
- package/dist/doc-system/site/site-config.d.ts +57 -0
- package/dist/hydrate/hydrate.js +54 -53
- package/dist/hydrate/hydrate.js.map +4 -4
- package/dist/iife.js +72 -71
- package/dist/iife.js.map +4 -4
- 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 +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,87 @@
|
|
|
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
|
+
|
|
41
|
+
## 1.9.0-beta.1
|
|
42
|
+
|
|
43
|
+
> **Prerelease.** The preview-host system works end to end and is in daily use, but the
|
|
44
|
+
> plan it belongs to (`REMOTE-ACCESS-PLAN.md`) has two more phases. Published under the
|
|
45
|
+
> `beta` dist-tag — **install with `tosijs-ui@beta`**; `latest` stays on 1.8.0.
|
|
46
|
+
>
|
|
47
|
+
> It's a **minor**, not a patch, despite being unfinished: it adds public API
|
|
48
|
+
> (`SiteConfig.preview`), emits a new artifact into every adopter's output
|
|
49
|
+
> (`/version.json`), and changes what happens when a build fails. Semver tracks the
|
|
50
|
+
> shape of a change, not how done it feels.
|
|
51
|
+
|
|
52
|
+
- **New: preview hosting.** `bun run deploy` rsyncs the built site to a box you control,
|
|
53
|
+
so a phone, a client, or a reviewer can see it without your dev server — or your
|
|
54
|
+
laptop — being up. The whole artifact is a few MB, so this is a copy, not a pipeline.
|
|
55
|
+
- **`preview: { host, path?, url? }`** in the site config. Only `host` is required;
|
|
56
|
+
`path` defaults to `/srv/preview/<name>`. Flag > env > config, so a one-off push
|
|
57
|
+
elsewhere doesn't need a config edit.
|
|
58
|
+
- **Dry run by default.** It runs `rsync --delete` (the remote must mirror the build,
|
|
59
|
+
stale pages must not linger), so the destructive form has to be typed: `--go`. It
|
|
60
|
+
also refuses any target that isn't an absolute path at least two levels deep.
|
|
61
|
+
- **Projects self-register.** Each deploy writes its own Caddy fragment; the server
|
|
62
|
+
glob-imports them. Adding a project touches no shared config and needs no DNS
|
|
63
|
+
change. The deploy **validates before reloading** and refuses to reload on invalid
|
|
64
|
+
config, so one bad fragment can't take down every project on the box.
|
|
65
|
+
- `deploy/Caddyfile` and `deploy/build-index.sh` are committed — server config in git
|
|
66
|
+
beats server config in someone's shell history.
|
|
67
|
+
- **New: `/version.json`** in every build — `{ generator, site, commit, commitTime }`.
|
|
68
|
+
Nothing exposed which commit produced a given site before. It matters wherever a
|
|
69
|
+
deploy is a snapshot: a reviewer can otherwise report a bug you fixed this morning
|
|
70
|
+
with no way to tell from the page which of you is stale.
|
|
71
|
+
**Deliberately deterministic** — no build timestamp, no dirty flag — so a committed
|
|
72
|
+
output dir doesn't churn on every build. Git fields are omitted (never blank) when
|
|
73
|
+
git isn't available.
|
|
74
|
+
- **Fix: a failed rebuild no longer destroys the site.** `buildSite()` opens with
|
|
75
|
+
`rm -rf <outputDir>`, so a build failing after that left nothing to serve — save a
|
|
76
|
+
typo, and the next refresh was a dead site. The dev server now moves the working
|
|
77
|
+
build aside first and restores it on failure, **and says so on the page**: the
|
|
78
|
+
existing floating widget (which already means "the far end has something to tell
|
|
79
|
+
you") shows the failure until a good build clears it, with detail in the console.
|
|
80
|
+
Note it survives the "tests disabled" preference — otherwise the one person who
|
|
81
|
+
turned test indicators off is the one who never learns their build is broken.
|
|
82
|
+
- **New: `DEV_NO_WATCH=1`** serves the built site without watching, for automated
|
|
83
|
+
suites that want a server rather than a rebuilder.
|
|
84
|
+
|
|
3
85
|
## 1.8.0
|
|
4
86
|
|
|
5
87
|
> **Read this first if you use `tosijs-ui/site`.** This release adds a **dependency-audit
|
|
@@ -47,6 +129,16 @@
|
|
|
47
129
|
filename with a raw NUL byte, which makes macOS `grep` silently match **nothing** in that
|
|
48
130
|
file (exit 1, no message) — so searching it for any symbol came back empty and the sort bug
|
|
49
131
|
above was effectively unfindable. The comparator needs no delimiter; the NUL is gone.
|
|
132
|
+
- **Adopting the gate — budget for one upgrade, not one pin.** From the first real
|
|
133
|
+
adoption (`tosijs`, which went from 12 blocking advisories to zero): if the gate flags
|
|
134
|
+
**`brace-expansion`**, note that `GHSA-mh99-v99m-4gvg` marks *every* version below
|
|
135
|
+
**5.0.8** affected, so the override is mandatory — and `brace-expansion@5` breaks
|
|
136
|
+
**eslint 8**'s bundled `minimatch@3` with `expand is not a function`. In practice that
|
|
137
|
+
means **an eslint 8 → 10 migration** (flat config + typescript-eslint 8), not a one-line
|
|
138
|
+
pin. The upside is that it clears the whole `minimatch`/`js-yaml`/`flatted` cluster at
|
|
139
|
+
once — which is exactly what the advisories-per-package tally is telling you when it
|
|
140
|
+
fingers a stale toolchain. Expect "replace, don't patch" to be the right answer more
|
|
141
|
+
often than a targeted override.
|
|
50
142
|
- **New: `openBrowser`** site-config option — opens the doc site on dev-server start, reusing
|
|
51
143
|
an existing tab on macOS rather than piling up new ones.
|
|
52
144
|
- **New: `DEV_NO_WATCH=1`** serves the built site without watching. The E2E lane used to run
|
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
|
package/dist/doc-browser.js
CHANGED
|
@@ -191,6 +191,10 @@ const testIndicatorStyleSpec = {
|
|
|
191
191
|
'body:not(.tests-enabled) .doc-link::after, body:not(.tests-enabled) .test-widget': {
|
|
192
192
|
display: 'none !important',
|
|
193
193
|
},
|
|
194
|
+
// ...EXCEPT a far-end status (a failed build). That is not a test result, so the
|
|
195
|
+
// "show test indicators" preference must not suppress it — otherwise the one
|
|
196
|
+
// person who turned tests off is the one who never learns their build is broken.
|
|
197
|
+
'body .test-widget.-dev-status': { display: 'flex !important' },
|
|
194
198
|
// Nav link dot indicators
|
|
195
199
|
'.doc-link.-test-passed::after, .doc-link.-test-failed::after': {
|
|
196
200
|
content: "''",
|
|
@@ -1210,8 +1214,48 @@ export function createDocBrowser(options) {
|
|
|
1210
1214
|
onClick: showTestMenu,
|
|
1211
1215
|
}, span({ part: 'label' }, 'Tests'), span({ class: 'count', part: 'count' }, '0'));
|
|
1212
1216
|
container.appendChild(testWidget);
|
|
1217
|
+
/*
|
|
1218
|
+
The far end has something to tell you.
|
|
1219
|
+
|
|
1220
|
+
The dev server injects `window.__tosiDevStatus` at serve time when a rebuild has
|
|
1221
|
+
FAILED — see statusSnippet() in site/dev-server.ts. That is the same kind of news
|
|
1222
|
+
as a failing browser test (something broke; here is what), so it uses the same
|
|
1223
|
+
floating widget rather than a second overlay competing for the same corner.
|
|
1224
|
+
|
|
1225
|
+
Two differences from a test run, both deliberate:
|
|
1226
|
+
- it does NOT auto-fade. A passing test run is over; a broken build is a standing
|
|
1227
|
+
condition you have to act on, and it stays until a good build clears it.
|
|
1228
|
+
- it wins ties. If tests also failed, the build failure is the more useful thing
|
|
1229
|
+
to read first — the tests probably failed BECAUSE the build did.
|
|
1230
|
+
|
|
1231
|
+
Nothing is injected when the server is healthy, so this is inert in production and
|
|
1232
|
+
in a normal session.
|
|
1233
|
+
*/
|
|
1234
|
+
const devStatus = globalThis.__tosiDevStatus;
|
|
1235
|
+
let devStatusShown = false;
|
|
1236
|
+
if (devStatus && devStatus.ok === false) {
|
|
1237
|
+
devStatusShown = true;
|
|
1238
|
+
const labelEl = testWidget.querySelector('[part="label"]');
|
|
1239
|
+
const countEl = testWidget.querySelector('[part="count"]');
|
|
1240
|
+
if (labelEl)
|
|
1241
|
+
labelEl.textContent = devStatus.label || 'Build failed';
|
|
1242
|
+
if (countEl)
|
|
1243
|
+
countEl.hidden = true;
|
|
1244
|
+
testWidget.classList.add('-failed', '-dev-status');
|
|
1245
|
+
testWidget.hidden = false;
|
|
1246
|
+
testWidget.title = devStatus.detail || '';
|
|
1247
|
+
// The page you are reading is the LAST GOOD build — the dev server keeps serving
|
|
1248
|
+
// it rather than the wreckage of the failed one — so say so plainly in the
|
|
1249
|
+
// console, where the detail is readable and copyable.
|
|
1250
|
+
console.error(`[tosi] ${devStatus.label || 'Build failed'} — showing the last good build.\n` +
|
|
1251
|
+
(devStatus.detail || ''));
|
|
1252
|
+
}
|
|
1213
1253
|
let testsRunning = false;
|
|
1214
1254
|
function setTestWidgetRunning() {
|
|
1255
|
+
// A broken build outranks a test run: the tests very likely failed BECAUSE the
|
|
1256
|
+
// build did, so replacing "Build failed" with "Tests" would hide the cause.
|
|
1257
|
+
if (devStatusShown)
|
|
1258
|
+
return;
|
|
1215
1259
|
testsRunning = true;
|
|
1216
1260
|
testWidget.hidden = false;
|
|
1217
1261
|
testWidget.classList.remove('-passed', '-failed');
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface BuildStamp {
|
|
2
|
+
/** the tosijs-ui version that generated this site */
|
|
3
|
+
generator: string;
|
|
4
|
+
/** the project's name, from site config */
|
|
5
|
+
site?: string;
|
|
6
|
+
/** short commit hash the site was built from, when available */
|
|
7
|
+
commit?: string;
|
|
8
|
+
/** ISO-8601 commit timestamp, when available */
|
|
9
|
+
commitTime?: string;
|
|
10
|
+
}
|
|
11
|
+
/** Injectable git seam for tests. Returns '' when the command can't run. */
|
|
12
|
+
export type GitReader = (args: string[]) => Promise<string>;
|
|
13
|
+
/**
|
|
14
|
+
* Collect build identity. Git fields are omitted (not blank) when unavailable, so
|
|
15
|
+
* `version.json` never asserts something it doesn't know.
|
|
16
|
+
*/
|
|
17
|
+
export declare function gatherBuildStamp(opts?: {
|
|
18
|
+
generator: string;
|
|
19
|
+
site?: string;
|
|
20
|
+
git?: GitReader;
|
|
21
|
+
}): Promise<BuildStamp>;
|
|
22
|
+
/** Serialize for `/version.json` (trailing newline so the file is diff-friendly). */
|
|
23
|
+
export declare function serializeBuildStamp(stamp: BuildStamp): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Build identity for a generated site: `/version.json`.
|
|
3
|
+
|
|
4
|
+
Answers one question — **what am I looking at?** — for any deployed copy of the site.
|
|
5
|
+
Nothing in the build exposed this before: `src/version.ts` is the *library* version,
|
|
6
|
+
which says nothing about which commit of the CONSUMER's project produced a given
|
|
7
|
+
build.
|
|
8
|
+
|
|
9
|
+
That question is easy to shrug at locally and genuinely expensive elsewhere. A static
|
|
10
|
+
deploy is a snapshot, so a preview host serves whatever was last pushed to it; someone
|
|
11
|
+
reviews it, reports a bug you fixed this morning, and there is no way to tell from the
|
|
12
|
+
page which of you is out of date. Same for a published doc site after a partial deploy.
|
|
13
|
+
|
|
14
|
+
DELIBERATELY DETERMINISTIC — no build timestamp.
|
|
15
|
+
|
|
16
|
+
`docs/` is committed in this repo (and in the other tosijs-* projects), so anything
|
|
17
|
+
that changes on every build shows up as a diff on every commit. A wall-clock stamp
|
|
18
|
+
would churn the tree forever and train everyone to ignore it — the same nuisance the
|
|
19
|
+
non-reproducible ePub already causes. Identity comes from the COMMIT instead, so
|
|
20
|
+
rebuilding the same source twice produces byte-identical output. That is also the more
|
|
21
|
+
useful answer: you want to know which source this is, not when someone happened to run
|
|
22
|
+
a build.
|
|
23
|
+
|
|
24
|
+
There is no `dirty` flag for the same reason. A build made from a dirty tree reports
|
|
25
|
+
its last commit, which may not describe what was actually built — so the warning
|
|
26
|
+
belongs at DEPLOY time, where a human can act on it, not baked into a committed file
|
|
27
|
+
that would then be permanently wrong.
|
|
28
|
+
|
|
29
|
+
Best-effort by construction: a consumer of `tosijs-ui/site` may not be in a git repo
|
|
30
|
+
at all, and a build must never fail because it couldn't read git metadata.
|
|
31
|
+
*/
|
|
32
|
+
import { $ } from 'bun';
|
|
33
|
+
const defaultGit = async (args) => {
|
|
34
|
+
try {
|
|
35
|
+
const r = await $ `git ${args}`.nothrow().quiet();
|
|
36
|
+
return r.exitCode === 0 ? r.stdout.toString().trim() : '';
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return ''; // git absent, not a repo, whatever — never fatal
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Collect build identity. Git fields are omitted (not blank) when unavailable, so
|
|
44
|
+
* `version.json` never asserts something it doesn't know.
|
|
45
|
+
*/
|
|
46
|
+
export async function gatherBuildStamp(opts = {
|
|
47
|
+
generator: 'unknown',
|
|
48
|
+
}) {
|
|
49
|
+
const git = opts.git ?? defaultGit;
|
|
50
|
+
const stamp = { generator: opts.generator };
|
|
51
|
+
if (opts.site)
|
|
52
|
+
stamp.site = opts.site;
|
|
53
|
+
const commit = await git(['rev-parse', '--short', 'HEAD']);
|
|
54
|
+
if (commit)
|
|
55
|
+
stamp.commit = commit;
|
|
56
|
+
// %cI is the committer date, strict ISO-8601 — stable for a given commit.
|
|
57
|
+
const commitTime = await git(['log', '-1', '--format=%cI']);
|
|
58
|
+
if (commitTime)
|
|
59
|
+
stamp.commitTime = commitTime;
|
|
60
|
+
return stamp;
|
|
61
|
+
}
|
|
62
|
+
/** Serialize for `/version.json` (trailing newline so the file is diff-friendly). */
|
|
63
|
+
export function serializeBuildStamp(stamp) {
|
|
64
|
+
return JSON.stringify(stamp, null, 2) + '\n';
|
|
65
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -38,6 +38,24 @@ export declare function resolveLimitMb(configMb: number | undefined, envMb: stri
|
|
|
38
38
|
* once per test page (N redundant loads in throwaway frames). An agent only ever
|
|
39
39
|
* drives the top page, so nested frames never need the channel.
|
|
40
40
|
*/
|
|
41
|
+
/**
|
|
42
|
+
* Is this request coming from THIS machine?
|
|
43
|
+
*
|
|
44
|
+
* The dev server binds every interface (`Bun.serve` with no `hostname`), which is
|
|
45
|
+
* deliberate — the mkcert dev cert covers `<host>.local` precisely so you can open the
|
|
46
|
+
* site on a phone or a second laptop. But "anyone on this network may READ the preview"
|
|
47
|
+
* and "anyone on this network may REWRITE my source files" are wildly different
|
|
48
|
+
* propositions, and the source endpoints were getting the first one's treatment.
|
|
49
|
+
*
|
|
50
|
+
* On any shared network — a café, a conference, a hotel — an unauthenticated
|
|
51
|
+
* `POST /__docstore/source` is remote code execution: it writes a file in the repo, the
|
|
52
|
+
* watcher rebuilds, and the build runs what was written. So those endpoints are
|
|
53
|
+
* loopback-only, while the site itself stays reachable from your other devices.
|
|
54
|
+
*
|
|
55
|
+
* IPv4-mapped IPv6 (`::ffff:127.0.0.1`) counts — that is how a v4 client shows up on a
|
|
56
|
+
* dual-stack listener, and missing it would lock out the local machine on some setups.
|
|
57
|
+
*/
|
|
58
|
+
export declare function isLoopbackAddress(address: string | undefined | null): boolean;
|
|
41
59
|
export declare function haltijaLoaderSnippet(httpsPort: number): string;
|
|
42
60
|
export declare function devServer(config: SiteConfig, opts?: {
|
|
43
61
|
test?: boolean;
|