From 710a58eac2b0c8b14aa5fa84d8a97968f485f1ad Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 28 Aug 2026 23:58:56 -0400 Subject: [PATCH 1/2] fix(cli): stop promising a claim link to signed-in publishers The publish consent notice always said "anyone with the URL can open the published project and claim it after authenticating", but that is only true for an anonymous publish. When a credential resolves, the server returns claimed=true with an empty claim_token and the CLI prints no claim URL, so a signed-in user was left hunting for a token that was never issued. Resolve the credential once before the notice and branch the wording on it. The --update/--space gate now reuses that same resolution instead of calling tryResolveCredential a second time. --- packages/cli/src/commands/publish.test.ts | 52 +++++++++++++++++++++++ packages/cli/src/commands/publish.ts | 14 ++++-- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/publish.test.ts b/packages/cli/src/commands/publish.test.ts index 3f3334836..b488a7475 100644 --- a/packages/cli/src/commands/publish.test.ts +++ b/packages/cli/src/commands/publish.test.ts @@ -4,12 +4,24 @@ import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; const publishState = vi.hoisted(() => ({ publish: vi.fn() })); +const authState = vi.hoisted(() => ({ tryResolveCredential: vi.fn().mockResolvedValue(null) })); +const clackState = vi.hoisted(() => ({ confirm: vi.fn() })); vi.mock("../utils/publishProject.js", async (importOriginal) => ({ ...(await importOriginal()), publishProjectArchive: publishState.publish, })); +vi.mock("../auth/index.js", async (importOriginal) => ({ + ...(await importOriginal()), + tryResolveCredential: authState.tryResolveCredential, +})); + +vi.mock("@clack/prompts", async (importOriginal) => ({ + ...(await importOriginal()), + confirm: clackState.confirm, +})); + import publishCommand, { parseUpdateTarget } from "./publish.js"; describe("parseUpdateTarget", () => { @@ -90,3 +102,43 @@ describe("publish default-entry preflight", () => { expect(output).toContain("publish accepts project directories, not individual HTML files"); }); }); + +describe("publish consent notice", () => { + async function runConsent(credential: unknown): Promise { + const project = mkdtempSync(join(tmpdir(), "hf-publish-consent-")); + writeFileSync( + join(project, "index.html"), + `
Visible
`, + ); + publishState.publish.mockReset(); + authState.tryResolveCredential.mockReset().mockResolvedValue(credential); + // Declining the prompt stops the run right after the notice — no network, no upload. + clackState.confirm.mockReset().mockResolvedValue(false); + const lines: string[] = []; + const log = vi.spyOn(console, "log").mockImplementation((...parts: unknown[]) => { + lines.push(parts.map(String).join(" ")); + }); + + try { + await publishCommand.run?.({ args: { dir: project, proxy: false } } as never); + expect(publishState.publish).not.toHaveBeenCalled(); + return lines.join("\n"); + } finally { + log.mockRestore(); + rmSync(project, { recursive: true, force: true }); + } + } + + it("offers the claim step when publishing anonymously", async () => { + const output = await runConsent(null); + + expect(output).toContain("claim it after authenticating"); + }); + + it("tells a signed-in publisher there is no claim link", async () => { + const output = await runConsent({ type: "api_key", key: "k", source: "env" }); + + expect(output).toContain("owned by your account"); + expect(output).not.toContain("claim it after authenticating"); + }); +}); diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 1959e1766..fbc7fc14c 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -119,13 +119,22 @@ export default defineCommand({ } } + // Resolved once: the consent notice and the --update/--space gate must agree on + // whether this publish is authenticated. + const credential = await tryResolveCredential(); + if (args.yes !== true) { console.log(); console.log( - ` ${c.bold("hyperframes publish uploads this project and creates a stable public URL.")}`, + ` ${c.bold("hyperframes publish uploads this project and creates a stable URL.")}`, ); + // Only an anonymous publish gets a claim token; an authenticated one is owned on + // creation. Promising a claim step to a signed-in user sends them looking for a + // token the server never issued. console.log( - ` ${c.dim("Anyone with the URL can open the published project and claim it after authenticating.")}`, + credential === null + ? ` ${c.dim("Anyone with the URL can open the published project and claim it after authenticating.")}` + : ` ${c.dim("You are signed in, so the project is owned by your account on publish. There is no claim link.")}`, ); console.log(); const approved = await clack.confirm({ message: "Publish this project?" }); @@ -147,7 +156,6 @@ export default defineCommand({ // --update / --space only take effect for an authenticated owner. Fail loudly rather // than silently minting a fresh URL — the exact failure mode this feature removes. if (updateTarget || spaceOverride) { - const credential = await tryResolveCredential(); if (!credential) { console.log(); console.log( From f86f39ef2b7098fb5251946948cf1637fbae9858 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 29 Aug 2026 11:50:34 -0400 Subject: [PATCH 2/2] fix(cli): keep the exposure disclosure on both consent branches The claim-link fix dropped "public" from the shared bold line and replaced, rather than supplemented, the dim line on the signed-in branch. A signed-in publisher then approved the upload without being told anywhere in the flow that the artifact is reachable by anyone holding the URL: the owned result block prints Project / Files / URL / Status, and "Public" only appears on the anonymous branch. The disclosure is true for an owned project, not defensive boilerplate. The published-project read is the one hyperframes route with no authentication decorator, so any URL holder can open it; --public governs whether the CLAIMED session is public in the web app, not whether the link is readable. Restore "stable public URL" and lead the signed-in line with the same exposure sentence, keeping only the claim half branch-specific. A parameterised test now asserts both branches carry it. --- packages/cli/src/commands/publish.test.ts | 15 ++++++++++++++- packages/cli/src/commands/publish.ts | 12 +++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/publish.test.ts b/packages/cli/src/commands/publish.test.ts index b488a7475..8efca5695 100644 --- a/packages/cli/src/commands/publish.test.ts +++ b/packages/cli/src/commands/publish.test.ts @@ -138,7 +138,20 @@ describe("publish consent notice", () => { it("tells a signed-in publisher there is no claim link", async () => { const output = await runConsent({ type: "api_key", key: "k", source: "env" }); - expect(output).toContain("owned by your account"); + expect(output).toContain("you own it on publish and there is no claim link"); expect(output).not.toContain("claim it after authenticating"); }); + + it.each([ + ["anonymous", null], + ["signed in", { type: "api_key", key: "k", source: "env" }], + ])("discloses the exposure before uploading (%s)", async (_label, credential) => { + // The consent prompt is the only place the user is told the artifact is world-readable: + // the result block prints no exposure line on the owned branch. Dropping it from either + // branch means someone approves an upload without being told who can reach it. + const output = await runConsent(credential); + + expect(output).toContain("creates a stable public URL"); + expect(output).toContain("Anyone with the URL can open the published project"); + }); }); diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index fbc7fc14c..b3e290adc 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -126,15 +126,17 @@ export default defineCommand({ if (args.yes !== true) { console.log(); console.log( - ` ${c.bold("hyperframes publish uploads this project and creates a stable URL.")}`, + ` ${c.bold("hyperframes publish uploads this project and creates a stable public URL.")}`, ); - // Only an anonymous publish gets a claim token; an authenticated one is owned on - // creation. Promising a claim step to a signed-in user sends them looking for a - // token the server never issued. + // Both branches must state the exposure — the published project is readable by anyone + // holding the URL whether or not it is owned. Only the claim half differs: an + // anonymous publish gets a claim token, an authenticated one is owned on creation, so + // promising a claim step to a signed-in user sends them looking for a token the server + // never issued. console.log( credential === null ? ` ${c.dim("Anyone with the URL can open the published project and claim it after authenticating.")}` - : ` ${c.dim("You are signed in, so the project is owned by your account on publish. There is no claim link.")}`, + : ` ${c.dim("Anyone with the URL can open the published project. You are signed in, so you own it on publish and there is no claim link.")}`, ); console.log(); const approved = await clack.confirm({ message: "Publish this project?" });