diff --git a/packages/cli/src/commands/publish.test.ts b/packages/cli/src/commands/publish.test.ts new file mode 100644 index 0000000000..0d41515c9d --- /dev/null +++ b/packages/cli/src/commands/publish.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { parseUpdateTarget } from "./publish.js"; + +describe("parseUpdateTarget", () => { + it("extracts the id from a full published URL", () => { + expect(parseUpdateTarget("https://hyperframes.dev/p/hfp_abc123")).toBe("hfp_abc123"); + }); + + it("handles a scheme-less URL (which new URL() rejects)", () => { + expect(parseUpdateTarget("hyperframes.dev/p/hfp_abc123")).toBe("hfp_abc123"); + }); + + it("strips a trailing query and hash", () => { + expect(parseUpdateTarget("https://hyperframes.dev/p/hfp_abc123?claim_token=x#frag")).toBe( + "hfp_abc123", + ); + }); + + it("accepts a bare id unchanged and trims surrounding whitespace", () => { + expect(parseUpdateTarget(" hfp_abc123 ")).toBe("hfp_abc123"); + }); + + it("falls back to the last path segment for a non-/p/ URL", () => { + expect(parseUpdateTarget("https://example.com/foo/hfp_abc123")).toBe("hfp_abc123"); + }); +}); diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index df263d87c7..a82754b951 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -1,6 +1,5 @@ -import { resolve } from "node:path"; +import { join, relative, resolve } from "node:path"; import { existsSync } from "node:fs"; -import { join } from "node:path"; import { defineCommand } from "citty"; import * as clack from "@clack/prompts"; @@ -9,14 +8,39 @@ import { c } from "../ui/colors.js"; import { lintProject } from "../utils/lintProject.js"; import { formatLintFindings } from "../utils/lintFormat.js"; import { publishProjectArchive } from "../utils/publishProject.js"; +import { tryResolveCredential } from "../auth/index.js"; +import { + ensureProjectId, + readProjectLink, + readTeamProject, + writeTeamProject, +} from "../utils/projectLink.js"; export const examples: Example[] = [ ["Publish the current project with a public URL", "hyperframes publish"], ["Publish a specific directory", "hyperframes publish ./my-video"], ["Make the claimed project public to anyone", "hyperframes publish --public"], + ["Update an existing published project in place", "hyperframes publish --update "], + ["Publish to a shared team space", "hyperframes publish --space "], ["Skip the consent prompt (scripts)", "hyperframes publish --yes"], ]; +/** Extract a project id from a published URL (with or without scheme, query, or hash) or accept a bare id. */ +export function parseUpdateTarget(value: string): string { + const trimmed = value.trim(); + // Pull the id straight out of a `/p/` path — works for full URLs, scheme-less URLs + // (which `new URL` rejects), and links carrying `?query`/`#hash`. + const pathMatch = trimmed.match(/\/p\/([^/?#]+)/); + if (pathMatch?.[1]) return pathMatch[1]; + try { + const segment = new URL(trimmed).pathname.split("/").filter(Boolean).pop(); + if (segment) return segment; + } catch { + // Not a URL — treat as a bare id. + } + return trimmed; +} + export default defineCommand({ meta: { name: "publish", @@ -35,6 +59,14 @@ export default defineCommand({ description: "Make the claimed project public to anyone, not just the claimer", default: false, }, + update: { + type: "string", + description: "Update an existing published project in place (its URL or id)", + }, + space: { + type: "string", + description: "Publish into a shared team space (its id) so teammates update one link", + }, }, async run({ args }) { const rawArg = args.dir; @@ -67,25 +99,119 @@ export default defineCommand({ } } + const updateTarget = + typeof args.update === "string" && args.update.trim() + ? parseUpdateTarget(args.update) + : undefined; + const spaceOverride = + typeof args.space === "string" && args.space.trim() ? args.space.trim() : undefined; + + // --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( + ` ${c.error(`${updateTarget ? "--update" : "--space"} requires authentication. Run 'hyperframes auth login' first.`)}`, + ); + console.log(); + process.exitCode = 1; + return; + } + } + + const committedTeam = readTeamProject(dir); + // Stable id: explicit --update wins, else the committed team id, else this machine's stored/minted id. + const requestedProjectId = updateTarget ?? committedTeam?.projectId ?? ensureProjectId(dir); + // Team space: explicit --space wins, else the committed space id, else the personal space. + const spaceId = spaceOverride ?? committedTeam?.spaceId; + + // Continuity cue: if this directory was published before, show where it lives so the + // user knows a re-publish updates that same link (when logged in). + const priorLink = readProjectLink(dir); + if (priorLink?.url) { + console.log(); + console.log(` ${c.dim(`Previously published at ${priorLink.url}`)}`); + } + clack.intro(c.bold("hyperframes publish")); const publishSpinner = clack.spinner(); publishSpinner.start("Uploading project..."); try { - const published = await publishProjectArchive(dir, { public: args.public === true }); - const claimUrl = new URL(published.url); - claimUrl.searchParams.set("claim_token", published.claimToken); + const published = await publishProjectArchive(dir, { + public: args.public === true, + projectId: requestedProjectId, + spaceId, + }); publishSpinner.stop(c.success("Project published")); console.log(); console.log(` ${c.dim("Project")} ${c.accent(published.title)}`); console.log(` ${c.dim("Files")} ${String(published.fileCount)}`); - console.log(` ${c.dim("Public")} ${c.accent(claimUrl.toString())}`); - console.log(); - console.log( - ` ${c.dim("Open the URL on hyperframes.dev to claim the project and continue editing.")}`, - ); - console.log(); + + if (published.claimed) { + // The server returns the same id on an in-place update, a fresh id on create. + const updatedInPlace = published.projectId === requestedProjectId; + console.log(` ${c.dim("URL")} ${c.accent(published.url)}`); + console.log( + ` ${c.dim("Status")} ${c.accent(updatedInPlace ? "Updated existing project" : "Created new project")}`, + ); + // Warn whenever we aimed at a KNOWN existing project (an explicit --update target or + // a committed team id) but the server created a fresh one instead — so a teammate + // whose space doesn't own the committed project doesn't silently lose the shared link. + if ((updateTarget || committedTeam) && !updatedInPlace) { + const targetDesc = updateTarget ? "The requested project" : "The committed team project"; + console.log(); + console.log( + ` ${c.dim(`${targetDesc} was not updated (not found, or your space can't access it); a new project was created above instead.`)}`, + ); + } + // Persist a committable descriptor so a team converges on this link. This is a + // convenience: wrap it so a read-only project dir can't turn a successful publish + // into a "Publish failed" (the outer catch owns publish failures only). + if ( + committedTeam === null || + (spaceId !== undefined && committedTeam.spaceId !== spaceId) + ) { + try { + const file = writeTeamProject(dir, { projectId: published.projectId, spaceId }); + console.log(); + console.log( + ` ${c.dim(`Wrote ${relative(dir, file) || file} — commit it so your team publishes to this link.`)}`, + ); + } catch { + // Convenience file only; never shadow a successful publish with a local write failure. + } + } + console.log(); + } else { + const claimUrl = new URL(published.url); + claimUrl.searchParams.set("claim_token", published.claimToken); + console.log(` ${c.dim("Public")} ${c.accent(claimUrl.toString())}`); + console.log(); + if (updateTarget || spaceOverride) { + // The pre-publish gate saw a credential, but the server didn't accept it (expired + // or invalid) and fell back to anonymous — say so loudly instead of pretending the + // requested update happened. + console.log( + ` ${c.error(`Your login looks expired or invalid, so ${updateTarget ? "--update" : "--space"} was ignored and a NEW url was created above.`)}`, + ); + console.log( + ` ${c.dim("Run 'hyperframes auth login' again, then re-publish to update in place.")}`, + ); + } else { + console.log( + ` ${c.dim("Open the URL on hyperframes.dev to claim the project and continue editing.")}`, + ); + console.log(); + console.log( + ` ${c.dim("Tip: run 'hyperframes auth login' first for a stable link you can re-publish to.")}`, + ); + } + console.log(); + } return; } catch (err: unknown) { publishSpinner.stop(c.error("Publish failed")); diff --git a/packages/cli/src/utils/projectLink.test.ts b/packages/cli/src/utils/projectLink.test.ts new file mode 100644 index 0000000000..37cbefd19b --- /dev/null +++ b/packages/cli/src/utils/projectLink.test.ts @@ -0,0 +1,116 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Point the module's ~/.hyperframes config dir at a throwaway home so tests use real fs +// without touching the developer's actual home directory. +const osState = vi.hoisted(() => ({ home: "" })); +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: () => osState.home }; +}); + +describe("projectLink", () => { + let projectsPath: string; + let projectDirs: string[]; + let ensureProjectId: typeof import("./projectLink.js").ensureProjectId; + let readProjectLink: typeof import("./projectLink.js").readProjectLink; + let writeProjectLink: typeof import("./projectLink.js").writeProjectLink; + let readTeamProject: typeof import("./projectLink.js").readTeamProject; + let writeTeamProject: typeof import("./projectLink.js").writeTeamProject; + + beforeEach(async () => { + osState.home = mkdtempSync(join(tmpdir(), "hf-home-")); + projectsPath = join(osState.home, ".hyperframes", "projects.json"); + projectDirs = []; + vi.resetModules(); + ({ ensureProjectId, readProjectLink, writeProjectLink, readTeamProject, writeTeamProject } = + await import("./projectLink.js")); + }); + + afterEach(() => { + rmSync(osState.home, { recursive: true, force: true }); + for (const dir of projectDirs) rmSync(dir, { recursive: true, force: true }); + }); + + function makeProjectDir(): string { + const dir = mkdtempSync(join(tmpdir(), "hf-proj-")); + projectDirs.push(dir); + return dir; + } + + it("mints one project id per directory and reuses it", () => { + const first = makeProjectDir(); + const second = makeProjectDir(); + const firstId = ensureProjectId(first); + + expect(ensureProjectId(first)).toBe(firstId); + expect(ensureProjectId(second)).not.toBe(firstId); + }); + + it("round-trips a project link", () => { + const dir = makeProjectDir(); + const link = { projectId: "hfp_123", url: "https://hyperframes.dev/p/hfp_123" }; + + writeProjectLink(dir, link); + + expect(readProjectLink(dir)).toEqual(link); + }); + + it("persists only the project id and URL", () => { + const dir = makeProjectDir(); + const link = { projectId: "hfp_123", url: "https://hyperframes.dev/p/hfp_123", secret: "nope" }; + + writeProjectLink(dir, link); + + expect(readFileSync(projectsPath, "utf-8")).not.toContain("nope"); + }); + + it("treats a missing projects file as empty and creates it on ensure", () => { + const dir = makeProjectDir(); + expect(readProjectLink(dir)).toBeNull(); + + const projectId = ensureProjectId(dir); + + expect(projectId).toBeTruthy(); + expect(readProjectLink(dir)).toEqual({ projectId, url: "" }); + }); + + it("treats corrupt JSON as empty and rewrites it on ensure", () => { + const dir = makeProjectDir(); + writeProjectLink(dir, { projectId: "x", url: "y" }); + writeFileSync(projectsPath, "{not valid json", "utf-8"); + expect(readProjectLink(dir)).toBeNull(); + + const projectId = ensureProjectId(dir); + + expect(readProjectLink(dir)).toEqual({ projectId, url: "" }); + expect(() => JSON.parse(readFileSync(projectsPath, "utf-8"))).not.toThrow(); + }); + + it("uses the resolved directory as the storage key", () => { + const dir = makeProjectDir(); + const projectId = ensureProjectId(dir); + + expect(ensureProjectId(resolve(dir))).toBe(projectId); + expect(Object.keys(JSON.parse(readFileSync(projectsPath, "utf-8")))).toEqual([resolve(dir)]); + }); + + it("reads and writes a committed team project (id + optional space)", () => { + const dir = makeProjectDir(); + expect(readTeamProject(dir)).toBeNull(); + + // Personal-space project: id only, no spaceId key. + const soloFile = writeTeamProject(dir, { projectId: "hfp_solo" }); + expect(readTeamProject(dir)).toEqual({ projectId: "hfp_solo" }); + const soloBody = readFileSync(soloFile, "utf-8"); + expect(soloBody).not.toContain("spaceId"); + // Never a secret. + expect(soloBody).not.toContain("token"); + + // Team-space project: id + shared space id round-trip. + writeTeamProject(dir, { projectId: "hfp_team", spaceId: "space-42" }); + expect(readTeamProject(dir)).toEqual({ projectId: "hfp_team", spaceId: "space-42" }); + }); +}); diff --git a/packages/cli/src/utils/projectLink.ts b/packages/cli/src/utils/projectLink.ts new file mode 100644 index 0000000000..33cc60ba0b --- /dev/null +++ b/packages/cli/src/utils/projectLink.ts @@ -0,0 +1,113 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +interface ProjectLink { + projectId: string; + url: string; +} + +type ProjectLinks = Record; + +const CONFIG_DIR = join(homedir(), ".hyperframes"); +const PROJECTS_FILE = join(CONFIG_DIR, "projects.json"); + +/** Read + parse a JSON object file, or null if it's missing, unreadable, or not an object. */ +function readJsonRecord(path: string): Record | null { + try { + if (!existsSync(path)) return null; + const parsed: unknown = JSON.parse(readFileSync(path, "utf-8")); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + return parsed as Record; + } catch { + return null; + } +} + +function isProjectLink(value: unknown): value is ProjectLink { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const link = value as Record; + return ( + typeof link.projectId === "string" && link.projectId.length > 0 && typeof link.url === "string" + ); +} + +function readProjectLinks(): ProjectLinks { + const record = readJsonRecord(PROJECTS_FILE); + if (!record) return {}; + const links: ProjectLinks = {}; + for (const [path, value] of Object.entries(record)) { + if (isProjectLink(value)) { + links[path] = { projectId: value.projectId, url: value.url }; + } + } + return links; +} + +function writeProjectLinks(links: ProjectLinks): void { + try { + mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); + writeFileSync(PROJECTS_FILE, `${JSON.stringify(links, null, 2)}\n`, { mode: 0o600 }); + } catch { + // Project links must never prevent local CLI commands from running. + } +} + +export function readProjectLink(absDir: string): ProjectLink | null { + return readProjectLinks()[resolve(absDir)] ?? null; +} + +export function writeProjectLink(absDir: string, link: ProjectLink): void { + const links = readProjectLinks(); + links[resolve(absDir)] = { projectId: link.projectId, url: link.url }; + writeProjectLinks(links); +} + +export function ensureProjectId(absDir: string): string { + const links = readProjectLinks(); + const path = resolve(absDir); + const existing = links[path]; + if (existing) return existing.projectId; + + const projectId = randomUUID(); + links[path] = { projectId, url: "" }; + writeProjectLinks(links); + return projectId; +} + +// A committed, in-project descriptor so a whole team publishes to one shared link. Holds +// the project id and (for team spaces) the space id — never a secret; ownership is +// enforced server-side by the authenticated space's membership. +const TEAM_PROJECT_DIR = ".hyperframes"; +const TEAM_PROJECT_FILE = "project.json"; + +export interface TeamProject { + projectId: string; + /** Shared team space id. Absent for a personal-space project (resolves per-user). */ + spaceId?: string; +} + +function teamProjectPath(projectDir: string): string { + return join(resolve(projectDir), TEAM_PROJECT_DIR, TEAM_PROJECT_FILE); +} + +export function readTeamProject(projectDir: string): TeamProject | null { + const record = readJsonRecord(teamProjectPath(projectDir)); + if (!record || typeof record.projectId !== "string" || record.projectId.length === 0) return null; + const spaceId = + typeof record.spaceId === "string" && record.spaceId.length > 0 ? record.spaceId : undefined; + return { projectId: record.projectId, ...(spaceId ? { spaceId } : {}) }; +} + +/** Write the committed team descriptor and return its path (for a "commit this" hint). */ +export function writeTeamProject(projectDir: string, team: TeamProject): string { + const file = teamProjectPath(projectDir); + const body: TeamProject = { + projectId: team.projectId, + ...(team.spaceId ? { spaceId: team.spaceId } : {}), + }; + mkdirSync(join(resolve(projectDir), TEAM_PROJECT_DIR), { recursive: true }); + writeFileSync(file, `${JSON.stringify(body, null, 2)}\n`); + return file; +} diff --git a/packages/cli/src/utils/publishProject.e2e.test.ts b/packages/cli/src/utils/publishProject.e2e.test.ts new file mode 100644 index 0000000000..b3836c071c --- /dev/null +++ b/packages/cli/src/utils/publishProject.e2e.test.ts @@ -0,0 +1,109 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { getPublishApiBaseUrl, publishProjectArchive } from "./publishProject.js"; + +// End-to-end round-trip against a LIVE experiment-framework instance. The unit tests mock +// the DAO, so only this test proves the real behavior: publish -> edit -> re-publish returns +// the SAME URL with updated content. +// +// It is skipped unless HYPERFRAMES_E2E_API_URL is set, and it requires the runner to be +// authenticated first (`hyperframes auth login`, or a HEYGEN credential the auth store can +// resolve) — stable URLs are an owner-only capability. Run it manually or in a nightly job +// against canary/staging with a test account: +// +// HYPERFRAMES_E2E_API_URL=https://api2.heygen.com \ +// bunx vitest run src/utils/publishProject.e2e.test.ts +const E2E_API_URL = process.env["HYPERFRAMES_E2E_API_URL"]; +const describeE2E = E2E_API_URL ? describe : describe.skip; + +describeE2E("publish stable-URL round trip (live server)", () => { + const priorPublishBase = process.env["HYPERFRAMES_PUBLISHED_PROJECTS_API_URL"]; + + beforeAll(() => { + // Point the publish client at the E2E target for the duration of this suite. + process.env["HYPERFRAMES_PUBLISHED_PROJECTS_API_URL"] = E2E_API_URL; + }); + + afterAll(() => { + if (priorPublishBase === undefined) + delete process.env["HYPERFRAMES_PUBLISHED_PROJECTS_API_URL"]; + else process.env["HYPERFRAMES_PUBLISHED_PROJECTS_API_URL"] = priorPublishBase; + }); + + async function fetchPublicProject(projectId: string): Promise> { + const response = await fetch( + `${getPublishApiBaseUrl()}/v1/hyperframes/projects/${projectId}/public`, + { headers: { heygen_route: "canary" } }, + ); + expect(response.ok).toBe(true); + const payload = (await response.json()) as { data: Record }; + return payload.data; + } + + it("re-publishing the same project keeps one URL and serves the updated content", async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-e2e-")); + try { + // First authenticated publish → the server creates and owns the project. + writeFileSync(join(dir, "index.html"), "version one", "utf-8"); + const first = await publishProjectArchive(dir); + // If this fails, the runner is not authenticated — stable URLs require an owner. + expect(first.claimed).toBe(true); + + // Edit locally, then re-publish targeting the owned project id. + writeFileSync( + join(dir, "index.html"), + "version two

added

", + "utf-8", + ); + writeFileSync(join(dir, "extra.html"), "second page", "utf-8"); + const second = await publishProjectArchive(dir, { projectId: first.projectId }); + + // Same project, same URL — the whole point. + expect(second.projectId).toBe(first.projectId); + expect(second.url).toBe(first.url); + expect(second.claimed).toBe(true); + + // The live public project reflects the second publish (extra file included). + const publicData = await fetchPublicProject(second.projectId); + expect(publicData["project_id"]).toBe(first.projectId); + expect(Number(publicData["file_count"])).toBeGreaterThanOrEqual(2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, 120_000); + + it("two identities in a shared team space converge on one URL, and other spaces can't hijack it", async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-e2e-team-")); + const originalKey = process.env["HYPERFRAMES_API_KEY"]; + try { + // Member A publishes into the shared team space. + writeFileSync(join(dir, "index.html"), "team v1", "utf-8"); + process.env["HYPERFRAMES_API_KEY"] = "e2e-team-member-a"; + const a = await publishProjectArchive(dir, { spaceId: "team-e2e" }); + expect(a.claimed).toBe(true); + + // A DIFFERENT member (different credential) re-publishes the same project id in the + // same space → converges on the same URL. + writeFileSync(join(dir, "index.html"), "team v2", "utf-8"); + process.env["HYPERFRAMES_API_KEY"] = "e2e-team-member-b"; + const b = await publishProjectArchive(dir, { projectId: a.projectId, spaceId: "team-e2e" }); + expect(b.projectId).toBe(a.projectId); + expect(b.url).toBe(a.url); + + // A member of a DIFFERENT space cannot overwrite it → gets a fresh project. + process.env["HYPERFRAMES_API_KEY"] = "e2e-outsider"; + const c = await publishProjectArchive(dir, { + projectId: a.projectId, + spaceId: "other-space", + }); + expect(c.projectId).not.toBe(a.projectId); + } finally { + if (originalKey === undefined) delete process.env["HYPERFRAMES_API_KEY"]; + else process.env["HYPERFRAMES_API_KEY"] = originalKey; + rmSync(dir, { recursive: true, force: true }); + } + }, 120_000); +}); diff --git a/packages/cli/src/utils/publishProject.test.ts b/packages/cli/src/utils/publishProject.test.ts index 9cebf4e072..6eddeb3e2b 100644 --- a/packages/cli/src/utils/publishProject.test.ts +++ b/packages/cli/src/utils/publishProject.test.ts @@ -4,6 +4,22 @@ import { tmpdir } from "node:os"; import { join, relative } from "node:path"; import AdmZip from "adm-zip"; +const authMocks = vi.hoisted(() => ({ + tryResolveCredential: vi.fn(), +})); + +vi.mock("../auth/index.js", () => ({ + tryResolveCredential: authMocks.tryResolveCredential, +})); + +const linkMocks = vi.hoisted(() => ({ + writeProjectLink: vi.fn(), +})); + +vi.mock("./projectLink.js", () => ({ + writeProjectLink: linkMocks.writeProjectLink, +})); + import { createPublishArchive, getPublishApiBaseUrl, @@ -16,6 +32,160 @@ function makeProjectDir(): string { return mkdtempSync(join(tmpdir(), "hf-publish-")); } +/** Writes an external asset and returns its path relative to `fromDir`, POSIX-slashed. */ +function stageExternalAsset( + extDir: string, + fromDir: string, + filename: string, + content: string, +): string { + writeFileSync(join(extDir, filename), content, "utf-8"); + return relative(fromDir, join(extDir, filename)).replaceAll("\\", "/"); +} + +/** A single-entry files map for `localizeExternalAssets`, keyed on index.html. */ +function indexHtmlFiles(html: string): Map { + const files = new Map(); + files.set("index.html", Buffer.from(html, "utf-8")); + return files; +} + +/** + * Stages one external asset in a fresh project/ext dir pair, builds the + * files map from it via `buildFiles`, runs `localizeExternalAssets`, and + * cleans up both dirs. Covers the single-external-asset test shape shared + * by most `localizeExternalAssets` cases. + */ +function localizeSingleAsset( + assetName: string, + assetContent: string, + buildFiles: (relToExt: string, projectDir: string) => Map, +): { count: number; files: Map; relToExt: string } { + const projectDir = makeProjectDir(); + const extDir = mkdtempSync(join(tmpdir(), "hf-ext-")); + try { + const relToExt = stageExternalAsset(extDir, projectDir, assetName, assetContent); + const files = buildFiles(relToExt, projectDir); + const count = localizeExternalAssets(projectDir, files); + return { count, files, relToExt }; + } finally { + rmSync(projectDir, { recursive: true, force: true }); + rmSync(extDir, { recursive: true, force: true }); + } +} + +/** Runs `localizeExternalAssets`, asserts nothing was rewritten, and returns the (unchanged) index.html. */ +function expectNoLocalization(projectDir: string, files: Map): string { + const count = localizeExternalAssets(projectDir, files); + expect(count).toBe(0); + return files.get("index.html")!.toString("utf-8"); +} + +/** Asserts a `localizeSingleAsset` result rewrote index.html exactly once, and returns it. */ +function expectLocalizedHtml(result: { + count: number; + files: Map; + relToExt: string; +}): string { + expect(result.count).toBe(1); + const rewrittenHtml = result.files.get("index.html")!.toString("utf-8"); + expect(rewrittenHtml).toContain("_ext/"); + expect(rewrittenHtml).not.toContain(result.relToExt); + return rewrittenHtml; +} + +/** The staged `/publish/upload` success response, overridable per test. */ +function uploadResponse(overrides?: Record): Response { + return new Response( + JSON.stringify({ + data: { + upload_url: "https://s3.example.com/upload", + upload_key: "ephemeral_store/hyperframes/project_uploads/upload-1/demo.zip", + upload_headers: { "content-type": "application/zip" }, + content_type: "application/zip", + ...overrides, + }, + }), + { status: 200 }, + ); +} + +/** The `/publish/complete` (or legacy `/publish`) success response, overridable per test. */ +function publishedResponse(overrides?: Record): Response { + return new Response( + JSON.stringify({ + data: { + project_id: "hfp_123", + title: "demo", + file_count: 1, + url: "https://hyperframes.dev/p/hfp_123", + claim_token: "claim-token", + ...overrides, + }, + }), + { status: 200 }, + ); +} + +/** Full staged flow: upload, S3 PUT, complete. */ +function stagedFetch(completeData?: Record, uploadData?: Record) { + return vi + .fn() + .mockResolvedValueOnce(uploadResponse(uploadData)) + .mockResolvedValueOnce(new Response(null, { status: 200 })) + .mockResolvedValueOnce(publishedResponse(completeData)); +} + +/** Legacy multipart flow: staged upload 404s, falls back to direct publish. */ +function directFetch(completeData?: Record) { + return vi + .fn() + .mockResolvedValueOnce(new Response("not found", { status: 404 })) + .mockResolvedValueOnce(publishedResponse(completeData)); +} + +/** Asserts the Nth fetch call, always requiring an AbortSignal alongside the given init. */ +function expectFetchCall( + fetchMock: ReturnType, + n: number, + url: string, + init: Record, +): void { + expect(fetchMock).toHaveBeenNthCalledWith( + n, + url, + expect.objectContaining({ signal: expect.any(AbortSignal), ...init }), + ); +} + +/** Resolves `tryResolveCredential` to a stubbed OAuth token for authenticated-request tests. */ +function withOAuthCredential(): void { + authMocks.tryResolveCredential.mockResolvedValue({ + type: "oauth", + access_token: "test-token", + source: "file_json", + refreshable: false, + }); +} + +/** Asserts the given fetch call carried the stubbed bearer token. */ +function expectAuthorizedHeaders(fetchMock: ReturnType, callIndex: number): void { + expect(fetchMock.mock.calls[callIndex]![1].headers).toEqual( + expect.objectContaining({ authorization: "Bearer test-token" }), + ); +} + +/** Stubs an OAuth credential and fetch, then publishes a minimal project through `dir`. */ +async function runAuthenticatedPublish( + fetchMock: ReturnType, + dir: string, +): Promise { + withOAuthCredential(); + vi.stubGlobal("fetch", fetchMock); + writeFileSync(join(dir, "index.html"), "", "utf-8"); + await publishProjectArchive(dir); +} + describe("createPublishArchive", () => { it("packages the project and skips hidden files and node_modules", () => { const dir = makeProjectDir(); @@ -40,146 +210,119 @@ describe("createPublishArchive", () => { describe("localizeExternalAssets", () => { it("copies external src/href assets and rewrites HTML paths", () => { - const projectDir = makeProjectDir(); - const extDir = mkdtempSync(join(tmpdir(), "hf-ext-")); - try { - writeFileSync(join(extDir, "logo.png"), "PNG_DATA", "utf-8"); - const relToExt = relative(projectDir, join(extDir, "logo.png")).replaceAll("\\", "/"); - - const html = ``; - const files = new Map(); - files.set("index.html", Buffer.from(html, "utf-8")); - - const count = localizeExternalAssets(projectDir, files); + const result = localizeSingleAsset("logo.png", "PNG_DATA", (rel) => + indexHtmlFiles(``), + ); + expectLocalizedHtml(result); + + const extEntries = [...result.files.keys()].filter((k) => k.startsWith("_ext/")); + expect(extEntries).toHaveLength(1); + expect(result.files.get(extEntries[0]!)!.toString("utf-8")).toBe("PNG_DATA"); + }); - expect(count).toBe(1); - const rewrittenHtml = files.get("index.html")!.toString("utf-8"); - expect(rewrittenHtml).not.toContain(relToExt); - expect(rewrittenHtml).toContain("_ext/"); + it("rewrites CSS url() in `, + ), + ); + + const rewrittenHtml = expectLocalizedHtml(result); + expect(rewrittenHtml).toContain("url("); + }); +}); - const extEntries = [...files.keys()].filter((k) => k.startsWith("_ext/")); - expect(extEntries).toHaveLength(1); - expect(files.get(extEntries[0]!)!.toString("utf-8")).toBe("PNG_DATA"); - } finally { - rmSync(projectDir, { recursive: true, force: true }); - rmSync(extDir, { recursive: true, force: true }); - } +describe("localizeExternalAssets", () => { + it("rewrites url() in standalone CSS files", () => { + const { count, files, relToExt } = localizeSingleAsset("font.woff2", "FONT_DATA", (rel) => { + const files = indexHtmlFiles(""); + files.set("styles.css", Buffer.from(`@font-face { src: url("${rel}"); }`, "utf-8")); + return files; + }); + + expect(count).toBe(1); + const rewrittenCss = files.get("styles.css")!.toString("utf-8"); + expect(rewrittenCss).toContain("_ext/"); + expect(rewrittenCss).not.toContain(relToExt); }); - it("rewrites CSS url() in `; - const files = new Map(); - files.set("index.html", Buffer.from(html, "utf-8")); + mkdirSync(join(projectDir, "assets")); + writeFileSync(join(projectDir, "assets", "logo.svg"), "", "utf-8"); + const files = indexHtmlFiles(``); + files.set("assets/logo.svg", Buffer.from("", "utf-8")); - const count = localizeExternalAssets(projectDir, files); + const rewrittenHtml = expectNoLocalization(projectDir, files); - expect(count).toBe(1); - const rewrittenHtml = files.get("index.html")!.toString("utf-8"); - expect(rewrittenHtml).toContain("url("); - expect(rewrittenHtml).toContain("_ext/"); - expect(rewrittenHtml).not.toContain(relToExt); + expect(rewrittenHtml).toContain('src="assets/logo.svg"'); + expect([...files.keys()].filter((k) => k.startsWith("_ext/"))).toHaveLength(0); } finally { rmSync(projectDir, { recursive: true, force: true }); - rmSync(extDir, { recursive: true, force: true }); } }); +}); - it("rewrites url() in standalone CSS files", () => { +describe("localizeExternalAssets", () => { + it("leaves remote URLs unchanged", () => { const projectDir = makeProjectDir(); - const extDir = mkdtempSync(join(tmpdir(), "hf-ext-")); try { - writeFileSync(join(extDir, "font.woff2"), "FONT_DATA", "utf-8"); - const relToExt = relative(projectDir, join(extDir, "font.woff2")).replaceAll("\\", "/"); + const files = indexHtmlFiles( + ``, + ); - const css = `@font-face { src: url("${relToExt}"); }`; - const files = new Map(); - files.set("index.html", Buffer.from("", "utf-8")); - files.set("styles.css", Buffer.from(css, "utf-8")); + const rewrittenHtml = expectNoLocalization(projectDir, files); - const count = localizeExternalAssets(projectDir, files); - - expect(count).toBe(1); - const rewrittenCss = files.get("styles.css")!.toString("utf-8"); - expect(rewrittenCss).toContain("_ext/"); - expect(rewrittenCss).not.toContain(relToExt); + expect(rewrittenHtml).toContain("https://cdn.example.com/logo.png"); + expect(rewrittenHtml).toContain("http://cdn.example.com/vid.mp4"); } finally { rmSync(projectDir, { recursive: true, force: true }); - rmSync(extDir, { recursive: true, force: true }); } }); - it("leaves internal assets unchanged", () => { + it("no-op when no external assets exist", () => { const projectDir = makeProjectDir(); try { - mkdirSync(join(projectDir, "assets")); - writeFileSync(join(projectDir, "assets", "logo.svg"), "", "utf-8"); - const html = ``; - const files = new Map(); - files.set("index.html", Buffer.from(html, "utf-8")); - files.set("assets/logo.svg", Buffer.from("", "utf-8")); + const files = indexHtmlFiles(`

Hello

`); - const count = localizeExternalAssets(projectDir, files); + expectNoLocalization(projectDir, files); - expect(count).toBe(0); - const rewrittenHtml = files.get("index.html")!.toString("utf-8"); - expect(rewrittenHtml).toContain('src="assets/logo.svg"'); - expect([...files.keys()].filter((k) => k.startsWith("_ext/"))).toHaveLength(0); + expect(files.size).toBe(1); } finally { rmSync(projectDir, { recursive: true, force: true }); } }); - it("leaves remote URLs unchanged", () => { + it("skips references to non-existent external files", () => { const projectDir = makeProjectDir(); try { - const html = ``; - const files = new Map(); - files.set("index.html", Buffer.from(html, "utf-8")); + const files = indexHtmlFiles(``); - const count = localizeExternalAssets(projectDir, files); + const rewrittenHtml = expectNoLocalization(projectDir, files); - expect(count).toBe(0); - const rewrittenHtml = files.get("index.html")!.toString("utf-8"); - expect(rewrittenHtml).toContain("https://cdn.example.com/logo.png"); - expect(rewrittenHtml).toContain("http://cdn.example.com/vid.mp4"); + expect(rewrittenHtml).toContain("../nonexistent/file.png"); } finally { rmSync(projectDir, { recursive: true, force: true }); } }); +}); +describe("localizeExternalAssets", () => { it("deduplicates: same external asset referenced from multiple files", () => { - const projectDir = makeProjectDir(); - const extDir = mkdtempSync(join(tmpdir(), "hf-ext-")); - try { - writeFileSync(join(extDir, "shared.png"), "SHARED", "utf-8"); - const relToExt = relative(projectDir, join(extDir, "shared.png")).replaceAll("\\", "/"); - - const files = new Map(); - files.set( - "index.html", - Buffer.from(``, "utf-8"), - ); + const { count, files } = localizeSingleAsset("shared.png", "SHARED", (rel, projectDir) => { + const files = indexHtmlFiles(``); mkdirSync(join(projectDir, "compositions")); files.set( "compositions/scene.html", - Buffer.from(``, "utf-8"), + Buffer.from(``, "utf-8"), ); + return files; + }); - const count = localizeExternalAssets(projectDir, files); - - expect(count).toBe(1); - const extEntries = [...files.keys()].filter((k) => k.startsWith("_ext/")); - expect(extEntries).toHaveLength(1); - } finally { - rmSync(projectDir, { recursive: true, force: true }); - rmSync(extDir, { recursive: true, force: true }); - } + expect(count).toBe(1); + const extEntries = [...files.keys()].filter((k) => k.startsWith("_ext/")); + expect(extEntries).toHaveLength(1); }); it("handles sub-composition HTML with external refs", () => { @@ -187,14 +330,14 @@ describe("localizeExternalAssets", () => { const extDir = mkdtempSync(join(tmpdir(), "hf-ext-")); try { mkdirSync(join(projectDir, "compositions")); - writeFileSync(join(extDir, "overlay.png"), "OVERLAY", "utf-8"); - const relFromComps = relative( + const relFromComps = stageExternalAsset( + extDir, join(projectDir, "compositions"), - join(extDir, "overlay.png"), - ).replaceAll("\\", "/"); + "overlay.png", + "OVERLAY", + ); - const files = new Map(); - files.set("index.html", Buffer.from("", "utf-8")); + const files = indexHtmlFiles(""); files.set( "compositions/scene.html", Buffer.from(``, "utf-8"), @@ -211,69 +354,24 @@ describe("localizeExternalAssets", () => { rmSync(extDir, { recursive: true, force: true }); } }); +}); - it("no-op when no external assets exist", () => { - const projectDir = makeProjectDir(); - try { - const html = `

Hello

`; - const files = new Map(); - files.set("index.html", Buffer.from(html, "utf-8")); - - const count = localizeExternalAssets(projectDir, files); - - expect(count).toBe(0); - expect(files.size).toBe(1); - } finally { - rmSync(projectDir, { recursive: true, force: true }); - } - }); - - it("skips references to non-existent external files", () => { - const projectDir = makeProjectDir(); - try { - const html = ``; - const files = new Map(); - files.set("index.html", Buffer.from(html, "utf-8")); - - const count = localizeExternalAssets(projectDir, files); - - expect(count).toBe(0); - const rewrittenHtml = files.get("index.html")!.toString("utf-8"); - expect(rewrittenHtml).toContain("../nonexistent/file.png"); - } finally { - rmSync(projectDir, { recursive: true, force: true }); - } - }); - +describe("localizeExternalAssets", () => { it("rewrites inline style url() references", () => { - const projectDir = makeProjectDir(); - const extDir = mkdtempSync(join(tmpdir(), "hf-ext-")); - try { - writeFileSync(join(extDir, "bg.jpg"), "JPEG_DATA", "utf-8"); - const relToExt = relative(projectDir, join(extDir, "bg.jpg")).replaceAll("\\", "/"); - - const html = `
`; - const files = new Map(); - files.set("index.html", Buffer.from(html, "utf-8")); + const result = localizeSingleAsset("bg.jpg", "JPEG_DATA", (rel) => + indexHtmlFiles( + `
`, + ), + ); - const count = localizeExternalAssets(projectDir, files); - - expect(count).toBe(1); - const rewrittenHtml = files.get("index.html")!.toString("utf-8"); - expect(rewrittenHtml).toContain("_ext/"); - expect(rewrittenHtml).not.toContain(relToExt); - } finally { - rmSync(projectDir, { recursive: true, force: true }); - rmSync(extDir, { recursive: true, force: true }); - } + expectLocalizedHtml(result); }); it("createPublishArchive includes localized external assets", () => { const projectDir = makeProjectDir(); const extDir = mkdtempSync(join(tmpdir(), "hf-ext-")); try { - writeFileSync(join(extDir, "video.mp4"), "MP4_DATA", "utf-8"); - const relToExt = relative(projectDir, join(extDir, "video.mp4")).replaceAll("\\", "/"); + const relToExt = stageExternalAsset(extDir, projectDir, "video.mp4", "MP4_DATA"); writeFileSync( join(projectDir, "index.html"), ``, @@ -314,53 +412,37 @@ describe("uploadTimeoutMs", () => { }); }); -describe("publishProjectArchive", () => { - beforeEach(() => { - vi.stubEnv("HYPERFRAMES_PUBLISHED_PROJECTS_API_URL", ""); - vi.stubEnv("HEYGEN_API_URL", ""); - }); +// Shared across every `publishProjectArchive` group below (file-scoped, not +// nested in a describe) so the reset logic isn't duplicated per group. +beforeEach(() => { + authMocks.tryResolveCredential.mockReset().mockResolvedValue(null); + linkMocks.writeProjectLink.mockReset(); + vi.stubEnv("HYPERFRAMES_PUBLISHED_PROJECTS_API_URL", ""); + vi.stubEnv("HEYGEN_API_URL", ""); +}); - afterEach(() => { - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); - }); +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); +const jsonHeaders = { "content-type": "application/json", heygen_route: "canary" }; +const signedStagedS3Url = + "https://s3.example.com/upload?X-Amz-SignedHeaders=content-length;content-type;host;x-amz-server-side-encryption"; + +describe("publishProjectArchive", () => { it("uploads through the staged publish flow and returns the stable project URL", async () => { const dir = makeProjectDir(); - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - data: { - upload_url: - "https://s3.example.com/upload?X-Amz-SignedHeaders=content-length;content-type;host;x-amz-server-side-encryption", - upload_key: "ephemeral_store/hyperframes/project_uploads/upload-1/demo.zip", - upload_headers: { - "content-type": "application/zip", - "x-amz-server-side-encryption": "AES256", - }, - content_type: "application/zip", - }, - }), - { status: 200 }, - ), - ) - .mockResolvedValueOnce(new Response(null, { status: 200 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - data: { - project_id: "hfp_123", - title: "demo", - file_count: 2, - url: "https://hyperframes.dev/p/hfp_123", - claim_token: "claim-token", - }, - }), - { status: 200 }, - ), - ); + const fetchMock = stagedFetch( + { file_count: 2 }, + { + upload_url: signedStagedS3Url, + upload_headers: { + "content-type": "application/zip", + "x-amz-server-side-encryption": "AES256", + }, + }, + ); vi.stubGlobal("fetch", fetchMock); try { @@ -375,61 +457,75 @@ describe("publishProjectArchive", () => { url: "https://hyperframes.dev/p/hfp_123", }); expect(fetchMock).toHaveBeenCalledTimes(3); - expect(fetchMock).toHaveBeenNthCalledWith( + expectFetchCall( + fetchMock, 1, "https://api2.heygen.com/v1/hyperframes/projects/publish/upload", - expect.objectContaining({ + { method: "POST", - headers: { "content-type": "application/json", heygen_route: "canary" }, - signal: expect.any(AbortSignal), - }), + headers: jsonHeaders, + }, ); - expect(fetchMock).toHaveBeenNthCalledWith( - 2, - "https://s3.example.com/upload?X-Amz-SignedHeaders=content-length;content-type;host;x-amz-server-side-encryption", - expect.objectContaining({ - method: "PUT", - headers: { - "content-length": expect.any(String), - "content-type": "application/zip", - "x-amz-server-side-encryption": "AES256", - }, - signal: expect.any(AbortSignal), - }), - ); - expect(fetchMock).toHaveBeenNthCalledWith( + expectFetchCall(fetchMock, 2, signedStagedS3Url, { + method: "PUT", + headers: { + "content-length": expect.any(String), + "content-type": "application/zip", + "x-amz-server-side-encryption": "AES256", + }, + }); + expectFetchCall( + fetchMock, 3, "https://api2.heygen.com/v1/hyperframes/projects/publish/complete", - expect.objectContaining({ + { method: "POST", - headers: { "content-type": "application/json", heygen_route: "canary" }, - signal: expect.any(AbortSignal), - }), + headers: jsonHeaders, + }, ); } finally { rmSync(dir, { recursive: true, force: true }); } }); +}); + +describe("publishProjectArchive", () => { + it("authenticates staged metadata requests but not the presigned S3 upload", async () => { + const dir = makeProjectDir(); + const fetchMock = stagedFetch(); + + try { + await runAuthenticatedPublish(fetchMock, dir); + + expect(authMocks.tryResolveCredential).toHaveBeenCalledTimes(1); + expectAuthorizedHeaders(fetchMock, 0); + expect(fetchMock.mock.calls[1]![1].headers).not.toHaveProperty("authorization"); + expectAuthorizedHeaders(fetchMock, 2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("authenticates the direct fallback request", async () => { + const dir = makeProjectDir(); + const fetchMock = directFetch(); + + try { + await runAuthenticatedPublish(fetchMock, dir); + + expect(authMocks.tryResolveCredential).toHaveBeenCalledTimes(1); + expectAuthorizedHeaders(fetchMock, 0); + expectAuthorizedHeaders(fetchMock, 1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); +describe("publishProjectArchive", () => { it("falls back to the legacy multipart endpoint when staged publish is not deployed", async () => { const dir = makeProjectDir(); - const fetchMock = vi - .fn() - .mockResolvedValueOnce(new Response("not found", { status: 404 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - data: { - project_id: "hfp_123", - title: "demo", - file_count: 2, - url: "https://hyperframes.dev/p/hfp_123", - claim_token: "claim-token", - }, - }), - { status: 200 }, - ), - ); + const fetchMock = directFetch({ file_count: 2 }); vi.stubGlobal("fetch", fetchMock); try { @@ -439,15 +535,10 @@ describe("publishProjectArchive", () => { expect(result.projectId).toBe("hfp_123"); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchMock).toHaveBeenNthCalledWith( - 2, - "https://api2.heygen.com/v1/hyperframes/projects/publish", - expect.objectContaining({ - method: "POST", - headers: { heygen_route: "canary" }, - signal: expect.any(AbortSignal), - }), - ); + expectFetchCall(fetchMock, 2, "https://api2.heygen.com/v1/hyperframes/projects/publish", { + method: "POST", + headers: { heygen_route: "canary" }, + }); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -455,37 +546,6 @@ describe("publishProjectArchive", () => { it("sends is_public in the staged complete body only when public is requested", async () => { const dir = makeProjectDir(); - const stagedFetch = () => - vi - .fn() - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - data: { - upload_url: "https://s3.example.com/upload", - upload_key: "ephemeral_store/hyperframes/project_uploads/upload-1/demo.zip", - upload_headers: { "content-type": "application/zip" }, - content_type: "application/zip", - }, - }), - { status: 200 }, - ), - ) - .mockResolvedValueOnce(new Response(null, { status: 200 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - data: { - project_id: "hfp_123", - title: "demo", - file_count: 1, - url: "https://hyperframes.dev/p/hfp_123", - claim_token: "claim-token", - }, - }), - { status: 200 }, - ), - ); try { writeFileSync(join(dir, "index.html"), "", "utf-8"); @@ -505,27 +565,11 @@ describe("publishProjectArchive", () => { rmSync(dir, { recursive: true, force: true }); } }); +}); +describe("publishProjectArchive", () => { it("sends is_public in the direct multipart form only when public is requested", async () => { const dir = makeProjectDir(); - const directFetch = () => - vi - .fn() - .mockResolvedValueOnce(new Response("not found", { status: 404 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - data: { - project_id: "hfp_123", - title: "demo", - file_count: 1, - url: "https://hyperframes.dev/p/hfp_123", - claim_token: "claim-token", - }, - }), - { status: 200 }, - ), - ); try { writeFileSync(join(dir, "index.html"), "", "utf-8"); @@ -551,20 +595,12 @@ describe("publishProjectArchive", () => { const fetchMock = vi .fn() .mockResolvedValueOnce( - new Response( - JSON.stringify({ - data: { - upload_url: "https://s3.example.com/upload", - upload_key: "ephemeral_store/hyperframes/project_uploads/upload-1/demo.zip", - upload_headers: { - "content-type": "application/zip", - "x-amz-server-side-encryption": "AES256", - }, - content_type: "application/zip", - }, - }), - { status: 200 }, - ), + uploadResponse({ + upload_headers: { + "content-type": "application/zip", + "x-amz-server-side-encryption": "AES256", + }, + }), ) .mockResolvedValueOnce(new Response("denied", { status: 403 })); vi.stubGlobal("fetch", fetchMock); @@ -579,3 +615,90 @@ describe("publishProjectArchive", () => { } }); }); + +/** Staged flow returning an already-owned, already-claimed stable project. */ +function ownedStagedFetch() { + return stagedFetch({ + project_id: "hfp_stable", + url: "https://hyperframes.dev/p/hfp_stable", + claim_token: "", + claimed: true, + }); +} + +describe("publishProjectArchive", () => { + it("sends and persists a stable project id when authenticated", async () => { + withOAuthCredential(); + const dir = makeProjectDir(); + const fetchMock = ownedStagedFetch(); + vi.stubGlobal("fetch", fetchMock); + + try { + writeFileSync(join(dir, "index.html"), "", "utf-8"); + + const result = await publishProjectArchive(dir, { projectId: "hfp_stable" }); + + const completeBody = JSON.parse(fetchMock.mock.calls[2]![1].body); + expect(completeBody.project_id).toBe("hfp_stable"); + // An owned re-publish returns claimed with no claim token to append. + expect(result.claimed).toBe(true); + expect(result.claimToken).toBe(""); + expect(linkMocks.writeProjectLink).toHaveBeenCalledWith(dir, { + projectId: "hfp_stable", + url: "https://hyperframes.dev/p/hfp_stable", + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("never sends a project id or persists a link when anonymous", async () => { + // Default credential is null (anonymous). + const dir = makeProjectDir(); + const fetchMock = ownedStagedFetch(); + vi.stubGlobal("fetch", fetchMock); + + try { + writeFileSync(join(dir, "index.html"), "", "utf-8"); + + await publishProjectArchive(dir, { projectId: "hfp_stable" }); + + const completeBody = JSON.parse(fetchMock.mock.calls[2]![1].body); + expect(completeBody).not.toHaveProperty("project_id"); + expect(linkMocks.writeProjectLink).not.toHaveBeenCalled(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + async function publishWithTeamSpace(authed: boolean) { + // Set explicitly (not just for the authed case) so calling this twice in one test — + // authed then anonymous — doesn't leak the credential mock across calls. + if (authed) withOAuthCredential(); + else authMocks.tryResolveCredential.mockResolvedValue(null); + const dir = makeProjectDir(); + const fetchMock = ownedStagedFetch(); + vi.stubGlobal("fetch", fetchMock); + try { + writeFileSync(join(dir, "index.html"), "", "utf-8"); + await publishProjectArchive(dir, { projectId: "hfp_stable", spaceId: "space-42" }); + return fetchMock; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + it("sends X-Space-Id only when authenticated (metadata requests, never the S3 PUT)", async () => { + const authed = await publishWithTeamSpace(true); + // Metadata calls (upload=0, complete=2) carry the team space header... + expect(authed.mock.calls[0]![1].headers["x-space-id"]).toBe("space-42"); + expect(authed.mock.calls[2]![1].headers["x-space-id"]).toBe("space-42"); + // ...but the presigned S3 PUT (1) must NOT — extra headers break the signature. + expect(authed.mock.calls[1]![1].headers).not.toHaveProperty("x-space-id"); + + // Anonymous: the header is dropped entirely even if a space was requested. + const anon = await publishWithTeamSpace(false); + expect(anon.mock.calls[0]![1].headers).not.toHaveProperty("x-space-id"); + expect(anon.mock.calls[2]![1].headers).not.toHaveProperty("x-space-id"); + }); +}); diff --git a/packages/cli/src/utils/publishProject.ts b/packages/cli/src/utils/publishProject.ts index 1470478571..9d3188cfdd 100644 --- a/packages/cli/src/utils/publishProject.ts +++ b/packages/cli/src/utils/publishProject.ts @@ -3,6 +3,9 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { parseHTML } from "linkedom"; import AdmZip from "adm-zip"; import { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "@hyperframes/core"; +import { buildAuthHeaders } from "../auth/client.js"; +import { tryResolveCredential } from "../auth/index.js"; +import { writeProjectLink } from "./projectLink.js"; const IGNORED_DIRS = new Set([".git", "node_modules", "dist", ".next", "coverage"]); const IGNORED_FILES = new Set([".DS_Store", "Thumbs.db"]); @@ -24,6 +27,8 @@ export interface PublishedProjectResponse { fileCount: number; url: string; claimToken: string; + /** True when the project is owned by the authenticated publisher (created-and-owned or updated in place). */ + claimed: boolean; } interface StagedUploadResponse { @@ -56,9 +61,14 @@ function parsePublishedProjectResponse(payload: unknown): PublishedProjectRespon const projectId = stringField(data, "project_id"); const title = stringField(data, "title"); const url = stringField(data, "url"); - const claimToken = stringField(data, "claim_token"); + const claimToken = stringField(data, "claim_token") ?? ""; + const claimed = data["claimed"] === true; const fileCount = data["file_count"]; - if (!projectId || !title || !url || !claimToken || typeof fileCount !== "number") { + if (!projectId || !title || !url || typeof fileCount !== "number") { + return null; + } + // Anonymous publishes must return a claim token; owned (claimed) ones need none. + if (!claimed && !claimToken) { return null; } return { @@ -67,6 +77,7 @@ function parsePublishedProjectResponse(payload: unknown): PublishedProjectRespon fileCount, url, claimToken, + claimed, }; } @@ -384,15 +395,19 @@ async function publishProjectArchiveDirect( title: string, archive: PublishArchiveResult, isPublic: boolean, + authHeaders: Record, + projectId: string | undefined, ): Promise { const body = new FormData(); body.set("title", title); if (isPublic) body.set("is_public", "true"); + if (projectId) body.set("project_id", projectId); body.set( "file", new File([archiveArrayBuffer(archive)], `${title}.zip`, { type: PUBLISH_CONTENT_TYPE }), ); const headers: Record = { + ...authHeaders, heygen_route: "canary", }; @@ -412,11 +427,31 @@ async function publishProjectArchiveDirect( return publishedProject; } +async function uploadArchiveToPresignedUrl( + stagedUpload: StagedUploadResponse, + archive: PublishArchiveResult, +): Promise { + const presignedUrlTtlMs = stagedUpload.expiresInSeconds * 1000 - PUBLISH_METADATA_TIMEOUT_MS; + const s3Response = await fetch(stagedUpload.uploadUrl, { + method: "PUT", + body: new Blob([archiveArrayBuffer(archive)], { type: stagedUpload.contentType }), + headers: stagedUpload.uploadHeaders, + signal: AbortSignal.timeout( + Math.min(uploadTimeoutMs(archive.buffer.byteLength), presignedUrlTtlMs), + ), + }); + if (!s3Response.ok) { + throw new Error(await readErrorMessage(s3Response, "Failed to upload project archive")); + } +} + async function publishProjectArchiveStaged( apiBaseUrl: string, title: string, archive: PublishArchiveResult, isPublic: boolean, + authHeaders: Record, + projectId: string | undefined, ): Promise { const fileName = `${title}.zip`; const uploadResponse = await fetch(`${apiBaseUrl}/v1/hyperframes/projects/publish/upload`, { @@ -427,6 +462,7 @@ async function publishProjectArchiveStaged( content_length: archive.buffer.byteLength, }), headers: { + ...authHeaders, "content-type": "application/json", heygen_route: "canary", }, @@ -443,18 +479,7 @@ async function publishProjectArchiveStaged( throw new Error(await readErrorMessage(uploadResponse, "Failed to prepare project upload")); } - const presignedUrlTtlMs = stagedUpload.expiresInSeconds * 1000 - PUBLISH_METADATA_TIMEOUT_MS; - const s3Response = await fetch(stagedUpload.uploadUrl, { - method: "PUT", - body: new Blob([archiveArrayBuffer(archive)], { type: stagedUpload.contentType }), - headers: stagedUpload.uploadHeaders, - signal: AbortSignal.timeout( - Math.min(uploadTimeoutMs(archive.buffer.byteLength), presignedUrlTtlMs), - ), - }); - if (!s3Response.ok) { - throw new Error(await readErrorMessage(s3Response, "Failed to upload project archive")); - } + await uploadArchiveToPresignedUrl(stagedUpload, archive); const completeResponse = await fetch(`${apiBaseUrl}/v1/hyperframes/projects/publish/complete`, { method: "POST", @@ -463,8 +488,10 @@ async function publishProjectArchiveStaged( file_name: fileName, title, ...(isPublic ? { is_public: true } : {}), + ...(projectId ? { project_id: projectId } : {}), }), headers: { + ...authHeaders, "content-type": "application/json", heygen_route: "canary", }, @@ -482,6 +509,10 @@ async function publishProjectArchiveStaged( export interface PublishOptions { public?: boolean; + /** Stable project id to update in place. Only sent when authenticated. */ + projectId?: string; + /** Shared team space id, sent as X-Space-Id so team members converge. Only when authenticated. */ + spaceId?: string; } export async function publishProjectArchive( @@ -492,7 +523,35 @@ export async function publishProjectArchive( const title = basename(projectDir); const archive = createPublishArchive(projectDir); const apiBaseUrl = getPublishApiBaseUrl(); - const stagedResult = await publishProjectArchiveStaged(apiBaseUrl, title, archive, isPublic); - if (stagedResult) return stagedResult; - return publishProjectArchiveDirect(apiBaseUrl, title, archive, isPublic); + const credential = await tryResolveCredential(); + const authHeaders = credential ? buildAuthHeaders(credential) : {}; + // A stable id / team space only mean something to an authenticated owner — the server + // ignores them otherwise, and anonymous publishes always mint a fresh project. + const projectId = credential ? opts.projectId : undefined; + const spaceId = credential ? opts.spaceId : undefined; + // X-Space-Id rides with the auth headers on the metadata requests only (never the + // presigned S3 PUT), so the server resolves the shared team space instead of the personal one. + const metadataHeaders = spaceId ? { ...authHeaders, "x-space-id": spaceId } : authHeaders; + const result = + (await publishProjectArchiveStaged( + apiBaseUrl, + title, + archive, + isPublic, + metadataHeaders, + projectId, + )) ?? + (await publishProjectArchiveDirect( + apiBaseUrl, + title, + archive, + isPublic, + metadataHeaders, + projectId, + )); + // Remember the server's id + url so the next publish of this directory updates in place. + if (credential) { + writeProjectLink(projectDir, { projectId: result.projectId, url: result.url }); + } + return result; }