cursedbelt-core 1.2.0 → 2.1.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/core/duplicates/index.d.ts +115 -0
- package/dist/core/duplicates/index.js +177 -0
- package/dist/core/master-lock/index.d.ts +1 -1
- package/dist/core/master-lock/wire.d.ts +104 -3
- package/dist/core/master-lock/wire.js +24 -0
- package/package.json +9 -3
- package/src/core/duplicates/index.spec.ts +116 -0
- package/src/core/duplicates/index.ts +203 -0
- package/src/core/master-lock/index.ts +5 -0
- package/src/core/master-lock/wire.spec.ts +33 -0
- package/src/core/master-lock/wire.ts +106 -3
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Are these two files the same thing?" — the fleet's ONE answer, with no I/O in it.
|
|
3
|
+
*
|
|
4
|
+
* ── Why this is here and not in an app ──────────────────────────────────────────────────
|
|
5
|
+
* `binary-server` wrote these rules first (2026-08-13, the owner: *"make strategy to always
|
|
6
|
+
* check for duplicate binaries in same app for same user … mark likely the duplicates for
|
|
7
|
+
* users to confirm"*), and they were measured against a real store: the copy-counter cap, the
|
|
8
|
+
* camera-generic stem list and the 5% size window each exist because a wider rule proposed
|
|
9
|
+
* thousands of pairs nobody could ever review. `music` then needed the same question asked of
|
|
10
|
+
* a 5,400-track library — *"I want to see a duplicate next to the file that it is a possible
|
|
11
|
+
* duplicate of … The 'binary-server' app (bs) already has this feature for checking duplicate
|
|
12
|
+
* files. We can reuse it and enhance the customizability if needed so the main strategy is
|
|
13
|
+
* shared."*
|
|
14
|
+
*
|
|
15
|
+
* So the strategy lives once, here, and both consume it. What each app keeps for itself is the
|
|
16
|
+
* evidence only it has: bs's perceptual frame hashes, music's artist/title key.
|
|
17
|
+
*
|
|
18
|
+
* ── What is NOT here, deliberately ──────────────────────────────────────────────────────
|
|
19
|
+
* Anything that needs a database, a filesystem, a fetch or a picture decoder. This module is
|
|
20
|
+
* pure so both consumers can prove their behaviour with `bun test` instead of against a live
|
|
21
|
+
* store, and so a browser bundle may import it (music renders the pair side by side).
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* How far two sizes may differ and still be called the same thing.
|
|
25
|
+
*
|
|
26
|
+
* A re-encode at a different quality is the case this exists for, and those land within a few
|
|
27
|
+
* percent. Zero would make a name match a strict subset of "same name AND same size", which
|
|
28
|
+
* misses precisely the files worth asking about; much larger starts pairing a clip with its
|
|
29
|
+
* own trailer.
|
|
30
|
+
*/
|
|
31
|
+
export declare const SIZE_TOLERANCE = 0.05;
|
|
32
|
+
/**
|
|
33
|
+
* How far two playing times may differ and still be the same recording.
|
|
34
|
+
*
|
|
35
|
+
* Wide enough for a re-encode (a trim of a trailing black frame, a container that rounds), far
|
|
36
|
+
* too narrow for the owner's 2026-08-14 report of a 3:56 video paired with a 1:52 one.
|
|
37
|
+
*/
|
|
38
|
+
export declare const DURATION_TOLERANCE = 0.05;
|
|
39
|
+
/**
|
|
40
|
+
* The name to compare, reduced to what a human means by "the same file".
|
|
41
|
+
*
|
|
42
|
+
* Strips the directory, the extension, and the marks a copy picks up along the way —
|
|
43
|
+
* `Finder`'s ` copy`/` copy 2`, a browser's ` (1)`, a script's `_1`/`-1` — then collapses
|
|
44
|
+
* everything that is not a letter or a digit. `My Video (1).mp4`, `my_video.MP4` and
|
|
45
|
+
* `My-Video copy 2.mov` all reduce to `myvideo`.
|
|
46
|
+
*
|
|
47
|
+
* 🔴 The extension is stripped ON PURPOSE, so a re-encode (`.mov` → `.mp4`, `.wma` → `.mp3`)
|
|
48
|
+
* still pairs with its source. That is the single most common likely-duplicate in this fleet's
|
|
49
|
+
* stores and a rule that kept the extension would never see one.
|
|
50
|
+
*
|
|
51
|
+
* 🔴 **The copy-counter strip is capped at two digits**, and that cap is a measured bug fix
|
|
52
|
+
* (bs, 2026-08-14). It used to be `[-_]\d+$`, which eats a CAMERA SEQUENCE as readily as a copy
|
|
53
|
+
* marker: `IMG_4595` and `IMG_4646` both reduced to `img`, so every iPhone capture in a tenant
|
|
54
|
+
* shared one bucket and paired with every other one whose size was within 5%. `_1`…`_99` is
|
|
55
|
+
* what a copy marker actually looks like; four digits is a photo's identity, and it must
|
|
56
|
+
* survive.
|
|
57
|
+
*/
|
|
58
|
+
export declare function normalizeName(name: string | null | undefined): string;
|
|
59
|
+
/**
|
|
60
|
+
* Stems that name a CAMERA, not a file.
|
|
61
|
+
*
|
|
62
|
+
* `IMG`, `DSC`, `PXL`, `VID` are what a device calls everything it produces; two files whose
|
|
63
|
+
* names reduce to one of these have told us nothing except that they came from a phone. Even
|
|
64
|
+
* with the counter cap above, `IMG_1.MOV` still reduces to `img`, and a bucket called "img" is
|
|
65
|
+
* every photo the owner has ever taken.
|
|
66
|
+
*
|
|
67
|
+
* 🔴 This rejects a MATCH, never a file. Both copies stay wherever they are, and an
|
|
68
|
+
* identical-bytes pair is unaffected — a checksum match never consults the name.
|
|
69
|
+
*/
|
|
70
|
+
export declare const GENERIC_STEMS: ReadonlySet<string>;
|
|
71
|
+
/**
|
|
72
|
+
* A name that could belong to any file from that device tells us nothing about identity.
|
|
73
|
+
*
|
|
74
|
+
* 🔴 The trailing digit run is stripped BEFORE the lookup, and only here — never in
|
|
75
|
+
* {@link normalizeName}, where removing it is what made every camera capture one bucket. The
|
|
76
|
+
* two are asked at different moments: `normalizeName` decides whether two names are the SAME
|
|
77
|
+
* (so `img4595` and `img4646` must stay apart), and this decides whether a name that already
|
|
78
|
+
* matched is worth anything (so two files both called `Track01` have told us nothing, and a
|
|
79
|
+
* ripper names half a library that).
|
|
80
|
+
*/
|
|
81
|
+
export declare const isGenericName: (normalized: string) => boolean;
|
|
82
|
+
/** Within {@link SIZE_TOLERANCE} of each other. Two zero-byte files are not "close" — they
|
|
83
|
+
* are two files nothing is known about. */
|
|
84
|
+
export declare function sizesClose(a: number, b: number, tolerance?: number): boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Within {@link DURATION_TOLERANCE} of each other.
|
|
87
|
+
*
|
|
88
|
+
* 🔴 A missing or zero duration answers `null`, never `false`: "nobody has measured this yet"
|
|
89
|
+
* and "these are different lengths" are opposite answers, and collapsing them makes an
|
|
90
|
+
* unprobed library look like a library with no duplicates in it.
|
|
91
|
+
*/
|
|
92
|
+
export declare function durationsClose(a: number | null | undefined, b: number | null | undefined, tolerance?: number): boolean | null;
|
|
93
|
+
/** One file, reduced to what this module can reason about. Units are the caller's — pass
|
|
94
|
+
* both durations in the same unit or not at all. */
|
|
95
|
+
export interface NamedFileFacts {
|
|
96
|
+
/** The name a person would recognise: a display title, else the last path segment. */
|
|
97
|
+
name: string | null | undefined;
|
|
98
|
+
sizeBytes: number;
|
|
99
|
+
/** Playing time, in whatever unit the caller uses consistently. Null = never measured. */
|
|
100
|
+
duration?: number | null;
|
|
101
|
+
}
|
|
102
|
+
export interface NameMatchVerdict {
|
|
103
|
+
match: boolean;
|
|
104
|
+
/** Written for the owner reading a review queue, never for a log. Empty when `match` is
|
|
105
|
+
* false — a non-match has nothing to explain. */
|
|
106
|
+
reason: string;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The shared "these look like the same thing" test: the NAME agrees after normalization, the
|
|
110
|
+
* sizes are close, and nothing about the length says otherwise.
|
|
111
|
+
*
|
|
112
|
+
* It is deliberately a QUESTION and never an action. Both consumers act only on a checksum,
|
|
113
|
+
* which is proof; this decides what is worth showing a person side by side.
|
|
114
|
+
*/
|
|
115
|
+
export declare function likelyTheSameFile(a: NamedFileFacts, b: NamedFileFacts): NameMatchVerdict;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Are these two files the same thing?" — the fleet's ONE answer, with no I/O in it.
|
|
3
|
+
*
|
|
4
|
+
* ── Why this is here and not in an app ──────────────────────────────────────────────────
|
|
5
|
+
* `binary-server` wrote these rules first (2026-08-13, the owner: *"make strategy to always
|
|
6
|
+
* check for duplicate binaries in same app for same user … mark likely the duplicates for
|
|
7
|
+
* users to confirm"*), and they were measured against a real store: the copy-counter cap, the
|
|
8
|
+
* camera-generic stem list and the 5% size window each exist because a wider rule proposed
|
|
9
|
+
* thousands of pairs nobody could ever review. `music` then needed the same question asked of
|
|
10
|
+
* a 5,400-track library — *"I want to see a duplicate next to the file that it is a possible
|
|
11
|
+
* duplicate of … The 'binary-server' app (bs) already has this feature for checking duplicate
|
|
12
|
+
* files. We can reuse it and enhance the customizability if needed so the main strategy is
|
|
13
|
+
* shared."*
|
|
14
|
+
*
|
|
15
|
+
* So the strategy lives once, here, and both consume it. What each app keeps for itself is the
|
|
16
|
+
* evidence only it has: bs's perceptual frame hashes, music's artist/title key.
|
|
17
|
+
*
|
|
18
|
+
* ── What is NOT here, deliberately ──────────────────────────────────────────────────────
|
|
19
|
+
* Anything that needs a database, a filesystem, a fetch or a picture decoder. This module is
|
|
20
|
+
* pure so both consumers can prove their behaviour with `bun test` instead of against a live
|
|
21
|
+
* store, and so a browser bundle may import it (music renders the pair side by side).
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* How far two sizes may differ and still be called the same thing.
|
|
25
|
+
*
|
|
26
|
+
* A re-encode at a different quality is the case this exists for, and those land within a few
|
|
27
|
+
* percent. Zero would make a name match a strict subset of "same name AND same size", which
|
|
28
|
+
* misses precisely the files worth asking about; much larger starts pairing a clip with its
|
|
29
|
+
* own trailer.
|
|
30
|
+
*/
|
|
31
|
+
export const SIZE_TOLERANCE = 0.05;
|
|
32
|
+
/**
|
|
33
|
+
* How far two playing times may differ and still be the same recording.
|
|
34
|
+
*
|
|
35
|
+
* Wide enough for a re-encode (a trim of a trailing black frame, a container that rounds), far
|
|
36
|
+
* too narrow for the owner's 2026-08-14 report of a 3:56 video paired with a 1:52 one.
|
|
37
|
+
*/
|
|
38
|
+
export const DURATION_TOLERANCE = 0.05;
|
|
39
|
+
/**
|
|
40
|
+
* The name to compare, reduced to what a human means by "the same file".
|
|
41
|
+
*
|
|
42
|
+
* Strips the directory, the extension, and the marks a copy picks up along the way —
|
|
43
|
+
* `Finder`'s ` copy`/` copy 2`, a browser's ` (1)`, a script's `_1`/`-1` — then collapses
|
|
44
|
+
* everything that is not a letter or a digit. `My Video (1).mp4`, `my_video.MP4` and
|
|
45
|
+
* `My-Video copy 2.mov` all reduce to `myvideo`.
|
|
46
|
+
*
|
|
47
|
+
* 🔴 The extension is stripped ON PURPOSE, so a re-encode (`.mov` → `.mp4`, `.wma` → `.mp3`)
|
|
48
|
+
* still pairs with its source. That is the single most common likely-duplicate in this fleet's
|
|
49
|
+
* stores and a rule that kept the extension would never see one.
|
|
50
|
+
*
|
|
51
|
+
* 🔴 **The copy-counter strip is capped at two digits**, and that cap is a measured bug fix
|
|
52
|
+
* (bs, 2026-08-14). It used to be `[-_]\d+$`, which eats a CAMERA SEQUENCE as readily as a copy
|
|
53
|
+
* marker: `IMG_4595` and `IMG_4646` both reduced to `img`, so every iPhone capture in a tenant
|
|
54
|
+
* shared one bucket and paired with every other one whose size was within 5%. `_1`…`_99` is
|
|
55
|
+
* what a copy marker actually looks like; four digits is a photo's identity, and it must
|
|
56
|
+
* survive.
|
|
57
|
+
*/
|
|
58
|
+
export function normalizeName(name) {
|
|
59
|
+
if (!name)
|
|
60
|
+
return "";
|
|
61
|
+
const leaf = name.split(/[\\/]/).pop() ?? "";
|
|
62
|
+
const stem = leaf.replace(/\.[a-z0-9]{1,5}$/i, "");
|
|
63
|
+
return stem
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
.replace(/\s*\(\d+\)\s*$/, "")
|
|
66
|
+
.replace(/\s+copy(\s+\d+)?\s*$/, "")
|
|
67
|
+
.replace(/[-_]\d{1,2}\s*$/, "")
|
|
68
|
+
.replace(/[^a-z0-9]+/g, "");
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Stems that name a CAMERA, not a file.
|
|
72
|
+
*
|
|
73
|
+
* `IMG`, `DSC`, `PXL`, `VID` are what a device calls everything it produces; two files whose
|
|
74
|
+
* names reduce to one of these have told us nothing except that they came from a phone. Even
|
|
75
|
+
* with the counter cap above, `IMG_1.MOV` still reduces to `img`, and a bucket called "img" is
|
|
76
|
+
* every photo the owner has ever taken.
|
|
77
|
+
*
|
|
78
|
+
* 🔴 This rejects a MATCH, never a file. Both copies stay wherever they are, and an
|
|
79
|
+
* identical-bytes pair is unaffected — a checksum match never consults the name.
|
|
80
|
+
*/
|
|
81
|
+
export const GENERIC_STEMS = new Set([
|
|
82
|
+
"img",
|
|
83
|
+
"image",
|
|
84
|
+
"images",
|
|
85
|
+
"dsc",
|
|
86
|
+
"dscf",
|
|
87
|
+
"dscn",
|
|
88
|
+
"pxl",
|
|
89
|
+
"vid",
|
|
90
|
+
"video",
|
|
91
|
+
"movie",
|
|
92
|
+
"mov",
|
|
93
|
+
"photo",
|
|
94
|
+
"pic",
|
|
95
|
+
"picture",
|
|
96
|
+
"screenshot",
|
|
97
|
+
"screenrecording",
|
|
98
|
+
"untitled",
|
|
99
|
+
"unknown",
|
|
100
|
+
"file",
|
|
101
|
+
"download",
|
|
102
|
+
"scan",
|
|
103
|
+
"capture",
|
|
104
|
+
"copy",
|
|
105
|
+
"new",
|
|
106
|
+
"temp",
|
|
107
|
+
"tmp",
|
|
108
|
+
// Music's own version of the same problem: an untagged rip is called this by every ripper
|
|
109
|
+
// ever written, and `track01` from two albums is two different songs.
|
|
110
|
+
"track",
|
|
111
|
+
"audiotrack",
|
|
112
|
+
"unknownartist",
|
|
113
|
+
]);
|
|
114
|
+
/**
|
|
115
|
+
* A name that could belong to any file from that device tells us nothing about identity.
|
|
116
|
+
*
|
|
117
|
+
* 🔴 The trailing digit run is stripped BEFORE the lookup, and only here — never in
|
|
118
|
+
* {@link normalizeName}, where removing it is what made every camera capture one bucket. The
|
|
119
|
+
* two are asked at different moments: `normalizeName` decides whether two names are the SAME
|
|
120
|
+
* (so `img4595` and `img4646` must stay apart), and this decides whether a name that already
|
|
121
|
+
* matched is worth anything (so two files both called `Track01` have told us nothing, and a
|
|
122
|
+
* ripper names half a library that).
|
|
123
|
+
*/
|
|
124
|
+
export const isGenericName = (normalized) => normalized.length < 3 ||
|
|
125
|
+
GENERIC_STEMS.has(normalized) ||
|
|
126
|
+
GENERIC_STEMS.has(normalized.replace(/\d+$/, ""));
|
|
127
|
+
/** Within {@link SIZE_TOLERANCE} of each other. Two zero-byte files are not "close" — they
|
|
128
|
+
* are two files nothing is known about. */
|
|
129
|
+
export function sizesClose(a, b, tolerance = SIZE_TOLERANCE) {
|
|
130
|
+
if (a === b && a > 0)
|
|
131
|
+
return true;
|
|
132
|
+
const larger = Math.max(a, b);
|
|
133
|
+
if (larger <= 0)
|
|
134
|
+
return false;
|
|
135
|
+
return Math.abs(a - b) / larger <= tolerance;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Within {@link DURATION_TOLERANCE} of each other.
|
|
139
|
+
*
|
|
140
|
+
* 🔴 A missing or zero duration answers `null`, never `false`: "nobody has measured this yet"
|
|
141
|
+
* and "these are different lengths" are opposite answers, and collapsing them makes an
|
|
142
|
+
* unprobed library look like a library with no duplicates in it.
|
|
143
|
+
*/
|
|
144
|
+
export function durationsClose(a, b, tolerance = DURATION_TOLERANCE) {
|
|
145
|
+
if (!a || !b || a <= 0 || b <= 0)
|
|
146
|
+
return null;
|
|
147
|
+
return Math.abs(a - b) / Math.max(a, b) <= tolerance;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The shared "these look like the same thing" test: the NAME agrees after normalization, the
|
|
151
|
+
* sizes are close, and nothing about the length says otherwise.
|
|
152
|
+
*
|
|
153
|
+
* It is deliberately a QUESTION and never an action. Both consumers act only on a checksum,
|
|
154
|
+
* which is proof; this decides what is worth showing a person side by side.
|
|
155
|
+
*/
|
|
156
|
+
export function likelyTheSameFile(a, b) {
|
|
157
|
+
const na = normalizeName(a.name);
|
|
158
|
+
const nb = normalizeName(b.name);
|
|
159
|
+
// Two empty names are not a match with each other — it means neither file has a name worth
|
|
160
|
+
// comparing (a bare digest key, an untitled upload), and pairing those proposes every
|
|
161
|
+
// unnamed object as a duplicate of every other.
|
|
162
|
+
if (!na || na !== nb)
|
|
163
|
+
return { match: false, reason: "" };
|
|
164
|
+
if (isGenericName(na))
|
|
165
|
+
return { match: false, reason: "" };
|
|
166
|
+
if (!sizesClose(a.sizeBytes, b.sizeBytes))
|
|
167
|
+
return { match: false, reason: "" };
|
|
168
|
+
if (durationsClose(a.duration, b.duration) === false)
|
|
169
|
+
return { match: false, reason: "" };
|
|
170
|
+
const pct = Math.round((Math.abs(a.sizeBytes - b.sizeBytes) / Math.max(a.sizeBytes, b.sizeBytes, 1)) * 100);
|
|
171
|
+
return {
|
|
172
|
+
match: true,
|
|
173
|
+
reason: a.sizeBytes === b.sizeBytes
|
|
174
|
+
? "same name, same size, different bytes"
|
|
175
|
+
: `same name, size within ${pct}%, different bytes`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -10,5 +10,5 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export { MASTER_LOCK_KDF, type MasterLockKdfParams, b64u, deriveMasterLockVerifier, isMasterLockKdfParams, newMasterLockKdfParams, unb64u, } from "./kdf";
|
|
12
12
|
export { BASE_DELAY_MS, DEFAULT_IDLE_MS, FREE_ATTEMPTS, MAX_DELAY_MS, MAX_IDLE_MS, MIN_IDLE_MS, clampIdleMs, delayAfter, windowRemaining, } from "./policy";
|
|
13
|
-
export { MASTER_LOCK_COOKIE, masterLockCookieName, MASTER_LOCK_HEADER, MASTER_LOCK_PATHS, MASTER_LOCK_PREFIX, MASTER_LOCK_STATE_HEADER, type MasterLockChangeRequest, type MasterLockEnrollRequest, type MasterLockRefusal, type MasterLockSettingsRequest, type MasterLockStatus, type MasterLockUnlockRequest, } from "./wire";
|
|
13
|
+
export { MASTER_LOCK_COOKIE, masterLockCookieName, MASTER_LOCK_HEADER, MASTER_LOCK_PATHS, MASTER_LOCK_PREFIX, MASTER_LOCK_STATE_HEADER, type MasterLockAccountSummary, type MasterLockAccountsResponse, type MasterLockAddAccountRequest, type MasterLockChangeRequest, type MasterLockEditAccountRequest, type MasterLockEnrollRequest, type MasterLockHintsResponse, type MasterLockRefusal, type MasterLockSettingsRequest, type MasterLockStatus, type MasterLockUnlockRequest, } from "./wire";
|
|
14
14
|
export { PRESENCE_ATTRIBUTE, type PresenceMedia, isPresenceMedia, isSomebodyWatching, } from "./presence";
|
|
@@ -36,6 +36,30 @@ export declare const MASTER_LOCK_PATHS: {
|
|
|
36
36
|
* current one.
|
|
37
37
|
*/
|
|
38
38
|
readonly enroll: "/__lock/enroll";
|
|
39
|
+
/**
|
|
40
|
+
* The ACCOUNTS surface — `GET` lists them, `POST` adds one, `PATCH` renames one or
|
|
41
|
+
* rewrites its hint.
|
|
42
|
+
*
|
|
43
|
+
* 🔴 Reachable only with a live unlock, unlike everything above it. Listing the
|
|
44
|
+
* accounts says how many master passwords exist and what they are called, which is
|
|
45
|
+
* the one fact the lock page is built never to disclose (see {@link hints}).
|
|
46
|
+
*/
|
|
47
|
+
readonly accounts: "/__lock/accounts";
|
|
48
|
+
/**
|
|
49
|
+
* The hints, and the ONE thing here that is readable while locked.
|
|
50
|
+
*
|
|
51
|
+
* The owner asked for it in as many words: *"let me setup hints for them … since the
|
|
52
|
+
* passwords are not recorded anywhere I will want a way to help me if I forget it."*
|
|
53
|
+
* A hint nobody can read until they are already in is not a hint.
|
|
54
|
+
*
|
|
55
|
+
* 🔴 It is a SEPARATE route rather than a field on {@link MasterLockStatus} precisely
|
|
56
|
+
* because it discloses what status must not: the number of accounts and their labels.
|
|
57
|
+
* The lock page fetches it only when the person clicks "I forgot", so the default
|
|
58
|
+
* page is byte-identical whether there is one account or ten, and revealing the list
|
|
59
|
+
* is a deliberate act rather than a side effect of loading a page. Everyone who can
|
|
60
|
+
* reach it has already passed the app's own owner-only sign-in.
|
|
61
|
+
*/
|
|
62
|
+
readonly hints: "/__lock/hints";
|
|
39
63
|
};
|
|
40
64
|
/**
|
|
41
65
|
* The base name of the cookie the unlock token rides in. Callers want
|
|
@@ -112,6 +136,71 @@ export interface MasterLockStatus {
|
|
|
112
136
|
remainingMs: number;
|
|
113
137
|
/** Milliseconds until another guess will be evaluated. `0` normally. */
|
|
114
138
|
retryAfterMs: number;
|
|
139
|
+
/**
|
|
140
|
+
* WHICH account the caller's unlock belongs to, or `null` when locked.
|
|
141
|
+
*
|
|
142
|
+
* 🔴 This is the whole point of the accounts model and it is the only account fact
|
|
143
|
+
* status carries. The master password a person types decides which tenant's data the
|
|
144
|
+
* app serves them, so the app needs to know which one opened — but nothing here says
|
|
145
|
+
* how many OTHER accounts exist, because that would let a stolen session count the
|
|
146
|
+
* owner's tenants by loading a page. See {@link MASTER_LOCK_PATHS.hints}.
|
|
147
|
+
*/
|
|
148
|
+
accountId: string | null;
|
|
149
|
+
/** The label of {@link accountId}, for a header chip. `null` when locked. */
|
|
150
|
+
accountLabel: string | null;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* One account, as the management UI is allowed to see it.
|
|
154
|
+
*
|
|
155
|
+
* 🔴 Never the verifier hash and never anything derived from the password. The `hint` IS
|
|
156
|
+
* disclosed, because a hint the owner cannot read is not a hint — it is their own words,
|
|
157
|
+
* written by them, for the moment they have forgotten which password opens which tenant.
|
|
158
|
+
*/
|
|
159
|
+
export interface MasterLockAccountSummary {
|
|
160
|
+
id: string;
|
|
161
|
+
label: string;
|
|
162
|
+
/** The owner's own reminder. Empty string when they set none. */
|
|
163
|
+
hint: string;
|
|
164
|
+
createdAt: number;
|
|
165
|
+
/** Is this the account the caller's own unlock belongs to? */
|
|
166
|
+
current: boolean;
|
|
167
|
+
}
|
|
168
|
+
/** What `GET /__lock/accounts` answers — requires a live unlock. */
|
|
169
|
+
export interface MasterLockAccountsResponse {
|
|
170
|
+
accounts: MasterLockAccountSummary[];
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Adding an account: a SECOND master password, and therefore a second tenant.
|
|
174
|
+
*
|
|
175
|
+
* The current password is re-typed for the same reason {@link MasterLockChangeRequest}
|
|
176
|
+
* demands it — an open session proves somebody is at the keyboard, not that it is the
|
|
177
|
+
* owner, and minting a whole new tenant is at least as consequential as rotating one.
|
|
178
|
+
*
|
|
179
|
+
* 🔴 There is no `kdf`. Every account on an app derives under the SAME params, so one
|
|
180
|
+
* PBKDF2 pass in the browser can be measured against all of them and the lock page
|
|
181
|
+
* cannot be made to disclose how many exist by how many salts it has to publish. See
|
|
182
|
+
* the engine's note on the shared descriptor.
|
|
183
|
+
*/
|
|
184
|
+
export interface MasterLockAddAccountRequest {
|
|
185
|
+
/** Derived under the app's params — proof the caller knows the password they are in. */
|
|
186
|
+
currentVerifier: string;
|
|
187
|
+
/** The NEW account's verifier, derived under those same params. */
|
|
188
|
+
verifier: string;
|
|
189
|
+
label: string;
|
|
190
|
+
hint?: string;
|
|
191
|
+
}
|
|
192
|
+
/** Renaming an account or rewriting its hint. Never its password — that is `change`. */
|
|
193
|
+
export interface MasterLockEditAccountRequest {
|
|
194
|
+
id: string;
|
|
195
|
+
label?: string;
|
|
196
|
+
hint?: string;
|
|
197
|
+
}
|
|
198
|
+
/** What `GET /__lock/hints` answers, readable WHILE LOCKED — see the path's note. */
|
|
199
|
+
export interface MasterLockHintsResponse {
|
|
200
|
+
hints: {
|
|
201
|
+
label: string;
|
|
202
|
+
hint: string;
|
|
203
|
+
}[];
|
|
115
204
|
}
|
|
116
205
|
export interface MasterLockUnlockRequest {
|
|
117
206
|
verifier: string;
|
|
@@ -122,11 +211,23 @@ export interface MasterLockEnrollRequest {
|
|
|
122
211
|
verifier: string;
|
|
123
212
|
}
|
|
124
213
|
export interface MasterLockChangeRequest {
|
|
125
|
-
/** Derived under the
|
|
214
|
+
/** Derived under the app's params — proof that the person changing it is the person
|
|
126
215
|
* who knows it, re-typed rather than inherited from the open session. */
|
|
127
216
|
currentVerifier: string;
|
|
128
|
-
/**
|
|
129
|
-
|
|
217
|
+
/**
|
|
218
|
+
* The new verifier, derived under those SAME params.
|
|
219
|
+
*
|
|
220
|
+
* 🔴 It used to carry a fresh `kdf`, and it may not any more. The params are now one
|
|
221
|
+
* app-wide descriptor that every account derives under, so minting a new salt here
|
|
222
|
+
* would silently invalidate every OTHER account's stored hash — the owner would
|
|
223
|
+
* change account 2's password and lock himself out of account 1. The cost is that the
|
|
224
|
+
* salt never rotates; it is 16 random bytes whose job is to stop precomputation
|
|
225
|
+
* across apps, and it does that for as long as it exists.
|
|
226
|
+
*
|
|
227
|
+
* Rotating a password never changes which tenant it opens. That is the owner's
|
|
228
|
+
* requirement, in his words: *"If I change account 2 password it just changes how I
|
|
229
|
+
* get the access … I would still access the same content afterward."*
|
|
230
|
+
*/
|
|
130
231
|
verifier: string;
|
|
131
232
|
}
|
|
132
233
|
export interface MasterLockSettingsRequest {
|
|
@@ -29,6 +29,30 @@ export const MASTER_LOCK_PATHS = {
|
|
|
29
29
|
* current one.
|
|
30
30
|
*/
|
|
31
31
|
enroll: `${MASTER_LOCK_PREFIX}/enroll`,
|
|
32
|
+
/**
|
|
33
|
+
* The ACCOUNTS surface — `GET` lists them, `POST` adds one, `PATCH` renames one or
|
|
34
|
+
* rewrites its hint.
|
|
35
|
+
*
|
|
36
|
+
* 🔴 Reachable only with a live unlock, unlike everything above it. Listing the
|
|
37
|
+
* accounts says how many master passwords exist and what they are called, which is
|
|
38
|
+
* the one fact the lock page is built never to disclose (see {@link hints}).
|
|
39
|
+
*/
|
|
40
|
+
accounts: `${MASTER_LOCK_PREFIX}/accounts`,
|
|
41
|
+
/**
|
|
42
|
+
* The hints, and the ONE thing here that is readable while locked.
|
|
43
|
+
*
|
|
44
|
+
* The owner asked for it in as many words: *"let me setup hints for them … since the
|
|
45
|
+
* passwords are not recorded anywhere I will want a way to help me if I forget it."*
|
|
46
|
+
* A hint nobody can read until they are already in is not a hint.
|
|
47
|
+
*
|
|
48
|
+
* 🔴 It is a SEPARATE route rather than a field on {@link MasterLockStatus} precisely
|
|
49
|
+
* because it discloses what status must not: the number of accounts and their labels.
|
|
50
|
+
* The lock page fetches it only when the person clicks "I forgot", so the default
|
|
51
|
+
* page is byte-identical whether there is one account or ten, and revealing the list
|
|
52
|
+
* is a deliberate act rather than a side effect of loading a page. Everyone who can
|
|
53
|
+
* reach it has already passed the app's own owner-only sign-in.
|
|
54
|
+
*/
|
|
55
|
+
hints: `${MASTER_LOCK_PREFIX}/hints`,
|
|
32
56
|
};
|
|
33
57
|
/**
|
|
34
58
|
* The base name of the cookie the unlock token rides in. Callers want
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "Framework-agnostic core of the cursedbelt split
|
|
6
|
+
"description": "Framework-agnostic core of the cursedbelt split — isomorphic domain logic. cwip below it, cursedbelt-server and cursedbelt (react) above it.",
|
|
7
7
|
"cursed": {
|
|
8
8
|
"schemaVersion": 1,
|
|
9
9
|
"app": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
|
21
21
|
"test": "NODE_ENV=development bun test src",
|
|
22
22
|
"build": "bun run clean && tsc -p tsconfig.build.json",
|
|
23
|
-
"paths": "bun
|
|
23
|
+
"paths": "bun run scripts/paths.ts",
|
|
24
24
|
"verify": "bun run paths && bun run typecheck && bun run build && bun run test",
|
|
25
25
|
"prepublishOnly": "bun run verify"
|
|
26
26
|
},
|
|
@@ -125,6 +125,12 @@
|
|
|
125
125
|
"source": "./src/core/download.ts",
|
|
126
126
|
"import": "./dist/core/download.js"
|
|
127
127
|
},
|
|
128
|
+
"./duplicates": {
|
|
129
|
+
"types": "./dist/core/duplicates/index.d.ts",
|
|
130
|
+
"bun": "./src/core/duplicates/index.ts",
|
|
131
|
+
"source": "./src/core/duplicates/index.ts",
|
|
132
|
+
"import": "./dist/core/duplicates/index.js"
|
|
133
|
+
},
|
|
128
134
|
"./errors": {
|
|
129
135
|
"types": "./dist/shared/errors.d.ts",
|
|
130
136
|
"bun": "./src/shared/errors.ts",
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared duplicate rules, proved against the cases that produced them.
|
|
3
|
+
*
|
|
4
|
+
* Every assertion here is a measured incident from `binary-server` or `music`, not an
|
|
5
|
+
* invented example — see the module header for which.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
DURATION_TOLERANCE,
|
|
10
|
+
durationsClose,
|
|
11
|
+
isGenericName,
|
|
12
|
+
likelyTheSameFile,
|
|
13
|
+
normalizeName,
|
|
14
|
+
SIZE_TOLERANCE,
|
|
15
|
+
sizesClose,
|
|
16
|
+
} from "./index";
|
|
17
|
+
|
|
18
|
+
describe("normalizeName", () => {
|
|
19
|
+
it("reduces the marks a copy picks up to the same stem", () => {
|
|
20
|
+
expect(normalizeName("My Video (1).mp4")).toBe("myvideo");
|
|
21
|
+
expect(normalizeName("my_video.MP4")).toBe("myvideo");
|
|
22
|
+
expect(normalizeName("My-Video copy 2.mov")).toBe("myvideo");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("strips the extension, so a re-encode still pairs with its source", () => {
|
|
26
|
+
expect(normalizeName("Song.wma")).toBe(normalizeName("Song.mp3"));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("🔴 keeps a four-digit camera sequence — the 2026-08-14 flood", () => {
|
|
30
|
+
// `IMG_4595` and `IMG_4646` must NOT collapse onto each other.
|
|
31
|
+
expect(normalizeName("IMG_4595.MOV")).not.toBe(normalizeName("IMG_4646.MOV"));
|
|
32
|
+
// …while a two-digit copy marker still does.
|
|
33
|
+
expect(normalizeName("mix_2.mp3")).toBe(normalizeName("mix.mp3"));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("drops the directory", () => {
|
|
37
|
+
expect(normalizeName("Main 1/rap/Song.mp3")).toBe("song");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("isGenericName", () => {
|
|
42
|
+
it("rejects the stems that name a device rather than a file", () => {
|
|
43
|
+
expect(isGenericName(normalizeName("IMG_1.MOV"))).toBe(true);
|
|
44
|
+
expect(isGenericName(normalizeName("Track01.mp3"))).toBe(true);
|
|
45
|
+
expect(isGenericName(normalizeName("Between U.mp3"))).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("sizesClose", () => {
|
|
50
|
+
it("accepts a re-encode and refuses a different file", () => {
|
|
51
|
+
expect(sizesClose(10_000_000, 10_400_000)).toBe(true);
|
|
52
|
+
expect(sizesClose(10_000_000, 20_000_000)).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("🔴 two zero-byte files are not close — nothing is known about either", () => {
|
|
56
|
+
expect(sizesClose(0, 0)).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("is bounded by the exported tolerance", () => {
|
|
60
|
+
expect(sizesClose(100, 100 * (1 + SIZE_TOLERANCE))).toBe(true);
|
|
61
|
+
expect(sizesClose(100, 100 * (1 + SIZE_TOLERANCE * 2))).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("durationsClose", () => {
|
|
66
|
+
it("🔴 answers null when nobody has measured, never false", () => {
|
|
67
|
+
expect(durationsClose(null, 200)).toBeNull();
|
|
68
|
+
expect(durationsClose(0, 200)).toBeNull();
|
|
69
|
+
expect(durationsClose(undefined, undefined)).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("refuses the owner's 3:56-vs-1:52 pair", () => {
|
|
73
|
+
expect(durationsClose(236, 112)).toBe(false);
|
|
74
|
+
expect(durationsClose(236, 236 * (1 + DURATION_TOLERANCE / 2))).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("likelyTheSameFile", () => {
|
|
79
|
+
const facts = (name: string, sizeBytes: number, duration?: number | null) => ({
|
|
80
|
+
name,
|
|
81
|
+
sizeBytes,
|
|
82
|
+
...(duration === undefined ? {} : { duration }),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("pairs a re-encode of the same song", () => {
|
|
86
|
+
const verdict = likelyTheSameFile(
|
|
87
|
+
facts("Between U.mp3", 8_000_000, 312),
|
|
88
|
+
facts("Between U.wma", 7_800_000, 312),
|
|
89
|
+
);
|
|
90
|
+
expect(verdict.match).toBe(true);
|
|
91
|
+
expect(verdict.reason).toContain("same name");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("does not pair two different songs of a similar size", () => {
|
|
95
|
+
expect(likelyTheSameFile(facts("A.mp3", 8_000_000), facts("B.mp3", 8_010_000)).match).toBe(
|
|
96
|
+
false,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("🔴 a length that disagrees kills a name+size match", () => {
|
|
101
|
+
expect(
|
|
102
|
+
likelyTheSameFile(facts("Set.mp3", 8_000_000, 236), facts("Set.mp3", 8_100_000, 112)).match,
|
|
103
|
+
).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("🔴 an unmeasured length does not — that would find nothing in an unprobed library", () => {
|
|
107
|
+
expect(
|
|
108
|
+
likelyTheSameFile(facts("Set.mp3", 8_000_000, null), facts("Set.mp3", 8_100_000, null)).match,
|
|
109
|
+
).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("refuses a generic stem, and refuses two nameless files", () => {
|
|
113
|
+
expect(likelyTheSameFile(facts("IMG_1.MOV", 500), facts("IMG_1.MOV", 500)).match).toBe(false);
|
|
114
|
+
expect(likelyTheSameFile(facts("", 500), facts("", 500)).match).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Are these two files the same thing?" — the fleet's ONE answer, with no I/O in it.
|
|
3
|
+
*
|
|
4
|
+
* ── Why this is here and not in an app ──────────────────────────────────────────────────
|
|
5
|
+
* `binary-server` wrote these rules first (2026-08-13, the owner: *"make strategy to always
|
|
6
|
+
* check for duplicate binaries in same app for same user … mark likely the duplicates for
|
|
7
|
+
* users to confirm"*), and they were measured against a real store: the copy-counter cap, the
|
|
8
|
+
* camera-generic stem list and the 5% size window each exist because a wider rule proposed
|
|
9
|
+
* thousands of pairs nobody could ever review. `music` then needed the same question asked of
|
|
10
|
+
* a 5,400-track library — *"I want to see a duplicate next to the file that it is a possible
|
|
11
|
+
* duplicate of … The 'binary-server' app (bs) already has this feature for checking duplicate
|
|
12
|
+
* files. We can reuse it and enhance the customizability if needed so the main strategy is
|
|
13
|
+
* shared."*
|
|
14
|
+
*
|
|
15
|
+
* So the strategy lives once, here, and both consume it. What each app keeps for itself is the
|
|
16
|
+
* evidence only it has: bs's perceptual frame hashes, music's artist/title key.
|
|
17
|
+
*
|
|
18
|
+
* ── What is NOT here, deliberately ──────────────────────────────────────────────────────
|
|
19
|
+
* Anything that needs a database, a filesystem, a fetch or a picture decoder. This module is
|
|
20
|
+
* pure so both consumers can prove their behaviour with `bun test` instead of against a live
|
|
21
|
+
* store, and so a browser bundle may import it (music renders the pair side by side).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How far two sizes may differ and still be called the same thing.
|
|
26
|
+
*
|
|
27
|
+
* A re-encode at a different quality is the case this exists for, and those land within a few
|
|
28
|
+
* percent. Zero would make a name match a strict subset of "same name AND same size", which
|
|
29
|
+
* misses precisely the files worth asking about; much larger starts pairing a clip with its
|
|
30
|
+
* own trailer.
|
|
31
|
+
*/
|
|
32
|
+
export const SIZE_TOLERANCE = 0.05;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* How far two playing times may differ and still be the same recording.
|
|
36
|
+
*
|
|
37
|
+
* Wide enough for a re-encode (a trim of a trailing black frame, a container that rounds), far
|
|
38
|
+
* too narrow for the owner's 2026-08-14 report of a 3:56 video paired with a 1:52 one.
|
|
39
|
+
*/
|
|
40
|
+
export const DURATION_TOLERANCE = 0.05;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The name to compare, reduced to what a human means by "the same file".
|
|
44
|
+
*
|
|
45
|
+
* Strips the directory, the extension, and the marks a copy picks up along the way —
|
|
46
|
+
* `Finder`'s ` copy`/` copy 2`, a browser's ` (1)`, a script's `_1`/`-1` — then collapses
|
|
47
|
+
* everything that is not a letter or a digit. `My Video (1).mp4`, `my_video.MP4` and
|
|
48
|
+
* `My-Video copy 2.mov` all reduce to `myvideo`.
|
|
49
|
+
*
|
|
50
|
+
* 🔴 The extension is stripped ON PURPOSE, so a re-encode (`.mov` → `.mp4`, `.wma` → `.mp3`)
|
|
51
|
+
* still pairs with its source. That is the single most common likely-duplicate in this fleet's
|
|
52
|
+
* stores and a rule that kept the extension would never see one.
|
|
53
|
+
*
|
|
54
|
+
* 🔴 **The copy-counter strip is capped at two digits**, and that cap is a measured bug fix
|
|
55
|
+
* (bs, 2026-08-14). It used to be `[-_]\d+$`, which eats a CAMERA SEQUENCE as readily as a copy
|
|
56
|
+
* marker: `IMG_4595` and `IMG_4646` both reduced to `img`, so every iPhone capture in a tenant
|
|
57
|
+
* shared one bucket and paired with every other one whose size was within 5%. `_1`…`_99` is
|
|
58
|
+
* what a copy marker actually looks like; four digits is a photo's identity, and it must
|
|
59
|
+
* survive.
|
|
60
|
+
*/
|
|
61
|
+
export function normalizeName(name: string | null | undefined): string {
|
|
62
|
+
if (!name) return "";
|
|
63
|
+
const leaf = name.split(/[\\/]/).pop() ?? "";
|
|
64
|
+
const stem = leaf.replace(/\.[a-z0-9]{1,5}$/i, "");
|
|
65
|
+
return stem
|
|
66
|
+
.toLowerCase()
|
|
67
|
+
.replace(/\s*\(\d+\)\s*$/, "")
|
|
68
|
+
.replace(/\s+copy(\s+\d+)?\s*$/, "")
|
|
69
|
+
.replace(/[-_]\d{1,2}\s*$/, "")
|
|
70
|
+
.replace(/[^a-z0-9]+/g, "");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Stems that name a CAMERA, not a file.
|
|
75
|
+
*
|
|
76
|
+
* `IMG`, `DSC`, `PXL`, `VID` are what a device calls everything it produces; two files whose
|
|
77
|
+
* names reduce to one of these have told us nothing except that they came from a phone. Even
|
|
78
|
+
* with the counter cap above, `IMG_1.MOV` still reduces to `img`, and a bucket called "img" is
|
|
79
|
+
* every photo the owner has ever taken.
|
|
80
|
+
*
|
|
81
|
+
* 🔴 This rejects a MATCH, never a file. Both copies stay wherever they are, and an
|
|
82
|
+
* identical-bytes pair is unaffected — a checksum match never consults the name.
|
|
83
|
+
*/
|
|
84
|
+
export const GENERIC_STEMS: ReadonlySet<string> = new Set([
|
|
85
|
+
"img",
|
|
86
|
+
"image",
|
|
87
|
+
"images",
|
|
88
|
+
"dsc",
|
|
89
|
+
"dscf",
|
|
90
|
+
"dscn",
|
|
91
|
+
"pxl",
|
|
92
|
+
"vid",
|
|
93
|
+
"video",
|
|
94
|
+
"movie",
|
|
95
|
+
"mov",
|
|
96
|
+
"photo",
|
|
97
|
+
"pic",
|
|
98
|
+
"picture",
|
|
99
|
+
"screenshot",
|
|
100
|
+
"screenrecording",
|
|
101
|
+
"untitled",
|
|
102
|
+
"unknown",
|
|
103
|
+
"file",
|
|
104
|
+
"download",
|
|
105
|
+
"scan",
|
|
106
|
+
"capture",
|
|
107
|
+
"copy",
|
|
108
|
+
"new",
|
|
109
|
+
"temp",
|
|
110
|
+
"tmp",
|
|
111
|
+
// Music's own version of the same problem: an untagged rip is called this by every ripper
|
|
112
|
+
// ever written, and `track01` from two albums is two different songs.
|
|
113
|
+
"track",
|
|
114
|
+
"audiotrack",
|
|
115
|
+
"unknownartist",
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A name that could belong to any file from that device tells us nothing about identity.
|
|
120
|
+
*
|
|
121
|
+
* 🔴 The trailing digit run is stripped BEFORE the lookup, and only here — never in
|
|
122
|
+
* {@link normalizeName}, where removing it is what made every camera capture one bucket. The
|
|
123
|
+
* two are asked at different moments: `normalizeName` decides whether two names are the SAME
|
|
124
|
+
* (so `img4595` and `img4646` must stay apart), and this decides whether a name that already
|
|
125
|
+
* matched is worth anything (so two files both called `Track01` have told us nothing, and a
|
|
126
|
+
* ripper names half a library that).
|
|
127
|
+
*/
|
|
128
|
+
export const isGenericName = (normalized: string): boolean =>
|
|
129
|
+
normalized.length < 3 ||
|
|
130
|
+
GENERIC_STEMS.has(normalized) ||
|
|
131
|
+
GENERIC_STEMS.has(normalized.replace(/\d+$/, ""));
|
|
132
|
+
|
|
133
|
+
/** Within {@link SIZE_TOLERANCE} of each other. Two zero-byte files are not "close" — they
|
|
134
|
+
* are two files nothing is known about. */
|
|
135
|
+
export function sizesClose(a: number, b: number, tolerance = SIZE_TOLERANCE): boolean {
|
|
136
|
+
if (a === b && a > 0) return true;
|
|
137
|
+
const larger = Math.max(a, b);
|
|
138
|
+
if (larger <= 0) return false;
|
|
139
|
+
return Math.abs(a - b) / larger <= tolerance;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Within {@link DURATION_TOLERANCE} of each other.
|
|
144
|
+
*
|
|
145
|
+
* 🔴 A missing or zero duration answers `null`, never `false`: "nobody has measured this yet"
|
|
146
|
+
* and "these are different lengths" are opposite answers, and collapsing them makes an
|
|
147
|
+
* unprobed library look like a library with no duplicates in it.
|
|
148
|
+
*/
|
|
149
|
+
export function durationsClose(
|
|
150
|
+
a: number | null | undefined,
|
|
151
|
+
b: number | null | undefined,
|
|
152
|
+
tolerance = DURATION_TOLERANCE,
|
|
153
|
+
): boolean | null {
|
|
154
|
+
if (!a || !b || a <= 0 || b <= 0) return null;
|
|
155
|
+
return Math.abs(a - b) / Math.max(a, b) <= tolerance;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** One file, reduced to what this module can reason about. Units are the caller's — pass
|
|
159
|
+
* both durations in the same unit or not at all. */
|
|
160
|
+
export interface NamedFileFacts {
|
|
161
|
+
/** The name a person would recognise: a display title, else the last path segment. */
|
|
162
|
+
name: string | null | undefined;
|
|
163
|
+
sizeBytes: number;
|
|
164
|
+
/** Playing time, in whatever unit the caller uses consistently. Null = never measured. */
|
|
165
|
+
duration?: number | null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface NameMatchVerdict {
|
|
169
|
+
match: boolean;
|
|
170
|
+
/** Written for the owner reading a review queue, never for a log. Empty when `match` is
|
|
171
|
+
* false — a non-match has nothing to explain. */
|
|
172
|
+
reason: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The shared "these look like the same thing" test: the NAME agrees after normalization, the
|
|
177
|
+
* sizes are close, and nothing about the length says otherwise.
|
|
178
|
+
*
|
|
179
|
+
* It is deliberately a QUESTION and never an action. Both consumers act only on a checksum,
|
|
180
|
+
* which is proof; this decides what is worth showing a person side by side.
|
|
181
|
+
*/
|
|
182
|
+
export function likelyTheSameFile(a: NamedFileFacts, b: NamedFileFacts): NameMatchVerdict {
|
|
183
|
+
const na = normalizeName(a.name);
|
|
184
|
+
const nb = normalizeName(b.name);
|
|
185
|
+
// Two empty names are not a match with each other — it means neither file has a name worth
|
|
186
|
+
// comparing (a bare digest key, an untitled upload), and pairing those proposes every
|
|
187
|
+
// unnamed object as a duplicate of every other.
|
|
188
|
+
if (!na || na !== nb) return { match: false, reason: "" };
|
|
189
|
+
if (isGenericName(na)) return { match: false, reason: "" };
|
|
190
|
+
if (!sizesClose(a.sizeBytes, b.sizeBytes)) return { match: false, reason: "" };
|
|
191
|
+
if (durationsClose(a.duration, b.duration) === false) return { match: false, reason: "" };
|
|
192
|
+
|
|
193
|
+
const pct = Math.round(
|
|
194
|
+
(Math.abs(a.sizeBytes - b.sizeBytes) / Math.max(a.sizeBytes, b.sizeBytes, 1)) * 100,
|
|
195
|
+
);
|
|
196
|
+
return {
|
|
197
|
+
match: true,
|
|
198
|
+
reason:
|
|
199
|
+
a.sizeBytes === b.sizeBytes
|
|
200
|
+
? "same name, same size, different bytes"
|
|
201
|
+
: `same name, size within ${pct}%, different bytes`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -35,8 +35,13 @@ export {
|
|
|
35
35
|
MASTER_LOCK_PATHS,
|
|
36
36
|
MASTER_LOCK_PREFIX,
|
|
37
37
|
MASTER_LOCK_STATE_HEADER,
|
|
38
|
+
type MasterLockAccountSummary,
|
|
39
|
+
type MasterLockAccountsResponse,
|
|
40
|
+
type MasterLockAddAccountRequest,
|
|
38
41
|
type MasterLockChangeRequest,
|
|
42
|
+
type MasterLockEditAccountRequest,
|
|
39
43
|
type MasterLockEnrollRequest,
|
|
44
|
+
type MasterLockHintsResponse,
|
|
40
45
|
type MasterLockRefusal,
|
|
41
46
|
type MasterLockSettingsRequest,
|
|
42
47
|
type MasterLockStatus,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire's own shape — the two properties a typo here would break silently.
|
|
3
|
+
*
|
|
4
|
+
* Every surface of this feature is a string constant in one object, and two of them
|
|
5
|
+
* arrived together (`accounts`, `hints`) with very different disclosure rules: one is
|
|
6
|
+
* reachable only with a live unlock, the other is readable while locked. A copy-paste that
|
|
7
|
+
* gave them the same string would route the management surface to the public one, and
|
|
8
|
+
* every unit test either side of the wire would stay green — the server would mount what
|
|
9
|
+
* it was told to mount and the browser would fetch what it was told to fetch.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import { MASTER_LOCK_PATHS, MASTER_LOCK_PREFIX } from "./wire";
|
|
13
|
+
|
|
14
|
+
describe("MASTER_LOCK_PATHS", () => {
|
|
15
|
+
it("keeps every surface under the one prefix the guard reserves", () => {
|
|
16
|
+
for (const [name, path] of Object.entries(MASTER_LOCK_PATHS)) {
|
|
17
|
+
expect(`${name}:${path}`).toBe(`${name}:${path}`);
|
|
18
|
+
expect(path.startsWith(`${MASTER_LOCK_PREFIX}/`)).toBe(true);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("gives every surface its own path", () => {
|
|
23
|
+
const paths = Object.values(MASTER_LOCK_PATHS);
|
|
24
|
+
expect(new Set(paths).size).toBe(paths.length);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("keeps the locked-readable hints apart from the unlock-only accounts surface", () => {
|
|
28
|
+
// The one pair whose collision would be a disclosure rather than a 404.
|
|
29
|
+
expect(MASTER_LOCK_PATHS.hints).not.toBe(MASTER_LOCK_PATHS.accounts);
|
|
30
|
+
expect(MASTER_LOCK_PATHS.accounts.startsWith(MASTER_LOCK_PATHS.hints)).toBe(false);
|
|
31
|
+
expect(MASTER_LOCK_PATHS.hints.startsWith(MASTER_LOCK_PATHS.accounts)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -38,6 +38,30 @@ export const MASTER_LOCK_PATHS = {
|
|
|
38
38
|
* current one.
|
|
39
39
|
*/
|
|
40
40
|
enroll: `${MASTER_LOCK_PREFIX}/enroll`,
|
|
41
|
+
/**
|
|
42
|
+
* The ACCOUNTS surface — `GET` lists them, `POST` adds one, `PATCH` renames one or
|
|
43
|
+
* rewrites its hint.
|
|
44
|
+
*
|
|
45
|
+
* 🔴 Reachable only with a live unlock, unlike everything above it. Listing the
|
|
46
|
+
* accounts says how many master passwords exist and what they are called, which is
|
|
47
|
+
* the one fact the lock page is built never to disclose (see {@link hints}).
|
|
48
|
+
*/
|
|
49
|
+
accounts: `${MASTER_LOCK_PREFIX}/accounts`,
|
|
50
|
+
/**
|
|
51
|
+
* The hints, and the ONE thing here that is readable while locked.
|
|
52
|
+
*
|
|
53
|
+
* The owner asked for it in as many words: *"let me setup hints for them … since the
|
|
54
|
+
* passwords are not recorded anywhere I will want a way to help me if I forget it."*
|
|
55
|
+
* A hint nobody can read until they are already in is not a hint.
|
|
56
|
+
*
|
|
57
|
+
* 🔴 It is a SEPARATE route rather than a field on {@link MasterLockStatus} precisely
|
|
58
|
+
* because it discloses what status must not: the number of accounts and their labels.
|
|
59
|
+
* The lock page fetches it only when the person clicks "I forgot", so the default
|
|
60
|
+
* page is byte-identical whether there is one account or ten, and revealing the list
|
|
61
|
+
* is a deliberate act rather than a side effect of loading a page. Everyone who can
|
|
62
|
+
* reach it has already passed the app's own owner-only sign-in.
|
|
63
|
+
*/
|
|
64
|
+
hints: `${MASTER_LOCK_PREFIX}/hints`,
|
|
41
65
|
} as const;
|
|
42
66
|
|
|
43
67
|
/**
|
|
@@ -123,6 +147,73 @@ export interface MasterLockStatus {
|
|
|
123
147
|
remainingMs: number;
|
|
124
148
|
/** Milliseconds until another guess will be evaluated. `0` normally. */
|
|
125
149
|
retryAfterMs: number;
|
|
150
|
+
/**
|
|
151
|
+
* WHICH account the caller's unlock belongs to, or `null` when locked.
|
|
152
|
+
*
|
|
153
|
+
* 🔴 This is the whole point of the accounts model and it is the only account fact
|
|
154
|
+
* status carries. The master password a person types decides which tenant's data the
|
|
155
|
+
* app serves them, so the app needs to know which one opened — but nothing here says
|
|
156
|
+
* how many OTHER accounts exist, because that would let a stolen session count the
|
|
157
|
+
* owner's tenants by loading a page. See {@link MASTER_LOCK_PATHS.hints}.
|
|
158
|
+
*/
|
|
159
|
+
accountId: string | null;
|
|
160
|
+
/** The label of {@link accountId}, for a header chip. `null` when locked. */
|
|
161
|
+
accountLabel: string | null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* One account, as the management UI is allowed to see it.
|
|
166
|
+
*
|
|
167
|
+
* 🔴 Never the verifier hash and never anything derived from the password. The `hint` IS
|
|
168
|
+
* disclosed, because a hint the owner cannot read is not a hint — it is their own words,
|
|
169
|
+
* written by them, for the moment they have forgotten which password opens which tenant.
|
|
170
|
+
*/
|
|
171
|
+
export interface MasterLockAccountSummary {
|
|
172
|
+
id: string;
|
|
173
|
+
label: string;
|
|
174
|
+
/** The owner's own reminder. Empty string when they set none. */
|
|
175
|
+
hint: string;
|
|
176
|
+
createdAt: number;
|
|
177
|
+
/** Is this the account the caller's own unlock belongs to? */
|
|
178
|
+
current: boolean;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** What `GET /__lock/accounts` answers — requires a live unlock. */
|
|
182
|
+
export interface MasterLockAccountsResponse {
|
|
183
|
+
accounts: MasterLockAccountSummary[];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Adding an account: a SECOND master password, and therefore a second tenant.
|
|
188
|
+
*
|
|
189
|
+
* The current password is re-typed for the same reason {@link MasterLockChangeRequest}
|
|
190
|
+
* demands it — an open session proves somebody is at the keyboard, not that it is the
|
|
191
|
+
* owner, and minting a whole new tenant is at least as consequential as rotating one.
|
|
192
|
+
*
|
|
193
|
+
* 🔴 There is no `kdf`. Every account on an app derives under the SAME params, so one
|
|
194
|
+
* PBKDF2 pass in the browser can be measured against all of them and the lock page
|
|
195
|
+
* cannot be made to disclose how many exist by how many salts it has to publish. See
|
|
196
|
+
* the engine's note on the shared descriptor.
|
|
197
|
+
*/
|
|
198
|
+
export interface MasterLockAddAccountRequest {
|
|
199
|
+
/** Derived under the app's params — proof the caller knows the password they are in. */
|
|
200
|
+
currentVerifier: string;
|
|
201
|
+
/** The NEW account's verifier, derived under those same params. */
|
|
202
|
+
verifier: string;
|
|
203
|
+
label: string;
|
|
204
|
+
hint?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Renaming an account or rewriting its hint. Never its password — that is `change`. */
|
|
208
|
+
export interface MasterLockEditAccountRequest {
|
|
209
|
+
id: string;
|
|
210
|
+
label?: string;
|
|
211
|
+
hint?: string;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** What `GET /__lock/hints` answers, readable WHILE LOCKED — see the path's note. */
|
|
215
|
+
export interface MasterLockHintsResponse {
|
|
216
|
+
hints: { label: string; hint: string }[];
|
|
126
217
|
}
|
|
127
218
|
|
|
128
219
|
export interface MasterLockUnlockRequest {
|
|
@@ -136,11 +227,23 @@ export interface MasterLockEnrollRequest {
|
|
|
136
227
|
}
|
|
137
228
|
|
|
138
229
|
export interface MasterLockChangeRequest {
|
|
139
|
-
/** Derived under the
|
|
230
|
+
/** Derived under the app's params — proof that the person changing it is the person
|
|
140
231
|
* who knows it, re-typed rather than inherited from the open session. */
|
|
141
232
|
currentVerifier: string;
|
|
142
|
-
/**
|
|
143
|
-
|
|
233
|
+
/**
|
|
234
|
+
* The new verifier, derived under those SAME params.
|
|
235
|
+
*
|
|
236
|
+
* 🔴 It used to carry a fresh `kdf`, and it may not any more. The params are now one
|
|
237
|
+
* app-wide descriptor that every account derives under, so minting a new salt here
|
|
238
|
+
* would silently invalidate every OTHER account's stored hash — the owner would
|
|
239
|
+
* change account 2's password and lock himself out of account 1. The cost is that the
|
|
240
|
+
* salt never rotates; it is 16 random bytes whose job is to stop precomputation
|
|
241
|
+
* across apps, and it does that for as long as it exists.
|
|
242
|
+
*
|
|
243
|
+
* Rotating a password never changes which tenant it opens. That is the owner's
|
|
244
|
+
* requirement, in his words: *"If I change account 2 password it just changes how I
|
|
245
|
+
* get the access … I would still access the same content afterward."*
|
|
246
|
+
*/
|
|
144
247
|
verifier: string;
|
|
145
248
|
}
|
|
146
249
|
|