@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
package/src/config.js ADDED
@@ -0,0 +1,412 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import { lstat, open, realpath } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import {
6
+ CLI_BINARY_NAME,
7
+ CONFIG_FILE_NAME,
8
+ CONFIG_VERSION,
9
+ ENVIRONMENT_LOCAL,
10
+ ENVIRONMENT_PRODUCTION,
11
+ LIMITS,
12
+ VERB_ENV,
13
+ } from "./constants.js";
14
+ import { hasAsciiControl, isCanonicalUuid, SiteAuthoringError } from "./errors.js";
15
+ import { parseJsonWithDuplicateGuard } from "./json-guard.js";
16
+
17
+ const CONFIG_KEYS = new Set([
18
+ "configVersion",
19
+ "siteId",
20
+ "workspaceDir",
21
+ ]);
22
+ const WINDOWS_DEVICE_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/iu;
23
+ // The duplicate-key guard lives in json-guard.js now, shared with the machine
24
+ // settings. It is still needed here for the reason it was written: a
25
+ // configuration that says `siteId` twice is ambiguous about which site the
26
+ // next command will write to, and the only safe reading of an ambiguous target
27
+ // is to refuse it.
28
+ function parseConfigJson(text) {
29
+ return parseJsonWithDuplicateGuard(text, {
30
+ onDuplicate: (key) =>
31
+ new SiteAuthoringError(
32
+ "config.duplicate_key",
33
+ `Configuration field '${key}' appears more than once.`,
34
+ { field: key },
35
+ ),
36
+ onInvalid: () =>
37
+ new SiteAuthoringError("config.invalid_json", "The site authoring configuration is not valid JSON."),
38
+ });
39
+ }
40
+
41
+ export async function readInspectedConfigFile(
42
+ filePath,
43
+ stats,
44
+ maximumBytes = LIMITS.configBytes,
45
+ errorField,
46
+ ) {
47
+ if (stats.isSymbolicLink() || !stats.isFile()) {
48
+ throw new SiteAuthoringError(
49
+ "config.not_regular",
50
+ "The site authoring configuration must be a regular file, not a link or directory.",
51
+ { field: errorField },
52
+ );
53
+ }
54
+ if (stats.size > BigInt(maximumBytes)) {
55
+ throw new SiteAuthoringError(
56
+ "config.too_large",
57
+ `The site authoring configuration exceeds ${maximumBytes} bytes.`,
58
+ { field: errorField },
59
+ );
60
+ }
61
+
62
+ let handle;
63
+ try {
64
+ handle = await open(
65
+ filePath,
66
+ fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0),
67
+ );
68
+ const before = await handle.stat({ bigint: true });
69
+ if (
70
+ !before.isFile()
71
+ || before.dev !== stats.dev
72
+ || before.ino !== stats.ino
73
+ || before.size !== stats.size
74
+ || before.mtimeNs !== stats.mtimeNs
75
+ || before.ctimeNs !== stats.ctimeNs
76
+ || before.size > BigInt(maximumBytes)
77
+ ) {
78
+ throw new SiteAuthoringError("config.changed", "The configuration changed while it was being opened.", {
79
+ field: errorField,
80
+ });
81
+ }
82
+ const buffer = Buffer.allocUnsafe(maximumBytes + 1);
83
+ let total = 0;
84
+ while (total <= maximumBytes) {
85
+ const { bytesRead } = await handle.read(buffer, total, buffer.byteLength - total, total);
86
+ if (bytesRead === 0) break;
87
+ total += bytesRead;
88
+ }
89
+ const after = await handle.stat({ bigint: true });
90
+ if (
91
+ after.dev !== before.dev
92
+ || after.ino !== before.ino
93
+ || after.size !== before.size
94
+ || after.mtimeNs !== before.mtimeNs
95
+ || after.ctimeNs !== before.ctimeNs
96
+ || total > maximumBytes
97
+ || total !== Number(after.size)
98
+ ) {
99
+ throw new SiteAuthoringError("config.changed", "The configuration changed while it was being read.", {
100
+ field: errorField,
101
+ });
102
+ }
103
+ return buffer.subarray(0, total);
104
+ } catch (error) {
105
+ if (error instanceof SiteAuthoringError) throw error;
106
+ throw new SiteAuthoringError("config.unreadable", "Could not read the site authoring configuration.", {
107
+ field: errorField,
108
+ });
109
+ } finally {
110
+ await handle?.close().catch(() => {});
111
+ }
112
+ }
113
+
114
+ async function readRegularFile(filePath, maximumBytes, missingCode, errorField) {
115
+ let stats;
116
+ try {
117
+ stats = await lstat(filePath, { bigint: true });
118
+ } catch (error) {
119
+ if (error && typeof error === "object" && error.code === "ENOENT") {
120
+ throw new SiteAuthoringError(missingCode, `Configuration file '${filePath}' does not exist.`, {
121
+ field: errorField,
122
+ });
123
+ }
124
+ throw new SiteAuthoringError("config.unreadable", `Could not inspect configuration file '${filePath}'.`, {
125
+ field: errorField,
126
+ });
127
+ }
128
+ return await readInspectedConfigFile(filePath, stats, maximumBytes, errorField);
129
+ }
130
+
131
+ async function discoverConfig(cwd) {
132
+ let current;
133
+ try {
134
+ current = await realpath(cwd);
135
+ } catch {
136
+ throw new SiteAuthoringError("config.cwd_invalid", "The working directory does not exist or cannot be resolved.");
137
+ }
138
+ const matches = [];
139
+ for (let depth = 0; depth < LIMITS.configDiscoveryParents; depth += 1) {
140
+ const candidate = path.join(current, CONFIG_FILE_NAME);
141
+ try {
142
+ await lstat(candidate);
143
+ matches.push(candidate);
144
+ } catch (error) {
145
+ if (!error || typeof error !== "object" || error.code !== "ENOENT") {
146
+ throw new SiteAuthoringError(
147
+ "config.discovery_failed",
148
+ "Could not inspect a site authoring configuration candidate.",
149
+ );
150
+ }
151
+ }
152
+ const parent = path.dirname(current);
153
+ if (parent === current) {
154
+ if (matches.length === 0) {
155
+ throw new SiteAuthoringError(
156
+ "config.not_found",
157
+ `No ${CONFIG_FILE_NAME} was found. Pass --config or add the file to the repository.`,
158
+ );
159
+ }
160
+ if (matches.length > 1) {
161
+ throw new SiteAuthoringError(
162
+ "config.ambiguous",
163
+ `More than one ${CONFIG_FILE_NAME} was found in the parent chain. Pass --config explicitly.`,
164
+ );
165
+ }
166
+ return matches[0];
167
+ }
168
+ current = parent;
169
+ }
170
+ throw new SiteAuthoringError(
171
+ "config.discovery_limit",
172
+ `Configuration discovery exceeded ${LIMITS.configDiscoveryParents} parent directories. Pass --config explicitly.`,
173
+ );
174
+ }
175
+
176
+ async function resolveExplicitConfigPath(cwd, configPath) {
177
+ const lexicalCwd = path.resolve(cwd);
178
+ let canonicalCwd;
179
+ try {
180
+ canonicalCwd = await realpath(lexicalCwd);
181
+ } catch {
182
+ throw new SiteAuthoringError("config.cwd_invalid", "The working directory does not exist or cannot be resolved.");
183
+ }
184
+
185
+ if (!path.isAbsolute(configPath)) return path.resolve(canonicalCwd, configPath);
186
+
187
+ const lexicalSelectedPath = path.resolve(configPath);
188
+ const relativePath = path.relative(lexicalCwd, lexicalSelectedPath);
189
+ const isWithinCwd = relativePath === ""
190
+ || (
191
+ relativePath !== ".."
192
+ && !relativePath.startsWith(`..${path.sep}`)
193
+ && !path.isAbsolute(relativePath)
194
+ );
195
+ return isWithinCwd
196
+ ? path.resolve(canonicalCwd, relativePath)
197
+ : lexicalSelectedPath;
198
+ }
199
+
200
+ /**
201
+ * The reviewed-origin allowlist. It runs before any client exists and
202
+ * therefore before any Authorization header can be constructed, which is the
203
+ * whole point: the credential is site-scoped and long-lived, and the one
204
+ * failure that cannot be walked back is sending it to a host we do not
205
+ * control. Loopback keeps the local Tilt stack usable without widening the
206
+ * reviewed set.
207
+ */
208
+ export function validateApiBaseUrl(value) {
209
+ let url;
210
+ try {
211
+ url = new URL(value);
212
+ } catch {
213
+ throw new SiteAuthoringError("config.api_base_url", "apiBaseUrl must be an absolute Taproot API URL.", {
214
+ field: "apiBaseUrl",
215
+ });
216
+ }
217
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
218
+ const reviewedHost = url.hostname === "app.taproot.io" || url.hostname === "app.taproot.test";
219
+ if (
220
+ (!reviewedHost && !loopback)
221
+ || (url.protocol !== "https:" && !(loopback && url.protocol === "http:"))
222
+ || (reviewedHost && url.port !== "")
223
+ || url.username !== ""
224
+ || url.password !== ""
225
+ || url.search !== ""
226
+ || url.hash !== ""
227
+ || url.pathname.replace(/\/+$/u, "") !== "/api"
228
+ ) {
229
+ throw new SiteAuthoringError(
230
+ "config.api_base_url",
231
+ "apiBaseUrl must be the /api endpoint on app.taproot.io, app.taproot.test, or an explicit loopback origin.",
232
+ { field: "apiBaseUrl" },
233
+ );
234
+ }
235
+ return url.toString().replace(/\/+$/u, "");
236
+ }
237
+
238
+ function validateWorkspaceDirectoryText(value) {
239
+ if (
240
+ typeof value !== "string"
241
+ || value.length === 0
242
+ || Buffer.byteLength(value, "utf8") > LIMITS.workspacePathBytes
243
+ || hasAsciiControl(value)
244
+ || path.isAbsolute(value)
245
+ || value.includes("\\")
246
+ || value.split("/").some((segment) => {
247
+ const base = segment.split(".")[0]?.replace(/[ .]+$/u, "") ?? "";
248
+ return segment === "" || segment === "." || segment === ".." || WINDOWS_DEVICE_BASENAME.test(base);
249
+ })
250
+ ) {
251
+ throw new SiteAuthoringError(
252
+ "config.workspace_dir",
253
+ "workspaceDir must be a bounded relative POSIX path beneath the configuration directory.",
254
+ { field: "workspaceDir" },
255
+ );
256
+ }
257
+ return value;
258
+ }
259
+
260
+ /**
261
+ * Resolves the local workspace `pull` writes into. Unlike a publish artifact,
262
+ * the workspace legitimately does not exist yet on a fresh clone — so a
263
+ * missing leaf is allowed, while every segment that *does* exist must be a
264
+ * real directory. A symlink anywhere in the chain is refused rather than
265
+ * followed: the workspace is a write target, and following a link is how a
266
+ * checked-in configuration turns `pull` into an arbitrary-file writer.
267
+ */
268
+ async function resolveWorkspaceDirectory(configDirectory, relativeDirectory) {
269
+ let current = configDirectory;
270
+ let exists = true;
271
+ for (const segment of relativeDirectory.split("/")) {
272
+ current = path.join(current, segment);
273
+ if (!exists) continue;
274
+ let stats;
275
+ try {
276
+ stats = await lstat(current, { bigint: true });
277
+ } catch (error) {
278
+ if (error && typeof error === "object" && error.code === "ENOENT") {
279
+ exists = false;
280
+ continue;
281
+ }
282
+ throw new SiteAuthoringError("config.workspace_unreadable", "Could not inspect the configured workspace.", {
283
+ field: "workspaceDir",
284
+ });
285
+ }
286
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
287
+ throw new SiteAuthoringError(
288
+ "config.workspace_invalid",
289
+ "The workspace and each of its ancestors must be real directories, not links or files.",
290
+ { field: "workspaceDir" },
291
+ );
292
+ }
293
+ }
294
+
295
+ if (exists) {
296
+ let canonical;
297
+ try {
298
+ canonical = await realpath(current);
299
+ } catch {
300
+ throw new SiteAuthoringError("config.workspace_unreadable", "Could not resolve the configured workspace.", {
301
+ field: "workspaceDir",
302
+ });
303
+ }
304
+ if (canonical !== current) {
305
+ throw new SiteAuthoringError(
306
+ "config.workspace_invalid",
307
+ "The workspace and each of its ancestors must be real directories, not links or files.",
308
+ { field: "workspaceDir" },
309
+ );
310
+ }
311
+ }
312
+ if (!current.startsWith(`${configDirectory}${path.sep}`)) {
313
+ throw new SiteAuthoringError(
314
+ "config.workspace_escape",
315
+ "The configured workspace must resolve beneath the configuration directory.",
316
+ { field: "workspaceDir" },
317
+ );
318
+ }
319
+ return { workspaceDir: current, workspaceExists: exists };
320
+ }
321
+
322
+ export async function loadSiteConfig({ cwd = process.cwd(), configPath } = {}) {
323
+ if (
324
+ configPath !== undefined
325
+ && (
326
+ typeof configPath !== "string"
327
+ || configPath.length === 0
328
+ || Buffer.byteLength(configPath, "utf8") > LIMITS.configPathBytes
329
+ || hasAsciiControl(configPath)
330
+ )
331
+ ) {
332
+ throw new SiteAuthoringError("config.path_invalid", "The explicit configuration path is invalid.", {
333
+ field: "configPath",
334
+ });
335
+ }
336
+ const explicit = configPath !== undefined;
337
+ const selectedPath = configPath
338
+ ? await resolveExplicitConfigPath(cwd, configPath)
339
+ : await discoverConfig(cwd);
340
+ let canonicalConfigPath;
341
+ try {
342
+ canonicalConfigPath = await realpath(selectedPath);
343
+ } catch {
344
+ throw new SiteAuthoringError("config.not_found", `Configuration file '${selectedPath}' does not exist.`, {
345
+ field: explicit ? "configPath" : undefined,
346
+ });
347
+ }
348
+ if (canonicalConfigPath !== selectedPath) {
349
+ throw new SiteAuthoringError(
350
+ "config.not_regular",
351
+ "The configuration and each of its parent directories must be real paths, not links.",
352
+ { field: explicit ? "configPath" : undefined },
353
+ );
354
+ }
355
+ const bytes = await readRegularFile(
356
+ selectedPath,
357
+ LIMITS.configBytes,
358
+ "config.not_found",
359
+ explicit ? "configPath" : undefined,
360
+ );
361
+ if (await realpath(selectedPath).catch(() => undefined) !== canonicalConfigPath) {
362
+ throw new SiteAuthoringError("config.changed", "The configuration path changed while it was being read.", {
363
+ field: explicit ? "configPath" : undefined,
364
+ });
365
+ }
366
+ const parsed = parseConfigJson(bytes.toString("utf8"));
367
+ if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") {
368
+ throw new SiteAuthoringError("config.shape", "The site authoring configuration must be a JSON object.");
369
+ }
370
+ // Named ahead of the general unknown-field refusal because this one field
371
+ // used to be valid here and its replacement is a command, not a spelling.
372
+ // "Unknown configuration field 'apiBaseUrl'" would be true and useless.
373
+ if (Object.hasOwn(parsed, "apiBaseUrl")) {
374
+ throw new SiteAuthoringError(
375
+ "config.api_base_url_moved",
376
+ "apiBaseUrl is no longer a configuration field: which Taproot the CLI talks to is a property of this "
377
+ + `machine, not of this project. Remove the line and run '${CLI_BINARY_NAME} ${VERB_ENV} `
378
+ + `${ENVIRONMENT_LOCAL}' (or '${ENVIRONMENT_PRODUCTION}').`,
379
+ { field: "apiBaseUrl" },
380
+ );
381
+ }
382
+ const unknown = Object.keys(parsed).filter((key) => !CONFIG_KEYS.has(key)).sort();
383
+ if (unknown.length > 0) {
384
+ throw new SiteAuthoringError("config.unknown_field", `Unknown configuration field '${unknown[0]}'.`, {
385
+ field: unknown[0],
386
+ });
387
+ }
388
+ if (parsed.configVersion !== CONFIG_VERSION) {
389
+ throw new SiteAuthoringError(
390
+ "config.unsupported_version",
391
+ `configVersion must be ${CONFIG_VERSION}.`,
392
+ { field: "configVersion" },
393
+ );
394
+ }
395
+ // Optional since TR00645: `use` writes it, and `login`, `sites`, and `whoami`
396
+ // all run before any site has been chosen. Present-but-malformed is still a
397
+ // refusal — an ambiguous site is not a missing one.
398
+ if (parsed.siteId !== undefined && !isCanonicalUuid(parsed.siteId)) {
399
+ throw new SiteAuthoringError("config.site_id", "siteId must be a canonical lowercase UUID.", { field: "siteId" });
400
+ }
401
+ const workspaceDirectoryText = validateWorkspaceDirectoryText(parsed.workspaceDir);
402
+ const configDirectory = path.dirname(canonicalConfigPath);
403
+ const { workspaceDir, workspaceExists } = await resolveWorkspaceDirectory(configDirectory, workspaceDirectoryText);
404
+ return Object.freeze({
405
+ configVersion: CONFIG_VERSION,
406
+ configPath: canonicalConfigPath,
407
+ configDirectory,
408
+ siteId: parsed.siteId,
409
+ workspaceDir,
410
+ workspaceExists,
411
+ });
412
+ }
@@ -0,0 +1,173 @@
1
+ export const CLI_NAME = "@taprootio/site-authoring";
2
+ export const CLI_VERSION = "0.1.0";
3
+ export const CLI_BINARY_NAME = "taproot-site";
4
+ export const RESULT_SCHEMA_VERSION = 1;
5
+ export const CONFIG_FILE_NAME = "taproot-site.json";
6
+ export const CONFIG_VERSION = 1;
7
+ export const CANONICAL_TIMESTAMP =
8
+ /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{3}|\.\d{6}|\.\d{9})?Z$/u;
9
+
10
+ // The site-scoped authoring key is read only from the environment. There is no
11
+ // flag, no config field, and no prompt: a credential on the command line lands
12
+ // in shell history, `ps` output, and CI logs, and a credential in the config
13
+ // file lands in the repository.
14
+ export const PUBLISH_KEY_ENVIRONMENT_VARIABLE = "TAPROOT_SITE_KEY";
15
+ export const DEFAULT_API_BASE_URL = "https://app.taproot.io/api";
16
+
17
+ // The one other Taproot that exists. `env` names these rather than making an
18
+ // operator type a URL, because the two of them are the whole set and a typo in
19
+ // the third component of a hostname is the kind of mistake that silently
20
+ // authors against the wrong place.
21
+ export const LOCAL_API_BASE_URL = "https://app.taproot.test/api";
22
+ export const ENVIRONMENT_PRODUCTION = "production";
23
+ export const ENVIRONMENT_LOCAL = "local";
24
+
25
+ // Which Taproot the CLI talks to, remembered per machine rather than per
26
+ // project (TR00645).
27
+ //
28
+ // It was a `taproot-site.json` field first, and that was the wrong home. The
29
+ // endpoint has to be known *before* any project exists — `login`, `sites`, and
30
+ // `use` all run before the file `use` writes — so pinning it in the project
31
+ // meant hand-writing a configuration purely to say "talk to local", including
32
+ // a `workspaceDir` that none of those verbs use. It is a property of the
33
+ // machine an operator is sitting at, not of the site they happen to be
34
+ // authoring, so it lives with the credential it is meaningless without.
35
+ export const SETTINGS_FILE_NAME = "settings.json";
36
+ export const SETTINGS_VERSION = 1;
37
+ export const SETTINGS_FILE_MODE = 0o644;
38
+
39
+ // The credential `login` writes and `logout` removes. It lives under the user's
40
+ // configuration directory, never in the repository: a credential inside the
41
+ // working tree is one `git add -A` away from being published, and one shared
42
+ // checkout away from being someone else's.
43
+ export const CREDENTIAL_DIRECTORY_NAME = "taproot-site";
44
+ export const CREDENTIAL_FILE_NAME = "credentials.json";
45
+ // Bumped by TR00645: the store went from many site credentials to one
46
+ // account sign-in per origin, which is a different record entirely. A v1
47
+ // store is refused with a message naming `login` rather than migrated —
48
+ // the CLI is unpublished, so the only v1 stores are in this repository, and
49
+ // a migration path would be code that exists for nobody.
50
+ export const CREDENTIAL_STORE_VERSION = 2;
51
+ export const CREDENTIAL_DIRECTORY_MODE = 0o700;
52
+ export const CREDENTIAL_FILE_MODE = 0o600;
53
+
54
+ // The browser page the CLI prints. It is composed from the reviewed API origin
55
+ // and never taken from a response, so a compromised or misconfigured server
56
+ // cannot steer an owner to somewhere else to approve a credential.
57
+ export const AUTHORIZE_CLI_PATH = "/authorize-cli";
58
+
59
+ /**
60
+ * The workspace directory `use` writes into a configuration it creates
61
+ * (TR00645).
62
+ *
63
+ * A real child directory rather than ".", because `validateWorkspaceDirectoryText`
64
+ * rejects a "." segment: the workspace must be *beneath* the configuration, so
65
+ * that pulling a site can never scatter page files over whatever else lives
66
+ * beside taproot-site.json.
67
+ */
68
+ export const DEFAULT_WORKSPACE_DIR = "site";
69
+ export const DEFAULT_LOGIN_KEY_NAME = "Site authoring CLI";
70
+ export const LOGIN_KEY_NAME_MAXIMUM = 100;
71
+
72
+ export const GITHUB_OUTPUT_RESULT_KEY = "taproot_site_result";
73
+ export const GITHUB_OUTPUT_KEY_PREFIX = "taproot_site";
74
+
75
+ // The verbs this binary accepts, in help order. The CLI matches the longest
76
+ // token sequence, so a two-token family ("pages push") never collides with a
77
+ // one-token verb.
78
+ export const VERB_PULL = "pull";
79
+ export const VERB_HELP = "help";
80
+ export const VERB_VALIDATE = "validate";
81
+ export const VERB_LOGIN = "login";
82
+ export const VERB_LOGOUT = "logout";
83
+ export const VERB_PAGES_PUSH = "pages push";
84
+ export const VERB_NAV_PUSH = "nav push";
85
+ export const VERB_THEME_PUSH = "theme push";
86
+ export const VERB_FOOTER_PUSH = "footer push";
87
+ export const VERB_MEDIA_UPLOAD = "media upload";
88
+ export const VERB_APPROVE = "approve";
89
+ export const VERB_DEPLOY = "deploy";
90
+ export const VERB_PREVIEW_PAGE = "preview page";
91
+ export const VERB_PREVIEW_REVOKE = "preview revoke";
92
+ export const VERB_STATUS = "status";
93
+ export const VERB_SITES = "sites";
94
+ export const VERB_USE = "use";
95
+ export const VERB_WHOAMI = "whoami";
96
+ export const VERB_ENV = "env";
97
+
98
+ export const DEPLOY_TARGET_STAGING = "staging";
99
+ export const DEPLOY_TARGET_PRODUCTION = "production";
100
+
101
+ /**
102
+ * The refusal vocabulary TR00602/TR00603 speak, collapsed into the four
103
+ * distinct client behaviors plus an explicit "we do not know" value. The CLI
104
+ * keeps these apart because an agent's correct next move differs for each:
105
+ * see `ApiError.refusalKind()` in transport.js.
106
+ */
107
+ export const REFUSAL_PLATFORM_PAUSED = "platform_paused";
108
+ export const REFUSAL_CREDENTIAL_REJECTED = "credential_rejected";
109
+ export const REFUSAL_PLAN_LIMIT = "plan_limit";
110
+ export const REFUSAL_THROTTLED = "throttled";
111
+ export const REFUSAL_UNCLASSIFIED = "unclassified";
112
+
113
+ export const REFUSAL_KINDS = Object.freeze([
114
+ REFUSAL_PLATFORM_PAUSED,
115
+ REFUSAL_CREDENTIAL_REJECTED,
116
+ REFUSAL_PLAN_LIMIT,
117
+ REFUSAL_THROTTLED,
118
+ REFUSAL_UNCLASSIFIED,
119
+ ]);
120
+
121
+ // The validation field names the API refuses with. They are wire identities,
122
+ // not display strings, so they are matched case-insensitively but never
123
+ // reworded.
124
+ export const ROLLOUT_REFUSAL_FIELD = "SiteAuthoringRollout";
125
+ export const CREDENTIAL_REFUSAL_FIELD = "ExternalApiKey";
126
+ export const PLAN_LIMIT_REFUSAL_FIELD = "UpgradePrompt";
127
+
128
+ // gRPC `ResourceExhausted`. Transcoded responses carry the numeric code in the
129
+ // body; the HTTP mapping is 429.
130
+ export const GRPC_RESOURCE_EXHAUSTED = 8;
131
+ export const HTTP_TOO_MANY_REQUESTS = 429;
132
+
133
+ // gRPC `Unauthenticated`, and its HTTP mapping. The authority resolver refuses
134
+ // a bad credential with a bare status and no field violation at all, so status
135
+ // is the only thing there is to classify on: see `ApiError.refusalKind()`.
136
+ export const GRPC_UNAUTHENTICATED = 16;
137
+ export const HTTP_UNAUTHORIZED = 401;
138
+
139
+ export const LIMITS = Object.freeze({
140
+ configBytes: 16 * 1024,
141
+ configPathBytes: 4 * 1024,
142
+ // One version and one URL. Anything larger is not this file.
143
+ settingsBytes: 4 * 1024,
144
+ configDiscoveryParents: 32,
145
+ workspacePathBytes: 4 * 1024,
146
+ apiResponseBytes: 1024 * 1024,
147
+ requestMilliseconds: 60_000,
148
+ uploadMilliseconds: 5 * 60_000,
149
+ deploymentMilliseconds: 30 * 60_000,
150
+ previewMilliseconds: 5 * 60_000,
151
+ pollIntervalMilliseconds: 2_000,
152
+ requestAttempts: 4,
153
+ uploadAttempts: 4,
154
+ diagnosticScalars: 2_000,
155
+ githubOutputBytes: 64 * 1024,
156
+ credentialsBytes: 64 * 1024,
157
+ // The store's read-modify-write is serialized by an exclusive lock file so two
158
+ // overlapping logins (or a login racing a logout) cannot discard each other's
159
+ // records — the store holds one record per API origin, so concurrent writes
160
+ // across origins are real. Contention is human-scale and brief; the bound
161
+ // exists so a crashed process's leftover lock fails loudly instead of hanging.
162
+ credentialLockAttempts: 20,
163
+ credentialLockRetryMilliseconds: 100,
164
+ // The login poll takes its wait and its cadence from the authorization the
165
+ // server actually issued (`expiresInSeconds`, `pollIntervalSeconds`) rather
166
+ // than from a constant here, because the server owns that policy. These are
167
+ // the walls that value is clamped between, so a wrong or hostile response
168
+ // cannot turn one interactive sign-in into an unbounded wait or a poll fast
169
+ // enough to trip the endpoint's own rate limiter.
170
+ loginMaximumMilliseconds: 30 * 60_000,
171
+ loginPollIntervalMinimumMilliseconds: 2_000,
172
+ loginPollIntervalMaximumMilliseconds: 60_000,
173
+ });