@hyperframes/share 0.0.0-stage → 0.9.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +51 -2
  3. package/dist/appOwnedPaths.d.mts +6 -0
  4. package/dist/fromReview.d.mts +15 -0
  5. package/dist/grantRefusal.d.mts +8 -0
  6. package/dist/host.d.mts +29 -0
  7. package/dist/hosts.d.ts +39 -0
  8. package/dist/index.d.mts +21 -0
  9. package/dist/projectLock.d.mts +17 -0
  10. package/dist/projectSnapshot.d.mts +20 -0
  11. package/dist/reviewAudience.d.mts +3 -0
  12. package/dist/reviewClient.d.mts +122 -0
  13. package/dist/reviewExport.d.mts +172 -0
  14. package/dist/reviewFiles.d.mts +43 -0
  15. package/dist/reviewLinkStates.d.mts +18 -0
  16. package/dist/reviewOpenCalls.d.mts +16 -0
  17. package/dist/reviewShare.d.mts +106 -0
  18. package/dist/reviewSwitch.d.mts +53 -0
  19. package/dist/reviewUpload.d.mts +16 -0
  20. package/dist/reviewVersionSentences.d.mts +2 -0
  21. package/dist/signedInRequest.d.mts +56 -0
  22. package/dist/storage.d.mts +20 -0
  23. package/dist/types/access.d.ts +12 -0
  24. package/dist/types/review.d.ts +59 -0
  25. package/dist/types/reviewWire.d.ts +200 -0
  26. package/dist/types/share.d.ts +119 -0
  27. package/dist/types/shareOpen.d.ts +96 -0
  28. package/package.json +112 -3
  29. package/scripts/copy-types.mjs +3 -0
  30. package/src/appOwnedPaths.mjs +27 -0
  31. package/src/fromReview.mjs +27 -0
  32. package/src/grantRefusal.mjs +28 -0
  33. package/src/host.mjs +46 -0
  34. package/src/hosts.d.ts +39 -0
  35. package/src/index.mjs +82 -0
  36. package/src/projectLock.mjs +120 -0
  37. package/src/projectSnapshot.mjs +120 -0
  38. package/src/reviewAudience.mjs +24 -0
  39. package/src/reviewClient.mjs +260 -0
  40. package/src/reviewExport.mjs +827 -0
  41. package/src/reviewFiles.mjs +98 -0
  42. package/src/reviewLinkStates.mjs +47 -0
  43. package/src/reviewOpenCalls.mjs +40 -0
  44. package/src/reviewShare.mjs +645 -0
  45. package/src/reviewSwitch.mjs +430 -0
  46. package/src/reviewUpload.mjs +98 -0
  47. package/src/reviewVersionSentences.mjs +48 -0
  48. package/src/signedInRequest.mjs +125 -0
  49. package/src/storage.mjs +116 -0
  50. package/src/types/access.d.ts +12 -0
  51. package/src/types/review.d.ts +59 -0
  52. package/src/types/reviewWire.d.ts +200 -0
  53. package/src/types/share.d.ts +119 -0
  54. package/src/types/shareOpen.d.ts +96 -0
package/src/hosts.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { SignedInDeps, signedInRequest } from "./signedInRequest.mjs";
2
+
3
+ /** What sharing a project and its review link take from the host's main process (host.mjs bindShareHost). The
4
+ * network and the person's session stay the host's: every review call goes through its signed-in request and every
5
+ * archive through its uploader. */
6
+ export interface ShareHost {
7
+ execFile: typeof import("node:child_process").execFile;
8
+ /** The key every open-project lookup uses: the real path. */
9
+ normalize(path: string): string;
10
+ /** The open project's normalized path; refuses a project that is not open. */
11
+ assertOpenProject(path: string): string;
12
+ /** The open project at `path`, or null when it is not open. */
13
+ resolveOpenProjectByPath(path: string): { id: string; dir: string } | null;
14
+ /** All open projects, for invalidating removed review identities. */
15
+ listOpenProjects(): Array<{ dir: string }>;
16
+ /** The name the host shows for a project. */
17
+ homeName(path: string, folder?: string): string;
18
+ /** PUTs an archive through the host's network, hearing each chunk sent; answers the HTTP status. */
19
+ uploadWithProgress(
20
+ url: URL,
21
+ headers: Record<string, string>,
22
+ archive: Uint8Array,
23
+ onSent: (sent: number) => void,
24
+ ): Promise<number>;
25
+ /** Requests to HeyGen's sign-in host as the signed-in person, traced as the host traces its API calls. */
26
+ signedInRequest(deps: Omit<SignedInDeps, "trace">): ReturnType<typeof signedInRequest>;
27
+ /** The newest finished export recorded in the project's renders folder whose file is still there. */
28
+ latestExportFile(dir: string): { path: string; fingerprint?: string; id: string } | null;
29
+ /** Trusted video upload origins configured by the host independently of the review API response. */
30
+ reviewUploadOrigins?: readonly string[];
31
+ /** The project as change detection sees it, as one hash. */
32
+ projectFingerprint(dir: string): Promise<string>;
33
+ /** The path of the host's FFmpeg tool `name`. */
34
+ ffBinary(name: "ffprobe"): string;
35
+ /** The sign-in site of the host's own channel, whose review record keeps the original name. */
36
+ channelSite: string;
37
+ /** Every sign-in site the host can sign in on. */
38
+ signinSites: string[];
39
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,82 @@
1
+ export {
2
+ setReviewSite,
3
+ takenDown,
4
+ listSpaces,
5
+ reviewSpaces,
6
+ shown,
7
+ ownerOf,
8
+ projectLine,
9
+ oneAtATime,
10
+ changeShare,
11
+ reviewProjectId,
12
+ localReviewProjects,
13
+ reviewRecord,
14
+ reviewRecordPath,
15
+ reviewSwitchPath,
16
+ onRebound,
17
+ rebindRecord,
18
+ setAsideReview,
19
+ shareAnew,
20
+ readShare,
21
+ reviewStatus,
22
+ shareForReview,
23
+ setReviewAccess,
24
+ turnOffReviewLink,
25
+ reviewSentence,
26
+ markReviewRemoved,
27
+ removeLocalReview,
28
+ removeReviewProject,
29
+ reviewDestination,
30
+ } from "./reviewShare.mjs";
31
+ export {
32
+ factsOf,
33
+ reviewVersion,
34
+ addVersion,
35
+ recheckHeld,
36
+ refreshReviewExport,
37
+ addFirstVersion,
38
+ versionFacts,
39
+ versionOnPage,
40
+ markFiles,
41
+ } from "./reviewExport.mjs";
42
+ export { ReviewFailed, SHARING_REFUSALS, reviewClient } from "./reviewClient.mjs";
43
+ export { filesStateOf, reviewFiles } from "./reviewFiles.mjs";
44
+ export {
45
+ switchOperationId,
46
+ retryOldLink,
47
+ oldLinkOf,
48
+ changeSpace,
49
+ resumeSpaceChange,
50
+ switchSentence,
51
+ } from "./reviewSwitch.mjs";
52
+ export { fileSha256Base64, putFile } from "./reviewUpload.mjs";
53
+ export { firstReviewAudience } from "./reviewAudience.mjs";
54
+ export { versionSentence } from "./reviewVersionSentences.mjs";
55
+ export { linkStateOf, reviewLinkStates } from "./reviewLinkStates.mjs";
56
+ export { shareOpenCalls } from "./reviewOpenCalls.mjs";
57
+ export {
58
+ AccountUnreachable,
59
+ UNREACHABLE,
60
+ readOrUnreachable,
61
+ orIfUnreachable,
62
+ signedInRequest,
63
+ } from "./signedInRequest.mjs";
64
+ export {
65
+ HF_ID_ATTRIBUTE,
66
+ contentKey,
67
+ snapshotProject,
68
+ snapshotFingerprint,
69
+ snapshotFiles,
70
+ diffChangedFiles,
71
+ projectFingerprint,
72
+ } from "./projectSnapshot.mjs";
73
+ export { ShareBusyError, withProjectShareLock } from "./projectLock.mjs";
74
+ export { bindShareHost } from "./host.mjs";
75
+ /** @typedef {import("./hosts.js").ShareHost} ShareHost */
76
+ /** @typedef {import("./reviewClient.mjs").ReviewClient} ReviewClient */
77
+ /** @typedef {import("./types/share.js").ReviewShare} ReviewShare */
78
+ /** @typedef {import("./reviewExport.mjs").VersionRequest} VersionRequest */
79
+ /** @typedef {import("./types/reviewWire.js")} Wire */
80
+ /** @typedef {import("./types/share.js").AccessMode} AccessMode */
81
+ /** @typedef {import("./types/share.js").ReviewExportProgress} ReviewExportProgress */
82
+ export { pendingReviewExport } from "./reviewExport.mjs";
@@ -0,0 +1,120 @@
1
+ // @ts-check
2
+ import fs, { open, readFile, realpath, unlink, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { hostname } from "node:os";
5
+ import { randomUUID } from "node:crypto";
6
+ import { errorCode, mkdirInside } from "./storage.mjs";
7
+
8
+ export class ShareBusyError extends Error {
9
+ /** @param {string} path */
10
+ constructor(path) {
11
+ super("Another process is sharing this project. Wait for it to finish, then retry.");
12
+ this.name = "ShareBusyError";
13
+ this.reason = "review_operation_busy";
14
+ this.lockPath = path;
15
+ }
16
+ }
17
+ /** @typedef {{ token: string, pid: number, hostname: string }} Owner */
18
+ /** @param {string} path @returns {Promise<Owner | null>} */
19
+ async function ownerAt(path) {
20
+ try {
21
+ const value = JSON.parse(await readFile(path, "utf8"));
22
+ return typeof value.token === "string" &&
23
+ /^[a-f0-9-]{36}$/.test(value.token) &&
24
+ Number.isInteger(value.pid) &&
25
+ value.pid > 0 &&
26
+ typeof value.hostname === "string"
27
+ ? value
28
+ : null;
29
+ } catch (error) {
30
+ if (errorCode(error) === "ENOENT" || error instanceof SyntaxError) return null;
31
+ throw error;
32
+ }
33
+ }
34
+ /** @param {Owner} owner */
35
+ function isDead(owner) {
36
+ if (owner.hostname !== hostname()) return false;
37
+ try {
38
+ process.kill(owner.pid, 0);
39
+ return false;
40
+ } catch (error) {
41
+ return errorCode(error) === "ESRCH";
42
+ }
43
+ }
44
+ /** A claim scoped to the old owner prevents two stale readers deleting a replacement lock. @param {string} path */
45
+ async function reap(path) {
46
+ const old = await ownerAt(path);
47
+ if (!old || !isDead(old)) return;
48
+ const claim = `${path}.reap-${old.token}`;
49
+ const guard = await open(claim, "wx", 0o600).catch((error) => {
50
+ if (errorCode(error) === "EEXIST") return null;
51
+ throw error;
52
+ });
53
+ if (!guard) return;
54
+ try {
55
+ if ((await ownerAt(path))?.token === old.token) await unlink(path);
56
+ } finally {
57
+ await guard.close();
58
+ await unlink(claim);
59
+ }
60
+ }
61
+ /** @param {string} candidate @param {string} lock @param {Owner} owner */
62
+ async function claim(candidate, lock, owner) {
63
+ try {
64
+ await fs.link(candidate, lock);
65
+ } catch (error) {
66
+ if (
67
+ !["EPERM", "ENOTSUP", "EOPNOTSUPP", "EXDEV", "EINVAL", "EISDIR"].includes(
68
+ String(errorCode(error)),
69
+ )
70
+ )
71
+ throw error;
72
+ const handle = await open(lock, "wx", 0o600);
73
+ let written = false;
74
+ try {
75
+ await handle.writeFile(JSON.stringify(owner));
76
+ written = true;
77
+ } finally {
78
+ await handle.close();
79
+ if (!written) await unlink(lock);
80
+ }
81
+ }
82
+ }
83
+ /** @template T @param {string} projectPath @param {"review" | "export"} lane @param {() => Promise<T>} run
84
+ * @param {{ timeoutMs?: number, signal?: AbortSignal }} [options] @returns {Promise<T>} */
85
+ export async function withProjectShareLock(
86
+ projectPath,
87
+ lane,
88
+ run,
89
+ { timeoutMs = 30_000, signal } = {},
90
+ ) {
91
+ const root = await realpath(projectPath);
92
+ const folder = await mkdirInside(root, ".hyperframes");
93
+ const lock = join(folder, lane === "review" ? "review.lock" : "review-export.lock");
94
+ const owner = { token: randomUUID(), pid: process.pid, hostname: hostname() };
95
+ const candidate = `${lock}.${owner.token}.tmp`;
96
+ await writeFile(candidate, JSON.stringify(owner), { flag: "wx", mode: 0o600 });
97
+ const deadline = Date.now() + timeoutMs;
98
+ let held = false;
99
+ try {
100
+ while (!held) {
101
+ signal?.throwIfAborted();
102
+ held = await claim(candidate, lock, owner).then(
103
+ () => true,
104
+ (error) => {
105
+ if (errorCode(error) === "EEXIST") return false;
106
+ throw error;
107
+ },
108
+ );
109
+ if (held) break;
110
+ await reap(lock);
111
+ if (Date.now() >= deadline) throw new ShareBusyError(lock);
112
+ await new Promise((resolve) => setTimeout(resolve, 25));
113
+ }
114
+ signal?.throwIfAborted();
115
+ return await run();
116
+ } finally {
117
+ if (held && (await ownerAt(lock))?.token === owner.token) await unlink(lock);
118
+ await unlink(candidate);
119
+ }
120
+ }
@@ -0,0 +1,120 @@
1
+ // @ts-check
2
+ // A project's files as change detection sees them: every file the film can use (isOutsideFilm), keyed by mtime and
3
+ // size, except html, keyed by content without the data-hf-id stamps. A run's change set and the watcher both read it.
4
+ import fs from "node:fs/promises";
5
+ import { constants } from "node:fs";
6
+ import path from "node:path";
7
+ import { createHash } from "node:crypto";
8
+ import { isOutsideFilm } from "./appOwnedPaths.mjs";
9
+
10
+ export const HF_ID_ATTRIBUTE = /\sdata-hf-id\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/g;
11
+
12
+ const sha256 = (/** @type {import("node:crypto").BinaryLike} */ data) =>
13
+ createHash("sha256").update(data).digest("hex");
14
+
15
+ // A write that only adds data-hf-id stamps must not count as a change, or reload -> stamp -> reload never ends (KTD4).
16
+ /** @param {string} html */
17
+ export function contentKey(html) {
18
+ return sha256(html.replace(HF_ID_ATTRIBUTE, ""));
19
+ }
20
+
21
+ /** A file renamed away between readdir and stat (atomic writes), or one the app may not read, is simply not there
22
+ * (undefined): one unreadable entry must not blind the whole scan. @param {string} full @param {boolean} byContent */
23
+ async function snapshotKey(full, byContent) {
24
+ const resolved = await fs.realpath(full).catch(() => null);
25
+ if (!resolved) return undefined;
26
+ const handle = await fs
27
+ .open(resolved, constants.O_RDONLY | constants.O_NOFOLLOW)
28
+ .catch(() => null);
29
+ if (!handle) return undefined;
30
+ try {
31
+ const stat = await handle.stat();
32
+ if (!stat.isFile()) return undefined;
33
+ if (full.endsWith(".html")) return contentKey(await handle.readFile("utf8"));
34
+ if (byContent) return sha256(await handle.readFile());
35
+ return `${stat.mtimeMs}:${stat.size}`;
36
+ } catch {
37
+ return undefined;
38
+ } finally {
39
+ await handle.close();
40
+ }
41
+ }
42
+
43
+ /** @param {import("node:fs").Dirent} entry @param {string} full @param {boolean} followSymlinks */
44
+ async function entryKind(entry, full, followSymlinks) {
45
+ if (!entry.isSymbolicLink()) return entry;
46
+ return followSymlinks ? fs.stat(full).catch(() => null) : null;
47
+ }
48
+
49
+ // What a run is judged against: each file's mtime and size, except html, whose key ignores the data-hf-id stamps an
50
+ // open or a save writes. Files the film cannot use are not part of the snapshot.
51
+ /** @typedef {Map<string, unknown>} Snapshot */
52
+ /** @param {string} dir @param {{ byContent?: boolean, followSymlinks?: boolean }} [options] @returns {Promise<Snapshot>} */
53
+ export async function snapshotProject(dir, { byContent = false, followSymlinks = false } = {}) {
54
+ /** @type {Snapshot} */
55
+ const result = new Map();
56
+ /** @param {string} current @param {Set<string>} ancestors */
57
+ async function walk(current, ancestors) {
58
+ const real = await fs.realpath(current).catch(() => null);
59
+ if (!real || ancestors.has(real)) return;
60
+ const parents = new Set([...ancestors, real]);
61
+ /** @type {import("node:fs").Dirent[]} */
62
+ let entries;
63
+ try {
64
+ entries = await fs.readdir(current, { withFileTypes: true });
65
+ } catch {
66
+ return;
67
+ }
68
+ for (const entry of entries) {
69
+ const full = path.join(current, entry.name);
70
+ if (isOutsideFilm(path.relative(dir, full))) continue;
71
+ const kind = await entryKind(entry, full, followSymlinks);
72
+ if (kind?.isDirectory()) await walk(full, parents);
73
+ else if (kind?.isFile()) {
74
+ const key = await snapshotKey(full, byContent);
75
+ if (key !== undefined) result.set(path.relative(dir, full), key);
76
+ }
77
+ }
78
+ }
79
+ await walk(dir, new Set());
80
+ return result;
81
+ }
82
+
83
+ /** What an export's sidecar records of the project, from a snapshot already taken.
84
+ * @param {Snapshot} snapshot */
85
+ export function snapshotFingerprint(snapshot) {
86
+ const files = [...snapshot].sort(([a], [b]) => (a < b ? -1 : 1));
87
+ return createHash("sha256").update(JSON.stringify(files)).digest("hex");
88
+ }
89
+
90
+ /** Just these project files, keyed as snapshotProject keys them (undefined: not a file the snapshot holds); null when
91
+ * one is a folder, which only a whole snapshot covers. @param {string} dir @param {Iterable<string>} files
92
+ * @returns {Promise<Map<string, unknown> | null>} */
93
+ export async function snapshotFiles(dir, files) {
94
+ /** @type {Map<string, unknown>} */
95
+ const result = new Map();
96
+ for (const file of files) {
97
+ const full = path.join(dir, file);
98
+ const stat = await fs.lstat(full).catch(() => null);
99
+ if (stat?.isDirectory()) return null;
100
+ const holds = stat?.isFile() && !isOutsideFilm(path.relative(dir, full));
101
+ result.set(file, holds ? await snapshotKey(full, false) : undefined);
102
+ }
103
+ return result;
104
+ }
105
+
106
+ /** @param {Snapshot} before @param {Snapshot} after */
107
+ export function diffChangedFiles(before, after) {
108
+ /** @type {string[]} */
109
+ const changed = [];
110
+ for (const [file, key] of after) {
111
+ if (before.get(file) !== key) changed.push(file);
112
+ }
113
+ // A file gone after the run was changed by it too, as the project watcher counts it.
114
+ for (const file of before.keys()) if (!after.has(file)) changed.push(file);
115
+ return changed;
116
+ }
117
+
118
+ /** @param {string} projectPath */
119
+ export const projectFingerprint = async (projectPath) =>
120
+ snapshotFingerprint(await snapshotProject(projectPath, { followSymlinks: true }));
@@ -0,0 +1,24 @@
1
+ // @ts-check
2
+ import { ReviewFailed } from "./reviewClient.mjs";
3
+
4
+ /** @param {import("./types/reviewWire.js").ReviewEntitlements | null | undefined} entitlements
5
+ * @param {import("./types/share.js").AccessMode | undefined} chosen */
6
+ export function firstReviewAudience(entitlements, chosen) {
7
+ // An omitted field is the legacy server contract; explicit null is an unavailable read.
8
+ if (entitlements === undefined) return chosen ?? "team";
9
+ if (!entitlements) throw new ReviewFailed(503, "review_entitlement_unavailable");
10
+ const audience =
11
+ chosen ?? (entitlements.allowed_collaborative_access_modes.includes("team") ? "team" : null);
12
+ if (!audience)
13
+ throw new ReviewFailed(402, "review_public_consent_required", undefined, undefined, {
14
+ reason: "review_public_consent_required",
15
+ workspace_id: entitlements.workspace_id,
16
+ });
17
+ if (audience !== "private" && !entitlements.allowed_collaborative_access_modes.includes(audience))
18
+ throw new ReviewFailed(402, "review_subscription_required", undefined, undefined, {
19
+ reason: "review_subscription_required",
20
+ workspace_id: entitlements.workspace_id,
21
+ requested_feature: audience,
22
+ });
23
+ return audience;
24
+ }
@@ -0,0 +1,260 @@
1
+ // @ts-check
2
+ // Review requests run on the host so the browser never receives the account bearer.
3
+
4
+ import { randomUUID } from "node:crypto";
5
+ import { rejectedGrantRequest } from "./grantRefusal.mjs";
6
+ import { signedInRequest } from "./host.mjs";
7
+ import { shareOpenCalls } from "./reviewOpenCalls.mjs";
8
+
9
+ /** @typedef {import("./types/reviewWire.js").ShareDTO} ShareDTO @typedef {import("./types/reviewWire.js").ProjectDTO} ProjectDTO
10
+ * @typedef {import("./types/reviewWire.js").ExportDTO} ExportDTO */
11
+
12
+ const TIMEOUT_MS = 30_000;
13
+ const PROJECTS = "/v1/hyperframes/review-projects";
14
+ const SPACES = "/v1/hyperframes/review-spaces";
15
+
16
+ /** A refused or failed call: the HTTP status (0 when the server was never reached) and the server's reason. */
17
+ export class ReviewFailed extends Error {
18
+ /** @param {number} status @param {string} reason @param {number} [currentRevision] @param {number} [retryAfterMs]
19
+ * @param {import("./types/reviewWire.js").ErrorEnvelope["data"]} [details] */
20
+ constructor(status, reason, currentRevision, retryAfterMs, details) {
21
+ super(`review ${status} ${reason}`);
22
+ this.status = status;
23
+ this.reason = reason;
24
+ this.currentRevision = currentRevision;
25
+ this.retryAfterMs = retryAfterMs;
26
+ this.details = details;
27
+ }
28
+ }
29
+
30
+ /** The answer's data; null for a 204. A refusal throws with the server's status and reason.
31
+ * @param {Response} reply @param {string} what @returns {Promise<any>} */
32
+ async function dataOf(reply, what) {
33
+ if (reply.status === 204) return null;
34
+ const payload = await reply.json().catch(() => null);
35
+ if (reply.ok) return payload?.data ?? null;
36
+ /** @type {import("./types/reviewWire.js").ErrorEnvelope["data"] | undefined} */
37
+ const data = payload?.data;
38
+ throw responseFailure(reply, what, data);
39
+ }
40
+
41
+ /** @param {Response} reply @param {string} what @param {import("./types/reviewWire.js").ErrorEnvelope["data"] | undefined} data */
42
+ function responseFailure(reply, what, data) {
43
+ console.error(`[review] ${what} refused:`, reply.status, data?.reason);
44
+ const retryAfter = Number(reply.headers.get("retry-after") ?? NaN);
45
+ return new ReviewFailed(
46
+ reply.status,
47
+ data?.reason ?? "",
48
+ data?.current_revision,
49
+ retryAfter >= 0 ? retryAfter * 1000 : undefined,
50
+ data,
51
+ );
52
+ }
53
+
54
+ /** The server's own "no" to the sharing read: signed out, not allowed, or no such route. */
55
+ export const SHARING_REFUSALS = [401, 403, 404];
56
+
57
+ /** @param {{ access: import("./types/access.js").ApiAccess, fetchImpl?: typeof fetch,
58
+ * signedInEmail?: () => Promise<string | null> }} deps */
59
+ export function reviewClient({ access, fetchImpl = fetch, signedInEmail = async () => null }) {
60
+ const request = signedInRequest({
61
+ access,
62
+ fetchImpl,
63
+ timeoutMs: TIMEOUT_MS,
64
+ tag: "review",
65
+ offline: () => new ReviewFailed(0, "offline"),
66
+ signedOut: () => new ReviewFailed(401, "login_required"),
67
+ });
68
+ /** @param {string} method @param {string} route @param {unknown} [body] @param {Record<string, string>} [headers]
69
+ * @param {number[] | null} [answers] @returns {Promise<any>} */
70
+ const call = async (method, route, body, headers = {}, answers = null) =>
71
+ dataOf((await request(method, route, body, headers, answers)).reply, `${method} ${route}`);
72
+ /** A call on a share token's route, logged as `what`: the token opens the share to anyone holding it.
73
+ * @param {string} what @param {string} method @param {string} route @param {unknown} [body]
74
+ * @returns {Promise<any>} */
75
+ const tokenCall = async (what, method, route, body) =>
76
+ dataOf((await request(method, route, body, {}, null, what)).reply, what);
77
+
78
+ const project = (/** @type {string} */ id) => `${PROJECTS}/${encodeURIComponent(id)}`;
79
+ return {
80
+ /** The signed-in account's email, null when unknown; the record keeps it as the project's owner. */
81
+ signedInEmail,
82
+ /** The spaces this account can share into, personal first.
83
+ * @returns {Promise<import("./types/reviewWire.js").SpaceDTO[]>} */
84
+ spaces: async () => {
85
+ const items = (await call("GET", SPACES))?.items;
86
+ // An answer without a list is not "no spaces": sharing then would pick a workspace nobody chose.
87
+ if (!Array.isArray(items)) throw new ReviewFailed(502, "malformed_spaces");
88
+ return items;
89
+ },
90
+ /** The server's per-account flags on review-spaces, each on only for `true`: `hyperframes-review-version-sharing`
91
+ * and `hyperframes-review-share-files`. One read answers both.
92
+ * @returns {Promise<{ versionSharing: boolean, shareFiles: boolean }>} */
93
+ reviewFlags: async () => {
94
+ const data = await call("GET", SPACES, undefined, {}, SHARING_REFUSALS);
95
+ return {
96
+ versionSharing: data?.version_sharing === true,
97
+ shareFiles: data?.share_files === true,
98
+ };
99
+ },
100
+ /** Registers into `spaceId`; without one the server uses the caller's default space.
101
+ * @param {import("./types/reviewWire.js").RegisterProjectRequest} body @param {string | null} [spaceId]
102
+ * @returns {Promise<ProjectDTO>} */
103
+ register: (body, spaceId) =>
104
+ call("POST", PROJECTS, body, spaceId ? { "x-space-id": spaceId } : {}),
105
+ /** @param {string} id @returns {Promise<ShareDTO>} */
106
+ share: (id) => call("GET", `${project(id)}/share`),
107
+ /** @param {string} id @param {import("./types/reviewWire.js").UpdateProjectShareRequest} body
108
+ * @returns {Promise<ShareDTO>} */
109
+ updateShare: (id, body) => call("PATCH", `${project(id)}/share`, body),
110
+ /** @param {string} id @param {import("./types/reviewWire.js").ProjectOperationRequest} body @returns {Promise<ShareDTO>} */
111
+ linkOn: (id, body) => call("POST", `${project(id)}/share`, body),
112
+ /** @param {string} id @returns {Promise<null>} */
113
+ linkOff: (id) => call("DELETE", `${project(id)}/share`),
114
+ /** @param {string} id @returns {Promise<ProjectDTO>} */
115
+ project: (id) => call("GET", project(id)),
116
+ /** One page of the project's comments, every state. @param {string} id @param {URLSearchParams} query
117
+ * @returns {Promise<import("./types/reviewWire.js").FeedbackPage>} */
118
+ feedback: (id, query) => call("GET", `${project(id)}/feedback?${query}`),
119
+ /** A new version from a local MP4. A body factory runs after sign-in on each attempt.
120
+ * @param {string} id @param {ExportRequest | (() => Promise<ExportRequest>)} body @returns {Promise<ExportDTO>} */
121
+ createExport: (id, body) => call("POST", `${project(id)}/exports`, body),
122
+ /** @param {string} id @param {string} videoId @returns {Promise<ExportDTO>} */
123
+ readExport: (id, videoId) =>
124
+ call("GET", `${project(id)}/exports/${encodeURIComponent(videoId)}`),
125
+ /** The upload is in storage: start processing. @param {string} id @param {string} videoId
126
+ * @returns {Promise<ExportDTO>} */
127
+ completeExport: (id, videoId) =>
128
+ call("POST", `${project(id)}/exports/${encodeURIComponent(videoId)}/complete`),
129
+ /** Attaches the synced snapshot to a version that has none; the same one again is a no-op, another is refused
130
+ * with 409 `snapshot_already_attached`. @param {string} id @param {string} versionId
131
+ * @param {import("./types/reviewWire.js").SnapshotRef} snapshot @returns {Promise<unknown>} */
132
+ attachSnapshot: (id, versionId, snapshot) =>
133
+ call("PUT", `${project(id)}/versions/${encodeURIComponent(versionId)}/snapshot`, snapshot),
134
+ ...shareOpenCalls(tokenCall),
135
+ ...managementClient(call, project, access),
136
+ ...threadWrites(call, project),
137
+ };
138
+ }
139
+
140
+ /** The owner's writes on one thread. @param {(method: string, route: string, body?: unknown) => Promise<any>} call
141
+ * @param {(id: string) => string} project */
142
+ function threadWrites(call, project) {
143
+ const thread = (/** @type {string} */ id, /** @type {string} */ rootId) =>
144
+ `${project(id)}/comments/${encodeURIComponent(rootId)}`;
145
+ return {
146
+ /** Accept, dismiss, reopen or resolve a thread. @param {string} id @param {string} rootId
147
+ * @param {{ action: import("./types/review.js").ReviewAction, expected_status_revision: number }} body */
148
+ setStatus: (id, rootId, body) => call("PATCH", `${thread(id, rootId)}/status`, body),
149
+ /** @param {string} id @param {string} rootId
150
+ * @param {{ client_operation_id: string, body: string, attachment_ids: string[] }} body */
151
+ reply: (id, rootId, body) => call("POST", `${thread(id, rootId)}/replies`, body),
152
+ };
153
+ }
154
+
155
+ /** An invite to an email whose grant was revoked: the server keeps the revoked row, leaves it out of the grant list,
156
+ * and takes it back only with that row's revision and a fresh operation ID, so the one revision the client can learn is
157
+ * the conflict's. Null for any other refusal, or one the same revision already got.
158
+ * @param {unknown} error @param {import("./types/reviewWire.js").CreateGrantRequest} body
159
+ * @returns {import("./types/reviewWire.js").CreateGrantRequest | null} */
160
+ function regrantOf(error, body) {
161
+ if (!(error instanceof ReviewFailed) || error.reason !== "revision_conflict") return null;
162
+ if (typeof error.currentRevision !== "number") return null;
163
+ // A current revision of 0 is no row at all, which the server asks for as no revision.
164
+ const revision = error.currentRevision || null;
165
+ if (revision === body.expected_revision) return null;
166
+ return { ...body, client_operation_id: randomUUID(), expected_revision: revision };
167
+ }
168
+
169
+ /** Creating a grant, keeping the re-grant `regrantOf` makes as that invitation's retry. The window retries with the
170
+ * operation it first sent; once a re-grant went out under a new operation, that exact re-grant is sent again, so the
171
+ * server's replay recovers a lost answer (or refuses it as revoked since) and no further re-grant is made. It goes
172
+ * exactly when the window drops its own operation: on an answer, on a refusal `rejectedGrantRequest` (the window's own
173
+ * test) calls final, or when the email is invited afresh. Any other refusal, a moderation hold among them, keeps it.
174
+ * @param {(method: string, route: string, body?: unknown) => Promise<any>} call @param {(id: string) => string} project
175
+ * @param {import("./types/access.js").ApiAccess} access */
176
+ function grantCreator(call, project, access) {
177
+ /** Re-grants awaiting a final answer, by site, account, project and the operation the window sent.
178
+ * @type {Map<string, { email: string, regrant: import("./types/reviewWire.js").CreateGrantRequest }>} */
179
+ const sent = new Map();
180
+ /** @param {string} route @param {string} key @param {import("./types/reviewWire.js").CreateGrantRequest} body */
181
+ const settle = async (route, key, body) => {
182
+ try {
183
+ const answer = await call("POST", route, body);
184
+ sent.delete(key);
185
+ return answer;
186
+ } catch (error) {
187
+ if (rejectedGrantRequest(error)) sent.delete(key);
188
+ throw error;
189
+ }
190
+ };
191
+ /** @param {string} id @param {import("./types/reviewWire.js").CreateGrantRequest} body
192
+ * @returns {Promise<import("./types/reviewWire.js").AccessGrantDTO>} */
193
+ return async (id, body) => {
194
+ const route = `${project(id)}/access-grants`;
195
+ // expected: signed out, the POST below refuses as login_required; the scope is only a key.
196
+ const who = await access().catch(() => null);
197
+ const scope = grantScope(who, id);
198
+ const key = `${scope}\n${body.client_operation_id}`;
199
+ const retained = sent.get(key);
200
+ if (retained) return settle(route, key, retained.regrant);
201
+ for (const [other, kept] of sent)
202
+ if (other.startsWith(`${scope}\n`) && kept.email === body.email) sent.delete(other);
203
+ try {
204
+ return await call("POST", route, body);
205
+ } catch (error) {
206
+ const regrant = regrantOf(error, body);
207
+ if (!regrant) throw error;
208
+ sent.set(key, { email: body.email, regrant });
209
+ return settle(route, key, regrant);
210
+ }
211
+ };
212
+ }
213
+
214
+ /** @typedef {ReturnType<typeof reviewClient>} ReviewClient
215
+ * @typedef {import("./types/reviewWire.js").ExportRequest} ExportRequest */
216
+
217
+ /** @param {(method: string, route: string, body?: unknown, headers?: Record<string,string>) => Promise<any>} call
218
+ * @param {(id: string) => string} project @param {import("./types/access.js").ApiAccess} access */
219
+ function managementClient(call, project, access) {
220
+ return {
221
+ /** @param {string} id @param {{ title: string, expected_title: string }} body
222
+ * @returns {Promise<{title: string}>} */
223
+ updateTitle: (id, body) => call("PATCH", project(id), body),
224
+ /** @param {string} spaceId @param {string | null} [cursor] @returns
225
+ * {Promise<import("./types/reviewWire.js").ManagedReviewPage>} */
226
+ listProjects: (spaceId, cursor = null) =>
227
+ call("GET", `${PROJECTS}?${new URLSearchParams(cursor ? { cursor } : {})}`, undefined, {
228
+ "x-space-id": spaceId,
229
+ }),
230
+ /** @param {string} id @returns {Promise<null>} */
231
+ removeProject: (id) => call("DELETE", project(id)),
232
+ /** @param {string} id @returns {Promise<{items: import("./types/reviewWire.js").AccessGrantDTO[]}>} */
233
+ grants: async (id) => {
234
+ const items = [];
235
+ const cursors = new Set();
236
+ let cursor = null;
237
+ do {
238
+ const query = new URLSearchParams(cursor ? { cursor } : {});
239
+ const page = await call("GET", `${project(id)}/access-grants?${query}`);
240
+ items.push(...page.items);
241
+ cursor = page.next_cursor;
242
+ if (cursor && (cursors.has(cursor) || cursors.size >= 1000))
243
+ throw new ReviewFailed(502, "malformed_grant_pagination");
244
+ cursors.add(cursor);
245
+ } while (cursor);
246
+ return { items };
247
+ },
248
+ createGrant: grantCreator(call, project, access),
249
+ /** @param {string} id @param {string} grantId @param {{role: import("./types/reviewWire.js").AccessGrantDTO["role"],
250
+ * expected_revision: number}} body @returns {Promise<import("./types/reviewWire.js").AccessGrantDTO>} */
251
+ updateGrant: (id, grantId, body) =>
252
+ call("PATCH", `${project(id)}/access-grants/${encodeURIComponent(grantId)}`, body),
253
+ /** @param {string} id @param {string} grantId @returns {Promise<null>} */
254
+ revokeGrant: (id, grantId) =>
255
+ call("DELETE", `${project(id)}/access-grants/${encodeURIComponent(grantId)}`),
256
+ };
257
+ }
258
+
259
+ /** @param {Awaited<ReturnType<import("./types/access.js").ApiAccess>>} who @param {string} id */
260
+ const grantScope = (who, id) => [who?.heygen ?? "", who?.owner ?? "", id].join("\n");