@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
|
@@ -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
|
+
}
|