@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,319 @@
1
+ import {
2
+ confirmImageUpload,
3
+ IMAGE_OWNERSHIP_SCOPE_SITE,
4
+ IMAGE_PROCESSING_STATE_COMPLETE,
5
+ IMAGE_PROCESSING_STATE_FAILED,
6
+ listSiteImages,
7
+ normalizeImage,
8
+ poll,
9
+ requestImageUpload,
10
+ withRefusalGuidance,
11
+ } from "../api.js";
12
+ import { LIMITS, VERB_MEDIA_UPLOAD } from "../constants.js";
13
+ import { SiteAuthoringError } from "../errors.js";
14
+ import { contentHash, inspectImageBytes } from "../image-metadata.js";
15
+ import { boundedList, openSession, successResult } from "../session.js";
16
+ import {
17
+ ensureWorkspaceRoot,
18
+ inspectWorkspaceEntry,
19
+ MEDIA_DIRECTORY,
20
+ MEDIA_MANIFEST_FILE_NAME,
21
+ MEDIA_MANIFEST_VERSION,
22
+ readMediaManifest,
23
+ readWorkspaceFile,
24
+ SAFE_MEDIA_SEGMENT,
25
+ walkWorkspaceFiles,
26
+ WORKSPACE_LIMITS,
27
+ writeMediaManifest,
28
+ } from "../workspace.js";
29
+
30
+ /**
31
+ * `media upload` — hash, request, PUT, confirm, then wait for processing.
32
+ *
33
+ * The client computes the SHA-256 and the pixel dimensions before it asks for
34
+ * anything, because `RequestImageUpload` needs all three: the hash drives the
35
+ * dedup short-circuit, and the dimensions are recorded with the image. When the
36
+ * response reports `isDuplicate`, the presigned PUT and the confirm are both
37
+ * skipped and the returned image is used as-is — re-uploading identical bytes
38
+ * would spend the site's storage budget to produce the row it already has.
39
+ *
40
+ * Otherwise the signed capability goes to `SiteApiClient.upload`, which echoes
41
+ * `requiredHeaders` verbatim: those values are covered by the signature, so a
42
+ * regenerated `Content-Type` turns into an opaque object-store 403.
43
+ *
44
+ * Processing is observed through `ListSiteImages`; `GetImageById` is
45
+ * session-only under TR00602's read list.
46
+ *
47
+ * What gets uploaded comes from the positional arguments — files, directories,
48
+ * or both — and from the workspace's `media/` directory when none are given.
49
+ *
50
+ * The workspace media manifest this writes is what `pages push` resolves image
51
+ * references against, and it is deliberately a separate file from the pull
52
+ * manifest: uploading media into a workspace that was never pulled is
53
+ * legitimate, and minting a pull manifest here would let `pages push` mistake
54
+ * it for evidence of a pull that never happened.
55
+ */
56
+
57
+ const MAXIMUM_REPORTED = 200;
58
+ const MAXIMUM_FILES = 500;
59
+ const MEDIA_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp"];
60
+ const MEDIA_WALK_OPTIONS = Object.freeze({
61
+ segmentPattern: SAFE_MEDIA_SEGMENT,
62
+ segmentDescription: "letters, digits, '.', '_', '-', and '@'",
63
+ });
64
+
65
+ function requireUploadCapability(response, fileName) {
66
+ if (
67
+ typeof response.presignedUrl !== "string"
68
+ || response.presignedUrl === ""
69
+ || typeof response.uploadId !== "string"
70
+ || response.uploadId === ""
71
+ ) {
72
+ throw new SiteAuthoringError(
73
+ "media.upload_capability_invalid",
74
+ `Taproot did not return a usable upload capability for '${fileName}'.`,
75
+ { field: fileName },
76
+ );
77
+ }
78
+ return { url: response.presignedUrl, requiredHeaders: response.requiredHeaders };
79
+ }
80
+
81
+ function fileNameOf(relativePath) {
82
+ return relativePath.slice(relativePath.lastIndexOf("/") + 1);
83
+ }
84
+
85
+ /**
86
+ * Normalizes one positional into the workspace-relative form the workspace
87
+ * helpers accept: `./media/hero.png` and `media/` and `media` all name the same
88
+ * thing to a person typing them, and none of the three should be a different
89
+ * outcome.
90
+ */
91
+ function normalizePositional(value) {
92
+ return typeof value === "string" ? value.replace(/^\.\//u, "").replace(/\/+$/u, "") : "";
93
+ }
94
+
95
+ /**
96
+ * Expands the positional arguments into the concrete files to upload. A
97
+ * directory is walked for media the way a bare invocation walks `media/`; a
98
+ * regular file is taken as named, and its container is validated from its bytes
99
+ * rather than its extension. Anything else — a missing path, a symlink, a
100
+ * device — is refused by name rather than surfacing later as an unrelated
101
+ * "not a regular file" from the read.
102
+ */
103
+ async function resolveUploadTargets(workspaceDir, positionals) {
104
+ const files = [];
105
+ const seen = new Set();
106
+ for (const positional of positionals) {
107
+ const candidate = normalizePositional(positional);
108
+ if (candidate === "") {
109
+ throw new SiteAuthoringError(
110
+ "media.path_invalid",
111
+ `'${positional}' does not name a file or directory relative to the workspace root.`,
112
+ { field: positional },
113
+ );
114
+ }
115
+ const kind = await inspectWorkspaceEntry(workspaceDir, candidate);
116
+ if (kind === "missing" || kind === "other") {
117
+ throw new SiteAuthoringError(
118
+ "media.path_invalid",
119
+ `'${candidate}' is neither a media file nor a directory relative to the workspace root.`,
120
+ { field: candidate },
121
+ );
122
+ }
123
+ const expanded = kind === "directory"
124
+ ? await walkWorkspaceFiles(workspaceDir, candidate, MEDIA_EXTENSIONS, MEDIA_WALK_OPTIONS)
125
+ : [candidate];
126
+ for (const file of expanded) {
127
+ // Overlapping positionals — a directory and a file inside it — upload
128
+ // each file once.
129
+ if (seen.has(file)) continue;
130
+ seen.add(file);
131
+ files.push(file);
132
+ }
133
+ }
134
+ return files;
135
+ }
136
+
137
+ async function waitForProcessing(client, siteId, imageIds, { onProgress, now }) {
138
+ const pending = new Set(imageIds);
139
+ if (pending.size === 0) return new Map();
140
+ const observed = new Map();
141
+ return await poll({
142
+ client,
143
+ now,
144
+ onProgress,
145
+ timeoutMilliseconds: LIMITS.uploadMilliseconds,
146
+ // The poller's `{ deadline, now }` travels into every page of the listing,
147
+ // so the bound holds *within* one read of a large library and not only
148
+ // between reads.
149
+ read: async (requestOptions) => (await listSiteImages(client, siteId, requestOptions)).images,
150
+ evaluate: (images) => {
151
+ for (const image of images) {
152
+ if (!pending.has(image.imageId)) continue;
153
+ observed.set(image.imageId, image);
154
+ }
155
+ const waiting = [...pending].filter((imageId) => {
156
+ const state = observed.get(imageId)?.processingState;
157
+ return state !== IMAGE_PROCESSING_STATE_COMPLETE && state !== IMAGE_PROCESSING_STATE_FAILED;
158
+ });
159
+ if (waiting.length === 0) return { done: true, value: observed };
160
+ return { done: false, progress: `Waiting for ${waiting.length} image(s) to finish processing.` };
161
+ },
162
+ timeoutCode: "media.processing_timeout",
163
+ });
164
+ }
165
+
166
+ export async function mediaUpload(invocation) {
167
+ const { client, config, siteId, now, onProgress } = await openSession(invocation);
168
+ await ensureWorkspaceRoot(config);
169
+
170
+ // Positional arguments name the files or directories to upload — `cli.js`
171
+ // hands them over as `paths`, and a programmatic caller can supply the same
172
+ // key directly. With none, the workspace's own `media/` directory is walked.
173
+ // Read before anything is selected or sent: a media manifest from another
174
+ // site holds image ids this site cannot deliver, and appending to it would
175
+ // mix two sites' media in one file.
176
+ const mediaManifest = await readMediaManifest(config.workspaceDir, siteId);
177
+
178
+ const selected = Array.isArray(invocation.paths) ? invocation.paths.filter((value) => value !== "") : [];
179
+ const files = selected.length > 0
180
+ ? await resolveUploadTargets(config.workspaceDir, selected)
181
+ : await walkWorkspaceFiles(config.workspaceDir, MEDIA_DIRECTORY, MEDIA_EXTENSIONS, MEDIA_WALK_OPTIONS);
182
+ if (files.length === 0) {
183
+ throw new SiteAuthoringError(
184
+ "media.none_found",
185
+ selected.length > 0
186
+ ? `No PNG, JPEG, GIF, or WebP files were found under ${selected.map((value) => `'${value}'`).join(", ")}.`
187
+ : `No PNG, JPEG, GIF, or WebP files were found under '${MEDIA_DIRECTORY}/' in the workspace.`,
188
+ { field: selected.length > 0 ? normalizePositional(selected[0]) : MEDIA_DIRECTORY },
189
+ );
190
+ }
191
+ if (files.length > MAXIMUM_FILES) {
192
+ throw new SiteAuthoringError(
193
+ "media.too_many_files",
194
+ `This run would upload ${files.length} files, more than the bounded maximum of ${MAXIMUM_FILES}.`,
195
+ { field: MEDIA_DIRECTORY },
196
+ );
197
+ }
198
+
199
+ const uploaded = [];
200
+
201
+ return await withRefusalGuidance(onProgress, "upload", async () => {
202
+ try {
203
+ for (const file of files) {
204
+ const fileName = fileNameOf(file);
205
+ const bytes = await readWorkspaceFile(config.workspaceDir, file, WORKSPACE_LIMITS.mediaBytes);
206
+ const { contentType, width, height } = inspectImageBytes(bytes, file);
207
+ const hash = contentHash(bytes);
208
+ onProgress(`Requesting an upload for '${file}' (${contentType}, ${width}x${height}).`);
209
+ const response = await requestImageUpload(client, {
210
+ fileName,
211
+ contentType,
212
+ fileSize: bytes.byteLength,
213
+ contentHash: hash,
214
+ width,
215
+ height,
216
+ ownershipScope: IMAGE_OWNERSHIP_SCOPE_SITE,
217
+ siteId,
218
+ });
219
+
220
+ let image;
221
+ let deduplicated = false;
222
+ if (response.isDuplicate === true) {
223
+ // The dedup hit *is* the existing image. There is nothing to PUT and
224
+ // nothing to confirm.
225
+ deduplicated = true;
226
+ image = normalizeImage(response.image);
227
+ onProgress(`'${file}' matched an existing image by content hash; skipping the upload.`);
228
+ } else {
229
+ await client.upload(requireUploadCapability(response, file), bytes);
230
+ const confirmation = await confirmImageUpload(client, response.uploadId);
231
+ image = normalizeImage(confirmation.image);
232
+ onProgress(`Uploaded and confirmed '${file}'.`);
233
+ }
234
+
235
+ mediaManifest.media[file] = {
236
+ imageId: image.imageId,
237
+ contentHash: hash,
238
+ contentType,
239
+ width: width || image.width,
240
+ height: height || image.height,
241
+ byteLength: bytes.byteLength,
242
+ // Preserve the author-owned alt text. Delivery fields start with
243
+ // whatever confirm returned and are refreshed from the completed
244
+ // library record below.
245
+ alt: typeof mediaManifest.media[file]?.alt === "string" ? mediaManifest.media[file].alt : "",
246
+ src: image.url,
247
+ urls: image.responsiveUrls,
248
+ deduplicated,
249
+ };
250
+ uploaded.push({ file, imageId: image.imageId, deduplicated, contentType, width, height });
251
+ }
252
+ } finally {
253
+ // Written before processing is awaited: an image that fails to process
254
+ // still exists, and losing its id would orphan it. The site is recorded
255
+ // with it, because an image id means nothing without one.
256
+ mediaManifest.mediaManifestVersion = MEDIA_MANIFEST_VERSION;
257
+ mediaManifest.siteId = siteId;
258
+ if (uploaded.length > 0) await writeMediaManifest(config.workspaceDir, mediaManifest);
259
+ }
260
+
261
+ onProgress("Waiting for image processing to finish.");
262
+ const observed = await waitForProcessing(client, siteId, uploaded.map((entry) => entry.imageId), {
263
+ onProgress,
264
+ now,
265
+ });
266
+ const failed = uploaded
267
+ .map((entry) => ({ ...entry, processingState: observed.get(entry.imageId)?.processingState }))
268
+ .filter((entry) => entry.processingState === IMAGE_PROCESSING_STATE_FAILED);
269
+ if (failed.length > 0) {
270
+ throw new SiteAuthoringError(
271
+ "media.processing_failed",
272
+ `Taproot failed to process ${failed.length} uploaded image(s), starting with '${failed[0].file}'. `
273
+ + `They are recorded in ${MEDIA_MANIFEST_FILE_NAME} but cannot be published.`,
274
+ { field: failed[0].file, status: IMAGE_PROCESSING_STATE_FAILED },
275
+ );
276
+ }
277
+
278
+ // Processing is the point at which the delivery contract is complete.
279
+ // Persist and report the exact object shape component media fields accept,
280
+ // so an author never has to invent empty `src`/`urls` placeholders.
281
+ for (const entry of uploaded) {
282
+ const image = observed.get(entry.imageId);
283
+ const manifestEntry = mediaManifest.media[entry.file];
284
+ if (image === undefined || manifestEntry === undefined) continue;
285
+ manifestEntry.src = image.url;
286
+ manifestEntry.urls = image.responsiveUrls;
287
+ }
288
+ await writeMediaManifest(config.workspaceDir, mediaManifest);
289
+
290
+ const reported = boundedList(
291
+ uploaded.map((entry) => ({
292
+ file: entry.file,
293
+ imageId: entry.imageId,
294
+ deduplicated: entry.deduplicated,
295
+ width: entry.width,
296
+ height: entry.height,
297
+ processingState: observed.get(entry.imageId)?.processingState ?? IMAGE_PROCESSING_STATE_COMPLETE,
298
+ media: {
299
+ imageId: entry.imageId,
300
+ src: observed.get(entry.imageId)?.url ?? "",
301
+ urls: observed.get(entry.imageId)?.responsiveUrls ?? [],
302
+ width: entry.width,
303
+ height: entry.height,
304
+ alt: mediaManifest.media[entry.file]?.alt ?? "",
305
+ },
306
+ })),
307
+ MAXIMUM_REPORTED,
308
+ );
309
+ return successResult(VERB_MEDIA_UPLOAD, siteId, {
310
+ mediaManifestFile: MEDIA_MANIFEST_FILE_NAME,
311
+ media: {
312
+ total: uploaded.length,
313
+ deduplicated: uploaded.filter((entry) => entry.deduplicated).length,
314
+ items: reported.items,
315
+ ...(reported.truncated ? { itemsTruncated: true } : {}),
316
+ },
317
+ });
318
+ });
319
+ }
@@ -0,0 +1,333 @@
1
+ import {
2
+ getNavigation,
3
+ listSitePages,
4
+ NAV_ITEM_KIND_EXTERNAL_URL,
5
+ NAV_ITEM_KIND_PAGE,
6
+ NAV_ITEM_KINDS,
7
+ NAVIGATION_MAXIMUM_DEPTH,
8
+ saveNavigation,
9
+ withRefusalGuidance,
10
+ } from "../api.js";
11
+ import { VERB_NAV_PUSH } from "../constants.js";
12
+ import { SiteAuthoringError } from "../errors.js";
13
+ import { boundedList, openSession, successResult } from "../session.js";
14
+ import {
15
+ NAVIGATION_FILE_NAME,
16
+ readWorkspaceJson,
17
+ workspaceFileExists,
18
+ WORKSPACE_LIMITS,
19
+ } from "../workspace.js";
20
+
21
+ /**
22
+ * `nav push` — replace the whole navigation tree.
23
+ *
24
+ * `SaveSiteNavigation` is a whole-tree PUT with no concurrency token, so it is
25
+ * last-write-wins against a browser session editing the same tree. Two things
26
+ * follow, and the body does both:
27
+ *
28
+ * 1. The tree is re-read immediately before the write, so the window in which
29
+ * someone else's edit can be lost is as small as the contract allows, and
30
+ * what the replace is about to remove is *named* rather than discovered
31
+ * afterwards.
32
+ * 2. Local validation is exhaustive and refuses by item path. The server stores
33
+ * what it is given; a `PAGE` item carrying `pageId` instead of `resourceId`,
34
+ * or a fourth level of nesting, is accepted and then renders wrong. And the
35
+ * one shape it does check — `id` and `resourceId` are parsed as GUIDs — it
36
+ * refuses with a bare `InvalidArgument` naming no field at all, so a single
37
+ * `id: "home"` in a hundred-item tree would come back as an unclassified 400
38
+ * with nothing to grep for. A `resourceId` that is a valid UUID pointing at
39
+ * no page is the same story with a bare `ResourceId`, so PAGE targets are
40
+ * checked against the live page list too. Naming `navItems[1].children[0]`
41
+ * is the difference between a fixable error and a hunt.
42
+ */
43
+
44
+ const MAXIMUM_REPORTED = 100;
45
+ const MAXIMUM_ITEMS = 1_000;
46
+ const MAXIMUM_TITLE_LENGTH = 200;
47
+ const MAXIMUM_URL_LENGTH = 2_048;
48
+ const ITEM_KEYS = new Set(["id", "kind", "title", "resourceId", "externalUrl", "children"]);
49
+
50
+ // `SaveSiteNavigation` maps both `id` and `resourceId` through
51
+ // `RequestValidation.RequireGuid`. A value that is not a GUID comes back as a
52
+ // bare `InvalidArgument` carrying no field violation at all — an unclassified
53
+ // 400 with nothing to say which of a hundred items was wrong. This verb's whole
54
+ // contract is that a malformed tree is named locally, by item path, so the
55
+ // shape is checked here rather than discovered there.
56
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
57
+
58
+ function navError(code, message, itemPath) {
59
+ return new SiteAuthoringError(code, `${itemPath}: ${message}`, { field: itemPath });
60
+ }
61
+
62
+ function isBoundedText(value, maximum) {
63
+ return typeof value === "string" && value.length > 0 && value.length <= maximum;
64
+ }
65
+
66
+ function isUuid(value) {
67
+ return typeof value === "string" && UUID.test(value);
68
+ }
69
+
70
+ function isEmpty(value) {
71
+ return value === undefined || value === null || value === "";
72
+ }
73
+
74
+ function validateItem(item, itemPath, depth, seenIds, counters) {
75
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
76
+ throw navError("nav.item_invalid", "a navigation item must be an object.", itemPath);
77
+ }
78
+ if (depth > NAVIGATION_MAXIMUM_DEPTH) {
79
+ throw navError(
80
+ "nav.depth_exceeded",
81
+ `navigation nests deeper than ${NAVIGATION_MAXIMUM_DEPTH} levels.`,
82
+ itemPath,
83
+ );
84
+ }
85
+ counters.items += 1;
86
+ if (counters.items > MAXIMUM_ITEMS) {
87
+ throw navError("nav.too_many_items", `the navigation tree holds more than ${MAXIMUM_ITEMS} items.`, itemPath);
88
+ }
89
+
90
+ const unknown = Object.keys(item).filter((key) => !ITEM_KEYS.has(key)).sort();
91
+ if (unknown.length > 0) {
92
+ // `pageId` is the specific mistake this catches: the navigation contract
93
+ // targets a page by its *site resource* id, and a `pageId` key is stored
94
+ // and then silently ignored.
95
+ throw navError(
96
+ "nav.unknown_field",
97
+ unknown.includes("pageId")
98
+ ? "a PAGE item targets 'resourceId', never 'pageId'."
99
+ : `unsupported navigation field '${unknown[0]}'.`,
100
+ itemPath,
101
+ );
102
+ }
103
+ if (!isUuid(item.id)) {
104
+ throw navError(
105
+ "nav.id_invalid",
106
+ "every navigation item needs a stable 'id' that is a canonical lowercase UUID; Taproot parses it as one.",
107
+ itemPath,
108
+ );
109
+ }
110
+ if (seenIds.has(item.id)) {
111
+ throw navError("nav.id_duplicate", `navigation id '${item.id}' appears more than once in the tree.`, itemPath);
112
+ }
113
+ seenIds.add(item.id);
114
+ if (typeof item.kind !== "string" || !NAV_ITEM_KINDS.includes(item.kind)) {
115
+ throw navError(
116
+ "nav.kind_invalid",
117
+ `'kind' must be one of ${NAV_ITEM_KINDS.join(", ")}.`,
118
+ itemPath,
119
+ );
120
+ }
121
+ if (!isBoundedText(item.title, MAXIMUM_TITLE_LENGTH)) {
122
+ throw navError("nav.title_invalid", "every navigation item needs a non-empty 'title'.", itemPath);
123
+ }
124
+
125
+ if (item.kind === NAV_ITEM_KIND_PAGE) {
126
+ if (!isUuid(item.resourceId)) {
127
+ throw navError(
128
+ "nav.resource_missing",
129
+ "a PAGE item needs the target page's 'resourceId' — the canonical lowercase UUID 'pull' records in the "
130
+ + "manifest, not the page path or pageId.",
131
+ itemPath,
132
+ );
133
+ }
134
+ if (!isEmpty(item.externalUrl)) {
135
+ throw navError("nav.external_url_unexpected", "a PAGE item must not carry 'externalUrl'.", itemPath);
136
+ }
137
+ // Kept with its item path so the liveness check below can still name the
138
+ // item; by then the tree has been rebuilt and the paths are gone.
139
+ counters.pageReferences.push({ itemPath, resourceId: item.resourceId });
140
+ } else if (item.kind === NAV_ITEM_KIND_EXTERNAL_URL) {
141
+ if (!isBoundedText(item.externalUrl, MAXIMUM_URL_LENGTH)) {
142
+ throw navError("nav.external_url_missing", "an EXTERNAL_URL item needs a non-empty 'externalUrl'.", itemPath);
143
+ }
144
+ let url;
145
+ try {
146
+ url = new URL(item.externalUrl);
147
+ } catch {
148
+ url = undefined;
149
+ }
150
+ if (url === undefined || (url.protocol !== "https:" && url.protocol !== "http:")) {
151
+ throw navError("nav.external_url_invalid", "'externalUrl' must be an absolute http or https URL.", itemPath);
152
+ }
153
+ if (!isEmpty(item.resourceId)) {
154
+ throw navError("nav.resource_unexpected", "an EXTERNAL_URL item must not carry 'resourceId'.", itemPath);
155
+ }
156
+ } else if (!isEmpty(item.resourceId) || !isEmpty(item.externalUrl)) {
157
+ throw navError(
158
+ "nav.target_unexpected",
159
+ "a GROUP_HEADER item is a label and must carry neither 'resourceId' nor 'externalUrl'.",
160
+ itemPath,
161
+ );
162
+ }
163
+
164
+ const children = item.children ?? [];
165
+ if (!Array.isArray(children)) {
166
+ throw navError("nav.children_invalid", "'children' must be a list when it is present.", itemPath);
167
+ }
168
+ const validated = children.map((child, index) =>
169
+ validateItem(child, `${itemPath}.children[${index}]`, depth + 1, seenIds, counters));
170
+ // Rebuilt field by field so nothing outside the contract can ride along, and
171
+ // so an absent optional crosses the wire as the zero value proto3 expects.
172
+ return {
173
+ id: item.id,
174
+ kind: item.kind,
175
+ title: item.title,
176
+ resourceId: item.kind === NAV_ITEM_KIND_PAGE ? item.resourceId : "",
177
+ externalUrl: item.kind === NAV_ITEM_KIND_EXTERNAL_URL ? item.externalUrl : "",
178
+ children: validated,
179
+ };
180
+ }
181
+
182
+ function collectIds(items, into = new Set()) {
183
+ for (const item of items) {
184
+ if (item === null || typeof item !== "object") continue;
185
+ if (typeof item.id === "string") into.add(item.id);
186
+ if (Array.isArray(item.children)) collectIds(item.children, into);
187
+ }
188
+ return into;
189
+ }
190
+
191
+ function requireKnownPageTargets(pageReferences, knownResourceIds, knownPageIds) {
192
+ for (const reference of pageReferences) {
193
+ if (knownResourceIds.has(reference.resourceId)) continue;
194
+ throw navError(
195
+ "nav.resource_unknown",
196
+ knownPageIds.has(reference.resourceId)
197
+ ? `'${reference.resourceId}' is a pageId, not a site resourceId. Navigation targets resource_id — use the `
198
+ + "'resourceId' that 'pull' records beside 'pageId' on the same manifest entry."
199
+ : `'${reference.resourceId}' does not name a page on this site. Use the 'resourceId' that 'pull' records `
200
+ + "in the manifest, and re-run 'pull' if the page was created elsewhere.",
201
+ reference.itemPath,
202
+ );
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Refuses a PAGE item whose `resourceId` is well-formed but points at nothing.
208
+ *
209
+ * The shape check upstream only proves the value is a UUID. A UUID that names
210
+ * no page on this site is accepted by every local rule and then refused by the
211
+ * server with a bare `ResourceId` — no index, no item path, nothing to locate
212
+ * across a tree that may hold a thousand items. Since the ids come from the
213
+ * manifest, where `pageId` and `resourceId` sit next to each other on the same
214
+ * entry, the overwhelmingly likely mistake is reaching for the wrong one; when
215
+ * the value turns out to be a live `pageId`, the message says so instead of
216
+ * repeating "not found".
217
+ *
218
+ * A page list this CLI could not fully enumerate produces no refusal at all: an
219
+ * incomplete set would reject valid trees, and a false refusal here is worse
220
+ * than the server's vague one.
221
+ */
222
+ async function requireLivePageTargets(client, siteId, pageReferences, onProgress) {
223
+ if (pageReferences.length === 0) return;
224
+ onProgress(`Checking ${pageReferences.length} PAGE target(s) against the site's pages.`);
225
+ const { pages, truncated } = await listSitePages(client, siteId, { onProgress });
226
+ if (truncated) {
227
+ onProgress("The page list was bounded before the site was fully enumerated; PAGE targets are not checked.");
228
+ return;
229
+ }
230
+ const liveResourceIds = new Set(pages.map((summary) => summary.resourceId).filter(Boolean));
231
+ const livePageIds = new Set(pages.map((summary) => summary.pageId));
232
+ requireKnownPageTargets(pageReferences, liveResourceIds, livePageIds);
233
+ }
234
+
235
+ /** The complete local validation phase shared by live push and offline fixtures. */
236
+ export function validateNavigationWorkspaceDocument(parsed, siteId, knownPages) {
237
+ // The pulled shape, and only the pulled shape. A bare array carries no site,
238
+ // and this verb replaces a whole navigation tree in one unversioned write.
239
+ if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") {
240
+ throw new SiteAuthoringError(
241
+ "nav.file_invalid",
242
+ `${NAVIGATION_FILE_NAME} must be the object 'pull' writes — { "siteId": ..., "navItems": [...] } — `
243
+ + "not a bare list.",
244
+ { field: NAVIGATION_FILE_NAME },
245
+ );
246
+ }
247
+ if (parsed.siteId !== siteId) {
248
+ throw new SiteAuthoringError(
249
+ "nav.file_site_mismatch",
250
+ `${NAVIGATION_FILE_NAME} records ${
251
+ typeof parsed.siteId === "string" && parsed.siteId !== "" ? `site ${parsed.siteId.slice(0, 64)}` : "no site"
252
+ }, but this configuration targets site ${siteId}. Run 'taproot-site pull' into a workspace of its own `
253
+ + "for this site.",
254
+ { field: NAVIGATION_FILE_NAME },
255
+ );
256
+ }
257
+ const declared = parsed.navItems;
258
+ if (!Array.isArray(declared)) {
259
+ throw new SiteAuthoringError(
260
+ "nav.file_invalid",
261
+ `${NAVIGATION_FILE_NAME} must hold a 'navItems' list.`,
262
+ { field: "navItems" },
263
+ );
264
+ }
265
+
266
+ const counters = { items: 0, pageReferences: [] };
267
+ const seenIds = new Set();
268
+ const navItems = declared.map((item, index) =>
269
+ validateItem(item, `navItems[${index}]`, 1, seenIds, counters)
270
+ );
271
+ if (knownPages !== undefined) {
272
+ requireKnownPageTargets(counters.pageReferences, knownPages.resourceIds, knownPages.pageIds);
273
+ }
274
+ return { navItems, items: counters.items, pageReferences: counters.pageReferences };
275
+ }
276
+
277
+ export async function navPush(invocation) {
278
+ const { client, config, siteId, onProgress } = await openSession(invocation);
279
+ if (!await workspaceFileExists(config.workspaceDir, NAVIGATION_FILE_NAME)) {
280
+ throw new SiteAuthoringError(
281
+ "nav.file_missing",
282
+ `No ${NAVIGATION_FILE_NAME} was found in the workspace. Run 'taproot-site pull' first.`,
283
+ { field: NAVIGATION_FILE_NAME },
284
+ );
285
+ }
286
+ const parsed = await readWorkspaceJson(config.workspaceDir, NAVIGATION_FILE_NAME, WORKSPACE_LIMITS.navigationBytes);
287
+ const validated = validateNavigationWorkspaceDocument(parsed, siteId);
288
+ const { navItems, pageReferences } = validated;
289
+ onProgress(`Validated ${validated.items} navigation items to depth ${NAVIGATION_MAXIMUM_DEPTH}.`);
290
+
291
+ return await withRefusalGuidance(onProgress, "navigation push", async () => {
292
+ await requireLivePageTargets(client, siteId, pageReferences, onProgress);
293
+
294
+ // Re-read immediately before the replace. There is no concurrency token to
295
+ // check, so this is the narrowest window the contract permits — and the
296
+ // only way to say what the replace removes. It happens *after* the target
297
+ // check so the window between reading the tree and replacing it stays as
298
+ // small as the contract allows.
299
+ onProgress("Re-reading the live navigation tree immediately before replacing it.");
300
+ const liveItems = await getNavigation(client, siteId);
301
+ const liveIds = collectIds(liveItems);
302
+ const pushedIds = collectIds(navItems);
303
+ const removed = [...liveIds].filter((id) => !pushedIds.has(id)).sort();
304
+ const added = [...pushedIds].filter((id) => !liveIds.has(id)).sort();
305
+ if (removed.length > 0) {
306
+ onProgress(
307
+ `This whole-tree replace removes ${removed.length} navigation item(s) that are live now: ${
308
+ removed.slice(0, 10).join(", ")
309
+ }${removed.length > 10 ? ", ..." : ""}.`,
310
+ );
311
+ }
312
+
313
+ const saved = await saveNavigation(client, siteId, navItems);
314
+ onProgress(`Replaced the navigation tree with ${validated.items} items.`);
315
+
316
+ const removedReport = boundedList(removed, MAXIMUM_REPORTED);
317
+ const addedReport = boundedList(added, MAXIMUM_REPORTED);
318
+ return successResult(VERB_NAV_PUSH, siteId, {
319
+ navigation: {
320
+ file: NAVIGATION_FILE_NAME,
321
+ items: validated.items,
322
+ rootItems: navItems.length,
323
+ savedRootItems: saved.length,
324
+ added: addedReport.items,
325
+ removed: removedReport.items,
326
+ ...(addedReport.truncated || removedReport.truncated ? { diffTruncated: true } : {}),
327
+ },
328
+ // Said plainly, because it is the risk this verb carries: there is no
329
+ // concurrency token on the navigation contract.
330
+ lastWriteWins: true,
331
+ });
332
+ });
333
+ }