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
+76 -17
View File
@@ -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<string, string>,
projectId: string | undefined,
): Promise<PublishedProjectResponse> {
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<string, string> = {
...authHeaders,
heygen_route: "canary",
};
@@ -412,11 +427,31 @@ async function publishProjectArchiveDirect(
return publishedProject;
}
async function uploadArchiveToPresignedUrl(
stagedUpload: StagedUploadResponse,
archive: PublishArchiveResult,
): Promise<void> {
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<string, string>,
projectId: string | undefined,
): Promise<PublishedProjectResponse | null> {
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;
}