diff --git a/packages/cli/src/commands/publish.test.ts b/packages/cli/src/commands/publish.test.ts index 3f3334836..8efca5695 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,56 @@ 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("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 1959e1766..b3e290adc 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -119,13 +119,24 @@ 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.")}`, ); + // 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( - ` ${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("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?" }); @@ -147,7 +158,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(