@volter/world-core 3.0.29 → 3.0.31
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/dist/host-routing.cjs +106 -0
- package/dist/host-routing.d.cts +7 -0
- package/dist/inject.cjs +5 -8
- package/dist/src/actions.d.ts +3 -1
- package/dist/src/actions.js +4 -2
- package/dist/src/board-keys.d.ts +6 -0
- package/dist/src/board-keys.js +4 -0
- package/dist/src/browser-cookie-jar.d.ts +9 -0
- package/dist/src/browser-cookie-jar.js +104 -0
- package/dist/src/browser-session.d.ts +9 -0
- package/dist/src/browser-session.js +30 -0
- package/dist/src/browser-state.d.ts +52 -0
- package/dist/src/browser-state.js +105 -0
- package/dist/src/browser.d.ts +12 -0
- package/dist/src/browser.js +135 -0
- package/dist/src/derived-core.d.ts +3 -1
- package/dist/src/derived-core.js +26 -19
- package/dist/src/derived.d.ts +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +2 -0
- package/dist/src/pack-fetch.js +2 -2
- package/dist/src/public-suffixes.json +1 -0
- package/dist/src/runtime.d.ts +2 -1
- package/dist/src/runtime.js +2 -1
- package/dist/src/serve.d.ts +3 -1
- package/dist/src/serve.js +4 -2
- package/dist/src/twin-fetch.d.ts +1 -1
- package/dist/src/twin-fetch.js +23 -4
- package/dist/vendor-hosts.cjs +13 -105
- package/dist/vendor-hosts.d.cts +3 -0
- package/host-routing.cjs +106 -0
- package/host-routing.d.cts +7 -0
- package/inject.cjs +5 -8
- package/package.json +11 -1
- package/src/actions.ts +3 -1
- package/src/board-keys.ts +4 -0
- package/src/browser-cookie-jar.ts +77 -0
- package/src/browser-session.ts +31 -0
- package/src/browser-state.ts +89 -0
- package/src/browser.ts +115 -0
- package/src/derived-core.ts +26 -16
- package/src/derived.ts +1 -1
- package/src/index.ts +4 -0
- package/src/pack-fetch.ts +2 -2
- package/src/public-suffixes.json +1 -0
- package/src/runtime.ts +4 -1
- package/src/serve.ts +3 -1
- package/src/twin-fetch.ts +9 -4
- package/vendor-hosts.cjs +13 -105
- package/vendor-hosts.d.cts +3 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Host routing is data in, answer out. No imports, environment reads or module-load table construction.
|
|
3
|
+
function compileHostRule(rule, where) {
|
|
4
|
+
const selectors = ['host', 'suffix', 'hostPattern'].filter((k) => rule[k] !== undefined).length;
|
|
5
|
+
if (selectors !== 1) throw new Error(`inject: ${where} rule ${JSON.stringify(rule)} — exactly one of host | suffix | hostPattern (pack-facts.json is hand-edited or stale)`);
|
|
6
|
+
const hostRe = rule.hostPattern === undefined ? null : new RegExp(rule.hostPattern);
|
|
7
|
+
const pathRe = rule.pathPattern === undefined ? null : new RegExp(rule.pathPattern);
|
|
8
|
+
return (h, p) =>
|
|
9
|
+
(rule.host !== undefined ? h === rule.host : rule.suffix !== undefined ? h.endsWith(rule.suffix) : hostRe.test(h))
|
|
10
|
+
&& (pathRe === null || p === undefined || pathRe.test(p));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A pack's host rules (its descriptor's `hosts`) read as the injector reads them: `names(host)`, whether any include
|
|
14
|
+
* rule's selector names the host, whatever the path; `takes(host, path)`, whether the request is the pack's (under
|
|
15
|
+
* one of its keys, an include matches and no exclude does). The content route at a World's place holds a
|
|
16
|
+
* `<base>/@<host>/<path>` to it (pack-fetch.ts). */
|
|
17
|
+
function hostRules(rules) {
|
|
18
|
+
const byKey = new Map();
|
|
19
|
+
for (const rule of rules || []) {
|
|
20
|
+
const key = rule.key === undefined ? '' : rule.key;
|
|
21
|
+
if (!byKey.has(key)) byKey.set(key, []);
|
|
22
|
+
byKey.get(key).push(rule);
|
|
23
|
+
}
|
|
24
|
+
const keys = [...byKey.values()].map((keyRules) => ({
|
|
25
|
+
includes: keyRules.filter((r) => r.exclude !== true).map((r) => compileHostRule(r, 'a pack')),
|
|
26
|
+
excludes: keyRules.filter((r) => r.exclude === true).map((r) => compileHostRule(r, 'a pack')),
|
|
27
|
+
}));
|
|
28
|
+
return {
|
|
29
|
+
names: (h) => keys.some((k) => k.includes.some((m) => m(h))),
|
|
30
|
+
takes: (h, p) => keys.some((k) => k.includes.some((m) => m(h, p)) && !k.excludes.some((m) => m(h, p))),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Build one independent host table, in the proxy's key order, from supplied pack facts and overlays. */
|
|
35
|
+
function createHostTable(packs, installed = []) {
|
|
36
|
+
const VENDOR_HOSTS = {
|
|
37
|
+
googlefavicon: (h) => h === 'www.google.com' || h === 'google.com' || h === 't2.gstatic.com',
|
|
38
|
+
browserassets: (h) =>
|
|
39
|
+
h === 'cdnjs.cloudflare.com' ||
|
|
40
|
+
h === 'www.googletagmanager.com' ||
|
|
41
|
+
h === 'www.google-analytics.com' ||
|
|
42
|
+
h === 'connect.facebook.net' ||
|
|
43
|
+
h === 'snap.licdn.com' ||
|
|
44
|
+
h === 'px.ads.linkedin.com' ||
|
|
45
|
+
h === 'static.hotjar.com' ||
|
|
46
|
+
h === 'fonts.googleapis.com' ||
|
|
47
|
+
h === 'fonts.gstatic.com' ||
|
|
48
|
+
h === 'api.fontshare.com' ||
|
|
49
|
+
h === 'images.subscribe.dev' ||
|
|
50
|
+
h === 'www.youtube.com' ||
|
|
51
|
+
h === 'www.youtube-nocookie.com' ||
|
|
52
|
+
h === 'i.ytimg.com',
|
|
53
|
+
};
|
|
54
|
+
const COMPILED = new Map();
|
|
55
|
+
const HAND = new Set(Object.keys(VENDOR_HOSTS));
|
|
56
|
+
function compilePackHosts(packs, overlay) {
|
|
57
|
+
for (const vendor of Object.keys(packs)) {
|
|
58
|
+
const rules = packs[vendor].hosts;
|
|
59
|
+
if (!rules || rules.length === 0) continue;
|
|
60
|
+
for (const key of COMPILED.get(vendor) || []) delete VENDOR_HOSTS[key];
|
|
61
|
+
COMPILED.delete(vendor);
|
|
62
|
+
if (VENDOR_HOSTS[vendor] && !(overlay && HAND.has(vendor))) {
|
|
63
|
+
throw new Error(`inject: vendor "${vendor}" declares hosts on its pack descriptor AND in the hand VENDOR_HOSTS table — one home per fact; delete the hand entry.`);
|
|
64
|
+
}
|
|
65
|
+
// Rules group by `key` (default: the vendor id) — aws's descriptor declares the s3 and
|
|
66
|
+
// secretsmanager routing identities its twin answers under. A key matches when ANY include
|
|
67
|
+
// rule matches AND NO exclude rule matches.
|
|
68
|
+
const byKey = new Map();
|
|
69
|
+
for (const rule of rules) {
|
|
70
|
+
const key = rule.key === undefined ? vendor : rule.key;
|
|
71
|
+
if (!byKey.has(key)) byKey.set(key, []);
|
|
72
|
+
byKey.get(key).push(rule);
|
|
73
|
+
}
|
|
74
|
+
for (const [key, keyRules] of byKey) {
|
|
75
|
+
if (VENDOR_HOSTS[key] && !(overlay && HAND.has(key))) {
|
|
76
|
+
throw new Error(`inject: key "${key}" (pack ${vendor}) is declared twice — on this descriptor and in the hand VENDOR_HOSTS table or another pack's descriptor; a key has one home.`);
|
|
77
|
+
}
|
|
78
|
+
const compile = (rule) => compileHostRule(rule, `key "${key}" (pack ${vendor})`);
|
|
79
|
+
const includes = keyRules.filter((r) => r.exclude !== true).map(compile);
|
|
80
|
+
const excludes = keyRules.filter((r) => r.exclude === true).map(compile);
|
|
81
|
+
VENDOR_HOSTS[key] = (h, p) => includes.some((m) => m(h, p)) && !excludes.some((m) => m(h, p));
|
|
82
|
+
COMPILED.set(vendor, [...(COMPILED.get(vendor) || []), key]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const overlays = new Set(installed);
|
|
87
|
+
compilePackHosts(Object.fromEntries(Object.entries(packs).filter(([vendor]) => !overlays.has(vendor))), false);
|
|
88
|
+
compilePackHosts(Object.fromEntries(Object.entries(packs).filter(([vendor]) => overlays.has(vendor))), true);
|
|
89
|
+
return { hosts: VENDOR_HOSTS, addPackHosts: packs => compilePackHosts(packs || {}, true) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Static host/path rules first, then supplied claimed hosts. */
|
|
93
|
+
function resolveTwinHost(rules, hostname, map, pathname, claimed = {}) {
|
|
94
|
+
for (const vendor of Object.keys(rules)) {
|
|
95
|
+
if (map[vendor] && rules[vendor](hostname, pathname)) return { vendor, origin: map[vendor] };
|
|
96
|
+
}
|
|
97
|
+
const host = String(hostname || '').toLowerCase();
|
|
98
|
+
for (const vendor of Object.keys(claimed)) {
|
|
99
|
+
if (!map[vendor]) continue;
|
|
100
|
+
const hosts = typeof claimed[vendor] === 'function' ? claimed[vendor]() : claimed[vendor];
|
|
101
|
+
if (hosts.has(host)) return { vendor, origin: map[vendor] };
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { createHostTable, hostRules, resolveTwinHost };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Pure host rules and resolution: supplied data only, safe in an isolate. */
|
|
2
|
+
export type HostRuleData = { host?: string; suffix?: string; hostPattern?: string; pathPattern?: string; key?: string; exclude?: true };
|
|
3
|
+
export type HostPredicate = (hostname: string, pathname?: string) => boolean;
|
|
4
|
+
export type HostFacts = Readonly<Record<string, { hosts?: ReadonlyArray<HostRuleData> }>>;
|
|
5
|
+
export function hostRules(rules: ReadonlyArray<HostRuleData> | undefined): { names(hostname: string): boolean; takes(hostname: string, pathname: string): boolean };
|
|
6
|
+
export function createHostTable(packs: HostFacts, installed?: Iterable<string>): { hosts: Record<string, HostPredicate>; addPackHosts(packs: HostFacts): void };
|
|
7
|
+
export function resolveTwinHost(rules: Readonly<Record<string, HostPredicate>>, hostname: string, map: Readonly<Record<string, string>>, pathname?: string, claimed?: Readonly<Record<string, ReadonlySet<string> | (() => ReadonlySet<string>)>>): { vendor: string; origin: string } | null;
|
package/dist/inject.cjs
CHANGED
|
@@ -47,6 +47,7 @@ const { createPublicKey, createVerify } = require('crypto');
|
|
|
47
47
|
|
|
48
48
|
// the vendor host table (VENDOR_HOSTS: a hostname, and a path where two vendors share a host, to the vendor whose twin
|
|
49
49
|
// serves it) has its own home, shared with the application route
|
|
50
|
+
const { resolveTwinHost } = require('./host-routing.cjs');
|
|
50
51
|
const { VENDOR_HOSTS, twinEnvStem, twinOrigins } = require('./vendor-hosts.cjs');
|
|
51
52
|
const dns = require('dns');
|
|
52
53
|
const http2 = require('http2');
|
|
@@ -216,15 +217,11 @@ function readMap(env) {
|
|
|
216
217
|
* sees only the SNI name) omit it and get host-level candidacy.
|
|
217
218
|
*/
|
|
218
219
|
function resolveTwin(hostname, map, pathname) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
// descriptor's hosts name, which the loop above has already answered
|
|
220
|
+
const target = resolveTwinHost(VENDOR_HOSTS, hostname, map, pathname, Object.fromEntries(
|
|
221
|
+
Object.keys(CLAIMERS).map(vendor => [vendor, () => claimsAt(map[vendor], CLAIMERS[vendor]).hosts])
|
|
222
|
+
));
|
|
223
|
+
if (target) return target;
|
|
224
224
|
const host = String(hostname || '').toLowerCase();
|
|
225
|
-
for (const vendor of Object.keys(CLAIMERS)) {
|
|
226
|
-
if (map[vendor] && claimsAt(map[vendor], CLAIMERS[vendor]).hosts.has(host)) return { vendor, origin: map[vendor] };
|
|
227
|
-
}
|
|
228
225
|
// last, the application's own production hostnames, routed to where it listens in the World; never the World's own
|
|
229
226
|
// origin or a twin's, whose requests carry the World key
|
|
230
227
|
const bare = host.replace(/\.$/, '');
|
package/dist/src/actions.d.ts
CHANGED
|
@@ -167,7 +167,9 @@ export type AtomicActionDecision<T> = {
|
|
|
167
167
|
* supplied snapshot. Durable state changes only through the returned action, which this helper
|
|
168
168
|
* appends before releasing the lock.
|
|
169
169
|
*/
|
|
170
|
-
export declare function decideAndAppendAction<T>(service: string, decide: (resources: TwinResource[]) => AtomicActionDecision<T>, root?: string
|
|
170
|
+
export declare function decideAndAppendAction<T>(service: string, decide: (resources: TwinResource[]) => AtomicActionDecision<T>, root?: string,
|
|
171
|
+
/** A narrower indexed snapshot, read under the same lock; it must cover every decision and precondition. */
|
|
172
|
+
read?: () => TwinResource[]): {
|
|
171
173
|
value: T;
|
|
172
174
|
action?: TwinAction;
|
|
173
175
|
appended: boolean;
|
package/dist/src/actions.js
CHANGED
|
@@ -252,9 +252,11 @@ export function occurrenceId(base, ordinal) {
|
|
|
252
252
|
* supplied snapshot. Durable state changes only through the returned action, which this helper
|
|
253
253
|
* appends before releasing the lock.
|
|
254
254
|
*/
|
|
255
|
-
export function decideAndAppendAction(service, decide, root
|
|
255
|
+
export function decideAndAppendAction(service, decide, root,
|
|
256
|
+
/** A narrower indexed snapshot, read under the same lock; it must cover every decision and precondition. */
|
|
257
|
+
read = () => projectResources(service, root)) {
|
|
256
258
|
return withProjection(service, root, () => withFileLock(actionsLock(service, root), () => {
|
|
257
|
-
const resources =
|
|
259
|
+
const resources = read();
|
|
258
260
|
const decision = decide(resources);
|
|
259
261
|
if (decision.kind === 'skip')
|
|
260
262
|
return { value: decision.value, appended: false };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** A page's key, shared by its World and console. Browser keys cannot equal vendor-prefixed plain keys. */
|
|
2
|
+
export function boardFrameKey(frame) {
|
|
3
|
+
return frame.browser ? `~browser:${encodeURIComponent(frame.browser)}:${frame.vendor}:${frame.id}` : `${frame.vendor}:${frame.id}`;
|
|
4
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type BrowserCookie } from './browser-state.js';
|
|
2
|
+
/** A seed's HTTP cookie jar; cookies remain at the real URL even when transport goes to a twin. */
|
|
3
|
+
export declare class BrowserCookieJar {
|
|
4
|
+
private cookies;
|
|
5
|
+
constructor(cookies?: BrowserCookie[]);
|
|
6
|
+
snapshot(now: number): BrowserCookie[];
|
|
7
|
+
header(url: URL, now: number): string;
|
|
8
|
+
set(line: string, url: URL, now: number): void;
|
|
9
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import suffixes from './public-suffixes.json' with { type: 'json' };
|
|
2
|
+
import { cookieMatchesHost } from "./browser-state.js";
|
|
3
|
+
/** Cookie domain acceptance uses the public suffix list, including its wildcard and exception rules. */
|
|
4
|
+
let rules;
|
|
5
|
+
function publicSuffix(domain) {
|
|
6
|
+
rules ??= new Set(suffixes.rules.map(rule => {
|
|
7
|
+
const prefix = rule.startsWith('!') ? '!' : rule.startsWith('*.') ? '*.' : '';
|
|
8
|
+
return `${prefix}${new URL(`https://${rule.slice(prefix.length)}`).hostname}`;
|
|
9
|
+
}));
|
|
10
|
+
const labels = domain.split('.');
|
|
11
|
+
let size = 1;
|
|
12
|
+
for (let i = 0; i < labels.length; i++) {
|
|
13
|
+
const suffix = labels.slice(i).join('.');
|
|
14
|
+
if (rules.has(`!${suffix}`))
|
|
15
|
+
return labels.length === labels.length - i - 1;
|
|
16
|
+
if (rules.has(suffix))
|
|
17
|
+
size = Math.max(size, labels.length - i);
|
|
18
|
+
if (i > 0 && rules.has(`*.${suffix}`))
|
|
19
|
+
size = Math.max(size, labels.length - i + 1);
|
|
20
|
+
}
|
|
21
|
+
return labels.length === size;
|
|
22
|
+
}
|
|
23
|
+
const domainMatches = (host, domain) => host === domain || host.endsWith(`.${domain}`);
|
|
24
|
+
const pathMatches = (path, scope) => path === scope || (path.startsWith(scope) && (scope.endsWith('/') || path[scope.length] === '/'));
|
|
25
|
+
/** A seed's HTTP cookie jar; cookies remain at the real URL even when transport goes to a twin. */
|
|
26
|
+
export class BrowserCookieJar {
|
|
27
|
+
cookies;
|
|
28
|
+
constructor(cookies = []) { this.cookies = structuredClone(cookies); }
|
|
29
|
+
snapshot(now) {
|
|
30
|
+
this.cookies = this.cookies.filter(c => c.expires === -1 || c.expires > now / 1000);
|
|
31
|
+
return structuredClone(this.cookies);
|
|
32
|
+
}
|
|
33
|
+
header(url, now) {
|
|
34
|
+
return this.snapshot(now).filter(c => (!c.secure || url.protocol === 'https:') &&
|
|
35
|
+
cookieMatchesHost(c, url.hostname) && pathMatches(url.pathname, c.path))
|
|
36
|
+
.sort((a, b) => b.path.length - a.path.length).map(c => `${c.name}=${c.value}`).join('; ');
|
|
37
|
+
}
|
|
38
|
+
set(line, url, now) {
|
|
39
|
+
const [pair = '', ...attributes] = line.split(';');
|
|
40
|
+
const eq = pair.indexOf('=');
|
|
41
|
+
if (eq < 1)
|
|
42
|
+
return;
|
|
43
|
+
const name = pair.slice(0, eq).trim();
|
|
44
|
+
const value = pair.slice(eq + 1).trim();
|
|
45
|
+
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) || /[\x00-\x1f\x7f;\u0100-\uffff]/.test(value))
|
|
46
|
+
return;
|
|
47
|
+
const attrs = new Map();
|
|
48
|
+
for (const part of attributes) {
|
|
49
|
+
const at = part.indexOf('=');
|
|
50
|
+
attrs.set((at < 0 ? part : part.slice(0, at)).trim().toLowerCase(), at < 0 ? '' : part.slice(at + 1).trim());
|
|
51
|
+
}
|
|
52
|
+
let domain = url.hostname;
|
|
53
|
+
const asked = attrs.get('domain');
|
|
54
|
+
if (asked) {
|
|
55
|
+
if (/[\s:/\\?#@%]/.test(asked))
|
|
56
|
+
return;
|
|
57
|
+
let normalized;
|
|
58
|
+
try {
|
|
59
|
+
normalized = new URL(`https://${asked.replace(/^\./, '')}`).hostname;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!domainMatches(url.hostname, normalized) || publicSuffix(normalized))
|
|
65
|
+
return;
|
|
66
|
+
domain = `.${normalized}`;
|
|
67
|
+
}
|
|
68
|
+
const last = url.pathname.lastIndexOf('/');
|
|
69
|
+
const fallback = last > 0 ? url.pathname.slice(0, last) : '/';
|
|
70
|
+
const path = attrs.get('path')?.startsWith('/') ? attrs.get('path') : fallback;
|
|
71
|
+
if (/[\x00-\x20\x7f;\u0100-\uffff]/.test(path))
|
|
72
|
+
return;
|
|
73
|
+
const secure = attrs.has('secure');
|
|
74
|
+
if (secure && url.protocol !== 'https:')
|
|
75
|
+
return;
|
|
76
|
+
const same = attrs.get('samesite')?.toLowerCase();
|
|
77
|
+
const sameSite = same === 'strict' ? 'Strict' : same === 'none' ? 'None' : 'Lax';
|
|
78
|
+
if (sameSite === 'None' && !secure)
|
|
79
|
+
return;
|
|
80
|
+
if (name.startsWith('__Secure-') && !secure)
|
|
81
|
+
return;
|
|
82
|
+
if (name.startsWith('__Host-') && (!secure || asked || attrs.get('path') !== '/'))
|
|
83
|
+
return;
|
|
84
|
+
let expires = -1;
|
|
85
|
+
const date = attrs.get('expires');
|
|
86
|
+
if (date && Number.isFinite(Date.parse(date)))
|
|
87
|
+
expires = Date.parse(date) / 1000;
|
|
88
|
+
const age = attrs.get('max-age');
|
|
89
|
+
if (age && /^-?\d+$/.test(age)) {
|
|
90
|
+
const seconds = Number(age);
|
|
91
|
+
expires = seconds <= 0 ? 0 : Math.min(8640000000000, now / 1000 + seconds);
|
|
92
|
+
}
|
|
93
|
+
const existing = this.cookies.findIndex(c => c.name === name && c.domain.replace(/^\./, '') === domain.replace(/^\./, '') && c.path === path);
|
|
94
|
+
// An insecure response cannot replace a secure cookie with an overlapping scope.
|
|
95
|
+
if (url.protocol !== 'https:' && this.cookies.some(c => c.name === name && c.secure && domainMatches(domain.replace(/^\./, ''), c.domain.replace(/^\./, '')) && pathMatches(path, c.path)))
|
|
96
|
+
return;
|
|
97
|
+
const cookie = { name, value, domain, path, expires, secure, httpOnly: attrs.has('httponly'), sameSite };
|
|
98
|
+
if (existing < 0)
|
|
99
|
+
this.cookies.push(cookie);
|
|
100
|
+
else
|
|
101
|
+
this.cookies[existing] = cookie;
|
|
102
|
+
this.snapshot(now);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { HandlerContext } from './derived-core.js';
|
|
2
|
+
type Writer = Pick<HandlerContext, 'record' | 'secret' | 'occurredAt'>;
|
|
3
|
+
/** The named cookie, without interpreting any other vendor's cookie. */
|
|
4
|
+
export declare function browserSessionToken(request: Request, name: string): string | undefined;
|
|
5
|
+
/** A new, independent sign-in, returning the cookie the vendor places on its response. */
|
|
6
|
+
export declare function startBrowserSession(ctx: Writer, email: string, cookie: string, scope?: string): Promise<string>;
|
|
7
|
+
/** End the session the request holds, then clear its cookie. Unknown cookies create no rows. */
|
|
8
|
+
export declare function endBrowserSession(ctx: Pick<HandlerContext, 'call' | 'record' | 'rowsRaw' | 'occurredAt'>, cookie: string): Promise<string>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** The named cookie, without interpreting any other vendor's cookie. */
|
|
2
|
+
export function browserSessionToken(request, name) {
|
|
3
|
+
for (const part of (request.headers.get('cookie') ?? '').split(';')) {
|
|
4
|
+
const at = part.indexOf('=');
|
|
5
|
+
if (at > 0 && part.slice(0, at).trim() === name) {
|
|
6
|
+
try {
|
|
7
|
+
return decodeURIComponent(part.slice(at + 1).trim());
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
/** A new, independent sign-in, returning the cookie the vendor places on its response. */
|
|
17
|
+
export async function startBrowserSession(ctx, email, cookie, scope) {
|
|
18
|
+
const issued = await ctx.record('_web_session_issue', { email, ...(scope !== undefined ? { scope } : {}) });
|
|
19
|
+
const token = await ctx.secret(`web-session:${issued}`);
|
|
20
|
+
await ctx.record('_web_session', { token, email, ...(scope !== undefined ? { scope } : {}), created_at: ctx.occurredAt }, `websession:${token}`);
|
|
21
|
+
return `${cookie}=${token}; path=/; HttpOnly; Secure; SameSite=Lax`;
|
|
22
|
+
}
|
|
23
|
+
/** End the session the request holds, then clear its cookie. Unknown cookies create no rows. */
|
|
24
|
+
export async function endBrowserSession(ctx, cookie) {
|
|
25
|
+
const token = browserSessionToken(ctx.call.request, cookie);
|
|
26
|
+
const session = token ? ctx.rowsRaw('_web_session').find((s) => s.token === token) : undefined;
|
|
27
|
+
if (session)
|
|
28
|
+
await ctx.record('_web_session', { token: null, ended_at: ctx.occurredAt }, String(session.id));
|
|
29
|
+
return `${cookie}=; path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`;
|
|
30
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** The frontend state of a scenario, in Playwright's storageState form. */
|
|
2
|
+
export type BrowserCookie = {
|
|
3
|
+
name: string;
|
|
4
|
+
value: string;
|
|
5
|
+
domain: string;
|
|
6
|
+
path: string;
|
|
7
|
+
expires: number;
|
|
8
|
+
httpOnly: boolean;
|
|
9
|
+
secure: boolean;
|
|
10
|
+
sameSite: 'Strict' | 'Lax' | 'None';
|
|
11
|
+
partitionKey?: string;
|
|
12
|
+
_crHasCrossSiteAncestor?: boolean;
|
|
13
|
+
};
|
|
14
|
+
export type BrowserIndexedDB = {
|
|
15
|
+
name: string;
|
|
16
|
+
version: number;
|
|
17
|
+
stores: {
|
|
18
|
+
name: string;
|
|
19
|
+
autoIncrement: boolean;
|
|
20
|
+
keyPath?: string;
|
|
21
|
+
keyPathArray?: string[];
|
|
22
|
+
records: {
|
|
23
|
+
key?: unknown;
|
|
24
|
+
keyEncoded?: unknown;
|
|
25
|
+
value?: unknown;
|
|
26
|
+
valueEncoded?: unknown;
|
|
27
|
+
}[];
|
|
28
|
+
indexes: {
|
|
29
|
+
name: string;
|
|
30
|
+
keyPath?: string;
|
|
31
|
+
keyPathArray?: string[];
|
|
32
|
+
multiEntry: boolean;
|
|
33
|
+
unique: boolean;
|
|
34
|
+
}[];
|
|
35
|
+
}[];
|
|
36
|
+
};
|
|
37
|
+
export type BrowserState = {
|
|
38
|
+
cookies: BrowserCookie[];
|
|
39
|
+
origins: {
|
|
40
|
+
origin: string;
|
|
41
|
+
localStorage: {
|
|
42
|
+
name: string;
|
|
43
|
+
value: string;
|
|
44
|
+
}[];
|
|
45
|
+
indexedDB?: BrowserIndexedDB[];
|
|
46
|
+
}[];
|
|
47
|
+
};
|
|
48
|
+
export declare function assertBrowserName(name: string): void;
|
|
49
|
+
export declare function browserOrigin(value: string): string;
|
|
50
|
+
/** Refuse unknown keys at every level; the accepted object is never reshaped. */
|
|
51
|
+
export declare function validateBrowserState(value: unknown): BrowserState;
|
|
52
|
+
export declare function cookieMatchesHost(cookie: BrowserCookie, hostname: string): boolean;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export function assertBrowserName(name) {
|
|
2
|
+
if (!/^[a-z0-9](?:[a-z0-9]|-(?!-)){0,23}$/.test(name) || name.endsWith('-'))
|
|
3
|
+
throw new Error('browser name: 1–24 lowercase letters, digits or single hyphens, starting and ending with a letter or digit');
|
|
4
|
+
}
|
|
5
|
+
function object(value, keys) {
|
|
6
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !keys.includes(k)))
|
|
7
|
+
throw new Error(`browser state: an object with only ${keys.join(', ')}`);
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
const host = (value) => value !== 'localhost' && !value.endsWith('.localhost') && !/^\d+(?:\.\d+){3}$/.test(value) && /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*$/.test(value) && value.length <= 253 && value.split('.').every(l => l.length <= 63);
|
|
11
|
+
export function browserOrigin(value) {
|
|
12
|
+
let url;
|
|
13
|
+
try {
|
|
14
|
+
url = new URL(value);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new Error('browser origin: an absolute http or https vendor origin');
|
|
18
|
+
}
|
|
19
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.origin !== value || !host(url.hostname))
|
|
20
|
+
throw new Error('browser origin: a canonical http or https vendor origin, never a local twin URL');
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
/** Refuse unknown keys at every level; the accepted object is never reshaped. */
|
|
24
|
+
export function validateBrowserState(value) {
|
|
25
|
+
const state = object(value, ['cookies', 'origins']);
|
|
26
|
+
if (!Array.isArray(state.cookies) || !Array.isArray(state.origins))
|
|
27
|
+
throw new Error('browser state: cookies and origins are arrays');
|
|
28
|
+
const cookies = new Set();
|
|
29
|
+
for (const raw of state.cookies) {
|
|
30
|
+
const c = object(raw, ['name', 'value', 'domain', 'path', 'expires', 'httpOnly', 'secure', 'sameSite', 'partitionKey', '_crHasCrossSiteAncestor']);
|
|
31
|
+
if (typeof c.name !== 'string' || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(c.name) || typeof c.value !== 'string' || /[\x00-\x1f\x7f;]/.test(c.value))
|
|
32
|
+
throw new Error('browser cookie: a cookie name and wire-safe string value');
|
|
33
|
+
if (/[^\u0000-\u00ff]/.test(c.value))
|
|
34
|
+
throw new Error('browser cookie: value characters must be U+00FF or below for Set-Cookie');
|
|
35
|
+
if (typeof c.domain !== 'string' || !host(c.domain.replace(/^\./, '')))
|
|
36
|
+
throw new Error('browser cookie: a real vendor domain, optionally prefixed by a dot');
|
|
37
|
+
if (typeof c.path !== 'string' || !c.path.startsWith('/') || /[\x00-\x20\x7f;\u0100-\uffff]/.test(c.path))
|
|
38
|
+
throw new Error('browser cookie: an absolute wire-safe cookie path');
|
|
39
|
+
if (typeof c.expires !== 'number' || !Number.isFinite(c.expires) || (c.expires < 0 && c.expires !== -1))
|
|
40
|
+
throw new Error('browser cookie: expires is Unix seconds, or -1 for a session');
|
|
41
|
+
if (typeof c.httpOnly !== 'boolean' || typeof c.secure !== 'boolean' || (typeof c.sameSite !== 'string' || !['Strict', 'Lax', 'None'].includes(c.sameSite)))
|
|
42
|
+
throw new Error('browser cookie: httpOnly and secure are booleans; sameSite is Strict, Lax or None');
|
|
43
|
+
if (c.partitionKey !== undefined && (typeof c.partitionKey !== 'string' || !c.partitionKey || /[\x00-\x20\x7f]/.test(c.partitionKey)))
|
|
44
|
+
throw new Error('browser cookie: partitionKey is a nonempty string');
|
|
45
|
+
if (c._crHasCrossSiteAncestor !== undefined && typeof c._crHasCrossSiteAncestor !== 'boolean')
|
|
46
|
+
throw new Error('browser cookie: _crHasCrossSiteAncestor is a boolean');
|
|
47
|
+
const key = JSON.stringify([c.name, c.domain, c.path, c.partitionKey]);
|
|
48
|
+
if (cookies.has(key))
|
|
49
|
+
throw new Error('browser state: duplicate cookie');
|
|
50
|
+
cookies.add(key);
|
|
51
|
+
}
|
|
52
|
+
const origins = new Set();
|
|
53
|
+
for (const raw of state.origins) {
|
|
54
|
+
const o = object(raw, ['origin', 'localStorage', 'indexedDB']);
|
|
55
|
+
if (typeof o.origin !== 'string')
|
|
56
|
+
throw new Error('browser state: origin is a string');
|
|
57
|
+
browserOrigin(o.origin);
|
|
58
|
+
if (origins.has(o.origin))
|
|
59
|
+
throw new Error('browser state: duplicate origin');
|
|
60
|
+
origins.add(o.origin);
|
|
61
|
+
if (!Array.isArray(o.localStorage))
|
|
62
|
+
throw new Error('browser state: localStorage is an array');
|
|
63
|
+
if (o.indexedDB !== undefined) {
|
|
64
|
+
if (!Array.isArray(o.indexedDB))
|
|
65
|
+
throw new Error('browser storage: indexedDB is an array');
|
|
66
|
+
const keyPath = (item) => {
|
|
67
|
+
if (item.keyPath !== undefined && typeof item.keyPath !== 'string')
|
|
68
|
+
throw new Error('browser IndexedDB: keyPath is a string');
|
|
69
|
+
if (item.keyPathArray !== undefined && (!Array.isArray(item.keyPathArray) || item.keyPathArray.some(k => typeof k !== 'string')))
|
|
70
|
+
throw new Error('browser IndexedDB: keyPathArray contains strings');
|
|
71
|
+
};
|
|
72
|
+
for (const rawDB of o.indexedDB) {
|
|
73
|
+
const db = object(rawDB, ['name', 'version', 'stores']);
|
|
74
|
+
if (typeof db.name !== 'string' || typeof db.version !== 'number' || !Number.isSafeInteger(db.version) || db.version < 1 || !Array.isArray(db.stores))
|
|
75
|
+
throw new Error('browser IndexedDB: a name, positive integer version and stores array');
|
|
76
|
+
for (const rawStore of db.stores) {
|
|
77
|
+
const store = object(rawStore, ['name', 'autoIncrement', 'keyPath', 'keyPathArray', 'records', 'indexes']);
|
|
78
|
+
if (typeof store.name !== 'string' || typeof store.autoIncrement !== 'boolean' || !Array.isArray(store.records) || !Array.isArray(store.indexes))
|
|
79
|
+
throw new Error('browser IndexedDB: a store name, autoIncrement boolean, records and indexes arrays');
|
|
80
|
+
keyPath(store);
|
|
81
|
+
for (const record of store.records)
|
|
82
|
+
object(record, ['key', 'keyEncoded', 'value', 'valueEncoded']);
|
|
83
|
+
for (const rawIndex of store.indexes) {
|
|
84
|
+
const index = object(rawIndex, ['name', 'keyPath', 'keyPathArray', 'multiEntry', 'unique']);
|
|
85
|
+
if (typeof index.name !== 'string' || typeof index.multiEntry !== 'boolean' || typeof index.unique !== 'boolean')
|
|
86
|
+
throw new Error('browser IndexedDB: an index name, multiEntry and unique booleans');
|
|
87
|
+
keyPath(index);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const names = new Set();
|
|
93
|
+
for (const rawEntry of o.localStorage) {
|
|
94
|
+
const entry = object(rawEntry, ['name', 'value']);
|
|
95
|
+
if (typeof entry.name !== 'string' || typeof entry.value !== 'string' || names.has(entry.name))
|
|
96
|
+
throw new Error('browser storage: unique string names and string values');
|
|
97
|
+
names.add(entry.name);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
export function cookieMatchesHost(cookie, hostname) {
|
|
103
|
+
const domain = cookie.domain.replace(/^\./, '');
|
|
104
|
+
return hostname === domain || (cookie.domain.startsWith('.') && hostname.endsWith(`.${domain}`));
|
|
105
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type BrowserState } from './browser-state.js';
|
|
2
|
+
export { cookieMatchesHost } from './browser-state.js';
|
|
3
|
+
export type SeedBrowser = {
|
|
4
|
+
fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
|
|
5
|
+
storage(origin: string): {
|
|
6
|
+
set(key: string, value: string): Promise<void>;
|
|
7
|
+
remove(key: string): Promise<void>;
|
|
8
|
+
};
|
|
9
|
+
state(): Promise<BrowserState>;
|
|
10
|
+
};
|
|
11
|
+
/** A seed's named browser. Every state write goes through the World's single HTTP loader. */
|
|
12
|
+
export declare function browser(name: string): Promise<SeedBrowser>;
|