@hyperframes/share 0.0.0-stage → 0.9.22
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.
- package/LICENSE +190 -0
- package/README.md +51 -2
- package/dist/appOwnedPaths.d.mts +6 -0
- package/dist/fromReview.d.mts +15 -0
- package/dist/grantRefusal.d.mts +8 -0
- package/dist/host.d.mts +29 -0
- package/dist/hosts.d.ts +39 -0
- package/dist/index.d.mts +21 -0
- package/dist/projectLock.d.mts +17 -0
- package/dist/projectSnapshot.d.mts +20 -0
- package/dist/reviewAudience.d.mts +3 -0
- package/dist/reviewClient.d.mts +122 -0
- package/dist/reviewExport.d.mts +172 -0
- package/dist/reviewFiles.d.mts +43 -0
- package/dist/reviewLinkStates.d.mts +18 -0
- package/dist/reviewOpenCalls.d.mts +16 -0
- package/dist/reviewShare.d.mts +106 -0
- package/dist/reviewSwitch.d.mts +53 -0
- package/dist/reviewUpload.d.mts +16 -0
- package/dist/reviewVersionSentences.d.mts +2 -0
- package/dist/signedInRequest.d.mts +56 -0
- package/dist/storage.d.mts +20 -0
- package/dist/types/access.d.ts +12 -0
- package/dist/types/review.d.ts +59 -0
- package/dist/types/reviewWire.d.ts +200 -0
- package/dist/types/share.d.ts +119 -0
- package/dist/types/shareOpen.d.ts +96 -0
- package/package.json +112 -3
- package/scripts/copy-types.mjs +3 -0
- package/src/appOwnedPaths.mjs +27 -0
- package/src/fromReview.mjs +27 -0
- package/src/grantRefusal.mjs +28 -0
- package/src/host.mjs +46 -0
- package/src/hosts.d.ts +39 -0
- package/src/index.mjs +82 -0
- package/src/projectLock.mjs +120 -0
- package/src/projectSnapshot.mjs +120 -0
- package/src/reviewAudience.mjs +24 -0
- package/src/reviewClient.mjs +260 -0
- package/src/reviewExport.mjs +827 -0
- package/src/reviewFiles.mjs +98 -0
- package/src/reviewLinkStates.mjs +47 -0
- package/src/reviewOpenCalls.mjs +40 -0
- package/src/reviewShare.mjs +645 -0
- package/src/reviewSwitch.mjs +430 -0
- package/src/reviewUpload.mjs +98 -0
- package/src/reviewVersionSentences.mjs +48 -0
- package/src/signedInRequest.mjs +125 -0
- package/src/storage.mjs +116 -0
- package/src/types/access.d.ts +12 -0
- package/src/types/review.d.ts +59 -0
- package/src/types/reviewWire.d.ts +200 -0
- package/src/types/share.d.ts +119 -0
- package/src/types/shareOpen.d.ts +96 -0
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Share for review: the project's review link. Its local id lives in .hyperframes/review.json;
|
|
3
|
+
// copies skip .hyperframes, so a duplicate gets its own link, as the backend wants of a fork.
|
|
4
|
+
import { withProjectShareLock } from "./projectLock.mjs";
|
|
5
|
+
import { firstReviewAudience } from "./reviewAudience.mjs";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { mkdirInside } from "./storage.mjs";
|
|
9
|
+
import {
|
|
10
|
+
isJsonObject,
|
|
11
|
+
isNullableSavedId,
|
|
12
|
+
isOptionalBoolean,
|
|
13
|
+
isOptionalSavedId,
|
|
14
|
+
isSavedId,
|
|
15
|
+
readJsonRecord,
|
|
16
|
+
SharingStateError,
|
|
17
|
+
writeJsonAtomic,
|
|
18
|
+
} from "./storage.mjs";
|
|
19
|
+
import { channelSite, normalize, signinSites } from "./host.mjs";
|
|
20
|
+
import { ReviewFailed } from "./reviewClient.mjs";
|
|
21
|
+
import { rename } from "./storage.mjs";
|
|
22
|
+
|
|
23
|
+
/** @typedef {import("./reviewClient.mjs").ReviewClient} ReviewClient
|
|
24
|
+
* @typedef {import("./types/reviewWire.js").ShareDTO} ShareDTO */
|
|
25
|
+
|
|
26
|
+
/** @typedef {import("./types/share.js").ReviewShare} ReviewShare
|
|
27
|
+
* @typedef {import("./types/share.js").ReviewSpace} ReviewSpace
|
|
28
|
+
* @typedef {import("./types/share.js").ReviewAccessChange} ReviewAccessChange */
|
|
29
|
+
|
|
30
|
+
/** Persist studioProjectId/spaceId before sending so a lost reply retries the same project and space; projectId after.
|
|
31
|
+
* opened marks the first completed share; site/ownerEmail bind the record to its backend/account.
|
|
32
|
+
* @typedef {{ studioProjectId: string, spaceId: string|null, projectId: string|null, opened: boolean,
|
|
33
|
+
* site: string, ownerEmail: string|null, removed?: boolean, registration?:
|
|
34
|
+
* import("./types/reviewWire.js").RegisterProjectRequest | undefined }} ReviewRecord */
|
|
35
|
+
|
|
36
|
+
/** The sign-in site this run's review calls go to, the channel's own until then.
|
|
37
|
+
* Ids from another site's server mean nothing here, so each site keeps its own record and never reads another's.
|
|
38
|
+
* @type {string | null} */
|
|
39
|
+
let chosenSite = null;
|
|
40
|
+
const site = () => chosenSite ?? channelSite();
|
|
41
|
+
/** @param {string} now */
|
|
42
|
+
export const setReviewSite = (now) => {
|
|
43
|
+
chosenSite = now;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** The channel's own site keeps the original review.json; any other site its own file.
|
|
47
|
+
* @param {string} projectPath @param {string} [at] */
|
|
48
|
+
const recordPath = (projectPath, at = site()) =>
|
|
49
|
+
path.join(
|
|
50
|
+
projectPath,
|
|
51
|
+
".hyperframes",
|
|
52
|
+
at === channelSite() ? "review.json" : `review-${new URL(at).host}.json`,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
/** The review metadata path for the current sign-in site. @param {string} projectPath */
|
|
56
|
+
export const reviewRecordPath = (projectPath) => recordPath(projectPath);
|
|
57
|
+
|
|
58
|
+
/** @param {unknown} value @returns {value is import("./types/reviewWire.js").RegisterProjectRequest} */
|
|
59
|
+
function isRegistration(value) {
|
|
60
|
+
return (
|
|
61
|
+
isJsonObject(value) &&
|
|
62
|
+
isSavedId(value.studio_project_id) &&
|
|
63
|
+
isSavedId(value.client_operation_id) &&
|
|
64
|
+
typeof value.title === "string"
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @typedef {{ studioProjectId: string } &
|
|
69
|
+
* Partial<Pick<ReviewRecord, "spaceId" | "projectId" | "opened" | "removed">>} SavedIdentity */
|
|
70
|
+
|
|
71
|
+
/** @param {Record<string, unknown>} record @returns {record is Record<string, unknown> & SavedIdentity} */
|
|
72
|
+
const isSavedIdentity = (record) =>
|
|
73
|
+
isSavedId(record.studioProjectId) &&
|
|
74
|
+
isOptionalSavedId(record.spaceId) &&
|
|
75
|
+
isOptionalSavedId(record.projectId) &&
|
|
76
|
+
isOptionalBoolean(record.opened) &&
|
|
77
|
+
isOptionalBoolean(record.removed) &&
|
|
78
|
+
(!record.opened || isSavedId(record.projectId));
|
|
79
|
+
|
|
80
|
+
/** @param {unknown} value @param {string} studioProjectId @param {string} file */
|
|
81
|
+
function savedRegistration(value, studioProjectId, file) {
|
|
82
|
+
if (value == null) return undefined;
|
|
83
|
+
if (!isRegistration(value) || value.studio_project_id !== studioProjectId)
|
|
84
|
+
throw new SharingStateError(file);
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @param {string} projectPath @param {string} [at] @returns {Promise<ReviewRecord | null>} */
|
|
89
|
+
async function readRecord(projectPath, at = site()) {
|
|
90
|
+
const file = recordPath(projectPath, at);
|
|
91
|
+
const record = await readJsonRecord(file);
|
|
92
|
+
if (!record) return null;
|
|
93
|
+
const { site: madeOn = channelSite() } = record;
|
|
94
|
+
if (madeOn !== at || !isSavedIdentity(record)) throw new SharingStateError(file);
|
|
95
|
+
return {
|
|
96
|
+
studioProjectId: record.studioProjectId,
|
|
97
|
+
spaceId: record.spaceId ?? null,
|
|
98
|
+
projectId: record.projectId ?? null,
|
|
99
|
+
opened: record.opened === true,
|
|
100
|
+
removed: record.removed === true,
|
|
101
|
+
registration: savedRegistration(record.registration, record.studioProjectId, file),
|
|
102
|
+
site: madeOn,
|
|
103
|
+
ownerEmail: typeof record.ownerEmail === "string" ? record.ownerEmail : null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Production, or Dev for any other site. @param {string} at */
|
|
108
|
+
const backendName = (at) => (at === channelSite() ? "Production" : "Dev");
|
|
109
|
+
|
|
110
|
+
/** Not shared here, but shared on another backend: say where, so the person can switch or share again here.
|
|
111
|
+
* @param {string} projectPath @returns {Promise<ReviewFailed | null>} */
|
|
112
|
+
async function sharedElsewhere(projectPath) {
|
|
113
|
+
for (const other of signinSites()) {
|
|
114
|
+
const record = other === site() ? null : await readRecord(projectPath, other);
|
|
115
|
+
if (record?.opened && record.projectId)
|
|
116
|
+
return new ReviewFailed(409, `other_backend:${backendName(other)}`);
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A space as the wire names it; `has_other_members` only when the server says.
|
|
122
|
+
* @param {NonNullable<ShareDTO["space"]> | import("./types/reviewWire.js").SpaceDTO} space */
|
|
123
|
+
const spaceRefOf = (space) => ({
|
|
124
|
+
id: space.space_id,
|
|
125
|
+
name: space.name,
|
|
126
|
+
kind: space.kind,
|
|
127
|
+
...(typeof space.has_other_members === "boolean" && { hasOtherMembers: space.has_other_members }),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/** @param {ShareDTO} share @returns {ReviewShare} */
|
|
131
|
+
const statusOf = (share) => ({
|
|
132
|
+
projectId: share.project_id,
|
|
133
|
+
url: share.revoked ? null : share.review_url,
|
|
134
|
+
access: share.access_mode,
|
|
135
|
+
role: share.default_role,
|
|
136
|
+
commentsOn: share.comments_enabled,
|
|
137
|
+
space: share.space ? spaceRefOf(share.space) : null,
|
|
138
|
+
elsewhere: false,
|
|
139
|
+
entitlements: share.review_entitlements,
|
|
140
|
+
...(share.sharing_state && { sharingState: share.sharing_state }),
|
|
141
|
+
held: share.moderation_hold === true,
|
|
142
|
+
...(takenDown(share) && { takedown: { page: share.authenticated_review_url } }),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/** A staff takedown: the server refuses a new link and any audience but private until staff lift it.
|
|
146
|
+
* @param {ShareDTO} share */
|
|
147
|
+
export const takenDown = (share) => share.moderation_hold_kind === "takedown";
|
|
148
|
+
|
|
149
|
+
/** The account's spaces as the server lists them; a server without the route answers 404: none.
|
|
150
|
+
* @param {ReviewClient} client */
|
|
151
|
+
export const listSpaces = (client) =>
|
|
152
|
+
client.spaces().catch((error) => {
|
|
153
|
+
if (error instanceof ReviewFailed && error.status === 404) return [];
|
|
154
|
+
throw error;
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/** The workspace Desktop is signed into, where every new link goes: its token is bound there and ignores any other
|
|
158
|
+
* space. The server marks it `bound`; a server that does not yet marks the one space it lets this token create in, else none.
|
|
159
|
+
* @param {import("./types/reviewWire.js").SpaceDTO[]} spaces */
|
|
160
|
+
function signedInSpace(spaces) {
|
|
161
|
+
const creatable = spaces.filter((space) => space.can_create_review);
|
|
162
|
+
return spaces.find((space) => space.bound) ?? (creatable.length === 1 ? creatable[0] : null);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Every space the account is in, the signed-in one marked: the later workspace switch lists them; v1 names one.
|
|
166
|
+
* @param {{ client: ReviewClient }} request @returns {Promise<ReviewSpace[]>} */
|
|
167
|
+
export async function reviewSpaces({ client }) {
|
|
168
|
+
const spaces = await listSpaces(client);
|
|
169
|
+
const here = signedInSpace(spaces);
|
|
170
|
+
return spaces.map((space) => ({
|
|
171
|
+
...spaceRefOf(space),
|
|
172
|
+
lastUsedAt: space.last_used_at,
|
|
173
|
+
canCreate: space.can_create_review,
|
|
174
|
+
signedIn: space === here,
|
|
175
|
+
entitlements: space.review_entitlements,
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The share as the person sees it: owner routes do not hold a token to its workspace, so a page in another one
|
|
180
|
+
* than Desktop is signed into works as any other, and is only named (`elsewhere`). A failed list names nothing.
|
|
181
|
+
* @param {ReviewClient} client @param {ShareDTO} share @returns {Promise<ReviewShare>} */
|
|
182
|
+
export async function shown(client, share) {
|
|
183
|
+
const bound = (await listSpaces(client).catch(() => [])).find((space) => space.bound);
|
|
184
|
+
const elsewhere = Boolean(bound && share.space && share.space.space_id !== bound.space_id);
|
|
185
|
+
return { ...statusOf(share), elsewhere };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** An owner route's refusal for a project this app shared: 403 permission_denied is an owner who left its workspace,
|
|
189
|
+
* a workspace deleted, or Desktop signed into another account (named, when the record knows whose it is); 404 is a
|
|
190
|
+
* project another account shares now, or the server no longer has.
|
|
191
|
+
* @param {ReviewClient} client @param {string} [projectPath] */
|
|
192
|
+
const ownerRefusal = (client, projectPath) => async (/** @type {unknown} */ error) => {
|
|
193
|
+
if (!(error instanceof ReviewFailed)) throw error;
|
|
194
|
+
if (error.reason === "permission_denied") {
|
|
195
|
+
const [record, now] = await Promise.all([
|
|
196
|
+
projectPath ? readRecord(projectPath) : null,
|
|
197
|
+
client.signedInEmail(),
|
|
198
|
+
]);
|
|
199
|
+
const owner = record?.ownerEmail;
|
|
200
|
+
const other = owner && now && owner.toLowerCase() !== now.toLowerCase();
|
|
201
|
+
throw new ReviewFailed(403, other ? OWNER_SIGNED_OUT + owner : "no_access");
|
|
202
|
+
}
|
|
203
|
+
throw error.status === 404 ? new ReviewFailed(404, "another_account") : error;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const OWNER_SIGNED_OUT = "owner_signed_out:";
|
|
207
|
+
/** The email in an owner_signed_out reason, else null. @param {string} reason */
|
|
208
|
+
export const ownerOf = (reason) =>
|
|
209
|
+
reason.startsWith(OWNER_SIGNED_OUT) ? reason.slice(OWNER_SIGNED_OUT.length) : null;
|
|
210
|
+
|
|
211
|
+
/** A line per project: each step runs once the one before it settled; `busy` while one runs or waits. */
|
|
212
|
+
export function projectLine() {
|
|
213
|
+
/** @type {Map<string, Promise<unknown>>} */
|
|
214
|
+
const queues = new Map();
|
|
215
|
+
/** @template T @param {string} projectPath @param {() => Promise<T>} run @returns {Promise<T>} */
|
|
216
|
+
const inTurn = (projectPath, run) => {
|
|
217
|
+
const key = normalize(projectPath);
|
|
218
|
+
const next = (queues.get(key) ?? Promise.resolve()).then(run, run);
|
|
219
|
+
const settled = next.catch(() => {});
|
|
220
|
+
queues.set(key, settled);
|
|
221
|
+
void settled.then(() => queues.get(key) === settled && queues.delete(key));
|
|
222
|
+
return next;
|
|
223
|
+
};
|
|
224
|
+
return {
|
|
225
|
+
inTurn,
|
|
226
|
+
busy: (/** @type {string} */ projectPath) => queues.has(normalize(projectPath)),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** One change per project at a time, so two presses never register twice or race a revision. */
|
|
231
|
+
const metadataLine = projectLine().inTurn;
|
|
232
|
+
/** @template T @param {string} projectPath @param {() => Promise<T>} run */
|
|
233
|
+
export const oneAtATime = (projectPath, run) =>
|
|
234
|
+
metadataLine(projectPath, () => withProjectShareLock(projectPath, "review", run));
|
|
235
|
+
|
|
236
|
+
/** Applies the person's choice over whatever else the share holds; a stale revision is read again once.
|
|
237
|
+
* @param {ReviewClient} client @param {string} id @param {ShareDTO} share
|
|
238
|
+
* @param {Partial<Pick<ShareDTO, "access_mode" | "default_role">>} change @returns {Promise<ShareDTO>} */
|
|
239
|
+
export async function changeShare(client, id, share, change) {
|
|
240
|
+
for (let read = share, retried = false; ; retried = true) {
|
|
241
|
+
const body = {
|
|
242
|
+
access_mode: read.access_mode,
|
|
243
|
+
default_role: read.default_role,
|
|
244
|
+
comments_enabled: read.comments_enabled,
|
|
245
|
+
...change,
|
|
246
|
+
expected_revision: read.revision,
|
|
247
|
+
};
|
|
248
|
+
// Only the workspace's audience may default to Can edit: leaving it hands the new audience Can comment.
|
|
249
|
+
if (body.access_mode !== "team" && body.default_role === "editor")
|
|
250
|
+
body.default_role = "commenter";
|
|
251
|
+
try {
|
|
252
|
+
return await client.updateShare(id, body);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
const stale = error instanceof ReviewFailed && error.reason === "revision_conflict";
|
|
255
|
+
if (retried || !stale) throw error;
|
|
256
|
+
read = await client.share(id);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** @param {string} projectPath @param {ReviewClient} _client */
|
|
262
|
+
async function sharedId(projectPath, _client) {
|
|
263
|
+
const record = await readRecord(projectPath);
|
|
264
|
+
if (record?.removed) throw new ReviewFailed(410, "review_project_removed");
|
|
265
|
+
if (!record?.opened || !record.projectId)
|
|
266
|
+
throw (await sharedElsewhere(projectPath)) ?? new ReviewFailed(404, "not_shared");
|
|
267
|
+
return record.projectId;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The server's id for a project this app finished sharing, else null; the comment feed reads it.
|
|
271
|
+
* @param {string} projectPath @returns {Promise<string | null>} */
|
|
272
|
+
export async function reviewProjectId(projectPath) {
|
|
273
|
+
const record = await readRecord(projectPath);
|
|
274
|
+
return !record?.removed && record?.opened && record.projectId ? record.projectId : null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Which local project each review project was shared from: review project id to its folder.
|
|
278
|
+
* @param {string[]} paths @returns {Promise<Record<string, string>>} */
|
|
279
|
+
export async function localReviewProjects(paths) {
|
|
280
|
+
const pairs = await Promise.all(paths.map(async (dir) => [await reviewProjectId(dir), dir]));
|
|
281
|
+
return Object.fromEntries(pairs.filter(([id]) => id));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The record's ids, for a change of space. @param {string} projectPath */
|
|
285
|
+
export const reviewRecord = (projectPath) => readRecord(projectPath);
|
|
286
|
+
|
|
287
|
+
/** Where a change of space waits: beside this backend's record, so one backend never finishes or drops another's.
|
|
288
|
+
* @param {string} projectPath */
|
|
289
|
+
export const reviewSwitchPath = (projectPath) =>
|
|
290
|
+
recordPath(projectPath).replace(/\.json$/, ".switch.json");
|
|
291
|
+
|
|
292
|
+
/** @param {string} dir @returns {Promise<import("./reviewSwitch.mjs").Switch | null>} */
|
|
293
|
+
export async function readReviewSwitch(dir) {
|
|
294
|
+
const file = reviewSwitchPath(dir);
|
|
295
|
+
const change = await readJsonRecord(file);
|
|
296
|
+
if (!change) return null;
|
|
297
|
+
if (
|
|
298
|
+
!isSavedId(change.from) ||
|
|
299
|
+
!isSavedId(change.spaceId) ||
|
|
300
|
+
typeof change.space !== "string" ||
|
|
301
|
+
!isNullableSavedId(change.projectId) ||
|
|
302
|
+
(change.id !== undefined && !isSavedId(change.id)) ||
|
|
303
|
+
!isOptionalBoolean(change.opened) ||
|
|
304
|
+
!isOptionalBoolean(change.paused) ||
|
|
305
|
+
(change.opened && !isSavedId(change.projectId)) ||
|
|
306
|
+
!isRegistration(change.body)
|
|
307
|
+
) {
|
|
308
|
+
throw new SharingStateError(file);
|
|
309
|
+
}
|
|
310
|
+
return /** @type {import("./reviewSwitch.mjs").Switch} */ (change);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** @type {Set<(projectPath: string) => void>} */
|
|
314
|
+
const rebound = new Set();
|
|
315
|
+
/** Called with a project's normalized path once its record names another review project.
|
|
316
|
+
* @param {(projectPath: string) => void} listener */
|
|
317
|
+
export const onRebound = (listener) => void rebound.add(listener);
|
|
318
|
+
|
|
319
|
+
/** Tells every listener that a project's record names another review project now. @param {string} projectPath */
|
|
320
|
+
const announceRebound = (projectPath) => {
|
|
321
|
+
for (const listener of rebound) listener(normalize(projectPath));
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/** Points the record at another review project, keeping every other field the record holds.
|
|
325
|
+
* @param {string} projectPath @param {string} projectId @param {string} spaceId */
|
|
326
|
+
export async function rebindRecord(projectPath, projectId, spaceId) {
|
|
327
|
+
const record = await readRecord(projectPath);
|
|
328
|
+
if (!record) throw new ReviewFailed(404, "not_shared");
|
|
329
|
+
if (record.removed) throw new ReviewFailed(410, "review_project_removed");
|
|
330
|
+
await writeJsonAtomic(recordPath(projectPath), { ...record, projectId, spaceId, opened: true });
|
|
331
|
+
announceRebound(projectPath);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Puts a project's record aside, so the next share registers it anew from the signed-in account: a new id and a new
|
|
335
|
+
* link. The old record stays beside it as review.previous-<time>.json, and its link and comments with its account.
|
|
336
|
+
* @param {string} projectPath */
|
|
337
|
+
export const setAsideReview = (projectPath) =>
|
|
338
|
+
oneAtATime(projectPath, async () => {
|
|
339
|
+
const at = recordPath(projectPath);
|
|
340
|
+
if (await readRecord(projectPath))
|
|
341
|
+
await rename(at, at.replace(/\.json$/, `.previous-${Date.now()}.json`));
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
/** Shares a project anew from the signed-in account: its record set aside, then `share` makes the new link, and the
|
|
345
|
+
* feed in view follows to the new page.
|
|
346
|
+
* @template T @param {string} projectPath @param {() => Promise<T>} share @returns {Promise<T>} */
|
|
347
|
+
export async function shareAnew(projectPath, share) {
|
|
348
|
+
await setAsideReview(projectPath);
|
|
349
|
+
const made = await share();
|
|
350
|
+
announceRebound(projectPath);
|
|
351
|
+
return made;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The share as the server holds it; `projectPath` lets a refusal name the account that shared it.
|
|
355
|
+
* @param {ReviewClient} client @param {string} id @param {string} [projectPath] */
|
|
356
|
+
export const readShare = (client, id, projectPath) =>
|
|
357
|
+
client.share(id).catch(ownerRefusal(client, projectPath));
|
|
358
|
+
|
|
359
|
+
/** The link as the server holds it now; null until the first share finished.
|
|
360
|
+
* @param {{ projectPath: string, client: ReviewClient }} request @returns {Promise<ReviewShare | null>} */
|
|
361
|
+
export async function reviewStatus({ projectPath, client }) {
|
|
362
|
+
const record = await readRecord(projectPath);
|
|
363
|
+
if (record?.removed) throw new ReviewFailed(410, "review_project_removed");
|
|
364
|
+
if (!record?.opened || !record.projectId) {
|
|
365
|
+
const held = await takenDownFirst(client, record);
|
|
366
|
+
if (held) return held;
|
|
367
|
+
const elsewhere = await sharedElsewhere(projectPath);
|
|
368
|
+
if (elsewhere) throw elsewhere;
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
return shown(client, await readShare(client, record.projectId, projectPath));
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (error instanceof ReviewFailed && error.reason === "review_project_removed")
|
|
375
|
+
await markReviewRemoved(projectPath, record.projectId);
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** A first share staff took down before it finished: registered, never opened, and the read still says so. Only that
|
|
381
|
+
* record reads the share; a failed read says nothing.
|
|
382
|
+
* @param {ReviewClient} client @param {ReviewRecord | null} record */
|
|
383
|
+
async function takenDownFirst(client, record) {
|
|
384
|
+
if (!record?.projectId) return null;
|
|
385
|
+
const share = await readShare(client, record.projectId).catch((error) => {
|
|
386
|
+
if (error instanceof ReviewFailed && error.reason === "review_project_removed") throw error;
|
|
387
|
+
return null;
|
|
388
|
+
});
|
|
389
|
+
return share && takenDown(share) ? shown(client, share) : null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** The project's record for a share press, written before any request. v1 passes no space: a new link goes to the
|
|
393
|
+
* signed-in workspace. A recorded space is kept until the server registered or refused it, so a retry after a lost
|
|
394
|
+
* reply never leaves a second project behind. A registered project keeps its space, whichever is signed in.
|
|
395
|
+
* @param {ReviewClient} client @param {string} projectPath @param {string | null} spaceId */
|
|
396
|
+
async function recordToShare(client, projectPath, spaceId) {
|
|
397
|
+
const record = await readRecord(projectPath);
|
|
398
|
+
if (record?.removed) throw new ReviewFailed(410, "review_project_removed");
|
|
399
|
+
if (record?.projectId) return record;
|
|
400
|
+
const space = await registrationSpace(client, spaceId, record);
|
|
401
|
+
if (record && space === record.spaceId) return record;
|
|
402
|
+
const fresh = {
|
|
403
|
+
studioProjectId: record?.studioProjectId ?? randomUUID(),
|
|
404
|
+
spaceId: space,
|
|
405
|
+
projectId: null,
|
|
406
|
+
opened: false,
|
|
407
|
+
site: site(),
|
|
408
|
+
ownerEmail: null,
|
|
409
|
+
};
|
|
410
|
+
await mkdirInside(projectPath, ".hyperframes");
|
|
411
|
+
await writeJsonAtomic(recordPath(projectPath), fresh);
|
|
412
|
+
return fresh;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** @param {ReviewClient} client @param {string | null} requested @param {ReviewRecord | null} record */
|
|
416
|
+
async function registrationSpace(client, requested, record) {
|
|
417
|
+
return requested ?? record?.spaceId ?? signedInSpace(await listSpaces(client))?.space_id ?? null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Registers the recorded project in its space. A refused space is forgotten, so the next press asks again.
|
|
421
|
+
* @param {ReviewClient} client @param {string} projectPath @param {ReviewRecord} record @param {string} title */
|
|
422
|
+
async function registerOnce(client, projectPath, record, title) {
|
|
423
|
+
const body = record.registration ?? {
|
|
424
|
+
studio_project_id: record.studioProjectId,
|
|
425
|
+
title: title.slice(0, 255),
|
|
426
|
+
client_operation_id: randomUUID(),
|
|
427
|
+
};
|
|
428
|
+
await writeJsonAtomic(recordPath(projectPath), { ...record, registration: body });
|
|
429
|
+
try {
|
|
430
|
+
return (await client.register(body, record.spaceId)).project_id;
|
|
431
|
+
} catch (error) {
|
|
432
|
+
if (!(error instanceof ReviewFailed) || error.reason !== "space_forbidden") throw error;
|
|
433
|
+
await writeJsonAtomic(recordPath(projectPath), { ...record, spaceId: null });
|
|
434
|
+
throw await spaceRefusal(client, record.spaceId, error);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** A space refused at registration. Only one picked before the workspace changed is replaced by the next press;
|
|
439
|
+
* otherwise (the signed-in workspace, the default, or a list that cannot say) another press would loop.
|
|
440
|
+
* @param {ReviewClient} client @param {string | null} refused @param {ReviewFailed} error */
|
|
441
|
+
async function spaceRefusal(client, refused, error) {
|
|
442
|
+
const here = signedInSpace(await listSpaces(client).catch(() => []));
|
|
443
|
+
const changed = Boolean(refused && here && here.space_id !== refused);
|
|
444
|
+
return changed ? error : new ReviewFailed(403, "workspace_cannot_share");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Share's press: the first registers the project into the space given (none: the signed-in one) and opens its link
|
|
448
|
+
* to that space; a later one reads the link, turning it back on when it was off. A registered project keeps its space.
|
|
449
|
+
* @param {{ projectPath: string, title: string, client: ReviewClient, spaceId?: string | null, audience?:
|
|
450
|
+
* import("./types/share.js").AccessMode | undefined }} request */
|
|
451
|
+
export const shareForReview = ({ projectPath, title, client, spaceId = null, audience }) =>
|
|
452
|
+
oneAtATime(projectPath, async () => {
|
|
453
|
+
const read = await recordToShare(client, projectPath, spaceId);
|
|
454
|
+
const id = read.projectId ?? (await registerOnce(client, projectPath, read, title));
|
|
455
|
+
const record = read.projectId
|
|
456
|
+
? read
|
|
457
|
+
: { ...read, projectId: id, ownerEmail: await client.signedInEmail() };
|
|
458
|
+
if (!read.projectId) await writeJsonAtomic(recordPath(projectPath), record);
|
|
459
|
+
const share = await readShare(client, id, projectPath);
|
|
460
|
+
if (takenDown(share)) return shown(client, share);
|
|
461
|
+
return openLink(client, projectPath, record, id, share, audience).then(
|
|
462
|
+
(opened) => shown(client, opened),
|
|
463
|
+
heldInstead(client, id),
|
|
464
|
+
);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
/** Turns a link that was off back on, and opens a first share to the workspace.
|
|
468
|
+
* @param {ReviewClient} client @param {string} projectPath @param {ReviewRecord} record @param {string} id
|
|
469
|
+
* @param {ShareDTO} share @param {import("./types/share.js").AccessMode} [audience] */
|
|
470
|
+
async function openLink(client, projectPath, record, id, share, audience) {
|
|
471
|
+
// Back on means a new address: the server never revives a turned-off link.
|
|
472
|
+
if (share.revoked)
|
|
473
|
+
share = await client.linkOn(id, {
|
|
474
|
+
expected_link_generation: share.link_generation,
|
|
475
|
+
client_operation_id: randomUUID(),
|
|
476
|
+
});
|
|
477
|
+
// A lost PATCH reply leaves the first share incomplete; a fresh explicit choice must still apply.
|
|
478
|
+
if (!record.opened) {
|
|
479
|
+
share = await applyFirstAudience(client, id, share, audience);
|
|
480
|
+
await writeJsonAtomic(recordPath(projectPath), { ...record, projectId: id, opened: true });
|
|
481
|
+
}
|
|
482
|
+
return share;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** @param {ReviewClient} client @param {string} id @param {ShareDTO} share
|
|
486
|
+
* @param {import("./types/share.js").AccessMode} [audience] */
|
|
487
|
+
export async function applyFirstAudience(client, id, share, audience) {
|
|
488
|
+
const chosen =
|
|
489
|
+
audience !== undefined || share.access_mode === "private"
|
|
490
|
+
? firstReviewAudience(share.review_entitlements, audience)
|
|
491
|
+
: share.access_mode;
|
|
492
|
+
if (share.access_mode !== chosen)
|
|
493
|
+
share = await changeShare(client, id, share, { access_mode: chosen });
|
|
494
|
+
return share;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** A takedown that landed after the read refuses the link with 409 moderation_hold; retrying never helps, so the
|
|
498
|
+
* press answers the held state instead. @param {ReviewClient} client @param {string} id */
|
|
499
|
+
const heldInstead = (client, id) => async (/** @type {unknown} */ error) => {
|
|
500
|
+
if (!(error instanceof ReviewFailed) || error.reason !== "moderation_hold") throw error;
|
|
501
|
+
return shown(client, await readShare(client, id));
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
/** Who can open the link, or whether they can comment: only what the person picked is sent, over the share as the
|
|
505
|
+
* server holds it now, so a change made on the page is never put back.
|
|
506
|
+
* @param {{ projectPath: string, client: ReviewClient, change: ReviewAccessChange }} request
|
|
507
|
+
* @returns {Promise<ReviewShare>} */
|
|
508
|
+
export const setReviewAccess = ({ projectPath, client, change }) =>
|
|
509
|
+
oneAtATime(projectPath, async () => {
|
|
510
|
+
const id = await sharedId(projectPath, client);
|
|
511
|
+
const share = await readShare(client, id, projectPath);
|
|
512
|
+
const picked =
|
|
513
|
+
"access" in change ? { access_mode: change.access } : { default_role: change.role };
|
|
514
|
+
return shown(client, await changeShare(client, id, share, picked));
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
/** Turns the link off for everyone; comments and settings are kept, and the next share makes a new link.
|
|
518
|
+
* @param {{ projectPath: string, client: ReviewClient }} request @returns {Promise<ReviewShare>} */
|
|
519
|
+
export const turnOffReviewLink = ({ projectPath, client }) =>
|
|
520
|
+
oneAtATime(projectPath, async () => {
|
|
521
|
+
const id = await sharedId(projectPath, client);
|
|
522
|
+
await client.linkOff(id).catch(ownerRefusal(client, projectPath));
|
|
523
|
+
return shown(client, await readShare(client, id, projectPath));
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
/** A refusal's sentence by its reason alone. */
|
|
527
|
+
const SENTENCES = {
|
|
528
|
+
review_project_limit_reached:
|
|
529
|
+
"This workspace has used its five review projects. Remove a review project or upgrade HyperFrames to create more.",
|
|
530
|
+
review_subscription_required: "Restricted sharing requires a HyperFrames subscription.",
|
|
531
|
+
review_public_consent_required: "Choose Share with anyone who has the link to continue.",
|
|
532
|
+
review_entitlement_unavailable: "Couldn't confirm sharing availability. Try again.",
|
|
533
|
+
review_project_changed: "This project’s review changed. Reopen sharing and try again.",
|
|
534
|
+
review_project_removed:
|
|
535
|
+
"This review project was removed. Share again to create a new link and discussion.",
|
|
536
|
+
moderation_hold: "This review was taken down for a policy violation, so it can't be shared.",
|
|
537
|
+
not_public: "Only a link anyone can open has a guest view. Open the review page instead.",
|
|
538
|
+
another_account:
|
|
539
|
+
"This link was made by another HeyGen account, or was deleted. Sign in with the account that shared it.",
|
|
540
|
+
no_access:
|
|
541
|
+
"This account can't open this project's review page. Sign in with the account that shared it, or ask to be added back to its workspace.",
|
|
542
|
+
workspace_cannot_share: "This workspace can't create review pages.",
|
|
543
|
+
space_forbidden: "Your workspace changed. Share again to use the workspace you're signed into.",
|
|
544
|
+
project_bound_to_other_owner:
|
|
545
|
+
"Someone else in your workspace already shares this project. Duplicate it to share your own copy.",
|
|
546
|
+
feature_unavailable: "Review pages aren't available for your account yet.",
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
/** The one sentence a person reads for a failed call; the log keeps the status and reason. `fallback` names the
|
|
550
|
+
* call when the reason has no sentence of its own.
|
|
551
|
+
* @param {unknown} error @param {string} [fallback] */
|
|
552
|
+
export function reviewSentence(error, fallback = "Couldn't share this project. Try again.") {
|
|
553
|
+
if (error instanceof SharingStateError) return error.message;
|
|
554
|
+
if (!(error instanceof ReviewFailed)) return fallback;
|
|
555
|
+
if (error.status === 0) return "Couldn't reach HeyGen. Check your connection and try again.";
|
|
556
|
+
if (error.status === 401) return "Your sign-in has expired. Sign in again, then share.";
|
|
557
|
+
if (error.reason.startsWith("other_backend:")) {
|
|
558
|
+
const name = error.reason.slice("other_backend:".length);
|
|
559
|
+
return `This review was shared on ${name}. Switch to ${name} to open it, or share again here.`;
|
|
560
|
+
}
|
|
561
|
+
const owner = ownerOf(error.reason);
|
|
562
|
+
if (owner) return `This review belongs to ${owner}. Switch to that account to manage it.`;
|
|
563
|
+
if (Object.hasOwn(SENTENCES, error.reason))
|
|
564
|
+
return SENTENCES[/** @type {keyof typeof SENTENCES} */ (error.reason)];
|
|
565
|
+
if (error.status === 403) return "This account can't share this project for review yet.";
|
|
566
|
+
if (error.status === 429 || error.status === 503)
|
|
567
|
+
return "Sharing is busy right now. Try again in a moment.";
|
|
568
|
+
return fallback;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** @param {string} projectPath @param {string} projectId */
|
|
572
|
+
export const markReviewRemoved = (projectPath, projectId) =>
|
|
573
|
+
oneAtATime(projectPath, () => retainRemovedRecord(projectPath, projectId));
|
|
574
|
+
|
|
575
|
+
/** @param {string} projectPath @param {string} projectId @param {ReviewClient} client */
|
|
576
|
+
export const removeLocalReview = (projectPath, projectId, client) =>
|
|
577
|
+
removeReviewProject(projectId, client, [projectPath]);
|
|
578
|
+
|
|
579
|
+
/** Locks each open binding involved in this removal before sending DELETE, including a move's source.
|
|
580
|
+
* @param {string} projectId @param {ReviewClient} client @param {string[]} projectPaths */
|
|
581
|
+
export async function removeReviewProject(projectId, client, projectPaths) {
|
|
582
|
+
/** @type {string[]} */
|
|
583
|
+
const affected = [];
|
|
584
|
+
for (const dir of [...new Set(projectPaths.map(normalize))].sort()) {
|
|
585
|
+
const record = await readRecord(dir);
|
|
586
|
+
const change = await readReviewSwitch(dir);
|
|
587
|
+
if (
|
|
588
|
+
record?.projectId === projectId ||
|
|
589
|
+
change?.from === projectId ||
|
|
590
|
+
change?.projectId === projectId
|
|
591
|
+
)
|
|
592
|
+
affected.push(dir);
|
|
593
|
+
}
|
|
594
|
+
/** @param {number} index @returns {Promise<void>} */
|
|
595
|
+
const locked = async (index) => {
|
|
596
|
+
const dir = affected[index];
|
|
597
|
+
if (dir) return oneAtATime(dir, () => locked(index + 1));
|
|
598
|
+
await client.removeProject(projectId);
|
|
599
|
+
for (const path of affected) await retainRemovedRecord(path, projectId);
|
|
600
|
+
};
|
|
601
|
+
await locked(0);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** @param {string} projectPath @param {string} projectId */
|
|
605
|
+
async function retainRemovedRecord(projectPath, projectId) {
|
|
606
|
+
const record = await readRecord(projectPath);
|
|
607
|
+
const journal = await readReviewSwitch(projectPath);
|
|
608
|
+
if (journal?.from === projectId || journal?.projectId === projectId)
|
|
609
|
+
await rename(
|
|
610
|
+
reviewSwitchPath(projectPath),
|
|
611
|
+
reviewSwitchPath(projectPath).replace(/\.json$/, `.removed-${Date.now()}.json`),
|
|
612
|
+
);
|
|
613
|
+
if (record?.projectId !== projectId) return;
|
|
614
|
+
await writeJsonAtomic(recordPath(projectPath), { ...record, removed: true });
|
|
615
|
+
announceRebound(projectPath);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** The owner workspace of an unfinished binding takes precedence over the active workspace.
|
|
619
|
+
* @param {{client: ReviewClient, projectPath: string}} request @returns {Promise<ReviewSpace | null>} */
|
|
620
|
+
export async function reviewDestination({ client, projectPath }) {
|
|
621
|
+
const record = await readRecord(projectPath);
|
|
622
|
+
if (record?.removed) throw new ReviewFailed(410, "review_project_removed");
|
|
623
|
+
if (record?.projectId) {
|
|
624
|
+
const share = await readShare(client, record.projectId, projectPath);
|
|
625
|
+
if (!share.space) return null;
|
|
626
|
+
return {
|
|
627
|
+
...spaceRefOf(share.space),
|
|
628
|
+
entitlements: share.review_entitlements,
|
|
629
|
+
existingProjectId: record.projectId,
|
|
630
|
+
resumingRegistration: true,
|
|
631
|
+
...(share.sharing_state && { sharingState: share.sharing_state }),
|
|
632
|
+
signedIn: false,
|
|
633
|
+
lastUsedAt: null,
|
|
634
|
+
canCreate: true,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
const spaces = await reviewSpaces({ client });
|
|
638
|
+
const destination =
|
|
639
|
+
(record?.spaceId
|
|
640
|
+
? spaces.find((space) => space.id === record.spaceId)
|
|
641
|
+
: spaces.find((space) => space.signedIn)) ?? null;
|
|
642
|
+
return destination
|
|
643
|
+
? { ...destination, resumingRegistration: Boolean(record?.registration) }
|
|
644
|
+
: null;
|
|
645
|
+
}
|