cursedbelt-core 2.2.0 → 2.4.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/file-tree/fileTreeModel.d.ts +18 -1
- package/dist/core/file-tree/fileTreeModel.js +10 -0
- package/dist/core/passwordPolicy.d.ts +49 -0
- package/dist/core/passwordPolicy.js +60 -0
- package/package.json +31 -4
- package/src/config/appConfig.spec.ts +19 -7
- package/src/config/appConfig.ts +6 -9
- package/src/core/file-tree/fileTreeModel.spec.ts +43 -0
- package/src/core/file-tree/fileTreeModel.ts +28 -1
- 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
|
});
|
|
@@ -80,9 +80,26 @@ export interface FileTreeNode {
|
|
|
80
80
|
*
|
|
81
81
|
* The consumer's veto, for a rule the tree cannot know: collections uses it so a folder
|
|
82
82
|
* cannot be dragged across album boundaries, which the server has no route for. Defaults
|
|
83
|
-
* to true for folders
|
|
83
|
+
* to true for folders.
|
|
84
|
+
*
|
|
85
|
+
* 🔴 On a FILE it is the opposite switch, and it is opt-in: `true` makes the file a
|
|
86
|
+
* container of its own — a drop on the middle half of its row is `{ kind: 'into',
|
|
87
|
+
* parentId: <the file> }`. `apps/desk` is why: a workbook is a leaf in its tree, and
|
|
88
|
+
* dropping a sheet onto one makes the sheet one of its tabs (the owner, desk file `02`).
|
|
89
|
+
* The edges of the row, and the middle whenever {@link ResolveDropOptions.canDrop} vetoes
|
|
90
|
+
* the file, still mean *the folder the file is in* — so a column of such files is never a
|
|
91
|
+
* wall of rows that refuse everything that cannot go inside them. Absent or `false`, a
|
|
92
|
+
* file row means its folder along its whole height, exactly as before.
|
|
84
93
|
*/
|
|
85
94
|
acceptsDrop?: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* This row's own name matched a search the consumer ran — drawn a step heavier.
|
|
97
|
+
*
|
|
98
|
+
* For a consumer that narrows the tree itself (by content, not only by name) and keeps
|
|
99
|
+
* the folders on the way to each hit: the hits are what the eye is scanning for, and the
|
|
100
|
+
* folders kept around them are context. Omit it and nothing is emphasised.
|
|
101
|
+
*/
|
|
102
|
+
matched?: boolean;
|
|
86
103
|
/**
|
|
87
104
|
* Whether this row can be picked up. Default true once `onMove` is wired.
|
|
88
105
|
*
|
|
@@ -156,6 +156,16 @@ export function resolveDrop({ nodes, row, offsetRatio, dragId, allowReorder = fa
|
|
|
156
156
|
return { target: null, refused: true, reason: lockedReason };
|
|
157
157
|
return vetted({ target: { kind: 'into', parentId: target.id }, refused: false, reason: null });
|
|
158
158
|
};
|
|
159
|
+
// A FILE the consumer opted in as a container takes the drop on the middle of its row —
|
|
160
|
+
// see `FileTreeNode.acceptsDrop`. Vetoed, it falls through to meaning its folder below.
|
|
161
|
+
if (target.kind === 'file' && !allowReorder && target.acceptsDrop === true && target.id !== dragId) {
|
|
162
|
+
const band = (1 - INTO_ZONE) / 2;
|
|
163
|
+
if (offsetRatio > band && offsetRatio < 1 - band) {
|
|
164
|
+
const into = vetted({ target: { kind: 'into', parentId: target.id }, refused: false, reason: null });
|
|
165
|
+
if (into.target)
|
|
166
|
+
return into;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
159
169
|
// A FILE row means "the folder that row is in" — what every file explorer does, and what
|
|
160
170
|
// keeps a long list of files from being a wall of rows nothing can be dropped on.
|
|
161
171
|
if (target.kind === 'file' && !allowReorder) {
|
|
@@ -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.4.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.",
|
|
@@ -18,11 +18,12 @@
|
|
|
18
18
|
"scripts": {
|
|
19
19
|
"clean": "rm -rf dist",
|
|
20
20
|
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
|
21
|
-
"test": "NODE_ENV=development bun test src",
|
|
21
|
+
"test": "NODE_ENV=development bun test --timeout 60000 --parallel=4 src",
|
|
22
22
|
"build": "bun run clean && tsc -p tsconfig.build.json",
|
|
23
23
|
"paths": "bun run scripts/paths.ts",
|
|
24
|
-
"verify": "bun run
|
|
25
|
-
"prepublishOnly": "bun run
|
|
24
|
+
"verify": "bun run gate",
|
|
25
|
+
"prepublishOnly": "bun run gate --force",
|
|
26
|
+
"gate": "bun run scripts/paths.ts --only gate.ts"
|
|
26
27
|
},
|
|
27
28
|
"files": [
|
|
28
29
|
"dist",
|
|
@@ -281,6 +282,12 @@
|
|
|
281
282
|
"source": "./src/core/palette/ShortcutRegistry.ts",
|
|
282
283
|
"import": "./dist/core/palette/ShortcutRegistry.js"
|
|
283
284
|
},
|
|
285
|
+
"./password-policy": {
|
|
286
|
+
"types": "./dist/core/passwordPolicy.d.ts",
|
|
287
|
+
"bun": "./src/core/passwordPolicy.ts",
|
|
288
|
+
"source": "./src/core/passwordPolicy.ts",
|
|
289
|
+
"import": "./dist/core/passwordPolicy.js"
|
|
290
|
+
},
|
|
284
291
|
"./session/claims": {
|
|
285
292
|
"types": "./dist/core/session/claims.d.ts",
|
|
286
293
|
"bun": "./src/core/session/claims.ts",
|
|
@@ -381,5 +388,25 @@
|
|
|
381
388
|
"zod": "4.4.3",
|
|
382
389
|
"@happy-dom/global-registrator": "^20.10.6",
|
|
383
390
|
"hono": "4.12.28"
|
|
391
|
+
},
|
|
392
|
+
"gate": {
|
|
393
|
+
"stages": [
|
|
394
|
+
{
|
|
395
|
+
"script": "paths"
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
"script": "typecheck"
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
"script": "build"
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
"script": "test",
|
|
405
|
+
"needs": [
|
|
406
|
+
"build"
|
|
407
|
+
],
|
|
408
|
+
"why": "build starts with `rm -rf dist`, and leafSubpathsImportNothing / shippedFilesAreTracked read dist/; racing it they read a directory that is not there yet"
|
|
409
|
+
}
|
|
410
|
+
]
|
|
384
411
|
}
|
|
385
412
|
}
|
|
@@ -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'],
|
|
@@ -234,6 +234,49 @@ describe('resolveDrop', () => {
|
|
|
234
234
|
});
|
|
235
235
|
});
|
|
236
236
|
|
|
237
|
+
/**
|
|
238
|
+
* A FILE that takes drops INTO it — `acceptsDrop: true` on a file, which `apps/desk` sets on
|
|
239
|
+
* a workbook so a sheet dropped on it becomes one of its tabs.
|
|
240
|
+
*/
|
|
241
|
+
describe('resolveDrop onto a file that accepts drops', () => {
|
|
242
|
+
const book: FileTreeNode = { id: 'q3', name: 'Q3 Review', kind: 'file', acceptsDrop: true };
|
|
243
|
+
const nodes: FileTreeNode[] = [
|
|
244
|
+
folder('work', 'Work', [book, { id: 'report', name: 'report.csv', kind: 'file' }]),
|
|
245
|
+
{ id: 'loose', name: 'loose.csv', kind: 'file' },
|
|
246
|
+
];
|
|
247
|
+
const rows = flattenFileTree(nodes, []);
|
|
248
|
+
const on = (id: string, dragId: string, offsetRatio: number, canDrop?: (d: string | null, t: FileTreeDrop) => string | null) =>
|
|
249
|
+
resolveDrop({ nodes, row: rowFor(rows, id), offsetRatio, dragId, ...(canDrop ? { canDrop } : {}) });
|
|
250
|
+
|
|
251
|
+
test('the middle of its row is INTO the file', () => {
|
|
252
|
+
expect(on('q3', 'loose', 0.5).target).toEqual({ kind: 'into', parentId: 'q3' });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('its edges still mean the folder it is in', () => {
|
|
256
|
+
expect(on('q3', 'loose', 0.1).target).toEqual({ kind: 'into', parentId: 'work' });
|
|
257
|
+
expect(on('q3', 'loose', 0.9).target).toEqual({ kind: 'into', parentId: 'work' });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('🔴 a veto on the file falls back to its folder rather than refusing the row', () => {
|
|
261
|
+
const onlySheets = (dragId: string | null, target: FileTreeDrop) =>
|
|
262
|
+
target.kind === 'into' && target.parentId === 'q3' && dragId !== 'loose' ? 'Only a sheet can become a tab' : null;
|
|
263
|
+
expect(on('q3', 'report', 0.5, onlySheets).target).toEqual({ kind: 'into', parentId: 'work' });
|
|
264
|
+
expect(on('q3', 'loose', 0.5, onlySheets).target).toEqual({ kind: 'into', parentId: 'q3' });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('a file never takes itself', () => {
|
|
268
|
+
expect(on('q3', 'q3', 0.5).target).toEqual({ kind: 'into', parentId: 'work' });
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('a file WITHOUT the flag means its folder along its whole height, as before', () => {
|
|
272
|
+
expect(on('report', 'loose', 0.5).target).toEqual({ kind: 'into', parentId: 'work' });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('the chip names the file it is going into', () => {
|
|
276
|
+
expect(describeDrop(nodes, { kind: 'into', parentId: 'q3' })).toBe('Into Q3 Review');
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
237
280
|
/**
|
|
238
281
|
* 🔴 The consumer's veto, which is the seam collections needs.
|
|
239
282
|
*
|
|
@@ -83,9 +83,26 @@ export interface FileTreeNode {
|
|
|
83
83
|
*
|
|
84
84
|
* The consumer's veto, for a rule the tree cannot know: collections uses it so a folder
|
|
85
85
|
* cannot be dragged across album boundaries, which the server has no route for. Defaults
|
|
86
|
-
* to true for folders
|
|
86
|
+
* to true for folders.
|
|
87
|
+
*
|
|
88
|
+
* 🔴 On a FILE it is the opposite switch, and it is opt-in: `true` makes the file a
|
|
89
|
+
* container of its own — a drop on the middle half of its row is `{ kind: 'into',
|
|
90
|
+
* parentId: <the file> }`. `apps/desk` is why: a workbook is a leaf in its tree, and
|
|
91
|
+
* dropping a sheet onto one makes the sheet one of its tabs (the owner, desk file `02`).
|
|
92
|
+
* The edges of the row, and the middle whenever {@link ResolveDropOptions.canDrop} vetoes
|
|
93
|
+
* the file, still mean *the folder the file is in* — so a column of such files is never a
|
|
94
|
+
* wall of rows that refuse everything that cannot go inside them. Absent or `false`, a
|
|
95
|
+
* file row means its folder along its whole height, exactly as before.
|
|
87
96
|
*/
|
|
88
97
|
acceptsDrop?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* This row's own name matched a search the consumer ran — drawn a step heavier.
|
|
100
|
+
*
|
|
101
|
+
* For a consumer that narrows the tree itself (by content, not only by name) and keeps
|
|
102
|
+
* the folders on the way to each hit: the hits are what the eye is scanning for, and the
|
|
103
|
+
* folders kept around them are context. Omit it and nothing is emphasised.
|
|
104
|
+
*/
|
|
105
|
+
matched?: boolean;
|
|
89
106
|
/**
|
|
90
107
|
* Whether this row can be picked up. Default true once `onMove` is wired.
|
|
91
108
|
*
|
|
@@ -330,6 +347,16 @@ export function resolveDrop({
|
|
|
330
347
|
return vetted({ target: { kind: 'into', parentId: target.id }, refused: false, reason: null });
|
|
331
348
|
};
|
|
332
349
|
|
|
350
|
+
// A FILE the consumer opted in as a container takes the drop on the middle of its row —
|
|
351
|
+
// see `FileTreeNode.acceptsDrop`. Vetoed, it falls through to meaning its folder below.
|
|
352
|
+
if (target.kind === 'file' && !allowReorder && target.acceptsDrop === true && target.id !== dragId) {
|
|
353
|
+
const band = (1 - INTO_ZONE) / 2;
|
|
354
|
+
if (offsetRatio > band && offsetRatio < 1 - band) {
|
|
355
|
+
const into = vetted({ target: { kind: 'into', parentId: target.id }, refused: false, reason: null });
|
|
356
|
+
if (into.target) return into;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
333
360
|
// A FILE row means "the folder that row is in" — what every file explorer does, and what
|
|
334
361
|
// keeps a long list of files from being a wall of rows nothing can be dropped on.
|
|
335
362
|
if (target.kind === 'file' && !allowReorder) {
|
|
@@ -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
|
/**
|