@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.
@@ -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 { dirname } from "node:path";
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 one of:
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 only (`BLOCOFY_SECRET_STORE=keychain` or `login --keychain`); other platforms → error.
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
- * Both stores expose `get(context, kind)`, `set(context, kind, value)`, `remove(context)` with kind `dev|api`.
15
- * No method ever puts a secret into an error message.
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
- throw new SecretStoreError("SECRET_STORE_UNAVAILABLE", `Unknown secret store "${name}" (use "file" or "keychain").`);
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", "local.json\n"]);
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
- if (resolved.context.dev && typeof secrets.devToken === "string" && !secrets.devToken.startsWith(DEV_TOKEN_PREFIX)) refuse("dev", secrets.devToken);
373
- if (resolved.context.api && typeof secrets.apiKey === "string" && !(secrets.apiKey.startsWith(API_KEY_PREFIX) && secrets.apiKey.length > API_KEY_PREFIX.length)) refuse("api", secrets.apiKey);
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) return { error: `HTTP ${res.status}` };
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)");
@@ -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("bcf_") && t.length >= 24;
20
+ return typeof t === "string" && DEV_TOKEN_PREFIXES.some((p) => t.startsWith(p)) && t.length >= 24;
18
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocofy/cli",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Develop your Blocofy theme locally against live data with instant preview — login, theme dev, pull, push.",
5
5
  "type": "module",
6
6
  "license": "MIT",