@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,754 @@
1
+ import { Readable } from 'node:stream';
2
+
3
+ /**
4
+ * Whole-archive extraction. Every supported format (zip, tar, 7z, rar,
5
+ * xz, gzip) extracts through 7-Zip with listing-validated budgets; when
6
+ * the optional 7-Zip binary is absent, zip falls back to a yauzl
7
+ * streaming path with a per-byte budget counter. Used by plugin
8
+ * installation, folder-import extraction and the plugin extract API —
9
+ * the workflows that need a directory layout.
10
+ */
11
+ /**
12
+ * Extraction progress reporter for {@link extractArchiveInto}. The
13
+ * 7-Zip path reports the two phases only (listing pre-check, done);
14
+ * the yauzl fallback reports per-entry events.
15
+ */
16
+ type ZipExtractReporter = (event: ZipExtractReport) => void;
17
+ type ZipExtractReport = {
18
+ readonly phase: "started";
19
+ readonly totalEntries: number;
20
+ readonly totalBytes: number;
21
+ } | {
22
+ readonly phase: "entry";
23
+ readonly entriesDone: number;
24
+ readonly totalEntries: number;
25
+ readonly bytesDone: number;
26
+ readonly totalBytes: number;
27
+ } | {
28
+ readonly phase: "done";
29
+ };
30
+ type ExtractArchiveOptions = {
31
+ /** Cumulative uncompressed byte budget for the whole archive. */
32
+ readonly maxBytes: number;
33
+ /** Optional entry-count budget (enforced on the listing). */
34
+ readonly maxEntries?: number;
35
+ readonly onProgress?: ZipExtractReporter;
36
+ };
37
+ /**
38
+ * Stream an archive into `destDir`, format-agnostic: every supported
39
+ * format (zip/tar/7z/rar/xz/gzip) extracts through 7-Zip after
40
+ * validating budgets, encryption and paths from its listing, then
41
+ * re-verifies the extracted tree (size + symlink scan). Without a
42
+ * 7-Zip binary, zip extracts through yauzl with a per-byte budget
43
+ * counter; the other formats error with "7-Zip is not installed".
44
+ * Used by plugin installation and folder-import extraction (the
45
+ * workflows that need a directory layout).
46
+ *
47
+ * Refuses any entry whose normalised path escapes `destDir` (zip-slip)
48
+ * or contains an absolute path / drive letter. Defends against zip
49
+ * bombs via `maxBytes` (and `maxEntries` when provided).
50
+ *
51
+ * @throws DomainError VALIDATION when the archive is malformed,
52
+ * contains an unsafe entry, exceeds the budgets, is encrypted, or is
53
+ * not a supported archive at all.
54
+ */
55
+ declare function extractArchiveInto(source: NodeJS.ReadableStream, destDir: string, opts: ExtractArchiveOptions): Promise<void>;
56
+
57
+ /** A unified, format-agnostic inner entry. */
58
+ type ArchiveEntry = {
59
+ readonly name: string;
60
+ readonly sizeBytes: number;
61
+ };
62
+
63
+ /**
64
+ * Cross-engine archive listing and budget validation. Zip lists through
65
+ * yauzl, tar/7z/rar/xz/gzip through the 7-Zip binary; the engine choice
66
+ * (and the zip-only legacy-cp437 name decoding, handled inside the 7-Zip
67
+ * wrapper) lives here so callers never branch on format.
68
+ */
69
+ /**
70
+ * List the file entries of an on-disk archive across engines: zip lists
71
+ * through yauzl, tar/7z/rar/xz/gzip through the 7-Zip binary (gzip
72
+ * reports its single decompressed stream).
73
+ *
74
+ * @throws DomainError `resource.archive_open_failed` when the file is
75
+ * not a readable archive, or 7-Zip is missing for a non-zip format.
76
+ */
77
+ declare function listArchiveEntries(archivePath: string): Promise<readonly ArchiveEntry[]>;
78
+ /**
79
+ * Validate an on-disk archive's cumulative uncompressed size against
80
+ * `maxBytes` without inflating any entry bytes (metadata-only — the
81
+ * listing never decompresses). Format-agnostic via
82
+ * {@link listArchiveEntries}; unknown content and non-zip formats
83
+ * without the 7-Zip binary are rejected outright.
84
+ *
85
+ * Used at commit time for uploaded archives so every supported format
86
+ * passes the same bomb gate.
87
+ *
88
+ * @throws DomainError `resource.archive_open_failed` when the file is
89
+ * not a readable archive (or 7-Zip is missing for non-zip formats).
90
+ * @throws DomainError `resource.archive_too_large` when the cumulative
91
+ * uncompressed size exceeds `maxBytes`.
92
+ */
93
+ declare function validateArchiveBudget(archivePath: string, maxBytes: number): Promise<void>;
94
+
95
+ /**
96
+ * On-the-fly zip exports. Resource sources are stored as bare files on
97
+ * disk — nothing is packed at commit time; this module streams STORED
98
+ * zip bytes straight to the consumer without a staging file.
99
+ */
100
+ /**
101
+ * A logical zip entry whose bytes are produced on demand. Streams are
102
+ * read once, in order, when the output is consumed.
103
+ */
104
+ type ZipStreamEntry = {
105
+ readonly name: string;
106
+ readonly size: number;
107
+ readonly openStream: () => Readable;
108
+ };
109
+ /**
110
+ * Stream a STORED zip from an ordered list of logical entries. Used by
111
+ * the HTTP layer for exports (resource source packs, bulk downloads)
112
+ * without a staging file on disk. Zero-length entries are packed as
113
+ * empty buffers.
114
+ */
115
+ declare function streamStoredZip(entries: readonly ZipStreamEntry[]): NodeJS.ReadableStream;
116
+
117
+ /**
118
+ * Shared directory-size walk: the one implementation of "recursive byte
119
+ * size of a directory tree" for host storage. Consumers opt into
120
+ * different file policies via options:
121
+ *
122
+ * - vault totals skip dot-prefixed files (host temp staging), while
123
+ * storage totals count everything — see {@link vaultTotalSize} and
124
+ * `apps/server/src/domain/storage/service.ts`.
125
+ *
126
+ * Symbolic links are skipped so cycles cannot hang the walk and
127
+ * linked-out files are not double counted; missing/transient entries
128
+ * are ignored.
129
+ */
130
+ type DirSizeOptions = {
131
+ /** Skip file names starting with a dot (host temp staging). */
132
+ readonly excludeDotPrefix?: boolean;
133
+ };
134
+ declare function sumDirSizes(root: string, opts?: DirSizeOptions): Promise<number>;
135
+
136
+ /**
137
+ * Explicit file order for a resource's content root (`data/`). Uploads
138
+ * install entries in the user's chosen order and persist that sequence
139
+ * in this manifest so the display order matches the upload order even
140
+ * when filenames sort differently. Written at commit time; because it
141
+ * lives inside the replaced `data/` subtree it is always consistent
142
+ * with the entries around it.
143
+ *
144
+ * The manifest is a JSON array of relative entry names (`/`-separated),
145
+ * listing the final installed names (after collision suffixing). Reads
146
+ * are strict: a malformed manifest, or one whose entries do not all
147
+ * resolve against the actual listing, is ignored entirely and callers
148
+ * fall back to the natural name sort. Strictness matters because
149
+ * `createDirectoryContainer` also serves arbitrary user directories
150
+ * (folder import, plugin dev) that may coincidentally contain a file
151
+ * named `.order`.
152
+ */
153
+ declare const ORDER_MANIFEST_NAME = ".order";
154
+ /** Path of the order manifest inside a resource content root. */
155
+ declare function orderManifestPath(dataDir: string): string;
156
+ /**
157
+ * Atomically persist `names` as the manifest of `dataDir` (tmp + rename,
158
+ * so a concurrent reader never observes a half-written file).
159
+ */
160
+ declare function writeOrderManifest(dataDir: string, names: readonly string[]): Promise<void>;
161
+ /**
162
+ * Read the manifest of `dataDir`. Resolves to `undefined` when the
163
+ * manifest is absent or fails structural validation
164
+ * ({@link parseOrderManifest}) — callers fall back to natural order.
165
+ */
166
+ declare function readOrderManifest(dataDir: string): Promise<readonly string[] | undefined>;
167
+ /**
168
+ * Structural validation of a raw manifest body: a JSON array of
169
+ * non-empty relative paths with `/` separators that cannot escape the
170
+ * content root. Returns `undefined` when the body is not a usable
171
+ * manifest. Semantic validation (entries exist in the listing) happens
172
+ * in {@link orderEntries}.
173
+ */
174
+ declare function parseOrderManifest(raw: string): readonly string[] | undefined;
175
+ /**
176
+ * Reorder `listed` by the manifest: entries named by the manifest come
177
+ * first, in manifest order; entries the manifest does not mention are
178
+ * appended in natural order. Returns `undefined` when the manifest
179
+ * cannot be applied against this listing (some manifest entry is
180
+ * missing — the strict trust rule), so the caller falls back to the
181
+ * natural sort.
182
+ */
183
+ declare function orderEntries(manifest: readonly string[], listed: readonly string[]): readonly string[] | undefined;
184
+ /** Case-insensitive natural name sort (`1 < 2 < 10`, `a < b`). */
185
+ declare function naturalSort(names: readonly string[]): readonly string[];
186
+
187
+ /**
188
+ * Name of the content subfolder inside a resource folder. User entries
189
+ * live under `resources/<id>/data/`; metadata dotfiles (`.cover.*`,
190
+ * `.deleted`, `.order`) stay at the resource root. Content and metadata
191
+ * are replaced/kept independently: commits swap the whole `data/`
192
+ * subtree, while root dotfiles survive re-uploads.
193
+ */
194
+ declare const RESOURCE_DATA_DIR_NAME = "data";
195
+ /**
196
+ * Root-level subdirectory semantics:
197
+ * - `versions/<version>/` is the user's manual-sync scope, partitioned by
198
+ * archive version. Each version folder holds: `app.sqlite` (the
199
+ * per-version database snapshot), `db-backups/` (manual backups,
200
+ * only kept for the current version), `snapshots/` (automatic daily
201
+ * snapshots, only kept for the current version), `resources/<id>/`,
202
+ * `characters/<id>/`, `plugins/<id>/` (installed content plugins
203
+ * frozen with that version; the builtin `file` plugin is not stored
204
+ * here). Old versions are FROZEN: no writes ever land in
205
+ * `versions/<v>` once a `versions/<v+1>` exists.
206
+ * - `local` holds host-only state. Derived caches (thumbs, previews,
207
+ * extraction caches, tmp, read-only DB clones for past-version
208
+ * viewing) live under `local/cache/` and are wiped wholesale by
209
+ * clear cache; the rest (`logs`, `trash`, session key, upload
210
+ * staging) is persistent. It never leaves the host.
211
+ *
212
+ * `paths.active` resolves to the **active** version (which may be the
213
+ * latest version, or a past version when the user is viewing
214
+ * read-only). `paths.latest` always points at the latest version --
215
+ * the only version writers may target. `paths.atVersion(v)` exposes
216
+ * arbitrary cross-version reads (used for character avatar/fullbody
217
+ * fallback once `avatarVersion` / `fullbodyVersion` columns point at a
218
+ * historical archive).
219
+ *
220
+ * Every resolved path MUST come out of this module so the frozen-version
221
+ * boundary stays enforceable.
222
+ */
223
+ type StoragePaths = {
224
+ readonly root: string;
225
+ readonly activeVersion: number;
226
+ readonly latestVersion: number;
227
+ readonly active: VersionPaths;
228
+ readonly latest: VersionPaths;
229
+ readonly local: LocalPaths;
230
+ /** Per-version archive paths. Use for cross-version fallback reads. */
231
+ atVersion(v: number): VersionPaths;
232
+ /**
233
+ * Path to the live runtime SQLite DB: `<root>/app.sqlite`.
234
+ * This file is the only writable database during normal operation.
235
+ * It lives outside `versions/` so that syncing `versions/` to other
236
+ * devices cannot corrupt the in-use database. Only archived snapshots
237
+ * (written by {@link createNextVersion}) and backup files belong in
238
+ * `versions/`.
239
+ */
240
+ runtimeDb(): string;
241
+ };
242
+ type VersionPaths = {
243
+ readonly root: string;
244
+ readonly version: number;
245
+ /** Path to the per-version SQLite DB: `<root>/versions/<v>/app.sqlite`. */
246
+ versionSnapshotDb(): string;
247
+ /** Root folder of a resource: `<root>/versions/<v>/resources/<id>`. */
248
+ resource(id: string): string;
249
+ /**
250
+ * Content root of a resource — where user entries live:
251
+ * `<root>/versions/<v>/resources/<id>/data`. The container reads
252
+ * entries from here; the resource root only holds metadata dotfiles.
253
+ */
254
+ resourceData(id: string): string;
255
+ /** Root folder of all resources in this version: `<root>/versions/<v>/resources`. */
256
+ resources(): string;
257
+ /** Root folder of all characters in this version: `<root>/versions/<v>/characters`. */
258
+ characters(): string;
259
+ /** Root folder of all documents in this version: `<root>/versions/<v>/documents`. */
260
+ documents(): string;
261
+ /** Root folder of a character: `<root>/versions/<v>/characters/<id>`. */
262
+ character(id: string): string;
263
+ /** Root of manual backups: `<root>/versions/<v>/db-backups`. */
264
+ dbBackups(): string;
265
+ /** Path to one manual backup: `<root>/versions/<v>/db-backups/<name>`. */
266
+ dbBackup(name: string): string;
267
+ /** Root of automatic snapshots: `<root>/versions/<v>/snapshots`. */
268
+ snapshots(): string;
269
+ /** Path to one automatic snapshot: `<root>/versions/<v>/snapshots/<name>`. */
270
+ snapshot(name: string): string;
271
+ /**
272
+ * Path to a deleted-entity placeholder
273
+ * (`<root>/versions/<v>/<kind>/<id>/.deleted`) written when hard
274
+ * delete cannot remove a folder whose files live under frozen past
275
+ * archives.
276
+ */
277
+ deletedMarker(kind: "resources" | "characters", id: string): string;
278
+ /** Root folder of a document: `<root>/versions/<v>/documents/<id>`. */
279
+ document(id: string): string;
280
+ /**
281
+ * Installed content plugins for this version:
282
+ * `<root>/versions/<v>/plugins`. Each subdirectory is named by
283
+ * `manifest.id`. The builtin `file` plugin is not stored here.
284
+ */
285
+ plugins(): string;
286
+ /**
287
+ * Host-managed plugin asset vault of an installed plugin:
288
+ * `<root>/versions/<v>/plugins/<id>/vault`. Downloads land here
289
+ * (user-consented, vault-confined); the directory is host-owned,
290
+ * reserved against plugin zips (upload rejects a shipped `vault/`),
291
+ * and its lifecycle follows the plugin's own directory.
292
+ */
293
+ pluginVaultDir(id: string): string;
294
+ };
295
+ type LocalPaths = {
296
+ /** The local (non-synced) root: `<root>/local`. */
297
+ readonly root: string;
298
+ /** Derived cache root: `<localRoot>/cache`. */
299
+ cache(): string;
300
+ /** Server logs: `<localRoot>/logs`. */
301
+ logs(): string;
302
+ /**
303
+ * Path to a local derived cover/thumb variant:
304
+ * `<localRoot>/cache/<resources|characters>/<id>/<variant>.<format>`.
305
+ * Holds synthesized covers (resource covers, character avatars and
306
+ * fullbody images); re-rendered when cleared.
307
+ */
308
+ localCover(subjectKind: "resource" | "character", id: string, variant: string, format?: string): string;
309
+ /**
310
+ * Per-file derived image variant:
311
+ * `<localRoot>/cache/resources/<id>/file-preview/<sourceCacheId>__<variantKey>.<format>`.
312
+ * The source identity (see {@link sourceCacheId}) is collision-free
313
+ * across nested paths; the variant key (see {@link imageVariantKey})
314
+ * encodes the render spec, so distinct specs never collide on one
315
+ * cache file.
316
+ */
317
+ resFileVariant(id: string, filename: string, variantKey: string, format?: string): string;
318
+ /** Directory holding a resource's per-file variants. */
319
+ resFilePreviewDir(id: string): string;
320
+ /** Resource file-list sidecar: `<localRoot>/cache/resources/<id>/files-cache.json`. */
321
+ resFilesCache(id: string): string;
322
+ /**
323
+ * Root of the local per-resource directory:
324
+ * `<localRoot>/cache/resources/<id>`.
325
+ */
326
+ resource(id: string): string;
327
+ /**
328
+ * Root of the local per-character directory:
329
+ * `<localRoot>/cache/characters/<id>`.
330
+ * Holds (a) versioned copies of replaced avatar / fullbody images and
331
+ * (b) thumbnail variants (`avatar.webp`, `fullbody.webp`).
332
+ */
333
+ character(id: string): string;
334
+ /** Root of the trash: `<localRoot>/trash`. */
335
+ trash(): string;
336
+ /** Path to a single trashed item: `<localRoot>/trash/<id>`. */
337
+ trashItem(id: string): string;
338
+ /**
339
+ * Root of the local temp directory: `<localRoot>/cache/tmp`. Holds
340
+ * short-lived working files (upload buffers, backup markers, read-only
341
+ * `view-<v>.sqlite` clones). Lives under the cache root so clear cache
342
+ * wipes it together with the other derived data; it is also cleaned on
343
+ * server startup.
344
+ */
345
+ tmp(): string;
346
+ /** Path to a single temp file: `<localRoot>/cache/tmp/<name>`. */
347
+ tmpFile(name: string): string;
348
+ /**
349
+ * Path to the iron-session seal key file: `<root>/local/.session-key`.
350
+ * 32-byte base64-encoded secret, auto-generated on first boot. Lives in
351
+ * `local/` (never synced) so each host has its own seal key.
352
+ */
353
+ sessionKey(): string;
354
+ /**
355
+ * Root of the host-only temporary directory tree:
356
+ * `<localRoot>/.tmp`. Holds the global staging pool
357
+ * ({@link stagingPoolRoot}) plus short-lived extraction directories
358
+ * (`extract-*`). The leading dot keeps host-only state out of any
359
+ * user-facing listing. Cleared on server startup together with
360
+ * {@link LocalPaths.tmp}.
361
+ */
362
+ uploadStagingRoot(): string;
363
+ /**
364
+ * Root of the global staging pool: `<localRoot>/.tmp/staging`.
365
+ * Every file uploaded through the per-file upload endpoint lands
366
+ * here as `<fileId><ext>` and is addressed by its `fileId` alone —
367
+ * there is no per-batch grouping. Files are removed individually
368
+ * on client delete or consumed (and deleted) by commit at resource
369
+ * creation. Cleared on startup together with
370
+ * {@link LocalPaths.uploadStagingRoot}.
371
+ */
372
+ stagingPoolRoot(): string;
373
+ /**
374
+ * Path of a single staged file in the global pool:
375
+ * `<stagingPoolRoot>/<fileId><ext>`. `ext` is the lower-cased
376
+ * extension of the original filename (empty for extensionless
377
+ * uploads).
378
+ */
379
+ stagingPoolFile(fileId: string, ext: string): string;
380
+ /**
381
+ * Per-video-frame thumbnail cache:
382
+ * `<localRoot>/cache/resources/<id>/frames/<sourceCacheId>/<timeMs>.avif`.
383
+ * Synthesised on-demand by the video hover preview endpoint.
384
+ */
385
+ resVideoFrame(id: string, filename: string, timeMs: number): string;
386
+ /**
387
+ * Root of persisted zip-entry extractions for a resource version:
388
+ * `<localRoot>/cache/resources/<id>/extracted/v<fileVersion>/`.
389
+ */
390
+ resExtractedDir(id: string, fileVersion: number): string;
391
+ /**
392
+ * On-disk path for a materialized zip entry used by probe/ffmpeg
393
+ * paths: `<localRoot>/cache/resources/<id>/extracted/v<v>/<sourceCacheId>`.
394
+ */
395
+ resExtractedEntry(id: string, fileVersion: number, entryName: string): string;
396
+ /**
397
+ * Plugin container extraction root for a resource version:
398
+ * `<localRoot>/cache/resources/<id>/extracted/v<fileVersion>/archives`.
399
+ * `extractArchive` materializes each archive into its own
400
+ * subdirectory here (`archives/<archiveName>/<innerPath>`), served to
401
+ * the browser via the `/extracted/` route.
402
+ */
403
+ resExtractedArchivesDir(id: string, fileVersion: number): string;
404
+ };
405
+ type CreateStoragePathsOptions = {
406
+ readonly root: string;
407
+ /**
408
+ * Active (viewing) version. When omitted together with latestVersion,
409
+ * resolves from `local/version-state.json` and the version dirs under
410
+ * `versions/`. When only latestVersion is pinned explicitly, defaults to
411
+ * that same value (call sites that override max version only).
412
+ */
413
+ readonly activeVersion?: number;
414
+ /**
415
+ * Latest (current, writable) version. When omitted, the maximum
416
+ * version directory under `versions/` is used, or `1` when none exist.
417
+ */
418
+ readonly latestVersion?: number;
419
+ };
420
+ /**
421
+ * Build a {@link StoragePaths} rooted at `opts.root`. The root must be an
422
+ * absolute path (the sync boundary would not be well-defined otherwise).
423
+ *
424
+ * @throws `Error` when `root` is not absolute.
425
+ */
426
+ declare function createStoragePaths(opts: CreateStoragePathsOptions): StoragePaths;
427
+ /**
428
+ * Short, stable cache identity for one derived image variant: the
429
+ * sha256 of the spec's canonical string (see
430
+ * `imageVariantCanonical` in `@hoardodile/sdk-types/image-variant`),
431
+ * truncated to 8 hex chars.
432
+ */
433
+ declare function imageVariantKey(canonical: string): string;
434
+ /**
435
+ * Validate a single path segment. We reject anything that embeds a path
436
+ * separator, a drive letter, a NUL or other control code, a Windows
437
+ * reserved basename, or a trailing dot/space (Windows normalises those away
438
+ * and you get the wrong file).
439
+ *
440
+ * @throws `Error` when `segment` is empty or rejected by any of the rules.
441
+ */
442
+ declare function assertSafeSegment(segment: string): string;
443
+ /**
444
+ * Ensure `candidate` is contained within `ancestor` (after `resolve`). Used
445
+ * as a belt-and-braces check before any disk operation that mixes user
446
+ * input with a base directory.
447
+ */
448
+ declare function assertInside(ancestor: string, candidate: string): string;
449
+
450
+ /** Thrown when a vault path violates the isolation rules (POLICY). */
451
+ declare class PluginVaultPathError extends Error {
452
+ constructor(message: string);
453
+ }
454
+ /**
455
+ * Parse a vault-relative destination and resolve it against the vault
456
+ * root. Every segment goes through {@link assertSafeSegment} (no empty
457
+ * segments, `.`/`..`, separators inside a segment, drive letters,
458
+ * control chars, Windows-reserved names, trailing dot/space) and the
459
+ * final path is re-checked with {@link assertInside} — doubly so, the
460
+ * whole point is that a downloaded file can only ever land inside the
461
+ * plugin's own vault.
462
+ */
463
+ declare function parsePluginVaultDest(vaultDir: string, dest: string): {
464
+ readonly rel: string;
465
+ readonly abs: string;
466
+ };
467
+ /**
468
+ * Byte size of a vault file (or `undefined` when absent or not a
469
+ * regular file). The cheap presence check `download` builds on.
470
+ */
471
+ declare function vaultStatFile(vaultDir: string, rel: string): Promise<{
472
+ readonly sizeBytes: number;
473
+ } | undefined>;
474
+ /** Stream a vault file's bytes (bounded by `maxBytes`). */
475
+ declare function vaultReadFile(vaultDir: string, rel: string, maxBytes: number): Promise<Uint8Array>;
476
+ /**
477
+ * Remove a vault file. Idempotent (absent → false); directories and
478
+ * anything outside the vault are rejected.
479
+ */
480
+ declare function vaultRemoveFile(vaultDir: string, rel: string): Promise<boolean>;
481
+ /**
482
+ * Path of the caller's download temp file inside the vault (host-side
483
+ * staging — never a user-visible name). The downloader streams into it;
484
+ * {@link commitVaultFile} renames it into place.
485
+ */
486
+ declare function vaultTempFile(vaultDir: string): string;
487
+ /** Delete a temp file (download failure path). */
488
+ declare function discardVaultTempFile(tempPath: string): Promise<void>;
489
+ /**
490
+ * Commit a downloaded temp file into the vault: mkdir the nested
491
+ * destination, verify the per-plugin total-size budget (the destination's
492
+ * own previous bytes count against the quota only once — re-downloading
493
+ * replaces, it does not add), then rename the temp file into place
494
+ * (atomic — the final path never holds a partial download).
495
+ */
496
+ declare function commitVaultFile(opts: {
497
+ readonly vaultDir: string;
498
+ readonly rel: string;
499
+ readonly tempPath: string;
500
+ readonly maxFileBytes: number;
501
+ readonly maxTotalBytes: number;
502
+ }): Promise<{
503
+ readonly sizeBytes: number;
504
+ readonly sha256: string;
505
+ }>;
506
+ /**
507
+ * Total bytes of the vault's regular files (shared directory-size walk;
508
+ * host temp staging files — leading `.` — are excluded as transient).
509
+ */
510
+ declare function vaultTotalSize(vaultDir: string): Promise<number>;
511
+ /** sha256 of a vault file's bytes (streamed; callers verify pins against it). */
512
+ declare function vaultFileSha256(vaultDir: string, rel: string): Promise<string>;
513
+ /** Result of {@link commitVaultFile}: the stored file's identity. */
514
+ type VaultCommitResult = {
515
+ readonly sizeBytes: number;
516
+ readonly sha256: string;
517
+ };
518
+
519
+ /**
520
+ * Filename hygiene for bare-file resources: map a user-supplied name
521
+ * (upload filename or zip entry name) onto a safe relative path inside
522
+ * a resource folder.
523
+ *
524
+ * Resource folders hold user entries next to metadata dotfiles
525
+ * (`.cover.*`, `.deleted`), so every rule here also guarantees the result
526
+ * can never collide with those: leading dots are stripped, and a
527
+ * trailing-dot/space rule mirrors Windows normalization.
528
+ *
529
+ * Cross-platform guarantees: names are NFC-normalized (macOS stores
530
+ * NFD on disk — one canonical form keeps names stable when a library
531
+ * moves between systems), segments are capped at 240 UTF-8 bytes
532
+ * (ext4's 255-byte component limit, minus suffix headroom), and the
533
+ * Windows reserved-name set includes the device aliases (`CONIN$`,
534
+ * superscript COM/LPT variants).
535
+ *
536
+ * The migration script (`scripts/migrate-hoard-to-files.mjs`) mirrors
537
+ * these rules verbatim — keep both in sync when changing them.
538
+ */
539
+ /**
540
+ * Sanitize a single entry name into a safe relative path. Separators
541
+ * (`/` and `\`) become subdirectory boundaries; `..` and absolute paths
542
+ * are rejected. Returns `undefined` when the name is unusable (empty,
543
+ * escapes the folder, or cleans to nothing) — callers drop or reject
544
+ * such entries.
545
+ */
546
+ declare function sanitizeEntryName(name: string): string | undefined;
547
+ /**
548
+ * Occupancy bookkeeping for name resolution: which paths are already
549
+ * taken by files (blocking everything) and which are directories
550
+ * (blocking only exact-path installs — a file cannot replace a folder).
551
+ */
552
+ type OccupiedNames = {
553
+ readonly files: Set<string>;
554
+ readonly dirs: Set<string>;
555
+ };
556
+ /** Build occupancy from optional existing on-disk names (with types). */
557
+ declare function createOccupiedNames(existing?: {
558
+ readonly files?: Iterable<string>;
559
+ readonly dirs?: Iterable<string>;
560
+ }): OccupiedNames;
561
+ /**
562
+ * Record `relPath` as an installed file; every ancestor prefix becomes
563
+ * a directory. A later entry colliding with one of the ancestors (a
564
+ * file that sits on a directory path) is resolved by
565
+ * {@link uniqueEntryName}.
566
+ */
567
+ declare function occupyEntryName(occupied: OccupiedNames, relPath: string): void;
568
+ /**
569
+ * Resolve a sanitized relative path against the names already present
570
+ * in the target folder by suffixing the colliding segment (`-1`, `-2`,
571
+ * …). The comparison is case-insensitive (Windows and macOS filesystems
572
+ * are). Files block their path and every ancestor prefix; directories
573
+ * block only their exact path. The suffix lands on the segment whose
574
+ * prefix collides, so `x/y.txt` against an occupied file `x` becomes
575
+ * `x-1/y.txt`, not `x/y-1.txt` (which would collide forever).
576
+ */
577
+ declare function uniqueEntryName(occupied: OccupiedNames, relPath: string): string;
578
+
579
+ /**
580
+ * Resolve the on-disk path of a file staged in the global pool by
581
+ * `fileId`. The pool is flat: each file is stored as `<fileId><ext>`
582
+ * where `ext` is the lower-cased extension of the original filename
583
+ * (or `.zip` for archive uploads). Because the extension is part of the
584
+ * filename we scan the directory for a sibling whose stem matches
585
+ * `fileId`.
586
+ *
587
+ * Returns `undefined` when no staged file matches `fileId`.
588
+ */
589
+ declare function findStagedPoolFile(paths: StoragePaths, fileId: string): Promise<string | undefined>;
590
+ /**
591
+ * Resolve multiple pool files by `fileId` with a SINGLE directory scan.
592
+ * Committing an ordered upload resolves and then consumes every staged
593
+ * file; the per-file {@link findStagedPoolFile} scan would cost one full
594
+ * `readdir` per file, and the pool can hold hundreds of entries under
595
+ * concurrent uploads. Returns a map keyed by `fileId`; ids without a
596
+ * staged match are absent.
597
+ */
598
+ declare function resolveStagedPoolFiles(paths: StoragePaths, fileIds: readonly string[]): Promise<Map<string, string>>;
599
+ /**
600
+ * Stream a single file into the global staging pool as
601
+ * `<fileId><ext>`. The file is first written to a `.tmp-*` sibling and
602
+ * atomically renamed so concurrent readers (preview) never observe a
603
+ * partial file. Returns the final staged path.
604
+ */
605
+ declare function writeStagedPoolFile(paths: StoragePaths, fileId: string, filename: string, stream: NodeJS.ReadableStream): Promise<string>;
606
+ /**
607
+ * Stream an archive (zip) upload into the global staging pool as
608
+ * `<fileId>.zip`. Same atomic write semantics as
609
+ * {@link writeStagedPoolFile}. Returns the final staged path.
610
+ */
611
+ declare function writeStagedArchiveFile(paths: StoragePaths, fileId: string, stream: NodeJS.ReadableStream): Promise<string>;
612
+ /** Resolve the staged path of an archive upload by `fileId`. */
613
+ declare function findStagedArchiveFile(paths: StoragePaths, fileId: string): Promise<string | undefined>;
614
+ /**
615
+ * Remove a single file from the global staging pool. Returns `true` when
616
+ * a file was removed, `false` when `fileId` was not present.
617
+ */
618
+ declare function removeStagedPoolFile(paths: StoragePaths, fileId: string): Promise<boolean>;
619
+
620
+ /**
621
+ * Enumerate version directories under `<root>/versions/`. Names that are
622
+ * not pure positive integers are ignored. Result is sorted ascending.
623
+ */
624
+ declare function listVersions(root: string): readonly number[];
625
+ /**
626
+ * Current (maximum) version on disk. Returns `0` when no version exists
627
+ * yet (caller is expected to bootstrap version 1 in that case).
628
+ */
629
+ declare function currentVersion(root: string): number;
630
+ /**
631
+ * Read the persisted active version. Falls back to current when the
632
+ * state file is missing or malformed, or when the recorded value points
633
+ * at a version that no longer exists.
634
+ */
635
+ declare function readActiveVersion(root: string): number;
636
+ /**
637
+ * Persist the active version. Caller must ensure `version` exists on
638
+ * disk (use {@link listVersions}).
639
+ *
640
+ * @throws DomainError `version.not_found` when `version` is not a known
641
+ * version directory.
642
+ */
643
+ declare function writeActiveVersion(root: string, version: number): void;
644
+ /**
645
+ * Bootstrap version 1 if the versions root has no version directories yet.
646
+ * Idempotent; a no-op when any version already exists.
647
+ */
648
+ declare function ensureBootstrapVersion(root: string): number;
649
+ type CreateNextVersionResult = {
650
+ readonly previous: number;
651
+ readonly created: number;
652
+ };
653
+ /**
654
+ * Snapshot the current version into a freshly-numbered next version.
655
+ *
656
+ * Process:
657
+ * 1. Compute `next = currentVersion + 1`.
658
+ * 2. Refuse to proceed when `versions/<prev>/app.sqlite` already exists.
659
+ * 3. Create `<root>/versions/<next>/` and copy installed plugins from
660
+ * `versions/<prev>/plugins` (resource binaries stay at their
661
+ * `fileVersion`; plugins have no equivalent pointer).
662
+ * 4. Vacuum-snapshot the live DB into `<root>/versions/<prev>/app.sqlite`.
663
+ * (Caller passes a `vacuumInto` function; we don't take a DB handle
664
+ * here to keep this module database-agnostic.)
665
+ * 5. On any failure after step 3, remove `versions/<next>` so
666
+ * {@link currentVersion} does not jump to an empty directory.
667
+ *
668
+ * @throws DomainError `version.bootstrap_required` when no version exists
669
+ * yet (caller must bootstrap first).
670
+ * @throws DomainError `version.already_exists` when the previous
671
+ * version's DB snapshot already exists.
672
+ */
673
+ declare function createNextVersion(root: string, vacuumInto: (destination: string) => void): CreateNextVersionResult;
674
+ /**
675
+ * Path to the DB file for version `v`: `<root>/versions/<v>/app.sqlite`.
676
+ */
677
+ declare function versionedDbFile(root: string, v: number): string;
678
+ /**
679
+ * Path to the per-version archive directory `<root>/versions/<v>`.
680
+ */
681
+ declare function versionedPath(root: string, v: number): string;
682
+
683
+ type VersionedFolderOps = {
684
+ /** Ensure the current-version entity folder exists. */
685
+ ensureFolder(id: string): Promise<void>;
686
+ /** Remove the current-version entity folder. Swallows missing-path errors. */
687
+ removeFolder(id: string): Promise<void>;
688
+ /**
689
+ * When an entity's files live only under frozen past archives, hard
690
+ * delete cannot remove those folders; drop a `.deleted` placeholder in
691
+ * the current-version folder instead.
692
+ */
693
+ markDeleted(id: string): Promise<string>;
694
+ /**
695
+ * Move the current-version entity folder into `local/trash/` with a
696
+ * unique directory name. No-op when the source path is missing (same as
697
+ * a removed folder). Returns the destination path (whether or not a move
698
+ * occurred).
699
+ */
700
+ moveFolderToTrash(id: string): Promise<string>;
701
+ };
702
+ /**
703
+ * The four lifecycle operations shared by the resource and character
704
+ * file-system layers. They differ only in which versioned folder they
705
+ * target and how the trash / placeholder names are derived.
706
+ *
707
+ * `moveFolderToTrash` treats `EPERM`/`EBUSY`/`UNKNOWN` as transient Windows
708
+ * locks (a file inside the folder is still open) and leaves the source in
709
+ * place; a boot-time orphan sweep reclaims it later.
710
+ *
711
+ * `readOnly` is a live `{ current: boolean }` ref (the server's runtime
712
+ * read-only flag), so a version switch mid-request re-reads it.
713
+ */
714
+ declare function buildVersionedFolderOps(paths: StoragePaths, readOnly: {
715
+ readonly current: boolean;
716
+ }, kind: "resource" | "character"): VersionedFolderOps;
717
+ /**
718
+ * Move every file in `sourceFolder` whose name matches `match` into
719
+ * `destFolder` under a timestamped archive name (`<prefix><stamp><ext>`, or
720
+ * `<prefix><stamp>_<i><ext>` when several files match). Moves to the local
721
+ * (non-synced) directory so replaced files are preserved without polluting
722
+ * the versions sync scope. Creates `destFolder` on demand; silently ignores
723
+ * ENOENT on source files and a missing source folder.
724
+ */
725
+ declare function archiveStaleFiles(args: {
726
+ readonly sourceFolder: string;
727
+ readonly destFolder: string;
728
+ readonly match: (name: string) => boolean;
729
+ readonly archivePrefix: string;
730
+ }): Promise<void>;
731
+
732
+ /**
733
+ * Command that performs a write under the **current** (latest, writable)
734
+ * archive version. Receiving `paths.latest` instead of the whole
735
+ * {@link StoragePaths} object makes it impossible for callers to accidentally
736
+ * target `paths.active`, which may point at a frozen past version when the
737
+ * server is in read-only viewing mode.
738
+ */
739
+ type VersionedWriteCommand<T> = (paths: StoragePaths["latest"]) => T | Promise<T>;
740
+ /**
741
+ * Central gate for every file-system write that lands under `versions/<v>`.
742
+ *
743
+ * - Blocks the operation when `readOnly` is true, surfacing a domain conflict
744
+ * with code `server.read_only_archive`.
745
+ * - Forces the command to operate on `paths.latest` (the latest archive
746
+ * version) so past frozen versions can never be mutated.
747
+ *
748
+ * Local-only writes (thumbs, caches, staging pool, trash) should NOT use this
749
+ * helper; they live under `local/` and are governed by the route-level
750
+ * write gate instead.
751
+ */
752
+ declare function writeVersioned<T>(paths: StoragePaths, readOnly: boolean, cmd: VersionedWriteCommand<T>): Promise<T>;
753
+
754
+ export { type CreateNextVersionResult, type CreateStoragePathsOptions, type DirSizeOptions, type LocalPaths, ORDER_MANIFEST_NAME, type OccupiedNames, PluginVaultPathError, RESOURCE_DATA_DIR_NAME, type StoragePaths, type VaultCommitResult, type VersionPaths, type VersionedFolderOps, type VersionedWriteCommand, type ZipStreamEntry, archiveStaleFiles, assertInside, assertSafeSegment, buildVersionedFolderOps, commitVaultFile, createNextVersion, createOccupiedNames, createStoragePaths, currentVersion, discardVaultTempFile, ensureBootstrapVersion, extractArchiveInto, findStagedArchiveFile, findStagedPoolFile, imageVariantKey, listArchiveEntries, listVersions, naturalSort, occupyEntryName, orderEntries, orderManifestPath, parseOrderManifest, parsePluginVaultDest, readActiveVersion, readOrderManifest, removeStagedPoolFile, resolveStagedPoolFiles, sanitizeEntryName, streamStoredZip, sumDirSizes, uniqueEntryName, validateArchiveBudget, vaultFileSha256, vaultReadFile, vaultRemoveFile, vaultStatFile, vaultTempFile, vaultTotalSize, versionedDbFile, versionedPath, writeActiveVersion, writeOrderManifest, writeStagedArchiveFile, writeStagedPoolFile, writeVersioned };