feat(cli): stable public URL for hyperframes publish (re-publish updates the same link) (#2363)

* feat(cli): stable-URL re-publish (send owned id, honest UX, --update, team id file)

Resolve a stable project id (committed team id > machine store > mint), send it
when authenticated so an owned re-publish updates the same URL in place, and
persist the server id+url. Report updated-vs-created honestly, add --update to
target a project explicitly, write a committable .hyperframes/project.json so a
team shares one link, and show the prior URL on re-publish.

* test(cli): env-gated E2E round-trip for stable-URL re-publish

Publish -> edit -> re-publish asserts one URL with updated content against a
live EF (HYPERFRAMES_E2E_API_URL + an authenticated runner); skipped otherwise.

* fix(cli): real team space, auth-gate --update/--space, safe team-file write

- --space + committed .hyperframes/project.json (projectId+spaceId) send X-Space-Id so a
  team converges on one link; personal space stays the default for solo users
- --update/--space error when unauthenticated, and warn loudly when a resolved-but-invalid
  token silently downgrades to a new anonymous URL (no more generic-tip-only)
- team-file write in its own try/catch so a read-only dir can't fake 'Publish failed'
- parseUpdateTarget handles scheme-less URLs + query/hash; X-Space-Id on metadata only (not S3 PUT)
- share readJsonRecord across the local + team descriptors

* test(cli): e2e team-space convergence + cross-space hijack guard

* test(cli): unit-test parseUpdateTarget url shapes (export for test)

* fix(cli): warn on committed-team miss too, not just --update
This commit is contained in:
Miguel Ángel
2026-07-13 22:47:28 -04:00
committed by GitHub
parent ca7c017e49
commit 4495cb7355
7 changed files with 1011 additions and 338 deletions
+433 -310
View File
@@ -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<string, Buffer> {
const files = new Map<string, Buffer>();
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<string, Buffer>,
): { count: number; files: Map<string, Buffer>; 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, Buffer>): 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<string, Buffer>;
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<string, unknown>): 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<string, unknown>): 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<string, unknown>, uploadData?: Record<string, unknown>) {
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<string, unknown>) {
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<typeof vi.fn>,
n: number,
url: string,
init: Record<string, unknown>,
): 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<typeof vi.fn>, 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<typeof vi.fn>,
dir: string,
): Promise<void> {
withOAuthCredential();
vi.stubGlobal("fetch", fetchMock);
writeFileSync(join(dir, "index.html"), "<html></html>", "utf-8");
await publishProjectArchive(dir);
}
describe("createPublishArchive", () => {
it("packages the project and skips hidden files and node_modules", () => {
const dir = makeProjectDir();
@@ -40,78 +210,40 @@ 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 result = localizeSingleAsset("logo.png", "PNG_DATA", (rel) =>
indexHtmlFiles(`<html><body><img src="${rel}"></body></html>`),
);
expectLocalizedHtml(result);
const html = `<html><body><img src="${relToExt}"></body></html>`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from(html, "utf-8"));
const count = localizeExternalAssets(projectDir, files);
expect(count).toBe(1);
const rewrittenHtml = files.get("index.html")!.toString("utf-8");
expect(rewrittenHtml).not.toContain(relToExt);
expect(rewrittenHtml).toContain("_ext/");
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 });
}
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");
});
it("rewrites CSS url() in <style> blocks", () => {
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 result = localizeSingleAsset("bg.jpg", "JPEG_DATA", (rel) =>
indexHtmlFiles(
`<html><head><style>body { background: url("${rel}"); }</style></head></html>`,
),
);
const html = `<html><head><style>body { background: url("${relToExt}"); }</style></head></html>`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from(html, "utf-8"));
const count = localizeExternalAssets(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);
} finally {
rmSync(projectDir, { recursive: true, force: true });
rmSync(extDir, { recursive: true, force: true });
}
const rewrittenHtml = expectLocalizedHtml(result);
expect(rewrittenHtml).toContain("url(");
});
});
describe("localizeExternalAssets", () => {
it("rewrites url() in standalone CSS files", () => {
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 { count, files, relToExt } = localizeSingleAsset("font.woff2", "FONT_DATA", (rel) => {
const files = indexHtmlFiles("<html></html>");
files.set("styles.css", Buffer.from(`@font-face { src: url("${rel}"); }`, "utf-8"));
return files;
});
const css = `@font-face { src: url("${relToExt}"); }`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from("<html></html>", "utf-8"));
files.set("styles.css", Buffer.from(css, "utf-8"));
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);
} finally {
rmSync(projectDir, { recursive: true, force: true });
rmSync(extDir, { recursive: true, force: true });
}
expect(count).toBe(1);
const rewrittenCss = files.get("styles.css")!.toString("utf-8");
expect(rewrittenCss).toContain("_ext/");
expect(rewrittenCss).not.toContain(relToExt);
});
it("leaves internal assets unchanged", () => {
@@ -119,33 +251,29 @@ describe("localizeExternalAssets", () => {
try {
mkdirSync(join(projectDir, "assets"));
writeFileSync(join(projectDir, "assets", "logo.svg"), "<svg/>", "utf-8");
const html = `<html><body><img src="assets/logo.svg"></body></html>`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from(html, "utf-8"));
const files = indexHtmlFiles(`<html><body><img src="assets/logo.svg"></body></html>`);
files.set("assets/logo.svg", Buffer.from("<svg/>", "utf-8"));
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('src="assets/logo.svg"');
expect([...files.keys()].filter((k) => k.startsWith("_ext/"))).toHaveLength(0);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
});
describe("localizeExternalAssets", () => {
it("leaves remote URLs unchanged", () => {
const projectDir = makeProjectDir();
try {
const html = `<html><body><img src="https://cdn.example.com/logo.png"><video src="http://cdn.example.com/vid.mp4"></video></body></html>`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from(html, "utf-8"));
const files = indexHtmlFiles(
`<html><body><img src="https://cdn.example.com/logo.png"><video src="http://cdn.example.com/vid.mp4"></video></body></html>`,
);
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");
} finally {
@@ -153,33 +281,48 @@ describe("localizeExternalAssets", () => {
}
});
it("deduplicates: same external asset referenced from multiple files", () => {
it("no-op when no external assets exist", () => {
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 = indexHtmlFiles(`<html><body><p>Hello</p></body></html>`);
const files = new Map<string, Buffer>();
files.set(
"index.html",
Buffer.from(`<html><body><img src="${relToExt}"></body></html>`, "utf-8"),
);
expectNoLocalization(projectDir, files);
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 files = indexHtmlFiles(`<html><body><img src="../nonexistent/file.png"></body></html>`);
const rewrittenHtml = expectNoLocalization(projectDir, files);
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 { count, files } = localizeSingleAsset("shared.png", "SHARED", (rel, projectDir) => {
const files = indexHtmlFiles(`<html><body><img src="${rel}"></body></html>`);
mkdirSync(join(projectDir, "compositions"));
files.set(
"compositions/scene.html",
Buffer.from(`<html><body><img src="../${relToExt}"></body></html>`, "utf-8"),
Buffer.from(`<html><body><img src="../${rel}"></body></html>`, "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<string, Buffer>();
files.set("index.html", Buffer.from("<html></html>", "utf-8"));
const files = indexHtmlFiles("<html></html>");
files.set(
"compositions/scene.html",
Buffer.from(`<html><body><img src="${relFromComps}"></body></html>`, "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 = `<html><body><p>Hello</p></body></html>`;
const files = new Map<string, Buffer>();
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 = `<html><body><img src="../nonexistent/file.png"></body></html>`;
const files = new Map<string, Buffer>();
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 result = localizeSingleAsset("bg.jpg", "JPEG_DATA", (rel) =>
indexHtmlFiles(
`<html><body><div style="background-image: url('${rel}')"></div></body></html>`,
),
);
const html = `<html><body><div style="background-image: url('${relToExt}')"></div></body></html>`;
const files = new Map<string, Buffer>();
files.set("index.html", Buffer.from(html, "utf-8"));
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"),
`<html><body><video src="${relToExt}"></video></body></html>`,
@@ -314,53 +412,37 @@ describe("uploadTimeoutMs", () => {
});
});
// 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();
});
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", () => {
beforeEach(() => {
vi.stubEnv("HYPERFRAMES_PUBLISHED_PROJECTS_API_URL", "");
vi.stubEnv("HEYGEN_API_URL", "");
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
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"), "<html></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"), "<html></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"), "<html></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"), "<html></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"), "<html></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");
});
});