@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,477 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { NAVIGATION_MAXIMUM_DEPTH, TEMPLATE_TYPE_FREE_FORM } from "../api.js";
5
+ import { sharedThemeContextNames } from "../content/free-form-sections.js";
6
+ import { VERB_VALIDATE } from "../constants.js";
7
+ import { isCanonicalUuid, SiteAuthoringError } from "../errors.js";
8
+ import { FOOTER_SETTINGS_FILE } from "../footer-workspace.js";
9
+ import { boundedList, successResult } from "../session.js";
10
+ import { SETTINGS_GROUPS, SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES } from "../settings-catalog.js";
11
+ import { validateFooterWorkspaceDocument } from "./footer-push.js";
12
+ import { validateNavigationWorkspaceDocument } from "./nav-push.js";
13
+ import { validateWorkspacePageDocument, validateWorkspacePageSource } from "./pages-push.js";
14
+ import { validateThemeWorkspace } from "./theme-push.js";
15
+ import {
16
+ MANIFEST_VERSION,
17
+ NAVIGATION_FILE_NAME,
18
+ normalizePagePath,
19
+ PAGE_SOURCE_EXTENSIONS,
20
+ PAGE_WORKSPACE_MODE_EDITABLE,
21
+ pageSourceFormat,
22
+ PAGES_DIRECTORY,
23
+ readWorkspaceJson,
24
+ SETTINGS_DIRECTORY,
25
+ walkWorkspaceFiles,
26
+ WORKSPACE_LIMITS,
27
+ } from "../workspace.js";
28
+
29
+ export const FIXTURE_MANIFEST_FILE_NAME = "manifest.fixture.json";
30
+ export const FIXTURE_CONTRACT_VERSION = 1;
31
+ const MAXIMUM_REPORTED = 200;
32
+ const FIXTURE_ROOT_KEYS = new Set([
33
+ "manifestVersion",
34
+ "siteId",
35
+ "pulledAt",
36
+ "pages",
37
+ "pagesTruncated",
38
+ "navigation",
39
+ "settings",
40
+ "settingsSkipped",
41
+ "deployments",
42
+ "fixture",
43
+ ]);
44
+ const FIXTURE_METADATA_KEYS = new Set(["contractVersion", "imageIds", "deliveryOrigins"]);
45
+
46
+ function fail(code, message, field, exitCode) {
47
+ throw new SiteAuthoringError(code, message, { field, exitCode });
48
+ }
49
+
50
+ function isPlainObject(value) {
51
+ return value !== null && typeof value === "object" && !Array.isArray(value);
52
+ }
53
+
54
+ async function resolveFixtureRoot(cwd, fixturePath) {
55
+ if (typeof fixturePath !== "string" || fixturePath.length === 0) {
56
+ fail("validate.fixture_path_invalid", "validate requires exactly one fixture directory.", "fixturePath", 2);
57
+ }
58
+ const candidate = path.resolve(cwd, fixturePath);
59
+ let stat;
60
+ try {
61
+ stat = await lstat(candidate);
62
+ } catch (error) {
63
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") {
64
+ fail("fixture.not_found", "The selected authoring fixture directory does not exist.", "fixturePath");
65
+ }
66
+ fail(
67
+ "fixture.path_invalid",
68
+ "The selected authoring fixture directory could not be inspected.",
69
+ "fixturePath",
70
+ );
71
+ }
72
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
73
+ fail("fixture.path_invalid", "The selected authoring fixture must be a real directory, not a link or file.", "fixturePath");
74
+ }
75
+ try {
76
+ return await realpath(candidate);
77
+ } catch {
78
+ fail(
79
+ "fixture.path_invalid",
80
+ "The selected authoring fixture directory could not be inspected.",
81
+ "fixturePath",
82
+ );
83
+ }
84
+ }
85
+
86
+ function validateFixtureManifest(manifest) {
87
+ if (!isPlainObject(manifest)) {
88
+ fail("fixture.manifest_invalid", `${FIXTURE_MANIFEST_FILE_NAME} must contain one JSON object.`, FIXTURE_MANIFEST_FILE_NAME);
89
+ }
90
+ const unknownRoot = Object.keys(manifest).filter((key) => !FIXTURE_ROOT_KEYS.has(key)).sort();
91
+ if (unknownRoot.length > 0) {
92
+ fail(
93
+ "fixture.manifest_unknown_field",
94
+ `${FIXTURE_MANIFEST_FILE_NAME} declares unsupported field '${unknownRoot[0]}'.`,
95
+ unknownRoot[0],
96
+ );
97
+ }
98
+ if (manifest.manifestVersion !== MANIFEST_VERSION) {
99
+ fail(
100
+ "fixture.manifest_version",
101
+ `${FIXTURE_MANIFEST_FILE_NAME} must use page manifest version ${MANIFEST_VERSION}.`,
102
+ "manifestVersion",
103
+ );
104
+ }
105
+ if (!isCanonicalUuid(manifest.siteId)) {
106
+ fail("fixture.site_id_invalid", "The fixture siteId must be a deterministic canonical UUID.", "siteId");
107
+ }
108
+ if (!Array.isArray(manifest.pages) || manifest.pages.length === 0 || manifest.pages.length > WORKSPACE_LIMITS.files) {
109
+ fail("fixture.pages_invalid", "The fixture manifest must bind one or more bounded editable pages.", "pages");
110
+ }
111
+ if (manifest.pagesTruncated !== false) {
112
+ fail("fixture.pages_invalid", "A complete offline fixture cannot use a truncated page manifest.", "pagesTruncated");
113
+ }
114
+ if (
115
+ !isPlainObject(manifest.navigation)
116
+ || manifest.navigation.file !== NAVIGATION_FILE_NAME
117
+ || !Number.isSafeInteger(manifest.navigation.items)
118
+ || manifest.navigation.items < 0
119
+ ) {
120
+ fail(
121
+ "fixture.navigation_invalid",
122
+ `The fixture manifest must bind '${NAVIGATION_FILE_NAME}' and its non-negative item count.`,
123
+ "navigation",
124
+ );
125
+ }
126
+ if (!Array.isArray(manifest.settingsSkipped) || manifest.settingsSkipped.length !== 0) {
127
+ fail(
128
+ "fixture.settings_invalid",
129
+ "A complete offline fixture cannot omit an authorable settings group.",
130
+ "settingsSkipped",
131
+ );
132
+ }
133
+ if (!isPlainObject(manifest.fixture)) {
134
+ fail("fixture.metadata_invalid", "The fixture manifest must declare versioned offline metadata.", "fixture");
135
+ }
136
+ const unknownMetadata = Object.keys(manifest.fixture).filter((key) => !FIXTURE_METADATA_KEYS.has(key)).sort();
137
+ if (unknownMetadata.length > 0) {
138
+ fail(
139
+ "fixture.metadata_unknown_field",
140
+ `fixture declares unsupported field '${unknownMetadata[0]}'.`,
141
+ `fixture.${unknownMetadata[0]}`,
142
+ );
143
+ }
144
+ if (manifest.fixture.contractVersion !== FIXTURE_CONTRACT_VERSION) {
145
+ fail(
146
+ "fixture.contract_version",
147
+ `fixture.contractVersion must be ${FIXTURE_CONTRACT_VERSION}.`,
148
+ "fixture.contractVersion",
149
+ );
150
+ }
151
+ if (
152
+ !Array.isArray(manifest.fixture.imageIds)
153
+ || manifest.fixture.imageIds.length > WORKSPACE_LIMITS.files
154
+ || manifest.fixture.imageIds.some((value) => !isCanonicalUuid(value))
155
+ ) {
156
+ fail(
157
+ "fixture.image_ids_invalid",
158
+ "fixture.imageIds must be a bounded list of deterministic canonical UUIDs.",
159
+ "fixture.imageIds",
160
+ );
161
+ }
162
+ const imageIds = new Set(manifest.fixture.imageIds);
163
+ if (imageIds.size !== manifest.fixture.imageIds.length) {
164
+ fail("fixture.image_ids_invalid", "fixture.imageIds must not contain duplicates.", "fixture.imageIds");
165
+ }
166
+ if (!Array.isArray(manifest.fixture.deliveryOrigins) || manifest.fixture.deliveryOrigins.length > 100) {
167
+ fail(
168
+ "fixture.delivery_origins_invalid",
169
+ "fixture.deliveryOrigins must be a bounded list of reserved HTTPS example origins.",
170
+ "fixture.deliveryOrigins",
171
+ );
172
+ }
173
+ const deliveryOrigins = new Set();
174
+ for (const [index, value] of manifest.fixture.deliveryOrigins.entries()) {
175
+ let url;
176
+ try {
177
+ url = new URL(value);
178
+ } catch {
179
+ url = undefined;
180
+ }
181
+ if (
182
+ url === undefined
183
+ || url.protocol !== "https:"
184
+ || url.username !== ""
185
+ || url.password !== ""
186
+ || url.pathname !== "/"
187
+ || url.search !== ""
188
+ || url.hash !== ""
189
+ || !(url.hostname === "example.test" || url.hostname.endsWith(".example.test"))
190
+ ) {
191
+ fail(
192
+ "fixture.delivery_origins_invalid",
193
+ "Every fixture delivery origin must be an origin-only HTTPS URL under the reserved example.test domain.",
194
+ `fixture.deliveryOrigins[${index}]`,
195
+ );
196
+ }
197
+ deliveryOrigins.add(url.origin);
198
+ }
199
+ if (deliveryOrigins.size !== manifest.fixture.deliveryOrigins.length) {
200
+ fail(
201
+ "fixture.delivery_origins_invalid",
202
+ "fixture.deliveryOrigins must not contain duplicate origins.",
203
+ "fixture.deliveryOrigins",
204
+ );
205
+ }
206
+
207
+ if (!Array.isArray(manifest.settings) || manifest.settings.length !== SETTINGS_GROUPS.length) {
208
+ fail(
209
+ "fixture.settings_invalid",
210
+ `The fixture manifest must bind exactly ${SETTINGS_GROUPS.length} authorable settings documents.`,
211
+ "settings",
212
+ );
213
+ }
214
+ const settingsEntityIds = new Map();
215
+ for (const group of SETTINGS_GROUPS) {
216
+ const expectedFile = `${SETTINGS_DIRECTORY}/${group.file}`;
217
+ const entry = manifest.settings.find((candidate) => candidate?.settingsType === group.settingsType);
218
+ if (
219
+ !isPlainObject(entry)
220
+ || entry.file !== expectedFile
221
+ || !isCanonicalUuid(entry.entityId)
222
+ ) {
223
+ fail(
224
+ "fixture.settings_invalid",
225
+ `The fixture manifest must bind ${group.settingsType} to '${expectedFile}' and one deterministic entityId.`,
226
+ "settings",
227
+ );
228
+ }
229
+ settingsEntityIds.set(group.settingsType, entry.entityId);
230
+ }
231
+
232
+ const pageIds = new Set();
233
+ const resourceIds = new Set();
234
+ const files = new Set();
235
+ const paths = new Set();
236
+ for (const [index, entry] of manifest.pages.entries()) {
237
+ const field = `pages[${index}]`;
238
+ const pagePath = normalizePagePath(entry?.path);
239
+ const extension = typeof entry?.file === "string"
240
+ ? PAGE_SOURCE_EXTENSIONS.find((candidate) => entry.file.toLowerCase().endsWith(candidate))
241
+ : undefined;
242
+ if (
243
+ !isPlainObject(entry)
244
+ || !isCanonicalUuid(entry.pageId)
245
+ || !isCanonicalUuid(entry.resourceId)
246
+ || entry.templateType !== TEMPLATE_TYPE_FREE_FORM
247
+ || entry.workspaceMode !== PAGE_WORKSPACE_MODE_EDITABLE
248
+ || typeof entry.file !== "string"
249
+ || !entry.file.startsWith(`${PAGES_DIRECTORY}/`)
250
+ || extension === undefined
251
+ || pagePath === undefined
252
+ ) {
253
+ fail(
254
+ "fixture.page_invalid",
255
+ `${field} must bind one editable free-form source to deterministic page/resource identities and a usable path.`,
256
+ field,
257
+ );
258
+ }
259
+ // A fixture is an example of what pull writes, so it declares the source
260
+ // registry the same way. The file's extension decides the format; a
261
+ // recorded one that disagrees is describing a file that is not there.
262
+ if (entry.sourceFormat !== pageSourceFormat(entry.file)) {
263
+ fail(
264
+ "fixture.page_invalid",
265
+ `${field} must record sourceFormat '${pageSourceFormat(entry.file)}' for '${entry.file}'.`,
266
+ `${field}.sourceFormat`,
267
+ );
268
+ }
269
+ if (pageIds.has(entry.pageId) || resourceIds.has(entry.resourceId) || files.has(entry.file) || paths.has(pagePath)) {
270
+ fail(
271
+ "fixture.page_duplicate",
272
+ `${field} duplicates a pageId, resourceId, source file, or page path.`,
273
+ field,
274
+ );
275
+ }
276
+ pageIds.add(entry.pageId);
277
+ resourceIds.add(entry.resourceId);
278
+ files.add(entry.file);
279
+ paths.add(pagePath);
280
+ }
281
+ return { manifest, imageIds, deliveryOrigins, pageIds, resourceIds, files, settingsEntityIds };
282
+ }
283
+
284
+ function validateDeliveryUrl(value, field, deliveryOrigins) {
285
+ if (typeof value !== "string" || value === "" || value.startsWith("/") || value.startsWith("./")) return;
286
+ let url;
287
+ try {
288
+ url = new URL(value);
289
+ } catch {
290
+ return;
291
+ }
292
+ if (!deliveryOrigins.has(url.origin)) {
293
+ fail(
294
+ "fixture.delivery_origin_unknown",
295
+ `Fixture image delivery URL '${url.origin}' is not declared in fixture.deliveryOrigins.`,
296
+ field,
297
+ );
298
+ }
299
+ }
300
+
301
+ function validatePageImageReferences(value, field, knownImageIds, deliveryOrigins) {
302
+ if (Array.isArray(value)) {
303
+ value.forEach((entry, index) =>
304
+ validatePageImageReferences(entry, `${field}/${index}`, knownImageIds, deliveryOrigins)
305
+ );
306
+ return;
307
+ }
308
+ if (!isPlainObject(value)) return;
309
+
310
+ if (typeof value.imageId === "string" && (Object.hasOwn(value, "src") || Object.hasOwn(value, "urls"))) {
311
+ if (!knownImageIds.has(value.imageId)) {
312
+ fail(
313
+ "fixture.image_reference_unknown",
314
+ `Fixture image '${value.imageId}' is not declared in fixture.imageIds.`,
315
+ `${field}/imageId`,
316
+ );
317
+ }
318
+ validateDeliveryUrl(value.src, `${field}/src`, deliveryOrigins);
319
+ if (Array.isArray(value.urls)) {
320
+ for (const [index, candidate] of value.urls.entries()) {
321
+ validateDeliveryUrl(candidate?.url, `${field}/urls/${index}/url`, deliveryOrigins);
322
+ }
323
+ }
324
+ }
325
+
326
+ if (value.type === "componentBlock" && typeof value.attrs?.componentData === "string") {
327
+ // The shared document validator already proved this is valid JSON with the
328
+ // component's closed shape. Parsing it here only binds its image identities
329
+ // to the offline fixture metadata.
330
+ validatePageImageReferences(
331
+ JSON.parse(value.attrs.componentData),
332
+ `${field}/attrs/componentData`,
333
+ knownImageIds,
334
+ deliveryOrigins,
335
+ );
336
+ }
337
+ for (const [key, child] of Object.entries(value)) {
338
+ if (key === "componentData") continue;
339
+ validatePageImageReferences(child, `${field}/${key}`, knownImageIds, deliveryOrigins);
340
+ }
341
+ }
342
+
343
+ export async function validateFixture(invocation = {}) {
344
+ const onProgress = typeof invocation.onProgress === "function" ? invocation.onProgress : () => {};
345
+ const fixtureRoot = await resolveFixtureRoot(invocation.cwd ?? process.cwd(), invocation.fixturePath);
346
+ onProgress(`Reading ${FIXTURE_MANIFEST_FILE_NAME}.`);
347
+ const contract = validateFixtureManifest(
348
+ await readWorkspaceJson(fixtureRoot, FIXTURE_MANIFEST_FILE_NAME, WORKSPACE_LIMITS.manifestBytes),
349
+ );
350
+ const {
351
+ manifest,
352
+ imageIds,
353
+ deliveryOrigins,
354
+ pageIds,
355
+ resourceIds,
356
+ files: declaredFiles,
357
+ settingsEntityIds,
358
+ } = contract;
359
+
360
+ onProgress("Validating the complete light/dark theme, appearance, header, brand, and footer colors.");
361
+ const presentation = await validateThemeWorkspace(fixtureRoot, manifest.siteId, imageIds, settingsEntityIds);
362
+ for (const warning of presentation.themes.warnings) onProgress(`Espalier warning: ${warning}`);
363
+ const sharedContexts = sharedThemeContextNames(
364
+ presentation.style.lightTheme,
365
+ presentation.style.darkTheme,
366
+ );
367
+
368
+ const pageFiles = await walkWorkspaceFiles(fixtureRoot, PAGES_DIRECTORY, PAGE_SOURCE_EXTENSIONS);
369
+ const manifestByFile = new Map(manifest.pages.map((entry) => [entry.file, entry]));
370
+ for (const file of declaredFiles) {
371
+ if (!pageFiles.includes(file)) {
372
+ fail("fixture.page_missing", `The fixture manifest binds missing page source '${file}'.`, file);
373
+ }
374
+ }
375
+ const untracked = pageFiles.find((file) => !declaredFiles.has(file));
376
+ if (untracked !== undefined) {
377
+ fail("fixture.page_untracked", `Page source '${untracked}' is not bound by ${FIXTURE_MANIFEST_FILE_NAME}.`, untracked);
378
+ }
379
+ const validatedPages = [];
380
+ for (const file of pageFiles) {
381
+ onProgress(`Validating '${file}'.`);
382
+ const entry = manifestByFile.get(file);
383
+ const page = await validateWorkspacePageSource({
384
+ workspaceDir: fixtureRoot,
385
+ file,
386
+ manifestEntry: entry,
387
+ mediaManifest: { media: {} },
388
+ content: invocation.content,
389
+ });
390
+ await validateWorkspacePageDocument({
391
+ workspaceDir: fixtureRoot,
392
+ siteId: manifest.siteId,
393
+ file,
394
+ document: page.document,
395
+ content: invocation.content,
396
+ getSharedThemeContexts: async () => sharedContexts,
397
+ });
398
+ if (page.pagePath !== normalizePagePath(entry.path)) {
399
+ fail(
400
+ "fixture.page_path_mismatch",
401
+ `'${file}' declares path '${page.pagePath}' but its fixture manifest entry binds '${entry.path}'.`,
402
+ file,
403
+ );
404
+ }
405
+ validatePageImageReferences(page.document, file, imageIds, deliveryOrigins);
406
+ validatedPages.push({ file, path: page.pagePath });
407
+ }
408
+
409
+ onProgress("Validating navigation shape and local PAGE targets.");
410
+ const navigation = validateNavigationWorkspaceDocument(
411
+ await readWorkspaceJson(fixtureRoot, NAVIGATION_FILE_NAME, WORKSPACE_LIMITS.navigationBytes),
412
+ manifest.siteId,
413
+ { pageIds, resourceIds },
414
+ );
415
+ if (navigation.items !== manifest.navigation.items) {
416
+ fail(
417
+ "fixture.navigation_count_mismatch",
418
+ `${NAVIGATION_FILE_NAME} contains ${navigation.items} item(s), but the fixture manifest records ${manifest.navigation.items}.`,
419
+ "navigation.items",
420
+ );
421
+ }
422
+
423
+ onProgress("Validating the closed footer document and local page/image targets.");
424
+ validateFooterWorkspaceDocument(
425
+ await readWorkspaceJson(fixtureRoot, FOOTER_SETTINGS_FILE, WORKSPACE_LIMITS.settingsBytes),
426
+ manifest.siteId,
427
+ {
428
+ expectedEntityId: settingsEntityIds.get(SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES),
429
+ knownPageResourceIds: resourceIds,
430
+ knownImageIds: imageIds,
431
+ },
432
+ );
433
+
434
+ const reportedPages = boundedList(validatedPages, MAXIMUM_REPORTED);
435
+ onProgress(
436
+ `Validated ${validatedPages.length} page(s), ${navigation.items} navigation item(s), two themes, appearance, and footer without credentials or mutation.`,
437
+ );
438
+ return successResult(VERB_VALIDATE, manifest.siteId, {
439
+ offline: true,
440
+ fixture: {
441
+ contractVersion: FIXTURE_CONTRACT_VERSION,
442
+ manifest: FIXTURE_MANIFEST_FILE_NAME,
443
+ imageIds: imageIds.size,
444
+ deliveryOrigins: deliveryOrigins.size,
445
+ },
446
+ validated: {
447
+ pages: {
448
+ total: validatedPages.length,
449
+ items: reportedPages.items,
450
+ ...(reportedPages.truncated ? { itemsTruncated: true } : {}),
451
+ },
452
+ navigation: { items: navigation.items, maximumDepth: NAVIGATION_MAXIMUM_DEPTH },
453
+ themes: 2,
454
+ appearanceSettings: presentation.scalarOperations.length,
455
+ footer: true,
456
+ },
457
+ warnings: {
458
+ items: presentation.themes.warnings,
459
+ count: presentation.themes.warningCount,
460
+ ...(presentation.themes.warningsTruncated ? { truncated: true } : {}),
461
+ },
462
+ proves: [
463
+ "fixture structure and bounded files",
464
+ "page content and named theme contexts",
465
+ "navigation shape and local page-resource references",
466
+ "complete theme, appearance, header, brand, and footer semantics",
467
+ "fixture-local image identities and reserved delivery origins",
468
+ ],
469
+ doesNotProve: [
470
+ "credential authorization or live site ownership",
471
+ "remote concurrency or current revisions",
472
+ "server normalization and pull round trips",
473
+ "preview or published rendering",
474
+ ],
475
+ nextStep: "Run a real pull and authorized preview before approval or deployment.",
476
+ });
477
+ }
@@ -0,0 +1,108 @@
1
+ import { CLI_BINARY_NAME, PUBLISH_KEY_ENVIRONMENT_VARIABLE, VERB_LOGIN, VERB_USE, VERB_WHOAMI } from "../constants.js";
2
+ import { credentialApiOrigin, findCredential } from "../credentials.js";
3
+ import { SITE_AUTHORING_CAPABILITIES } from "../capabilities.js";
4
+ import { openAnonymousSession, successResult } from "../session.js";
5
+ import { environmentNameFor } from "../settings.js";
6
+
7
+ /**
8
+ * `taproot-site whoami` — which Taproot, which account, which site, until when.
9
+ *
10
+ * Answers offline, on purpose. Everything it reports is already known locally:
11
+ * the sign-in record carries the account, the credential id, its display
12
+ * prefix, and its expiry; the configuration carries the site; and what an
13
+ * exchanged credential may do is a property of the shipped envelope rather than
14
+ * of any particular credential. Asking the server would make the one command an
15
+ * operator reaches for when something is wrong depend on the thing that might
16
+ * be wrong.
17
+ *
18
+ * The credential itself is never printed — only its id and display prefix,
19
+ * which are the values an owner needs to find and revoke it.
20
+ */
21
+ export async function whoami(invocation) {
22
+ const environment = invocation.environment ?? process.env;
23
+ const { config, apiBaseUrl, onProgress } = await openAnonymousSession({ ...invocation, client: null });
24
+ const apiOrigin = credentialApiOrigin(apiBaseUrl);
25
+ const { path: credentialPath, credential } = await findCredential(environment, apiOrigin);
26
+
27
+ // Reported, not resolved. An operator debugging a permission surprise needs
28
+ // to know the environment override is in play; printing its value would put
29
+ // a live credential in a terminal and a scrollback buffer.
30
+ const environmentOverride = environment?.[PUBLISH_KEY_ENVIRONMENT_VARIABLE] !== undefined;
31
+
32
+ const now = (invocation.now ?? Date.now)();
33
+ const expired = credential?.keyExpiresAt !== undefined && Date.parse(credential.keyExpiresAt) <= now;
34
+
35
+ onProgress(`Taproot: ${apiOrigin} (${environmentNameFor(apiBaseUrl)})`);
36
+ if (environmentOverride) {
37
+ onProgress(
38
+ `${PUBLISH_KEY_ENVIRONMENT_VARIABLE} is set, so commands use that site credential and skip the sign-in.`,
39
+ );
40
+ }
41
+ if (credential === undefined) {
42
+ onProgress(`Not signed in. Run '${CLI_BINARY_NAME} ${VERB_LOGIN}'.`);
43
+ } else {
44
+ onProgress(`Account: ${credential.accountId}`);
45
+ onProgress(`Sign-in: ${credential.keyPrefix} (id ${credential.keyId})`);
46
+ onProgress(
47
+ credential.keyExpiresAt === undefined
48
+ ? "Sign-in expiry: none recorded."
49
+ : `Sign-in ${expired ? "expired" : "expires"}: ${credential.keyExpiresAt}`,
50
+ );
51
+ }
52
+ onProgress(
53
+ config?.siteId
54
+ ? `Site: ${config.siteId}`
55
+ : `No site selected. Run '${CLI_BINARY_NAME} ${VERB_USE} <site>'.`,
56
+ );
57
+ // What the last exchange actually produced, when there has been one. That is
58
+ // the honest answer to "what can this do", and it is recorded locally so this
59
+ // stays offline. The ceiling is reported as the ceiling, never as the grant.
60
+ const lastExchange = credential?.lastExchange;
61
+ if (environmentOverride) {
62
+ onProgress(
63
+ `${PUBLISH_KEY_ENVIRONMENT_VARIABLE} supplies the site credential directly, so what it can do is whatever `
64
+ + "it was issued with — this command cannot tell you.",
65
+ );
66
+ } else if (lastExchange === undefined) {
67
+ onProgress(
68
+ "No site credential has been exchanged yet. Each command mints one carrying only what that command "
69
+ + `needs, never more than: ${SITE_AUTHORING_CAPABILITIES.join(", ")}.`,
70
+ );
71
+ } else {
72
+ const lapsed = Date.parse(lastExchange.expiresAt) <= now;
73
+ onProgress(`Last exchanged for site ${lastExchange.siteId}: ${lastExchange.capabilities.join(", ")}.`);
74
+ onProgress(
75
+ `That credential ${lapsed ? "expired" : "expires"} ${lastExchange.expiresAt}`
76
+ + `${lapsed ? "; the next command mints another." : "."}`,
77
+ );
78
+ }
79
+
80
+ return successResult(VERB_WHOAMI, config?.siteId, {
81
+ apiOrigin,
82
+ // Named as well as spelled out, so "am I on local right now" is answerable
83
+ // without the reader knowing which origin is which.
84
+ environment: environmentNameFor(apiBaseUrl),
85
+ signedIn: credential !== undefined,
86
+ environmentOverride,
87
+ ...(credential === undefined ? {} : {
88
+ accountId: credential.accountId,
89
+ keyId: credential.keyId,
90
+ keyPrefix: credential.keyPrefix,
91
+ ...(credential.keyExpiresAt === undefined ? {} : { keyExpiresAt: credential.keyExpiresAt, expired }),
92
+ credentialPath,
93
+ }),
94
+ ...(config?.configPath === undefined ? {} : { configPath: config.configPath }),
95
+ // Named for what each is. `capabilities` alone would conflate the ceiling
96
+ // with the grant, which are different numbers and differently trustworthy.
97
+ capabilityCeiling: SITE_AUTHORING_CAPABILITIES,
98
+ ...(environmentOverride || lastExchange === undefined ? {} : {
99
+ lastExchange: {
100
+ siteId: lastExchange.siteId,
101
+ capabilities: lastExchange.capabilities,
102
+ expiresAt: lastExchange.expiresAt,
103
+ expired: Date.parse(lastExchange.expiresAt) <= now,
104
+ },
105
+ }),
106
+ ...(environmentOverride ? { capabilitiesKnown: false } : {}),
107
+ });
108
+ }