cursedbelt-core 1.0.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.
Files changed (307) hide show
  1. package/LICENSE +15 -0
  2. package/dist/client/wsWire.d.ts +49 -0
  3. package/dist/client/wsWire.js +57 -0
  4. package/dist/config/appConfig.d.ts +361 -0
  5. package/dist/config/appConfig.js +308 -0
  6. package/dist/core/activity/index.d.ts +8 -0
  7. package/dist/core/activity/index.js +7 -0
  8. package/dist/core/activity/model.d.ts +115 -0
  9. package/dist/core/activity/model.js +63 -0
  10. package/dist/core/analytics/index.d.ts +18 -0
  11. package/dist/core/analytics/index.js +18 -0
  12. package/dist/core/analytics/range.d.ts +186 -0
  13. package/dist/core/analytics/range.js +271 -0
  14. package/dist/core/analytics/schedule.d.ts +301 -0
  15. package/dist/core/analytics/schedule.js +372 -0
  16. package/dist/core/category/index.d.ts +3 -0
  17. package/dist/core/category/index.js +6 -0
  18. package/dist/core/category/memoryStore.d.ts +21 -0
  19. package/dist/core/category/memoryStore.js +97 -0
  20. package/dist/core/category/tree.d.ts +26 -0
  21. package/dist/core/category/tree.js +113 -0
  22. package/dist/core/category/types.d.ts +48 -0
  23. package/dist/core/category/types.js +8 -0
  24. package/dist/core/color.d.ts +14 -0
  25. package/dist/core/color.js +32 -0
  26. package/dist/core/colorScheme/colorSchemeStore.d.ts +70 -0
  27. package/dist/core/colorScheme/colorSchemeStore.js +123 -0
  28. package/dist/core/colorScheme/index.d.ts +7 -0
  29. package/dist/core/colorScheme/index.js +7 -0
  30. package/dist/core/domainOwners.d.ts +19 -0
  31. package/dist/core/domainOwners.js +15 -0
  32. package/dist/core/download.d.ts +14 -0
  33. package/dist/core/download.js +37 -0
  34. package/dist/core/events/ccEventBus.d.ts +44 -0
  35. package/dist/core/events/ccEventBus.js +70 -0
  36. package/dist/core/file-tree/fileTreeModel.d.ts +276 -0
  37. package/dist/core/file-tree/fileTreeModel.js +312 -0
  38. package/dist/core/folder-tree/actions.d.ts +84 -0
  39. package/dist/core/folder-tree/actions.js +119 -0
  40. package/dist/core/folder-tree/index.d.ts +3 -0
  41. package/dist/core/folder-tree/index.js +20 -0
  42. package/dist/core/folder-tree/path.d.ts +76 -0
  43. package/dist/core/folder-tree/path.js +146 -0
  44. package/dist/core/folder-tree/tree.d.ts +86 -0
  45. package/dist/core/folder-tree/tree.js +184 -0
  46. package/dist/core/layout/index.d.ts +1 -0
  47. package/dist/core/layout/index.js +7 -0
  48. package/dist/core/master-lock/index.d.ts +14 -0
  49. package/dist/core/master-lock/index.js +14 -0
  50. package/dist/core/master-lock/kdf.d.ts +63 -0
  51. package/dist/core/master-lock/kdf.js +117 -0
  52. package/dist/core/master-lock/policy.d.ts +32 -0
  53. package/dist/core/master-lock/policy.js +52 -0
  54. package/dist/core/master-lock/presence.d.ts +52 -0
  55. package/dist/core/master-lock/presence.js +61 -0
  56. package/dist/core/master-lock/wire.d.ts +139 -0
  57. package/dist/core/master-lock/wire.js +81 -0
  58. package/dist/core/media/bgRemoval.d.ts +27 -0
  59. package/dist/core/media/bgRemoval.js +101 -0
  60. package/dist/core/media/ffmpeg.d.ts +57 -0
  61. package/dist/core/media/ffmpeg.js +126 -0
  62. package/dist/core/media/format.d.ts +20 -0
  63. package/dist/core/media/format.js +54 -0
  64. package/dist/core/media/index.d.ts +12 -0
  65. package/dist/core/media/index.js +18 -0
  66. package/dist/core/media/mediaItem.d.ts +15 -0
  67. package/dist/core/media/mediaItem.js +34 -0
  68. package/dist/core/media/renderPlan.d.ts +63 -0
  69. package/dist/core/media/renderPlan.js +232 -0
  70. package/dist/core/media/segment.d.ts +79 -0
  71. package/dist/core/media/segment.js +247 -0
  72. package/dist/core/media/size.d.ts +1 -0
  73. package/dist/core/media/size.js +3 -0
  74. package/dist/core/media/trim.d.ts +21 -0
  75. package/dist/core/media/trim.js +40 -0
  76. package/dist/core/media/types.d.ts +148 -0
  77. package/dist/core/media/types.js +11 -0
  78. package/dist/core/media/uploadLimits.d.ts +7 -0
  79. package/dist/core/media/uploadLimits.js +16 -0
  80. package/dist/core/media/validate.d.ts +9 -0
  81. package/dist/core/media/validate.js +121 -0
  82. package/dist/core/media/videoCuts.d.ts +152 -0
  83. package/dist/core/media/videoCuts.js +284 -0
  84. package/dist/core/navigation/breadcrumbCompat.d.ts +24 -0
  85. package/dist/core/navigation/breadcrumbCompat.js +21 -0
  86. package/dist/core/navigation/coverageTest.d.ts +24 -0
  87. package/dist/core/navigation/coverageTest.js +48 -0
  88. package/dist/core/navigation/index.d.ts +6 -0
  89. package/dist/core/navigation/index.js +10 -0
  90. package/dist/core/navigation/migrateNavPreferences.d.ts +55 -0
  91. package/dist/core/navigation/migrateNavPreferences.js +63 -0
  92. package/dist/core/navigation/navigationModel.d.ts +165 -0
  93. package/dist/core/navigation/navigationModel.js +14 -0
  94. package/dist/core/navigation/navigationSelectors.d.ts +63 -0
  95. package/dist/core/navigation/navigationSelectors.js +167 -0
  96. package/dist/core/navigation/searchCatalogue.d.ts +23 -0
  97. package/dist/core/navigation/searchCatalogue.js +76 -0
  98. package/dist/core/net/reconnectSchedule.d.ts +46 -0
  99. package/dist/core/net/reconnectSchedule.js +38 -0
  100. package/dist/core/palette/ShortcutRegistry.d.ts +33 -0
  101. package/dist/core/palette/ShortcutRegistry.js +52 -0
  102. package/dist/core/palette/fuzzyRank.d.ts +18 -0
  103. package/dist/core/palette/fuzzyRank.js +73 -0
  104. package/dist/core/palette/hotkeys.d.ts +28 -0
  105. package/dist/core/palette/hotkeys.js +118 -0
  106. package/dist/core/palette/index.d.ts +3 -0
  107. package/dist/core/palette/index.js +8 -0
  108. package/dist/core/schema/projection.d.ts +84 -0
  109. package/dist/core/schema/projection.js +184 -0
  110. package/dist/core/session/claims.d.ts +145 -0
  111. package/dist/core/session/claims.js +157 -0
  112. package/dist/core/sharing/index.d.ts +5 -0
  113. package/dist/core/sharing/index.js +4 -0
  114. package/dist/core/sharing/model.d.ts +195 -0
  115. package/dist/core/sharing/model.js +150 -0
  116. package/dist/core/site/siteCopy.d.ts +92 -0
  117. package/dist/core/site/siteCopy.js +91 -0
  118. package/dist/core/slots.d.ts +22 -0
  119. package/dist/core/slots.js +31 -0
  120. package/dist/core/styles/constructableSheet.d.ts +51 -0
  121. package/dist/core/styles/constructableSheet.js +80 -0
  122. package/dist/core/tokens/registry.d.ts +119 -0
  123. package/dist/core/tokens/registry.js +447 -0
  124. package/dist/core/uiScale/index.d.ts +8 -0
  125. package/dist/core/uiScale/index.js +8 -0
  126. package/dist/core/uiScale/scale.d.ts +36 -0
  127. package/dist/core/uiScale/scale.js +47 -0
  128. package/dist/core/uiScale/uiScaleStore.d.ts +47 -0
  129. package/dist/core/uiScale/uiScaleStore.js +63 -0
  130. package/dist/core/wire/__fixtures__/rowsMarshalWorker.d.ts +1 -0
  131. package/dist/core/wire/__fixtures__/rowsMarshalWorker.js +23 -0
  132. package/dist/core/wire/index.d.ts +17 -0
  133. package/dist/core/wire/index.js +17 -0
  134. package/dist/core/wire/ingress.d.ts +44 -0
  135. package/dist/core/wire/ingress.js +134 -0
  136. package/dist/core/wire/marshalWorker.d.ts +70 -0
  137. package/dist/core/wire/marshalWorker.js +273 -0
  138. package/dist/core/wire/marshalling.d.ts +122 -0
  139. package/dist/core/wire/marshalling.js +126 -0
  140. package/dist/core/wire/wireHub.d.ts +80 -0
  141. package/dist/core/wire/wireHub.js +116 -0
  142. package/dist/ctgr/base64url.d.ts +8 -0
  143. package/dist/ctgr/base64url.js +39 -0
  144. package/dist/ctgr/chunkAccumulator.d.ts +214 -0
  145. package/dist/ctgr/chunkAccumulator.js +307 -0
  146. package/dist/ctgr/chunkStore.d.ts +24 -0
  147. package/dist/ctgr/chunkStore.js +125 -0
  148. package/dist/ctgr/codec.d.ts +44 -0
  149. package/dist/ctgr/codec.js +153 -0
  150. package/dist/ctgr/compress.d.ts +2 -0
  151. package/dist/ctgr/compress.js +20 -0
  152. package/dist/ctgr/crc32.d.ts +2 -0
  153. package/dist/ctgr/crc32.js +23 -0
  154. package/dist/ctgr/digest.d.ts +1 -0
  155. package/dist/ctgr/digest.js +15 -0
  156. package/dist/ctgr/index.d.ts +9 -0
  157. package/dist/ctgr/index.js +17 -0
  158. package/dist/ctgr/types.d.ts +64 -0
  159. package/dist/ctgr/types.js +21 -0
  160. package/dist/ctgr/v0compat.d.ts +27 -0
  161. package/dist/ctgr/v0compat.js +128 -0
  162. package/dist/index.d.ts +15 -0
  163. package/dist/index.js +17 -0
  164. package/dist/shared/errors.d.ts +101 -0
  165. package/dist/shared/errors.js +99 -0
  166. package/dist/testing/bytes.d.ts +32 -0
  167. package/dist/testing/bytes.js +33 -0
  168. package/dist/testing/domInspect.d.ts +59 -0
  169. package/dist/testing/domInspect.js +115 -0
  170. package/dist/testing/fakeFetch.d.ts +23 -0
  171. package/dist/testing/fakeFetch.js +21 -0
  172. package/dist/testing/index.d.ts +3 -0
  173. package/dist/testing/index.js +11 -0
  174. package/package.json +373 -0
  175. package/src/client/wsWire.ts +83 -0
  176. package/src/config/appConfig.spec.ts +158 -0
  177. package/src/config/appConfig.ts +372 -0
  178. package/src/core/activity/index.ts +21 -0
  179. package/src/core/activity/model.ts +160 -0
  180. package/src/core/analytics/index.ts +18 -0
  181. package/src/core/analytics/range.spec.ts +280 -0
  182. package/src/core/analytics/range.ts +383 -0
  183. package/src/core/analytics/schedule.spec.ts +311 -0
  184. package/src/core/analytics/schedule.ts +624 -0
  185. package/src/core/category/category.spec.ts +144 -0
  186. package/src/core/category/index.ts +19 -0
  187. package/src/core/category/memoryStore.ts +104 -0
  188. package/src/core/category/tree.ts +118 -0
  189. package/src/core/category/types.ts +59 -0
  190. package/src/core/color.spec.ts +35 -0
  191. package/src/core/color.ts +35 -0
  192. package/src/core/colorScheme/colorScheme.spec.ts +127 -0
  193. package/src/core/colorScheme/colorSchemeStore.ts +184 -0
  194. package/src/core/colorScheme/index.ts +23 -0
  195. package/src/core/domainOwners.ts +32 -0
  196. package/src/core/download.spec.ts +38 -0
  197. package/src/core/download.ts +35 -0
  198. package/src/core/events/ccEventBus.spec.ts +90 -0
  199. package/src/core/events/ccEventBus.ts +120 -0
  200. package/src/core/file-tree/fileTreeModel.spec.ts +462 -0
  201. package/src/core/file-tree/fileTreeModel.ts +511 -0
  202. package/src/core/folder-tree/actions.spec.ts +103 -0
  203. package/src/core/folder-tree/actions.ts +153 -0
  204. package/src/core/folder-tree/index.ts +46 -0
  205. package/src/core/folder-tree/path.spec.ts +113 -0
  206. package/src/core/folder-tree/path.ts +148 -0
  207. package/src/core/folder-tree/tree.spec.ts +157 -0
  208. package/src/core/folder-tree/tree.ts +244 -0
  209. package/src/core/layout/index.ts +7 -0
  210. package/src/core/master-lock/index.ts +50 -0
  211. package/src/core/master-lock/kdf.spec.ts +95 -0
  212. package/src/core/master-lock/kdf.ts +145 -0
  213. package/src/core/master-lock/policy.ts +55 -0
  214. package/src/core/master-lock/presence.spec.ts +73 -0
  215. package/src/core/master-lock/presence.ts +71 -0
  216. package/src/core/master-lock/wire.ts +155 -0
  217. package/src/core/media/bgRemoval.spec.ts +120 -0
  218. package/src/core/media/bgRemoval.ts +123 -0
  219. package/src/core/media/ffmpeg.spec.ts +135 -0
  220. package/src/core/media/ffmpeg.ts +162 -0
  221. package/src/core/media/format.spec.ts +41 -0
  222. package/src/core/media/format.ts +58 -0
  223. package/src/core/media/index.ts +105 -0
  224. package/src/core/media/mediaItem.spec.ts +46 -0
  225. package/src/core/media/mediaItem.ts +40 -0
  226. package/src/core/media/renderPlan.spec.ts +140 -0
  227. package/src/core/media/renderPlan.ts +318 -0
  228. package/src/core/media/segment.spec.ts +159 -0
  229. package/src/core/media/segment.ts +352 -0
  230. package/src/core/media/size.spec.ts +19 -0
  231. package/src/core/media/size.ts +3 -0
  232. package/src/core/media/trim.spec.ts +75 -0
  233. package/src/core/media/trim.ts +60 -0
  234. package/src/core/media/types.ts +147 -0
  235. package/src/core/media/uploadLimits.ts +18 -0
  236. package/src/core/media/validate.spec.ts +106 -0
  237. package/src/core/media/validate.ts +131 -0
  238. package/src/core/media/videoCuts.spec.ts +327 -0
  239. package/src/core/media/videoCuts.ts +355 -0
  240. package/src/core/navigation/breadcrumbCompat.ts +44 -0
  241. package/src/core/navigation/coverageTest.ts +54 -0
  242. package/src/core/navigation/index.ts +50 -0
  243. package/src/core/navigation/migrateNavPreferences.ts +119 -0
  244. package/src/core/navigation/navigation.spec.ts +388 -0
  245. package/src/core/navigation/navigationModel.ts +185 -0
  246. package/src/core/navigation/navigationSelectors.ts +206 -0
  247. package/src/core/navigation/searchCatalogue.ts +89 -0
  248. package/src/core/net/reconnectSchedule.spec.ts +49 -0
  249. package/src/core/net/reconnectSchedule.ts +78 -0
  250. package/src/core/palette/ShortcutRegistry.ts +69 -0
  251. package/src/core/palette/fuzzyRank.ts +78 -0
  252. package/src/core/palette/hotkeys.ts +132 -0
  253. package/src/core/palette/index.ts +22 -0
  254. package/src/core/palette/palette.spec.ts +160 -0
  255. package/src/core/schema/projection.spec.ts +122 -0
  256. package/src/core/schema/projection.ts +225 -0
  257. package/src/core/session/claims.ts +181 -0
  258. package/src/core/sharing/index.ts +33 -0
  259. package/src/core/sharing/model.spec.ts +104 -0
  260. package/src/core/sharing/model.ts +296 -0
  261. package/src/core/site/siteCopy.ts +115 -0
  262. package/src/core/slots.ts +38 -0
  263. package/src/core/styles/constructableSheet.ts +86 -0
  264. package/src/core/tokens/registry.spec.ts +160 -0
  265. package/src/core/tokens/registry.ts +536 -0
  266. package/src/core/uiScale/index.ts +32 -0
  267. package/src/core/uiScale/scale.ts +63 -0
  268. package/src/core/uiScale/uiScale.spec.ts +77 -0
  269. package/src/core/uiScale/uiScaleStore.ts +100 -0
  270. package/src/core/wire/__fixtures__/rowsMarshalWorker.ts +24 -0
  271. package/src/core/wire/index.ts +56 -0
  272. package/src/core/wire/ingress.spec.ts +276 -0
  273. package/src/core/wire/ingress.ts +182 -0
  274. package/src/core/wire/marshalWorker.spec.ts +196 -0
  275. package/src/core/wire/marshalWorker.ts +361 -0
  276. package/src/core/wire/marshalling.spec.ts +139 -0
  277. package/src/core/wire/marshalling.ts +238 -0
  278. package/src/core/wire/wireHub.spec.ts +137 -0
  279. package/src/core/wire/wireHub.ts +213 -0
  280. package/src/ctgr/base64url.ts +44 -0
  281. package/src/ctgr/chunkAccumulator.ts +403 -0
  282. package/src/ctgr/chunkStore.spec.ts +101 -0
  283. package/src/ctgr/chunkStore.ts +168 -0
  284. package/src/ctgr/codec.spec.ts +208 -0
  285. package/src/ctgr/codec.ts +202 -0
  286. package/src/ctgr/compress.ts +21 -0
  287. package/src/ctgr/crc32.ts +23 -0
  288. package/src/ctgr/digest.ts +14 -0
  289. package/src/ctgr/index.ts +39 -0
  290. package/src/ctgr/types.ts +80 -0
  291. package/src/ctgr/v0compat.spec.ts +73 -0
  292. package/src/ctgr/v0compat.ts +156 -0
  293. package/src/declaredDepsAreImported.spec.ts +87 -0
  294. package/src/index.ts +25 -0
  295. package/src/leafSubpathsImportNothing.spec.ts +287 -0
  296. package/src/lockfileIsTracked.spec.ts +50 -0
  297. package/src/namedSubpathsResolve.spec.ts +122 -0
  298. package/src/noPathDeps.spec.ts +24 -0
  299. package/src/publishShape.spec.ts +89 -0
  300. package/src/shared/errors.ts +153 -0
  301. package/src/skippedTestsAreAnswered.spec.ts +221 -0
  302. package/src/testTmpRoot.spec.ts +102 -0
  303. package/src/testing/bytes.ts +38 -0
  304. package/src/testing/domInspect.spec.ts +79 -0
  305. package/src/testing/domInspect.ts +127 -0
  306. package/src/testing/fakeFetch.ts +26 -0
  307. package/src/testing/index.ts +11 -0
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Folder PATH encoding — the storage contract every consumer of the folder tree
3
+ * shares. A folder path is an ordered array of segment names (`["Home","Phones",
4
+ * "Noah"]`); on disk it is a single delimiter-joined STRING so it can ride in one
5
+ * field (an entry's `meta.folder`, a note's group tag) without a separate registry.
6
+ *
7
+ * ── Why a delimited string and not id references ─────────────────────────────
8
+ * The first consumer (the vault) is a zero-knowledge, op-log-synced store: a folder
9
+ * lives inside an entry's ENCRYPTED meta, and there is no server-side place to keep
10
+ * an id→name map that every device would have to converge on. Deriving the whole
11
+ * tree from the set of path strings on the items keeps folders a pure function of
12
+ * the items — nothing extra to sync, nothing to version-skew. The cost is that a
13
+ * folder exists only while it (or a descendant) holds an item; that is the same
14
+ * "derived, not declared" model the vault already used for flat folders, extended
15
+ * to nesting.
16
+ *
17
+ * ── The delimiter, and why it is ESCAPED rather than forbidden ───────────────
18
+ * `/` joins segments. A `/` is common enough inside a real folder name ("TV/Film",
19
+ * "and/or") that forbidding it would be a footgun, and — critically — a MIGRATION
20
+ * hazard: a legacy flat folder literally named "TV/Movies" must stay ONE folder,
21
+ * not silently split into two. So the delimiter is escaped, not banned: a literal
22
+ * `/` in a name becomes `\/` and a literal `\` becomes `\\`. Serialize→parse is a
23
+ * lossless round trip for any name that is non-empty and free of control chars.
24
+ */
25
+ /** The character that joins path segments in the stored string. */
26
+ export const FOLDER_PATH_DELIMITER = "/";
27
+ /** The escape character. A literal delimiter or escape inside a name is prefixed. */
28
+ export const FOLDER_PATH_ESCAPE = "\\";
29
+ /** Trim a raw name for use as a segment. Internal whitespace is preserved; only the
30
+ * ends are trimmed, so " My Bank " and "My Bank" are the same folder. */
31
+ export function normalizeFolderName(name) {
32
+ return name.trim();
33
+ }
34
+ /** A folder name is valid iff it is non-empty after trimming and carries no control
35
+ * characters (which would corrupt the stored string and never render). The
36
+ * delimiter and escape are ALLOWED — they are escaped on serialize. */
37
+ export function isValidFolderName(name) {
38
+ const n = normalizeFolderName(name);
39
+ if (n.length === 0)
40
+ return false;
41
+ // Reject C0 control chars (0x00–0x1F) and DEL (0x7F): they never render and would
42
+ // corrupt the stored string. Checked by code point so no literal control byte
43
+ // (or the no-control-characters-in-regex lint) ever appears in this source.
44
+ for (const ch of n) {
45
+ const code = ch.codePointAt(0) ?? 0;
46
+ if (code < 0x20 || code === 0x7f)
47
+ return false;
48
+ }
49
+ return true;
50
+ }
51
+ /** Escape one segment: `\` → `\\`, then `/` → `\/`. Order matters — escape the
52
+ * escape first so a name containing `\/` is not double-decoded. */
53
+ function escapeSegment(seg) {
54
+ return seg
55
+ .replaceAll(FOLDER_PATH_ESCAPE, FOLDER_PATH_ESCAPE + FOLDER_PATH_ESCAPE)
56
+ .replaceAll(FOLDER_PATH_DELIMITER, FOLDER_PATH_ESCAPE + FOLDER_PATH_DELIMITER);
57
+ }
58
+ /**
59
+ * Serialize a path (array of segment names) into the stored string. Empty and
60
+ * whitespace-only segments are dropped, and each surviving segment is trimmed, so
61
+ * the output is canonical: `serialize(parse(s))` normalizes any spelling of `s`.
62
+ */
63
+ export function serializeFolderPath(segments) {
64
+ return segments
65
+ .map(normalizeFolderName)
66
+ .filter((s) => s.length > 0)
67
+ .map(escapeSegment)
68
+ .join(FOLDER_PATH_DELIMITER);
69
+ }
70
+ /**
71
+ * Parse a stored string back into its segment array. Splits on UNESCAPED delimiters
72
+ * only, then unescapes each segment. Empty segments (a leading/trailing/double
73
+ * delimiter) are dropped rather than becoming blank folders.
74
+ */
75
+ export function parseFolderPath(serialized) {
76
+ const out = [];
77
+ let seg = "";
78
+ let escaped = false;
79
+ for (const ch of serialized) {
80
+ if (escaped) {
81
+ seg += ch;
82
+ escaped = false;
83
+ }
84
+ else if (ch === FOLDER_PATH_ESCAPE) {
85
+ escaped = true;
86
+ }
87
+ else if (ch === FOLDER_PATH_DELIMITER) {
88
+ out.push(seg);
89
+ seg = "";
90
+ }
91
+ else {
92
+ seg += ch;
93
+ }
94
+ }
95
+ out.push(seg);
96
+ // A trailing lone escape (malformed input) is dropped with the empty tail.
97
+ return out.map(normalizeFolderName).filter((s) => s.length > 0);
98
+ }
99
+ /**
100
+ * A case-insensitive identity key for a path — the thing two paths compare equal by.
101
+ * Folder names dedupe case-insensitively (like the flat rail always did), so "Work"
102
+ * and "work" are one folder, but the key is still per-SEGMENT so "Home/Phones" and
103
+ * "Homework/hones" never collide (which a naive lowercased-string prefix would).
104
+ */
105
+ export function folderPathKey(segments) {
106
+ return serializeFolderPath(segments.map((s) => normalizeFolderName(s).toLowerCase()));
107
+ }
108
+ /** True when `child` is `parent` or sits anywhere beneath it (segment-aligned, so
109
+ * `Home` contains `Home/Phones` but NOT `Homework`). An empty `parent` (the root)
110
+ * contains everything. */
111
+ export function isFolderPathUnder(child, parent) {
112
+ if (parent.length === 0)
113
+ return true;
114
+ if (child.length < parent.length)
115
+ return false;
116
+ for (let i = 0; i < parent.length; i++) {
117
+ const c = normalizeFolderName(child[i] ?? "").toLowerCase();
118
+ const p = normalizeFolderName(parent[i] ?? "").toLowerCase();
119
+ if (c !== p)
120
+ return false;
121
+ }
122
+ return true;
123
+ }
124
+ /**
125
+ * The stored-string prefix EVERY descendant of `segments` starts with, and nothing
126
+ * else does — `serialize(segments) + "/"`. It exists so a store that keeps the
127
+ * serialized path in one column (a SQL `LIKE 'Home/%'`, a `startsWith` on a key/value
128
+ * store) can select a whole subtree without parsing every row.
129
+ *
130
+ * It is exact BECAUSE the delimiter is escaped: a folder literally named `TV/Film`
131
+ * serializes as `TV\/Film`, whose fifth character is the escape, so it can never be
132
+ * mistaken for `TV` + a child. `Homework` does not start with `Home/` either. The one
133
+ * thing the prefix does NOT cover is the folder itself — a caller wants
134
+ * `path = serialized OR path LIKE prefix || '%'`, which is what `isFolderPathUnder`
135
+ * says in memory. The root (an empty path) has no prefix that means "everything", so
136
+ * it returns `""` and the caller should skip the filter entirely.
137
+ */
138
+ export function folderDescendantPrefix(segments) {
139
+ const serialized = serializeFolderPath(segments);
140
+ return serialized === "" ? "" : serialized + FOLDER_PATH_DELIMITER;
141
+ }
142
+ /** Render a path as a human breadcrumb: `Home › Phones › Noah`. The separator is a
143
+ * display choice; the default is the one the picker and rail use. */
144
+ export function formatFolderPath(segments, separator = " › ") {
145
+ return segments.map(normalizeFolderName).join(separator);
146
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The folder TREE model — pure, React-free, and the single source of truth for
3
+ * "given the folder paths the items carry, what is the tree, and what does a
4
+ * rename / move / delete DO to those paths".
5
+ *
6
+ * The consumer owns the items and their storage; this module never touches an item.
7
+ * A structural edit is expressed as a set of {@link FolderRemap}s — `from`→`to`
8
+ * serialized-path rewrites the consumer applies to every item whose folder matches
9
+ * `from`. That keeps the tree generic (it works the same for the vault's encrypted
10
+ * entries and for notes' groups) and keeps the dangerous part — rewriting stored
11
+ * items — a small, testable list of string swaps.
12
+ */
13
+ /** A node in the derived tree. `path` is the full segment array from the root; a
14
+ * `child` node's path is its parent's plus one segment. */
15
+ export interface FolderNode {
16
+ /** The leaf name of this node (its last path segment). */
17
+ name: string;
18
+ /** Full path from the root, e.g. `["Home","Phones"]`. */
19
+ path: string[];
20
+ /** Serialized full path — the value an item at THIS folder stores. */
21
+ serialized: string;
22
+ /** Case-insensitive identity key for the full path. */
23
+ key: string;
24
+ /** Depth from the root (a root folder is 0). */
25
+ depth: number;
26
+ /** Child folders, sorted case-insensitively by name. */
27
+ children: FolderNode[];
28
+ }
29
+ /**
30
+ * Build the nested tree from a flat collection of serialized folder-path strings
31
+ * (one per item, duplicates fine). EVERY prefix becomes a node, so a tree with only
32
+ * `Home/Phones/Noah` still surfaces `Home` and `Home/Phones` as ancestors. Names
33
+ * dedupe case-insensitively, first spelling wins; siblings sort by name.
34
+ */
35
+ export declare function buildFolderTree(serializedPaths: Iterable<string>): FolderNode[];
36
+ /** The derived tree plus a CUMULATIVE per-folder count — how many of the supplied
37
+ * paths sit at-or-under each folder. */
38
+ export interface FolderIndex {
39
+ tree: FolderNode[];
40
+ /** Items at-or-under the folder with this serialized path. Unknown folder → 0. */
41
+ count: (serialized: string) => number;
42
+ }
43
+ /**
44
+ * {@link buildFolderTree} plus counts, in ONE pass over the paths.
45
+ *
46
+ * Counts are cumulative: an item in `Home/Phones/Noah` counts for `Home`,
47
+ * `Home/Phones` AND `Home/Phones/Noah`, so a collapsed parent still shows a
48
+ * meaningful total rather than the zero its own direct membership would report.
49
+ * That is the number a rail wants next to a folder, and every consumer wants the
50
+ * same one — it was written once in the vault and lifted here when notes needed
51
+ * it, rather than counted twice with two chances to disagree about whether a
52
+ * parent includes its children.
53
+ */
54
+ export declare function buildFolderIndex(serializedPaths: Iterable<string>): FolderIndex;
55
+ /** Depth-first flatten (parent before children) — the order a picker lists options
56
+ * and a rail renders rows. */
57
+ export declare function flattenFolderTree(nodes: readonly FolderNode[]): FolderNode[];
58
+ /** A single stored-path rewrite. `to === ""` means the item drops to the root
59
+ * (uncategorized). Consumers apply one to every item whose serialized folder path
60
+ * equals `from`. */
61
+ export interface FolderRemap {
62
+ from: string;
63
+ to: string;
64
+ }
65
+ /**
66
+ * Rename the folder at `target` to `newName`, keeping it under the same parent. Every
67
+ * item at `target` OR beneath it is remapped so descendants move with it. Returns the
68
+ * `from`→`to` rewrites the consumer applies; an empty list means nothing to change.
69
+ * Throws on an invalid/blank `newName`.
70
+ */
71
+ export declare function planFolderRename(serializedPaths: Iterable<string>, target: readonly string[], newName: string): FolderRemap[];
72
+ /**
73
+ * Move the folder at `source` under `newParent` (an empty array = the root),
74
+ * carrying its descendants. Refuses to move a folder into itself or a descendant
75
+ * (which would orphan the subtree), and to move a folder to where it already is.
76
+ * Returns the rewrites to apply.
77
+ */
78
+ export declare function planFolderMove(serializedPaths: Iterable<string>, source: readonly string[], newParent: readonly string[]): FolderRemap[];
79
+ /**
80
+ * Delete the folder at `target` by SPLICING it out: every item at or beneath it is
81
+ * re-homed to `target`'s parent, preserving the relative sub-structure. An item
82
+ * directly in `target` moves to the parent (or the root, if `target` was a root);
83
+ * an item in `target/child` moves to `parent/child`. Nothing is deleted from the
84
+ * consumer's store — items keep their secrets, they just lose one path segment.
85
+ */
86
+ export declare function planFolderDelete(serializedPaths: Iterable<string>, target: readonly string[]): FolderRemap[];
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The folder TREE model — pure, React-free, and the single source of truth for
3
+ * "given the folder paths the items carry, what is the tree, and what does a
4
+ * rename / move / delete DO to those paths".
5
+ *
6
+ * The consumer owns the items and their storage; this module never touches an item.
7
+ * A structural edit is expressed as a set of {@link FolderRemap}s — `from`→`to`
8
+ * serialized-path rewrites the consumer applies to every item whose folder matches
9
+ * `from`. That keeps the tree generic (it works the same for the vault's encrypted
10
+ * entries and for notes' groups) and keeps the dangerous part — rewriting stored
11
+ * items — a small, testable list of string swaps.
12
+ */
13
+ import { folderPathKey, isFolderPathUnder, normalizeFolderName, parseFolderPath, serializeFolderPath, } from "./path";
14
+ /**
15
+ * Build the nested tree from a flat collection of serialized folder-path strings
16
+ * (one per item, duplicates fine). EVERY prefix becomes a node, so a tree with only
17
+ * `Home/Phones/Noah` still surfaces `Home` and `Home/Phones` as ancestors. Names
18
+ * dedupe case-insensitively, first spelling wins; siblings sort by name.
19
+ */
20
+ export function buildFolderTree(serializedPaths) {
21
+ const roots = new Map();
22
+ for (const serialized of serializedPaths) {
23
+ const segments = parseFolderPath(serialized);
24
+ if (segments.length === 0)
25
+ continue;
26
+ let level = roots;
27
+ const acc = [];
28
+ for (const seg of segments) {
29
+ acc.push(seg);
30
+ const k = normalizeFolderName(seg).toLowerCase();
31
+ let node = level.get(k);
32
+ if (!node) {
33
+ node = { name: seg, path: [...acc], children: new Map() };
34
+ level.set(k, node);
35
+ }
36
+ level = node.children;
37
+ }
38
+ }
39
+ const toNode = (b, depth) => ({
40
+ name: b.name,
41
+ path: b.path,
42
+ serialized: serializeFolderPath(b.path),
43
+ key: folderPathKey(b.path),
44
+ depth,
45
+ children: sortNodes([...b.children.values()].map((c) => toNode(c, depth + 1))),
46
+ });
47
+ return sortNodes([...roots.values()].map((b) => toNode(b, 0)));
48
+ }
49
+ /**
50
+ * {@link buildFolderTree} plus counts, in ONE pass over the paths.
51
+ *
52
+ * Counts are cumulative: an item in `Home/Phones/Noah` counts for `Home`,
53
+ * `Home/Phones` AND `Home/Phones/Noah`, so a collapsed parent still shows a
54
+ * meaningful total rather than the zero its own direct membership would report.
55
+ * That is the number a rail wants next to a folder, and every consumer wants the
56
+ * same one — it was written once in the vault and lifted here when notes needed
57
+ * it, rather than counted twice with two chances to disagree about whether a
58
+ * parent includes its children.
59
+ */
60
+ export function buildFolderIndex(serializedPaths) {
61
+ const paths = [];
62
+ const counts = new Map();
63
+ for (const serialized of serializedPaths) {
64
+ const segments = parseFolderPath(serialized);
65
+ if (segments.length === 0)
66
+ continue;
67
+ paths.push(serializeFolderPath(segments));
68
+ for (let i = 1; i <= segments.length; i++) {
69
+ const key = folderPathKey(segments.slice(0, i));
70
+ counts.set(key, (counts.get(key) ?? 0) + 1);
71
+ }
72
+ }
73
+ return {
74
+ tree: buildFolderTree(paths),
75
+ count: (serialized) => counts.get(folderPathKey(parseFolderPath(serialized))) ?? 0,
76
+ };
77
+ }
78
+ function sortNodes(nodes) {
79
+ return nodes.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
80
+ }
81
+ /** Depth-first flatten (parent before children) — the order a picker lists options
82
+ * and a rail renders rows. */
83
+ export function flattenFolderTree(nodes) {
84
+ const out = [];
85
+ const walk = (list) => {
86
+ for (const n of list) {
87
+ out.push(n);
88
+ walk(n.children);
89
+ }
90
+ };
91
+ walk(nodes);
92
+ return out;
93
+ }
94
+ /** The distinct set of serialized paths that actually appear on items (parents that
95
+ * exist only as ancestors are not stored on any item, so they never need a remap).
96
+ * Deduped by identity key. */
97
+ function distinctStoredPaths(serializedPaths) {
98
+ const seen = new Map();
99
+ for (const s of serializedPaths) {
100
+ const segs = parseFolderPath(s);
101
+ if (segs.length === 0)
102
+ continue;
103
+ const k = folderPathKey(segs);
104
+ if (!seen.has(k))
105
+ seen.set(k, segs);
106
+ }
107
+ return [...seen.values()];
108
+ }
109
+ /** Rebuild a stored path by swapping its first `prefixLen` segments for `replacement`
110
+ * (which may be empty, re-homing to the root). */
111
+ function replacePrefix(path, prefixLen, replacement) {
112
+ return [...replacement, ...path.slice(prefixLen)];
113
+ }
114
+ /**
115
+ * Rename the folder at `target` to `newName`, keeping it under the same parent. Every
116
+ * item at `target` OR beneath it is remapped so descendants move with it. Returns the
117
+ * `from`→`to` rewrites the consumer applies; an empty list means nothing to change.
118
+ * Throws on an invalid/blank `newName`.
119
+ */
120
+ export function planFolderRename(serializedPaths, target, newName) {
121
+ const name = normalizeFolderName(newName);
122
+ if (name.length === 0)
123
+ throw new Error("a folder name cannot be blank");
124
+ if (target.length === 0)
125
+ throw new Error("cannot rename the root");
126
+ const renamedPrefix = [...target.slice(0, -1), name];
127
+ return remapUnder(serializedPaths, target, renamedPrefix);
128
+ }
129
+ /**
130
+ * Move the folder at `source` under `newParent` (an empty array = the root),
131
+ * carrying its descendants. Refuses to move a folder into itself or a descendant
132
+ * (which would orphan the subtree), and to move a folder to where it already is.
133
+ * Returns the rewrites to apply.
134
+ */
135
+ export function planFolderMove(serializedPaths, source, newParent) {
136
+ if (source.length === 0)
137
+ throw new Error("cannot move the root");
138
+ if (isFolderPathUnder(newParent, source)) {
139
+ throw new Error("cannot move a folder into itself or one of its descendants");
140
+ }
141
+ const movedPrefix = [...newParent, source[source.length - 1]];
142
+ if (folderPathKey(movedPrefix) === folderPathKey(source))
143
+ return [];
144
+ return remapUnder(serializedPaths, source, movedPrefix);
145
+ }
146
+ /**
147
+ * Delete the folder at `target` by SPLICING it out: every item at or beneath it is
148
+ * re-homed to `target`'s parent, preserving the relative sub-structure. An item
149
+ * directly in `target` moves to the parent (or the root, if `target` was a root);
150
+ * an item in `target/child` moves to `parent/child`. Nothing is deleted from the
151
+ * consumer's store — items keep their secrets, they just lose one path segment.
152
+ */
153
+ export function planFolderDelete(serializedPaths, target) {
154
+ if (target.length === 0)
155
+ throw new Error("cannot delete the root");
156
+ const parent = target.slice(0, -1);
157
+ const out = [];
158
+ for (const path of distinctStoredPaths(serializedPaths)) {
159
+ if (!isFolderPathUnder(path, target))
160
+ continue;
161
+ // Drop the target segment: keep the parent, then everything below target.
162
+ const spliced = [...parent, ...path.slice(target.length)];
163
+ const from = serializeFolderPath(path);
164
+ const to = serializeFolderPath(spliced);
165
+ if (from !== to)
166
+ out.push({ from, to });
167
+ }
168
+ return out;
169
+ }
170
+ /** Shared core for rename/move: replace the `target`-length prefix of every matching
171
+ * stored path with `replacement`. */
172
+ function remapUnder(serializedPaths, target, replacement) {
173
+ const out = [];
174
+ for (const path of distinctStoredPaths(serializedPaths)) {
175
+ if (!isFolderPathUnder(path, target))
176
+ continue;
177
+ const rebuilt = replacePrefix(path, target.length, replacement);
178
+ const from = serializeFolderPath(path);
179
+ const to = serializeFolderPath(rebuilt);
180
+ if (from !== to)
181
+ out.push({ from, to });
182
+ }
183
+ return out;
184
+ }
@@ -0,0 +1 @@
1
+ export * from 'cwip/layout';
@@ -0,0 +1,7 @@
1
+ // src/core/layout — re-export of `cwip/layout`, the single source of truth for the
2
+ // framework-agnostic layout/widget engine core (v2 node model + idempotent migration,
3
+ // pure treeOps, aggregates, container-query grid/style class maps, binding resolution,
4
+ // undo/redo history, wire version). cursedbelt's copy was absorbed into cwip
5
+ // (feat/layout-absorb) — add layout-engine core logic THERE, and the React
6
+ // renderer/editor on top of it here (src/react/layoutEngine).
7
+ export * from 'cwip/layout';
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `cursedbelt-core/master-lock` — the isomorphic half of the sitewide master-password lock.
3
+ *
4
+ * Derivation, policy numbers and the wire contract: everything both the browser and the
5
+ * server need to agree on, and nothing that needs either. The server engine is
6
+ * `cursedbelt-server/master-lock`; the in-app controls are `cursedbelt/react/master-lock`.
7
+ *
8
+ * What the whole feature is, and what it is NOT, is written once in the engine's module
9
+ * note — read that before changing anything here.
10
+ */
11
+ export { MASTER_LOCK_KDF, type MasterLockKdfParams, b64u, deriveMasterLockVerifier, isMasterLockKdfParams, newMasterLockKdfParams, unb64u, } from "./kdf";
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";
14
+ export { PRESENCE_ATTRIBUTE, type PresenceMedia, isPresenceMedia, isSomebodyWatching, } from "./presence";
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `cursedbelt-core/master-lock` — the isomorphic half of the sitewide master-password lock.
3
+ *
4
+ * Derivation, policy numbers and the wire contract: everything both the browser and the
5
+ * server need to agree on, and nothing that needs either. The server engine is
6
+ * `cursedbelt-server/master-lock`; the in-app controls are `cursedbelt/react/master-lock`.
7
+ *
8
+ * What the whole feature is, and what it is NOT, is written once in the engine's module
9
+ * note — read that before changing anything here.
10
+ */
11
+ export { MASTER_LOCK_KDF, b64u, deriveMasterLockVerifier, isMasterLockKdfParams, newMasterLockKdfParams, unb64u, } from "./kdf";
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, } from "./wire";
14
+ export { PRESENCE_ATTRIBUTE, isPresenceMedia, isSomebodyWatching, } from "./presence";
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The master-password derivation — deliberately BYTE-IDENTICAL to `apps/vault`'s.
3
+ *
4
+ * ── Why identical, and not merely similar ───────────────────────────────────
5
+ * The owner's instruction for this feature was *"set the master password to be the same
6
+ * as the vault master password for now … apply the verification like vault uses so I can
7
+ * type in the same master password I have on the vault and it works"*. Nobody — not the
8
+ * vault, not an agent, not this code — knows that password. The only way an app can accept
9
+ * it on day one is to be seeded with the vault's own KDF params and the vault's own stored
10
+ * `argon2id(verifier)`, and for that seed to mean anything the derivation has to agree to
11
+ * the byte. So this is a deliberate DUPLICATE of `apps/vault/src/crypto/kdf.ts`, pinned by
12
+ * `kdf.spec.ts` against the same fixed (salt, password) → verifier vector that file pins.
13
+ *
14
+ * It is not imported from the vault because the dependency would point the wrong way: a
15
+ * satellite app is a consumer of this package, and `cursedbelt` may never import one.
16
+ *
17
+ * ── The two outputs ────────────────────────────────────────────────────────
18
+ * KEK = PBKDF2-SHA256(password, salt, 600_000) → 256 bits
19
+ * verifier = PBKDF2-SHA256(KEK ‖ password, salt, 1), base64url
20
+ *
21
+ * The extra round exists so the value the server sees is one step past the key. Here there
22
+ * IS no encryption key — this lock guards a UI, and the bytes behind it are plaintext at
23
+ * rest by nature (see the module note in `../../server/master-lock/masterLock.ts`). The KEK
24
+ * is computed only because dropping it would change the verifier and break the whole point
25
+ * of the paragraph above.
26
+ *
27
+ * The server stores `argon2id(verifier)`, never the verifier, and the password never leaves
28
+ * the browser.
29
+ */
30
+ /** The pinned v1 parameters. Iterations may only ever be RAISED, and only by a change. */
31
+ export declare const MASTER_LOCK_KDF: {
32
+ readonly v: 1;
33
+ readonly alg: "PBKDF2-SHA256";
34
+ readonly iter: 600000;
35
+ readonly saltBytes: 16;
36
+ };
37
+ /** The versioned KDF descriptor. Every field is PUBLIC — it is served to the lock page
38
+ * before anyone has proved anything, exactly as the vault serves it. */
39
+ export interface MasterLockKdfParams {
40
+ readonly v: 1;
41
+ readonly alg: "PBKDF2-SHA256";
42
+ readonly iter: number;
43
+ /** base64url of the random salt. */
44
+ readonly salt: string;
45
+ }
46
+ /** Is this a KDF descriptor this build knows how to derive under? Used at the seam where
47
+ * params arrive from a database, an env seed or the network — all three are outside this
48
+ * module's control, and a silently-wrong `iter` derives a verifier that can never match. */
49
+ export declare function isMasterLockKdfParams(value: unknown): value is MasterLockKdfParams;
50
+ /** Fresh params for a NEW master password: today's pinned numbers, a random salt. */
51
+ export declare function newMasterLockKdfParams(): MasterLockKdfParams;
52
+ /**
53
+ * Derive the unlock verifier from a typed master password.
54
+ *
55
+ * Runs in the BROWSER (the lock page and the change-password dialog) and in `bun test`.
56
+ * `crypto.subtle` ships PBKDF2 natively in both, which is why it is PBKDF2 and not argon2 —
57
+ * the same reasoning the vault records at length.
58
+ */
59
+ export declare function deriveMasterLockVerifier(masterPassword: string, params: MasterLockKdfParams): Promise<string>;
60
+ /** base64url, unpadded — the vault's `b64u`. */
61
+ export declare function b64u(bytes: Uint8Array): string;
62
+ /** The inverse of {@link b64u}. */
63
+ export declare function unb64u(text: string): Uint8Array;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The master-password derivation — deliberately BYTE-IDENTICAL to `apps/vault`'s.
3
+ *
4
+ * ── Why identical, and not merely similar ───────────────────────────────────
5
+ * The owner's instruction for this feature was *"set the master password to be the same
6
+ * as the vault master password for now … apply the verification like vault uses so I can
7
+ * type in the same master password I have on the vault and it works"*. Nobody — not the
8
+ * vault, not an agent, not this code — knows that password. The only way an app can accept
9
+ * it on day one is to be seeded with the vault's own KDF params and the vault's own stored
10
+ * `argon2id(verifier)`, and for that seed to mean anything the derivation has to agree to
11
+ * the byte. So this is a deliberate DUPLICATE of `apps/vault/src/crypto/kdf.ts`, pinned by
12
+ * `kdf.spec.ts` against the same fixed (salt, password) → verifier vector that file pins.
13
+ *
14
+ * It is not imported from the vault because the dependency would point the wrong way: a
15
+ * satellite app is a consumer of this package, and `cursedbelt` may never import one.
16
+ *
17
+ * ── The two outputs ────────────────────────────────────────────────────────
18
+ * KEK = PBKDF2-SHA256(password, salt, 600_000) → 256 bits
19
+ * verifier = PBKDF2-SHA256(KEK ‖ password, salt, 1), base64url
20
+ *
21
+ * The extra round exists so the value the server sees is one step past the key. Here there
22
+ * IS no encryption key — this lock guards a UI, and the bytes behind it are plaintext at
23
+ * rest by nature (see the module note in `../../server/master-lock/masterLock.ts`). The KEK
24
+ * is computed only because dropping it would change the verifier and break the whole point
25
+ * of the paragraph above.
26
+ *
27
+ * The server stores `argon2id(verifier)`, never the verifier, and the password never leaves
28
+ * the browser.
29
+ */
30
+ /** The pinned v1 parameters. Iterations may only ever be RAISED, and only by a change. */
31
+ export const MASTER_LOCK_KDF = {
32
+ v: 1,
33
+ alg: "PBKDF2-SHA256",
34
+ iter: 600_000,
35
+ saltBytes: 16,
36
+ };
37
+ const B64_ALPHABET = /^[0-9A-Za-z_-]+$/;
38
+ /** Is this a KDF descriptor this build knows how to derive under? Used at the seam where
39
+ * params arrive from a database, an env seed or the network — all three are outside this
40
+ * module's control, and a silently-wrong `iter` derives a verifier that can never match. */
41
+ export function isMasterLockKdfParams(value) {
42
+ if (value === null || typeof value !== "object")
43
+ return false;
44
+ const p = value;
45
+ return (p.v === 1 &&
46
+ p.alg === "PBKDF2-SHA256" &&
47
+ typeof p.iter === "number" &&
48
+ Number.isInteger(p.iter) &&
49
+ p.iter >= 1 &&
50
+ typeof p.salt === "string" &&
51
+ p.salt.length > 0 &&
52
+ B64_ALPHABET.test(p.salt));
53
+ }
54
+ /** Fresh params for a NEW master password: today's pinned numbers, a random salt. */
55
+ export function newMasterLockKdfParams() {
56
+ const salt = new Uint8Array(MASTER_LOCK_KDF.saltBytes);
57
+ crypto.getRandomValues(salt);
58
+ return { v: MASTER_LOCK_KDF.v, alg: MASTER_LOCK_KDF.alg, iter: MASTER_LOCK_KDF.iter, salt: b64u(salt) };
59
+ }
60
+ /**
61
+ * Derive the unlock verifier from a typed master password.
62
+ *
63
+ * Runs in the BROWSER (the lock page and the change-password dialog) and in `bun test`.
64
+ * `crypto.subtle` ships PBKDF2 natively in both, which is why it is PBKDF2 and not argon2 —
65
+ * the same reasoning the vault records at length.
66
+ */
67
+ export async function deriveMasterLockVerifier(masterPassword, params) {
68
+ if (!isMasterLockKdfParams(params)) {
69
+ throw new Error(`unsupported master-lock KDF params: ${JSON.stringify(params)}`);
70
+ }
71
+ const salt = unb64u(params.salt);
72
+ const kek = await pbkdf2(utf8(masterPassword), salt, params.iter);
73
+ // Same salt as the KEK, by design: it individualises the verifier without a second
74
+ // stored value. One round, over KEK ‖ password.
75
+ const verifier = await pbkdf2(concat(kek, utf8(masterPassword)), salt, 1);
76
+ return b64u(verifier);
77
+ }
78
+ async function pbkdf2(material, salt, iterations) {
79
+ const key = await crypto.subtle.importKey("raw", buf(material), "PBKDF2", false, ["deriveBits"]);
80
+ const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt: buf(salt), iterations, hash: "SHA-256" }, key, 256);
81
+ return new Uint8Array(bits);
82
+ }
83
+ // ── bytes ───────────────────────────────────────────────────────────────────
84
+ // Local rather than imported: this module is the one piece that must stay derivable with
85
+ // nothing behind it, and `atob`/`btoa` exist in every runtime it runs in.
86
+ /** A copy whose backing store is a plain `ArrayBuffer` — what `BufferSource` wants, and
87
+ * what a `Uint8Array` over a `SharedArrayBuffer` is not. */
88
+ function buf(bytes) {
89
+ const out = new ArrayBuffer(bytes.byteLength);
90
+ new Uint8Array(out).set(bytes);
91
+ return out;
92
+ }
93
+ function utf8(text) {
94
+ return new TextEncoder().encode(text);
95
+ }
96
+ function concat(a, b) {
97
+ const out = new Uint8Array(a.length + b.length);
98
+ out.set(a, 0);
99
+ out.set(b, a.length);
100
+ return out;
101
+ }
102
+ /** base64url, unpadded — the vault's `b64u`. */
103
+ export function b64u(bytes) {
104
+ let binary = "";
105
+ for (const byte of bytes)
106
+ binary += String.fromCharCode(byte);
107
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
108
+ }
109
+ /** The inverse of {@link b64u}. */
110
+ export function unb64u(text) {
111
+ const padded = text.replace(/-/g, "+").replace(/_/g, "/");
112
+ const binary = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
113
+ const out = new Uint8Array(binary.length);
114
+ for (let i = 0; i < binary.length; i += 1)
115
+ out[i] = binary.charCodeAt(i);
116
+ return out;
117
+ }