@hoardodile/host 0.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 (144) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +73 -0
  3. package/dist/chunks/worker-entry.mjs +422 -0
  4. package/dist/container-fKxefO7p.d.ts +58 -0
  5. package/dist/contract/index.d.ts +49 -0
  6. package/dist/contract/index.js +187 -0
  7. package/dist/contract/index.js.map +1 -0
  8. package/dist/hoard/index.d.ts +754 -0
  9. package/dist/hoard/index.js +1939 -0
  10. package/dist/hoard/index.js.map +1 -0
  11. package/dist/image-Dx2Be3Bj.d.ts +168 -0
  12. package/dist/index.d.ts +788 -0
  13. package/dist/index.js +3311 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/media/index.d.ts +96 -0
  16. package/dist/media/index.js +152 -0
  17. package/dist/media/index.js.map +1 -0
  18. package/dist/probe/index.d.ts +113 -0
  19. package/dist/probe/index.js +454 -0
  20. package/dist/probe/index.js.map +1 -0
  21. package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
  22. package/dist/render/index.d.ts +125 -0
  23. package/dist/render/index.js +430 -0
  24. package/dist/render/index.js.map +1 -0
  25. package/package.json +103 -0
  26. package/src/__testutils__/zip-fixtures.ts +139 -0
  27. package/src/activation.ts +116 -0
  28. package/src/api-types.ts +60 -0
  29. package/src/api.test.ts +564 -0
  30. package/src/api.ts +501 -0
  31. package/src/archive/7z.test.ts +222 -0
  32. package/src/archive/7z.ts +261 -0
  33. package/src/archive/archive-ops.test.ts +654 -0
  34. package/src/archive/extract-archive.test.ts +237 -0
  35. package/src/archive/extract-archive.ts +534 -0
  36. package/src/archive/extract.ts +693 -0
  37. package/src/archive/format.ts +49 -0
  38. package/src/archive/index.ts +116 -0
  39. package/src/archive/listing.ts +121 -0
  40. package/src/archive/materialize.ts +88 -0
  41. package/src/archive/name-decode.test.ts +34 -0
  42. package/src/archive/name-decode.ts +287 -0
  43. package/src/archive/nested-cd-cache.ts +55 -0
  44. package/src/archive/nested-entry.test.ts +194 -0
  45. package/src/archive/nested-entry.ts +254 -0
  46. package/src/archive/pack.ts +40 -0
  47. package/src/archive/zip-entries.test.ts +56 -0
  48. package/src/archive/zip-entries.ts +336 -0
  49. package/src/capability-guard.ts +73 -0
  50. package/src/container.ts +62 -0
  51. package/src/contract/container-fixture.ts +74 -0
  52. package/src/contract/index.ts +10 -0
  53. package/src/contract/suite.test.ts +101 -0
  54. package/src/contract/suite.ts +205 -0
  55. package/src/define-plugin.test.ts +53 -0
  56. package/src/define-plugin.ts +12 -0
  57. package/src/dev-runner.test.ts +98 -0
  58. package/src/dev-runner.ts +73 -0
  59. package/src/directory-api.test.ts +229 -0
  60. package/src/directory-api.ts +42 -0
  61. package/src/directory-container.ts +141 -0
  62. package/src/discovery.ts +295 -0
  63. package/src/errors.ts +74 -0
  64. package/src/fixtures.ts +8 -0
  65. package/src/hash.test.ts +301 -0
  66. package/src/hash.ts +167 -0
  67. package/src/hoard/dir-size.ts +58 -0
  68. package/src/hoard/index.ts +84 -0
  69. package/src/hoard/migrate-script.test.ts +539 -0
  70. package/src/hoard/order-manifest.test.ts +115 -0
  71. package/src/hoard/order-manifest.ts +131 -0
  72. package/src/hoard/paths.test.ts +226 -0
  73. package/src/hoard/paths.ts +569 -0
  74. package/src/hoard/plugin-vault.test.ts +160 -0
  75. package/src/hoard/plugin-vault.ts +233 -0
  76. package/src/hoard/sanitize.test.ts +149 -0
  77. package/src/hoard/sanitize.ts +230 -0
  78. package/src/hoard/staging-dir.ts +133 -0
  79. package/src/hoard/version.test.ts +285 -0
  80. package/src/hoard/version.ts +216 -0
  81. package/src/hoard/versioned-folder-ops.ts +152 -0
  82. package/src/hoard/write-versioned.test.ts +67 -0
  83. package/src/hoard/write-versioned.ts +39 -0
  84. package/src/hooks.test.ts +528 -0
  85. package/src/hooks.ts +428 -0
  86. package/src/index.ts +135 -0
  87. package/src/loader.test.ts +442 -0
  88. package/src/loader.ts +213 -0
  89. package/src/media/index.ts +16 -0
  90. package/src/media/render-cache.ts +27 -0
  91. package/src/media/seekable.ts +35 -0
  92. package/src/media/thumb-input.test.ts +109 -0
  93. package/src/media/thumb-input.ts +194 -0
  94. package/src/nested-view.test.ts +223 -0
  95. package/src/nested-view.ts +239 -0
  96. package/src/probe/audio.test.ts +135 -0
  97. package/src/probe/audio.ts +140 -0
  98. package/src/probe/av.ts +84 -0
  99. package/src/probe/ffprobe.ts +197 -0
  100. package/src/probe/image.test.ts +251 -0
  101. package/src/probe/image.ts +336 -0
  102. package/src/probe/index.ts +54 -0
  103. package/src/probe/sniff.test.ts +72 -0
  104. package/src/probe/sniff.ts +67 -0
  105. package/src/probe/video.test.ts +41 -0
  106. package/src/probe/video.ts +116 -0
  107. package/src/probe-cache.test.ts +89 -0
  108. package/src/probe-cache.ts +65 -0
  109. package/src/render/ffmpeg.ts +78 -0
  110. package/src/render/index.ts +34 -0
  111. package/src/render/pipeline.test.ts +187 -0
  112. package/src/render/pipeline.ts +479 -0
  113. package/src/sandbox/call-timers.ts +68 -0
  114. package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
  115. package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
  116. package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
  117. package/src/sandbox/fixtures/container-plugin.mjs +8 -0
  118. package/src/sandbox/fixtures/context-plugin.mjs +19 -0
  119. package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
  120. package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
  121. package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
  122. package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
  123. package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
  124. package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
  125. package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
  126. package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
  127. package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
  128. package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
  129. package/src/sandbox/fixtures/range-plugin.mjs +9 -0
  130. package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
  131. package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
  132. package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
  133. package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
  134. package/src/sandbox/host.test.ts +619 -0
  135. package/src/sandbox/host.ts +933 -0
  136. package/src/sandbox/host.unit.test.ts +330 -0
  137. package/src/sandbox/protocol.test.ts +85 -0
  138. package/src/sandbox/protocol.ts +147 -0
  139. package/src/sandbox/sandboxed-plugin.ts +30 -0
  140. package/src/sandbox/worker-entry.mjs +422 -0
  141. package/src/seed.test.ts +102 -0
  142. package/src/seed.ts +160 -0
  143. package/src/settings-store.ts +26 -0
  144. package/src/types.ts +21 -0
@@ -0,0 +1,569 @@
1
+ import { createHash } from "node:crypto"
2
+ import { isAbsolute, resolve, sep } from "node:path"
3
+ import {
4
+ currentVersion as diskCurrentVersion,
5
+ readActiveVersion,
6
+ } from "./version.ts"
7
+
8
+ /**
9
+ * Windows reserved base names (case-insensitive). These must not appear as
10
+ * the base of any filename we create, regardless of extension, or CreateFile
11
+ * will fail with bizarre errors. The set is strict on Windows and advisory
12
+ * on other platforms.
13
+ *
14
+ * @see https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
15
+ */
16
+ const WINDOWS_RESERVED = new Set([
17
+ "CON",
18
+ "PRN",
19
+ "AUX",
20
+ "NUL",
21
+ "COM1",
22
+ "COM2",
23
+ "COM3",
24
+ "COM4",
25
+ "COM5",
26
+ "COM6",
27
+ "COM7",
28
+ "COM8",
29
+ "COM9",
30
+ "LPT1",
31
+ "LPT2",
32
+ "LPT3",
33
+ "LPT4",
34
+ "LPT5",
35
+ "LPT6",
36
+ "LPT7",
37
+ "LPT8",
38
+ "LPT9",
39
+ ])
40
+
41
+ /**
42
+ * Name of the content subfolder inside a resource folder. User entries
43
+ * live under `resources/<id>/data/`; metadata dotfiles (`.cover.*`,
44
+ * `.deleted`, `.order`) stay at the resource root. Content and metadata
45
+ * are replaced/kept independently: commits swap the whole `data/`
46
+ * subtree, while root dotfiles survive re-uploads.
47
+ */
48
+ export const RESOURCE_DATA_DIR_NAME = "data"
49
+
50
+ const FORBIDDEN_VISIBLE_CHARS = '<>:"|?*'
51
+
52
+ function hasControlChar(segment: string): boolean {
53
+ for (let i = 0; i < segment.length; i++) {
54
+ if (segment.charCodeAt(i) < 32) return true
55
+ }
56
+ return false
57
+ }
58
+
59
+ function hasForbiddenVisibleChar(segment: string): boolean {
60
+ for (let i = 0; i < segment.length; i++) {
61
+ const ch = segment[i]
62
+ if (ch !== undefined && FORBIDDEN_VISIBLE_CHARS.includes(ch)) return true
63
+ }
64
+ return false
65
+ }
66
+
67
+ /**
68
+ * Root-level subdirectory semantics:
69
+ * - `versions/<version>/` is the user's manual-sync scope, partitioned by
70
+ * archive version. Each version folder holds: `app.sqlite` (the
71
+ * per-version database snapshot), `db-backups/` (manual backups,
72
+ * only kept for the current version), `snapshots/` (automatic daily
73
+ * snapshots, only kept for the current version), `resources/<id>/`,
74
+ * `characters/<id>/`, `plugins/<id>/` (installed content plugins
75
+ * frozen with that version; the builtin `file` plugin is not stored
76
+ * here). Old versions are FROZEN: no writes ever land in
77
+ * `versions/<v>` once a `versions/<v+1>` exists.
78
+ * - `local` holds host-only state. Derived caches (thumbs, previews,
79
+ * extraction caches, tmp, read-only DB clones for past-version
80
+ * viewing) live under `local/cache/` and are wiped wholesale by
81
+ * clear cache; the rest (`logs`, `trash`, session key, upload
82
+ * staging) is persistent. It never leaves the host.
83
+ *
84
+ * `paths.active` resolves to the **active** version (which may be the
85
+ * latest version, or a past version when the user is viewing
86
+ * read-only). `paths.latest` always points at the latest version --
87
+ * the only version writers may target. `paths.atVersion(v)` exposes
88
+ * arbitrary cross-version reads (used for character avatar/fullbody
89
+ * fallback once `avatarVersion` / `fullbodyVersion` columns point at a
90
+ * historical archive).
91
+ *
92
+ * Every resolved path MUST come out of this module so the frozen-version
93
+ * boundary stays enforceable.
94
+ */
95
+ export type StoragePaths = {
96
+ readonly root: string
97
+ readonly activeVersion: number
98
+ readonly latestVersion: number
99
+ readonly active: VersionPaths
100
+ readonly latest: VersionPaths
101
+ readonly local: LocalPaths
102
+ /** Per-version archive paths. Use for cross-version fallback reads. */
103
+ atVersion(v: number): VersionPaths
104
+ /**
105
+ * Path to the live runtime SQLite DB: `<root>/app.sqlite`.
106
+ * This file is the only writable database during normal operation.
107
+ * It lives outside `versions/` so that syncing `versions/` to other
108
+ * devices cannot corrupt the in-use database. Only archived snapshots
109
+ * (written by {@link createNextVersion}) and backup files belong in
110
+ * `versions/`.
111
+ */
112
+ runtimeDb(): string
113
+ }
114
+
115
+ export type VersionPaths = {
116
+ readonly root: string
117
+ readonly version: number
118
+ /** Path to the per-version SQLite DB: `<root>/versions/<v>/app.sqlite`. */
119
+ versionSnapshotDb(): string
120
+ /** Root folder of a resource: `<root>/versions/<v>/resources/<id>`. */
121
+ resource(id: string): string
122
+ /**
123
+ * Content root of a resource — where user entries live:
124
+ * `<root>/versions/<v>/resources/<id>/data`. The container reads
125
+ * entries from here; the resource root only holds metadata dotfiles.
126
+ */
127
+ resourceData(id: string): string
128
+ /** Root folder of all resources in this version: `<root>/versions/<v>/resources`. */
129
+ resources(): string
130
+ /** Root folder of all characters in this version: `<root>/versions/<v>/characters`. */
131
+ characters(): string
132
+ /** Root folder of all documents in this version: `<root>/versions/<v>/documents`. */
133
+ documents(): string
134
+ /** Root folder of a character: `<root>/versions/<v>/characters/<id>`. */
135
+ character(id: string): string
136
+ /** Root of manual backups: `<root>/versions/<v>/db-backups`. */
137
+ dbBackups(): string
138
+ /** Path to one manual backup: `<root>/versions/<v>/db-backups/<name>`. */
139
+ dbBackup(name: string): string
140
+ /** Root of automatic snapshots: `<root>/versions/<v>/snapshots`. */
141
+ snapshots(): string
142
+ /** Path to one automatic snapshot: `<root>/versions/<v>/snapshots/<name>`. */
143
+ snapshot(name: string): string
144
+ /**
145
+ * Path to a deleted-entity placeholder
146
+ * (`<root>/versions/<v>/<kind>/<id>/.deleted`) written when hard
147
+ * delete cannot remove a folder whose files live under frozen past
148
+ * archives.
149
+ */
150
+ deletedMarker(kind: "resources" | "characters", id: string): string
151
+ /** Root folder of a document: `<root>/versions/<v>/documents/<id>`. */
152
+ document(id: string): string
153
+ /**
154
+ * Installed content plugins for this version:
155
+ * `<root>/versions/<v>/plugins`. Each subdirectory is named by
156
+ * `manifest.id`. The builtin `file` plugin is not stored here.
157
+ */
158
+ plugins(): string
159
+ /**
160
+ * Host-managed plugin asset vault of an installed plugin:
161
+ * `<root>/versions/<v>/plugins/<id>/vault`. Downloads land here
162
+ * (user-consented, vault-confined); the directory is host-owned,
163
+ * reserved against plugin zips (upload rejects a shipped `vault/`),
164
+ * and its lifecycle follows the plugin's own directory.
165
+ */
166
+ pluginVaultDir(id: string): string
167
+ }
168
+
169
+ export type LocalPaths = {
170
+ /** The local (non-synced) root: `<root>/local`. */
171
+ readonly root: string
172
+ /** Derived cache root: `<localRoot>/cache`. */
173
+ cache(): string
174
+ /** Server logs: `<localRoot>/logs`. */
175
+ logs(): string
176
+ /**
177
+ * Path to a local derived cover/thumb variant:
178
+ * `<localRoot>/cache/<resources|characters>/<id>/<variant>.<format>`.
179
+ * Holds synthesized covers (resource covers, character avatars and
180
+ * fullbody images); re-rendered when cleared.
181
+ */
182
+ localCover(
183
+ subjectKind: "resource" | "character",
184
+ id: string,
185
+ variant: string,
186
+ format?: string,
187
+ ): string
188
+ /**
189
+ * Per-file derived image variant:
190
+ * `<localRoot>/cache/resources/<id>/file-preview/<sourceCacheId>__<variantKey>.<format>`.
191
+ * The source identity (see {@link sourceCacheId}) is collision-free
192
+ * across nested paths; the variant key (see {@link imageVariantKey})
193
+ * encodes the render spec, so distinct specs never collide on one
194
+ * cache file.
195
+ */
196
+ resFileVariant(
197
+ id: string,
198
+ filename: string,
199
+ variantKey: string,
200
+ format?: string,
201
+ ): string
202
+ /** Directory holding a resource's per-file variants. */
203
+ resFilePreviewDir(id: string): string
204
+ /** Resource file-list sidecar: `<localRoot>/cache/resources/<id>/files-cache.json`. */
205
+ resFilesCache(id: string): string
206
+ /**
207
+ * Root of the local per-resource directory:
208
+ * `<localRoot>/cache/resources/<id>`.
209
+ */
210
+ resource(id: string): string
211
+ /**
212
+ * Root of the local per-character directory:
213
+ * `<localRoot>/cache/characters/<id>`.
214
+ * Holds (a) versioned copies of replaced avatar / fullbody images and
215
+ * (b) thumbnail variants (`avatar.webp`, `fullbody.webp`).
216
+ */
217
+ character(id: string): string
218
+ /** Root of the trash: `<localRoot>/trash`. */
219
+ trash(): string
220
+ /** Path to a single trashed item: `<localRoot>/trash/<id>`. */
221
+ trashItem(id: string): string
222
+ /**
223
+ * Root of the local temp directory: `<localRoot>/cache/tmp`. Holds
224
+ * short-lived working files (upload buffers, backup markers, read-only
225
+ * `view-<v>.sqlite` clones). Lives under the cache root so clear cache
226
+ * wipes it together with the other derived data; it is also cleaned on
227
+ * server startup.
228
+ */
229
+ tmp(): string
230
+ /** Path to a single temp file: `<localRoot>/cache/tmp/<name>`. */
231
+ tmpFile(name: string): string
232
+ /**
233
+ * Path to the iron-session seal key file: `<root>/local/.session-key`.
234
+ * 32-byte base64-encoded secret, auto-generated on first boot. Lives in
235
+ * `local/` (never synced) so each host has its own seal key.
236
+ */
237
+ sessionKey(): string
238
+ /**
239
+ * Root of the host-only temporary directory tree:
240
+ * `<localRoot>/.tmp`. Holds the global staging pool
241
+ * ({@link stagingPoolRoot}) plus short-lived extraction directories
242
+ * (`extract-*`). The leading dot keeps host-only state out of any
243
+ * user-facing listing. Cleared on server startup together with
244
+ * {@link LocalPaths.tmp}.
245
+ */
246
+ uploadStagingRoot(): string
247
+ /**
248
+ * Root of the global staging pool: `<localRoot>/.tmp/staging`.
249
+ * Every file uploaded through the per-file upload endpoint lands
250
+ * here as `<fileId><ext>` and is addressed by its `fileId` alone —
251
+ * there is no per-batch grouping. Files are removed individually
252
+ * on client delete or consumed (and deleted) by commit at resource
253
+ * creation. Cleared on startup together with
254
+ * {@link LocalPaths.uploadStagingRoot}.
255
+ */
256
+ stagingPoolRoot(): string
257
+ /**
258
+ * Path of a single staged file in the global pool:
259
+ * `<stagingPoolRoot>/<fileId><ext>`. `ext` is the lower-cased
260
+ * extension of the original filename (empty for extensionless
261
+ * uploads).
262
+ */
263
+ stagingPoolFile(fileId: string, ext: string): string
264
+ /**
265
+ * Per-video-frame thumbnail cache:
266
+ * `<localRoot>/cache/resources/<id>/frames/<sourceCacheId>/<timeMs>.avif`.
267
+ * Synthesised on-demand by the video hover preview endpoint.
268
+ */
269
+ resVideoFrame(id: string, filename: string, timeMs: number): string
270
+ /**
271
+ * Root of persisted zip-entry extractions for a resource version:
272
+ * `<localRoot>/cache/resources/<id>/extracted/v<fileVersion>/`.
273
+ */
274
+ resExtractedDir(id: string, fileVersion: number): string
275
+ /**
276
+ * On-disk path for a materialized zip entry used by probe/ffmpeg
277
+ * paths: `<localRoot>/cache/resources/<id>/extracted/v<v>/<sourceCacheId>`.
278
+ */
279
+ resExtractedEntry(id: string, fileVersion: number, entryName: string): string
280
+ /**
281
+ * Plugin container extraction root for a resource version:
282
+ * `<localRoot>/cache/resources/<id>/extracted/v<fileVersion>/archives`.
283
+ * `extractArchive` materializes each archive into its own
284
+ * subdirectory here (`archives/<archiveName>/<innerPath>`), served to
285
+ * the browser via the `/extracted/` route.
286
+ */
287
+ resExtractedArchivesDir(id: string, fileVersion: number): string
288
+ }
289
+
290
+ export type CreateStoragePathsOptions = {
291
+ readonly root: string
292
+ /**
293
+ * Active (viewing) version. When omitted together with latestVersion,
294
+ * resolves from `local/version-state.json` and the version dirs under
295
+ * `versions/`. When only latestVersion is pinned explicitly, defaults to
296
+ * that same value (call sites that override max version only).
297
+ */
298
+ readonly activeVersion?: number
299
+ /**
300
+ * Latest (current, writable) version. When omitted, the maximum
301
+ * version directory under `versions/` is used, or `1` when none exist.
302
+ */
303
+ readonly latestVersion?: number
304
+ }
305
+
306
+ /**
307
+ * Build a {@link StoragePaths} rooted at `opts.root`. The root must be an
308
+ * absolute path (the sync boundary would not be well-defined otherwise).
309
+ *
310
+ * @throws `Error` when `root` is not absolute.
311
+ */
312
+ export function createStoragePaths(
313
+ opts: CreateStoragePathsOptions,
314
+ ): StoragePaths {
315
+ if (!isAbsolute(opts.root)) {
316
+ throw new Error(`storage root must be an absolute path: ${opts.root}`)
317
+ }
318
+ const root = resolve(opts.root)
319
+ const versionsRootPath = resolve(root, "versions")
320
+ const localRoot = resolve(root, "local")
321
+
322
+ const diskMax = diskCurrentVersion(root)
323
+ let latestVersion: number
324
+ if (opts.latestVersion !== undefined) {
325
+ latestVersion = opts.latestVersion
326
+ } else {
327
+ latestVersion = diskMax > 0 ? diskMax : 1
328
+ }
329
+
330
+ let activeVersion: number
331
+ if (opts.activeVersion !== undefined) {
332
+ activeVersion = opts.activeVersion
333
+ } else if (opts.latestVersion !== undefined) {
334
+ activeVersion = latestVersion
335
+ } else {
336
+ activeVersion = diskMax > 0 ? readActiveVersion(root) : latestVersion
337
+ }
338
+
339
+ function versionAt(version: number): VersionPaths {
340
+ const vSeg = assertSafeSegment(String(version))
341
+ const vRoot = join(versionsRootPath, vSeg)
342
+ return {
343
+ root: vRoot,
344
+ version,
345
+ versionSnapshotDb: () => join(vRoot, "app.sqlite"),
346
+ resource: (id) => join(vRoot, "resources", assertSafeSegment(id)),
347
+ resourceData: (id) =>
348
+ join(vRoot, "resources", assertSafeSegment(id), RESOURCE_DATA_DIR_NAME),
349
+ resources: () => join(vRoot, "resources"),
350
+ characters: () => join(vRoot, "characters"),
351
+ documents: () => join(vRoot, "documents"),
352
+ character: (id) => join(vRoot, "characters", assertSafeSegment(id)),
353
+ dbBackups: () => join(vRoot, "db-backups"),
354
+ dbBackup: (name) => join(vRoot, "db-backups", assertSafeSegment(name)),
355
+ snapshots: () => join(vRoot, "snapshots"),
356
+ snapshot: (name) => join(vRoot, "snapshots", assertSafeSegment(name)),
357
+ deletedMarker: (kind, id) =>
358
+ join(vRoot, kind, assertSafeSegment(id), ".deleted"),
359
+ document: (id) => join(vRoot, "documents", assertSafeSegment(id)),
360
+ plugins: () => join(vRoot, "plugins"),
361
+ pluginVaultDir: (id) =>
362
+ join(vRoot, "plugins", assertSafeSegment(id), "vault"),
363
+ }
364
+ }
365
+
366
+ const active = versionAt(activeVersion)
367
+ const latest = versionAt(latestVersion)
368
+ const uploadStagingRootPath = join(localRoot, ".tmp")
369
+ const cacheRoot = join(localRoot, "cache")
370
+
371
+ const local: LocalPaths = {
372
+ root: localRoot,
373
+ cache: () => cacheRoot,
374
+ logs: () => join(localRoot, "logs"),
375
+ localCover: (subjectKind, id, variant, format) =>
376
+ join(
377
+ cacheRoot,
378
+ localCoverSubjectDir(subjectKind),
379
+ assertSafeSegment(id),
380
+ `${assertSafeSegment(variant)}.${format ?? "avif"}`,
381
+ ),
382
+ resFileVariant: (id, filename, variantKey, format) =>
383
+ join(
384
+ cacheRoot,
385
+ "resources",
386
+ assertSafeSegment(id),
387
+ "file-preview",
388
+ `${assertSafeSegment(sourceCacheId(filename))}__${assertSafeSegment(variantKey)}.${format ?? "avif"}`,
389
+ ),
390
+ resFilePreviewDir: (id) =>
391
+ join(cacheRoot, "resources", assertSafeSegment(id), "file-preview"),
392
+ resFilesCache: (id) =>
393
+ join(cacheRoot, "resources", assertSafeSegment(id), "files-cache.json"),
394
+ resource: (id) => join(cacheRoot, "resources", assertSafeSegment(id)),
395
+ character: (id) => join(cacheRoot, "characters", assertSafeSegment(id)),
396
+ trash: () => join(localRoot, "trash"),
397
+ trashItem: (id) => join(localRoot, "trash", assertSafeSegment(id)),
398
+ tmp: () => join(cacheRoot, "tmp"),
399
+ tmpFile: (name) => join(cacheRoot, "tmp", assertSafeSegment(name)),
400
+ sessionKey: () => join(localRoot, ".session-key"),
401
+ uploadStagingRoot: () => uploadStagingRootPath,
402
+ stagingPoolRoot: () => join(uploadStagingRootPath, "staging"),
403
+ stagingPoolFile: (fileId, ext) =>
404
+ join(
405
+ uploadStagingRootPath,
406
+ "staging",
407
+ `${assertSafeSegment(fileId)}${ext}`,
408
+ ),
409
+ resVideoFrame: (id, filename, timeMs) =>
410
+ join(
411
+ cacheRoot,
412
+ "resources",
413
+ assertSafeSegment(id),
414
+ "frames",
415
+ assertSafeSegment(sourceCacheId(filename)),
416
+ `${timeMs}.avif`,
417
+ ),
418
+ resExtractedDir: (id, fileVersion) =>
419
+ join(
420
+ cacheRoot,
421
+ "resources",
422
+ assertSafeSegment(id),
423
+ "extracted",
424
+ `v${fileVersion}`,
425
+ ),
426
+ resExtractedEntry: (id, fileVersion, entryName) =>
427
+ join(
428
+ cacheRoot,
429
+ "resources",
430
+ assertSafeSegment(id),
431
+ "extracted",
432
+ `v${fileVersion}`,
433
+ assertSafeSegment(sourceCacheId(entryName)),
434
+ ),
435
+ resExtractedArchivesDir: (id, fileVersion) =>
436
+ join(
437
+ cacheRoot,
438
+ "resources",
439
+ assertSafeSegment(id),
440
+ "extracted",
441
+ `v${fileVersion}`,
442
+ "archives",
443
+ ),
444
+ }
445
+
446
+ return {
447
+ root,
448
+ activeVersion,
449
+ latestVersion,
450
+ active,
451
+ latest,
452
+ local,
453
+ atVersion: (v) => versionAt(v),
454
+ runtimeDb: () => join(root, "app.sqlite"),
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Map a {@link LocalPaths.thumb} subjectKind onto its on-disk subdirectory.
460
+ * Variants now live flat inside the per-id local directory (no enclosing
461
+ * `thumbs/` parent), so `resource` -> `resources` and `character` ->
462
+ * `characters` (both plural to match the storage layout convention).
463
+ */
464
+ function localCoverSubjectDir(subjectKind: "resource" | "character"): string {
465
+ return subjectKind === "resource" ? "resources" : "characters"
466
+ }
467
+
468
+ /**
469
+ * Map a source filename to the readable prefix of its derived-cache
470
+ * identity (see {@link sourceCacheId}). Strips the source extension
471
+ * before {@link LocalPaths.thumb} appends `.webp`, so `1.jpeg` becomes
472
+ * `1__jpeg` (not `1.jpeg.webp`). The source extension is folded into
473
+ * the basename so two sources sharing a stem (e.g. `01.png` /
474
+ * `01.jpg`) cannot collide on the same cache file. Separators are
475
+ * flattened so subdirectory entry names never trip the single-segment
476
+ * path authority check.
477
+ *
478
+ * The encoding (`__<ext>`) avoids any character that
479
+ * {@link assertSafeSegment} rejects (no dot, no separator, no control
480
+ * char) so the result passes the boundary check unchanged.
481
+ */
482
+ function toCacheBasename(filename: string): string {
483
+ const dot = filename.lastIndexOf(".")
484
+ if (dot <= 0) return filename.replace(/[/\\]/g, "__")
485
+ const stem = filename.slice(0, dot).replace(/[/\\]/g, "__")
486
+ const ext = filename.slice(dot + 1)
487
+ return `${stem}__${ext}`
488
+ }
489
+
490
+ /**
491
+ * Stable, collision-free cache identity for one source file:
492
+ * the flattened basename (human-readable) plus a short hash of the
493
+ * full relative path. The hash disambiguates flattening ambiguities —
494
+ * `a__b/c.png` and `a/b__c.png` both flatten to `a__b__c__png`, but
495
+ * their paths hash differently, so distinct files never share a cache
496
+ * entry. Pure hex suffix, so the result passes
497
+ * {@link assertSafeSegment} unchanged.
498
+ */
499
+ function sourceCacheId(filename: string): string {
500
+ return `${toCacheBasename(filename)}__${shortHash(filename)}`
501
+ }
502
+
503
+ /**
504
+ * Short, stable hash fragment: the first 8 hex chars of the sha256 of
505
+ * `input`. Pure hex, so the result passes {@link assertSafeSegment}
506
+ * unchanged.
507
+ */
508
+ function shortHash(input: string): string {
509
+ return createHash("sha256").update(input).digest("hex").slice(0, 8)
510
+ }
511
+
512
+ /**
513
+ * Short, stable cache identity for one derived image variant: the
514
+ * sha256 of the spec's canonical string (see
515
+ * `imageVariantCanonical` in `@hoardodile/sdk-types/image-variant`),
516
+ * truncated to 8 hex chars.
517
+ */
518
+ export function imageVariantKey(canonical: string): string {
519
+ return shortHash(canonical)
520
+ }
521
+
522
+ /**
523
+ * Validate a single path segment. We reject anything that embeds a path
524
+ * separator, a drive letter, a NUL or other control code, a Windows
525
+ * reserved basename, or a trailing dot/space (Windows normalises those away
526
+ * and you get the wrong file).
527
+ *
528
+ * @throws `Error` when `segment` is empty or rejected by any of the rules.
529
+ */
530
+ export function assertSafeSegment(segment: string): string {
531
+ if (segment.length === 0) throw new Error("path segment must not be empty")
532
+ if (segment === "." || segment === "..") {
533
+ throw new Error(`path segment must not be '${segment}'`)
534
+ }
535
+ if (segment.includes("/") || segment.includes("\\")) {
536
+ throw new Error(`path segment must not contain separators: ${segment}`)
537
+ }
538
+ if (hasForbiddenVisibleChar(segment) || hasControlChar(segment)) {
539
+ throw new Error(`path segment contains disallowed characters: ${segment}`)
540
+ }
541
+ if (segment.endsWith(".") || segment.endsWith(" ")) {
542
+ throw new Error(
543
+ `path segment must not end with dot or space: ${JSON.stringify(segment)}`,
544
+ )
545
+ }
546
+ const base = segment.split(".")[0]?.toUpperCase()
547
+ if (base !== undefined && WINDOWS_RESERVED.has(base)) {
548
+ throw new Error(`path segment is a reserved name: ${segment}`)
549
+ }
550
+ return segment
551
+ }
552
+
553
+ function join(...segments: readonly string[]): string {
554
+ return resolve(...segments)
555
+ }
556
+
557
+ /**
558
+ * Ensure `candidate` is contained within `ancestor` (after `resolve`). Used
559
+ * as a belt-and-braces check before any disk operation that mixes user
560
+ * input with a base directory.
561
+ */
562
+ export function assertInside(ancestor: string, candidate: string): string {
563
+ const resolved = resolve(candidate)
564
+ const base = resolve(ancestor)
565
+ if (resolved !== base && !resolved.startsWith(base + sep)) {
566
+ throw new Error(`path ${resolved} escapes ${base}`)
567
+ }
568
+ return resolved
569
+ }