cursedbelt-core 2.1.1 → 2.3.0
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/config/appConfig.d.ts +3 -5
- package/dist/config/appConfig.js +6 -6
- package/dist/core/offline/shellWorker.d.ts +185 -0
- package/dist/core/offline/shellWorker.js +200 -0
- package/dist/core/passwordPolicy.d.ts +49 -0
- package/dist/core/passwordPolicy.js +60 -0
- package/package.json +13 -1
- package/src/config/appConfig.spec.ts +19 -7
- package/src/config/appConfig.ts +6 -9
- package/src/core/offline/shellWorker.spec.ts +392 -0
- package/src/core/offline/shellWorker.ts +346 -0
- package/src/core/offline/shellWorker.webworker.spec.ts +80 -0
- package/src/core/passwordPolicy.spec.ts +19 -0
- package/src/core/passwordPolicy.ts +63 -0
- package/src/leafSubpathsImportNothing.spec.ts +10 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app-shell service worker, once, for every app that serves its shell offline
|
|
3
|
+
* (task 2073, 2026-09-22).
|
|
4
|
+
*
|
|
5
|
+
* `apps/roms` wrote this by hand as plain JS in `public/sw.js`, with a TypeScript
|
|
6
|
+
* twin in `src/offline/swRouting.ts` held in step by a comment saying "keep the two
|
|
7
|
+
* in step". `apps/music` needed the same worker, and a third hand copy is how a fix
|
|
8
|
+
* lands in one app and not the others. This module is the one body; an app's
|
|
9
|
+
* `src/sw.ts` passes a config and adds whatever is genuinely its own (background
|
|
10
|
+
* sync, an outbox, a digest-keyed media cache) as further listeners beside it.
|
|
11
|
+
*
|
|
12
|
+
* What it does, and it is deliberately all it does:
|
|
13
|
+
* - `install` — precache the shell and the config's `precache` list, then `skipWaiting`.
|
|
14
|
+
* - `activate` — delete every cache named `<cachePrefix>*` that is not this version's,
|
|
15
|
+
* then `clients.claim()`.
|
|
16
|
+
* - `fetch` — same-origin static ASSETS cache-first; document NAVIGATIONS
|
|
17
|
+
* network-first, falling back offline to the exact URL, then the same
|
|
18
|
+
* path ignoring the query, then the shell. Everything else is left on
|
|
19
|
+
* the network: `respondWith` is never called for it.
|
|
20
|
+
*
|
|
21
|
+
* 🔴 `/api/` is NEVER cached and NEVER served from a cache. It is auth-gated, and a
|
|
22
|
+
* browser whose session has since been revoked must not read yesterday's copy of a
|
|
23
|
+
* private response out of its own cache. That is not a default a config can switch
|
|
24
|
+
* off: `apiPrefixes` ADDS prefixes to `/api/`, it cannot remove it, a precache entry
|
|
25
|
+
* under an API prefix is refused when the worker is installed, and `shouldCache`
|
|
26
|
+
* refuses to store an `api` route even if a caller asks.
|
|
27
|
+
*
|
|
28
|
+
* 🔴 `noShellFallback` exists because of the 2026-08-12 frame incident in roms. A game
|
|
29
|
+
* boots by loading `/emulator-frame.html` into a sandboxed iframe — a NAVIGATION, so it
|
|
30
|
+
* went network-first, and offline with nothing cached under that path the fallback
|
|
31
|
+
* handed it the APP SHELL. The iframe rendered a second copy of the library where the
|
|
32
|
+
* game should be: no canvas, no error, no clue. Any document that is not the shell (an
|
|
33
|
+
* iframe, a popup, a print view) must be listed there, and must be in `precache` if it
|
|
34
|
+
* is meant to work offline at all — an offline miss on it then REJECTS, which is the
|
|
35
|
+
* honest answer, rather than resolving the wrong document.
|
|
36
|
+
*
|
|
37
|
+
* Imports nothing at runtime (`leafSubpathsImportNothing.spec.ts` holds it to that):
|
|
38
|
+
* it is bundled into a service worker, where every byte ships on every install.
|
|
39
|
+
* `scope` is typed structurally so this compiles without the WebWorker lib, is testable
|
|
40
|
+
* with a fake, and still accepts the real `self` — `shellWorker.webworker.spec.ts`
|
|
41
|
+
* typechecks exactly that against `lib.webworker.d.ts`.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** Where a request goes. */
|
|
45
|
+
export type ShellRoute =
|
|
46
|
+
/** A document navigation — network-first, cached-document fallback offline. */
|
|
47
|
+
| 'navigate'
|
|
48
|
+
/** A same-origin static asset (JS/CSS/WASM/font/image/JSON) — cache-first. */
|
|
49
|
+
| 'asset'
|
|
50
|
+
/** An auth-gated API call — never cached, never answered from a cache. */
|
|
51
|
+
| 'api'
|
|
52
|
+
/** Everything else (non-GET, cross-origin, `passthroughPrefixes`) — untouched. */
|
|
53
|
+
| 'passthrough';
|
|
54
|
+
|
|
55
|
+
/** The facts about a request that classification reads — nothing else. */
|
|
56
|
+
export interface RequestFacts {
|
|
57
|
+
method: string;
|
|
58
|
+
/** Same origin as the worker (`new URL(request.url).origin === location.origin`). */
|
|
59
|
+
sameOrigin: boolean;
|
|
60
|
+
/** URL pathname, no query. */
|
|
61
|
+
pathname: string;
|
|
62
|
+
/** `request.mode === 'navigate'`. */
|
|
63
|
+
isNavigate: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ShellWorkerConfig {
|
|
67
|
+
/**
|
|
68
|
+
* Every cache this worker owns is named `<cachePrefix><version>`, and `activate`
|
|
69
|
+
* deletes every OTHER cache starting with `cachePrefix`. So the prefix must be one
|
|
70
|
+
* nothing else in the app shares — `roms-shell-`, not `roms-`, or the app's own
|
|
71
|
+
* digest-keyed caches go with it.
|
|
72
|
+
*/
|
|
73
|
+
cachePrefix: string;
|
|
74
|
+
/** Bump to evict the previous shell on the next activate. */
|
|
75
|
+
version: string;
|
|
76
|
+
/**
|
|
77
|
+
* Same-origin paths fetched into the cache at install. `shellPath` is always
|
|
78
|
+
* precached whether listed or not — a shell fallback with no shell is no fallback.
|
|
79
|
+
*/
|
|
80
|
+
precache: readonly string[];
|
|
81
|
+
/**
|
|
82
|
+
* Extra API prefixes. `/api/` is ALWAYS one of them and cannot be removed — see the
|
|
83
|
+
* header. Default: `['/api/']`.
|
|
84
|
+
*/
|
|
85
|
+
apiPrefixes?: readonly string[];
|
|
86
|
+
/**
|
|
87
|
+
* Same-origin prefixes the worker must leave alone even for GET assets — e.g. roms'
|
|
88
|
+
* `/_nes-roms/`, whose bytes the app caches itself under a content digest.
|
|
89
|
+
*/
|
|
90
|
+
passthroughPrefixes?: readonly string[];
|
|
91
|
+
/**
|
|
92
|
+
* Exact pathnames that must NEVER be answered with the shell offline (roms'
|
|
93
|
+
* `/emulator-frame.html`). An offline miss on one rejects instead. See the header.
|
|
94
|
+
*/
|
|
95
|
+
noShellFallback?: readonly string[];
|
|
96
|
+
/** Which same-origin pathnames count as static assets. Default: `DEFAULT_ASSET_EXT`. */
|
|
97
|
+
assetExtensions?: RegExp;
|
|
98
|
+
/** The app shell's path, the navigation fallback. Default: `'/'`. */
|
|
99
|
+
shellPath?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A config with every default applied and every refusal already checked. */
|
|
103
|
+
export interface ResolvedShellWorkerConfig {
|
|
104
|
+
cachePrefix: string;
|
|
105
|
+
version: string;
|
|
106
|
+
cacheName: string;
|
|
107
|
+
precache: readonly string[];
|
|
108
|
+
apiPrefixes: readonly string[];
|
|
109
|
+
passthroughPrefixes: readonly string[];
|
|
110
|
+
noShellFallback: readonly string[];
|
|
111
|
+
assetExtensions: RegExp;
|
|
112
|
+
shellPath: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** roms' `ASSET_EXT`, verbatim. */
|
|
116
|
+
export const DEFAULT_ASSET_EXT = /\.(?:js|mjs|css|wasm|woff2?|ttf|png|jpe?g|webp|svg|ico|json)$/i;
|
|
117
|
+
|
|
118
|
+
/** Always an API prefix, whatever the config says. */
|
|
119
|
+
export const ALWAYS_API_PREFIX = '/api/';
|
|
120
|
+
|
|
121
|
+
const apiPrefixesOf = (config: Pick<ShellWorkerConfig, 'apiPrefixes'>): readonly string[] => {
|
|
122
|
+
const extra = config.apiPrefixes ?? [];
|
|
123
|
+
return extra.includes(ALWAYS_API_PREFIX) ? extra : [ALWAYS_API_PREFIX, ...extra];
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const startsWithAny = (pathname: string, prefixes: readonly string[]): boolean =>
|
|
127
|
+
prefixes.some((prefix) => pathname.startsWith(prefix));
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Classify a request. Order matters and is roms' order: non-GET and cross-origin
|
|
131
|
+
* first, then the API (so an API path that happens to end in `.json` is still `api`),
|
|
132
|
+
* then the app's own passthrough prefixes, then navigation, then assets.
|
|
133
|
+
*/
|
|
134
|
+
export function classifyRequest(
|
|
135
|
+
facts: RequestFacts,
|
|
136
|
+
config: Pick<ShellWorkerConfig, 'apiPrefixes' | 'passthroughPrefixes' | 'assetExtensions'> = {},
|
|
137
|
+
): ShellRoute {
|
|
138
|
+
if (facts.method !== 'GET') return 'passthrough';
|
|
139
|
+
if (!facts.sameOrigin) return 'passthrough';
|
|
140
|
+
if (startsWithAny(facts.pathname, apiPrefixesOf(config))) return 'api';
|
|
141
|
+
if (startsWithAny(facts.pathname, config.passthroughPrefixes ?? [])) return 'passthrough';
|
|
142
|
+
if (facts.isNavigate) return 'navigate';
|
|
143
|
+
if ((config.assetExtensions ?? DEFAULT_ASSET_EXT).test(facts.pathname)) return 'asset';
|
|
144
|
+
return 'passthrough';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Whether a response on this route may be written to the cache. Never `api`. */
|
|
148
|
+
export function shouldCache(route: ShellRoute): boolean {
|
|
149
|
+
return route === 'asset' || route === 'navigate';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** May an offline miss on this navigation be answered with the shell? */
|
|
153
|
+
export function mayFallBackToShell(pathname: string, config: Pick<ShellWorkerConfig, 'noShellFallback'> = {}): boolean {
|
|
154
|
+
return !(config.noShellFallback ?? []).includes(pathname);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Apply defaults and refuse the configs that would break a promise in the header.
|
|
159
|
+
* `installShellWorker` calls this; it is exported so an app's own test can prove its
|
|
160
|
+
* config is accepted without standing up a worker.
|
|
161
|
+
*/
|
|
162
|
+
export function resolveShellWorkerConfig(config: ShellWorkerConfig): ResolvedShellWorkerConfig {
|
|
163
|
+
if (config.cachePrefix.trim() === '') {
|
|
164
|
+
// An empty prefix makes EVERY cache on the origin "an old version of ours", and
|
|
165
|
+
// activate would delete the app's other caches with it.
|
|
166
|
+
throw new Error('shell worker: cachePrefix must not be empty — activate deletes every cache it prefixes');
|
|
167
|
+
}
|
|
168
|
+
if (config.version.trim() === '') throw new Error('shell worker: version must not be empty');
|
|
169
|
+
const shellPath = config.shellPath ?? '/';
|
|
170
|
+
const apiPrefixes = apiPrefixesOf(config);
|
|
171
|
+
const precache = config.precache.includes(shellPath) ? [...config.precache] : [shellPath, ...config.precache];
|
|
172
|
+
for (const path of precache) {
|
|
173
|
+
if (!path.startsWith('/')) {
|
|
174
|
+
throw new Error(`shell worker: precache entry "${path}" must be a same-origin path starting with "/"`);
|
|
175
|
+
}
|
|
176
|
+
if (startsWithAny(path, apiPrefixes)) {
|
|
177
|
+
throw new Error(`shell worker: precache entry "${path}" is under an API prefix — the API is never cached`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (config.noShellFallback?.includes(shellPath)) {
|
|
181
|
+
throw new Error(`shell worker: the shell path "${shellPath}" cannot also be in noShellFallback`);
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
cachePrefix: config.cachePrefix,
|
|
185
|
+
version: config.version,
|
|
186
|
+
cacheName: `${config.cachePrefix}${config.version}`,
|
|
187
|
+
precache,
|
|
188
|
+
apiPrefixes,
|
|
189
|
+
passthroughPrefixes: config.passthroughPrefixes ?? [],
|
|
190
|
+
noShellFallback: config.noShellFallback ?? [],
|
|
191
|
+
assetExtensions: config.assetExtensions ?? DEFAULT_ASSET_EXT,
|
|
192
|
+
shellPath,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── The structural slice of ServiceWorkerGlobalScope this worker touches ──────────
|
|
197
|
+
// Method syntax throughout, on purpose: TypeScript checks method parameters
|
|
198
|
+
// bivariantly, which is what lets the real `self` (whose `Request`/`Response` are
|
|
199
|
+
// far wider than these) satisfy them.
|
|
200
|
+
|
|
201
|
+
export interface ShellWorkerRequest {
|
|
202
|
+
readonly url: string;
|
|
203
|
+
readonly method: string;
|
|
204
|
+
readonly mode: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The structural mirror of `RequestInfo | URL` — a request, a string, or a URL-like —
|
|
209
|
+
* which is what the real `fetch`, `Cache` and `CacheStorage` accept. It has to be the
|
|
210
|
+
* whole union: with `ShellWorkerRequest` alone the real `self` is refused, because a
|
|
211
|
+
* `URL` is not a request in either direction (measured by the webworker spec).
|
|
212
|
+
*/
|
|
213
|
+
export type ShellWorkerRequestInfo = ShellWorkerRequest | string | { readonly href: string };
|
|
214
|
+
|
|
215
|
+
export interface ShellWorkerResponse {
|
|
216
|
+
readonly ok: boolean;
|
|
217
|
+
clone(): ShellWorkerResponse;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface ShellWorkerCache {
|
|
221
|
+
addAll(requests: string[]): Promise<void>;
|
|
222
|
+
put(request: ShellWorkerRequestInfo, response: ShellWorkerResponse): Promise<void>;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface ShellWorkerCacheStorage {
|
|
226
|
+
open(cacheName: string): Promise<ShellWorkerCache>;
|
|
227
|
+
keys(): Promise<string[]>;
|
|
228
|
+
delete(cacheName: string): Promise<boolean>;
|
|
229
|
+
match(request: ShellWorkerRequestInfo, options?: { ignoreSearch?: boolean }): Promise<ShellWorkerResponse | undefined>;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface ShellExtendableEvent {
|
|
233
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface ShellFetchEvent extends ShellExtendableEvent {
|
|
237
|
+
readonly request: ShellWorkerRequest;
|
|
238
|
+
respondWith(response: Promise<ShellWorkerResponse>): void;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface ShellWorkerScope {
|
|
242
|
+
addEventListener(type: 'install', listener: (event: ShellExtendableEvent) => void): void;
|
|
243
|
+
addEventListener(type: 'activate', listener: (event: ShellExtendableEvent) => void): void;
|
|
244
|
+
addEventListener(type: 'fetch', listener: (event: ShellFetchEvent) => void): void;
|
|
245
|
+
readonly caches: ShellWorkerCacheStorage;
|
|
246
|
+
readonly clients: { claim(): Promise<void> };
|
|
247
|
+
readonly location: { readonly origin: string };
|
|
248
|
+
fetch(request: ShellWorkerRequestInfo): Promise<ShellWorkerResponse>;
|
|
249
|
+
skipWaiting(): Promise<void>;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Wire `install`, `activate` and `fetch` onto `scope` (the worker's `self`).
|
|
254
|
+
* Returns the resolved config, so an app can read `cacheName` for its own use.
|
|
255
|
+
*/
|
|
256
|
+
export function installShellWorker(scope: ShellWorkerScope, config: ShellWorkerConfig): ResolvedShellWorkerConfig {
|
|
257
|
+
const resolved = resolveShellWorkerConfig(config);
|
|
258
|
+
const { caches } = scope;
|
|
259
|
+
|
|
260
|
+
// Written after the response is handed back, like roms' `cache.put` — but kept
|
|
261
|
+
// alive with `waitUntil` so the worker is not stopped mid-write, and swallowed on
|
|
262
|
+
// failure: a full quota must not turn a good network response into an error.
|
|
263
|
+
const store = (event: ShellFetchEvent, route: ShellRoute, response: ShellWorkerResponse): void => {
|
|
264
|
+
if (!shouldCache(route) || !response.ok) return;
|
|
265
|
+
const copy = response.clone();
|
|
266
|
+
event.waitUntil(
|
|
267
|
+
caches
|
|
268
|
+
.open(resolved.cacheName)
|
|
269
|
+
.then((cache) => cache.put(event.request, copy))
|
|
270
|
+
.catch(() => undefined),
|
|
271
|
+
);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const cacheFirst = async (event: ShellFetchEvent): Promise<ShellWorkerResponse> => {
|
|
275
|
+
const cached = await caches.match(event.request);
|
|
276
|
+
if (cached) return cached;
|
|
277
|
+
const response = await scope.fetch(event.request);
|
|
278
|
+
store(event, 'asset', response);
|
|
279
|
+
return response;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const networkFirst = async (event: ShellFetchEvent): Promise<ShellWorkerResponse> => {
|
|
283
|
+
const { request } = event;
|
|
284
|
+
try {
|
|
285
|
+
const response = await scope.fetch(request);
|
|
286
|
+
store(event, 'navigate', response);
|
|
287
|
+
return response;
|
|
288
|
+
} catch (error) {
|
|
289
|
+
// Offline. The exact URL first, then the same path ignoring the query — roms'
|
|
290
|
+
// frame is `/emulator-frame.html?system=nes`, and one cached copy answers for
|
|
291
|
+
// every console.
|
|
292
|
+
const cached = (await caches.match(request)) ?? (await caches.match(request, { ignoreSearch: true }));
|
|
293
|
+
if (cached) return cached;
|
|
294
|
+
if (mayFallBackToShell(new URL(request.url).pathname, resolved)) {
|
|
295
|
+
const shell = await caches.match(resolved.shellPath);
|
|
296
|
+
if (shell) return shell;
|
|
297
|
+
}
|
|
298
|
+
// Nothing honest to serve: reject, and the browser shows its own offline page
|
|
299
|
+
// rather than a document that is not the one asked for.
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
scope.addEventListener('install', (event) => {
|
|
305
|
+
event.waitUntil(
|
|
306
|
+
caches
|
|
307
|
+
.open(resolved.cacheName)
|
|
308
|
+
.then((cache) => cache.addAll([...resolved.precache]))
|
|
309
|
+
.then(() => scope.skipWaiting()),
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
scope.addEventListener('activate', (event) => {
|
|
314
|
+
event.waitUntil(
|
|
315
|
+
caches
|
|
316
|
+
.keys()
|
|
317
|
+
.then((keys) =>
|
|
318
|
+
Promise.all(
|
|
319
|
+
keys
|
|
320
|
+
.filter((key) => key.startsWith(resolved.cachePrefix) && key !== resolved.cacheName)
|
|
321
|
+
.map((key) => caches.delete(key)),
|
|
322
|
+
),
|
|
323
|
+
)
|
|
324
|
+
.then(() => scope.clients.claim()),
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
scope.addEventListener('fetch', (event) => {
|
|
329
|
+
const url = new URL(event.request.url);
|
|
330
|
+
const route = classifyRequest(
|
|
331
|
+
{
|
|
332
|
+
method: event.request.method,
|
|
333
|
+
sameOrigin: url.origin === scope.location.origin,
|
|
334
|
+
pathname: url.pathname,
|
|
335
|
+
isNavigate: event.request.mode === 'navigate',
|
|
336
|
+
},
|
|
337
|
+
resolved,
|
|
338
|
+
);
|
|
339
|
+
if (route === 'asset') event.respondWith(cacheFirst(event));
|
|
340
|
+
else if (route === 'navigate') event.respondWith(networkFirst(event));
|
|
341
|
+
// `api` and `passthrough`: no respondWith, so the browser goes to the network as
|
|
342
|
+
// if this worker did not exist. Never a cache read, never a cache write.
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
return resolved;
|
|
346
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `ShellWorkerScope` is structural so the module typechecks without the WebWorker lib
|
|
6
|
+
* and can be tested with a fake. The promise that makes that worth anything is the
|
|
7
|
+
* other half: an app's `src/sw.ts` passes the REAL `self` and it must compile. Nothing
|
|
8
|
+
* in this repo's own tsconfig can see that (it carries lib DOM, not WebWorker), so this
|
|
9
|
+
* spec typechecks a two-file fixture against `lib.webworker.d.ts` with no DOM at all:
|
|
10
|
+
*
|
|
11
|
+
* - `sw.ts` passes a `ServiceWorkerGlobalScope` — must be clean;
|
|
12
|
+
* - `control.ts` passes a scope with no `caches` — must be RED, or tsc was not
|
|
13
|
+
* checking anything and the clean result above is vacuous.
|
|
14
|
+
*
|
|
15
|
+
* It also proves `shellWorker.ts` itself compiles with no DOM lib, which is the lib a
|
|
16
|
+
* service-worker build has.
|
|
17
|
+
*/
|
|
18
|
+
const REPO = fileURLToPath(new URL('../../..', import.meta.url));
|
|
19
|
+
const SOURCE = fileURLToPath(new URL('./shellWorker.ts', import.meta.url));
|
|
20
|
+
|
|
21
|
+
const typecheck = async (): Promise<string> => {
|
|
22
|
+
const root = `${process.env.TMPDIR ?? '/tmp'}/shell-worker-webworker-${process.pid}`;
|
|
23
|
+
// Bun.write, NOT node:fs — this repo's test preload virtualizes node:fs, and the
|
|
24
|
+
// spawned tsc would read a directory the mock never wrote.
|
|
25
|
+
await Bun.write(
|
|
26
|
+
`${root}/tsconfig.json`,
|
|
27
|
+
JSON.stringify({
|
|
28
|
+
compilerOptions: {
|
|
29
|
+
target: 'ESNext',
|
|
30
|
+
module: 'ESNext',
|
|
31
|
+
moduleResolution: 'bundler',
|
|
32
|
+
lib: ['ESNext', 'WebWorker'],
|
|
33
|
+
types: [],
|
|
34
|
+
strict: true,
|
|
35
|
+
noEmit: true,
|
|
36
|
+
skipLibCheck: true,
|
|
37
|
+
allowImportingTsExtensions: true,
|
|
38
|
+
},
|
|
39
|
+
files: ['sw.ts', 'control.ts'],
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
await Bun.write(
|
|
43
|
+
`${root}/sw.ts`,
|
|
44
|
+
`import { installShellWorker } from ${JSON.stringify(SOURCE)};
|
|
45
|
+
declare const sw: ServiceWorkerGlobalScope;
|
|
46
|
+
installShellWorker(sw, { cachePrefix: 'app-shell-', version: 'v1', precache: ['/'] });
|
|
47
|
+
`,
|
|
48
|
+
);
|
|
49
|
+
await Bun.write(
|
|
50
|
+
`${root}/control.ts`,
|
|
51
|
+
`import { installShellWorker } from ${JSON.stringify(SOURCE)};
|
|
52
|
+
declare const sw: ServiceWorkerGlobalScope;
|
|
53
|
+
const { caches: _dropped, ...noCaches } = sw;
|
|
54
|
+
installShellWorker(noCaches, { cachePrefix: 'app-shell-', version: 'v1', precache: ['/'] });
|
|
55
|
+
`,
|
|
56
|
+
);
|
|
57
|
+
const child = Bun.spawnSync([process.execPath, `${REPO}/node_modules/typescript/bin/tsc`, '-p', root], {
|
|
58
|
+
stdout: 'pipe',
|
|
59
|
+
stderr: 'pipe',
|
|
60
|
+
});
|
|
61
|
+
return `${child.stdout.toString()}${child.stderr.toString()}`;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const output = await typecheck();
|
|
65
|
+
const errorsIn = (file: string): string[] =>
|
|
66
|
+
output.split('\n').filter((line) => line.includes(`${file}(`) && line.includes('error TS'));
|
|
67
|
+
|
|
68
|
+
describe('shell worker against lib.webworker', () => {
|
|
69
|
+
it('rejects a scope with no caches (negative control — tsc really checked)', () => {
|
|
70
|
+
const control = errorsIn('control.ts');
|
|
71
|
+
expect(control.length, `tsc output:\n${output}`).toBeGreaterThan(0);
|
|
72
|
+
// Red for the RIGHT reason — the missing member, not a broken fixture.
|
|
73
|
+
expect(output, `tsc output:\n${output}`).toContain("Property 'caches' is missing");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('accepts the real ServiceWorkerGlobalScope, and the module compiles without DOM', () => {
|
|
77
|
+
expect(errorsIn('sw.ts'), `tsc output:\n${output}`).toEqual([]);
|
|
78
|
+
expect(errorsIn('shellWorker.ts'), `tsc output:\n${output}`).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The floor may be RAISED by an app and never lowered — the asymmetry is the whole policy.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from 'bun:test';
|
|
5
|
+
import { DEFAULT_MIN_PASSWORD_BYTES, resolveMinPasswordBytes } from './passwordPolicy';
|
|
6
|
+
|
|
7
|
+
describe('resolveMinPasswordBytes', () => {
|
|
8
|
+
it('is the shared default when an app asks for nothing', () => {
|
|
9
|
+
expect(resolveMinPasswordBytes()).toBe(DEFAULT_MIN_PASSWORD_BYTES);
|
|
10
|
+
expect(DEFAULT_MIN_PASSWORD_BYTES).toBe(9);
|
|
11
|
+
});
|
|
12
|
+
it('lets an app raise it, visibly, at its own call site', () => {
|
|
13
|
+
expect(resolveMinPasswordBytes(14)).toBe(14);
|
|
14
|
+
});
|
|
15
|
+
it('🔴 refuses to go BELOW the default, and refuses a non-integer', () => {
|
|
16
|
+
expect(() => resolveMinPasswordBytes(8)).toThrow(/may only be RAISED/);
|
|
17
|
+
expect(() => resolveMinPasswordBytes(9.5)).toThrow(/must be an integer/);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a satellite's owner-password floor is decided.
|
|
3
|
+
*
|
|
4
|
+
* ── Why this is shared, and why the default is 9 ────────────────────────────
|
|
5
|
+
* Three apps had independently written a `MIN_PASSWORD_BYTES` constant into
|
|
6
|
+
* their own `scripts/owner.ts`, and they had already drifted (9, 14, 14) with
|
|
7
|
+
* a paragraph in each explaining itself against the others. A floor is a
|
|
8
|
+
* POLICY, and a policy copied three times is three policies.
|
|
9
|
+
*
|
|
10
|
+
* The default is {@link DEFAULT_MIN_PASSWORD_BYTES} = 9, which is the floor the
|
|
11
|
+
* owner wants for an ordinary satellite: long enough that it is not a PIN, short
|
|
12
|
+
* enough that it does not push the owner toward writing it down or reusing one.
|
|
13
|
+
* Argon2id — what every caller hashes with — is what actually carries the cost
|
|
14
|
+
* of a guess here; these apps have exactly ONE principal and no enumerable user
|
|
15
|
+
* list, so the marginal value of characters 10–14 is small and the friction is
|
|
16
|
+
* not.
|
|
17
|
+
*
|
|
18
|
+
* ── `cursedbelt-core/password-policy`, since 2.3.0 (task 321) ────────────────
|
|
19
|
+
* The floor that exists so it cannot drift was itself copied — `src/kit/passwordPolicy.ts`
|
|
20
|
+
* in `auth`, `patterns` and `vault`. It is here, in the isomorphic tier, because `vault`
|
|
21
|
+
* enforces it in the BROWSER (`src/crypto/vaultCipher.ts`) as well as on the server, and a
|
|
22
|
+
* floor checked in two runtimes must be one import in both. It imports nothing.
|
|
23
|
+
*
|
|
24
|
+
* ── Raising it is an APP's decision, and it is the only direction ───────────
|
|
25
|
+
* {@link resolveMinPasswordBytes} takes an optional override and **refuses to
|
|
26
|
+
* go below the default**. An app that genuinely runs at higher stakes says so
|
|
27
|
+
* at its own call site, in one visible argument, with a comment saying why.
|
|
28
|
+
* No app overrides it today — the retired orch companion app did (14, as the single
|
|
29
|
+
* internet-facing door into the host that runs `claude` in every repo) until
|
|
30
|
+
* the owner dropped it to the shared floor on 2026-07-26.
|
|
31
|
+
*
|
|
32
|
+
* The asymmetry is the whole point: a shared default can be raised locally
|
|
33
|
+
* where the risk is local, but nothing can quietly lower the floor for
|
|
34
|
+
* everyone — which is exactly the mistake a bare exported constant invites.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The floor, in BYTES as typed (not codepoints — callers measure the raw bytes
|
|
39
|
+
* they read, so a multi-byte character counts as more than one, which is the
|
|
40
|
+
* conservative direction).
|
|
41
|
+
*/
|
|
42
|
+
export const DEFAULT_MIN_PASSWORD_BYTES = 9;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The floor this app enforces: the default, or a higher one it asks for.
|
|
46
|
+
*
|
|
47
|
+
* Throws on an override below the default rather than silently clamping. A
|
|
48
|
+
* caller passing 6 believes it is enforcing 6; being handed 9 without a word is
|
|
49
|
+
* how a security floor becomes folklore.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveMinPasswordBytes(override?: number): number {
|
|
52
|
+
if (override === undefined) return DEFAULT_MIN_PASSWORD_BYTES;
|
|
53
|
+
if (!Number.isInteger(override)) {
|
|
54
|
+
throw new Error(`minPasswordBytes must be an integer, got ${override}`);
|
|
55
|
+
}
|
|
56
|
+
if (override < DEFAULT_MIN_PASSWORD_BYTES) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`minPasswordBytes may only be RAISED above the shared default of ` +
|
|
59
|
+
`${DEFAULT_MIN_PASSWORD_BYTES}; refusing ${override}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return override;
|
|
63
|
+
}
|
|
@@ -108,6 +108,16 @@ const LEAVES = [
|
|
|
108
108
|
/** Wire-format types + the method⇄abbreviation maps. Trivially isomorphic on purpose. */
|
|
109
109
|
evaluates: 'METHOD_TO_ABBREV',
|
|
110
110
|
},
|
|
111
|
+
{
|
|
112
|
+
subpath: './offline/shell-worker',
|
|
113
|
+
/** Bundled into an app's service worker, where every byte ships on every install. */
|
|
114
|
+
evaluates: 'installShellWorker',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
subpath: './password-policy',
|
|
118
|
+
/** The owner-password floor (2.3.0, task 321) — enforced in a browser bundle as well as a server. */
|
|
119
|
+
evaluates: 'resolveMinPasswordBytes',
|
|
120
|
+
},
|
|
111
121
|
] as const;
|
|
112
122
|
|
|
113
123
|
/**
|