cursedbelt-core 2.5.0 → 2.7.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.
@@ -101,10 +101,12 @@ export interface FileTreeNode {
101
101
  * container of its own — a drop on the middle half of its row is `{ kind: 'into',
102
102
  * parentId: <the file> }`. `apps/desk` is why: a workbook is a leaf in its tree, and
103
103
  * dropping a sheet onto one makes the sheet one of its tabs (the owner, desk file `02`).
104
- * The edges of the row, and the middle whenever {@link ResolveDropOptions.canDrop} vetoes
105
- * the file, still mean *the folder the file is in* — so a column of such files is never a
106
- * wall of rows that refuse everything that cannot go inside them. Absent or `false`, a
107
- * file row means its folder along its whole height, exactly as before.
104
+ * The edges of the row still mean *the folder the file is in*, and so does the middle for
105
+ * any drag {@link ResolveDropOptions.takesDrop} says is not a candidate — so a column of
106
+ * such files is never a wall of rows that refuse everything that cannot go inside them.
107
+ * A candidate that {@link ResolveDropOptions.canDrop} vetoes is REFUSED with its words:
108
+ * the file was the target, and why it cannot take this one has a remedy worth saying.
109
+ * Absent or `false`, a file row means its folder along its whole height, as before.
108
110
  */
109
111
  acceptsDrop?: boolean;
110
112
  /**
@@ -233,6 +235,17 @@ export interface ResolveDropOptions {
233
235
  * this tree is that an impossible drop is never drawn as possible.
234
236
  */
235
237
  canDrop?: (dragId: string | null, target: FileTreeDrop) => string | null;
238
+ /**
239
+ * Whether a file that accepts drops (`acceptsDrop: true` on a FILE) is a candidate for
240
+ * THIS drag at all. Default: every drag is.
241
+ *
242
+ * 🔴 This is the question {@link canDrop} cannot answer, because its `null` means "allow":
243
+ * `apps/desk` takes a SHEET into a workbook and never a note, so a note over a workbook
244
+ * must mean the workbook's folder — not a refusal, and not a merge. `false` here is that:
245
+ * the row goes back to meaning its folder. `true` makes the file the target, and then
246
+ * `canDrop` decides, in words — *"“Beta” has 2 sheets"* is a refusal with a remedy.
247
+ */
248
+ takesDrop?: (dragId: string | null, file: FileTreeNode) => boolean;
236
249
  }
237
250
  /**
238
251
  * Where the pointer currently is, as a decision.
@@ -241,7 +254,7 @@ export interface ResolveDropOptions {
241
254
  * offered.** Every refusal below returns a reason rather than a null target, so the cursor
242
255
  * and the chip can say no while the row is still in the air.
243
256
  */
244
- export declare function resolveDrop({ nodes, row, offsetRatio, dragId, allowReorder, lockedReason, canDrop, }: ResolveDropOptions): DropOffer;
257
+ export declare function resolveDrop({ nodes, row, offsetRatio, dragId, allowReorder, lockedReason, canDrop, takesDrop, }: ResolveDropOptions): DropOffer;
245
258
  export interface KeyboardMoveOptions {
246
259
  nodes: readonly FileTreeNode[];
247
260
  /** The rows on screen — siblings are read off these, which is every sibling by construction. */
@@ -125,7 +125,7 @@ export const INTO_ZONE = 0.5;
125
125
  * offered.** Every refusal below returns a reason rather than a null target, so the cursor
126
126
  * and the chip can say no while the row is still in the air.
127
127
  */
128
- export function resolveDrop({ nodes, row, offsetRatio, dragId, allowReorder = false, lockedReason = 'That folder is locked', canDrop, }) {
128
+ export function resolveDrop({ nodes, row, offsetRatio, dragId, allowReorder = false, lockedReason = 'That folder is locked', canDrop, takesDrop, }) {
129
129
  // Blank space under the last row: out of every folder. The one gesture that needs no
130
130
  // target, and the only way to say "top level" when the top level has no row of its own.
131
131
  if (!row)
@@ -156,15 +156,17 @@ 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) {
159
+ // A FILE the consumer opted in as a container takes a CANDIDATE drag on the middle of its
160
+ // row, vetted with words — see `FileTreeNode.acceptsDrop` and `takesDrop`. Anything else
161
+ // falls through to meaning its folder below.
162
+ if (target.kind === 'file' &&
163
+ !allowReorder &&
164
+ target.acceptsDrop === true &&
165
+ target.id !== dragId &&
166
+ (takesDrop?.(dragId, target) ?? true)) {
162
167
  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
+ if (offsetRatio > band && offsetRatio < 1 - band)
169
+ return vetted({ target: { kind: 'into', parentId: target.id }, refused: false, reason: null });
168
170
  }
169
171
  // A FILE row means "the folder that row is in" — what every file explorer does, and what
170
172
  // keeps a long list of files from being a wall of rows nothing can be dropped on.
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * The one place a satellite's owner-password floor is decided.
3
3
  *
4
- * ── Why this is shared, and why the default is 9 ────────────────────────────
4
+ * ── Why this is shared, and why the default is 6 ────────────────────────────
5
5
  * Three apps had independently written a `MIN_PASSWORD_BYTES` constant into
6
6
  * their own `scripts/owner.ts`, and they had already drifted (9, 14, 14) with
7
7
  * a paragraph in each explaining itself against the others. A floor is a
8
8
  * POLICY, and a policy copied three times is three policies.
9
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.
10
+ * The default is {@link DEFAULT_MIN_PASSWORD_BYTES} = 6 — the owner, 2026-09-24:
11
+ * *"make the minimum password length 6 characters"* (it was 9). It is a SIGN-IN
12
+ * floor: every door that uses it rate-limits guesses, so a short password costs
13
+ * an attacker attempts, not seconds. A secret that can be guessed OFFLINE (vault's
14
+ * passphrase, which derives its encryption key) raises it at its own call site.
13
15
  * Argon2id — what every caller hashes with — is what actually carries the cost
14
16
  * of a guess here; these apps have exactly ONE principal and no enumerable user
15
17
  * list, so the marginal value of characters 10–14 is small and the friction is
@@ -25,7 +27,7 @@
25
27
  * {@link resolveMinPasswordBytes} takes an optional override and **refuses to
26
28
  * go below the default**. An app that genuinely runs at higher stakes says so
27
29
  * 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
30
+ * `vault` does (9, since 2026-09-24 — its passphrase is an encryption key). The retired orch companion app did (14, as the single
29
31
  * internet-facing door into the host that runs `claude` in every repo) until
30
32
  * the owner dropped it to the shared floor on 2026-07-26.
31
33
  *
@@ -38,12 +40,12 @@
38
40
  * they read, so a multi-byte character counts as more than one, which is the
39
41
  * conservative direction).
40
42
  */
41
- export declare const DEFAULT_MIN_PASSWORD_BYTES = 9;
43
+ export declare const DEFAULT_MIN_PASSWORD_BYTES = 6;
42
44
  /**
43
45
  * The floor this app enforces: the default, or a higher one it asks for.
44
46
  *
45
47
  * 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
48
+ * caller passing 4 believes it is enforcing 4; being handed 6 without a word is
47
49
  * how a security floor becomes folklore.
48
50
  */
49
51
  export declare function resolveMinPasswordBytes(override?: number): number;
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * The one place a satellite's owner-password floor is decided.
3
3
  *
4
- * ── Why this is shared, and why the default is 9 ────────────────────────────
4
+ * ── Why this is shared, and why the default is 6 ────────────────────────────
5
5
  * Three apps had independently written a `MIN_PASSWORD_BYTES` constant into
6
6
  * their own `scripts/owner.ts`, and they had already drifted (9, 14, 14) with
7
7
  * a paragraph in each explaining itself against the others. A floor is a
8
8
  * POLICY, and a policy copied three times is three policies.
9
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.
10
+ * The default is {@link DEFAULT_MIN_PASSWORD_BYTES} = 6 — the owner, 2026-09-24:
11
+ * *"make the minimum password length 6 characters"* (it was 9). It is a SIGN-IN
12
+ * floor: every door that uses it rate-limits guesses, so a short password costs
13
+ * an attacker attempts, not seconds. A secret that can be guessed OFFLINE (vault's
14
+ * passphrase, which derives its encryption key) raises it at its own call site.
13
15
  * Argon2id — what every caller hashes with — is what actually carries the cost
14
16
  * of a guess here; these apps have exactly ONE principal and no enumerable user
15
17
  * list, so the marginal value of characters 10–14 is small and the friction is
@@ -25,7 +27,7 @@
25
27
  * {@link resolveMinPasswordBytes} takes an optional override and **refuses to
26
28
  * go below the default**. An app that genuinely runs at higher stakes says so
27
29
  * 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
30
+ * `vault` does (9, since 2026-09-24 — its passphrase is an encryption key). The retired orch companion app did (14, as the single
29
31
  * internet-facing door into the host that runs `claude` in every repo) until
30
32
  * the owner dropped it to the shared floor on 2026-07-26.
31
33
  *
@@ -38,12 +40,12 @@
38
40
  * they read, so a multi-byte character counts as more than one, which is the
39
41
  * conservative direction).
40
42
  */
41
- export const DEFAULT_MIN_PASSWORD_BYTES = 9;
43
+ export const DEFAULT_MIN_PASSWORD_BYTES = 6;
42
44
  /**
43
45
  * The floor this app enforces: the default, or a higher one it asks for.
44
46
  *
45
47
  * 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
48
+ * caller passing 4 believes it is enforcing 4; being handed 6 without a word is
47
49
  * how a security floor becomes folklore.
48
50
  */
49
51
  export function resolveMinPasswordBytes(override) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-core",
3
- "version": "2.5.0",
3
+ "version": "2.7.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.",
@@ -246,8 +246,14 @@ describe('resolveDrop onto a file that accepts drops', () => {
246
246
  { id: 'loose', name: 'loose.csv', kind: 'file' },
247
247
  ];
248
248
  const rows = flattenFileTree(nodes, []);
249
- const on = (id: string, dragId: string, offsetRatio: number, canDrop?: (d: string | null, t: FileTreeDrop) => string | null) =>
250
- resolveDrop({ nodes, row: rowFor(rows, id), offsetRatio, dragId, ...(canDrop ? { canDrop } : {}) });
249
+ const on = (
250
+ id: string,
251
+ dragId: string,
252
+ offsetRatio: number,
253
+ canDrop?: (d: string | null, t: FileTreeDrop) => string | null,
254
+ takesDrop?: (d: string | null, file: FileTreeNode) => boolean,
255
+ ) =>
256
+ resolveDrop({ nodes, row: rowFor(rows, id), offsetRatio, dragId, ...(canDrop ? { canDrop } : {}), ...(takesDrop ? { takesDrop } : {}) });
251
257
 
252
258
  test('the middle of its row is INTO the file', () => {
253
259
  expect(on('q3', 'loose', 0.5).target).toEqual({ kind: 'into', parentId: 'q3' });
@@ -258,11 +264,22 @@ describe('resolveDrop onto a file that accepts drops', () => {
258
264
  expect(on('q3', 'loose', 0.9).target).toEqual({ kind: 'into', parentId: 'work' });
259
265
  });
260
266
 
261
- test('🔴 a veto on the file falls back to its folder rather than refusing the row', () => {
262
- const onlySheets = (dragId: string | null, target: FileTreeDrop) =>
263
- target.kind === 'into' && target.parentId === 'q3' && dragId !== 'loose' ? 'Only a sheet can become a tab' : null;
264
- expect(on('q3', 'report', 0.5, onlySheets).target).toEqual({ kind: 'into', parentId: 'work' });
265
- expect(on('q3', 'loose', 0.5, onlySheets).target).toEqual({ kind: 'into', parentId: 'q3' });
267
+ test('🔴 a drag that is not a candidate means the folder — not a refusal, not the file', () => {
268
+ // desk: a note over a workbook files it beside the workbook.
269
+ const onlySheets = (dragId: string | null) => dragId === 'loose';
270
+ expect(on('q3', 'report', 0.5, undefined, onlySheets).target).toEqual({ kind: 'into', parentId: 'work' });
271
+ expect(on('q3', 'loose', 0.5, undefined, onlySheets).target).toEqual({ kind: 'into', parentId: 'q3' });
272
+ });
273
+
274
+ test('🔴 a candidate the consumer vetoes is REFUSED, in its words — the file was the target', () => {
275
+ const full = (_dragId: string | null, target: FileTreeDrop) =>
276
+ target.kind === 'into' && target.parentId === 'q3' ? '“loose” has 2 sheets' : null;
277
+ const offer = on('q3', 'loose', 0.5, full);
278
+ expect(offer.target).toBeNull();
279
+ expect(offer.refused).toBe(true);
280
+ expect(offer.reason).toBe('“loose” has 2 sheets');
281
+ // …and the edges still file it beside the workbook.
282
+ expect(on('q3', 'loose', 0.1, full).target).toEqual({ kind: 'into', parentId: 'work' });
266
283
  });
267
284
 
268
285
  test('a file never takes itself', () => {
@@ -104,10 +104,12 @@ export interface FileTreeNode {
104
104
  * container of its own — a drop on the middle half of its row is `{ kind: 'into',
105
105
  * parentId: <the file> }`. `apps/desk` is why: a workbook is a leaf in its tree, and
106
106
  * dropping a sheet onto one makes the sheet one of its tabs (the owner, desk file `02`).
107
- * The edges of the row, and the middle whenever {@link ResolveDropOptions.canDrop} vetoes
108
- * the file, still mean *the folder the file is in* — so a column of such files is never a
109
- * wall of rows that refuse everything that cannot go inside them. Absent or `false`, a
110
- * file row means its folder along its whole height, exactly as before.
107
+ * The edges of the row still mean *the folder the file is in*, and so does the middle for
108
+ * any drag {@link ResolveDropOptions.takesDrop} says is not a candidate — so a column of
109
+ * such files is never a wall of rows that refuse everything that cannot go inside them.
110
+ * A candidate that {@link ResolveDropOptions.canDrop} vetoes is REFUSED with its words:
111
+ * the file was the target, and why it cannot take this one has a remedy worth saying.
112
+ * Absent or `false`, a file row means its folder along its whole height, as before.
111
113
  */
112
114
  acceptsDrop?: boolean;
113
115
  /**
@@ -311,6 +313,17 @@ export interface ResolveDropOptions {
311
313
  * this tree is that an impossible drop is never drawn as possible.
312
314
  */
313
315
  canDrop?: (dragId: string | null, target: FileTreeDrop) => string | null;
316
+ /**
317
+ * Whether a file that accepts drops (`acceptsDrop: true` on a FILE) is a candidate for
318
+ * THIS drag at all. Default: every drag is.
319
+ *
320
+ * 🔴 This is the question {@link canDrop} cannot answer, because its `null` means "allow":
321
+ * `apps/desk` takes a SHEET into a workbook and never a note, so a note over a workbook
322
+ * must mean the workbook's folder — not a refusal, and not a merge. `false` here is that:
323
+ * the row goes back to meaning its folder. `true` makes the file the target, and then
324
+ * `canDrop` decides, in words — *"“Beta” has 2 sheets"* is a refusal with a remedy.
325
+ */
326
+ takesDrop?: (dragId: string | null, file: FileTreeNode) => boolean;
314
327
  }
315
328
 
316
329
  /**
@@ -328,6 +341,7 @@ export function resolveDrop({
328
341
  allowReorder = false,
329
342
  lockedReason = 'That folder is locked',
330
343
  canDrop,
344
+ takesDrop,
331
345
  }: ResolveDropOptions): DropOffer {
332
346
  // Blank space under the last row: out of every folder. The one gesture that needs no
333
347
  // target, and the only way to say "top level" when the top level has no row of its own.
@@ -362,14 +376,19 @@ export function resolveDrop({
362
376
  return vetted({ target: { kind: 'into', parentId: target.id }, refused: false, reason: null });
363
377
  };
364
378
 
365
- // A FILE the consumer opted in as a container takes the drop on the middle of its row —
366
- // see `FileTreeNode.acceptsDrop`. Vetoed, it falls through to meaning its folder below.
367
- if (target.kind === 'file' && !allowReorder && target.acceptsDrop === true && target.id !== dragId) {
379
+ // A FILE the consumer opted in as a container takes a CANDIDATE drag on the middle of its
380
+ // row, vetted with words — see `FileTreeNode.acceptsDrop` and `takesDrop`. Anything else
381
+ // falls through to meaning its folder below.
382
+ if (
383
+ target.kind === 'file' &&
384
+ !allowReorder &&
385
+ target.acceptsDrop === true &&
386
+ target.id !== dragId &&
387
+ (takesDrop?.(dragId, target) ?? true)
388
+ ) {
368
389
  const band = (1 - INTO_ZONE) / 2;
369
- if (offsetRatio > band && offsetRatio < 1 - band) {
370
- const into = vetted({ target: { kind: 'into', parentId: target.id }, refused: false, reason: null });
371
- if (into.target) return into;
372
- }
390
+ if (offsetRatio > band && offsetRatio < 1 - band)
391
+ return vetted({ target: { kind: 'into', parentId: target.id }, refused: false, reason: null });
373
392
  }
374
393
 
375
394
  // A FILE row means "the folder that row is in" — what every file explorer does, and what
@@ -7,13 +7,13 @@ import { DEFAULT_MIN_PASSWORD_BYTES, resolveMinPasswordBytes } from './passwordP
7
7
  describe('resolveMinPasswordBytes', () => {
8
8
  it('is the shared default when an app asks for nothing', () => {
9
9
  expect(resolveMinPasswordBytes()).toBe(DEFAULT_MIN_PASSWORD_BYTES);
10
- expect(DEFAULT_MIN_PASSWORD_BYTES).toBe(9);
10
+ expect(DEFAULT_MIN_PASSWORD_BYTES).toBe(6);
11
11
  });
12
12
  it('lets an app raise it, visibly, at its own call site', () => {
13
13
  expect(resolveMinPasswordBytes(14)).toBe(14);
14
14
  });
15
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/);
16
+ expect(() => resolveMinPasswordBytes(5)).toThrow(/may only be RAISED/);
17
+ expect(() => resolveMinPasswordBytes(6.5)).toThrow(/must be an integer/);
18
18
  });
19
19
  });
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * The one place a satellite's owner-password floor is decided.
3
3
  *
4
- * ── Why this is shared, and why the default is 9 ────────────────────────────
4
+ * ── Why this is shared, and why the default is 6 ────────────────────────────
5
5
  * Three apps had independently written a `MIN_PASSWORD_BYTES` constant into
6
6
  * their own `scripts/owner.ts`, and they had already drifted (9, 14, 14) with
7
7
  * a paragraph in each explaining itself against the others. A floor is a
8
8
  * POLICY, and a policy copied three times is three policies.
9
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.
10
+ * The default is {@link DEFAULT_MIN_PASSWORD_BYTES} = 6 — the owner, 2026-09-24:
11
+ * *"make the minimum password length 6 characters"* (it was 9). It is a SIGN-IN
12
+ * floor: every door that uses it rate-limits guesses, so a short password costs
13
+ * an attacker attempts, not seconds. A secret that can be guessed OFFLINE (vault's
14
+ * passphrase, which derives its encryption key) raises it at its own call site.
13
15
  * Argon2id — what every caller hashes with — is what actually carries the cost
14
16
  * of a guess here; these apps have exactly ONE principal and no enumerable user
15
17
  * list, so the marginal value of characters 10–14 is small and the friction is
@@ -25,7 +27,7 @@
25
27
  * {@link resolveMinPasswordBytes} takes an optional override and **refuses to
26
28
  * go below the default**. An app that genuinely runs at higher stakes says so
27
29
  * 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
30
+ * `vault` does (9, since 2026-09-24 — its passphrase is an encryption key). The retired orch companion app did (14, as the single
29
31
  * internet-facing door into the host that runs `claude` in every repo) until
30
32
  * the owner dropped it to the shared floor on 2026-07-26.
31
33
  *
@@ -39,13 +41,13 @@
39
41
  * they read, so a multi-byte character counts as more than one, which is the
40
42
  * conservative direction).
41
43
  */
42
- export const DEFAULT_MIN_PASSWORD_BYTES = 9;
44
+ export const DEFAULT_MIN_PASSWORD_BYTES = 6;
43
45
 
44
46
  /**
45
47
  * The floor this app enforces: the default, or a higher one it asks for.
46
48
  *
47
49
  * 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
50
+ * caller passing 4 believes it is enforcing 4; being handed 6 without a word is
49
51
  * how a security floor becomes folklore.
50
52
  */
51
53
  export function resolveMinPasswordBytes(override?: number): number {