@blocofy/cli 0.16.0 → 0.17.1

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 CHANGED
@@ -132,6 +132,9 @@ and editor views for this command are retired; to share a draft page, create a p
132
132
  it (v1 API `POST /themes/{id}/preview-links` or MCP `create_preview_link`; one page per link).
133
133
  The target site is verified once at start; a long session keeps that target (restart it after
134
134
  changing credentials, context or the project binding).
135
+ The dev server answers this machine only: it listens on `http://127.0.0.1:<port>`, refuses a request
136
+ whose `Host` (or `Origin`, when sent) is not that address or `localhost:<port>`, and answers only
137
+ `GET`/`HEAD` (anything else gets `405`). The reload channel is same-origin only.
135
138
  - `--port <n>` — local port (default 3030).
136
139
  - `--no-sync` — local preview only (skip draft sync).
137
140
  - `--name <name>` — name the draft when it is first created (ignored if it already exists).
@@ -623,7 +626,27 @@ Each retry prints a notice on stderr.
623
626
 
624
627
  ## Changelog
625
628
 
626
- - **0.16.0** (unreleased) — Browser login, `blocofy init`, honest `logout` (ADR-0014 wave P4).
629
+ - **0.17.1** (2026-11-01) — Security fix: `theme dev` answers this machine only.
630
+ - The local server listens on IPv4 loopback (`127.0.0.1`) instead of every network interface, and
631
+ prints that address. There is no option to open it to the network.
632
+ - A request whose `Host` is not `127.0.0.1:<port>` / `localhost:<port>`, or whose `Origin` is another
633
+ site, is refused before anything is read or rendered (DNS-rebinding and cross-site pages).
634
+ - Only `GET` and `HEAD` are answered; other methods get `405` with `Allow: GET, HEAD` and never reach
635
+ the platform. `HEAD` returns headers only and never spends a render.
636
+ - The reload channel no longer sends `Access-Control-Allow-Origin: *`; the local page still reloads on
637
+ save. Views on other origins no longer subscribe to it.
638
+ - **0.17.0** (2026-10-31) — `theme publish` tells you when the live site is not serving the files
639
+ you published.
640
+ - **`theme publish` reports an unrefreshed live site.** When the platform answers
641
+ `live_sealed: false`, the CLI prints the reason and the fix (`blocofy theme push --live --yes`)
642
+ and exits 4 (not complete) instead of reporting success. Platforms that do not send the field
643
+ behave as before.
644
+ - **`blocofy status` shows live file freshness** ("Live files: up to date", or which files are
645
+ stale, missing or extra on the live site).
646
+ - `theme publish` accepts `--yes` / `--confirm` (no prompt to skip; accepted so scripts that pass
647
+ it to every live command do not fail).
648
+ - `theme dev` no longer prints a preview URL containing `undefined&hr=` for a session without one.
649
+ - **0.16.0** (2026-10-06) — Browser login, `blocofy init`, honest `logout` (ADR-0014 wave P4).
627
650
  Pairs with the platform release of the credential profiles (draft-only "Tema geliştirme" by
628
651
  default). **Minimum platform:** the browser login needs a platform that publishes the CLI client
629
652
  (`blocofy-cli`), RFC 9207 `iss`, `/revoke` and the `profile` / `audience` / `dev_endpoint` fields of
package/bin/blocofy.mjs CHANGED
@@ -100,7 +100,8 @@ const KNOWN = {
100
100
  themePull: ["draft", "instance"],
101
101
  themePush: ["diff", "draft", "instance", "name", "live", "yes", "confirm", "dry-run", "validate", "idempotency-key", "prune", "work"],
102
102
  themeDev: ["port", "dry", "no-sync", "name", "instance"],
103
- themePublish: ["instance"],
103
+ // `yes`/`confirm` are accepted (no-op: publish never prompts) so `--yes` works the same as on `theme push --live`.
104
+ themePublish: ["instance", "yes", "confirm"],
104
105
  themeRename: ["name", "dir"],
105
106
  themeWorkStart: ["intent", "idempotency-key"],
106
107
  themeWorkStatus: ["dir"],
@@ -2595,9 +2596,11 @@ async function themeDev(rest) {
2595
2596
  }
2596
2597
  }
2597
2598
 
2598
- const localUrl = `http://localhost:${port}`;
2599
- const previewUrl = session ? `${session.previewUrl}&hr=${port}` : null;
2600
- const editorUrl = session ? `${session.editorUrl}&hr=${port}` : null;
2599
+ // The URL the dev server actually binds (IPv4 loopback): `localhost` may resolve to ::1 first.
2600
+ const localUrl = `http://127.0.0.1:${port}`;
2601
+ // A session without a URL (an older platform answer) must not become "undefined&hr=…".
2602
+ const previewUrl = session?.previewUrl ? `${session.previewUrl}&hr=${port}` : null;
2603
+ const editorUrl = session?.editorUrl ? `${session.editorUrl}&hr=${port}` : null;
2601
2604
 
2602
2605
  // Çözülen site'ı göster (session TOKEN'dan çözer) → hangi tenant'ı düzenlediğin belli olsun.
2603
2606
  const siteLine = session?.site ? `${siteLabel(session.site)} · ` : "";
@@ -2614,7 +2617,7 @@ async function themeDev(rest) {
2614
2617
  .filter(Boolean)
2615
2618
  .join(" ");
2616
2619
  console.log(`\n Press: ${keys}`);
2617
- console.log(` Edit a theme file and save — every open view reloads automatically.\n`);
2620
+ console.log(` Edit a theme file and save — the local view reloads automatically.\n`);
2618
2621
 
2619
2622
  // Senkron kapsamı (#119 CLI bulgu #2): hangi dizinler taşınıyor / taşınmıyor —
2620
2623
  // "config/pages senkronlanıyor sandım" karışıklığını açıkça önler.
@@ -2778,6 +2781,16 @@ async function themePublish(rest) {
2778
2781
  console.log(
2779
2782
  `✓ Theme ${result.published} is now LIVE${result.cloned ? " (pages cloned from the previous live theme)" : ""}.`,
2780
2783
  );
2784
+ // The platform seals the new live theme's current files on publish and says whether it did. Not sealed = the
2785
+ // live site may still serve the files this theme had at its last seal (an older app.js): exit 4 so scripts
2786
+ // notice, and say how to seal again. An older platform sends no `live_sealed`: nothing changes.
2787
+ if (result.live_sealed === false) {
2788
+ console.error(
2789
+ `Warning: the live site may still serve this theme's previous files (live files not refreshed: ${result.live_seal_reason ?? "unknown"}).\n` +
2790
+ `Refresh them with: blocofy theme push --live --yes`,
2791
+ );
2792
+ process.exitCode = 4;
2793
+ }
2781
2794
  }
2782
2795
 
2783
2796
  // ── theme work ("çalışma"): a safe, private copy of the live theme, continued by its exact handle ──────────────
@@ -3242,6 +3255,17 @@ async function status(rest) {
3242
3255
  : `Live theme: none`,
3243
3256
  );
3244
3257
  console.log(`Health: ${s.health}`);
3258
+ // What live actually serves vs. the stored theme files (platforms that report it).
3259
+ const ls = s.live_source;
3260
+ if (ls?.pinned === true && ls.in_sync === false) {
3261
+ const paths = [...(ls.stale ?? []), ...(ls.missing ?? []), ...(ls.extra ?? [])];
3262
+ console.error(`Live files: OUT OF DATE — the live site does not serve ${paths.length} stored file(s): ${paths.slice(0, 5).join(", ")}${paths.length > 5 ? ", …" : ""}`);
3263
+ console.error(" Refresh them with: blocofy theme push --live --yes");
3264
+ } else if (ls?.pinned === true) {
3265
+ console.log("Live files: up to date");
3266
+ } else if (ls?.pinned === null) {
3267
+ console.error("Live files: could not be checked right now");
3268
+ }
3245
3269
  // CF-T9: no imperative one-line fix — which theme holds the pages, why, and safe (preview-first) next steps.
3246
3270
  for (const line of healthAdvice(s)) console.error(line);
3247
3271
  if (Array.isArray(s.drafts) && s.drafts.length > 0) {
@@ -11,6 +11,43 @@ import {
11
11
  } from "./local-theme.mjs";
12
12
  import { DRAFT_TARGET_AMBIGUOUS, draftSyncErrorLine, pushTheme } from "./theme-sync.mjs";
13
13
 
14
+ /**
15
+ * The dev server proxies page renders with the developer's bearer, so it answers only this machine:
16
+ * it binds IPv4 loopback, and every request must name it (Host + optional Origin) before any branch runs.
17
+ * There is no LAN option on purpose — exposing it needs its own auth/threat model.
18
+ */
19
+ const DEV_SERVER_HOST = "127.0.0.1";
20
+ const ALLOWED_METHODS = "GET, HEAD";
21
+
22
+ /** Why `req` is refused (status + short reason; never echoes request data or the credential), or null. */
23
+ function refusal(req, port) {
24
+ if (req.method !== "GET" && req.method !== "HEAD") return { status: 405, reason: "Method not allowed." };
25
+ // Origin-form only: an absolute request target (proxy style) could name another authority.
26
+ if (typeof req.url !== "string" || !req.url.startsWith("/")) return { status: 400, reason: "Bad request target." };
27
+ const ownHosts = new Set([`${DEV_SERVER_HOST}:${port}`, `localhost:${port}`]);
28
+ const ownOrigins = new Set([...ownHosts].map((host) => `http://${host}`));
29
+ // rawHeaders keeps duplicates that req.headers would silently collapse.
30
+ const headerValues = (name) => {
31
+ const values = [];
32
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
33
+ if (req.rawHeaders[i].toLowerCase() === name) values.push(req.rawHeaders[i + 1].trim().toLowerCase());
34
+ }
35
+ return values;
36
+ };
37
+ // Exactly one Host, and it is this server: a DNS-rebinding page arrives with its own host name.
38
+ // X-Forwarded-Host / Forwarded / Referer are never read.
39
+ const hosts = headerValues("host");
40
+ if (hosts.length !== 1 || !ownHosts.has(hosts[0])) {
41
+ return { status: 403, reason: `This dev server only answers http://${DEV_SERVER_HOST}:${port}.` };
42
+ }
43
+ // A browser that sends Origin must be on this server's own origin (cross-origin pages and `null` refused).
44
+ const origins = headerValues("origin");
45
+ if (origins.length > 1 || (origins.length === 1 && !ownOrigins.has(origins[0]))) {
46
+ return { status: 403, reason: `Cross-origin requests are not served; open http://${DEV_SERVER_HOST}:${port}.` };
47
+ }
48
+ return null;
49
+ }
50
+
14
51
  /**
15
52
  * Standalone dev server. For each page request it reads the local theme files and
16
53
  * posts them to the platform's `/api/dev/render` endpoint, then injects a livereload
@@ -105,18 +142,29 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, insta
105
142
  }
106
143
 
107
144
  const server = createServer(async (req, res) => {
145
+ // Before every branch: no SSE subscription, disk read or authenticated render for a refused request.
146
+ const refused = refusal(req, server.address()?.port);
147
+ if (refused) {
148
+ res.writeHead(refused.status, {
149
+ "content-type": "text/plain; charset=utf-8",
150
+ "cache-control": "no-store",
151
+ ...(refused.status === 405 ? { allow: ALLOWED_METHODS } : {}),
152
+ });
153
+ res.end(`${refused.reason}\n`);
154
+ return;
155
+ }
156
+ const head = req.method === "HEAD";
108
157
  try {
109
158
  const pathname = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
110
159
 
111
- // Livereload SSE channel.
160
+ // Livereload SSE channel — same-origin only (no CORS header): cross-origin views no longer subscribe.
112
161
  if (pathname === LIVERELOAD_PATH) {
113
162
  res.writeHead(200, {
114
163
  "content-type": "text/event-stream",
115
164
  "cache-control": "no-cache, no-transform",
116
- connection: "keep-alive",
117
- // Cross-origin iframe'ler (canlı-domain önizleme + editör) subscribe edebilsin.
118
- "access-control-allow-origin": "*",
165
+ ...(head ? {} : { connection: "keep-alive" }),
119
166
  });
167
+ if (head) return void res.end();
120
168
  res.write("retry: 1000\n\n");
121
169
  clients.add(res);
122
170
  req.on("close", () => clients.delete(res));
@@ -127,7 +175,14 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, insta
127
175
  const assetFile = safeAssetFile(dir, pathname);
128
176
  if (assetFile) {
129
177
  res.writeHead(200, { "content-type": contentTypeFor(assetFile), "cache-control": "no-store" });
130
- res.end(readFileSync(assetFile));
178
+ res.end(head ? undefined : readFileSync(assetFile));
179
+ return;
180
+ }
181
+
182
+ // HEAD never spends an authenticated render: headers only.
183
+ if (head) {
184
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
185
+ res.end();
131
186
  return;
132
187
  }
133
188
 
@@ -166,7 +221,7 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, insta
166
221
  onReload?.({ file, synced: syncDraft && !error, clients: clients.size, error });
167
222
  });
168
223
 
169
- server.listen(port);
224
+ server.listen(port, DEV_SERVER_HOST);
170
225
 
171
226
  return {
172
227
  server,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocofy/cli",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
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",