@taprootio/site-authoring 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +239 -0
  3. package/bin/taproot-site.js +5 -0
  4. package/package.json +40 -0
  5. package/src/api.js +1507 -0
  6. package/src/appearance-contract.js +311 -0
  7. package/src/atomic-file.js +136 -0
  8. package/src/bounded-file.js +59 -0
  9. package/src/capabilities.js +29 -0
  10. package/src/cli.js +872 -0
  11. package/src/config.js +412 -0
  12. package/src/constants.js +173 -0
  13. package/src/content/components.js +870 -0
  14. package/src/content/free-form-section-registry.json +524 -0
  15. package/src/content/free-form-sections.js +582 -0
  16. package/src/content/index.js +29 -0
  17. package/src/content/inline-facts.js +97 -0
  18. package/src/content/markdown.js +1625 -0
  19. package/src/content/tiptap-prosemirror.ts +1266 -0
  20. package/src/content/validate-document.js +1079 -0
  21. package/src/content/vocabulary.js +397 -0
  22. package/src/credentials.js +544 -0
  23. package/src/errors.js +133 -0
  24. package/src/footer-contract.js +687 -0
  25. package/src/footer-draft-hash.js +203 -0
  26. package/src/footer-workspace.js +136 -0
  27. package/src/image-metadata.js +169 -0
  28. package/src/index.js +16 -0
  29. package/src/json-guard.js +111 -0
  30. package/src/output.js +157 -0
  31. package/src/presentation-reference.js +600 -0
  32. package/src/reference-help.js +927 -0
  33. package/src/session.js +316 -0
  34. package/src/settings-catalog.js +187 -0
  35. package/src/settings.js +157 -0
  36. package/src/theme-validation.js +344 -0
  37. package/src/transport.js +777 -0
  38. package/src/verbs/approve.js +178 -0
  39. package/src/verbs/deploy.js +327 -0
  40. package/src/verbs/env.js +89 -0
  41. package/src/verbs/footer-push.js +115 -0
  42. package/src/verbs/index.js +125 -0
  43. package/src/verbs/login.js +529 -0
  44. package/src/verbs/logout.js +39 -0
  45. package/src/verbs/media-upload.js +319 -0
  46. package/src/verbs/nav-push.js +333 -0
  47. package/src/verbs/pages-push.js +990 -0
  48. package/src/verbs/preview-page.js +107 -0
  49. package/src/verbs/preview-revoke.js +56 -0
  50. package/src/verbs/pull.js +727 -0
  51. package/src/verbs/sites.js +57 -0
  52. package/src/verbs/status.js +94 -0
  53. package/src/verbs/theme-push.js +197 -0
  54. package/src/verbs/use.js +129 -0
  55. package/src/verbs/validate.js +477 -0
  56. package/src/verbs/whoami.js +108 -0
  57. package/src/workspace.js +1085 -0
@@ -0,0 +1,544 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import { chmod, lstat, mkdir, open, stat, unlink } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import { atomicWriteFile } from "./atomic-file.js";
7
+ import { readBoundedFile } from "./bounded-file.js";
8
+ import { SITE_AUTHORING_CAPABILITIES } from "./capabilities.js";
9
+
10
+ import {
11
+ CANONICAL_TIMESTAMP,
12
+ CLI_BINARY_NAME,
13
+ CREDENTIAL_DIRECTORY_MODE,
14
+ CREDENTIAL_DIRECTORY_NAME,
15
+ CREDENTIAL_FILE_MODE,
16
+ CREDENTIAL_FILE_NAME,
17
+ CREDENTIAL_STORE_VERSION,
18
+ LIMITS,
19
+ VERB_LOGOUT,
20
+ } from "./constants.js";
21
+ import { hasAsciiControl, isCanonicalUuid, SiteAuthoringError } from "./errors.js";
22
+ import { isWellFormedCredential, MAXIMUM_TOKEN_LENGTH } from "./transport.js";
23
+
24
+ /**
25
+ * The credential `login` writes, `openSession` reads, and `logout` removes.
26
+ *
27
+ * Four properties are the whole point of this module:
28
+ *
29
+ * 1. **It is outside the repository.** The store lives under the user's
30
+ * configuration directory. A credential inside a working tree is one
31
+ * `git add -A` from being published and one shared checkout from being
32
+ * someone else's.
33
+ * 2. **Its location comes from the injected environment**, not from
34
+ * `process.env`. Tests point HOME and XDG_CONFIG_HOME at a temporary
35
+ * directory and get complete control; `os.homedir()` is consulted only when
36
+ * the environment carries neither.
37
+ * 3. **The write is atomic and narrow.** Directory `0700`, file `0600`,
38
+ * temp-file + `O_EXCL|O_NOFOLLOW` + fsync + rename — `atomicWriteFile`,
39
+ * the same primitive `writeWorkspaceFile` uses. What stays here is the part
40
+ * that is actually about a credential: the `0700` directory this file lives
41
+ * in, deliberately outside every workspace, and the mode pinned past the
42
+ * operator's umask.
43
+ * 4. **A read refuses what it cannot vouch for.** A symlink, a non-regular
44
+ * file, an oversized file, or a malformed record is a stable refusal, not a
45
+ * silently ignored entry — a store that quietly drops the record you just
46
+ * wrote is indistinguishable from one that never wrote it.
47
+ *
48
+ * The key itself never leaves this module except as the value `openSession`
49
+ * hands straight to `SiteApiClient`. Nothing here puts it in an error message,
50
+ * a progress line, or a result.
51
+ */
52
+
53
+ // The display prefix the API mints (`tr_live_` + eight characters + "..."). A
54
+ // display value, never a secret, but still bounded and printable. Exported so
55
+ // the wire layer accepts exactly what this store will keep: a prefix accepted
56
+ // on claim but refused on save would turn a successful mint into an orphaned
57
+ // key.
58
+ export const KEY_PREFIX = /^[\w.-]{1,64}$/u;
59
+
60
+ // Stand-ins for the fields a pre-mint projection cannot know yet. Each is the
61
+ // largest value its own validator accepts, so the projected record is an upper
62
+ // bound on the real one rather than a guess at it.
63
+ const PROJECTION_UUID = "00000000-0000-4000-8000-000000000000";
64
+ const PROJECTION_TIMESTAMP = "2000-01-01T00:00:00.000Z";
65
+ const PROJECTION_KEY_PREFIX_LENGTH = 64;
66
+
67
+ function usableDirectory(value) {
68
+ return typeof value === "string"
69
+ && value.length > 0
70
+ && Buffer.byteLength(value, "utf8") <= LIMITS.configPathBytes
71
+ && !hasAsciiControl(value)
72
+ && path.isAbsolute(value);
73
+ }
74
+
75
+ /**
76
+ * `$XDG_CONFIG_HOME`, else `$HOME/.config`, else the runtime's own home. The
77
+ * environment is read from the invocation, never from `process.env`, so a test
78
+ * can relocate the store without mutating the process it runs in.
79
+ */
80
+ export function credentialStoreDirectory(environment = {}) {
81
+ const configHome = environment?.XDG_CONFIG_HOME;
82
+ if (usableDirectory(configHome)) return path.join(configHome, CREDENTIAL_DIRECTORY_NAME);
83
+ const home = environment?.HOME;
84
+ const base = usableDirectory(home) ? home : os.homedir();
85
+ if (!usableDirectory(base)) {
86
+ throw new SiteAuthoringError(
87
+ "credentials.home_unknown",
88
+ "No home or XDG_CONFIG_HOME directory could be resolved for the stored credential.",
89
+ );
90
+ }
91
+ return path.join(base, ".config", CREDENTIAL_DIRECTORY_NAME);
92
+ }
93
+
94
+ export function credentialStorePath(environment = {}) {
95
+ return path.join(credentialStoreDirectory(environment), CREDENTIAL_FILE_NAME);
96
+ }
97
+
98
+ /** The store key: one sign-in per API origin, replaced on re-login. */
99
+ export function credentialApiOrigin(apiBaseUrl) {
100
+ return new URL(apiBaseUrl).origin;
101
+ }
102
+
103
+ function requireStoreShape(condition, message) {
104
+ if (!condition) throw new SiteAuthoringError("credentials.contract", message);
105
+ }
106
+
107
+ /**
108
+ * Validates one stored record. `key` clears exactly the bar the transport
109
+ * applies to `TAPROOT_SITE_KEY`, because it reaches exactly the same header.
110
+ */
111
+ function normalizeEntry(entry) {
112
+ requireStoreShape(
113
+ entry !== null && typeof entry === "object" && !Array.isArray(entry),
114
+ "A stored credential record is not an object.",
115
+ );
116
+ requireStoreShape(
117
+ typeof entry.apiOrigin === "string" && entry.apiOrigin.length > 0 && entry.apiOrigin.length <= 2_048,
118
+ "A stored credential record has an invalid apiOrigin.",
119
+ );
120
+ requireStoreShape(isCanonicalUuid(entry.accountId), "A stored credential record has an invalid accountId.");
121
+ requireStoreShape(isWellFormedCredential(entry.key), "A stored credential record has an invalid key.");
122
+ requireStoreShape(isCanonicalUuid(entry.keyId), "A stored credential record has an invalid keyId.");
123
+ requireStoreShape(
124
+ typeof entry.keyPrefix === "string" && KEY_PREFIX.test(entry.keyPrefix),
125
+ "A stored credential record has an invalid keyPrefix.",
126
+ );
127
+ requireStoreShape(
128
+ entry.keyExpiresAt === undefined
129
+ || (typeof entry.keyExpiresAt === "string" && CANONICAL_TIMESTAMP.test(entry.keyExpiresAt)),
130
+ "A stored credential record has an invalid keyExpiresAt.",
131
+ );
132
+ requireStoreShape(
133
+ typeof entry.createdAt === "string" && CANONICAL_TIMESTAMP.test(entry.createdAt),
134
+ "A stored credential record has an invalid createdAt.",
135
+ );
136
+ // What the last exchange produced. Entirely non-secret — a site id, the
137
+ // capabilities that exchange asked for, and when the credential it returned
138
+ // dies — and the only way `whoami` can answer "what can this actually do"
139
+ // without a network round trip. Absent before the first exchange.
140
+ requireStoreShape(
141
+ entry.lastExchange === undefined
142
+ || (entry.lastExchange !== null
143
+ && typeof entry.lastExchange === "object"
144
+ && !Array.isArray(entry.lastExchange)
145
+ && isCanonicalUuid(entry.lastExchange.siteId)
146
+ && Array.isArray(entry.lastExchange.capabilities)
147
+ && entry.lastExchange.capabilities.every((value) => typeof value === "string")
148
+ && typeof entry.lastExchange.expiresAt === "string"
149
+ && CANONICAL_TIMESTAMP.test(entry.lastExchange.expiresAt)),
150
+ "A stored credential record has an invalid lastExchange.",
151
+ );
152
+ return Object.freeze({
153
+ apiOrigin: entry.apiOrigin,
154
+ accountId: entry.accountId,
155
+ key: entry.key,
156
+ keyId: entry.keyId,
157
+ keyPrefix: entry.keyPrefix,
158
+ ...(entry.keyExpiresAt === undefined ? {} : { keyExpiresAt: entry.keyExpiresAt }),
159
+ ...(entry.lastExchange === undefined ? {} : {
160
+ lastExchange: Object.freeze({
161
+ siteId: entry.lastExchange.siteId,
162
+ capabilities: Object.freeze([...entry.lastExchange.capabilities]),
163
+ expiresAt: entry.lastExchange.expiresAt,
164
+ }),
165
+ }),
166
+ createdAt: entry.createdAt,
167
+ });
168
+ }
169
+
170
+ async function readStoreBytes(filePath) {
171
+ return await readBoundedFile(filePath, {
172
+ maximumBytes: LIMITS.credentialsBytes,
173
+ failures: {
174
+ isOwn: (error) => error instanceof SiteAuthoringError,
175
+ // A missing store is the ordinary state before the first login, not a
176
+ // failure: `openSession` needs to be able to say "no credential anywhere".
177
+ // `readBoundedFile` answers that with undefined before any of these.
178
+ inspect: () =>
179
+ new SiteAuthoringError("credentials.unreadable", "Could not inspect the stored credential file."),
180
+ notRegular: () =>
181
+ new SiteAuthoringError(
182
+ "credentials.not_regular",
183
+ "The stored credential file must be a regular file, not a link or directory.",
184
+ ),
185
+ tooLarge: () =>
186
+ new SiteAuthoringError(
187
+ "credentials.too_large",
188
+ `The stored credential file exceeds ${LIMITS.credentialsBytes} bytes.`,
189
+ ),
190
+ changed: () =>
191
+ new SiteAuthoringError("credentials.changed", "The stored credential file changed while it was being opened."),
192
+ read: () => new SiteAuthoringError("credentials.unreadable", "Could not read the stored credential file."),
193
+ },
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Names the store file in a refusal. Both login and logout route through the
199
+ * same store, so an unusable file fails them identically — and the one thing
200
+ * the operator needs in order to repair or remove it is which of the candidate
201
+ * locations it actually is. The path is operator-local and not a secret.
202
+ */
203
+ function namedStoreFailure(error, filePath) {
204
+ if (!(error instanceof SiteAuthoringError) || error.message.includes(filePath)) return error;
205
+ return new SiteAuthoringError(
206
+ error.code,
207
+ `${error.message.replace(/\.$/u, "")} (${filePath}).`,
208
+ {
209
+ ...(error.field === undefined ? {} : { field: error.field }),
210
+ ...(error.status === undefined ? {} : { status: error.status }),
211
+ exitCode: error.exitCode,
212
+ },
213
+ );
214
+ }
215
+
216
+ /**
217
+ * Reads the whole store. A missing file yields an empty store; anything else
218
+ * that cannot be vouched for is a stable refusal naming the file it is about.
219
+ */
220
+ export async function readCredentialStore(environment = {}) {
221
+ const filePath = credentialStorePath(environment);
222
+ try {
223
+ const bytes = await readStoreBytes(filePath);
224
+ if (bytes === undefined || bytes.byteLength === 0) return { path: filePath, credentials: [] };
225
+ let parsed;
226
+ try {
227
+ parsed = JSON.parse(bytes.toString("utf8"));
228
+ } catch {
229
+ throw new SiteAuthoringError("credentials.invalid_json", "The stored credential file is not valid JSON.");
230
+ }
231
+ requireStoreShape(
232
+ parsed !== null && typeof parsed === "object" && !Array.isArray(parsed),
233
+ "The stored credential file is not a JSON object.",
234
+ );
235
+ if (parsed.schemaVersion !== CREDENTIAL_STORE_VERSION) {
236
+ throw new SiteAuthoringError(
237
+ "credentials.unsupported_version",
238
+ `The stored credential file must declare schemaVersion ${CREDENTIAL_STORE_VERSION}.`,
239
+ );
240
+ }
241
+ requireStoreShape(Array.isArray(parsed.credentials), "The stored credential file has no credentials array.");
242
+ return { path: filePath, credentials: parsed.credentials.map(normalizeEntry) };
243
+ } catch (error) {
244
+ throw namedStoreFailure(error, filePath);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * The one sign-in for this API origin, or undefined.
250
+ *
251
+ * Keyed by origin alone since TR00645. Through TR00602 this was keyed by
252
+ * `(origin, siteId)` because every stored credential authorized exactly one
253
+ * site; the sign-in credential authorizes an account, and an operator has one
254
+ * of those per Taproot they use.
255
+ */
256
+ export function selectCredential(credentials, apiOrigin) {
257
+ return credentials.find((entry) => entry.apiOrigin === apiOrigin);
258
+ }
259
+
260
+ export async function findCredential(environment, apiOrigin) {
261
+ const store = await readCredentialStore(environment);
262
+ return { path: store.path, credential: selectCredential(store.credentials, apiOrigin) };
263
+ }
264
+
265
+ /**
266
+ * Narrows a mode that is wider than it should be, and never widens one. On
267
+ * Windows `chmod` is advisory at best, so a failure here is not a failure of
268
+ * the write it accompanies.
269
+ */
270
+ async function narrowMode(target, allowed) {
271
+ try {
272
+ const current = await stat(target);
273
+ const mode = current.mode & 0o777;
274
+ if ((mode & ~allowed) !== 0) await chmod(target, mode & allowed);
275
+ } catch {
276
+ // Best-effort only; the store's real protection is the directory it is in.
277
+ }
278
+ }
279
+
280
+ async function ensureStoreDirectory(directory) {
281
+ try {
282
+ await mkdir(directory, { recursive: true, mode: CREDENTIAL_DIRECTORY_MODE });
283
+ } catch {
284
+ throw new SiteAuthoringError(
285
+ "credentials.unwritable",
286
+ "Could not create the configuration directory for the stored credential.",
287
+ );
288
+ }
289
+ let stats;
290
+ try {
291
+ stats = await lstat(directory);
292
+ } catch {
293
+ throw new SiteAuthoringError(
294
+ "credentials.unwritable",
295
+ "Could not inspect the configuration directory for the stored credential.",
296
+ );
297
+ }
298
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
299
+ throw new SiteAuthoringError(
300
+ "credentials.directory_invalid",
301
+ "The credential directory must be a real directory, not a link or file.",
302
+ );
303
+ }
304
+ await narrowMode(directory, CREDENTIAL_DIRECTORY_MODE);
305
+ }
306
+
307
+ /**
308
+ * Replaces the whole store atomically. The temp file is created `0600` with
309
+ * `O_EXCL|O_NOFOLLOW`, fsynced, then renamed over the destination — so a
310
+ * concurrent reader sees either the old complete store or the new one, and
311
+ * never a half-written file with a truncated key in it.
312
+ */
313
+ async function writeCredentialStore(filePath, credentials) {
314
+ try {
315
+ return await writeCredentialStoreUnnamed(filePath, credentials);
316
+ } catch (error) {
317
+ throw namedStoreFailure(error, filePath);
318
+ }
319
+ }
320
+
321
+ /**
322
+ * The exact bytes a given set of records becomes on disk. Shared by the write
323
+ * and by the pre-mint projection so the two cannot disagree about the format
324
+ * they are each measuring.
325
+ */
326
+ function serializeStore(credentials) {
327
+ return `${JSON.stringify({ schemaVersion: CREDENTIAL_STORE_VERSION, credentials }, undefined, 2)}\n`;
328
+ }
329
+
330
+ /**
331
+ * Refuses a store this module's own reader would then refuse.
332
+ *
333
+ * `readStoreBytes` caps the file at `credentialsBytes`, and TR00645 removed the
334
+ * record-count bound that used to keep it under that cap — so without this the
335
+ * writer and the reader no longer agree on what is acceptable, and the failure
336
+ * takes the worst shape available: the write succeeds, and every subsequent
337
+ * read fails permanently against a store the operator did not knowingly create.
338
+ * Checked in bytes, not records, because bytes are what the reader checks.
339
+ */
340
+ function requireStoreFits(contents) {
341
+ if (Buffer.byteLength(contents, "utf8") > LIMITS.credentialsBytes) {
342
+ throw new SiteAuthoringError(
343
+ "credentials.too_large",
344
+ `The stored credential file would exceed ${LIMITS.credentialsBytes} bytes. `
345
+ + `Run '${CLI_BINARY_NAME} ${VERB_LOGOUT}' against a Taproot you no longer author.`,
346
+ );
347
+ }
348
+ }
349
+
350
+ async function writeCredentialStoreUnnamed(filePath, credentials) {
351
+ await ensureStoreDirectory(path.dirname(filePath));
352
+ const contents = serializeStore(credentials);
353
+ requireStoreFits(contents);
354
+
355
+ // `pinMode` matters here and not in the workspace: the creation mode is
356
+ // umask-narrowed, and this file must be exactly 0600 whatever the operator's
357
+ // umask says, not merely no wider than it.
358
+ return await atomicWriteFile(filePath, contents, {
359
+ mode: CREDENTIAL_FILE_MODE,
360
+ pinMode: true,
361
+ failures: {
362
+ inspect: () =>
363
+ new SiteAuthoringError("credentials.unwritable", "Could not inspect the stored credential file."),
364
+ notRegular: () =>
365
+ new SiteAuthoringError(
366
+ "credentials.not_regular",
367
+ "The stored credential file must be a regular file, not a link or directory.",
368
+ ),
369
+ write: () =>
370
+ new SiteAuthoringError("credentials.unwritable", "Could not write the stored credential file."),
371
+ },
372
+ });
373
+ }
374
+
375
+ function defaultLockSleep(milliseconds) {
376
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
377
+ }
378
+
379
+ /**
380
+ * Serializes the store's read-modify-write.
381
+ *
382
+ * TR00645 removed this on the reasoning that one record per origin left no
383
+ * array to merge. That reasoning was wrong, and the lock is back: the store
384
+ * holds one record *per origin*, not one record. An operator who uses both
385
+ * production and a local Tilt has two, and two overlapping writes still each
386
+ * read the same array, each write their own complete copy, and the second
387
+ * rename still silently discards the first's record — a live, unrevoked
388
+ * credential whose local copy just vanished.
389
+ *
390
+ * The rename inside `writeCredentialStore` is atomic; the read-filter-write
391
+ * sequence around it is not, and that is what this guards. The lock is an
392
+ * `O_EXCL` file beside the store; contention is human-scale and brief, and a
393
+ * leftover lock from a crashed process fails loudly with its path rather than
394
+ * hanging.
395
+ */
396
+ async function withStoreLock(filePath, action, { sleep = defaultLockSleep } = {}) {
397
+ const directory = path.dirname(filePath);
398
+ await ensureStoreDirectory(directory);
399
+ const lockPath = `${filePath}.lock`;
400
+ let handle;
401
+ for (let attempt = 1;; attempt++) {
402
+ try {
403
+ handle = await open(
404
+ lockPath,
405
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0),
406
+ CREDENTIAL_FILE_MODE,
407
+ );
408
+ break;
409
+ } catch (error) {
410
+ if (error && typeof error === "object" && error.code === "EEXIST") {
411
+ if (attempt >= LIMITS.credentialLockAttempts) {
412
+ throw namedStoreFailure(
413
+ new SiteAuthoringError(
414
+ "credentials.locked",
415
+ "Another taproot-site command is updating the stored credential file. "
416
+ + "If none is running, remove the leftover lock file.",
417
+ ),
418
+ lockPath,
419
+ );
420
+ }
421
+ await sleep(LIMITS.credentialLockRetryMilliseconds);
422
+ continue;
423
+ }
424
+ throw namedStoreFailure(
425
+ new SiteAuthoringError(
426
+ "credentials.unwritable",
427
+ "Could not create the lock file beside the stored credential file.",
428
+ ),
429
+ lockPath,
430
+ );
431
+ }
432
+ }
433
+ try {
434
+ return await action();
435
+ } finally {
436
+ await handle.close().catch(() => {});
437
+ await unlink(lockPath).catch(() => {});
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Refuses, before anything is minted, a login whose credential could not then
443
+ * be stored.
444
+ *
445
+ * The write-time bound alone is not enough for `login`. By the time the write
446
+ * runs the credential exists on the server, so a refusal there is the one
447
+ * outcome the login path works hardest to avoid: a live credential nobody
448
+ * holds, which the operator has to go and revoke by hand. Projecting the
449
+ * post-save size before the claim converts that into an ordinary refusal
450
+ * costing nothing.
451
+ *
452
+ * The projection is deliberately pessimistic. It measures a record with a
453
+ * maximum-length key, a maximum-length display prefix, and the `lastExchange`
454
+ * block a first exchange will add — none of which are known yet at preflight,
455
+ * and all of which only grow the record. Over-refusing here means telling
456
+ * someone with a nearly full store to log out of a Taproot they no longer use;
457
+ * under-refusing means orphaning a credential. The asymmetry picks the side.
458
+ */
459
+ export async function assertCredentialCapacity(environment, apiOrigin) {
460
+ const store = await readCredentialStore(environment);
461
+ const projected = normalizeEntry({
462
+ apiOrigin,
463
+ accountId: PROJECTION_UUID,
464
+ key: "k".repeat(MAXIMUM_TOKEN_LENGTH),
465
+ keyId: PROJECTION_UUID,
466
+ keyPrefix: "p".repeat(PROJECTION_KEY_PREFIX_LENGTH),
467
+ keyExpiresAt: PROJECTION_TIMESTAMP,
468
+ lastExchange: {
469
+ siteId: PROJECTION_UUID,
470
+ capabilities: [...SITE_AUTHORING_CAPABILITIES],
471
+ expiresAt: PROJECTION_TIMESTAMP,
472
+ },
473
+ createdAt: PROJECTION_TIMESTAMP,
474
+ });
475
+ const others = store.credentials.filter((entry) => entry.apiOrigin !== apiOrigin);
476
+ requireStoreFits(serializeStore([...others, projected]));
477
+ return store.path;
478
+ }
479
+
480
+ /**
481
+ * Stores the one sign-in for this API origin, replacing any prior record.
482
+ * `createdAt` comes from the injected clock, so a test observes a deterministic
483
+ * record. The whole read-modify-write runs under the store lock.
484
+ */
485
+ export async function saveCredential(environment, credential, { now = Date.now, sleep } = {}) {
486
+ const record = normalizeEntry({
487
+ ...credential,
488
+ createdAt: new Date(now()).toISOString(),
489
+ });
490
+ const filePath = credentialStorePath(environment);
491
+ return withStoreLock(filePath, async () => {
492
+ const store = await readCredentialStore(environment);
493
+ const others = store.credentials.filter((entry) => entry.apiOrigin !== record.apiOrigin);
494
+ const ordered = [...others, record].sort((left, right) => left.apiOrigin.localeCompare(right.apiOrigin));
495
+ await writeCredentialStore(store.path, ordered);
496
+ return store.path;
497
+ }, { sleep });
498
+ }
499
+
500
+ /**
501
+ * Updates the non-secret metadata on a stored sign-in, and only if it is still
502
+ * the same credential.
503
+ *
504
+ * A plain `saveCredential` would not do: the caller read the record before a
505
+ * network round trip, and in that window a concurrent `logout` may have removed
506
+ * it or a concurrent `login` may have replaced it. Writing the pre-flight copy
507
+ * back would resurrect a discarded credential or overwrite a newer one with a
508
+ * stale key. So the read, the identity check, and the write all happen inside
509
+ * the same lock, and a credential that is no longer the one we exchanged with
510
+ * is left exactly as it is.
511
+ *
512
+ * Metadata only: this never touches the key. Returns whether it updated.
513
+ */
514
+ export async function updateCredentialMetadata(environment, apiOrigin, keyId, metadata, { sleep } = {}) {
515
+ const filePath = credentialStorePath(environment);
516
+ return withStoreLock(filePath, async () => {
517
+ const store = await readCredentialStore(environment);
518
+ const current = selectCredential(store.credentials, apiOrigin);
519
+ if (current === undefined || current.keyId !== keyId) return false;
520
+
521
+ const updated = normalizeEntry({ ...current, ...metadata });
522
+ const others = store.credentials.filter((entry) => entry.apiOrigin !== apiOrigin);
523
+ const ordered = [...others, updated].sort((left, right) => left.apiOrigin.localeCompare(right.apiOrigin));
524
+ await writeCredentialStore(store.path, ordered);
525
+ return true;
526
+ }, { sleep });
527
+ }
528
+
529
+ /**
530
+ * Local discard. Returns whether anything was there — a logout with nothing
531
+ * stored is a successful no-op, not an error, because the state the caller
532
+ * asked for is the state they end up in. The read-modify-write runs under the
533
+ * same store lock the save takes.
534
+ */
535
+ export async function removeCredential(environment, apiOrigin, { sleep } = {}) {
536
+ const filePath = credentialStorePath(environment);
537
+ return withStoreLock(filePath, async () => {
538
+ const store = await readCredentialStore(environment);
539
+ const remaining = store.credentials.filter((entry) => entry.apiOrigin !== apiOrigin);
540
+ if (remaining.length === store.credentials.length) return { path: store.path, removed: false };
541
+ await writeCredentialStore(store.path, remaining);
542
+ return { path: store.path, removed: true };
543
+ }, { sleep });
544
+ }
package/src/errors.js ADDED
@@ -0,0 +1,133 @@
1
+ import { CANONICAL_TIMESTAMP, LIMITS } from "./constants.js";
2
+
3
+ const DELETE_CODE_POINT = 0x7f;
4
+ const C1_LAST_CODE_POINT = 0x9f;
5
+ const BIDI_OVERRIDE_FIRST = 0x202a;
6
+ const BIDI_OVERRIDE_LAST = 0x202e;
7
+ const BIDI_ISOLATE_FIRST = 0x2066;
8
+ const BIDI_ISOLATE_LAST = 0x2069;
9
+ const CANONICAL_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
10
+
11
+ // The character classes are expressed as code-point predicates rather than
12
+ // regular-expression escapes so that every module rejects exactly the same
13
+ // set, and so a copy-edit cannot silently narrow one of them.
14
+
15
+ /** C0 controls and DEL — the class that must never reach a path or a filename. */
16
+ export function hasAsciiControl(value) {
17
+ for (const character of value) {
18
+ const codePoint = character.codePointAt(0);
19
+ if (codePoint <= 0x1f || codePoint === DELETE_CODE_POINT) return true;
20
+ }
21
+ return false;
22
+ }
23
+
24
+ /** C0, DEL, and C1 — the class that must never reach a header value or a token. */
25
+ export function hasControlCharacter(value) {
26
+ for (const character of value) {
27
+ const codePoint = character.codePointAt(0);
28
+ if (codePoint <= 0x1f || (codePoint >= DELETE_CODE_POINT && codePoint <= C1_LAST_CODE_POINT)) return true;
29
+ }
30
+ return false;
31
+ }
32
+
33
+ export function isCanonicalUuid(value) {
34
+ return typeof value === "string" && CANONICAL_UUID.test(value);
35
+ }
36
+
37
+ /** Select the bounded, non-secret fields needed to recover a created preview. */
38
+ export function normalizePreviewRecovery(preview) {
39
+ if (
40
+ preview === null
41
+ || typeof preview !== "object"
42
+ || !isCanonicalUuid(preview.siteId)
43
+ || !isCanonicalUuid(preview.pageId)
44
+ || !isCanonicalUuid(preview.snapshotId)
45
+ || typeof preview.expiresAt !== "string"
46
+ || !CANONICAL_TIMESTAMP.test(preview.expiresAt)
47
+ ) return undefined;
48
+
49
+ return Object.freeze({
50
+ siteId: preview.siteId,
51
+ pageId: preview.pageId,
52
+ snapshotId: preview.snapshotId,
53
+ expiresAt: preview.expiresAt,
54
+ });
55
+ }
56
+
57
+ function isUnsafeDiagnosticCodePoint(codePoint) {
58
+ return codePoint <= 0x1f
59
+ || (codePoint >= DELETE_CODE_POINT && codePoint <= C1_LAST_CODE_POINT)
60
+ || (codePoint >= BIDI_OVERRIDE_FIRST && codePoint <= BIDI_OVERRIDE_LAST)
61
+ || (codePoint >= BIDI_ISOLATE_FIRST && codePoint <= BIDI_ISOLATE_LAST);
62
+ }
63
+
64
+ /**
65
+ * Bounds and de-fangs any string that reaches a terminal or a machine-readable
66
+ * result: terminal controls, C1, and bidirectional overrides are removed so a
67
+ * hostile diagnostic cannot repaint the operator's screen or reorder the text
68
+ * a reviewer reads.
69
+ */
70
+ export function sanitizeDiagnostic(value, fallback = "The operation failed.") {
71
+ if (typeof value !== "string") return fallback;
72
+ const clean = [...value.normalize("NFC")]
73
+ .filter((character) => !isUnsafeDiagnosticCodePoint(character.codePointAt(0)))
74
+ .join("")
75
+ .trim();
76
+ return [...clean].slice(0, LIMITS.diagnosticScalars).join("") || fallback;
77
+ }
78
+
79
+ /**
80
+ * The one error type this package throws. Every failure carries a stable
81
+ * dotted code and an exit code: 2 for a usage fault the caller fixes by
82
+ * changing the command line, 1 for everything else.
83
+ */
84
+ export class SiteAuthoringError extends Error {
85
+ constructor(code, message, options = {}) {
86
+ super(sanitizeDiagnostic(message), options.cause ? { cause: options.cause } : undefined);
87
+ this.name = "SiteAuthoringError";
88
+ this.code = typeof code === "string" && /^[a-z0-9_.-]+$/u.test(code)
89
+ ? code
90
+ : "site.failed";
91
+ this.field = typeof options.field === "string" ? sanitizeDiagnostic(options.field, "") : undefined;
92
+ this.status = typeof options.status === "string" ? sanitizeDiagnostic(options.status, "") : undefined;
93
+ this.alternatives = Array.isArray(options.alternatives)
94
+ ? options.alternatives
95
+ .filter((value) => typeof value === "string")
96
+ .slice(0, 100)
97
+ .map((value) => sanitizeDiagnostic(value, "option"))
98
+ : undefined;
99
+ this.exitCode = Number.isSafeInteger(options.exitCode) && options.exitCode >= 1 && options.exitCode <= 255
100
+ ? options.exitCode
101
+ : 1;
102
+ }
103
+
104
+ /** Attach the bounded operation labels that committed before this failure. */
105
+ withCompletedWrites(values) {
106
+ this.completedWrites = Array.isArray(values)
107
+ ? values
108
+ .filter((value) => typeof value === "string")
109
+ .slice(0, 100)
110
+ .map((value) => sanitizeDiagnostic(value, "write"))
111
+ : [];
112
+ return this;
113
+ }
114
+
115
+ /** Attach the non-secret identity needed to inspect or revoke a created preview. */
116
+ withPreviewRecovery(preview) {
117
+ const recovery = normalizePreviewRecovery(preview);
118
+ if (recovery) this.previewRecovery = recovery;
119
+ return this;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Collapses an unknown throw into a bounded failure. The original message is
125
+ * deliberately discarded: an arbitrary error can carry the credential, a
126
+ * presigned URL, or page content, and this value reaches stdout and
127
+ * GITHUB_OUTPUT.
128
+ */
129
+ export function asSiteAuthoringError(error) {
130
+ return error instanceof SiteAuthoringError
131
+ ? error
132
+ : new SiteAuthoringError("site.failed", "The Taproot site authoring command failed.");
133
+ }