cursedbelt-core 2.2.0 → 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/passwordPolicy.d.ts +49 -0
- package/dist/core/passwordPolicy.js +60 -0
- package/package.json +7 -1
- package/src/config/appConfig.spec.ts +19 -7
- package/src/config/appConfig.ts +6 -9
- package/src/core/passwordPolicy.spec.ts +19 -0
- package/src/core/passwordPolicy.ts +63 -0
- package/src/leafSubpathsImportNothing.spec.ts +5 -0
|
@@ -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
|
|
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. `
|
|
13
|
-
* `
|
|
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>;
|
package/dist/config/appConfig.js
CHANGED
|
@@ -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
|
|
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. `
|
|
13
|
-
* `
|
|
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
|
|
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,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.
|
|
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.",
|
|
@@ -281,6 +281,12 @@
|
|
|
281
281
|
"source": "./src/core/palette/ShortcutRegistry.ts",
|
|
282
282
|
"import": "./dist/core/palette/ShortcutRegistry.js"
|
|
283
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
|
+
},
|
|
284
290
|
"./session/claims": {
|
|
285
291
|
"types": "./dist/core/session/claims.d.ts",
|
|
286
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
package/src/config/appConfig.ts
CHANGED
|
@@ -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
|
|
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. `
|
|
13
|
-
* `
|
|
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).
|
|
@@ -35,7 +35,9 @@ export const AssetFeatures = z
|
|
|
35
35
|
editor: z.boolean().default(false), // lazy chunk; joins bundleKey
|
|
36
36
|
fileBrowser: z.boolean().default(false),
|
|
37
37
|
taggedFiles: z.boolean().default(false),
|
|
38
|
-
privateMedia
|
|
38
|
+
// `privateMedia` was here until 2.3.0 (task 330). Its server module and the `pfs/` keyspace
|
|
39
|
+
// were deleted from cursedbelt-server in 4.0.0, so the flag named a tier that no longer
|
|
40
|
+
// exists; `.strict()` now refuses it as `unknown-config-key`, which is the honest answer.
|
|
39
41
|
vaultSource: z.boolean().default(false),
|
|
40
42
|
ocr: z.boolean().default(false),
|
|
41
43
|
imageStudio: z.boolean().default(false),
|
|
@@ -326,11 +328,6 @@ export const AppConfigSchema = z
|
|
|
326
328
|
const f = parsed.data as Record<string, unknown>;
|
|
327
329
|
|
|
328
330
|
// (c) cross-engine dependencies — hard blocks, not comments (J2-1)
|
|
329
|
-
if (inst.engine === 'asset' && f.privateMedia === true && !engineSet.has('guard'))
|
|
330
|
-
issue(
|
|
331
|
-
['instances', i, 'features', 'privateMedia'],
|
|
332
|
-
`'privateMedia' requires the 'guard' engine in engines[] (engine-dependency-missing).`,
|
|
333
|
-
);
|
|
334
331
|
if (inst.engine === 'knowledge' && f.oralHistory === true && !engineSet.has('asset'))
|
|
335
332
|
issue(
|
|
336
333
|
['instances', i, 'features', 'oralHistory'],
|
|
@@ -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
|
+
}
|
|
@@ -113,6 +113,11 @@ const LEAVES = [
|
|
|
113
113
|
/** Bundled into an app's service worker, where every byte ships on every install. */
|
|
114
114
|
evaluates: 'installShellWorker',
|
|
115
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
|
+
},
|
|
116
121
|
] as const;
|
|
117
122
|
|
|
118
123
|
/**
|