@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.
- package/LICENSE +15 -0
- package/README.md +239 -0
- package/bin/taproot-site.js +5 -0
- package/package.json +40 -0
- package/src/api.js +1507 -0
- package/src/appearance-contract.js +311 -0
- package/src/atomic-file.js +136 -0
- package/src/bounded-file.js +59 -0
- package/src/capabilities.js +29 -0
- package/src/cli.js +872 -0
- package/src/config.js +412 -0
- package/src/constants.js +173 -0
- package/src/content/components.js +870 -0
- package/src/content/free-form-section-registry.json +524 -0
- package/src/content/free-form-sections.js +582 -0
- package/src/content/index.js +29 -0
- package/src/content/inline-facts.js +97 -0
- package/src/content/markdown.js +1625 -0
- package/src/content/tiptap-prosemirror.ts +1266 -0
- package/src/content/validate-document.js +1079 -0
- package/src/content/vocabulary.js +397 -0
- package/src/credentials.js +544 -0
- package/src/errors.js +133 -0
- package/src/footer-contract.js +687 -0
- package/src/footer-draft-hash.js +203 -0
- package/src/footer-workspace.js +136 -0
- package/src/image-metadata.js +169 -0
- package/src/index.js +16 -0
- package/src/json-guard.js +111 -0
- package/src/output.js +157 -0
- package/src/presentation-reference.js +600 -0
- package/src/reference-help.js +927 -0
- package/src/session.js +316 -0
- package/src/settings-catalog.js +187 -0
- package/src/settings.js +157 -0
- package/src/theme-validation.js +344 -0
- package/src/transport.js +777 -0
- package/src/verbs/approve.js +178 -0
- package/src/verbs/deploy.js +327 -0
- package/src/verbs/env.js +89 -0
- package/src/verbs/footer-push.js +115 -0
- package/src/verbs/index.js +125 -0
- package/src/verbs/login.js +529 -0
- package/src/verbs/logout.js +39 -0
- package/src/verbs/media-upload.js +319 -0
- package/src/verbs/nav-push.js +333 -0
- package/src/verbs/pages-push.js +990 -0
- package/src/verbs/preview-page.js +107 -0
- package/src/verbs/preview-revoke.js +56 -0
- package/src/verbs/pull.js +727 -0
- package/src/verbs/sites.js +57 -0
- package/src/verbs/status.js +94 -0
- package/src/verbs/theme-push.js +197 -0
- package/src/verbs/use.js +129 -0
- package/src/verbs/validate.js +477 -0
- package/src/verbs/whoami.js +108 -0
- package/src/workspace.js +1085 -0
package/src/workspace.js
ADDED
|
@@ -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
|
+
}
|