@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/session.js ADDED
@@ -0,0 +1,316 @@
1
+ import {
2
+ CLI_BINARY_NAME,
3
+ CLI_NAME,
4
+ CLI_VERSION,
5
+ DEFAULT_API_BASE_URL,
6
+ PUBLISH_KEY_ENVIRONMENT_VARIABLE,
7
+ RESULT_SCHEMA_VERSION,
8
+ VERB_LOGIN,
9
+ VERB_USE,
10
+ } from "./constants.js";
11
+ import { loadSiteConfig } from "./config.js";
12
+ import {
13
+ credentialApiOrigin,
14
+ readCredentialStore,
15
+ selectCredential,
16
+ updateCredentialMetadata,
17
+ } from "./credentials.js";
18
+ import { SiteAuthoringError } from "./errors.js";
19
+ import { exchangeSiteAuthoringToken } from "./api.js";
20
+ import { SiteApiClient } from "./transport.js";
21
+ import { readStoredApiBaseUrl } from "./settings.js";
22
+
23
+ /**
24
+ * The two things every verb does at its edges: open a credentialed session, and
25
+ * shape the one machine-readable result that reaches stdout.
26
+ *
27
+ * Since TR00645 the credential a verb runs on is usually not the credential on
28
+ * disk. What is stored is an account-scoped sign-in that authorizes nothing;
29
+ * what a verb needs is a site credential, minted on demand by exchanging that
30
+ * sign-in for one. The exchange happens here so no verb has to know it happened.
31
+ *
32
+ * The exchanged credential is held in memory for the life of the process and
33
+ * never written down. That is deliberate: it expires within the hour, it is
34
+ * cheap to mint again, and a short-lived secret in a file is a long-lived
35
+ * secret in a backup.
36
+ */
37
+
38
+ /** The optional seams a test (or a future caller) may inject. */
39
+ const CLIENT_OPTION_KEYS = ["fetch", "signal", "sleep", "now", "timeoutSignal"];
40
+
41
+ function progressSink(invocation) {
42
+ return typeof invocation.onProgress === "function" ? invocation.onProgress : () => {};
43
+ }
44
+
45
+ function keyMissingError() {
46
+ return new SiteAuthoringError(
47
+ "auth.key_missing",
48
+ `No Taproot sign-in is available. Run '${CLI_BINARY_NAME} ${VERB_LOGIN}' to authorize this CLI, `
49
+ + `or set ${PUBLISH_KEY_ENVIRONMENT_VARIABLE} to a site-scoped Taproot site authoring key.`,
50
+ );
51
+ }
52
+
53
+ function siteMissingError() {
54
+ return new SiteAuthoringError(
55
+ "config.site_missing",
56
+ `No site is selected. Run '${CLI_BINARY_NAME} ${VERB_USE} <site>' to choose one, `
57
+ + "or set siteId in taproot-site.json.",
58
+ );
59
+ }
60
+
61
+ async function loadConfig(invocation) {
62
+ return await loadSiteConfig({
63
+ cwd: invocation.cwd ?? process.cwd(),
64
+ configPath: invocation.configPath,
65
+ });
66
+ }
67
+
68
+ function buildClientOptions(invocation, apiBaseUrl, token) {
69
+ const clientOptions = { apiBaseUrl, ...(token === undefined ? {} : { token }) };
70
+ for (const key of CLIENT_OPTION_KEYS) {
71
+ if (invocation[key] !== undefined) clientOptions[key] = invocation[key];
72
+ }
73
+ return clientOptions;
74
+ }
75
+
76
+ /**
77
+ * The stored sign-in for the configured origin, or a refusal naming `login`.
78
+ *
79
+ * Read before the configuration, and that ordering is load bearing: "there is
80
+ * no credential anywhere" is knowable without a configuration, and answering it
81
+ * first preserves the long-standing promise that a credential-less invocation
82
+ * stops before reading config, resolving a workspace path, or building a
83
+ * request.
84
+ */
85
+ async function requireSignIn(environment, resolveApiBaseUrl) {
86
+ const store = await readCredentialStore(environment);
87
+ // Emptiness is answered before the configuration is resolved at all. That
88
+ // ordering is a promise, not an optimization: a credential-less invocation
89
+ // must stop before reading config, resolving a workspace path, or building a
90
+ // request, and answering "no credential anywhere" needs none of those.
91
+ if (store.credentials.length === 0) throw keyMissingError();
92
+ const stored = selectCredential(store.credentials, credentialApiOrigin(await resolveApiBaseUrl()));
93
+ if (stored === undefined) throw keyMissingError();
94
+ return stored;
95
+ }
96
+
97
+ /**
98
+ * Opens an account-authorized session: the sign-in credential itself, used for
99
+ * the two things it can do. Never used to author.
100
+ */
101
+ export async function openAccountSession(invocation = {}) {
102
+ const environment = invocation.environment ?? process.env;
103
+ // Tolerates a missing configuration for the same reason `login` does: `sites`
104
+ // and `use` are what an operator runs *before* there is one, and requiring
105
+ // the file they exist to produce would be a loop.
106
+ let resolved;
107
+ const signIn = await requireSignIn(environment, async () => {
108
+ resolved = await openAnonymousSession({ ...invocation, client: null });
109
+ return resolved.apiBaseUrl;
110
+ });
111
+ const { config, apiBaseUrl } = resolved;
112
+ const client = invocation.client
113
+ ?? new SiteApiClient(buildClientOptions(invocation, apiBaseUrl, signIn.key));
114
+ return {
115
+ config,
116
+ apiBaseUrl,
117
+ client,
118
+ signIn,
119
+ now: invocation.now ?? Date.now,
120
+ onProgress: progressSink(invocation),
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Opens a site-authorized session for the configured site.
126
+ *
127
+ * Two credential sources, in the one order that keeps existing automation
128
+ * unaffected:
129
+ *
130
+ * `TAPROOT_SITE_KEY` wins outright and byte-for-byte, and skips the exchange
131
+ * entirely. CI and non-interactive agents set it deliberately, and a stored
132
+ * sign-in silently overriding it would be the worst kind of surprise. Presence
133
+ * is authoritative even when the value is empty or malformed — including CI
134
+ * that masked a missing secret into an empty string — because an operator who
135
+ * set the variable meant that credential and no other; falling through would
136
+ * run the command as a different identity. The transport rejects an unusable
137
+ * value with a clear code instead.
138
+ *
139
+ * There is deliberately no second environment variable for the sign-in
140
+ * credential. One override, one precedence rule.
141
+ */
142
+ /**
143
+ * Which Taproot to talk to. Machine state, then the production default — the
144
+ * project has no say since TR00645, so every session path resolves it here
145
+ * rather than reaching into a config field that no longer exists.
146
+ */
147
+ export async function resolveApiBaseUrl(environment) {
148
+ return (await readStoredApiBaseUrl(environment)) ?? DEFAULT_API_BASE_URL;
149
+ }
150
+
151
+ export async function openSession(invocation = {}) {
152
+ const environment = invocation.environment ?? process.env;
153
+ const onProgress = progressSink(invocation);
154
+ const now = invocation.now ?? Date.now;
155
+
156
+ // The endpoint is resolved inside each branch, not ahead of both. The
157
+ // stored-sign-in branch promises to answer "no credential anywhere" before
158
+ // it reads anything else, and the machine settings file is something else:
159
+ // resolving it first would turn an empty store plus a malformed settings
160
+ // file into a settings error, in front of the one answer that needs no file
161
+ // at all.
162
+ const environmentToken = environment?.[PUBLISH_KEY_ENVIRONMENT_VARIABLE];
163
+ if (environmentToken !== undefined) {
164
+ const apiBaseUrl = await resolveApiBaseUrl(environment);
165
+ const config = await loadConfig(invocation);
166
+ if (!config.siteId) throw siteMissingError();
167
+ const client = invocation.client
168
+ ?? new SiteApiClient(buildClientOptions(invocation, apiBaseUrl, environmentToken));
169
+ return { config, client, siteId: config.siteId, now, onProgress };
170
+ }
171
+
172
+ let config;
173
+ let apiBaseUrl;
174
+ const signIn = await requireSignIn(environment, async () => {
175
+ apiBaseUrl = await resolveApiBaseUrl(environment);
176
+ config = await loadConfig(invocation);
177
+ return apiBaseUrl;
178
+ });
179
+ if (!config.siteId) throw siteMissingError();
180
+
181
+ // The server is the authority on expiry, so an expired sign-in is still sent
182
+ // — the clock here may simply be wrong. What is worth saying out loud is that
183
+ // the credential is already past the expiry it recorded, so a refusal that
184
+ // follows is explained rather than mysterious.
185
+ if (signIn.keyExpiresAt !== undefined && Date.parse(signIn.keyExpiresAt) <= now()) {
186
+ onProgress(
187
+ `The stored sign-in expired at ${signIn.keyExpiresAt}. `
188
+ + `If Taproot refuses it, run '${CLI_BINARY_NAME} ${VERB_LOGIN}' again.`,
189
+ );
190
+ }
191
+
192
+ // A caller may supply the site client directly (tests, and the verbs that
193
+ // already hold one); otherwise the sign-in is exchanged for one.
194
+ if (invocation.client) {
195
+ return { config, client: invocation.client, siteId: config.siteId, now, onProgress };
196
+ }
197
+
198
+ const accountClient = new SiteApiClient(buildClientOptions(invocation, apiBaseUrl, signIn.key));
199
+ const exchanged = await exchangeSiteAuthoringToken(accountClient, {
200
+ siteId: config.siteId,
201
+ capabilities: invocation.capabilities ?? [],
202
+ });
203
+ // Two non-secret facts the store should now carry: the sign-in's slid
204
+ // deadline, and what this exchange actually produced — the latter is the only
205
+ // thing that lets `whoami` answer offline with the real grant rather than the
206
+ // ceiling.
207
+ //
208
+ // A compare-and-set, not a save: the record was read before a network round
209
+ // trip, and in that window a concurrent logout may have removed it or a
210
+ // login replaced it. Writing the pre-flight copy back would resurrect a
211
+ // discarded credential or overwrite a newer one.
212
+ //
213
+ // Best-effort: failing a command because its *metadata* could not be recorded
214
+ // would trade a working run for a cosmetic accuracy, and the next exchange
215
+ // tries again.
216
+ try {
217
+ await updateCredentialMetadata(
218
+ environment,
219
+ credentialApiOrigin(apiBaseUrl),
220
+ signIn.keyId,
221
+ {
222
+ ...(exchanged.signInExpiresAt === undefined ? {} : { keyExpiresAt: exchanged.signInExpiresAt }),
223
+ lastExchange: {
224
+ siteId: exchanged.siteId,
225
+ capabilities: exchanged.capabilities,
226
+ expiresAt: exchanged.expiresAt,
227
+ },
228
+ },
229
+ );
230
+ } catch {
231
+ // Deliberately swallowed; see above.
232
+ }
233
+
234
+ const client = new SiteApiClient(buildClientOptions(invocation, apiBaseUrl, exchanged.key));
235
+ return {
236
+ config,
237
+ client,
238
+ siteId: config.siteId,
239
+ exchanged: {
240
+ keyId: exchanged.keyId,
241
+ keyPrefix: exchanged.keyPrefix,
242
+ expiresAt: exchanged.expiresAt,
243
+ capabilities: exchanged.capabilities,
244
+ },
245
+ now,
246
+ onProgress,
247
+ };
248
+ }
249
+
250
+ /**
251
+ * The credential-free half of the session seam, for the verbs that manage the
252
+ * credential rather than use it.
253
+ *
254
+ * Since TR00645 `login` needs no site and therefore no configuration at all:
255
+ * the account sign-in is addressed by API origin, which has a default. So the
256
+ * configuration is loaded when one is discoverable and quietly skipped when it
257
+ * is not — which is what lets `npm i -g` then `login` work in any directory.
258
+ */
259
+ export async function openAnonymousSession(invocation = {}) {
260
+ let config;
261
+ try {
262
+ config = await loadConfig(invocation);
263
+ } catch (error) {
264
+ // Only "there is no configuration here" is tolerated, and only when the
265
+ // caller did not name one. Every other config failure — malformed JSON, a
266
+ // duplicate key, an unsafe workspace path, an unreviewed API origin —
267
+ // surfaces. Swallowing those would silently fall back to the production
268
+ // origin and let overwrite a configuration written for another one.
269
+ if (invocation.configPath !== undefined) throw error;
270
+ if (!(error instanceof SiteAuthoringError) || error.code !== "config.not_found") throw error;
271
+ config = undefined;
272
+ }
273
+
274
+ // The endpoint comes from the machine, not the project (TR00645). It has to:
275
+ // `login`, `sites`, and `use` all run before a project configuration exists,
276
+ // so a project-pinned endpoint could never have told the first of them where
277
+ // to go.
278
+ const apiBaseUrl = await resolveApiBaseUrl(invocation.environment ?? process.env);
279
+ // `client: null` is how openAccountSession borrows the config resolution
280
+ // without paying for an anonymous client it will immediately replace.
281
+ const client = invocation.client === null
282
+ ? undefined
283
+ : invocation.client ?? SiteApiClient.anonymous(buildClientOptions(invocation, apiBaseUrl));
284
+ return {
285
+ config,
286
+ apiBaseUrl,
287
+ client,
288
+ now: invocation.now ?? Date.now,
289
+ onProgress: progressSink(invocation),
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Bounds a list that is about to travel in a result. The result must fit
295
+ * `LIMITS.githubOutputBytes`, and an unbounded per-item report over a large
296
+ * site would blow that bound and turn a successful run into an
297
+ * `output.too_large` failure — losing the record of work that already
298
+ * happened. Truncation is reported, never silent.
299
+ */
300
+ export function boundedList(values, maximum) {
301
+ const items = values.slice(0, maximum);
302
+ return items.length === values.length
303
+ ? { items, count: values.length }
304
+ : { items, count: values.length, truncated: true };
305
+ }
306
+
307
+ export function successResult(verb, siteId, payload = {}) {
308
+ return {
309
+ schemaVersion: RESULT_SCHEMA_VERSION,
310
+ ok: true,
311
+ cli: { name: CLI_NAME, version: CLI_VERSION },
312
+ verb,
313
+ ...(siteId === undefined ? {} : { siteId }),
314
+ ...payload,
315
+ };
316
+ }
@@ -0,0 +1,187 @@
1
+ import { parseTheme } from "@taprootio/espalier/shared/theme";
2
+
3
+ import { SiteAuthoringError } from "./errors.js";
4
+ import { projectFooterSettingsForWorkspace } from "./footer-contract.js";
5
+
6
+ /**
7
+ * The settings surface the authoring CLI covers.
8
+ *
9
+ * `GetSettings` answers with a oneof-shaped envelope: one property per group,
10
+ * and — because proto3 omits zero values — a field the site has never set is
11
+ * simply absent rather than present-and-default. A snapshot that inherited that
12
+ * omission would be unreadable: an agent could not tell "unset" from "not part
13
+ * of this group", and a later diff would churn. So the projection below
14
+ * materializes every in-scope field at its zero value, which is exactly what
15
+ * the server would answer with.
16
+ *
17
+ * Scope (TR00604's refinement):
18
+ * - The four theme-gated groups are covered. `SETTING_TYPE_SITE_RUNTIME_PREFERENCES`
19
+ * is not: it is gated on `site.settings.manage` rather than theme management,
20
+ * promotes immediately rather than through staging, and is site administration
21
+ * rather than authored presentation.
22
+ * - `lightTheme` and `darkTheme` are decoded into readable JSON objects. Theme
23
+ * push validates and re-encodes them only at the wire boundary.
24
+ * - `footerSettings` is snapshotted as its structured document. Theme push
25
+ * overlays only its scheme colors onto a fresh server read before using the
26
+ * optimistic-concurrency footer endpoint.
27
+ * - `membershipsEnabled` is excluded. It appears in the publishing-preferences
28
+ * message but not in the group's settable field set: it is a plan-derived
29
+ * flag the server computes, not site configuration a workspace owns.
30
+ *
31
+ * `wireType` records how a field crosses `SetSetting`, whose `value` is a bare
32
+ * string for every type. Theme push uses it for scalar writes; complete themes
33
+ * and footer settings take their dedicated validated paths. It also lets the
34
+ * read projection normalize an omitted field to the right zero value.
35
+ */
36
+
37
+ export const SETTINGS_TYPE_TAPROOT_STYLES = "SETTING_TYPE_TAPROOT_STYLES";
38
+ export const SETTINGS_TYPE_BRAND = "SETTING_TYPE_BRAND";
39
+ export const SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES = "SETTING_TYPE_SITE_PUBLISHING_PREFERENCES";
40
+ export const SETTINGS_TYPE_SITE_HEADER = "SETTING_TYPE_SITE_HEADER";
41
+
42
+ function stringFields(...names) {
43
+ return names.map((name) => ({ name, wireType: "string" }));
44
+ }
45
+
46
+ export const SETTINGS_GROUPS = Object.freeze([
47
+ Object.freeze({
48
+ settingsType: SETTINGS_TYPE_TAPROOT_STYLES,
49
+ // The property `GetSettingsResponse` carries this group's message under.
50
+ responseProperty: "styleSettings",
51
+ // The workspace file `pull` writes this group into.
52
+ file: "taproot-styles.json",
53
+ candidateSelectable: true,
54
+ fields: Object.freeze([
55
+ { name: "lightTheme", wireType: "theme" },
56
+ { name: "darkTheme", wireType: "theme" },
57
+ ...stringFields(
58
+ "defaultScheme",
59
+ "fontBrand",
60
+ "fontWeightBrand",
61
+ "lightLogoId",
62
+ "lightLogoUrl",
63
+ "darkLogoId",
64
+ "darkLogoUrl",
65
+ "lightCanvasImageId",
66
+ "lightCanvasImageUrl",
67
+ "darkCanvasImageId",
68
+ "darkCanvasImageUrl",
69
+ ),
70
+ { name: "lightCanvasImageOpacity", wireType: "double" },
71
+ { name: "darkCanvasImageOpacity", wireType: "double" },
72
+ ]),
73
+ }),
74
+ Object.freeze({
75
+ settingsType: SETTINGS_TYPE_BRAND,
76
+ responseProperty: "brandSettings",
77
+ file: "brand.json",
78
+ candidateSelectable: true,
79
+ fields: Object.freeze(stringFields("faviconId", "faviconUrl")),
80
+ }),
81
+ Object.freeze({
82
+ settingsType: SETTINGS_TYPE_SITE_HEADER,
83
+ responseProperty: "headerSettings",
84
+ file: "site-header.json",
85
+ candidateSelectable: true,
86
+ fields: Object.freeze([
87
+ ...stringFields(
88
+ "headerLayout",
89
+ "brandText",
90
+ "brandColor",
91
+ "logoAlt",
92
+ "navMenuDisplay",
93
+ "headerPosition",
94
+ "lightBrandColor",
95
+ "darkBrandColor",
96
+ ),
97
+ { name: "showThemeToggle", wireType: "boolean" },
98
+ { name: "showBrandText", wireType: "boolean" },
99
+ { name: "brandLogoSize", wireType: "string" },
100
+ { name: "brandHoverGrow", wireType: "boolean" },
101
+ ]),
102
+ }),
103
+ Object.freeze({
104
+ settingsType: SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
105
+ responseProperty: "sitePublishingPreferences",
106
+ file: "site-publishing-preferences.json",
107
+ candidateSelectable: true,
108
+ fields: Object.freeze([
109
+ // `commentsMode` is an enum; on the wire it is the enum's name string,
110
+ // and an omitted value reads as the zero member — which is spelled
111
+ // UNSPECIFIED here, not UNKNOWN as most of this contract's enums are
112
+ // (protos/Settings.proto). A snapshot naming a member that does not
113
+ // exist is worse than one that omits the field.
114
+ { name: "commentsMode", wireType: "enum", zero: "SITE_COMMENTS_MODE_UNSPECIFIED" },
115
+ ...stringFields("tiptapImagePlacement"),
116
+ { name: "allowSearch", wireType: "boolean" },
117
+ { name: "albumSeamless", wireType: "boolean" },
118
+ { name: "tiptapImageMaxHeightVh", wireType: "int" },
119
+ { name: "tiptapImageBorderWidth", wireType: "int" },
120
+ { name: "albumBorderWidth", wireType: "int" },
121
+ { name: "footerSettings", wireType: "document", zero: {} },
122
+ ]),
123
+ }),
124
+ ]);
125
+
126
+ export const CANDIDATE_SELECTABLE_SETTINGS_TYPES = Object.freeze(
127
+ SETTINGS_GROUPS.filter((group) => group.candidateSelectable).map((group) => group.settingsType),
128
+ );
129
+
130
+ function zeroValue(field) {
131
+ if (field.wireType === "boolean") return false;
132
+ if (field.wireType === "int" || field.wireType === "double") return 0;
133
+ if (field.wireType === "theme" || field.wireType === "document") return field.zero ?? {};
134
+ return field.zero ?? "";
135
+ }
136
+
137
+ function normalizeField(field, value) {
138
+ if (value === undefined || value === null) {
139
+ const zero = zeroValue(field);
140
+ return field.wireType === "document" && field.name === "footerSettings"
141
+ ? projectFooterSettingsForWorkspace(zero)
142
+ : zero;
143
+ }
144
+ if (field.wireType === "theme") return decodeTheme(value, field.name);
145
+ if (field.wireType === "document") {
146
+ const document_ = typeof value === "object" && !Array.isArray(value) ? value : zeroValue(field);
147
+ return field.name === "footerSettings" ? projectFooterSettingsForWorkspace(document_) : document_;
148
+ }
149
+ if (field.wireType === "boolean") return value === true;
150
+ if (field.wireType === "int" || field.wireType === "double") {
151
+ // Transcoded 64-bit and some 32-bit numerics arrive as strings.
152
+ const numeric = typeof value === "string" ? Number(value) : value;
153
+ return typeof numeric === "number" && Number.isFinite(numeric) ? numeric : zeroValue(field);
154
+ }
155
+ return typeof value === "string" ? value : zeroValue(field);
156
+ }
157
+
158
+ function decodeTheme(value, field) {
159
+ if (value === "") return {};
160
+ const theme = typeof value === "string" ? parseTheme(value) : undefined;
161
+ if (!theme) {
162
+ throw new SiteAuthoringError(
163
+ "api.theme_contract",
164
+ "Taproot returned a non-empty theme that could not be decoded.",
165
+ { field },
166
+ );
167
+ }
168
+ return theme;
169
+ }
170
+
171
+ /**
172
+ * Projects one `GetSettings` response down to the fields this CLI covers,
173
+ * materializing the zero value for every field proto3 omitted. Fields outside
174
+ * the catalog are dropped rather than snapshotted, so the workspace never
175
+ * records something the CLI cannot round-trip.
176
+ */
177
+ export function projectSettingsGroup(group, response) {
178
+ const message = response !== null && typeof response === "object" && !Array.isArray(response)
179
+ ? response[group.responseProperty]
180
+ : undefined;
181
+ const source = message !== null && typeof message === "object" && !Array.isArray(message) ? message : {};
182
+ const projected = {};
183
+ for (const field of group.fields) {
184
+ projected[field.name] = normalizeField(field, source[field.name]);
185
+ }
186
+ return projected;
187
+ }
@@ -0,0 +1,157 @@
1
+ import { lstat, mkdir, open, unlink } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { atomicWriteFile } from "./atomic-file.js";
5
+ import { readBoundedFile } from "./bounded-file.js";
6
+ import { parseJsonWithDuplicateGuard } from "./json-guard.js";
7
+ import { credentialStoreDirectory } from "./credentials.js";
8
+ import {
9
+ CREDENTIAL_DIRECTORY_MODE,
10
+ DEFAULT_API_BASE_URL,
11
+ ENVIRONMENT_LOCAL,
12
+ ENVIRONMENT_PRODUCTION,
13
+ LIMITS,
14
+ LOCAL_API_BASE_URL,
15
+ SETTINGS_FILE_MODE,
16
+ SETTINGS_FILE_NAME,
17
+ SETTINGS_VERSION,
18
+ } from "./constants.js";
19
+ import { SiteAuthoringError } from "./errors.js";
20
+ import { validateApiBaseUrl } from "./config.js";
21
+
22
+ /**
23
+ * Which Taproot this machine talks to (TR00645).
24
+ *
25
+ * Deliberately not the credential store, though it sits beside it. The store
26
+ * holds secrets at 0600 and is keyed by origin; this holds one non-secret
27
+ * choice *of* an origin, and conflating them would either widen the store's
28
+ * shape or narrow this file's permissions for no reason. Absent means
29
+ * production, so the file existing at all is the exception.
30
+ */
31
+ export function settingsPath(environment = {}) {
32
+ return path.join(credentialStoreDirectory(environment), SETTINGS_FILE_NAME);
33
+ }
34
+
35
+ /** The named environment an origin corresponds to, for reporting. */
36
+ export function environmentNameFor(apiBaseUrl) {
37
+ if (apiBaseUrl === DEFAULT_API_BASE_URL) return ENVIRONMENT_PRODUCTION;
38
+ if (apiBaseUrl === LOCAL_API_BASE_URL) return ENVIRONMENT_LOCAL;
39
+ return "custom";
40
+ }
41
+
42
+ /**
43
+ * Resolves `local` / `production` / an explicit URL to a validated base URL.
44
+ * The explicit form exists for a loopback port during development; it clears
45
+ * exactly the bar a configuration's own origin had to clear, so `env` cannot
46
+ * reach somewhere `taproot-site.json` could not.
47
+ */
48
+ export function resolveEnvironmentSelector(selector) {
49
+ if (selector === ENVIRONMENT_PRODUCTION) return DEFAULT_API_BASE_URL;
50
+ if (selector === ENVIRONMENT_LOCAL) return LOCAL_API_BASE_URL;
51
+ return validateApiBaseUrl(selector);
52
+ }
53
+
54
+ /**
55
+ * The stored endpoint, or undefined when none is set.
56
+ *
57
+ * A malformed settings file refuses rather than falling back to production:
58
+ * an operator who set this to local and then reads a corrupt file back should
59
+ * be told, not silently pointed at the live Taproot.
60
+ */
61
+ export async function readStoredApiBaseUrl(environment = {}) {
62
+ const filePath = settingsPath(environment);
63
+ // The same bounded, no-follow read the credential store uses, for the same
64
+ // reason: this path is under the operator's home and editable by anything
65
+ // running as them. A plain readFile would follow a symlink to a FIFO and
66
+ // block, or to a device and read until memory ran out — both before any
67
+ // size check on the result could run.
68
+ const bytes = await readBoundedFile(filePath, {
69
+ maximumBytes: LIMITS.settingsBytes,
70
+ failures: {
71
+ isOwn: (error) => error instanceof SiteAuthoringError,
72
+ inspect: () => new SiteAuthoringError("settings.unreadable", `Could not inspect '${filePath}'.`),
73
+ notRegular: () =>
74
+ new SiteAuthoringError("settings.not_regular", `'${filePath}' must be a regular file, not a link or directory.`),
75
+ tooLarge: () =>
76
+ new SiteAuthoringError("settings.too_large", `'${filePath}' is larger than ${LIMITS.settingsBytes} bytes.`),
77
+ changed: () => new SiteAuthoringError("settings.changed", `'${filePath}' changed while it was being read.`),
78
+ read: () => new SiteAuthoringError("settings.unreadable", `Could not read '${filePath}'.`),
79
+ },
80
+ });
81
+ if (bytes === undefined) return undefined;
82
+ const contents = bytes.toString("utf8");
83
+
84
+ // Duplicate-guarded, not JSON.parse: this file is hand-editable, and a
85
+ // second `apiBaseUrl` would make what it visibly says and where it routes a
86
+ // credential two different Taproots.
87
+ const parsed = parseJsonWithDuplicateGuard(contents, {
88
+ onDuplicate: (key) =>
89
+ new SiteAuthoringError("settings.duplicate_key", `'${filePath}' declares '${key}' more than once.`),
90
+ onInvalid: () => new SiteAuthoringError("settings.malformed", `'${filePath}' is not valid JSON.`),
91
+ });
92
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
93
+ throw new SiteAuthoringError("settings.malformed", `'${filePath}' must be a JSON object.`);
94
+ }
95
+ if (parsed.schemaVersion !== SETTINGS_VERSION) {
96
+ throw new SiteAuthoringError("settings.unsupported_version", `'${filePath}' must declare schemaVersion ${SETTINGS_VERSION}.`);
97
+ }
98
+ if (typeof parsed.apiBaseUrl !== "string") {
99
+ throw new SiteAuthoringError("settings.malformed", `'${filePath}' has no apiBaseUrl.`);
100
+ }
101
+ // Re-validated on the way out, not just on the way in: the file is editable
102
+ // by hand, and this is the last point before the value becomes the origin a
103
+ // credential is sent to.
104
+ return validateApiBaseUrl(parsed.apiBaseUrl);
105
+ }
106
+
107
+ async function ensureSettingsDirectory(directory) {
108
+ try {
109
+ await mkdir(directory, { recursive: true, mode: CREDENTIAL_DIRECTORY_MODE });
110
+ } catch {
111
+ throw new SiteAuthoringError("settings.unwritable", "Could not create the configuration directory.");
112
+ }
113
+ let stats;
114
+ try {
115
+ stats = await lstat(directory);
116
+ } catch {
117
+ throw new SiteAuthoringError("settings.unwritable", "Could not inspect the configuration directory.");
118
+ }
119
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
120
+ throw new SiteAuthoringError(
121
+ "settings.directory_invalid",
122
+ "The configuration directory must be a real directory, not a link or file.",
123
+ );
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Records the endpoint. Production clears the file rather than writing the
129
+ * default into it, so "no file" and "production" stay the same state and there
130
+ * is only one thing a fresh machine can mean.
131
+ */
132
+ export async function writeStoredApiBaseUrl(environment, apiBaseUrl) {
133
+ const filePath = settingsPath(environment);
134
+ if (apiBaseUrl === DEFAULT_API_BASE_URL) {
135
+ try {
136
+ await unlink(filePath);
137
+ } catch (error) {
138
+ if (!error || typeof error !== "object" || error.code !== "ENOENT") {
139
+ throw new SiteAuthoringError("settings.unwritable", `Could not clear '${filePath}'.`);
140
+ }
141
+ }
142
+ return filePath;
143
+ }
144
+
145
+ await ensureSettingsDirectory(path.dirname(filePath));
146
+ const contents = `${JSON.stringify({ schemaVersion: SETTINGS_VERSION, apiBaseUrl }, undefined, 2)}\n`;
147
+ await atomicWriteFile(filePath, contents, {
148
+ mode: SETTINGS_FILE_MODE,
149
+ openDirectory: open,
150
+ failures: {
151
+ inspect: () => new SiteAuthoringError("settings.unwritable", `Could not inspect '${filePath}'.`),
152
+ notRegular: () => new SiteAuthoringError("settings.not_regular", `'${filePath}' is not a regular file.`),
153
+ write: () => new SiteAuthoringError("settings.unwritable", `Could not write '${filePath}'.`),
154
+ },
155
+ });
156
+ return filePath;
157
+ }