@taprootio/site-authoring 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +239 -0
  3. package/bin/taproot-site.js +5 -0
  4. package/package.json +40 -0
  5. package/src/api.js +1507 -0
  6. package/src/appearance-contract.js +311 -0
  7. package/src/atomic-file.js +136 -0
  8. package/src/bounded-file.js +59 -0
  9. package/src/capabilities.js +29 -0
  10. package/src/cli.js +872 -0
  11. package/src/config.js +412 -0
  12. package/src/constants.js +173 -0
  13. package/src/content/components.js +870 -0
  14. package/src/content/free-form-section-registry.json +524 -0
  15. package/src/content/free-form-sections.js +582 -0
  16. package/src/content/index.js +29 -0
  17. package/src/content/inline-facts.js +97 -0
  18. package/src/content/markdown.js +1625 -0
  19. package/src/content/tiptap-prosemirror.ts +1266 -0
  20. package/src/content/validate-document.js +1079 -0
  21. package/src/content/vocabulary.js +397 -0
  22. package/src/credentials.js +544 -0
  23. package/src/errors.js +133 -0
  24. package/src/footer-contract.js +687 -0
  25. package/src/footer-draft-hash.js +203 -0
  26. package/src/footer-workspace.js +136 -0
  27. package/src/image-metadata.js +169 -0
  28. package/src/index.js +16 -0
  29. package/src/json-guard.js +111 -0
  30. package/src/output.js +157 -0
  31. package/src/presentation-reference.js +600 -0
  32. package/src/reference-help.js +927 -0
  33. package/src/session.js +316 -0
  34. package/src/settings-catalog.js +187 -0
  35. package/src/settings.js +157 -0
  36. package/src/theme-validation.js +344 -0
  37. package/src/transport.js +777 -0
  38. package/src/verbs/approve.js +178 -0
  39. package/src/verbs/deploy.js +327 -0
  40. package/src/verbs/env.js +89 -0
  41. package/src/verbs/footer-push.js +115 -0
  42. package/src/verbs/index.js +125 -0
  43. package/src/verbs/login.js +529 -0
  44. package/src/verbs/logout.js +39 -0
  45. package/src/verbs/media-upload.js +319 -0
  46. package/src/verbs/nav-push.js +333 -0
  47. package/src/verbs/pages-push.js +990 -0
  48. package/src/verbs/preview-page.js +107 -0
  49. package/src/verbs/preview-revoke.js +56 -0
  50. package/src/verbs/pull.js +727 -0
  51. package/src/verbs/sites.js +57 -0
  52. package/src/verbs/status.js +94 -0
  53. package/src/verbs/theme-push.js +197 -0
  54. package/src/verbs/use.js +129 -0
  55. package/src/verbs/validate.js +477 -0
  56. package/src/verbs/whoami.js +108 -0
  57. package/src/workspace.js +1085 -0
@@ -0,0 +1,1085 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { lstat, mkdir, open, readdir, unlink } from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ import { atomicWriteFile } from "./atomic-file.js";
7
+ import { hasAsciiControl, SiteAuthoringError } from "./errors.js";
8
+
9
+ /**
10
+ * The local workspace: what `pull` writes, what the push verbs read back, and
11
+ * the two manifests that carry identity between them.
12
+ *
13
+ * A workspace is a checked-out tree, so a symlink planted inside it is the
14
+ * cheapest way to turn `pull` into an arbitrary-file writer or `pages push`
15
+ * into an arbitrary-file reader. Containment therefore takes three separate
16
+ * measures, and each covers a hole the others leave open:
17
+ *
18
+ * 1. `resolveWorkspacePath` rejects anything that is not a bounded relative
19
+ * POSIX path under the root. This is **lexical only** — it proves the string
20
+ * names somewhere inside, and nothing about what the filesystem does with it.
21
+ * 2. `requireRealDirectoryChain` `lstat`s *every* directory segment on the way
22
+ * to a target and refuses a symlink or a non-directory. This is the measure
23
+ * that matters: `O_NOFOLLOW` covers only a path's final component, and
24
+ * `readdir` follows a linked directory without complaint, so a single
25
+ * `pages -> ../outside` link is enough to read out-of-tree files if the
26
+ * intermediate segments are never inspected.
27
+ * 3. Opens add `O_NOFOLLOW` so the final component cannot be a link either, and
28
+ * directory walks skip symlink entries rather than descending them.
29
+ *
30
+ * What this deliberately does not claim: the checks are not atomic with the
31
+ * opens that follow them. Defeating them requires swapping a directory for a
32
+ * link inside the window, which is a different threat from the one a workspace
33
+ * actually faces — a link that arrives with the checkout and sits there.
34
+ * Closing it would need `openat`-relative traversal, which Node does not
35
+ * expose.
36
+ */
37
+
38
+ export const MANIFEST_FILE_NAME = ".taproot-site-manifest.json";
39
+ export const MEDIA_MANIFEST_FILE_NAME = ".taproot-site-media.json";
40
+ export const NAVIGATION_FILE_NAME = "nav.json";
41
+ export const PAGES_DIRECTORY = "pages";
42
+ /**
43
+ * The page sources `pages push` walks. Shared so the set `pull` checks for
44
+ * before writing into a workspace is exactly the set a later push would find:
45
+ * a file one of them counts and the other ignores is a page that gets pushed
46
+ * from a workspace nothing proved the ownership of.
47
+ */
48
+ export const PAGE_SOURCE_EXTENSIONS = Object.freeze([".md", ".pm.json"]);
49
+ export const SETTINGS_DIRECTORY = "settings";
50
+ export const MEDIA_DIRECTORY = "media";
51
+
52
+ /**
53
+ * The two authoritative source formats one page path may have, and the mapping
54
+ * from a workspace file to the one it is.
55
+ *
56
+ * The extension *is* the format. Nothing else can be, because the file a push
57
+ * reads is chosen by name: a manifest that recorded some other format would be
58
+ * describing a file that does not exist. `sourceFormat` in the manifest is
59
+ * therefore a recorded copy of this derivation, kept so the registry is
60
+ * self-describing and so a hand edit that disagrees with it is visible rather
61
+ * than silently overridden.
62
+ */
63
+ export const PAGE_SOURCE_FORMAT_MARKDOWN = "markdown";
64
+ export const PAGE_SOURCE_FORMAT_PROSEMIRROR = "prosemirror";
65
+ const PAGE_SOURCE_FORMATS_BY_EXTENSION = Object.freeze([
66
+ Object.freeze([".pm.json", PAGE_SOURCE_FORMAT_PROSEMIRROR]),
67
+ Object.freeze([".md", PAGE_SOURCE_FORMAT_MARKDOWN]),
68
+ ]);
69
+
70
+ /** The authoritative source format one workspace file carries, by its extension. */
71
+ export function pageSourceFormat(file) {
72
+ if (typeof file !== "string") return undefined;
73
+ const lowered = file.toLowerCase();
74
+ for (const [extension, format] of PAGE_SOURCE_FORMATS_BY_EXTENSION) {
75
+ if (lowered.endsWith(extension)) return format;
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ /**
81
+ * Internal workspace state: the remote body `pull` last read for a page whose
82
+ * workspace source does not already hold it.
83
+ *
84
+ * It lives outside `pages/` and under a dot-prefixed directory for one reason:
85
+ * it must never be discoverable as an authored page. `walkWorkspaceFiles`
86
+ * skips dot-entries and every page walk is rooted at `pages/`, so neither a
87
+ * push nor the offline fixture validator can bind one of these files as a
88
+ * source — which is exactly the ambiguity a `pages/<path>.pm.json` beside a
89
+ * tracked `pages/<path>.md` created. Writes still go through the same
90
+ * containment-checked writer as everything else.
91
+ */
92
+ export const INTERNAL_STATE_DIRECTORY = ".taproot-site-state";
93
+ export const INTERNAL_PAGE_BASELINE_DIRECTORY = `${INTERNAL_STATE_DIRECTORY}/pages`;
94
+
95
+ /**
96
+ * Where one page's internal baseline body lives, or `undefined` when the page
97
+ * id cannot be carried as a file name. Page ids are server data reaching a
98
+ * filesystem, so they pass the same grammar as every other workspace name.
99
+ */
100
+ export function internalPageBaselineFile(pageId) {
101
+ return typeof pageId === "string" && SAFE_SEGMENT.test(pageId)
102
+ ? `${INTERNAL_PAGE_BASELINE_DIRECTORY}/${pageId}.pm.json`
103
+ : undefined;
104
+ }
105
+
106
+ // Version 5 makes the page list a source registry: each entry names the one
107
+ // authoritative `file` for its path, the `sourceFormat` that file carries, and
108
+ // the `baseline` hashes pull compares against to detect a remote edit it
109
+ // cannot reconcile. Version 4 classified pulled page files as editable,
110
+ // metadata-only, or an integrity-checked read-only projection but recorded no
111
+ // registry, so a push against one cannot tell an intentional format change
112
+ // from a stale entry. The package is in-repo and unreleased, so an older
113
+ // manifest is re-pulled rather than migrated.
114
+ export const MANIFEST_VERSION = 5;
115
+ export const PAGE_WORKSPACE_MODE_EDITABLE = "editable";
116
+ export const PAGE_WORKSPACE_MODE_METADATA_ONLY = "metadata-only";
117
+ export const PAGE_WORKSPACE_MODE_READ_ONLY = "read-only";
118
+ export const PAGE_READ_ONLY_REASON_SYSTEM_404 = "system-404";
119
+ export const SYSTEM_PAGE_NOT_FOUND_PATH = "404";
120
+ export const SYSTEM_PAGE_NOT_FOUND_FILE = `${PAGES_DIRECTORY}/404.pm.json`;
121
+ export const WORKSPACE_CONTENT_HASH = /^sha256:[0-9a-f]{64}$/u;
122
+ // Bumped when the media manifest gained its `siteId` binding: a manifest
123
+ // without one cannot be proved to belong to the site being written to, and the
124
+ // package is in-repo and unreleased, so an old file is re-pulled rather than
125
+ // migrated.
126
+ export const MEDIA_MANIFEST_VERSION = 2;
127
+
128
+ export const WORKSPACE_LIMITS = Object.freeze({
129
+ // One authored page: a Markdown source or a ProseMirror document.
130
+ documentBytes: 2 * 1024 * 1024,
131
+ navigationBytes: 1024 * 1024,
132
+ settingsBytes: 256 * 1024,
133
+ manifestBytes: 8 * 1024 * 1024,
134
+ mediaBytes: 32 * 1024 * 1024,
135
+ // Pull holds the bodies of the pages this workspace already tracks until it
136
+ // knows whether any of them conflict, because a refusal has to leave `pages/`
137
+ // untouched. Generous for any real authoring workspace, and an explicit
138
+ // refusal rather than an unbounded buffer for one that is not.
139
+ pulledBodyBytes: 64 * 1024 * 1024,
140
+ files: 5_000,
141
+ directoryDepth: 12,
142
+ relativePathBytes: 1_024,
143
+ pagePathBytes: 512,
144
+ });
145
+
146
+ /** Hashes the exact bounded bytes pull wrote for a read-only projection. */
147
+ export function workspaceContentHash(bytes) {
148
+ return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
149
+ }
150
+
151
+ // Deep enough for any document the vocabulary accepts, shallow enough that a
152
+ // hostile one cannot exhaust the stack inside the hash.
153
+ const CANONICAL_MAXIMUM_DEPTH = 100;
154
+
155
+ function canonicalJson(value, depth) {
156
+ if (depth > CANONICAL_MAXIMUM_DEPTH) {
157
+ throw new SiteAuthoringError(
158
+ "workspace.document_too_deep",
159
+ `A page document nests deeper than ${CANONICAL_MAXIMUM_DEPTH} levels and cannot be compared.`,
160
+ );
161
+ }
162
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item, depth + 1)).join(",")}]`;
163
+ if (value !== null && typeof value === "object") {
164
+ return `{${
165
+ Object.keys(value)
166
+ .sort()
167
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key], depth + 1)}`)
168
+ .join(",")
169
+ }}`;
170
+ }
171
+ return JSON.stringify(value) ?? "null";
172
+ }
173
+
174
+ /**
175
+ * Hashes a page body by its *content*, not by the bytes it happened to arrive
176
+ * in.
177
+ *
178
+ * `FreeFormData.body` is a `google.protobuf.Struct`, so object member order is
179
+ * not part of the contract and two reads of an unchanged page may serialize
180
+ * their maps differently. Hashing raw bytes would turn that into a reported
181
+ * conflict on a page nobody touched, so the hash is taken over a deterministic
182
+ * sorted-key rendering instead. `JSON.parse` has already collapsed numeric
183
+ * spellings (`2.0` and `2` alike) by the time this runs.
184
+ */
185
+ export function canonicalDocumentHash(document_) {
186
+ return `sha256:${createHash("sha256").update(canonicalJson(document_, 0), "utf8").digest("hex")}`;
187
+ }
188
+
189
+ function normalizeBaselineHash(value) {
190
+ return typeof value === "string" && WORKSPACE_CONTENT_HASH.test(value) ? value : undefined;
191
+ }
192
+
193
+ /**
194
+ * Whether a manifest entry names a file `walkWorkspaceFiles` could have
195
+ * produced: under `pages/`, bounded, and every segment in the workspace
196
+ * grammar.
197
+ *
198
+ * The registry is the one place a manifest string is used to *reach* a file
199
+ * rather than to match one the walk already found, so it re-derives the same
200
+ * grammar rather than trusting the value. A hand-edited `../outside.md` is
201
+ * dropped here instead of becoming a path refusal inside the verb that exists
202
+ * to repair the manifest.
203
+ */
204
+ function isDiscoverablePageSource(file) {
205
+ if (typeof file !== "string" || !file.startsWith(`${PAGES_DIRECTORY}/`)) return false;
206
+ const segments = file.split("/");
207
+ return segments.length > 1
208
+ && segments.length <= WORKSPACE_LIMITS.directoryDepth
209
+ && segments.slice(1).every((segment) => SAFE_SEGMENT.test(segment));
210
+ }
211
+
212
+ /**
213
+ * Reads the manifest's page list as a source registry keyed by page id: which
214
+ * workspace file is a page's one authoritative source, what format that file
215
+ * carries, and the hashes `pull` compares against.
216
+ *
217
+ * Nothing here throws. `pull` is the verb that repairs a workspace, so a
218
+ * registry read that refused would leave a damaged manifest with no way out
219
+ * except deleting it — which `pull` then refuses in turn, because page sources
220
+ * without a manifest cannot be proved to belong to this site. The two kinds of
221
+ * damage are therefore handled differently and deliberately:
222
+ *
223
+ * - An entry whose `file` is missing, unrecognized, or not a file the workspace
224
+ * walk could have produced is **dropped**. It names no source this package
225
+ * would ever read, so there is nothing to protect.
226
+ * - A `baseline` that is not a usable pair of hashes is **discarded and
227
+ * reported**. Losing it costs one pull's worth of conflict detection and
228
+ * nothing else: the next pull re-establishes it from what it reads, and the
229
+ * authored source is never written over in the meantime.
230
+ * - A `sourceFormat` that disagrees with the file's own extension is recorded
231
+ * as `formatMismatch` and the extension wins. `pages push` refuses on it —
232
+ * it is a hand-edited manifest and re-pulling is the fix — while `pull`
233
+ * proceeds and rewrites the entry correctly.
234
+ * - Two entries naming the same `file`, or two entries naming the same
235
+ * `pageId`, are both recorded as `duplicateSource` / `duplicateIdentity`.
236
+ * - Two entries naming the same `file` are both recorded as
237
+ * `duplicateSource`. One file cannot be two pages' source, and choosing
238
+ * between them would let one page's content be sent to the other, so
239
+ * neither is trusted: `pull` ignores both and assigns each page a fresh
240
+ * file, which rewrites the manifest without the ambiguity, and `pages push`
241
+ * refuses until that has happened.
242
+ */
243
+ export function pageSourceRegistry(manifest) {
244
+ const registry = new Map();
245
+ const byFile = new Map();
246
+ const byId = new Map();
247
+ const pages = manifest === null || typeof manifest !== "object" ? undefined : manifest.pages;
248
+ if (!Array.isArray(pages)) return registry;
249
+ for (const entry of pages) {
250
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) continue;
251
+ if (typeof entry.pageId !== "string" || entry.pageId === "") continue;
252
+ const sourceFormat = pageSourceFormat(entry.file);
253
+ if (sourceFormat === undefined || !isDiscoverablePageSource(entry.file)) continue;
254
+ const declared = entry.baseline;
255
+ const remoteHash = normalizeBaselineHash(declared?.remoteHash);
256
+ const sourceHash = normalizeBaselineHash(declared?.sourceHash);
257
+ const baseline = remoteHash === undefined && sourceHash === undefined
258
+ ? undefined
259
+ : { ...(remoteHash === undefined ? {} : { remoteHash }), ...(sourceHash === undefined ? {} : { sourceHash }) };
260
+ const record = {
261
+ pageId: entry.pageId,
262
+ file: entry.file,
263
+ sourceFormat,
264
+ baseline,
265
+ baselineDiscarded: declared !== undefined && baseline === undefined,
266
+ formatMismatch: entry.sourceFormat !== undefined && entry.sourceFormat !== sourceFormat,
267
+ duplicateSource: false,
268
+ duplicateIdentity: false,
269
+ };
270
+ const previous = byFile.get(entry.file);
271
+ if (previous === undefined) {
272
+ byFile.set(entry.file, record);
273
+ } else {
274
+ previous.duplicateSource = true;
275
+ record.duplicateSource = true;
276
+ }
277
+ // Accumulated in place: a manifest is bounded by bytes, not by entries, so
278
+ // one at the 8 MB ceiling can carry enough repeated page ids that copying
279
+ // the group on every append would take quadratic time before the integrity
280
+ // check below ever gets to reject it.
281
+ const group = byId.get(entry.pageId);
282
+ if (group === undefined) byId.set(entry.pageId, [record]);
283
+ else group.push(record);
284
+ registry.set(entry.pageId, record);
285
+ }
286
+ // The mirror image of two entries naming one file: two files claiming one
287
+ // page. The registry is keyed by page id, so without this the second entry
288
+ // simply replaces the first and the manifest reads as consistent — while
289
+ // `pages push` keys its own lookup by file, plans both, and writes that one
290
+ // live page twice from two different sources. Every implicated file is
291
+ // recorded on the surviving record, because "remove the extra one" is not
292
+ // guidance anybody can follow without knowing which files are meant.
293
+ for (const records of byId.values()) {
294
+ if (records.length < 2) continue;
295
+ const sourceFiles = records.map((candidate) => candidate.file);
296
+ for (const candidate of records) {
297
+ candidate.duplicateIdentity = true;
298
+ candidate.sourceFiles = sourceFiles;
299
+ }
300
+ }
301
+ return registry;
302
+ }
303
+
304
+ /**
305
+ * Refuses a manifest that no longer describes a workspace this package can act
306
+ * on without guessing.
307
+ *
308
+ * Both conditions are hand-edit or corruption states rather than anything a
309
+ * pull or a push produces, and both would otherwise be resolved silently in
310
+ * favor of whichever entry happened to be read last:
311
+ *
312
+ * - A recorded `sourceFormat` the file's own extension contradicts. The
313
+ * extension already decided which reader runs, so the manifest is describing
314
+ * a file that is not there.
315
+ * - Two entries naming one source file. `pages push` keys its manifest lookup
316
+ * by file, so trusting either entry would send that file's content to
317
+ * whichever page won the lookup — a page the author never edited.
318
+ *
319
+ * `pull` does not call this. It is the verb that repairs a workspace, and it
320
+ * ignores both kinds of entry and rewrites them, so "run pull again" is a real
321
+ * remedy rather than a loop.
322
+ */
323
+ export function requireManifestSourceRegistry(registry) {
324
+ for (const entry of registry.values()) {
325
+ if (entry.formatMismatch) {
326
+ throw new SiteAuthoringError(
327
+ "workspace.manifest_invalid",
328
+ `The manifest records a source format for '${entry.file}' that the file's own extension contradicts. `
329
+ + "Run 'taproot-site pull' again.",
330
+ { field: entry.file },
331
+ );
332
+ }
333
+ if (entry.duplicateSource) {
334
+ throw new SiteAuthoringError(
335
+ "workspace.manifest_invalid",
336
+ `The manifest records '${entry.file}' as the source of more than one page, so this file's content cannot be `
337
+ + "sent without guessing which page it belongs to. Run 'taproot-site pull' again.",
338
+ { field: entry.file },
339
+ );
340
+ }
341
+ if (entry.duplicateIdentity) {
342
+ throw new SiteAuthoringError(
343
+ "workspace.manifest_invalid",
344
+ `The manifest records ${entry.sourceFiles.length} sources for page ${entry.pageId} `
345
+ + `(${entry.sourceFiles.join(", ")}), so that page would be written more than once from different files. `
346
+ + "Run 'taproot-site pull' again.",
347
+ { field: entry.file, alternatives: entry.sourceFiles },
348
+ );
349
+ }
350
+ }
351
+ return registry;
352
+ }
353
+
354
+ /**
355
+ * Returns only the complete read-only projection shape that `pull` writes.
356
+ *
357
+ * A manifest is editable input, so a caller must not treat an arbitrary
358
+ * `workspaceMode: read-only` as permission to omit a page from push or
359
+ * approval. The live page identity is checked separately after listing the
360
+ * site; this function proves only the deterministic on-disk half.
361
+ */
362
+ export function readOnlySystem404Projections(manifest) {
363
+ const projections = [];
364
+ for (const [index, entry] of manifest.pages.entries()) {
365
+ if (entry?.workspaceMode !== PAGE_WORKSPACE_MODE_READ_ONLY) continue;
366
+ const pagePath = normalizePagePath(entry.path);
367
+ if (
368
+ pagePath?.toLowerCase() !== SYSTEM_PAGE_NOT_FOUND_PATH
369
+ || entry.file !== SYSTEM_PAGE_NOT_FOUND_FILE
370
+ || typeof entry.pageId !== "string"
371
+ || entry.pageId.trim() === ""
372
+ || entry.readOnlyReason !== PAGE_READ_ONLY_REASON_SYSTEM_404
373
+ || typeof entry.workspaceContentHash !== "string"
374
+ || !WORKSPACE_CONTENT_HASH.test(entry.workspaceContentHash)
375
+ ) {
376
+ throw new SiteAuthoringError(
377
+ "workspace.manifest_invalid",
378
+ `The read-only page projection at pages[${index}] is not the complete system-404 shape written by pull. `
379
+ + "Run 'taproot-site pull' again.",
380
+ { field: `pages[${index}].workspaceMode` },
381
+ );
382
+ }
383
+ projections.push(entry);
384
+ }
385
+ if (projections.length > 1) {
386
+ throw new SiteAuthoringError(
387
+ "workspace.manifest_invalid",
388
+ "The manifest records more than one read-only system 404 projection. Run 'taproot-site pull' again.",
389
+ { field: "pages" },
390
+ );
391
+ }
392
+ return projections;
393
+ }
394
+
395
+ const WINDOWS_DEVICE_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/iu;
396
+
397
+ /**
398
+ * The one name shape this package carries, for a workspace file and for a page
399
+ * path segment alike. It is deliberately identical to the server's
400
+ * `Validations.PagePathSegmentPattern` (`\A[A-Za-z0-9][A-Za-z0-9._-]*\z`), so
401
+ * `pages push` can refuse a path the server would refuse *before* it starts
402
+ * mutating rather than halfway through.
403
+ */
404
+ export const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
405
+ // Media names are workspace identities, not page-path segments. Retina asset
406
+ // names such as `logo@2x.png` are conventional and never cross the server's
407
+ // stricter page-path grammar.
408
+ export const SAFE_MEDIA_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._@-]*$/u;
409
+
410
+ function invalidRelativePath(relativePath) {
411
+ return new SiteAuthoringError(
412
+ "workspace.path_invalid",
413
+ `'${relativePath}' is not a bounded relative path inside the workspace.`,
414
+ { field: typeof relativePath === "string" ? relativePath : undefined },
415
+ );
416
+ }
417
+
418
+ /**
419
+ * Resolves one workspace-relative POSIX path to an absolute path that is
420
+ * lexically inside the workspace.
421
+ *
422
+ * Lexical is all this is. It proves the *string* stays inside; it proves
423
+ * nothing about the directories the string names, so every reader pairs it with
424
+ * `requireRealDirectoryChain` before touching the filesystem.
425
+ */
426
+ export function resolveWorkspacePath(workspaceDir, relativePath) {
427
+ if (
428
+ typeof relativePath !== "string"
429
+ || relativePath.length === 0
430
+ || Buffer.byteLength(relativePath, "utf8") > WORKSPACE_LIMITS.relativePathBytes
431
+ || hasAsciiControl(relativePath)
432
+ || relativePath.includes("\\")
433
+ || path.posix.isAbsolute(relativePath)
434
+ || path.isAbsolute(relativePath)
435
+ ) {
436
+ throw invalidRelativePath(relativePath);
437
+ }
438
+ const segments = relativePath.split("/");
439
+ if (segments.length > WORKSPACE_LIMITS.directoryDepth) throw invalidRelativePath(relativePath);
440
+ for (const segment of segments) {
441
+ const base = segment.split(".")[0]?.replace(/[ .]+$/u, "") ?? "";
442
+ if (segment === "" || segment === "." || segment === ".." || WINDOWS_DEVICE_BASENAME.test(base)) {
443
+ throw invalidRelativePath(relativePath);
444
+ }
445
+ }
446
+ const resolved = path.resolve(workspaceDir, ...segments);
447
+ if (resolved !== workspaceDir && !resolved.startsWith(`${workspaceDir}${path.sep}`)) {
448
+ throw invalidRelativePath(relativePath);
449
+ }
450
+ return resolved;
451
+ }
452
+
453
+ /**
454
+ * Walks the directory segments leading to `relativePath` and refuses the whole
455
+ * operation if any of them is a symlink or is not a directory.
456
+ *
457
+ * This is the read path's containment check, and it exists because the two
458
+ * mechanisms that look like they cover it do not. `resolveWorkspacePath` is
459
+ * lexical, so `pages/about.md` resolves inside the workspace whether or not
460
+ * `pages` is a link pointing out of it. `O_NOFOLLOW` refuses a linked *final*
461
+ * component only, so opening `pages/about.md` through a linked `pages` succeeds
462
+ * — and `readdir` follows a linked directory without any complaint at all. One
463
+ * checked-in `pages -> ../outside` was therefore enough to make `pages push`
464
+ * publish out-of-tree documents and `media upload` upload out-of-tree files,
465
+ * with no positional argument and no unusual invocation.
466
+ *
467
+ * Returns `false` when a segment simply does not exist yet, which is an
468
+ * ordinary state a caller reports in its own vocabulary ("missing", "no
469
+ * manifest", "nothing to walk") rather than a refusal.
470
+ *
471
+ * `includeLeaf` extends the check to the last segment, for callers whose target
472
+ * must itself be a real directory — a walk root, for instance.
473
+ */
474
+ async function requireRealDirectoryChain(workspaceDir, relativePath, { includeLeaf = false } = {}) {
475
+ const segments = relativePath.split("/");
476
+ const directories = includeLeaf ? segments : segments.slice(0, -1);
477
+ let current = workspaceDir;
478
+ let walked = "";
479
+ for (const segment of directories) {
480
+ current = path.join(current, segment);
481
+ walked = walked === "" ? segment : `${walked}/${segment}`;
482
+ let stats;
483
+ try {
484
+ stats = await lstat(current);
485
+ } catch (error) {
486
+ if (error && typeof error === "object" && error.code === "ENOENT") return false;
487
+ throw new SiteAuthoringError(
488
+ "workspace.unreadable",
489
+ `Could not inspect workspace path '${relativePath}'.`,
490
+ { field: relativePath },
491
+ );
492
+ }
493
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
494
+ throw new SiteAuthoringError(
495
+ "workspace.not_directory",
496
+ `'${relativePath}' is reached through '${walked}', which is a link or a file where the workspace `
497
+ + "requires a real directory. Nothing outside the workspace is read.",
498
+ { field: relativePath },
499
+ );
500
+ }
501
+ }
502
+ return true;
503
+ }
504
+
505
+ async function requireRealDirectory(directory) {
506
+ let stats;
507
+ try {
508
+ stats = await lstat(directory);
509
+ } catch (error) {
510
+ if (error && typeof error === "object" && error.code === "ENOENT") return false;
511
+ throw new SiteAuthoringError("workspace.unreadable", "Could not inspect a workspace directory.");
512
+ }
513
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
514
+ throw new SiteAuthoringError(
515
+ "workspace.not_directory",
516
+ "A workspace directory is a link or a file where a real directory is required.",
517
+ );
518
+ }
519
+ return true;
520
+ }
521
+
522
+ async function createAndVerifyDirectory(directory) {
523
+ try {
524
+ await mkdir(directory, { recursive: false });
525
+ } catch (error) {
526
+ if (!error || typeof error !== "object" || error.code !== "EEXIST") {
527
+ throw new SiteAuthoringError("workspace.unwritable", "Could not create a workspace directory.");
528
+ }
529
+ }
530
+ if (!await requireRealDirectory(directory)) {
531
+ throw new SiteAuthoringError("workspace.unwritable", "Could not create a workspace directory.");
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Creates the workspace root, one segment at a time, from the configuration
537
+ * directory `loadSiteConfig` canonicalized. A fresh clone legitimately has no
538
+ * workspace yet, so this is a create — but each segment is created and then
539
+ * re-inspected as a real directory, so a link that appears between the `mkdir`
540
+ * and the next write is caught rather than followed out of the tree.
541
+ */
542
+ export async function ensureWorkspaceRoot({ configDirectory, workspaceDir }) {
543
+ const relative = path.relative(configDirectory, workspaceDir);
544
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
545
+ throw new SiteAuthoringError(
546
+ "workspace.escape",
547
+ "The configured workspace does not resolve beneath the configuration directory.",
548
+ );
549
+ }
550
+ let current = configDirectory;
551
+ for (const segment of relative.split(path.sep)) {
552
+ current = path.join(current, segment);
553
+ await createAndVerifyDirectory(current);
554
+ }
555
+ return current;
556
+ }
557
+
558
+ /** Creates one relative directory beneath an existing workspace root. */
559
+ export async function ensureWorkspaceDirectory(workspaceDir, relativeDirectory = "") {
560
+ if (relativeDirectory === "") {
561
+ await createAndVerifyDirectory(workspaceDir);
562
+ return workspaceDir;
563
+ }
564
+ resolveWorkspacePath(workspaceDir, relativeDirectory);
565
+ let current = workspaceDir;
566
+ for (const segment of relativeDirectory.split("/")) {
567
+ current = path.join(current, segment);
568
+ await createAndVerifyDirectory(current);
569
+ }
570
+ return current;
571
+ }
572
+
573
+ /**
574
+ * Reads one workspace file, refusing a link anywhere on the way to it — every
575
+ * parent directory, and the file itself — and anything past its byte bound.
576
+ */
577
+ export async function readWorkspaceFile(workspaceDir, relativePath, maximumBytes) {
578
+ const filePath = resolveWorkspacePath(workspaceDir, relativePath);
579
+ if (!await requireRealDirectoryChain(workspaceDir, relativePath)) {
580
+ throw new SiteAuthoringError(
581
+ "workspace.file_missing",
582
+ `Workspace file '${relativePath}' does not exist.`,
583
+ { field: relativePath },
584
+ );
585
+ }
586
+ let handle;
587
+ try {
588
+ handle = await open(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
589
+ const stats = await handle.stat({ bigint: true });
590
+ if (!stats.isFile()) {
591
+ throw new SiteAuthoringError(
592
+ "workspace.not_regular",
593
+ `Workspace file '${relativePath}' is not a regular file.`,
594
+ { field: relativePath },
595
+ );
596
+ }
597
+ if (stats.size > BigInt(maximumBytes)) {
598
+ throw new SiteAuthoringError(
599
+ "workspace.file_too_large",
600
+ `Workspace file '${relativePath}' exceeds ${maximumBytes} bytes.`,
601
+ { field: relativePath },
602
+ );
603
+ }
604
+ const buffer = Buffer.allocUnsafe(Number(stats.size));
605
+ let total = 0;
606
+ while (total < buffer.byteLength) {
607
+ const { bytesRead } = await handle.read(buffer, total, buffer.byteLength - total, total);
608
+ if (bytesRead === 0) break;
609
+ total += bytesRead;
610
+ }
611
+ return buffer.subarray(0, total);
612
+ } catch (error) {
613
+ if (error instanceof SiteAuthoringError) throw error;
614
+ if (error && typeof error === "object" && error.code === "ENOENT") {
615
+ throw new SiteAuthoringError(
616
+ "workspace.file_missing",
617
+ `Workspace file '${relativePath}' does not exist.`,
618
+ { field: relativePath },
619
+ );
620
+ }
621
+ throw new SiteAuthoringError(
622
+ "workspace.unreadable",
623
+ `Could not read workspace file '${relativePath}'.`,
624
+ { field: relativePath },
625
+ );
626
+ } finally {
627
+ await handle?.close().catch(() => {});
628
+ }
629
+ }
630
+
631
+ export async function readWorkspaceJson(workspaceDir, relativePath, maximumBytes) {
632
+ const bytes = await readWorkspaceFile(workspaceDir, relativePath, maximumBytes);
633
+ try {
634
+ return JSON.parse(bytes.toString("utf8"));
635
+ } catch {
636
+ throw new SiteAuthoringError(
637
+ "workspace.invalid_json",
638
+ `Workspace file '${relativePath}' is not valid JSON.`,
639
+ { field: relativePath },
640
+ );
641
+ }
642
+ }
643
+
644
+ /**
645
+ * Classifies one workspace path so a caller can tell a file from a directory
646
+ * before it tries to read it. `"file"`, `"directory"`, `"missing"`, or
647
+ * `"other"` — a symlink is deliberately *not* resolved and reports as
648
+ * `"other"`, because following one is how a positional argument turns into a
649
+ * read outside the workspace.
650
+ */
651
+ export async function inspectWorkspaceEntry(workspaceDir, relativePath) {
652
+ const target = resolveWorkspacePath(workspaceDir, relativePath);
653
+ // The leaf `lstat` below refuses a linked final component; this refuses a
654
+ // linked one anywhere above it, which is how `media/link/sub` would otherwise
655
+ // classify as a perfectly ordinary directory sitting outside the workspace.
656
+ if (!await requireRealDirectoryChain(workspaceDir, relativePath)) return "missing";
657
+ let stats;
658
+ try {
659
+ stats = await lstat(target);
660
+ } catch (error) {
661
+ if (error && typeof error === "object" && error.code === "ENOENT") return "missing";
662
+ throw new SiteAuthoringError(
663
+ "workspace.unreadable",
664
+ `Could not inspect workspace path '${relativePath}'.`,
665
+ { field: relativePath },
666
+ );
667
+ }
668
+ if (stats.isSymbolicLink()) return "other";
669
+ if (stats.isDirectory()) return "directory";
670
+ return stats.isFile() ? "file" : "other";
671
+ }
672
+
673
+ /**
674
+ * Whether one workspace path is a regular file.
675
+ *
676
+ * `false` means one thing only: nothing is there. Every other answer is a
677
+ * refusal, because every caller of this treats `false` as "absent, carry on
678
+ * without it" — and carrying on is wrong for anything that *does* exist but
679
+ * cannot be read as a file.
680
+ *
681
+ * A symlink at the final component is the case that made this matter. The
682
+ * chain check above covers parent segments, but the leaf used to go through
683
+ * `isFile()`, which is false for a link — so a linked media manifest read as
684
+ * absent, `readMediaManifest` minted an empty one, and `media upload` uploaded
685
+ * and confirmed images against the API before the `O_NOFOLLOW` write finally
686
+ * failed, leaving confirmed images recorded nowhere. A linked pull manifest
687
+ * read as absent in `deploy`, which then promoted whatever staging deployment
688
+ * it found rather than the one the workspace recorded. Neither is a state to
689
+ * discover after the network calls.
690
+ *
691
+ * A bare `catch` did the same for `EACCES`, `ELOOP`, and everything else: a
692
+ * manifest that exists but cannot be inspected is not a manifest that is
693
+ * missing.
694
+ */
695
+ export async function workspaceFileExists(workspaceDir, relativePath) {
696
+ const filePath = resolveWorkspacePath(workspaceDir, relativePath);
697
+ if (!await requireRealDirectoryChain(workspaceDir, relativePath)) return false;
698
+ let stats;
699
+ try {
700
+ stats = await lstat(filePath);
701
+ } catch (error) {
702
+ if (error && typeof error === "object" && error.code === "ENOENT") return false;
703
+ throw new SiteAuthoringError(
704
+ "workspace.unreadable",
705
+ `Could not inspect workspace file '${relativePath}'.`,
706
+ { field: relativePath },
707
+ );
708
+ }
709
+ if (stats.isFile()) return true;
710
+ throw new SiteAuthoringError(
711
+ "workspace.not_regular",
712
+ `Workspace file '${relativePath}' is not a regular file. Nothing outside the workspace is read.`,
713
+ { field: relativePath },
714
+ );
715
+ }
716
+
717
+ /** Atomically writes one workspace file, creating its parent chain and refusing links. */
718
+ export async function writeWorkspaceFile(workspaceDir, relativePath, contents, { openDirectory = open } = {}) {
719
+ const filePath = resolveWorkspacePath(workspaceDir, relativePath);
720
+ const segments = relativePath.split("/");
721
+ await ensureWorkspaceDirectory(workspaceDir, segments.slice(0, -1).join("/"));
722
+
723
+ // The write discipline itself lives in `atomicWriteFile`; what stays here is
724
+ // the part that is genuinely about a workspace — the containment above, and
725
+ // the vocabulary a workspace refusal speaks in.
726
+ await atomicWriteFile(filePath, contents, {
727
+ openDirectory,
728
+ failures: {
729
+ inspect: () =>
730
+ new SiteAuthoringError(
731
+ "workspace.unwritable",
732
+ `Could not inspect workspace file '${relativePath}' before writing.`,
733
+ { field: relativePath },
734
+ ),
735
+ notRegular: () =>
736
+ new SiteAuthoringError(
737
+ "workspace.not_regular",
738
+ `Workspace file '${relativePath}' is not a regular file. Nothing outside the workspace is written.`,
739
+ { field: relativePath },
740
+ ),
741
+ write: () =>
742
+ new SiteAuthoringError(
743
+ "workspace.unwritable",
744
+ `Could not write workspace file '${relativePath}'.`,
745
+ { field: relativePath },
746
+ ),
747
+ },
748
+ });
749
+ return relativePath;
750
+ }
751
+
752
+ export async function writeWorkspaceJson(workspaceDir, relativePath, value) {
753
+ return await writeWorkspaceFile(workspaceDir, relativePath, `${JSON.stringify(value, undefined, 2)}\n`);
754
+ }
755
+
756
+ /**
757
+ * Removes one internal workspace file, refusing a link anywhere on the way to
758
+ * it exactly as a read would. Returns whether anything was there.
759
+ *
760
+ * Only internal state is ever removed this way. Authored sources are the
761
+ * author's, and a verb that deleted one would be doing the thing this package
762
+ * exists to prevent.
763
+ */
764
+ export async function deleteWorkspaceFile(workspaceDir, relativePath) {
765
+ if (!await workspaceFileExists(workspaceDir, relativePath)) return false;
766
+ try {
767
+ await unlink(resolveWorkspacePath(workspaceDir, relativePath));
768
+ } catch (error) {
769
+ if (error && typeof error === "object" && error.code === "ENOENT") return false;
770
+ throw new SiteAuthoringError(
771
+ "workspace.unwritable",
772
+ `Could not remove workspace file '${relativePath}'.`,
773
+ { field: relativePath },
774
+ );
775
+ }
776
+ return true;
777
+ }
778
+
779
+ /**
780
+ * Collects the workspace files a push verb should consider, in a stable sorted
781
+ * order so two runs of the same workspace produce the same request sequence.
782
+ *
783
+ * What is skipped, and what is refused, is the whole contract here:
784
+ *
785
+ * - **Skipped silently:** dot-entries, non-conforming *directories*, and
786
+ * anything that is neither a regular file nor a directory. None of these is
787
+ * authored content a caller expects to publish.
788
+ * - **Refused by name:** a *file* that matches the extension filter but whose
789
+ * name is not `[A-Za-z0-9][A-Za-z0-9._-]*`. `pages/Hero Draft.md` is a page
790
+ * someone wrote and expects to see published; dropping it would let the push
791
+ * report success with a page quietly missing, which is precisely the silent
792
+ * narrowing this CLI exists to prevent.
793
+ * - **Refused on overrun:** more matching files than the bound allows.
794
+ *
795
+ * Symlinks are never followed, at either end of the walk. The root is checked
796
+ * *including its last segment* — a linked `pages` is exactly the case that made
797
+ * this a real hole, and `readdir` would have followed it silently — and inside
798
+ * the walk a symlink entry reports neither `isDirectory()` nor `isFile()`, so
799
+ * it is never queued and never collected.
800
+ */
801
+ export async function walkWorkspaceFiles(
802
+ workspaceDir,
803
+ relativeRoot,
804
+ extensions,
805
+ { segmentPattern = SAFE_SEGMENT, segmentDescription = "letters, digits, '.', '_', and '-'" } = {},
806
+ ) {
807
+ const rootPath = resolveWorkspacePath(workspaceDir, relativeRoot);
808
+ if (!await requireRealDirectoryChain(workspaceDir, relativeRoot, { includeLeaf: true })) return [];
809
+ const collected = [];
810
+ const queue = [{ directory: rootPath, relative: relativeRoot, depth: 0 }];
811
+ while (queue.length > 0) {
812
+ const current = queue.shift();
813
+ if (current.depth >= WORKSPACE_LIMITS.directoryDepth) continue;
814
+ let entries;
815
+ try {
816
+ entries = await readdir(current.directory, { withFileTypes: true });
817
+ } catch (error) {
818
+ if (error && typeof error === "object" && error.code === "ENOENT") continue;
819
+ throw new SiteAuthoringError("workspace.unreadable", "Could not read a workspace directory.");
820
+ }
821
+ for (const entry of [...entries].sort((left, right) => (left.name < right.name ? -1 : 1))) {
822
+ if (entry.name.startsWith(".")) continue;
823
+ const relative = `${current.relative}/${entry.name}`;
824
+ const conforming = segmentPattern.test(entry.name);
825
+ if (entry.isDirectory()) {
826
+ if (!conforming) continue;
827
+ queue.push({ directory: path.join(current.directory, entry.name), relative, depth: current.depth + 1 });
828
+ continue;
829
+ }
830
+ if (!entry.isFile()) continue;
831
+ if (extensions.some((extension) => entry.name.toLowerCase().endsWith(extension))) {
832
+ // A file the caller plainly meant to publish — right directory, right
833
+ // extension — but whose name this package cannot carry. Skipping it
834
+ // would be exactly the silent narrowing this CLI exists to prevent: the
835
+ // push would report success having quietly left one page behind.
836
+ if (!conforming) {
837
+ throw new SiteAuthoringError(
838
+ "workspace.name_unsupported",
839
+ `Workspace file '${relative}' has an unsupported name. Names must match `
840
+ + `the allowed workspace grammar — start with a letter or digit, and use only ${segmentDescription}.`,
841
+ { field: relative },
842
+ );
843
+ }
844
+ if (collected.length >= WORKSPACE_LIMITS.files) {
845
+ throw new SiteAuthoringError(
846
+ "workspace.too_many_files",
847
+ `The workspace holds more than ${WORKSPACE_LIMITS.files} files under '${relativeRoot}'.`,
848
+ { field: relativeRoot },
849
+ );
850
+ }
851
+ collected.push(relative);
852
+ }
853
+ }
854
+ }
855
+ return collected.sort();
856
+ }
857
+
858
+ /**
859
+ * Normalizes a site page path the way `SystemPagePaths.Normalize` does, so the
860
+ * CLI's idea of "the home page" and the server's are the same string. The
861
+ * documented homepage spelling `/` normalizes to the manifest's empty root
862
+ * path here; every path-addressed verb resolves selectors through this one
863
+ * helper, so that spelling reaches the site root everywhere.
864
+ */
865
+ export function normalizePagePath(value) {
866
+ if (typeof value !== "string") return undefined;
867
+ const trimmed = value.trim().replace(/^\/+/u, "").replace(/\/+$/u, "");
868
+ if (
869
+ Buffer.byteLength(trimmed, "utf8") > WORKSPACE_LIMITS.pagePathBytes
870
+ || hasAsciiControl(trimmed)
871
+ || trimmed.includes("\\")
872
+ || trimmed.split("/").some((segment) => segment === "." || segment === ".." || (trimmed !== "" && segment === ""))
873
+ ) {
874
+ return undefined;
875
+ }
876
+ return trimmed;
877
+ }
878
+
879
+ /**
880
+ * Chooses the workspace file name for a pulled page. The server's path is
881
+ * preferred because it is what an author recognizes, but it is server data
882
+ * reaching a filesystem, so anything that is not plainly safe falls back to the
883
+ * page id. The manifest — not the file name — remains the authority on which
884
+ * page a file is.
885
+ */
886
+ export function workspaceFileNameForPage(pagePath, pageId) {
887
+ const normalized = normalizePagePath(pagePath);
888
+ if (normalized === "") return "index";
889
+ const segments = normalized === undefined ? [] : normalized.split("/");
890
+ if (
891
+ segments.length > 0
892
+ && segments.length < WORKSPACE_LIMITS.directoryDepth - 1
893
+ && segments.every((segment) => SAFE_SEGMENT.test(segment))
894
+ ) {
895
+ return segments.join("/");
896
+ }
897
+ return SAFE_SEGMENT.test(pageId ?? "") ? pageId : "page";
898
+ }
899
+
900
+ function requireManifestObject(value, code, description, fileName) {
901
+ if (value === null || Array.isArray(value) || typeof value !== "object") {
902
+ throw new SiteAuthoringError(code, `The ${description} in '${fileName}' is malformed.`, { field: fileName });
903
+ }
904
+ return value;
905
+ }
906
+
907
+ /** Workspace data reaching a diagnostic: bounded before it is interpolated. */
908
+ function describeStoredSite(value) {
909
+ if (typeof value !== "string" || value === "") return "no site";
910
+ return `site ${value.slice(0, 64)}`;
911
+ }
912
+
913
+ /**
914
+ * Binds a workspace file to the site the current configuration and credential
915
+ * name.
916
+ *
917
+ * A workspace is a directory of files, and nothing about it says which site it
918
+ * came from except this. Point the configuration — or the key — at a second
919
+ * site and every page id, resource id, and image id in the manifest still reads
920
+ * as valid: `pages push` would plan site A's content onto site B, and the stale
921
+ * image ids would fail only once phase two was already writing. So the binding
922
+ * is checked before any planning and before any request.
923
+ *
924
+ * A file with no `siteId` at all is refused the same way. It can only be a
925
+ * workspace from before this check existed, and the package is in-repo and
926
+ * unreleased — a re-pull costs nothing, and guessing that an unbound manifest
927
+ * "is probably this site" is the exact assumption that makes the failure
928
+ * silent.
929
+ */
930
+ function requireManifestSite(parsed, expectedSiteId, fileName) {
931
+ if (typeof expectedSiteId !== "string" || expectedSiteId === "") {
932
+ throw new SiteAuthoringError(
933
+ "workspace.manifest_site_unknown",
934
+ `Reading '${fileName}' requires the site it must belong to.`,
935
+ { field: fileName },
936
+ );
937
+ }
938
+ if (parsed.siteId !== expectedSiteId) {
939
+ throw new SiteAuthoringError(
940
+ "workspace.manifest_site_mismatch",
941
+ `'${fileName}' records ${describeStoredSite(parsed.siteId)}, but this configuration targets site `
942
+ + `${expectedSiteId}. Run 'taproot-site pull' into a workspace of its own for this site.`,
943
+ { field: fileName },
944
+ );
945
+ }
946
+ return parsed;
947
+ }
948
+
949
+ /**
950
+ * What a workspace's pull manifest says about the site it belongs to, as one of
951
+ * four distinct states rather than a site id that may or may not be there:
952
+ *
953
+ * - `"absent"` — no manifest. A workspace nobody has pulled into yet.
954
+ * - `"unreadable"` — a manifest that is not parseable JSON, or not an object.
955
+ * - `"unbound"` — a manifest object that records no `siteId`.
956
+ * - `"bound"` — a manifest naming the site in `siteId`.
957
+ *
958
+ * The states matter because each carries a different obligation for the
959
+ * caller, and collapsing them to `undefined` hid that. `"bound"` is compared
960
+ * with the target site. `"unreadable"` and `"unbound"` cannot establish
961
+ * ownership — a truncated write leaves the first, a pre-binding pull left the
962
+ * second — and must be refused: proceeding rewrites the file with a new site
963
+ * id while the previous site's page sources survive beside it, and the next
964
+ * `pages push` then reads a manifest that agrees with itself, finds no
965
+ * entries for those files, and creates the first site's bodies as new pages
966
+ * on the second. `"absent"` alone is not proof of a fresh workspace either —
967
+ * a crash between the page writes and the manifest write produces it — so
968
+ * `pull` accepts it only after separately checking that no page sources exist
969
+ * and that every site-bound artifact present identifies the current site.
970
+ */
971
+ export async function inspectArtifactSiteBinding(workspaceDir, relativePath) {
972
+ if (!await workspaceFileExists(workspaceDir, relativePath)) return { state: "absent" };
973
+ let parsed;
974
+ try {
975
+ parsed = await readWorkspaceJson(workspaceDir, relativePath, WORKSPACE_LIMITS.manifestBytes);
976
+ } catch {
977
+ return { state: "unreadable" };
978
+ }
979
+ if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") return { state: "unreadable" };
980
+ return typeof parsed.siteId === "string" && parsed.siteId !== ""
981
+ ? { state: "bound", siteId: parsed.siteId }
982
+ : { state: "unbound" };
983
+ }
984
+
985
+ /** The pull manifest's own binding — the site a workspace was last pulled from. */
986
+ export async function inspectManifestSiteBinding(workspaceDir) {
987
+ return await inspectArtifactSiteBinding(workspaceDir, MANIFEST_FILE_NAME);
988
+ }
989
+
990
+ /**
991
+ * Reads the pull manifest. A push against a workspace that was never pulled is
992
+ * refused rather than guessed at: without the manifest the CLI cannot tell a
993
+ * new page from an edit of an existing one, and guessing wrong either
994
+ * duplicates a page or overwrites the wrong one.
995
+ */
996
+ export async function readManifest(workspaceDir, expectedSiteId, { required = true } = {}) {
997
+ if (!await workspaceFileExists(workspaceDir, MANIFEST_FILE_NAME)) {
998
+ if (!required) return undefined;
999
+ throw new SiteAuthoringError(
1000
+ "workspace.manifest_missing",
1001
+ `No ${MANIFEST_FILE_NAME} was found in the workspace. Run 'taproot-site pull' before pushing.`,
1002
+ { field: MANIFEST_FILE_NAME },
1003
+ );
1004
+ }
1005
+ const parsed = requireManifestObject(
1006
+ await readWorkspaceJson(workspaceDir, MANIFEST_FILE_NAME, WORKSPACE_LIMITS.manifestBytes),
1007
+ "workspace.manifest_invalid",
1008
+ "manifest",
1009
+ MANIFEST_FILE_NAME,
1010
+ );
1011
+ // Before the version and the shape: a manifest for another site is the wrong
1012
+ // file entirely, and saying so is more use than complaining about its
1013
+ // contents. A mismatch refuses even when the manifest was optional.
1014
+ requireManifestSite(parsed, expectedSiteId, MANIFEST_FILE_NAME);
1015
+ if (parsed.manifestVersion !== MANIFEST_VERSION) {
1016
+ throw new SiteAuthoringError(
1017
+ "workspace.manifest_version",
1018
+ `${MANIFEST_FILE_NAME} declares an unsupported manifestVersion. Run 'taproot-site pull' again.`,
1019
+ { field: "manifestVersion" },
1020
+ );
1021
+ }
1022
+ if (!Array.isArray(parsed.pages)) {
1023
+ throw new SiteAuthoringError(
1024
+ "workspace.manifest_invalid",
1025
+ `${MANIFEST_FILE_NAME} does not record a page list. Run 'taproot-site pull' again.`,
1026
+ { field: "pages" },
1027
+ );
1028
+ }
1029
+ return parsed;
1030
+ }
1031
+
1032
+ export async function writeManifest(workspaceDir, manifest) {
1033
+ return await writeWorkspaceJson(workspaceDir, MANIFEST_FILE_NAME, manifest);
1034
+ }
1035
+
1036
+ /**
1037
+ * The media manifest is separate from the pull manifest on purpose: uploading
1038
+ * media is legitimate in a workspace that has never been pulled, and folding it
1039
+ * into the pull manifest would make `media upload` mint a manifest that
1040
+ * `pages push` would then read as proof of a pull that never happened.
1041
+ */
1042
+ export async function readMediaManifest(workspaceDir, expectedSiteId) {
1043
+ if (!await workspaceFileExists(workspaceDir, MEDIA_MANIFEST_FILE_NAME)) {
1044
+ if (typeof expectedSiteId !== "string" || expectedSiteId === "") {
1045
+ throw new SiteAuthoringError(
1046
+ "workspace.manifest_site_unknown",
1047
+ `Reading '${MEDIA_MANIFEST_FILE_NAME}' requires the site it must belong to.`,
1048
+ { field: MEDIA_MANIFEST_FILE_NAME },
1049
+ );
1050
+ }
1051
+ return { mediaManifestVersion: MEDIA_MANIFEST_VERSION, siteId: expectedSiteId, media: {} };
1052
+ }
1053
+ const parsed = requireManifestObject(
1054
+ await readWorkspaceJson(workspaceDir, MEDIA_MANIFEST_FILE_NAME, WORKSPACE_LIMITS.manifestBytes),
1055
+ "workspace.media_manifest_invalid",
1056
+ "media manifest",
1057
+ MEDIA_MANIFEST_FILE_NAME,
1058
+ );
1059
+ // Image ids are site-scoped, so a media manifest from another site resolves
1060
+ // every reference to something this site cannot deliver — and only finds out
1061
+ // once `pages push` is already writing pages.
1062
+ requireManifestSite(parsed, expectedSiteId, MEDIA_MANIFEST_FILE_NAME);
1063
+ if (parsed.mediaManifestVersion !== MEDIA_MANIFEST_VERSION) {
1064
+ throw new SiteAuthoringError(
1065
+ "workspace.media_manifest_version",
1066
+ `${MEDIA_MANIFEST_FILE_NAME} declares an unsupported mediaManifestVersion. `
1067
+ + "Run 'taproot-site media upload' again to rewrite it.",
1068
+ { field: "mediaManifestVersion" },
1069
+ );
1070
+ }
1071
+ return {
1072
+ mediaManifestVersion: MEDIA_MANIFEST_VERSION,
1073
+ siteId: expectedSiteId,
1074
+ media: requireManifestObject(
1075
+ parsed.media ?? {},
1076
+ "workspace.media_manifest_invalid",
1077
+ "media map",
1078
+ MEDIA_MANIFEST_FILE_NAME,
1079
+ ),
1080
+ };
1081
+ }
1082
+
1083
+ export async function writeMediaManifest(workspaceDir, mediaManifest) {
1084
+ return await writeWorkspaceJson(workspaceDir, MEDIA_MANIFEST_FILE_NAME, mediaManifest);
1085
+ }