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.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `app-config.json` — the single schema the compiler AND the runtime import
3
3
  * (02-MASTER-ENGINES §2). Nothing else may re-declare this shape: a second copy
4
- * is how `privateMedia` without `guard` parsed as valid for a year (J2-1).
4
+ * is how a feature without the engine it needs parsed as valid for a year (J2-1).
5
5
  *
6
6
  * Two laws are encoded here rather than in prose:
7
7
  *
@@ -9,8 +9,8 @@
9
9
  * `features` block — is a parse ERROR (`unknown-config-key`), never silently
10
10
  * stripped. A typo'd flag that gets dropped reads as "the feature is off" at
11
11
  * runtime, which is indistinguishable from a bug in the feature.
12
- * 2. `.superRefine()` cross-engine dependencies. `privateMedia` needs `guard`,
13
- * `oralHistory` needs `asset`, `auth.enabled` needs `guard`, and the saas
12
+ * 2. `.superRefine()` cross-engine dependencies. `oralHistory` needs `asset`,
13
+ * `auth.enabled` needs `guard`, and the saas
14
14
  * profile needs `guard` + `rbac:true`. These are structural: the config
15
15
  * references something that will not exist at runtime, so it dies at V0 —
16
16
  * fail-closed on structure (04 §0).
@@ -41,7 +41,6 @@ export declare const AssetFeatures: z.ZodObject<{
41
41
  editor: z.ZodDefault<z.ZodBoolean>;
42
42
  fileBrowser: z.ZodDefault<z.ZodBoolean>;
43
43
  taggedFiles: z.ZodDefault<z.ZodBoolean>;
44
- privateMedia: z.ZodDefault<z.ZodBoolean>;
45
44
  vaultSource: z.ZodDefault<z.ZodBoolean>;
46
45
  ocr: z.ZodDefault<z.ZodBoolean>;
47
46
  imageStudio: z.ZodDefault<z.ZodBoolean>;
@@ -127,7 +126,6 @@ export declare const FeaturesByEngine: {
127
126
  editor: z.ZodDefault<z.ZodBoolean>;
128
127
  fileBrowser: z.ZodDefault<z.ZodBoolean>;
129
128
  taggedFiles: z.ZodDefault<z.ZodBoolean>;
130
- privateMedia: z.ZodDefault<z.ZodBoolean>;
131
129
  vaultSource: z.ZodDefault<z.ZodBoolean>;
132
130
  ocr: z.ZodDefault<z.ZodBoolean>;
133
131
  imageStudio: z.ZodDefault<z.ZodBoolean>;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `app-config.json` — the single schema the compiler AND the runtime import
3
3
  * (02-MASTER-ENGINES §2). Nothing else may re-declare this shape: a second copy
4
- * is how `privateMedia` without `guard` parsed as valid for a year (J2-1).
4
+ * is how a feature without the engine it needs parsed as valid for a year (J2-1).
5
5
  *
6
6
  * Two laws are encoded here rather than in prose:
7
7
  *
@@ -9,8 +9,8 @@
9
9
  * `features` block — is a parse ERROR (`unknown-config-key`), never silently
10
10
  * stripped. A typo'd flag that gets dropped reads as "the feature is off" at
11
11
  * runtime, which is indistinguishable from a bug in the feature.
12
- * 2. `.superRefine()` cross-engine dependencies. `privateMedia` needs `guard`,
13
- * `oralHistory` needs `asset`, `auth.enabled` needs `guard`, and the saas
12
+ * 2. `.superRefine()` cross-engine dependencies. `oralHistory` needs `asset`,
13
+ * `auth.enabled` needs `guard`, and the saas
14
14
  * profile needs `guard` + `rbac:true`. These are structural: the config
15
15
  * references something that will not exist at runtime, so it dies at V0 —
16
16
  * fail-closed on structure (04 §0).
@@ -31,7 +31,9 @@ export const AssetFeatures = z
31
31
  editor: z.boolean().default(false), // lazy chunk; joins bundleKey
32
32
  fileBrowser: z.boolean().default(false),
33
33
  taggedFiles: z.boolean().default(false),
34
- privateMedia: z.boolean().default(false), // requires guard engine — enforced in superRefine
34
+ // `privateMedia` was here until 2.3.0 (task 330). Its server module and the `pfs/` keyspace
35
+ // were deleted from cursedbelt-server in 4.0.0, so the flag named a tier that no longer
36
+ // exists; `.strict()` now refuses it as `unknown-config-key`, which is the honest answer.
35
37
  vaultSource: z.boolean().default(false),
36
38
  ocr: z.boolean().default(false),
37
39
  imageStudio: z.boolean().default(false),
@@ -277,8 +279,6 @@ export const AppConfigSchema = z
277
279
  }
278
280
  const f = parsed.data;
279
281
  // (c) cross-engine dependencies — hard blocks, not comments (J2-1)
280
- if (inst.engine === 'asset' && f.privateMedia === true && !engineSet.has('guard'))
281
- issue(['instances', i, 'features', 'privateMedia'], `'privateMedia' requires the 'guard' engine in engines[] (engine-dependency-missing).`);
282
282
  if (inst.engine === 'knowledge' && f.oralHistory === true && !engineSet.has('asset'))
283
283
  issue(['instances', i, 'features', 'oralHistory'], `'oralHistory' requires the 'asset' engine for its media player tier (engine-dependency-missing).`);
284
284
  });
@@ -0,0 +1,185 @@
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
+ /** Where a request goes. */
44
+ export type ShellRoute =
45
+ /** A document navigation — network-first, cached-document fallback offline. */
46
+ 'navigate'
47
+ /** A same-origin static asset (JS/CSS/WASM/font/image/JSON) — cache-first. */
48
+ | 'asset'
49
+ /** An auth-gated API call — never cached, never answered from a cache. */
50
+ | 'api'
51
+ /** Everything else (non-GET, cross-origin, `passthroughPrefixes`) — untouched. */
52
+ | 'passthrough';
53
+ /** The facts about a request that classification reads — nothing else. */
54
+ export interface RequestFacts {
55
+ method: string;
56
+ /** Same origin as the worker (`new URL(request.url).origin === location.origin`). */
57
+ sameOrigin: boolean;
58
+ /** URL pathname, no query. */
59
+ pathname: string;
60
+ /** `request.mode === 'navigate'`. */
61
+ isNavigate: boolean;
62
+ }
63
+ export interface ShellWorkerConfig {
64
+ /**
65
+ * Every cache this worker owns is named `<cachePrefix><version>`, and `activate`
66
+ * deletes every OTHER cache starting with `cachePrefix`. So the prefix must be one
67
+ * nothing else in the app shares — `roms-shell-`, not `roms-`, or the app's own
68
+ * digest-keyed caches go with it.
69
+ */
70
+ cachePrefix: string;
71
+ /** Bump to evict the previous shell on the next activate. */
72
+ version: string;
73
+ /**
74
+ * Same-origin paths fetched into the cache at install. `shellPath` is always
75
+ * precached whether listed or not — a shell fallback with no shell is no fallback.
76
+ */
77
+ precache: readonly string[];
78
+ /**
79
+ * Extra API prefixes. `/api/` is ALWAYS one of them and cannot be removed — see the
80
+ * header. Default: `['/api/']`.
81
+ */
82
+ apiPrefixes?: readonly string[];
83
+ /**
84
+ * Same-origin prefixes the worker must leave alone even for GET assets — e.g. roms'
85
+ * `/_nes-roms/`, whose bytes the app caches itself under a content digest.
86
+ */
87
+ passthroughPrefixes?: readonly string[];
88
+ /**
89
+ * Exact pathnames that must NEVER be answered with the shell offline (roms'
90
+ * `/emulator-frame.html`). An offline miss on one rejects instead. See the header.
91
+ */
92
+ noShellFallback?: readonly string[];
93
+ /** Which same-origin pathnames count as static assets. Default: `DEFAULT_ASSET_EXT`. */
94
+ assetExtensions?: RegExp;
95
+ /** The app shell's path, the navigation fallback. Default: `'/'`. */
96
+ shellPath?: string;
97
+ }
98
+ /** A config with every default applied and every refusal already checked. */
99
+ export interface ResolvedShellWorkerConfig {
100
+ cachePrefix: string;
101
+ version: string;
102
+ cacheName: string;
103
+ precache: readonly string[];
104
+ apiPrefixes: readonly string[];
105
+ passthroughPrefixes: readonly string[];
106
+ noShellFallback: readonly string[];
107
+ assetExtensions: RegExp;
108
+ shellPath: string;
109
+ }
110
+ /** roms' `ASSET_EXT`, verbatim. */
111
+ export declare const DEFAULT_ASSET_EXT: RegExp;
112
+ /** Always an API prefix, whatever the config says. */
113
+ export declare const ALWAYS_API_PREFIX = "/api/";
114
+ /**
115
+ * Classify a request. Order matters and is roms' order: non-GET and cross-origin
116
+ * first, then the API (so an API path that happens to end in `.json` is still `api`),
117
+ * then the app's own passthrough prefixes, then navigation, then assets.
118
+ */
119
+ export declare function classifyRequest(facts: RequestFacts, config?: Pick<ShellWorkerConfig, 'apiPrefixes' | 'passthroughPrefixes' | 'assetExtensions'>): ShellRoute;
120
+ /** Whether a response on this route may be written to the cache. Never `api`. */
121
+ export declare function shouldCache(route: ShellRoute): boolean;
122
+ /** May an offline miss on this navigation be answered with the shell? */
123
+ export declare function mayFallBackToShell(pathname: string, config?: Pick<ShellWorkerConfig, 'noShellFallback'>): boolean;
124
+ /**
125
+ * Apply defaults and refuse the configs that would break a promise in the header.
126
+ * `installShellWorker` calls this; it is exported so an app's own test can prove its
127
+ * config is accepted without standing up a worker.
128
+ */
129
+ export declare function resolveShellWorkerConfig(config: ShellWorkerConfig): ResolvedShellWorkerConfig;
130
+ export interface ShellWorkerRequest {
131
+ readonly url: string;
132
+ readonly method: string;
133
+ readonly mode: string;
134
+ }
135
+ /**
136
+ * The structural mirror of `RequestInfo | URL` — a request, a string, or a URL-like —
137
+ * which is what the real `fetch`, `Cache` and `CacheStorage` accept. It has to be the
138
+ * whole union: with `ShellWorkerRequest` alone the real `self` is refused, because a
139
+ * `URL` is not a request in either direction (measured by the webworker spec).
140
+ */
141
+ export type ShellWorkerRequestInfo = ShellWorkerRequest | string | {
142
+ readonly href: string;
143
+ };
144
+ export interface ShellWorkerResponse {
145
+ readonly ok: boolean;
146
+ clone(): ShellWorkerResponse;
147
+ }
148
+ export interface ShellWorkerCache {
149
+ addAll(requests: string[]): Promise<void>;
150
+ put(request: ShellWorkerRequestInfo, response: ShellWorkerResponse): Promise<void>;
151
+ }
152
+ export interface ShellWorkerCacheStorage {
153
+ open(cacheName: string): Promise<ShellWorkerCache>;
154
+ keys(): Promise<string[]>;
155
+ delete(cacheName: string): Promise<boolean>;
156
+ match(request: ShellWorkerRequestInfo, options?: {
157
+ ignoreSearch?: boolean;
158
+ }): Promise<ShellWorkerResponse | undefined>;
159
+ }
160
+ export interface ShellExtendableEvent {
161
+ waitUntil(promise: Promise<unknown>): void;
162
+ }
163
+ export interface ShellFetchEvent extends ShellExtendableEvent {
164
+ readonly request: ShellWorkerRequest;
165
+ respondWith(response: Promise<ShellWorkerResponse>): void;
166
+ }
167
+ export interface ShellWorkerScope {
168
+ addEventListener(type: 'install', listener: (event: ShellExtendableEvent) => void): void;
169
+ addEventListener(type: 'activate', listener: (event: ShellExtendableEvent) => void): void;
170
+ addEventListener(type: 'fetch', listener: (event: ShellFetchEvent) => void): void;
171
+ readonly caches: ShellWorkerCacheStorage;
172
+ readonly clients: {
173
+ claim(): Promise<void>;
174
+ };
175
+ readonly location: {
176
+ readonly origin: string;
177
+ };
178
+ fetch(request: ShellWorkerRequestInfo): Promise<ShellWorkerResponse>;
179
+ skipWaiting(): Promise<void>;
180
+ }
181
+ /**
182
+ * Wire `install`, `activate` and `fetch` onto `scope` (the worker's `self`).
183
+ * Returns the resolved config, so an app can read `cacheName` for its own use.
184
+ */
185
+ export declare function installShellWorker(scope: ShellWorkerScope, config: ShellWorkerConfig): ResolvedShellWorkerConfig;
@@ -0,0 +1,200 @@
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
+ /** roms' `ASSET_EXT`, verbatim. */
44
+ export const DEFAULT_ASSET_EXT = /\.(?:js|mjs|css|wasm|woff2?|ttf|png|jpe?g|webp|svg|ico|json)$/i;
45
+ /** Always an API prefix, whatever the config says. */
46
+ export const ALWAYS_API_PREFIX = '/api/';
47
+ const apiPrefixesOf = (config) => {
48
+ const extra = config.apiPrefixes ?? [];
49
+ return extra.includes(ALWAYS_API_PREFIX) ? extra : [ALWAYS_API_PREFIX, ...extra];
50
+ };
51
+ const startsWithAny = (pathname, prefixes) => prefixes.some((prefix) => pathname.startsWith(prefix));
52
+ /**
53
+ * Classify a request. Order matters and is roms' order: non-GET and cross-origin
54
+ * first, then the API (so an API path that happens to end in `.json` is still `api`),
55
+ * then the app's own passthrough prefixes, then navigation, then assets.
56
+ */
57
+ export function classifyRequest(facts, config = {}) {
58
+ if (facts.method !== 'GET')
59
+ return 'passthrough';
60
+ if (!facts.sameOrigin)
61
+ return 'passthrough';
62
+ if (startsWithAny(facts.pathname, apiPrefixesOf(config)))
63
+ return 'api';
64
+ if (startsWithAny(facts.pathname, config.passthroughPrefixes ?? []))
65
+ return 'passthrough';
66
+ if (facts.isNavigate)
67
+ return 'navigate';
68
+ if ((config.assetExtensions ?? DEFAULT_ASSET_EXT).test(facts.pathname))
69
+ return 'asset';
70
+ return 'passthrough';
71
+ }
72
+ /** Whether a response on this route may be written to the cache. Never `api`. */
73
+ export function shouldCache(route) {
74
+ return route === 'asset' || route === 'navigate';
75
+ }
76
+ /** May an offline miss on this navigation be answered with the shell? */
77
+ export function mayFallBackToShell(pathname, config = {}) {
78
+ return !(config.noShellFallback ?? []).includes(pathname);
79
+ }
80
+ /**
81
+ * Apply defaults and refuse the configs that would break a promise in the header.
82
+ * `installShellWorker` calls this; it is exported so an app's own test can prove its
83
+ * config is accepted without standing up a worker.
84
+ */
85
+ export function resolveShellWorkerConfig(config) {
86
+ if (config.cachePrefix.trim() === '') {
87
+ // An empty prefix makes EVERY cache on the origin "an old version of ours", and
88
+ // activate would delete the app's other caches with it.
89
+ throw new Error('shell worker: cachePrefix must not be empty — activate deletes every cache it prefixes');
90
+ }
91
+ if (config.version.trim() === '')
92
+ throw new Error('shell worker: version must not be empty');
93
+ const shellPath = config.shellPath ?? '/';
94
+ const apiPrefixes = apiPrefixesOf(config);
95
+ const precache = config.precache.includes(shellPath) ? [...config.precache] : [shellPath, ...config.precache];
96
+ for (const path of precache) {
97
+ if (!path.startsWith('/')) {
98
+ throw new Error(`shell worker: precache entry "${path}" must be a same-origin path starting with "/"`);
99
+ }
100
+ if (startsWithAny(path, apiPrefixes)) {
101
+ throw new Error(`shell worker: precache entry "${path}" is under an API prefix — the API is never cached`);
102
+ }
103
+ }
104
+ if (config.noShellFallback?.includes(shellPath)) {
105
+ throw new Error(`shell worker: the shell path "${shellPath}" cannot also be in noShellFallback`);
106
+ }
107
+ return {
108
+ cachePrefix: config.cachePrefix,
109
+ version: config.version,
110
+ cacheName: `${config.cachePrefix}${config.version}`,
111
+ precache,
112
+ apiPrefixes,
113
+ passthroughPrefixes: config.passthroughPrefixes ?? [],
114
+ noShellFallback: config.noShellFallback ?? [],
115
+ assetExtensions: config.assetExtensions ?? DEFAULT_ASSET_EXT,
116
+ shellPath,
117
+ };
118
+ }
119
+ /**
120
+ * Wire `install`, `activate` and `fetch` onto `scope` (the worker's `self`).
121
+ * Returns the resolved config, so an app can read `cacheName` for its own use.
122
+ */
123
+ export function installShellWorker(scope, config) {
124
+ const resolved = resolveShellWorkerConfig(config);
125
+ const { caches } = scope;
126
+ // Written after the response is handed back, like roms' `cache.put` — but kept
127
+ // alive with `waitUntil` so the worker is not stopped mid-write, and swallowed on
128
+ // failure: a full quota must not turn a good network response into an error.
129
+ const store = (event, route, response) => {
130
+ if (!shouldCache(route) || !response.ok)
131
+ return;
132
+ const copy = response.clone();
133
+ event.waitUntil(caches
134
+ .open(resolved.cacheName)
135
+ .then((cache) => cache.put(event.request, copy))
136
+ .catch(() => undefined));
137
+ };
138
+ const cacheFirst = async (event) => {
139
+ const cached = await caches.match(event.request);
140
+ if (cached)
141
+ return cached;
142
+ const response = await scope.fetch(event.request);
143
+ store(event, 'asset', response);
144
+ return response;
145
+ };
146
+ const networkFirst = async (event) => {
147
+ const { request } = event;
148
+ try {
149
+ const response = await scope.fetch(request);
150
+ store(event, 'navigate', response);
151
+ return response;
152
+ }
153
+ catch (error) {
154
+ // Offline. The exact URL first, then the same path ignoring the query — roms'
155
+ // frame is `/emulator-frame.html?system=nes`, and one cached copy answers for
156
+ // every console.
157
+ const cached = (await caches.match(request)) ?? (await caches.match(request, { ignoreSearch: true }));
158
+ if (cached)
159
+ return cached;
160
+ if (mayFallBackToShell(new URL(request.url).pathname, resolved)) {
161
+ const shell = await caches.match(resolved.shellPath);
162
+ if (shell)
163
+ return shell;
164
+ }
165
+ // Nothing honest to serve: reject, and the browser shows its own offline page
166
+ // rather than a document that is not the one asked for.
167
+ throw error;
168
+ }
169
+ };
170
+ scope.addEventListener('install', (event) => {
171
+ event.waitUntil(caches
172
+ .open(resolved.cacheName)
173
+ .then((cache) => cache.addAll([...resolved.precache]))
174
+ .then(() => scope.skipWaiting()));
175
+ });
176
+ scope.addEventListener('activate', (event) => {
177
+ event.waitUntil(caches
178
+ .keys()
179
+ .then((keys) => Promise.all(keys
180
+ .filter((key) => key.startsWith(resolved.cachePrefix) && key !== resolved.cacheName)
181
+ .map((key) => caches.delete(key))))
182
+ .then(() => scope.clients.claim()));
183
+ });
184
+ scope.addEventListener('fetch', (event) => {
185
+ const url = new URL(event.request.url);
186
+ const route = classifyRequest({
187
+ method: event.request.method,
188
+ sameOrigin: url.origin === scope.location.origin,
189
+ pathname: url.pathname,
190
+ isNavigate: event.request.mode === 'navigate',
191
+ }, resolved);
192
+ if (route === 'asset')
193
+ event.respondWith(cacheFirst(event));
194
+ else if (route === 'navigate')
195
+ event.respondWith(networkFirst(event));
196
+ // `api` and `passthrough`: no respondWith, so the browser goes to the network as
197
+ // if this worker did not exist. Never a cache read, never a cache write.
198
+ });
199
+ return resolved;
200
+ }
@@ -0,0 +1,49 @@
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
+ * The floor, in BYTES as typed (not codepoints — callers measure the raw bytes
38
+ * they read, so a multi-byte character counts as more than one, which is the
39
+ * conservative direction).
40
+ */
41
+ export declare const DEFAULT_MIN_PASSWORD_BYTES = 9;
42
+ /**
43
+ * The floor this app enforces: the default, or a higher one it asks for.
44
+ *
45
+ * Throws on an override below the default rather than silently clamping. A
46
+ * caller passing 6 believes it is enforcing 6; being handed 9 without a word is
47
+ * how a security floor becomes folklore.
48
+ */
49
+ export declare function resolveMinPasswordBytes(override?: number): number;
@@ -0,0 +1,60 @@
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
+ * The floor, in BYTES as typed (not codepoints — callers measure the raw bytes
38
+ * they read, so a multi-byte character counts as more than one, which is the
39
+ * conservative direction).
40
+ */
41
+ export const DEFAULT_MIN_PASSWORD_BYTES = 9;
42
+ /**
43
+ * The floor this app enforces: the default, or a higher one it asks for.
44
+ *
45
+ * Throws on an override below the default rather than silently clamping. A
46
+ * caller passing 6 believes it is enforcing 6; being handed 9 without a word is
47
+ * how a security floor becomes folklore.
48
+ */
49
+ export function resolveMinPasswordBytes(override) {
50
+ if (override === undefined)
51
+ return DEFAULT_MIN_PASSWORD_BYTES;
52
+ if (!Number.isInteger(override)) {
53
+ throw new Error(`minPasswordBytes must be an integer, got ${override}`);
54
+ }
55
+ if (override < DEFAULT_MIN_PASSWORD_BYTES) {
56
+ throw new Error(`minPasswordBytes may only be RAISED above the shared default of ` +
57
+ `${DEFAULT_MIN_PASSWORD_BYTES}; refusing ${override}`);
58
+ }
59
+ return override;
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-core",
3
- "version": "2.1.1",
3
+ "version": "2.3.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "Framework-agnostic core of the cursedbelt split — isomorphic domain logic. cwip below it, cursedbelt-server and cursedbelt (react) above it.",
@@ -251,6 +251,12 @@
251
251
  "source": "./src/core/net/reconnectSchedule.ts",
252
252
  "import": "./dist/core/net/reconnectSchedule.js"
253
253
  },
254
+ "./offline/shell-worker": {
255
+ "types": "./dist/core/offline/shellWorker.d.ts",
256
+ "bun": "./src/core/offline/shellWorker.ts",
257
+ "source": "./src/core/offline/shellWorker.ts",
258
+ "import": "./dist/core/offline/shellWorker.js"
259
+ },
254
260
  "./palette": {
255
261
  "types": "./dist/core/palette/index.d.ts",
256
262
  "bun": "./src/core/palette/index.ts",
@@ -275,6 +281,12 @@
275
281
  "source": "./src/core/palette/ShortcutRegistry.ts",
276
282
  "import": "./dist/core/palette/ShortcutRegistry.js"
277
283
  },
284
+ "./password-policy": {
285
+ "types": "./dist/core/passwordPolicy.d.ts",
286
+ "bun": "./src/core/passwordPolicy.ts",
287
+ "source": "./src/core/passwordPolicy.ts",
288
+ "import": "./dist/core/passwordPolicy.js"
289
+ },
278
290
  "./session/claims": {
279
291
  "types": "./dist/core/session/claims.d.ts",
280
292
  "bun": "./src/core/session/claims.ts",
@@ -51,13 +51,11 @@ describe('strictness (02 §2.2)', () => {
51
51
  });
52
52
 
53
53
  describe('cross-engine dependencies are code, not comments (J2-1)', () => {
54
- it('blocks privateMedia without the guard engine', () => {
55
- const config = base({ instances: [{ engine: 'asset', id: 'gallery', features: { privateMedia: true } }] });
56
- expect(parse(config).success).toBe(false);
57
- expect(messages(config).join(' ')).toContain("'privateMedia' requires the 'guard' engine");
58
- });
59
-
60
- it('allows privateMedia once guard is activated', () => {
54
+ // 🔴 `privateMedia` was the other half of this mechanism's proof until 2.3.0 (task 330). The
55
+ // flag is gone, so `oralHistory` → `asset` is now the ONE feature-to-engine dependency, and it
56
+ // is proven both ways below — blocked without its engine, allowed with it — so the
57
+ // `engine-dependency-missing` diagnostic keeps a failure path AND a pass path.
58
+ it('refuses the retired privateMedia flag as an unknown key, never silently ignores it', () => {
61
59
  const config = base({
62
60
  engines: ['asset', 'guard'],
63
61
  instances: [
@@ -65,6 +63,20 @@ describe('cross-engine dependencies are code, not comments (J2-1)', () => {
65
63
  { engine: 'guard', id: 'guard-main' },
66
64
  ],
67
65
  });
66
+ expect(parse(config).success).toBe(false);
67
+ expect(parse(config).error?.issues.map((issue) => issue.path.join('.'))).toContain(
68
+ 'instances.0.features.privateMedia',
69
+ );
70
+ });
71
+
72
+ it('allows oralHistory once the asset engine is activated', () => {
73
+ const config = base({
74
+ engines: ['knowledge', 'asset'],
75
+ instances: [
76
+ { engine: 'knowledge', id: 'stories', features: { oralHistory: true } },
77
+ { engine: 'asset', id: 'player' },
78
+ ],
79
+ });
68
80
  expect(parse(config).success).toBe(true);
69
81
  });
70
82