tosijs-ui 1.16.29 → 1.16.30
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 +18 -0
- package/dist/doc-system/site/dev-auth.d.ts +41 -3
- package/dist/doc-system/site/dev-auth.js +45 -4
- package/dist/doc-system/site/dev-server.d.ts +16 -0
- package/dist/doc-system/site/dev-server.js +56 -2
- package/dist/iife.js +2 -2
- package/dist/iife.js.map +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.16.30
|
|
4
|
+
|
|
5
|
+
### Tunnel
|
|
6
|
+
|
|
7
|
+
- **A dev-server restart no longer costs a key holder their next save.** `bun start` restarts
|
|
8
|
+
the server whenever a file in its import graph changes, and that includes every component
|
|
9
|
+
source, so editing an example restarts it. Save to source writes such a file, so the save
|
|
10
|
+
itself restarted the server, and the NEXT save from the same page was told "This workspace
|
|
11
|
+
needs an invite link". Reloading recovered the session but lost the unsaved edit. Now a
|
|
12
|
+
request from the workspace's own page that holds the global key cookie is given a fresh
|
|
13
|
+
session in place, and the haltija agent bridge's reconnection is too. "Own page" means
|
|
14
|
+
`Sec-Fetch-Site: same-origin` or an `Origin` that is this host; a sibling workspace's page,
|
|
15
|
+
which receives the same cookie, gets nothing, nor does a request with neither header. A
|
|
16
|
+
session opened from a plain `--link` holds no key, so a restart still ends it.
|
|
17
|
+
- **For a key holder, the key cookie now bounds access, not the session.** A key opens a
|
|
18
|
+
one-day session, but the cookie renews it, so access lasts as long as the cookie (30 days for
|
|
19
|
+
the edit key) or until you rotate the key and restart the dev servers.
|
|
20
|
+
|
|
3
21
|
## 1.16.29
|
|
4
22
|
|
|
5
23
|
### Doc system
|
|
@@ -422,13 +422,16 @@ export declare const GLOBAL_EDIT_COOKIE = "tosi_edit";
|
|
|
422
422
|
export declare const GLOBAL_KEY_MIN_LENGTH = 24;
|
|
423
423
|
/**
|
|
424
424
|
* A session minted from a global key lasts a day, or until this dev server stops (sessions
|
|
425
|
-
* live in memory).
|
|
426
|
-
*
|
|
425
|
+
* live in memory). While the key cookie lasts, the next page load or same-origin request mints
|
|
426
|
+
* another, so for a key holder it is the COOKIE's lifetime that ends access, not this. The key
|
|
427
|
+
* is NOT re-checked while a session lives, so rotating a key ends the sessions it already opened
|
|
428
|
+
* only when each workspace restarts.
|
|
427
429
|
*/
|
|
428
430
|
export declare const GLOBAL_KEY_SESSION_TTL_MS: number;
|
|
429
431
|
/**
|
|
430
432
|
* The most live sessions one server holds; past it the oldest goes. Only a key holder can mint
|
|
431
|
-
* one per page load,
|
|
433
|
+
* one: per page load, and per request from a page left open across a restart until its first
|
|
434
|
+
* renewed cookie lands. So this bounds a browser stuck reloading, not an attacker.
|
|
432
435
|
*/
|
|
433
436
|
export declare const MAX_SESSIONS = 1000;
|
|
434
437
|
export interface GlobalKeys {
|
|
@@ -483,6 +486,41 @@ export declare function globalKeyExchange(request: {
|
|
|
483
486
|
keys: GlobalKeys;
|
|
484
487
|
now: number;
|
|
485
488
|
}): 'edit' | 'view' | null;
|
|
489
|
+
/**
|
|
490
|
+
* Should a request from this workspace's OWN page, that is not a page load, trade a global key
|
|
491
|
+
* for a session in place?
|
|
492
|
+
*
|
|
493
|
+
* Sessions live in memory, and `bun start` restarts the process whenever a file in its import
|
|
494
|
+
* graph changes, which includes every component source and so every doc-comment example. Save
|
|
495
|
+
* to source writes exactly such a file. So the save restarts the server, and the next save from
|
|
496
|
+
* the same page arrived holding a dead session and was told "This workspace needs an invite
|
|
497
|
+
* link". A reload would have exchanged the key, and lost the unsaved edit (the maintainer,
|
|
498
|
+
* 2026-10-20: a restart must not reset a key holder).
|
|
499
|
+
*
|
|
500
|
+
* The answer is the page-load exchange's, with two extra conditions, because no redirect is
|
|
501
|
+
* involved: the session is issued and used on this same request.
|
|
502
|
+
* - Positive evidence that the request comes from this origin: `Sec-Fetch-Site: same-origin`,
|
|
503
|
+
* or, from a browser that sends no fetch metadata, an `Origin` that is this host. Absence of
|
|
504
|
+
* both is NOT enough here (unlike `isSameOriginRequest`, which allows it for curl and our
|
|
505
|
+
* own CLI, whose requests are not a browser spending an ambient cookie): every host under the preview domain
|
|
506
|
+
* is the same site and receives the key cookie, so a sibling workspace's page must not be
|
|
507
|
+
* able to spend it here.
|
|
508
|
+
*
|
|
509
|
+
* A WebSocket upgrade (the haltija bridge) qualifies too; browsers send `Origin` and
|
|
510
|
+
* `Sec-Fetch-Site` on the handshake, and the 101 carries the cookie.
|
|
511
|
+
*/
|
|
512
|
+
export declare function globalKeyRefresh(request: {
|
|
513
|
+
method: string;
|
|
514
|
+
headers: {
|
|
515
|
+
get(name: string): string | null;
|
|
516
|
+
};
|
|
517
|
+
}, opts: {
|
|
518
|
+
viaTunnel: boolean;
|
|
519
|
+
host: string;
|
|
520
|
+
auth: AuthState;
|
|
521
|
+
keys: GlobalKeys;
|
|
522
|
+
now: number;
|
|
523
|
+
}): 'edit' | 'view' | null;
|
|
486
524
|
/**
|
|
487
525
|
* The origin allowed to read this workspace's liveness answer: the box's own list.
|
|
488
526
|
*
|
|
@@ -867,13 +867,16 @@ export const GLOBAL_EDIT_COOKIE = 'tosi_edit';
|
|
|
867
867
|
export const GLOBAL_KEY_MIN_LENGTH = 24;
|
|
868
868
|
/**
|
|
869
869
|
* A session minted from a global key lasts a day, or until this dev server stops (sessions
|
|
870
|
-
* live in memory).
|
|
871
|
-
*
|
|
870
|
+
* live in memory). While the key cookie lasts, the next page load or same-origin request mints
|
|
871
|
+
* another, so for a key holder it is the COOKIE's lifetime that ends access, not this. The key
|
|
872
|
+
* is NOT re-checked while a session lives, so rotating a key ends the sessions it already opened
|
|
873
|
+
* only when each workspace restarts.
|
|
872
874
|
*/
|
|
873
875
|
export const GLOBAL_KEY_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
874
876
|
/**
|
|
875
877
|
* The most live sessions one server holds; past it the oldest goes. Only a key holder can mint
|
|
876
|
-
* one per page load,
|
|
878
|
+
* one: per page load, and per request from a page left open across a restart until its first
|
|
879
|
+
* renewed cookie lands. So this bounds a browser stuck reloading, not an attacker.
|
|
877
880
|
*/
|
|
878
881
|
export const MAX_SESSIONS = 1000;
|
|
879
882
|
/**
|
|
@@ -944,7 +947,10 @@ export function isNavigation(request) {
|
|
|
944
947
|
export function globalKeyExchange(request, opts) {
|
|
945
948
|
if (!opts.viaTunnel || !isNavigation(request))
|
|
946
949
|
return null;
|
|
947
|
-
|
|
950
|
+
return keyAddsAccess(request.headers.get('cookie'), opts);
|
|
951
|
+
}
|
|
952
|
+
/** What a global key would add to this request's session: nothing, or a session of a kind. */
|
|
953
|
+
function keyAddsAccess(header, opts) {
|
|
948
954
|
const access = globalKeyAccess(header, opts.keys);
|
|
949
955
|
if (!access)
|
|
950
956
|
return null;
|
|
@@ -955,6 +961,41 @@ export function globalKeyExchange(request, opts) {
|
|
|
955
961
|
}
|
|
956
962
|
return access;
|
|
957
963
|
}
|
|
964
|
+
/**
|
|
965
|
+
* Should a request from this workspace's OWN page, that is not a page load, trade a global key
|
|
966
|
+
* for a session in place?
|
|
967
|
+
*
|
|
968
|
+
* Sessions live in memory, and `bun start` restarts the process whenever a file in its import
|
|
969
|
+
* graph changes, which includes every component source and so every doc-comment example. Save
|
|
970
|
+
* to source writes exactly such a file. So the save restarts the server, and the next save from
|
|
971
|
+
* the same page arrived holding a dead session and was told "This workspace needs an invite
|
|
972
|
+
* link". A reload would have exchanged the key, and lost the unsaved edit (the maintainer,
|
|
973
|
+
* 2026-10-20: a restart must not reset a key holder).
|
|
974
|
+
*
|
|
975
|
+
* The answer is the page-load exchange's, with two extra conditions, because no redirect is
|
|
976
|
+
* involved: the session is issued and used on this same request.
|
|
977
|
+
* - Positive evidence that the request comes from this origin: `Sec-Fetch-Site: same-origin`,
|
|
978
|
+
* or, from a browser that sends no fetch metadata, an `Origin` that is this host. Absence of
|
|
979
|
+
* both is NOT enough here (unlike `isSameOriginRequest`, which allows it for curl and our
|
|
980
|
+
* own CLI, whose requests are not a browser spending an ambient cookie): every host under the preview domain
|
|
981
|
+
* is the same site and receives the key cookie, so a sibling workspace's page must not be
|
|
982
|
+
* able to spend it here.
|
|
983
|
+
*
|
|
984
|
+
* A WebSocket upgrade (the haltija bridge) qualifies too; browsers send `Origin` and
|
|
985
|
+
* `Sec-Fetch-Site` on the handshake, and the 101 carries the cookie.
|
|
986
|
+
*/
|
|
987
|
+
export function globalKeyRefresh(request, opts) {
|
|
988
|
+
if (!opts.viaTunnel || isNavigation(request))
|
|
989
|
+
return null;
|
|
990
|
+
const site = request.headers.get('sec-fetch-site');
|
|
991
|
+
const origin = request.headers.get('origin');
|
|
992
|
+
const ours = site !== null
|
|
993
|
+
? site === 'same-origin'
|
|
994
|
+
: origin !== null && origin === `https://${opts.host.toLowerCase()}`;
|
|
995
|
+
if (!ours)
|
|
996
|
+
return null;
|
|
997
|
+
return keyAddsAccess(request.headers.get('cookie'), opts);
|
|
998
|
+
}
|
|
958
999
|
/**
|
|
959
1000
|
* The origin allowed to read this workspace's liveness answer: the box's own list.
|
|
960
1001
|
*
|
|
@@ -150,6 +150,22 @@ export declare function haltijaTunnelLoaderSnippet(): string;
|
|
|
150
150
|
/** Same-origin paths the tunnel bridge serves. One place, so the loader and the routes agree. */
|
|
151
151
|
export declare const HALTIJA_BRIDGE_PREFIX = "/__haltija/";
|
|
152
152
|
export declare const HALTIJA_BRIDGE_WS = "/__haltija/ws";
|
|
153
|
+
/** The slice of a `Bun.Server` the request handler uses. */
|
|
154
|
+
export type Upgrader = {
|
|
155
|
+
requestIP?: (r: Request) => {
|
|
156
|
+
address?: string;
|
|
157
|
+
} | null;
|
|
158
|
+
upgrade?: (r: Request, opts?: {
|
|
159
|
+
data?: unknown;
|
|
160
|
+
headers?: Record<string, string>;
|
|
161
|
+
}) => boolean;
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* The same server, except that an accepted WebSocket upgrade also sets `setCookie` on its 101.
|
|
165
|
+
* How a renewed session reaches a browser whose request was a handshake (the haltija bridge),
|
|
166
|
+
* which has no Response of ours to carry it.
|
|
167
|
+
*/
|
|
168
|
+
export declare function upgraderSettingCookie(srv: Upgrader, setCookie: string): Upgrader;
|
|
153
169
|
export declare const HALTIJA_BRIDGE_COMPONENT = "/__haltija/component.js";
|
|
154
170
|
/** br if the client takes it, else gzip, else nothing. */
|
|
155
171
|
export declare function negotiateEncoding(accept: string | null): 'br' | 'gzip' | null;
|
|
@@ -22,7 +22,7 @@ import { resolveTunnelLocalPort } from './site-config.js';
|
|
|
22
22
|
import { acquireBuildLock, describeHolder } from './build-lock.js';
|
|
23
23
|
import { sinkPathFor, appendToSink } from './debug-sink.js';
|
|
24
24
|
import { invitePageHtml } from './invite-page.js';
|
|
25
|
-
import { TUNNEL_LINK_CMD, resolveLinkArrival, isLockedDown, hasTunnel, isLoopbackAddressForAuth as isLoopbackAddress, mayReadSite, shouldInterceptLinkToken, createAuthState, issueLink, globalKeyCookie, grantDomainFor, takeSessionGrant, outstandingGrants, MAX_OUTSTANDING_GRANTS, resolveShareTtlMs, readCookie, redeemThroughGate, createRedemptionGate, resolveLinkSettings, sessionCookie, urlWithoutToken, sameOriginPath, isSameOriginRequest, validSessionCookie, sessionRejection, haltijaBridgeAdmission, isProxiedRequest, sourceRequestAdmission, trustLanRefusalHint, trustLanWriteRecord, resolveLanTrust, describeLanTrust, resolveGlobalKeys, globalKeyAccess, globalKeyExchange, liveProbeOrigin, issueSession, cookieMayWrite, GLOBAL_KEY_SESSION_TTL_MS, SESSION_COOKIE, } from './dev-auth.js';
|
|
25
|
+
import { TUNNEL_LINK_CMD, resolveLinkArrival, isLockedDown, hasTunnel, isLoopbackAddressForAuth as isLoopbackAddress, mayReadSite, shouldInterceptLinkToken, createAuthState, issueLink, globalKeyCookie, grantDomainFor, takeSessionGrant, outstandingGrants, MAX_OUTSTANDING_GRANTS, resolveShareTtlMs, readCookie, redeemThroughGate, createRedemptionGate, resolveLinkSettings, sessionCookie, urlWithoutToken, sameOriginPath, isSameOriginRequest, validSessionCookie, sessionRejection, haltijaBridgeAdmission, isProxiedRequest, sourceRequestAdmission, trustLanRefusalHint, trustLanWriteRecord, resolveLanTrust, describeLanTrust, resolveGlobalKeys, globalKeyAccess, globalKeyExchange, globalKeyRefresh, liveProbeOrigin, issueSession, cookieMayWrite, GLOBAL_KEY_SESSION_TTL_MS, SESSION_COOKIE, } from './dev-auth.js';
|
|
26
26
|
import { readLocalSecret } from './local-secrets.js';
|
|
27
27
|
import { resolveDataStore, resolveDataFile, dataContentProblem, listDataFiles, saveDataFile, dataHistory, readDataAt, isGitWorkTree, } from './data-store.js';
|
|
28
28
|
import { SOURCE_ACCESS_HEADER, SOURCE_ENDPOINT_SNIPPET, } from '../../live-example/source-access.js';
|
|
@@ -361,6 +361,20 @@ export function haltijaTunnelLoaderSnippet() {
|
|
|
361
361
|
/** Same-origin paths the tunnel bridge serves. One place, so the loader and the routes agree. */
|
|
362
362
|
export const HALTIJA_BRIDGE_PREFIX = '/__haltija/';
|
|
363
363
|
export const HALTIJA_BRIDGE_WS = '/__haltija/ws';
|
|
364
|
+
/**
|
|
365
|
+
* The same server, except that an accepted WebSocket upgrade also sets `setCookie` on its 101.
|
|
366
|
+
* How a renewed session reaches a browser whose request was a handshake (the haltija bridge),
|
|
367
|
+
* which has no Response of ours to carry it.
|
|
368
|
+
*/
|
|
369
|
+
export function upgraderSettingCookie(srv, setCookie) {
|
|
370
|
+
return {
|
|
371
|
+
requestIP: (r) => srv.requestIP?.(r) ?? null,
|
|
372
|
+
upgrade: (r, opts) => srv.upgrade?.(r, {
|
|
373
|
+
...opts,
|
|
374
|
+
headers: { ...opts?.headers, 'Set-Cookie': setCookie },
|
|
375
|
+
}) ?? false,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
364
378
|
export const HALTIJA_BRIDGE_COMPONENT = '/__haltija/component.js';
|
|
365
379
|
/**
|
|
366
380
|
* Reclaim the port we are about to bind, from the process LISTENING on it.
|
|
@@ -1725,6 +1739,46 @@ export async function devServer(config, opts = {}) {
|
|
|
1725
1739
|
outright. Source and data writes check same-origin on both paths (sourceRequestAdmission).
|
|
1726
1740
|
*/
|
|
1727
1741
|
const csrfOk = (request, viaTunnel) => viaTunnel || isSameOriginRequest(request);
|
|
1742
|
+
/*
|
|
1743
|
+
A request from this workspace's own page that holds a global key but a dead session (the
|
|
1744
|
+
server restarted under it) is served with a fresh session, which its response hands back.
|
|
1745
|
+
See `globalKeyRefresh`. The session goes into the request's cookie header, so every gate in
|
|
1746
|
+
handleRequest sees a session and none of them has to know about keys. A WebSocket upgrade
|
|
1747
|
+
(the haltija bridge) carries the cookie on its 101, through `upgrade`'s headers.
|
|
1748
|
+
*/
|
|
1749
|
+
// Said once per process: a page left open across a restart renews on every request it makes.
|
|
1750
|
+
let renewalLogged = false;
|
|
1751
|
+
const withKeySession = async (request, srv, handle) => {
|
|
1752
|
+
const grant = globalKeyRefresh(request, {
|
|
1753
|
+
viaTunnel: true,
|
|
1754
|
+
host: request.headers.get('host') ?? '',
|
|
1755
|
+
auth,
|
|
1756
|
+
keys: globalKeys.keys,
|
|
1757
|
+
now: Date.now(),
|
|
1758
|
+
});
|
|
1759
|
+
if (!grant)
|
|
1760
|
+
return handle(request, srv);
|
|
1761
|
+
const session = issueSession(auth, Date.now(), GLOBAL_KEY_SESSION_TTL_MS, grant === 'view');
|
|
1762
|
+
const setCookie = sessionCookie(session, GLOBAL_KEY_SESSION_TTL_MS);
|
|
1763
|
+
const headers = new Headers(request.headers);
|
|
1764
|
+
const others = (headers.get('cookie') ?? '')
|
|
1765
|
+
.split(';')
|
|
1766
|
+
.map((c) => c.trim())
|
|
1767
|
+
.filter((c) => c && !c.startsWith(`${SESSION_COOKIE}=`));
|
|
1768
|
+
// The `name=value` pair the browser will send from now on.
|
|
1769
|
+
headers.set('cookie', [...others, setCookie.split(';')[0]].join('; '));
|
|
1770
|
+
if (!renewalLogged) {
|
|
1771
|
+
renewalLogged = true;
|
|
1772
|
+
console.log(`🔓 global ${grant} key — session renewed after a restart`);
|
|
1773
|
+
}
|
|
1774
|
+
const response = await handle(new Request(request, { headers }), srv && upgraderSettingCookie(srv, setCookie));
|
|
1775
|
+
// An accepted upgrade answers with nothing; its cookie went out on the 101.
|
|
1776
|
+
if (!response)
|
|
1777
|
+
return response;
|
|
1778
|
+
const refreshed = new Response(response.body, response);
|
|
1779
|
+
refreshed.headers.append('Set-Cookie', setCookie);
|
|
1780
|
+
return refreshed;
|
|
1781
|
+
};
|
|
1728
1782
|
const handleRequest = async (request, srv, viaTunnel) => {
|
|
1729
1783
|
/*
|
|
1730
1784
|
A REQUEST is activity. This was lost in the two-listener refactor, leaving the only
|
|
@@ -2490,7 +2544,7 @@ export async function devServer(config, opts = {}) {
|
|
|
2490
2544
|
// Same reasoning as the main listener — a tunnelled load is slower, not faster.
|
|
2491
2545
|
idleTimeout: DEV_IDLE_TIMEOUT_SECONDS,
|
|
2492
2546
|
hostname: '127.0.0.1',
|
|
2493
|
-
fetch: (request, srv) =>
|
|
2547
|
+
fetch: (request, srv) => withKeySession(request, srv, (r, s) => handleRequest(r, s, true)),
|
|
2494
2548
|
websocket: haltijaWebSocketHandlers,
|
|
2495
2549
|
});
|
|
2496
2550
|
console.log(`Tunnel listener on http://127.0.0.1:${tunnelPort} (loopback only; writes require a session)`);
|