@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.
- package/LICENSE +15 -0
- package/README.md +239 -0
- package/bin/taproot-site.js +5 -0
- package/package.json +40 -0
- package/src/api.js +1507 -0
- package/src/appearance-contract.js +311 -0
- package/src/atomic-file.js +136 -0
- package/src/bounded-file.js +59 -0
- package/src/capabilities.js +29 -0
- package/src/cli.js +872 -0
- package/src/config.js +412 -0
- package/src/constants.js +173 -0
- package/src/content/components.js +870 -0
- package/src/content/free-form-section-registry.json +524 -0
- package/src/content/free-form-sections.js +582 -0
- package/src/content/index.js +29 -0
- package/src/content/inline-facts.js +97 -0
- package/src/content/markdown.js +1625 -0
- package/src/content/tiptap-prosemirror.ts +1266 -0
- package/src/content/validate-document.js +1079 -0
- package/src/content/vocabulary.js +397 -0
- package/src/credentials.js +544 -0
- package/src/errors.js +133 -0
- package/src/footer-contract.js +687 -0
- package/src/footer-draft-hash.js +203 -0
- package/src/footer-workspace.js +136 -0
- package/src/image-metadata.js +169 -0
- package/src/index.js +16 -0
- package/src/json-guard.js +111 -0
- package/src/output.js +157 -0
- package/src/presentation-reference.js +600 -0
- package/src/reference-help.js +927 -0
- package/src/session.js +316 -0
- package/src/settings-catalog.js +187 -0
- package/src/settings.js +157 -0
- package/src/theme-validation.js +344 -0
- package/src/transport.js +777 -0
- package/src/verbs/approve.js +178 -0
- package/src/verbs/deploy.js +327 -0
- package/src/verbs/env.js +89 -0
- package/src/verbs/footer-push.js +115 -0
- package/src/verbs/index.js +125 -0
- package/src/verbs/login.js +529 -0
- package/src/verbs/logout.js +39 -0
- package/src/verbs/media-upload.js +319 -0
- package/src/verbs/nav-push.js +333 -0
- package/src/verbs/pages-push.js +990 -0
- package/src/verbs/preview-page.js +107 -0
- package/src/verbs/preview-revoke.js +56 -0
- package/src/verbs/pull.js +727 -0
- package/src/verbs/sites.js +57 -0
- package/src/verbs/status.js +94 -0
- package/src/verbs/theme-push.js +197 -0
- package/src/verbs/use.js +129 -0
- package/src/verbs/validate.js +477 -0
- package/src/verbs/whoami.js +108 -0
- 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
|
+
}
|
package/src/settings.js
ADDED
|
@@ -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
|
+
}
|