@blocofy/cli 0.9.0 → 0.11.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/lib/target.mjs ADDED
@@ -0,0 +1,485 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ import { fetchWithRetry } from "./http.mjs";
6
+ import { API_KEY_PREFIX } from "./media-uses.mjs";
7
+
8
+ /**
9
+ * CF-T1/T2 (contract C2) — project binding + target resolution + remote identity verification.
10
+ *
11
+ * A project is bound to ONE site by `<root>/.blocofy/project.json` (shared, secret-free). The user's context
12
+ * choice for that project is `<root>/.blocofy/local.json` (git-ignored). No global "last login" is ever the
13
+ * authority for a mutation: with a binding present `current_context` is ignored, and a remote-mutation without a
14
+ * binding is refused before any network call.
15
+ */
16
+
17
+ /** A target/binding refusal: exit 3, nothing read or written. `exitCode` 1 for LOGIN_REQUIRED (usage). */
18
+ export class TargetError extends Error {
19
+ constructor(code, message, details = {}, exitCode = 3) {
20
+ super(message);
21
+ this.name = "TargetError";
22
+ this.code = code;
23
+ this.details = details;
24
+ this.exitCode = exitCode;
25
+ }
26
+ }
27
+
28
+ /** The ephemeral environment-variable context (same name as credentials.mjs ENV_CONTEXT). */
29
+ const ENV_CONTEXT = "env";
30
+
31
+ export const COMMAND_CLASSES = new Set(["remote-mutation", "local-write", "read"]);
32
+ const PROJECT_DIR = ".blocofy";
33
+ const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
34
+ const sameId = (a, b) => a !== null && a !== undefined && b !== null && b !== undefined && String(a) === String(b);
35
+ const origin = (v) => (typeof v === "string" && v ? v : null);
36
+
37
+ /**
38
+ * Platform-origin rule (orchestrator decision, CF-T2 follow-up). Servers deployed before contract C3 do not send
39
+ * `platform_origin`, so records created against them hold null.
40
+ * recorded == remote (both non-null) → "match"
41
+ * recorded null, remote non-null → "upgrade" (proceeds with one stderr warning; nothing is rewritten)
42
+ * recorded non-null, remote null → "unproven" (TARGET_UNVERIFIED: the server does not prove its platform)
43
+ * both null → "match"
44
+ * two different non-null values → "mismatch"
45
+ * The site id must match separately in every case.
46
+ */
47
+ export function compareOrigin(recorded, remote) {
48
+ const a = origin(recorded);
49
+ const b = origin(remote);
50
+ if (a === b) return "match";
51
+ if (a === null) return "upgrade";
52
+ if (b === null) return "unproven";
53
+ return "mismatch";
54
+ }
55
+
56
+ export const CONTEXT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
57
+
58
+ // ── binding ─────────────────────────────────────────────────────────────────────────────────────────────────
59
+
60
+ /** First `.blocofy/project.json` at `startDir` or an ancestor → `{ root, projectPath, project, localContext }` | null. */
61
+ export function findBinding(startDir) {
62
+ let current = resolve(startDir);
63
+ for (;;) {
64
+ const projectPath = join(current, PROJECT_DIR, "project.json");
65
+ if (existsSync(projectPath)) return readBinding(current);
66
+ const parent = dirname(current);
67
+ if (parent === current) return null;
68
+ current = parent;
69
+ }
70
+ }
71
+
72
+ function readBinding(root) {
73
+ const projectPath = join(root, PROJECT_DIR, "project.json");
74
+ let project;
75
+ try {
76
+ project = JSON.parse(readFileSync(projectPath, "utf8"));
77
+ } catch {
78
+ project = null;
79
+ }
80
+ if (!isObj(project) || project.schema_version !== 1 || !(typeof project.site_id === "string" || Number.isFinite(project.site_id))) {
81
+ throw new TargetError("TARGET_BINDING_INVALID", `The project binding is not valid: ${projectPath}. Nothing was read or written. Re-create it with \`blocofy link --context <name> --adopt\`.`, { binding: projectPath });
82
+ }
83
+ let localContext = null;
84
+ const localPath = join(root, PROJECT_DIR, "local.json");
85
+ if (existsSync(localPath)) {
86
+ try {
87
+ const local = JSON.parse(readFileSync(localPath, "utf8"));
88
+ if (isObj(local) && typeof local.context === "string" && local.context) localContext = local.context;
89
+ } catch {
90
+ throw new TargetError("TARGET_BINDING_INVALID", `The local context file is not valid JSON: ${localPath}. Nothing was read or written.`, { binding: localPath });
91
+ }
92
+ }
93
+ return { root, projectPath, project, localContext };
94
+ }
95
+
96
+ function lstatOrNull(p) {
97
+ try {
98
+ return lstatSync(p);
99
+ } catch (error) {
100
+ if (error?.code === "ENOENT") return null;
101
+ throw error;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Write `.blocofy/project.json` (+ `local.json` for a named context, + `.gitignore`). Review I2: never follows a
107
+ * symlink — `.blocofy` must be a real directory and none of the three files may be a symlink (checked for all
108
+ * before the first write); each file is written to a fresh temp file in the same directory (`wx`, 0644) and
109
+ * renamed into place. `staleLocal: true` (link) removes a `local.json` that an env-context binding does not own.
110
+ */
111
+ export function writeBinding(root, { site, platformOrigin, contextName, staleLocal = false }) {
112
+ const d = join(root, PROJECT_DIR);
113
+ const refuse = (path, what) => {
114
+ throw new TargetError("TARGET_BINDING_INVALID", `${path} ${what}; the project binding was not written.`, { binding: path });
115
+ };
116
+ let st = lstatOrNull(d);
117
+ if (st && (st.isSymbolicLink() || !st.isDirectory())) refuse(d, "is not a real directory (symlink or file)");
118
+ if (!st) {
119
+ mkdirSync(root, { recursive: true });
120
+ mkdirSync(d);
121
+ st = lstatSync(d);
122
+ if (st.isSymbolicLink() || !st.isDirectory()) refuse(d, "is not a real directory");
123
+ }
124
+ const project = { schema_version: 1, site_id: site.id, site_slug: site.slug ?? null, platform_origin: origin(platformOrigin) };
125
+ const writes = [["project.json", JSON.stringify(project, null, 2) + "\n"]];
126
+ const ownsLocal = contextName && contextName !== ENV_CONTEXT;
127
+ if (ownsLocal) writes.push(["local.json", JSON.stringify({ context: contextName }, null, 2) + "\n"]);
128
+ writes.push([".gitignore", "local.json\n"]);
129
+ for (const name of ["project.json", "local.json", ".gitignore"]) {
130
+ const f = lstatOrNull(join(d, name));
131
+ if (f && (f.isSymbolicLink() || !f.isFile())) refuse(join(d, name), "is a symlink or not a regular file");
132
+ }
133
+ for (const [name, content] of writes) {
134
+ const target = join(d, name);
135
+ const tmp = join(d, `.${name}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
136
+ try {
137
+ writeFileSync(tmp, content, { flag: "wx", mode: 0o644 });
138
+ renameSync(tmp, target);
139
+ } catch (error) {
140
+ rmSync(tmp, { force: true });
141
+ throw error;
142
+ }
143
+ }
144
+ if (!ownsLocal && staleLocal) rmSync(join(d, "local.json"), { force: true });
145
+ return join(d, "project.json");
146
+ }
147
+
148
+ /**
149
+ * Review M4 — a pull into a new directory claims `.blocofy/project.json` EXCLUSIVELY before writing content, so two
150
+ * concurrent pulls into the same empty directory cannot both write. The complete file is hard-linked into place
151
+ * (fails atomically with EEXIST; a reader never sees a half-written file). If another process claimed it first,
152
+ * its binding must name the same site (else TARGET_SITE_MISMATCH, nothing written by us). Returns `{ release }`:
153
+ * undoes our claim (and the directories it created, if still empty) when the pull fails.
154
+ */
155
+ export function claimNewBinding(root, { site, platformOrigin }) {
156
+ const createdRoot = !existsSync(root);
157
+ mkdirSync(root, { recursive: true });
158
+ const d = join(root, PROJECT_DIR);
159
+ const st = lstatOrNull(d);
160
+ if (st && (st.isSymbolicLink() || !st.isDirectory())) {
161
+ throw new TargetError("TARGET_BINDING_INVALID", `${d} is not a real directory; nothing was written.`, { binding: d });
162
+ }
163
+ let createdDir = false;
164
+ if (!st) {
165
+ try {
166
+ mkdirSync(d);
167
+ createdDir = true;
168
+ } catch (error) {
169
+ if (error?.code !== "EEXIST") throw error;
170
+ }
171
+ }
172
+ const projectPath = join(d, "project.json");
173
+ const project = { schema_version: 1, site_id: site.id, site_slug: site.slug ?? null, platform_origin: origin(platformOrigin) };
174
+ const tmp = join(d, `.project.json.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
175
+ writeFileSync(tmp, JSON.stringify(project, null, 2) + "\n", { flag: "wx", mode: 0o644 });
176
+ try {
177
+ linkSync(tmp, projectPath);
178
+ } catch (error) {
179
+ if (error?.code !== "EEXIST") throw error;
180
+ const other = readBinding(root);
181
+ const o = compareOrigin(other.project.platform_origin, platformOrigin);
182
+ if (!sameId(other.project.site_id, site.id) || o === "mismatch" || o === "unproven") {
183
+ throw new TargetError(
184
+ "TARGET_SITE_MISMATCH",
185
+ `${root} was just bound to site ${other.project.site_slug ?? other.project.site_id} by another command; this pull is for ${site.slug ?? site.id}. Nothing was written.`,
186
+ { dir: root, binding_site_id: other.project.site_id, remote_site_id: site.id },
187
+ );
188
+ }
189
+ return { claimed: false, release() {} };
190
+ } finally {
191
+ rmSync(tmp, { force: true });
192
+ }
193
+ const tryRmdir = (p) => {
194
+ try {
195
+ rmdirSync(p);
196
+ } catch {
197
+ /* not empty (someone else wrote) — leave it */
198
+ }
199
+ };
200
+ return {
201
+ claimed: true,
202
+ release() {
203
+ rmSync(projectPath, { force: true });
204
+ if (createdDir) tryRmdir(d);
205
+ if (createdRoot) tryRmdir(root);
206
+ },
207
+ };
208
+ }
209
+
210
+ function isEmptyOrMissing(dir) {
211
+ if (!existsSync(dir)) return true;
212
+ if (!statSync(dir).isDirectory()) return false;
213
+ return readdirSync(dir).length === 0;
214
+ }
215
+
216
+ /**
217
+ * Command-class policy (before any credential or network use). Returns `{ newBinding }`: true when a local-write
218
+ * into a missing/empty unbound dir will record provenance after it succeeds.
219
+ */
220
+ export function enforceBindingPolicy({ commandClass, binding, dir, command }) {
221
+ if (!COMMAND_CLASSES.has(commandClass)) throw new Error(`internal: unknown command class ${commandClass}`);
222
+ if (binding) return { newBinding: false };
223
+ if (commandClass === "remote-mutation") {
224
+ throw new TargetError(
225
+ "TARGET_BINDING_REQUIRED",
226
+ `\`${command}\` changes a site, but ${dir} is not bound to one. Nothing was sent. Bind it first: blocofy link ${dir} --context <name>`,
227
+ { dir },
228
+ );
229
+ }
230
+ if (commandClass === "local-write") {
231
+ if (isEmptyOrMissing(dir)) return { newBinding: true };
232
+ throw new TargetError(
233
+ "TARGET_BINDING_REQUIRED",
234
+ `${dir} is not empty and not bound to a site. Nothing was written. Bind it first (blocofy link ${dir} --context <name>) or pull into an empty directory.`,
235
+ { dir },
236
+ );
237
+ }
238
+ return { newBinding: false };
239
+ }
240
+
241
+ // ── context resolution (pure) ───────────────────────────────────────────────────────────────────────────────
242
+
243
+ /** Offline: both sides are records, so a null on either side is not a mismatch (the remote check decides). */
244
+ function contextMatchesBinding(ctx, project) {
245
+ return isObj(ctx?.site) && sameId(ctx.site.id, project.site_id) && compareOrigin(ctx.platform_origin, project.platform_origin) !== "mismatch";
246
+ }
247
+
248
+ const authorityLabel = (a) => (a.source === "env" ? "env credentials (BLOCOFY_URL/BLOCOFY_TOKEN, BLOCOFY_API_URL/BLOCOFY_API_KEY)" : `${a.source}=${a.name}`);
249
+
250
+ /**
251
+ * Contract C2 order: --context → BLOCOFY_CONTEXT → full env pair → .blocofy/local.json → the unique context
252
+ * matching the binding (site.id + platform_origin) → TTY pick among matching candidates → TARGET_CONTEXT_REQUIRED.
253
+ *
254
+ * 1.8 closure (zero silent fallback): BLOCOFY_CONTEXT, the env pair and `.blocofy/local.json` are each a stated
255
+ * choice. When two of them name DIFFERENT contexts the command is refused before anything is read
256
+ * (TARGET_CONTEXT_CONFLICT, exit 3) — only an explicit `--context` settles it, and the result still lists what it
257
+ * overrode (`ignored`). With a binding present `current_context` is ignored; without one it is used only by the
258
+ * commands that opt in (`allowCurrentContext`: status, target, pages check — contract C2).
259
+ *
260
+ * `getStore()` is called only when a named context is needed (a corrupt file does not block the env context).
261
+ * `prompt(candidates)` → name (TTY only). Returns `{ name, source, context, ignored, env? }`; `ignored` lists the
262
+ * stated authorities (`{ source, name }`) that named another context.
263
+ */
264
+ export async function resolveContext({ flagContext = null, envContextName = null, envCtx = null, getStore, binding = null, allowCurrentContext = false, isTTY = false, prompt = null }) {
265
+ const stated = [];
266
+ if (envContextName) stated.push({ source: "BLOCOFY_CONTEXT", name: envContextName });
267
+ if (envCtx) stated.push({ source: "env", name: ENV_CONTEXT });
268
+ if (binding?.localContext) stated.push({ source: ".blocofy/local.json", name: binding.localContext });
269
+
270
+ const named = (name, source) => {
271
+ const ignored = stated.filter((a) => a.name !== name);
272
+ if (name === ENV_CONTEXT) {
273
+ if (envCtx) return { name: ENV_CONTEXT, source, context: envCtx.context, env: envCtx, ignored };
274
+ throw new TargetError("TARGET_CONTEXT_UNKNOWN", `Context "env" needs BLOCOFY_URL+BLOCOFY_TOKEN or BLOCOFY_API_URL+BLOCOFY_API_KEY.`, { context: name });
275
+ }
276
+ const store = getStore();
277
+ const ctx = store.contexts[name];
278
+ if (!ctx) {
279
+ throw new TargetError("TARGET_CONTEXT_UNKNOWN", `No context named "${name}" (from ${source}). List them with \`blocofy contexts\`.`, { context: name, source });
280
+ }
281
+ return { name, source, context: ctx, ignored };
282
+ };
283
+
284
+ if (flagContext) return named(flagContext, "--context");
285
+ if (new Set(stated.map((a) => a.name)).size > 1) {
286
+ throw new TargetError(
287
+ "TARGET_CONTEXT_CONFLICT",
288
+ `Conflicting context choices: ${stated.map(authorityLabel).join(" vs ")}. Nothing was read or written. Choose one explicitly with --context <name>, or unset the others.`,
289
+ { authorities: stated },
290
+ );
291
+ }
292
+ if (stated.length) return named(stated[0].name, stated[0].source);
293
+
294
+ const store = getStore();
295
+ const names = Object.keys(store.contexts);
296
+ if (names.length === 0) {
297
+ throw new TargetError("LOGIN_REQUIRED", "Login required: run `blocofy login` (or set BLOCOFY_URL + BLOCOFY_TOKEN).", {}, 1);
298
+ }
299
+
300
+ if (binding) {
301
+ const candidates = names.filter((n) => contextMatchesBinding(store.contexts[n], binding.project));
302
+ if (candidates.length === 1) return { name: candidates[0], source: "binding", context: store.contexts[candidates[0]], ignored: [] };
303
+ if (candidates.length > 1 && isTTY && prompt) {
304
+ const picked = await prompt(candidates);
305
+ if (candidates.includes(picked)) return { name: picked, source: "prompt", context: store.contexts[picked], ignored: [] };
306
+ }
307
+ throw new TargetError(
308
+ "TARGET_CONTEXT_REQUIRED",
309
+ candidates.length > 1
310
+ ? `Several contexts match this project's site (${candidates.join(", ")}). Choose one with --context <name> or \`blocofy link --context <name>\`.`
311
+ : `No saved context matches this project's site (${binding.project.site_slug ?? binding.project.site_id}). Log in to it (blocofy login) or pass --context <name>.`,
312
+ { site_id: binding.project.site_id, candidates },
313
+ );
314
+ }
315
+
316
+ if (allowCurrentContext && store.current_context && store.contexts[store.current_context]) {
317
+ return { name: store.current_context, source: "current_context", context: store.contexts[store.current_context], ignored: [] };
318
+ }
319
+ throw new TargetError(
320
+ "TARGET_CONTEXT_REQUIRED",
321
+ "This directory is not bound to a site, so the site must be chosen explicitly: pass --context <name> (see `blocofy contexts`), set BLOCOFY_CONTEXT, or bind the directory (blocofy link <dir> --context <name>).",
322
+ { contexts: names },
323
+ );
324
+ }
325
+
326
+ /** Offline check before any network: a context already recorded for another site cannot serve this binding. */
327
+ export function precheckContext({ binding, resolved }) {
328
+ const ctx = resolved.context;
329
+ if (!binding || !isObj(ctx?.site)) return;
330
+ if (!contextMatchesBinding(ctx, binding.project)) {
331
+ throw new TargetError(
332
+ "TARGET_SITE_MISMATCH",
333
+ `Context "${resolved.name}" is for site ${ctx.site.slug ?? ctx.site.id}, but this project is bound to site ${binding.project.site_slug ?? binding.project.site_id}. Nothing was read or written.`,
334
+ { context: resolved.name, context_site_id: ctx.site.id, binding_site_id: binding.project.site_id },
335
+ );
336
+ }
337
+ }
338
+
339
+ /** The theme dev token's prefix (the v1 API key's is API_KEY_PREFIX, `blcf_live_`). */
340
+ const DEV_TOKEN_PREFIX = "bcf_";
341
+
342
+ /**
343
+ * 1.8: the dev token and the v1 API key are two separate credentials and stay two. A pair that holds the other
344
+ * type's secret is refused before any request (TARGET_CREDENTIAL_WRONG_TYPE, exit 1) — it is never sent to the
345
+ * other endpoint, and never silently used as the other type. The message names where the secret came from, never
346
+ * the secret.
347
+ */
348
+ export function assertCredentialTypes({ resolved, secrets }) {
349
+ const refuse = (kind, secret) => {
350
+ const env = Boolean(resolved.env);
351
+ 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}"`;
352
+ const other = kind === "dev" ? secret.startsWith("blcf_") : secret.startsWith(DEV_TOKEN_PREFIX);
353
+ 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";
354
+ const expected = kind === "dev" ? `a theme dev token starts with ${DEV_TOKEN_PREFIX}` : `a v1 API key starts with ${API_KEY_PREFIX}`;
355
+ const fix = env
356
+ ? kind === "dev"
357
+ ? "put the dev token in BLOCOFY_TOKEN and the API key in BLOCOFY_API_KEY (+ BLOCOFY_API_URL)"
358
+ : "put the API key in BLOCOFY_API_KEY and the dev token in BLOCOFY_TOKEN (+ BLOCOFY_URL)"
359
+ : kind === "dev"
360
+ ? `run \`blocofy login --context ${resolved.name}\``
361
+ : `run \`blocofy login --api-key --context ${resolved.name}\``;
362
+ throw new TargetError(
363
+ "TARGET_CREDENTIAL_WRONG_TYPE",
364
+ `${where} ${holds}; ${expected}. The dev token and the v1 API key are separate credentials: ${fix}. Nothing was sent.`,
365
+ { context: resolved.name, pair: kind },
366
+ 1,
367
+ );
368
+ };
369
+ if (resolved.context.dev && typeof secrets.devToken === "string" && !secrets.devToken.startsWith(DEV_TOKEN_PREFIX)) refuse("dev", secrets.devToken);
370
+ 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);
371
+ }
372
+
373
+ // ── remote identity ─────────────────────────────────────────────────────────────────────────────────────────
374
+
375
+ const IDENTITY_TIMEOUT_MS = 15000;
376
+
377
+ /** Identity reads use the shared retry contract (lib/http.mjs); `retry` = `{ onRetry, sleep, now }` overrides. */
378
+ async function getJson(fetchImpl, url, secret, retry = {}) {
379
+ let res;
380
+ try {
381
+ res = await fetchWithRetry(url, { headers: { authorization: `Bearer ${secret}`, accept: "application/json" } }, { ...retry, fetchImpl, timeoutMs: IDENTITY_TIMEOUT_MS });
382
+ } catch (error) {
383
+ return { error: `unreachable (${error?.name === "TimeoutError" ? "timeout" : "network error"})` };
384
+ }
385
+ const text = await res.text().catch(() => "");
386
+ if (!res.ok) return { error: `HTTP ${res.status}` };
387
+ try {
388
+ return { body: JSON.parse(text) };
389
+ } catch {
390
+ return { error: "malformed response (not JSON)" };
391
+ }
392
+ }
393
+
394
+ function unverified(what, url, reason) {
395
+ return new TargetError("TARGET_UNVERIFIED", `Could not verify the target site via ${what} (${reason}). Nothing was sent or written.`, { endpoint: url, reason });
396
+ }
397
+
398
+ /** `GET {url}/api/dev/whoami` → `{ site, platformOrigin, liveThemeId }` or TARGET_UNVERIFIED. */
399
+ export async function verifyDev({ url, token, fetchImpl = globalThis.fetch, retry = {} }) {
400
+ const endpoint = `${url.replace(/\/+$/, "")}/api/dev/whoami`;
401
+ const r = await getJson(fetchImpl, endpoint, token, retry);
402
+ if (r.error) throw unverified("/api/dev/whoami", endpoint, r.error);
403
+ const site = r.body?.site;
404
+ if (!isObj(site) || !(typeof site.id === "string" || Number.isFinite(site.id)) || typeof site.slug !== "string") {
405
+ throw unverified("/api/dev/whoami", endpoint, "malformed response (no site id/slug)");
406
+ }
407
+ return { site: { id: site.id, slug: site.slug, name: typeof site.name === "string" ? site.name : null, domain: typeof site.domain === "string" ? site.domain : null }, platformOrigin: origin(r.body.platform_origin), liveThemeId: r.body.liveThemeId ?? null };
408
+ }
409
+
410
+ /** `GET {apiUrl}/api/v1/ping` → `{ site, platformOrigin }` or TARGET_UNVERIFIED. */
411
+ export async function verifyApi({ url, apiKey, fetchImpl = globalThis.fetch, retry = {} }) {
412
+ const endpoint = `${url.replace(/\/+$/, "")}/api/v1/ping`;
413
+ const r = await getJson(fetchImpl, endpoint, apiKey, retry);
414
+ if (r.error) throw unverified("/api/v1/ping", endpoint, r.error);
415
+ const site = r.body?.site;
416
+ if (!isObj(site) || !(typeof site.id === "string" || Number.isFinite(site.id))) {
417
+ throw unverified("/api/v1/ping", endpoint, "malformed response (no site id)");
418
+ }
419
+ return { site: { id: site.id, slug: typeof site.slug === "string" ? site.slug : null, name: typeof site.name === "string" ? site.name : null, domain: typeof site.domain === "string" ? site.domain : null }, platformOrigin: origin(r.body.platform_origin), liveThemeId: null };
420
+ }
421
+
422
+ function originUnproven(what, recorded) {
423
+ return new TargetError(
424
+ "TARGET_UNVERIFIED",
425
+ `The server does not report its platform origin, so it cannot be proven to be ${recorded} (recorded for ${what}). Nothing was sent or written.`,
426
+ { reason: "platform_origin_missing", recorded_platform_origin: recorded },
427
+ );
428
+ }
429
+
430
+ /** Both pairs of one context must name the same site (and platform). */
431
+ export function assertSameSite(dev, api, contextName) {
432
+ if (!sameId(dev.site.id, api.site.id) || dev.platformOrigin !== api.platformOrigin) {
433
+ throw new TargetError(
434
+ "TARGET_CREDENTIAL_MISMATCH",
435
+ `Context "${contextName}" mixes credentials of two sites: the dev token is for ${dev.site.slug ?? dev.site.id}, the API key is for ${api.site.slug ?? api.site.id}. Nothing was sent or written.`,
436
+ { context: contextName, dev_site_id: dev.site.id, api_site_id: api.site.id },
437
+ );
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Verify every usable pair of the resolved context, then compare with the context's recorded site and with the
443
+ * binding. Returns the verified identity (`site`, `platformOrigin`, `liveThemeId`, and `warnings` when a record lacks
444
+ * its platform origin — see `compareOrigin`).
445
+ */
446
+ export async function verifyTarget({ resolved, secrets, binding, fetchImpl = globalThis.fetch, retry = {} }) {
447
+ const ctx = resolved.context;
448
+ const dev = ctx.dev && secrets.devToken ? await verifyDev({ url: ctx.dev.url, token: secrets.devToken, fetchImpl, retry }) : null;
449
+ const api = ctx.api && secrets.apiKey ? await verifyApi({ url: ctx.api.url, apiKey: secrets.apiKey, fetchImpl, retry }) : null;
450
+ if (dev && api) assertSameSite(dev, api, resolved.name);
451
+ const identity = dev ? { ...dev, site: { ...dev.site, domain: dev.site.domain ?? api?.site.domain ?? null } } : api;
452
+ if (!identity) throw new TargetError("LOGIN_REQUIRED", `Context "${resolved.name}" has no usable credentials. Run \`blocofy login\`.`, { context: resolved.name }, 1);
453
+
454
+ const warnings = [];
455
+ if (isObj(ctx.site)) {
456
+ const o = compareOrigin(ctx.platform_origin, identity.platformOrigin);
457
+ if (!sameId(ctx.site.id, identity.site.id) || o === "mismatch") {
458
+ throw new TargetError(
459
+ "TARGET_SITE_MISMATCH",
460
+ `Context "${resolved.name}" is recorded for site ${ctx.site.slug ?? ctx.site.id}, but its credentials now resolve to ${identity.site.slug ?? identity.site.id}. Nothing was sent or written. Log in again: blocofy login --context ${resolved.name}`,
461
+ { context: resolved.name, context_site_id: ctx.site.id, remote_site_id: identity.site.id },
462
+ );
463
+ }
464
+ if (o === "unproven") throw originUnproven(`context "${resolved.name}"`, ctx.platform_origin);
465
+ if (o === "upgrade") {
466
+ warnings.push({ code: "TARGET_CONTEXT_ORIGIN_MISSING", message: `Context "${resolved.name}" is missing its platform origin; run \`blocofy login --context ${resolved.name}\` to record ${identity.platformOrigin}.` });
467
+ }
468
+ }
469
+ if (binding) {
470
+ const o = compareOrigin(binding.project.platform_origin, identity.platformOrigin);
471
+ if (!sameId(binding.project.site_id, identity.site.id) || o === "mismatch") {
472
+ throw new TargetError(
473
+ "TARGET_SITE_MISMATCH",
474
+ `This project is bound to site ${binding.project.site_slug ?? binding.project.site_id}, but context "${resolved.name}" resolves to ${identity.site.slug ?? identity.site.id}. Nothing was sent or written.`,
475
+ { context: resolved.name, binding_site_id: binding.project.site_id, remote_site_id: identity.site.id },
476
+ );
477
+ }
478
+ if (o === "unproven") throw originUnproven("this project's binding", binding.project.platform_origin);
479
+ if (o === "upgrade") {
480
+ warnings.push({ code: "TARGET_BINDING_ORIGIN_MISSING", message: `The project binding is missing its platform origin; run \`blocofy link --adopt\` to record ${identity.platformOrigin}.` });
481
+ }
482
+ }
483
+ if (warnings.length) identity.warnings = warnings;
484
+ return identity;
485
+ }