@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,311 @@
1
+ import { hasControlCharacter, isCanonicalUuid, SiteAuthoringError } from "./errors.js";
2
+ import {
3
+ SETTINGS_TYPE_BRAND,
4
+ SETTINGS_TYPE_SITE_HEADER,
5
+ SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
6
+ SETTINGS_TYPE_TAPROOT_STYLES,
7
+ } from "./settings-catalog.js";
8
+ import { FOOTER_COLOR_TOKENS, HEADER_BRAND_COLOR_TOKENS, requireAppearanceColor } from "./theme-validation.js";
9
+
10
+ export const APPEARANCE_FILES = Object.freeze({
11
+ [SETTINGS_TYPE_TAPROOT_STYLES]: "settings/taproot-styles.json",
12
+ [SETTINGS_TYPE_BRAND]: "settings/brand.json",
13
+ [SETTINGS_TYPE_SITE_HEADER]: "settings/site-header.json",
14
+ [SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES]: "settings/site-publishing-preferences.json",
15
+ });
16
+
17
+ // Declaration order is both mutation order and help order. Adding a writable
18
+ // scalar therefore changes one executable registry instead of a validator,
19
+ // request mapper, and prose list independently.
20
+ export const APPEARANCE_SCALAR_FIELDS = Object.freeze([
21
+ Object.freeze({
22
+ settingsType: SETTINGS_TYPE_TAPROOT_STYLES,
23
+ name: "defaultScheme",
24
+ path: "taproot-styles.defaultScheme",
25
+ type: "enum",
26
+ values: Object.freeze(["system", "light", "dark"]),
27
+ default: "light",
28
+ }),
29
+ Object.freeze({
30
+ settingsType: SETTINGS_TYPE_TAPROOT_STYLES,
31
+ name: "fontBrand",
32
+ path: "taproot-styles.fontBrand",
33
+ type: "string",
34
+ default: "",
35
+ }),
36
+ Object.freeze({
37
+ settingsType: SETTINGS_TYPE_TAPROOT_STYLES,
38
+ name: "fontWeightBrand",
39
+ path: "taproot-styles.fontWeightBrand",
40
+ type: "string",
41
+ default: "",
42
+ }),
43
+ ...["lightLogoId", "darkLogoId", "lightCanvasImageId", "darkCanvasImageId"].map((name) =>
44
+ Object.freeze({
45
+ settingsType: SETTINGS_TYPE_TAPROOT_STYLES,
46
+ name,
47
+ path: `taproot-styles.${name}`,
48
+ type: "site-owned-image-id",
49
+ default: "",
50
+ })
51
+ ),
52
+ ...["lightCanvasImageOpacity", "darkCanvasImageOpacity"].map((name) =>
53
+ Object.freeze({
54
+ settingsType: SETTINGS_TYPE_TAPROOT_STYLES,
55
+ name,
56
+ path: `taproot-styles.${name}`,
57
+ type: "number",
58
+ minimum: 0,
59
+ maximum: 1,
60
+ default: 1,
61
+ })
62
+ ),
63
+ Object.freeze({
64
+ settingsType: SETTINGS_TYPE_BRAND,
65
+ name: "faviconId",
66
+ path: "brand.faviconId",
67
+ type: "site-owned-image-id",
68
+ default: "",
69
+ }),
70
+ Object.freeze({
71
+ settingsType: SETTINGS_TYPE_SITE_HEADER,
72
+ name: "headerLayout",
73
+ path: "site-header.headerLayout",
74
+ type: "enum",
75
+ values: Object.freeze(["standard", "centered-brand", "minimal"]),
76
+ default: "standard",
77
+ }),
78
+ Object.freeze({
79
+ settingsType: SETTINGS_TYPE_SITE_HEADER,
80
+ name: "brandText",
81
+ path: "site-header.brandText",
82
+ type: "plain-text",
83
+ maximumLength: 120,
84
+ default: "",
85
+ }),
86
+ Object.freeze({
87
+ settingsType: SETTINGS_TYPE_SITE_HEADER,
88
+ name: "logoAlt",
89
+ path: "site-header.logoAlt",
90
+ type: "plain-text",
91
+ maximumLength: 160,
92
+ default: "",
93
+ }),
94
+ Object.freeze({
95
+ settingsType: SETTINGS_TYPE_SITE_HEADER,
96
+ name: "navMenuDisplay",
97
+ path: "site-header.navMenuDisplay",
98
+ type: "enum",
99
+ values: Object.freeze(["auto", "drawer"]),
100
+ default: "auto",
101
+ }),
102
+ Object.freeze({
103
+ settingsType: SETTINGS_TYPE_SITE_HEADER,
104
+ name: "headerPosition",
105
+ path: "site-header.headerPosition",
106
+ type: "enum",
107
+ values: Object.freeze(["normal", "sticky"]),
108
+ default: "normal",
109
+ }),
110
+ Object.freeze({
111
+ settingsType: SETTINGS_TYPE_SITE_HEADER,
112
+ name: "showThemeToggle",
113
+ path: "site-header.showThemeToggle",
114
+ type: "boolean",
115
+ default: false,
116
+ }),
117
+ Object.freeze({
118
+ settingsType: SETTINGS_TYPE_SITE_HEADER,
119
+ name: "showBrandText",
120
+ path: "site-header.showBrandText",
121
+ type: "boolean",
122
+ default: true,
123
+ }),
124
+ Object.freeze({
125
+ settingsType: SETTINGS_TYPE_SITE_HEADER,
126
+ name: "brandLogoSize",
127
+ path: "site-header.brandLogoSize",
128
+ type: "enum",
129
+ values: Object.freeze(["standard", "large", "full"]),
130
+ default: "standard",
131
+ }),
132
+ Object.freeze({
133
+ settingsType: SETTINGS_TYPE_SITE_HEADER,
134
+ name: "brandHoverGrow",
135
+ path: "site-header.brandHoverGrow",
136
+ type: "boolean",
137
+ default: false,
138
+ }),
139
+ ...["brandColor", "lightBrandColor", "darkBrandColor"].map((name) =>
140
+ Object.freeze({
141
+ settingsType: SETTINGS_TYPE_SITE_HEADER,
142
+ name,
143
+ path: `site-header.${name}`,
144
+ type: "appearance-color",
145
+ allowedTokens: HEADER_BRAND_COLOR_TOKENS,
146
+ default: "",
147
+ })
148
+ ),
149
+ ]);
150
+
151
+ export const APPEARANCE_READ_ONLY_FIELDS = Object.freeze([
152
+ Object.freeze({ file: APPEARANCE_FILES[SETTINGS_TYPE_TAPROOT_STYLES], path: "settings.lightLogoUrl" }),
153
+ Object.freeze({ file: APPEARANCE_FILES[SETTINGS_TYPE_TAPROOT_STYLES], path: "settings.darkLogoUrl" }),
154
+ Object.freeze({ file: APPEARANCE_FILES[SETTINGS_TYPE_TAPROOT_STYLES], path: "settings.lightCanvasImageUrl" }),
155
+ Object.freeze({ file: APPEARANCE_FILES[SETTINGS_TYPE_TAPROOT_STYLES], path: "settings.darkCanvasImageUrl" }),
156
+ Object.freeze({ file: APPEARANCE_FILES[SETTINGS_TYPE_BRAND], path: "settings.faviconUrl" }),
157
+ ]);
158
+
159
+ export const APPEARANCE_FOOTER_COLOR_FIELDS = Object.freeze(
160
+ ["light", "dark"].flatMap((scheme) =>
161
+ Object.keys(FOOTER_COLOR_TOKENS).map((name) =>
162
+ Object.freeze({
163
+ file: APPEARANCE_FILES[SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES],
164
+ path: `settings.footerSettings.${scheme}.${name}`,
165
+ scheme,
166
+ name,
167
+ type: "appearance-color",
168
+ default: "",
169
+ allowedTokens: FOOTER_COLOR_TOKENS[name],
170
+ })
171
+ )
172
+ ),
173
+ );
174
+
175
+ export const APPEARANCE_IMAGE_FIELDS = Object.freeze(
176
+ APPEARANCE_SCALAR_FIELDS.filter((field) => field.type === "site-owned-image-id"),
177
+ );
178
+
179
+ export function appearanceImageIds(documents) {
180
+ return [...new Set(APPEARANCE_IMAGE_FIELDS
181
+ .map((field) => documents[field.settingsType]?.[field.name])
182
+ .filter((value) => isCanonicalUuid(value)))].sort();
183
+ }
184
+
185
+ function fail(code, message, field) {
186
+ throw new SiteAuthoringError(code, message, { field });
187
+ }
188
+
189
+ function requireString(value, field, maximum = 256 * 1024) {
190
+ if (typeof value !== "string" || value.length > maximum || hasControlCharacter(value)) {
191
+ fail("theme.setting_invalid", `${field} must be a bounded printable string.`, field);
192
+ }
193
+ return value;
194
+ }
195
+
196
+ function validateScalar(definition, value, knownImageIds) {
197
+ switch (definition.type) {
198
+ case "enum":
199
+ if (typeof value !== "string" || !definition.values.includes(value)) {
200
+ fail(
201
+ "theme.setting_invalid",
202
+ `${definition.path} must be one of ${definition.values.join(", ")}.`,
203
+ definition.path,
204
+ );
205
+ }
206
+ return value;
207
+ case "string":
208
+ return requireString(value, definition.path);
209
+ case "plain-text": {
210
+ const text = requireString(value, definition.path, definition.maximumLength);
211
+ if ([...text].some((character) => character === "<" || character === ">" || character === "\"")) {
212
+ fail("theme.setting_invalid", `${definition.path} cannot contain markup-breaking characters.`, definition.path);
213
+ }
214
+ return text;
215
+ }
216
+ case "site-owned-image-id":
217
+ if (typeof value !== "string" || (value !== "" && !isCanonicalUuid(value))) {
218
+ fail(
219
+ "theme.image_id_invalid",
220
+ `${definition.path} must be empty or a canonical lowercase site-owned image ID from pull or 'media upload'.`,
221
+ definition.path,
222
+ );
223
+ }
224
+ if (value !== "" && !knownImageIds.has(value)) {
225
+ fail(
226
+ "theme.image_reference_unknown",
227
+ `${definition.path} must name an image retained from pull or recorded by 'media upload'.`,
228
+ definition.path,
229
+ );
230
+ }
231
+ return value;
232
+ case "number":
233
+ if (
234
+ typeof value !== "number"
235
+ || !Number.isFinite(value)
236
+ || value < definition.minimum
237
+ || value > definition.maximum
238
+ ) {
239
+ fail(
240
+ "theme.setting_invalid",
241
+ `${definition.path} must be a number from ${definition.minimum} through ${definition.maximum}.`,
242
+ definition.path,
243
+ );
244
+ }
245
+ return String(value);
246
+ case "boolean":
247
+ if (typeof value !== "boolean") {
248
+ fail("theme.setting_invalid", `${definition.path} must be true or false.`, definition.path);
249
+ }
250
+ return String(value);
251
+ case "appearance-color":
252
+ return requireAppearanceColor(value, definition.allowedTokens, definition.path);
253
+ default:
254
+ throw new TypeError(`Unknown appearance field type '${definition.type}'.`);
255
+ }
256
+ }
257
+
258
+ export function buildAppearanceScalarOperations(documents, knownImageIds) {
259
+ if (!(knownImageIds instanceof Set)) {
260
+ throw new TypeError("Appearance validation requires the known site-owned image identities.");
261
+ }
262
+ return APPEARANCE_SCALAR_FIELDS.map((definition) => ({
263
+ settingsType: definition.settingsType,
264
+ setting: definition.name,
265
+ value: validateScalar(definition, documents[definition.settingsType]?.[definition.name], knownImageIds),
266
+ }));
267
+ }
268
+
269
+ export function footerColorOverlay(workspaceFooter) {
270
+ if (workspaceFooter === null || typeof workspaceFooter !== "object" || Array.isArray(workspaceFooter)) {
271
+ fail(
272
+ "theme.footer_missing",
273
+ "site-publishing-preferences.footerSettings is missing. Run 'taproot-site pull' again.",
274
+ "site-publishing-preferences.footerSettings",
275
+ );
276
+ }
277
+ const colors = { light: {}, dark: {} };
278
+ for (const definition of APPEARANCE_FOOTER_COLOR_FIELDS) {
279
+ const scheme = workspaceFooter[definition.scheme];
280
+ if (scheme === null || typeof scheme !== "object" || Array.isArray(scheme)) {
281
+ fail(
282
+ "theme.footer_invalid",
283
+ `footerSettings.${definition.scheme} must be an object.`,
284
+ `footerSettings.${definition.scheme}`,
285
+ );
286
+ }
287
+ colors[definition.scheme][definition.name] = requireAppearanceColor(
288
+ scheme[definition.name] === undefined ? "" : scheme[definition.name],
289
+ definition.allowedTokens,
290
+ `footerSettings.${definition.scheme}.${definition.name}`,
291
+ );
292
+ }
293
+ return colors;
294
+ }
295
+
296
+ export function applyFooterColors(currentFooter, colors) {
297
+ const next = structuredClone(
298
+ currentFooter !== null && typeof currentFooter === "object" && !Array.isArray(currentFooter)
299
+ ? currentFooter
300
+ : {},
301
+ );
302
+ for (const scheme of ["light", "dark"]) {
303
+ next[scheme] = {
304
+ ...(next[scheme] !== null && typeof next[scheme] === "object" && !Array.isArray(next[scheme])
305
+ ? next[scheme]
306
+ : {}),
307
+ ...colors[scheme],
308
+ };
309
+ }
310
+ return next;
311
+ }
@@ -0,0 +1,136 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { chmod, lstat, open, rename, unlink } from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ import { SiteAuthoringError } from "./errors.js";
7
+
8
+ /**
9
+ * The one durable single-file write in this package (TR00645).
10
+ *
11
+ * Two callers need the identical sequence and used to carry their own copy of
12
+ * it: `writeWorkspaceFile` in `workspace.js`, which replaces a pulled page, and
13
+ * the credential store in `credentials.js`, which replaces the file holding a
14
+ * live key. The credential module's comment acknowledged the duplication and
15
+ * justified it on the grounds that the workspace function is bound to a
16
+ * validated workspace — true of that function's *path handling*, and not of the
17
+ * write discipline underneath it. Splitting the two apart leaves one primitive
18
+ * here and each caller's own containment rules where they belong.
19
+ *
20
+ * The discipline, and why each step is in it:
21
+ *
22
+ * 1. **`lstat` the destination first.** A symlink or a non-regular file at the
23
+ * target is refused rather than followed, so neither caller can be turned
24
+ * into a writer of some other file by something planted in its directory.
25
+ * `lstat` rather than `stat` is the whole point — `stat` would resolve the
26
+ * link and report the target as a perfectly ordinary file.
27
+ * 2. **Write a temp file in the same directory**, opened
28
+ * `O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW`. `O_EXCL` means an attacker cannot
29
+ * pre-create the temp path and have us write through it; `O_NOFOLLOW` means
30
+ * they cannot pre-create it as a link. Same directory so the rename below is
31
+ * a rename and not a cross-device copy.
32
+ * 3. **fsync before the rename.** The rename is the commit point, and a rename
33
+ * that commits a file whose contents have not reached disk is exactly how a
34
+ * crash produces a truncated credential or a half-written page.
35
+ * 4. **Rename over the destination.** Atomic, so a concurrent reader sees the
36
+ * complete old file or the complete new one and never a partial write.
37
+ * 5. **Best-effort parent-directory sync**, which makes the rename itself
38
+ * durable. Deliberately swallowed: the replacement is already live, and
39
+ * platforms that refuse directory handles must not turn a committed write
40
+ * into a reported failure.
41
+ * 6. **Unlink the temp file on any failure**, so a failed write leaves nothing
42
+ * behind to accumulate beside the real one.
43
+ *
44
+ * Messages stay with the callers. Both surfaces have their own vocabulary and
45
+ * their own error codes, and collapsing them into one generic "could not write
46
+ * file" would make every refusal less useful than the two it replaced — so the
47
+ * caller supplies the three failures this can produce.
48
+ */
49
+
50
+ /**
51
+ * Replaces one file atomically.
52
+ *
53
+ * @param {string} filePath Absolute path to replace. Callers are responsible
54
+ * for having validated it and for having created its directory.
55
+ * @param {string} contents UTF-8 contents to write.
56
+ * @param {object} options
57
+ * @param {number} [options.mode] Creation mode for the temp file. Defaults to
58
+ * `0600`, the safe choice for both current callers.
59
+ * @param {boolean} [options.pinMode] Re-apply `mode` with an explicit `chmod`
60
+ * after writing. The creation mode is narrowed by the process umask, so a
61
+ * file that must be exactly `0600` whatever the operator's umask says needs
62
+ * this; a file that only must not be *wider* than `mode` does not. Advisory
63
+ * on Windows, so a failure here never fails the write.
64
+ * @param {Function} [options.openDirectory] Injection seam for the parent
65
+ * directory sync, so a test can exercise the unsupported-directory-handle
66
+ * path without a platform that has one.
67
+ * @param {{inspect: Function, notRegular: Function, write: Function}}
68
+ * options.failures Factories for this caller's three refusals.
69
+ * @returns {Promise<string>} The path that was replaced.
70
+ */
71
+ export async function atomicWriteFile(filePath, contents, {
72
+ mode = 0o600,
73
+ pinMode = false,
74
+ openDirectory = open,
75
+ failures,
76
+ } = {}) {
77
+ const directory = path.dirname(filePath);
78
+
79
+ let existing;
80
+ try {
81
+ existing = await lstat(filePath);
82
+ } catch (error) {
83
+ // A missing destination is the ordinary case for a first write, not a
84
+ // failure. Anything else means we cannot vouch for what is there.
85
+ if (!error || typeof error !== "object" || error.code !== "ENOENT") {
86
+ throw failures.inspect();
87
+ }
88
+ }
89
+ if (existing && (existing.isSymbolicLink() || !existing.isFile())) {
90
+ throw failures.notRegular();
91
+ }
92
+
93
+ const temporaryPath = path.join(directory, `.${path.basename(filePath)}.${randomUUID()}.tmp`);
94
+ let handle;
95
+ let renamed = false;
96
+ try {
97
+ handle = await open(
98
+ temporaryPath,
99
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0),
100
+ mode,
101
+ );
102
+ await handle.writeFile(contents, { encoding: "utf8" });
103
+ await handle.sync();
104
+ await handle.close();
105
+ handle = undefined;
106
+ if (pinMode) {
107
+ try {
108
+ await chmod(temporaryPath, mode);
109
+ } catch {
110
+ // Best-effort: Windows does not honor POSIX modes.
111
+ }
112
+ }
113
+ await rename(temporaryPath, filePath);
114
+ renamed = true;
115
+ } catch (error) {
116
+ if (error instanceof SiteAuthoringError) throw error;
117
+ throw failures.write();
118
+ } finally {
119
+ await handle?.close().catch(() => {});
120
+ if (!renamed) await unlink(temporaryPath).catch(() => {});
121
+ }
122
+
123
+ let directoryHandle;
124
+ try {
125
+ directoryHandle = await openDirectory(
126
+ directory,
127
+ fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY ?? 0),
128
+ );
129
+ await directoryHandle.sync();
130
+ } catch {
131
+ // Best-effort durability only; the complete replacement is already live.
132
+ } finally {
133
+ await directoryHandle?.close().catch(() => {});
134
+ }
135
+ return filePath;
136
+ }
@@ -0,0 +1,59 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import { lstat, open } from "node:fs/promises";
3
+
4
+ /**
5
+ * One bounded, no-follow read of a small local file, shared by the credential
6
+ * store and the machine settings (TR00645).
7
+ *
8
+ * The discipline: `lstat` first and refuse anything that is not a regular file
9
+ * (so a symlink is never followed), open with `O_NOFOLLOW | O_NONBLOCK` (so a
10
+ * FIFO or device swapped in under the path cannot block the process), confirm
11
+ * the opened inode is the one inspected, and read at most `maximumBytes + 1`
12
+ * — the extra byte is how "too large" is detected without trusting the size
13
+ * `lstat` reported. A plain `readFile` does none of that: it follows links,
14
+ * blocks on a FIFO, and reads an unbounded device to exhaustion before any
15
+ * post-read size check can run.
16
+ *
17
+ * Parameterized the way `atomicWriteFile` is: the caller supplies the failure
18
+ * factories so each store keeps its own error vocabulary, and one reader keeps
19
+ * one set of checks. A missing file resolves to `undefined`, because for both
20
+ * callers absence is a state — no sign-in yet, production by default — rather
21
+ * than a failure.
22
+ */
23
+ export async function readBoundedFile(filePath, { maximumBytes, failures }) {
24
+ let stats;
25
+ try {
26
+ stats = await lstat(filePath, { bigint: true });
27
+ } catch (error) {
28
+ if (error && typeof error === "object" && error.code === "ENOENT") return undefined;
29
+ throw failures.inspect();
30
+ }
31
+ if (stats.isSymbolicLink() || !stats.isFile()) throw failures.notRegular();
32
+ if (stats.size > BigInt(maximumBytes)) throw failures.tooLarge();
33
+
34
+ let handle;
35
+ try {
36
+ handle = await open(
37
+ filePath,
38
+ fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0),
39
+ );
40
+ const opened = await handle.stat({ bigint: true });
41
+ if (!opened.isFile() || opened.dev !== stats.dev || opened.ino !== stats.ino) throw failures.changed();
42
+ const buffer = Buffer.allocUnsafe(maximumBytes + 1);
43
+ let total = 0;
44
+ while (total <= maximumBytes) {
45
+ const { bytesRead } = await handle.read(buffer, total, buffer.byteLength - total, total);
46
+ if (bytesRead === 0) break;
47
+ total += bytesRead;
48
+ }
49
+ if (total > maximumBytes) throw failures.tooLarge();
50
+ return buffer.subarray(0, total);
51
+ } catch (error) {
52
+ // The factories above produce the caller's own error type; anything else
53
+ // is the filesystem talking, and gets the caller's generic read failure.
54
+ if (error instanceof Error && failures.isOwn(error)) throw error;
55
+ throw failures.read();
56
+ } finally {
57
+ await handle?.close().catch(() => {});
58
+ }
59
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The delegation capabilities an exchanged site credential can carry (TR00645).
3
+ *
4
+ * These are the client's copy of `SiteAuthoringKeyEnvelope.CapabilityKeys`, and
5
+ * the copy is deliberate: the CLI has to name them in `--capabilities` help and
6
+ * in `whoami` before it has talked to any server, and a value it cannot print
7
+ * without a network round trip is a value it cannot validate a typo against.
8
+ *
9
+ * The server remains the authority. It refuses anything outside its own
10
+ * envelope rather than trusting this list, so the worst a stale copy here can
11
+ * do is offer a capability the exchange then declines by name — a clear
12
+ * refusal, never a silently wider credential.
13
+ */
14
+ export const CAPABILITY_CONTENT = "delegation.content";
15
+ export const CAPABILITY_DESIGN = "delegation.design";
16
+ export const CAPABILITY_DEPLOYMENTS = "delegation.deployments";
17
+
18
+ export const SITE_AUTHORING_CAPABILITIES = Object.freeze([
19
+ CAPABILITY_CONTENT,
20
+ CAPABILITY_DESIGN,
21
+ CAPABILITY_DEPLOYMENTS,
22
+ ]);
23
+
24
+ /** What each capability lets an exchanged credential do, for help and `whoami`. */
25
+ export const SITE_AUTHORING_CAPABILITY_SUMMARIES = Object.freeze({
26
+ "delegation.content": "write pages and media",
27
+ "delegation.design": "set the theme, navigation, and footer",
28
+ "delegation.deployments": "stage and deploy",
29
+ });