@blocofy/cli 0.15.0 → 0.16.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/README.md +111 -13
- package/bin/blocofy.mjs +595 -40
- package/lib/args.mjs +1 -1
- package/lib/credentials.mjs +150 -6
- package/lib/dev-server.mjs +6 -3
- package/lib/init.mjs +382 -0
- package/lib/loopback.mjs +111 -0
- package/lib/media-uses.mjs +4 -2
- package/lib/oauth.mjs +295 -0
- package/lib/refresh.mjs +244 -0
- package/lib/secret-store.mjs +177 -10
- package/lib/target.mjs +25 -11
- package/lib/theme-sync.mjs +2 -0
- package/lib/validate.mjs +4 -1
- package/package.json +1 -1
package/lib/secret-store.mjs
CHANGED
|
@@ -1,22 +1,35 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
|
-
import {
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* CF-T1 (contract C1) — where credential SECRETS live. `credentials.json` only records which store holds a
|
|
8
|
-
* context's secret (`secret: { store: "file" | "keychain" }`); the value itself is in
|
|
9
|
+
* context's secret (`secret: { store: "file" | "keychain" | "dpapi" | "secret-service" }`); the value itself is in:
|
|
9
10
|
*
|
|
10
|
-
* file `~/.blocofy/secrets.json` (0600, atomic tmp+rename) `{ "<context>": { dev_token, api_key } }`.
|
|
11
|
-
* keychain macOS Keychain via the `security` binary: service `blocofy-cli`, account `<context>:dev|api`.
|
|
12
|
-
* Opt-in
|
|
11
|
+
* file `~/.blocofy/secrets.json` (0600, atomic tmp+rename) `{ "<context>": { dev_token, api_key, oauth_tokens } }`.
|
|
12
|
+
* keychain macOS Keychain via the `security` binary: service `blocofy-cli`, account `<context>:dev|api|oauth`.
|
|
13
|
+
* Opt-in for a pasted token (`BLOCOFY_SECRET_STORE=keychain` or `login --keychain`); other platforms → error.
|
|
13
14
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
15
|
+
* ADR-0014 §5.4 (wave P4) — the browser login's token set (kind `oauth`: one JSON string `{access_token,
|
|
16
|
+
* refresh_token, expires_at}`) lives in the OS secure store by default (`oauthSecretStore`):
|
|
17
|
+
*
|
|
18
|
+
* macOS keychain (above)
|
|
19
|
+
* Windows dpapi `%APPDATA%\blocofy\secrets.dpapi`: each value protected with DPAPI (user scope) by
|
|
20
|
+
* PowerShell `ConvertFrom-SecureString`; the secret goes in on stdin, never argv
|
|
21
|
+
* Linux secret-service libsecret's `secret-tool` (stdin), when a DBus session is present
|
|
22
|
+
* any file only when chosen explicitly (`--insecure-storage` / `BLOCOFY_SECRET_STORE=file`) and
|
|
23
|
+
* announced; an unavailable OS store is an error, never a silent plaintext downgrade
|
|
24
|
+
*
|
|
25
|
+
* Every store exposes `get(context, kind)`, `set(context, kind, value)`, `remove(context, kind?)` with kind
|
|
26
|
+
* `dev|api|oauth`. No method ever puts a secret into an error message.
|
|
16
27
|
*/
|
|
17
28
|
|
|
18
29
|
export const KEYCHAIN_SERVICE = "blocofy-cli";
|
|
19
|
-
const FIELD = { dev: "dev_token", api: "api_key" };
|
|
30
|
+
const FIELD = { dev: "dev_token", api: "api_key", oauth: "oauth_tokens" };
|
|
31
|
+
/** Every store name a context may record (`secret: { store }`). */
|
|
32
|
+
export const STORE_NAMES = ["file", "keychain", "dpapi", "secret-service"];
|
|
20
33
|
|
|
21
34
|
export class SecretStoreError extends Error {
|
|
22
35
|
constructor(code, message) {
|
|
@@ -125,9 +138,163 @@ export function createKeychainSecretStore({ exec = defaultExec, platform = proce
|
|
|
125
138
|
};
|
|
126
139
|
}
|
|
127
140
|
|
|
141
|
+
const storeAccount = (context, kind) => `${context}:${kind}`;
|
|
142
|
+
|
|
143
|
+
/** Windows: the protect/unprotect scripts. The value travels on stdin both ways; argv carries only the script. */
|
|
144
|
+
const PS_PROTECT =
|
|
145
|
+
"$s=[Console]::In.ReadToEnd(); $ss=ConvertTo-SecureString -String $s -AsPlainText -Force; [Console]::Out.Write((ConvertFrom-SecureString -SecureString $ss))";
|
|
146
|
+
const PS_UNPROTECT =
|
|
147
|
+
"$e=[Console]::In.ReadToEnd().Trim(); $ss=ConvertTo-SecureString -String $e; $b=[Runtime.InteropServices.Marshal]::SecureStringToBSTR($ss); " +
|
|
148
|
+
"try { [Console]::Out.Write([Runtime.InteropServices.Marshal]::PtrToStringBSTR($b)) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($b) }";
|
|
149
|
+
const powershell = (exec, script, input) => exec("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { input });
|
|
150
|
+
|
|
151
|
+
/** The DPAPI file's default place: `%APPDATA%\blocofy\secrets.dpapi` (user scope). */
|
|
152
|
+
export function dpapiPath(env = process.env) {
|
|
153
|
+
return join(env.APPDATA || join(homedir(), "AppData", "Roaming"), "blocofy", "secrets.dpapi");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Windows DPAPI adapter: the file maps `<context>:<kind>` to a DPAPI-protected blob (useless to another user or
|
|
158
|
+
* machine). An unreadable blob is an error (SECRET_STORE_FAILED), never "not logged in".
|
|
159
|
+
*/
|
|
160
|
+
export function createDpapiSecretStore({ exec = defaultExec, platform = process.platform, filePath = dpapiPath() } = {}) {
|
|
161
|
+
const ensure = () => {
|
|
162
|
+
if (platform !== "win32") throw new SecretStoreError("SECRET_STORE_UNAVAILABLE", "The DPAPI secret store is only available on Windows.");
|
|
163
|
+
};
|
|
164
|
+
const readAll = () => {
|
|
165
|
+
let raw;
|
|
166
|
+
try {
|
|
167
|
+
raw = readFileSync(filePath, "utf8");
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error?.code === "ENOENT") return {};
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const data = JSON.parse(raw);
|
|
174
|
+
if (data && typeof data === "object" && !Array.isArray(data)) return data;
|
|
175
|
+
} catch {
|
|
176
|
+
/* fall through */
|
|
177
|
+
}
|
|
178
|
+
throw new SecretStoreError("CREDENTIALS_CORRUPT", `The protected secrets file is not valid JSON: ${filePath}. Nothing was written.`);
|
|
179
|
+
};
|
|
180
|
+
return {
|
|
181
|
+
name: "dpapi",
|
|
182
|
+
get(context, kind) {
|
|
183
|
+
ensure();
|
|
184
|
+
const blob = readAll()[storeAccount(context, kind)];
|
|
185
|
+
if (typeof blob !== "string" || !blob) return null;
|
|
186
|
+
const r = powershell(exec, PS_UNPROTECT, blob);
|
|
187
|
+
if (r.status !== 0 || r.error) throw new SecretStoreError("SECRET_STORE_FAILED", `Could not read the secret from the Windows protected store (${filePath}); it was not used.`);
|
|
188
|
+
return r.stdout || null;
|
|
189
|
+
},
|
|
190
|
+
set(context, kind, value) {
|
|
191
|
+
ensure();
|
|
192
|
+
const r = powershell(exec, PS_PROTECT, String(value));
|
|
193
|
+
const blob = String(r.stdout ?? "").trim();
|
|
194
|
+
if (r.status !== 0 || r.error || !blob) throw new SecretStoreError("SECRET_STORE_FAILED", "Could not protect the secret with Windows DPAPI. Nothing was saved.");
|
|
195
|
+
const data = readAll();
|
|
196
|
+
data[storeAccount(context, kind)] = blob;
|
|
197
|
+
writeJsonAtomic(filePath, data);
|
|
198
|
+
},
|
|
199
|
+
remove(context, kind = null) {
|
|
200
|
+
ensure();
|
|
201
|
+
const data = readAll();
|
|
202
|
+
let changed = false;
|
|
203
|
+
for (const k of kind ? [kind] : Object.keys(FIELD)) {
|
|
204
|
+
if (storeAccount(context, k) in data) {
|
|
205
|
+
delete data[storeAccount(context, k)];
|
|
206
|
+
changed = true;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (changed) writeJsonAtomic(filePath, data);
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Linux Secret Service (libsecret) adapter through `secret-tool`; the secret goes in on stdin. */
|
|
215
|
+
export function createSecretServiceStore({ exec = defaultExec, platform = process.platform } = {}) {
|
|
216
|
+
const ensure = () => {
|
|
217
|
+
if (platform !== "linux") throw new SecretStoreError("SECRET_STORE_UNAVAILABLE", "The Secret Service store is only used on Linux.");
|
|
218
|
+
};
|
|
219
|
+
const attrs = (context, kind) => ["service", KEYCHAIN_SERVICE, "account", storeAccount(context, kind)];
|
|
220
|
+
return {
|
|
221
|
+
name: "secret-service",
|
|
222
|
+
get(context, kind) {
|
|
223
|
+
ensure();
|
|
224
|
+
const r = exec("secret-tool", ["lookup", ...attrs(context, kind)]);
|
|
225
|
+
if (r.error && r.error.code === "ENOENT") throw new SecretStoreError("SECRET_STORE_UNAVAILABLE", "`secret-tool` (libsecret) is not installed; the saved login cannot be read.");
|
|
226
|
+
if (r.status !== 0) return null;
|
|
227
|
+
const v = String(r.stdout ?? "").replace(/\r?\n$/, "");
|
|
228
|
+
return v || null;
|
|
229
|
+
},
|
|
230
|
+
set(context, kind, value) {
|
|
231
|
+
ensure();
|
|
232
|
+
const r = exec("secret-tool", ["store", `--label=Blocofy CLI (${context})`, ...attrs(context, kind)], { input: String(value) });
|
|
233
|
+
if (r.status !== 0 || r.error) throw new SecretStoreError("SECRET_STORE_FAILED", `Could not save the secret to the Secret Service (secret-tool exit ${r.status}). Nothing was saved.`);
|
|
234
|
+
},
|
|
235
|
+
remove(context, kind = null) {
|
|
236
|
+
ensure();
|
|
237
|
+
for (const k of kind ? [kind] : Object.keys(FIELD)) exec("secret-tool", ["clear", ...attrs(context, k)]);
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
128
242
|
/** `store` name → adapter. Unknown names are a usage error. */
|
|
129
|
-
export function secretStoreFor(name, { filePath, exec, platform } = {}) {
|
|
243
|
+
export function secretStoreFor(name, { filePath, exec, platform, dpapiPath: dpapiFile } = {}) {
|
|
130
244
|
if (name === "file") return createFileSecretStore(filePath);
|
|
131
245
|
if (name === "keychain") return createKeychainSecretStore({ exec, platform });
|
|
132
|
-
|
|
246
|
+
if (name === "dpapi") return createDpapiSecretStore({ exec, platform, ...(dpapiFile ? { filePath: dpapiFile } : {}) });
|
|
247
|
+
if (name === "secret-service") return createSecretServiceStore({ exec, platform });
|
|
248
|
+
throw new SecretStoreError("SECRET_STORE_UNAVAILABLE", `Unknown secret store "${name}" (use ${STORE_NAMES.map((n) => `"${n}"`).join(", ")}).`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Where a store keeps secrets, in words (login output names it). */
|
|
252
|
+
export function storeLabel(name) {
|
|
253
|
+
switch (name) {
|
|
254
|
+
case "keychain":
|
|
255
|
+
return "macOS Keychain";
|
|
256
|
+
case "dpapi":
|
|
257
|
+
return "Windows DPAPI-protected file (OS protected file)";
|
|
258
|
+
case "secret-service":
|
|
259
|
+
return "Linux Secret Service (libsecret)";
|
|
260
|
+
case "file":
|
|
261
|
+
return "plain file ~/.blocofy/secrets.json (0600) — chosen explicitly";
|
|
262
|
+
default:
|
|
263
|
+
return String(name);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const OS_STORE = { darwin: "keychain", win32: "dpapi", linux: "secret-service" };
|
|
268
|
+
|
|
269
|
+
/** Is the Secret Service usable here? A DBus session and an installed `secret-tool`. */
|
|
270
|
+
function secretServiceAvailable(env, exec) {
|
|
271
|
+
if (!env.DBUS_SESSION_BUS_ADDRESS) return false;
|
|
272
|
+
const r = exec("secret-tool", ["--help"]);
|
|
273
|
+
return !(r.error && r.error.code === "ENOENT");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* ADR-0014 §5.4 — the store for the browser login's tokens. `insecure` (`--insecure-storage`) or
|
|
278
|
+
* `BLOCOFY_SECRET_STORE=file` choose the 0600 file (`explicit: true`; the caller announces it); another
|
|
279
|
+
* `BLOCOFY_SECRET_STORE` value names an OS store, which must be this OS's. Otherwise this platform's OS store; none
|
|
280
|
+
* available → SECRET_STORE_UNAVAILABLE (never a silent plaintext downgrade).
|
|
281
|
+
*/
|
|
282
|
+
export function oauthSecretStore({ platform = process.platform, env = process.env, insecure = false, exec = defaultExec } = {}) {
|
|
283
|
+
const unavailable = (why) =>
|
|
284
|
+
new SecretStoreError(
|
|
285
|
+
"SECRET_STORE_UNAVAILABLE",
|
|
286
|
+
`${why} The CLI login is never saved in a plain file automatically. To keep it in ~/.blocofy/secrets.json (0600) instead, choose that explicitly: blocofy login --insecure-storage (or BLOCOFY_SECRET_STORE=file). Nothing was saved.`,
|
|
287
|
+
);
|
|
288
|
+
const requested = env.BLOCOFY_SECRET_STORE;
|
|
289
|
+
if (insecure || requested === "file") return { name: "file", explicit: true };
|
|
290
|
+
if (requested !== undefined && requested !== "") {
|
|
291
|
+
if (!STORE_NAMES.includes(requested)) throw unavailable(`BLOCOFY_SECRET_STORE="${requested}" is not a known store (${STORE_NAMES.join(", ")}).`);
|
|
292
|
+
if (OS_STORE[platform] !== requested) throw unavailable(`The "${requested}" store is not available on this operating system (${platform}).`);
|
|
293
|
+
}
|
|
294
|
+
const name = OS_STORE[platform];
|
|
295
|
+
if (!name) throw unavailable(`No OS secure store is supported on this operating system (${platform}).`);
|
|
296
|
+
if (name === "secret-service" && !secretServiceAvailable(env, exec)) {
|
|
297
|
+
throw unavailable("No Secret Service is available (it needs a desktop DBus session and `secret-tool` from libsecret).");
|
|
298
|
+
}
|
|
299
|
+
return { name, explicit: false };
|
|
133
300
|
}
|
package/lib/target.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync,
|
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
|
|
5
5
|
import { fetchWithRetry } from "./http.mjs";
|
|
6
|
-
import { API_KEY_PREFIX } from "./media-uses.mjs";
|
|
6
|
+
import { API_KEY_PREFIX, isValidApiKey } from "./media-uses.mjs";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* CF-T1/T2 (contract C2) — project binding + target resolution + remote identity verification.
|
|
@@ -108,7 +108,7 @@ function lstatOrNull(p) {
|
|
|
108
108
|
* before the first write); each file is written to a fresh temp file in the same directory (`wx`, 0644) and
|
|
109
109
|
* renamed into place. `staleLocal: true` (link) removes a `local.json` that an env-context binding does not own.
|
|
110
110
|
*/
|
|
111
|
-
export function writeBinding(root, { site, platformOrigin, contextName, staleLocal = false }) {
|
|
111
|
+
export function writeBinding(root, { site, platformOrigin, contextName, staleLocal = false, profile = null, ignore = ["local.json"] }) {
|
|
112
112
|
const d = join(root, PROJECT_DIR);
|
|
113
113
|
const refuse = (path, what) => {
|
|
114
114
|
throw new TargetError("TARGET_BINDING_INVALID", `${path} ${what}; the project binding was not written.`, { binding: path });
|
|
@@ -122,10 +122,12 @@ export function writeBinding(root, { site, platformOrigin, contextName, staleLoc
|
|
|
122
122
|
if (st.isSymbolicLink() || !st.isDirectory()) refuse(d, "is not a real directory");
|
|
123
123
|
}
|
|
124
124
|
const project = { schema_version: 1, site_id: site.id, site_slug: site.slug ?? null, platform_origin: origin(platformOrigin) };
|
|
125
|
+
// ADR-0014 §5.6 step 6 — additive: the profile the project was set up with (an older CLI ignores it; the server decides).
|
|
126
|
+
if (profile && typeof profile.id === "string") project.profile = { id: profile.id, version: profile.version ?? null };
|
|
125
127
|
const writes = [["project.json", JSON.stringify(project, null, 2) + "\n"]];
|
|
126
128
|
const ownsLocal = contextName && contextName !== ENV_CONTEXT;
|
|
127
129
|
if (ownsLocal) writes.push(["local.json", JSON.stringify({ context: contextName }, null, 2) + "\n"]);
|
|
128
|
-
writes.push([".gitignore", "
|
|
130
|
+
writes.push([".gitignore", ignore.join("\n") + "\n"]);
|
|
129
131
|
for (const name of ["project.json", "local.json", ".gitignore"]) {
|
|
130
132
|
const f = lstatOrNull(join(d, name));
|
|
131
133
|
if (f && (f.isSymbolicLink() || !f.isFile())) refuse(join(d, name), "is a symlink or not a regular file");
|
|
@@ -352,7 +354,7 @@ export function assertCredentialTypes({ resolved, secrets }) {
|
|
|
352
354
|
const refuse = (kind, secret) => {
|
|
353
355
|
const env = Boolean(resolved.env);
|
|
354
356
|
const where = kind === "dev" ? (env ? "BLOCOFY_TOKEN" : `The dev token of context "${resolved.name}"`) : env ? "BLOCOFY_API_KEY" : `The API key of context "${resolved.name}"`;
|
|
355
|
-
const other = kind === "dev" ? secret.startsWith("blcf_") : secret.startsWith(DEV_TOKEN_PREFIX);
|
|
357
|
+
const other = kind === "dev" ? secret.startsWith("blcf_") : secret.startsWith(DEV_TOKEN_PREFIX) || secret.startsWith("bcf2_");
|
|
356
358
|
const holds = kind === "dev" ? (other ? "holds a v1 API key (blcf_…)" : "is not a dev token") : other ? "holds a theme dev token (bcf_…)" : "is not a v1 API key";
|
|
357
359
|
const expected = kind === "dev" ? `a theme dev token starts with ${DEV_TOKEN_PREFIX}` : `a v1 API key starts with ${API_KEY_PREFIX}`;
|
|
358
360
|
const fix = env
|
|
@@ -369,8 +371,10 @@ export function assertCredentialTypes({ resolved, secrets }) {
|
|
|
369
371
|
1,
|
|
370
372
|
);
|
|
371
373
|
};
|
|
372
|
-
|
|
373
|
-
if (resolved.
|
|
374
|
+
// A CLI login's context carries its own short-lived token in both places; the refresh layer vouches for it.
|
|
375
|
+
if (resolved.oauth) return;
|
|
376
|
+
if (resolved.context.dev && typeof secrets.devToken === "string" && !(secrets.devToken.startsWith(DEV_TOKEN_PREFIX) || secrets.devToken.startsWith("bcf2_"))) refuse("dev", secrets.devToken);
|
|
377
|
+
if (resolved.context.api && typeof secrets.apiKey === "string" && !isValidApiKey(secrets.apiKey)) refuse("api", secrets.apiKey);
|
|
374
378
|
}
|
|
375
379
|
|
|
376
380
|
// ── remote identity ─────────────────────────────────────────────────────────────────────────────────────────
|
|
@@ -386,7 +390,17 @@ async function getJson(fetchImpl, url, secret, retry = {}) {
|
|
|
386
390
|
return { error: `unreachable (${error?.name === "TimeoutError" ? "timeout" : "network error"})` };
|
|
387
391
|
}
|
|
388
392
|
const text = await res.text().catch(() => "");
|
|
389
|
-
if (!res.ok)
|
|
393
|
+
if (!res.ok) {
|
|
394
|
+
// ADR-0014: keep the server's refusal code (v1 `{error:{code}}`, renderer `{error, code}`) — e.g. audience_mismatch.
|
|
395
|
+
let code = null;
|
|
396
|
+
try {
|
|
397
|
+
const j = JSON.parse(text);
|
|
398
|
+
code = typeof j?.error?.code === "string" ? j.error.code : typeof j?.code === "string" ? j.code : null;
|
|
399
|
+
} catch {
|
|
400
|
+
code = null;
|
|
401
|
+
}
|
|
402
|
+
return { error: `HTTP ${res.status}${code ? ` ${code}` : ""}`, serverCode: code };
|
|
403
|
+
}
|
|
390
404
|
try {
|
|
391
405
|
return { body: JSON.parse(text) };
|
|
392
406
|
} catch {
|
|
@@ -394,15 +408,15 @@ async function getJson(fetchImpl, url, secret, retry = {}) {
|
|
|
394
408
|
}
|
|
395
409
|
}
|
|
396
410
|
|
|
397
|
-
function unverified(what, url, reason) {
|
|
398
|
-
return new TargetError("TARGET_UNVERIFIED", `Could not verify the target site via ${what} (${reason}). Nothing was sent or written.`, { endpoint: url, reason });
|
|
411
|
+
function unverified(what, url, reason, serverCode = null) {
|
|
412
|
+
return new TargetError("TARGET_UNVERIFIED", `Could not verify the target site via ${what} (${reason}). Nothing was sent or written.`, { endpoint: url, reason, ...(serverCode ? { server_code: serverCode } : {}) });
|
|
399
413
|
}
|
|
400
414
|
|
|
401
415
|
/** `GET {url}/api/dev/whoami` → `{ site, platformOrigin, liveThemeId }` or TARGET_UNVERIFIED. */
|
|
402
416
|
export async function verifyDev({ url, token, fetchImpl = globalThis.fetch, retry = {} }) {
|
|
403
417
|
const endpoint = `${url.replace(/\/+$/, "")}/api/dev/whoami`;
|
|
404
418
|
const r = await getJson(fetchImpl, endpoint, token, retry);
|
|
405
|
-
if (r.error) throw unverified("/api/dev/whoami", endpoint, r.error);
|
|
419
|
+
if (r.error) throw unverified("/api/dev/whoami", endpoint, r.error, r.serverCode);
|
|
406
420
|
const site = r.body?.site;
|
|
407
421
|
if (!isObj(site) || !(typeof site.id === "string" || Number.isFinite(site.id)) || typeof site.slug !== "string") {
|
|
408
422
|
throw unverified("/api/dev/whoami", endpoint, "malformed response (no site id/slug)");
|
|
@@ -414,7 +428,7 @@ export async function verifyDev({ url, token, fetchImpl = globalThis.fetch, retr
|
|
|
414
428
|
export async function verifyApi({ url, apiKey, fetchImpl = globalThis.fetch, retry = {} }) {
|
|
415
429
|
const endpoint = `${url.replace(/\/+$/, "")}/api/v1/ping`;
|
|
416
430
|
const r = await getJson(fetchImpl, endpoint, apiKey, retry);
|
|
417
|
-
if (r.error) throw unverified("/api/v1/ping", endpoint, r.error);
|
|
431
|
+
if (r.error) throw unverified("/api/v1/ping", endpoint, r.error, r.serverCode);
|
|
418
432
|
const site = r.body?.site;
|
|
419
433
|
if (!isObj(site) || !(typeof site.id === "string" || Number.isFinite(site.id))) {
|
|
420
434
|
throw unverified("/api/v1/ping", endpoint, "malformed response (no site id)");
|
package/lib/theme-sync.mjs
CHANGED
|
@@ -73,6 +73,8 @@ async function responseError(res) {
|
|
|
73
73
|
// `{ error: "<sentence>", code }` (e.g. dev/publish `work_not_publishable`): the code sits beside the sentence.
|
|
74
74
|
else if (typeof body?.code === "string" && /^[a-z][a-z0-9_]*$/.test(body.code)) err.code = body.code;
|
|
75
75
|
else if (typeof detail === "string" && /^[a-z][a-z0-9_]*$/.test(detail)) err.code = detail;
|
|
76
|
+
// ADR-0014: a credential refusal's details (e.g. credential_reapproval_required's due date) for the CLI's words.
|
|
77
|
+
if (body && typeof body.details === "object" && body.details !== null) err.serverDetails = body.details;
|
|
76
78
|
return err;
|
|
77
79
|
}
|
|
78
80
|
|
package/lib/validate.mjs
CHANGED
|
@@ -13,6 +13,9 @@ export function isValidUrl(u) {
|
|
|
13
13
|
return /^https?:\/\/.+/i.test(u);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** A theme dev token: `bcf_` (legacy) or `bcf2_` (profiled, ADR-0014 §3.2: needs CLI >= 0.16.0). */
|
|
17
|
+
export const DEV_TOKEN_PREFIXES = ["bcf_", "bcf2_"];
|
|
18
|
+
|
|
16
19
|
export function isValidToken(t) {
|
|
17
|
-
return typeof t === "string" && t.startsWith(
|
|
20
|
+
return typeof t === "string" && DEV_TOKEN_PREFIXES.some((p) => t.startsWith(p)) && t.length >= 24;
|
|
18
21
|
}
|
package/package.json
CHANGED