From debb19c267980c522c0033fb4df014adf2c71a1d Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Sun, 1 Nov 2026 15:06:57 -0500
Subject: [PATCH 01/17] Carry a share revision in share links
Agent-Session: 64182dea-534e-45f1-8e28-c1e27a5d6bab
Agent-Session: 12a7a292-c657-465f-b5f3-c638d3396f22
Agent-Session: 61c94475-694a-4e46-8971-e0d703d3b344
Agent-Session: 2b04f44e-91c6-4568-8c57-c4a01b2828ad
Agent-Session: 196d7f0b-63a2-4186-84b0-5068df27bf9a
Agent-Session: b47f56d8-24aa-4d4f-95b3-4217b7ec9e00
Agent-Session: f2f8f3d8-5a50-4c15-8ad8-9c9308d4a110
Agent-Session: 226d831c-5d71-4f4d-8cdb-7b3c4f30284d
---
.../review-share-protocol/src/index.test.ts | 27 ++++++++++++++++
packages/review-share-protocol/src/index.ts | 31 ++++++++++++++++---
2 files changed, 53 insertions(+), 5 deletions(-)
diff --git a/packages/review-share-protocol/src/index.test.ts b/packages/review-share-protocol/src/index.test.ts
index 7222737cec..1d268e01c3 100644
--- a/packages/review-share-protocol/src/index.test.ts
+++ b/packages/review-share-protocol/src/index.test.ts
@@ -29,6 +29,33 @@ it("puts the capability in the path and rejects credentials, fragments or other
);
});
+it("carries a revision as ?rev= and rejects any other query", () => {
+ const token = "a".repeat(43),
+ rev = "5b0e9a52-4a43-4f7e-9d8e-3c1f0e7b2a11";
+
+ expect(shareLink("https://app.dev.fast", token, rev)).toBe(
+ `https://app.dev.fast/s/${token}?rev=${rev}`,
+ );
+ expect(parseShareLink(`https://app.dev.fast/s/${token}?rev=${rev}`)).toEqual({
+ origin: "https://app.dev.fast",
+ capability: token,
+ revision: rev,
+ });
+ expect(
+ parseShareLink(`https://app.dev.fast/s/${token}?app=preview&rev=${rev}`)
+ .revision,
+ ).toBe(rev);
+ expect(() =>
+ parseShareLink(`https://app.dev.fast/s/${token}?rev=nope`),
+ ).toThrow("Invalid UUID");
+ expect(() => parseShareLink(`https://app.dev.fast/s/${token}?x=1`)).toThrow(
+ "HTTPS",
+ );
+ expect(parseShareLink(`https://app.dev.fast/s/${token}`)).not.toHaveProperty(
+ "revision",
+ );
+});
+
it("rejects traversal paths and platform-specific separators", () => {
for (const file of [
"../secret",
diff --git a/packages/review-share-protocol/src/index.ts b/packages/review-share-protocol/src/index.ts
index 9f31a0dd6c..29096e741a 100644
--- a/packages/review-share-protocol/src/index.ts
+++ b/packages/review-share-protocol/src/index.ts
@@ -139,11 +139,26 @@ export const createShareSchema = z.object({
bundle: shareBundleSchema,
});
-export function shareLink(origin: string, capability: string): string {
- return new URL(`/s/${capabilitySchema.parse(capability)}`, origin).href;
+export function shareLink(
+ origin: string,
+ capability: string,
+ revision?: string,
+): string {
+ const url = new URL(`/s/${capabilitySchema.parse(capability)}`, origin);
+
+ if (revision !== undefined)
+ url.searchParams.set("rev", shareIdSchema.parse(revision));
+
+ return url.href;
}
-export function parseShareLink(value: string) {
+export type ShareLink = {
+ origin: string;
+ capability: string;
+ revision?: string;
+};
+
+export function parseShareLink(value: string): ShareLink {
const url = new URL(value);
if (
@@ -151,14 +166,20 @@ export function parseShareLink(value: string) {
url.username ||
url.password ||
url.hash ||
- [...url.searchParams.keys()].some((key) => key !== "app")
+ [...url.searchParams.keys()].some((key) => key !== "app" && key !== "rev")
)
throw new Error("Use an HTTPS Whiteboard share link.");
- return {
+ const link: ShareLink = {
origin: url.origin,
capability: capabilitySchema.parse(
/^\/s\/([^/]+)$/.exec(url.pathname)?.[1],
),
};
+
+ const revision = url.searchParams.get("rev");
+
+ if (revision !== null) link.revision = shareIdSchema.parse(revision);
+
+ return link;
}
From daef1da90b59d91d33adc37dc499d005a7dabab6 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Sun, 1 Nov 2026 15:07:20 -0500
Subject: [PATCH 02/17] Add share revision schemas and release
review-share-protocol 0.5.0
Agent-Session: 64182dea-534e-45f1-8e28-c1e27a5d6bab
Agent-Session: 12a7a292-c657-465f-b5f3-c638d3396f22
Agent-Session: 61c94475-694a-4e46-8971-e0d703d3b344
Agent-Session: 2b04f44e-91c6-4568-8c57-c4a01b2828ad
Agent-Session: 196d7f0b-63a2-4186-84b0-5068df27bf9a
Agent-Session: b47f56d8-24aa-4d4f-95b3-4217b7ec9e00
Agent-Session: f2f8f3d8-5a50-4c15-8ad8-9c9308d4a110
Agent-Session: 226d831c-5d71-4f4d-8cdb-7b3c4f30284d
---
packages/review-share-protocol/package.json | 2 +-
.../review-share-protocol/src/index.test.ts | 35 +++++++++++++++++++
packages/review-share-protocol/src/index.ts | 35 +++++++++++++++++++
3 files changed, 71 insertions(+), 1 deletion(-)
diff --git a/packages/review-share-protocol/package.json b/packages/review-share-protocol/package.json
index ea15c605fc..eaa50f4c07 100644
--- a/packages/review-share-protocol/package.json
+++ b/packages/review-share-protocol/package.json
@@ -1,6 +1,6 @@
{
"name": "@dev.fast/review-share-protocol",
- "version": "0.4.1",
+ "version": "0.5.0",
"description": "Portable immutable Review sharing envelope.",
"license": "MIT",
"repository": {
diff --git a/packages/review-share-protocol/src/index.test.ts b/packages/review-share-protocol/src/index.test.ts
index 1d268e01c3..cb51072c59 100644
--- a/packages/review-share-protocol/src/index.test.ts
+++ b/packages/review-share-protocol/src/index.test.ts
@@ -6,8 +6,11 @@ import {
normalizeGitHubRemote,
parseShareLink,
shareBundleSchema,
+ shareCreatedSchema,
shareLink,
sharePathSchema,
+ shareRevisionsSchema,
+ sharedMetaSchema,
} from "./index.js";
it("puts the capability in the path and rejects credentials, fragments or other paths", () => {
@@ -56,6 +59,38 @@ it("carries a revision as ?rev= and rejects any other query", () => {
);
});
+it("reads a link's revisions and the revision a capability serves", () => {
+ const id = "5b0e9a52-4a43-4f7e-9d8e-3c1f0e7b2a11",
+ url = `https://app.dev.fast/s/${"a".repeat(43)}?rev=${id}`;
+
+ expect(
+ shareRevisionsSchema.parse({
+ latest: id,
+ items: [{ id, version: 3, size: 10, createdAt: 1, url, extra: true }],
+ }).items[0],
+ ).toEqual({ id, version: 3, size: 10, createdAt: 1, url });
+ expect(
+ sharedMetaSchema.safeParse({
+ sender: { login: "ada" },
+ sharedAt: 1,
+ size: 10,
+ }).success,
+ ).toBe(false);
+ expect(
+ sharedMetaSchema.parse({
+ title: "T",
+ sender: { login: "ada" },
+ sharedAt: 1,
+ size: 10,
+ revision: { id, version: 3, createdAt: 1 },
+ latest: id,
+ }).revision.id,
+ ).toBe(id);
+ expect(
+ shareCreatedSchema.safeParse({ shareId: id, url, revision: id }).success,
+ ).toBe(false);
+});
+
it("rejects traversal paths and platform-specific separators", () => {
for (const file of [
"../secret",
diff --git a/packages/review-share-protocol/src/index.ts b/packages/review-share-protocol/src/index.ts
index 29096e741a..9f1dd072eb 100644
--- a/packages/review-share-protocol/src/index.ts
+++ b/packages/review-share-protocol/src/index.ts
@@ -139,6 +139,41 @@ export const createShareSchema = z.object({
bundle: shareBundleSchema,
});
+export const shareCreatedSchema = z.strictObject({
+ shareId: shareIdSchema,
+ url: z.url(),
+});
+
+const shareSizeSchema = z.number().int().positive().max(MAX_SHARE_BYTES);
+
+export const shareRevisionRefSchema = z.object({
+ id: shareIdSchema,
+ version: z.number().int().nonnegative(),
+ createdAt: z.number(),
+});
+
+export const shareRevisionSchema = shareRevisionRefSchema.extend({
+ size: shareSizeSchema,
+ url: z.url(),
+});
+
+export const shareRevisionsSchema = z.object({
+ latest: shareIdSchema,
+ items: z.array(shareRevisionSchema),
+});
+
+export type ShareRevisions = z.infer;
+
+export const sharedMetaSchema = z.object({
+ sender: z.strictObject({ login: z.string().min(1).max(256) }),
+ sharedAt: z.number(),
+ size: shareSizeSchema,
+ revision: shareRevisionRefSchema,
+ latest: shareIdSchema,
+});
+
+export type SharedMeta = z.infer;
+
export function shareLink(
origin: string,
capability: string,
From 24ec43c8928073aba2e2c9e9d6251e505acb1ba6 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Sun, 1 Nov 2026 15:10:28 -0500
Subject: [PATCH 03/17] Read share revisions and download the revision the meta
names
Agent-Session: 64182dea-534e-45f1-8e28-c1e27a5d6bab
Agent-Session: 12a7a292-c657-465f-b5f3-c638d3396f22
Agent-Session: 61c94475-694a-4e46-8971-e0d703d3b344
Agent-Session: 2b04f44e-91c6-4568-8c57-c4a01b2828ad
Agent-Session: 196d7f0b-63a2-4186-84b0-5068df27bf9a
Agent-Session: b47f56d8-24aa-4d4f-95b3-4217b7ec9e00
Agent-Session: f2f8f3d8-5a50-4c15-8ad8-9c9308d4a110
Agent-Session: 226d831c-5d71-4f4d-8cdb-7b3c4f30284d
---
packages/review/src/sharing/client.test.ts | 134 +++++++++++++++++----
packages/review/src/sharing/client.ts | 56 ++++++---
packages/review/src/sharing/host.test.ts | 9 +-
3 files changed, 154 insertions(+), 45 deletions(-)
diff --git a/packages/review/src/sharing/client.test.ts b/packages/review/src/sharing/client.test.ts
index 1d857f502e..15a884eb3b 100644
--- a/packages/review/src/sharing/client.test.ts
+++ b/packages/review/src/sharing/client.test.ts
@@ -5,6 +5,7 @@ import path from "node:path";
import {
MAX_SHARE_BYTES,
+ type SharedMeta,
createShareSchema,
} from "@dev.fast/review-share-protocol";
import { ReviewInputError } from "@dev.fast/whiteboard-document";
@@ -96,45 +97,63 @@ it("surfaces a failed create and refuses an oversized share before any request",
expect(send).not.toHaveBeenCalled();
});
+function sharedService(meta: SharedMeta, body: () => BodyInit) {
+ const requests: { path: string; headers: Headers }[] = [];
+
+ const send = vi.fn(async (input, init) => {
+ const url = new URL(String(input));
+ requests.push({
+ path: url.pathname + url.search,
+ headers: new Headers(init?.headers),
+ });
+
+ return url.pathname.endsWith("/bundle")
+ ? new Response(body())
+ : Response.json(meta);
+ });
+
+ return {
+ requests,
+ send,
+ client: new ShareClient("https://app.dev.fast", "secret", send),
+ };
+}
+
it("downloads a share with the link's capability within the size limit", async () => {
const shared = await bundle();
const capability = "c".repeat(43);
- const requests: { path: string; headers: Headers }[] = [];
+ const R = randomUUID();
+ const L = randomUUID();
+
+ const meta = {
+ sender: { login: "sender" },
+ sharedAt: 1,
+ size: 1,
+ revision: { id: R, version: 3, createdAt: 1 },
+ latest: L,
+ };
- const client = (body: () => BodyInit) =>
- new ShareClient("https://app.dev.fast", "secret", async (input, init) => {
- const url = new URL(String(input));
- requests.push({
- path: url.pathname,
- headers: new Headers(init?.headers),
- });
-
- return url.pathname.endsWith("/bundle")
- ? new Response(body())
- : Response.json({
- title: shared.title,
- sender: { login: "sender" },
- sharedAt: 1,
- size: 1,
- });
- });
+ const { requests, client } = sharedService(meta, () =>
+ JSON.stringify(shared),
+ );
- expect(
- await client(() => JSON.stringify(shared)).download(capability),
- ).toEqual({
+ expect(await client.download(capability)).toEqual({
bundle: shared,
attribution: { login: "sender", sharedAt: 1 },
+ revision: { id: R, version: 3, createdAt: 1 },
+ latest: L,
});
expect(requests.map(({ path }) => path)).toEqual([
`/api/v2/shared/${capability}`,
- `/api/v2/shared/${capability}/bundle`,
+ `/api/v2/shared/${capability}/bundle?rev=${R}`,
]);
for (const { headers } of requests)
expect(headers.get("authorization")).toBeNull();
await expect(
- client(
+ sharedService(
+ meta,
() =>
new ReadableStream({
start(controller) {
@@ -142,10 +161,77 @@ it("downloads a share with the link's capability within the size limit", async (
controller.close();
},
}),
- ).download(capability),
+ ).client.download(capability),
).rejects.toThrow("limit");
});
+it("asks for a named revision and refuses a malformed one before any request", async () => {
+ const shared = await bundle();
+ const capability = "c".repeat(43);
+ const R = randomUUID();
+
+ const meta = {
+ sender: { login: "sender" },
+ sharedAt: 1,
+ size: 1,
+ revision: { id: R, version: 2, createdAt: 1 },
+ latest: randomUUID(),
+ };
+
+ const { requests, client } = sharedService(meta, () =>
+ JSON.stringify(shared),
+ );
+
+ await client.download(capability, R);
+ expect(requests[0]?.path).toBe(`/api/v2/shared/${capability}?rev=${R}`);
+
+ const refused = sharedService(meta, () => JSON.stringify(shared));
+
+ await expect(refused.client.shared(capability, "nope")).rejects.toThrow(
+ "UUID",
+ );
+ expect(refused.send).not.toHaveBeenCalled();
+});
+
+it("reads a link's revisions with the account's token", async () => {
+ const shareId = randomUUID();
+ const R1 = randomUUID();
+ const R2 = randomUUID();
+ const u1 = `https://app.dev.fast/s/${"x".repeat(43)}?rev=${R1}`;
+ const u2 = `https://app.dev.fast/s/${"x".repeat(43)}?rev=${R2}`;
+ const requests: string[] = [];
+
+ const revisions = {
+ latest: R2,
+ items: [
+ { id: R2, version: 5, size: 20, createdAt: 2, url: u2 },
+ { id: R1, version: 4, size: 10, createdAt: 1, url: u1 },
+ ],
+ };
+
+ const client = new ShareClient(
+ "https://app.dev.fast",
+ "secret",
+ async (input, init) => {
+ const { pathname } = new URL(String(input));
+
+ if (pathname === "/api/auth/token")
+ return new Response(null, { status: 404 });
+ requests.push(
+ `${init?.method ?? "GET"} ${pathname} ${new Headers(init?.headers).get("authorization")}`,
+ );
+
+ return Response.json(revisions);
+ },
+ );
+
+ expect(await client.revisions(shareId)).toEqual(revisions);
+ expect(requests).toEqual([
+ `GET /api/v2/shares/${shareId}/revisions Bearer secret`,
+ ]);
+ await expect(client.revisions("x")).rejects.toThrow("UUID");
+});
+
it("stops a streamed response as soon as it exceeds the declared size", async () => {
let cancelled = false;
diff --git a/packages/review/src/sharing/client.ts b/packages/review/src/sharing/client.ts
index 43a31065a1..e23d62a9d7 100644
--- a/packages/review/src/sharing/client.ts
+++ b/packages/review/src/sharing/client.ts
@@ -4,8 +4,14 @@ import { type JsonValue, parseJsonText } from "@dev.fast/json";
import {
MAX_SHARE_BYTES,
type ShareBundle,
+ type ShareRevisions,
+ type SharedMeta,
capabilitySchema,
shareBundleSchema,
+ shareCreatedSchema,
+ shareIdSchema,
+ shareRevisionsSchema,
+ sharedMetaSchema,
} from "@dev.fast/review-share-protocol";
import {
StreamLimitError,
@@ -37,14 +43,6 @@ const jwtPayloadSchema = z.object({ exp: z.number() });
const bearers = new Map>();
-const linkSchema = z.strictObject({ shareId: z.uuid(), url: z.url() });
-
-const receivedSchema = z.object({
- sender: z.strictObject({ login: z.string().min(1).max(256) }),
- sharedAt: z.number(),
- size: z.number().int().positive().max(MAX_SHARE_BYTES),
-});
-
/** Size is checked while reading, even if Content-Length is absent or false. */
export async function readBoundedBytes(
response: Response,
@@ -206,7 +204,7 @@ export class ShareClient {
}
async create(bundle: ShareBundle, requestId: string = randomUUID()) {
- return linkSchema.parse(
+ return shareCreatedSchema.parse(
await this.api(
"/api/v2/shares",
"POST",
@@ -222,7 +220,7 @@ export class ShareClient {
}
async recoverLink(shareId: string) {
- return linkSchema.parse(
+ return shareCreatedSchema.parse(
await this.api(`/api/v2/shares/${z.uuid().parse(shareId)}/link`),
);
}
@@ -231,16 +229,35 @@ export class ShareClient {
return this.api(`/api/v2/shares/${z.uuid().parse(shareId)}`, "DELETE");
}
- /** The bundle and who shared it, read with the link's capability. */
- async download(capability: string) {
- const route = `/api/v2/shared/${capabilitySchema.parse(capability)}`;
+ async revisions(shareId: string): Promise {
+ return shareRevisionsSchema.parse(
+ await this.api(
+ `/api/v2/shares/${shareIdSchema.parse(shareId)}/revisions`,
+ ),
+ );
+ }
+
+ /** Who shared a link's revision, and which revision is its latest. */
+ async shared(capability: string, revision?: string): Promise {
+ const query =
+ revision === undefined ? "" : `?rev=${shareIdSchema.parse(revision)}`;
- const received = receivedSchema.parse(
- await this.api(route, "GET", undefined, true),
+ return sharedMetaSchema.parse(
+ await this.api(
+ `/api/v2/shared/${capabilitySchema.parse(capability)}${query}`,
+ "GET",
+ undefined,
+ true,
+ ),
);
+ }
+
+ /** The bundle of the revision the meta names, and who shared it. */
+ async download(capability: string, revision?: string) {
+ const meta = await this.shared(capability, revision);
const bytes = await this.request(
- `${route}/bundle`,
+ `/api/v2/shared/${capability}/bundle?rev=${meta.revision.id}`,
"GET",
undefined,
true,
@@ -251,10 +268,9 @@ export class ShareClient {
bundle: shareBundleSchema.parse(
parseJsonText(new TextDecoder().decode(bytes)),
),
- attribution: {
- login: received.sender.login,
- sharedAt: received.sharedAt,
- },
+ attribution: { login: meta.sender.login, sharedAt: meta.sharedAt },
+ revision: meta.revision,
+ latest: meta.latest,
};
}
}
diff --git a/packages/review/src/sharing/host.test.ts b/packages/review/src/sharing/host.test.ts
index 4aeb8bae51..c4e287186f 100644
--- a/packages/review/src/sharing/host.test.ts
+++ b/packages/review/src/sharing/host.test.ts
@@ -133,7 +133,14 @@ it("reports download and checkout preparation, and opens the same copy when the
expect(await status()).toMatchObject({ stage: "downloading" });
await start();
expect(downloadSpy).toHaveBeenCalledTimes(1);
- download({ bundle, attribution: { login: "sender", sharedAt: 1 } });
+ const shareId = randomUUID();
+
+ download({
+ bundle,
+ attribution: { login: "sender", sharedAt: 1 },
+ revision: { id: shareId, version: 0, createdAt: 1 },
+ latest: shareId,
+ });
await vi.waitFor(async () =>
expect(await status()).toMatchObject({ stage: "fetching" }),
);
From 69f91be27f6ea1810d6855e53ae6e029a5410c49 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Sun, 1 Nov 2026 15:12:06 -0500
Subject: [PATCH 04/17] List every copy of a share link and record the version
each import made
Agent-Session: 64182dea-534e-45f1-8e28-c1e27a5d6bab
Agent-Session: 12a7a292-c657-465f-b5f3-c638d3396f22
Agent-Session: 61c94475-694a-4e46-8971-e0d703d3b344
Agent-Session: 2b04f44e-91c6-4568-8c57-c4a01b2828ad
Agent-Session: 196d7f0b-63a2-4186-84b0-5068df27bf9a
Agent-Session: b47f56d8-24aa-4d4f-95b3-4217b7ec9e00
Agent-Session: f2f8f3d8-5a50-4c15-8ad8-9c9308d4a110
Agent-Session: 226d831c-5d71-4f4d-8cdb-7b3c4f30284d
---
.../src/review-api/copied-board.test.ts | 46 ++++++++++++++-----
packages/review/src/review-api/store.ts | 31 +++++++++----
packages/review/src/sharing/host.ts | 2 +-
packages/review/src/sharing/import.test.ts | 1 +
packages/review/src/sharing/import.ts | 2 +-
5 files changed, 61 insertions(+), 21 deletions(-)
diff --git a/packages/review/src/review-api/copied-board.test.ts b/packages/review/src/review-api/copied-board.test.ts
index b9058a5369..dabae836b6 100644
--- a/packages/review/src/review-api/copied-board.test.ts
+++ b/packages/review/src/review-api/copied-board.test.ts
@@ -91,7 +91,8 @@ it("keeps a copied board's blocks, lenses and provenance, reading in its own rep
seed.document[2],
]);
expect(snapshot.lenses).toEqual(seed.lenses);
- expect(snapshot.shared).toEqual(seed.shared);
+ expect(snapshot.shared).toEqual({ ...seed.shared, importedVersion: 0 });
+ expect(snapshot.version).toBe(0);
expect(snapshot.origin?.branch).toBe("feature");
const { targetId } = await store.execute({
@@ -105,23 +106,46 @@ it("keeps a copied board's blocks, lenses and provenance, reading in its own rep
expect(targetId).toBe("block-13");
});
-it("finds a copy by its link until it is deleted", async () => {
- const { reviewId } = await copy();
+it("lists a link's copies newest first, Trash included, until each is deleted", async () => {
+ const { reviewId: first } = await copy();
+ const { reviewId: second } = await copy();
+ const shared = { ...seed.shared, importedVersion: 0 };
- expect(store.copyOf("link")).toBe(reviewId);
- expect(store.copyOf("other")).toBeUndefined();
+ expect(store.copiesOf("link")).toEqual([
+ { reviewId: second, version: 0, shared },
+ { reviewId: first, version: 0, shared },
+ ]);
+ expect(store.copiesOf("other")).toEqual([]);
+
+ await store.execute({
+ operation: {
+ type: "edit",
+ reviewId: first,
+ edit: { type: "insert", content: { type: "divider" } },
+ },
+ });
+ expect(store.copiesOf("link")[1]).toEqual({
+ reviewId: first,
+ version: 1,
+ shared,
+ });
await store.execute(
- { operation: { type: "lifecycle", state: "trash", reviewId } },
+ { operation: { type: "lifecycle", state: "trash", reviewId: first } },
undefined,
{ kind: "person" },
);
- expect(store.copyOf("link")).toBe(reviewId);
+ expect(store.copiesOf("link").map((c) => c.reviewId)).toEqual([
+ second,
+ first,
+ ]);
- await store.execute({ operation: { type: "delete", reviewId } }, undefined, {
- kind: "person",
- });
- expect(store.copyOf("link")).toBeUndefined();
+ await store.execute(
+ { operation: { type: "delete", reviewId: first } },
+ undefined,
+ { kind: "person" },
+ );
+ expect(store.copiesOf("link").map((c) => c.reviewId)).toEqual([second]);
});
it("still checks the images of a board made from scratch", async () => {
diff --git a/packages/review/src/review-api/store.ts b/packages/review/src/review-api/store.ts
index 47ae2596d5..827956a6ef 100644
--- a/packages/review/src/review-api/store.ts
+++ b/packages/review/src/review-api/store.ts
@@ -362,6 +362,10 @@ export interface SharedProvenance {
sharedAt?: number;
cloneUrl?: string;
key?: string;
+ /** The link revision the board was made or last refreshed from. */
+ revisionId?: string;
+ /** The version that import wrote; a later version is the receiver's edit. */
+ importedVersion?: number;
}
/** Content a new board starts with, as another board left it. */
@@ -1110,16 +1114,24 @@ export class ReviewStore {
return run;
}
- /** The board copied from a share link, until it is deleted. */
- copyOf(key: string): string | undefined {
- const row = this.db
+ /** The boards copied from a share link, newest first, until each is deleted. */
+ copiesOf(
+ key: string,
+ ): { reviewId: string; version: number; shared: SharedProvenance }[] {
+ return this.db
.prepare(
- `SELECT reviews.id FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
- WHERE CASE WHEN json_valid(versions.snapshot) THEN json_extract(versions.snapshot,'$.shared.key') END=?`,
+ `SELECT reviews.id, reviews.version, json_extract(versions.snapshot,'$.shared') AS shared
+ FROM reviews JOIN versions ON versions.review_id=reviews.id AND versions.version=reviews.version
+ WHERE CASE WHEN json_valid(versions.snapshot) THEN json_extract(versions.snapshot,'$.shared.key') END=?
+ ORDER BY reviews.rowid DESC`,
)
- .get(key);
-
- return row && String(row.id);
+ .all(key)
+ .map((row) => ({
+ reviewId: String(row.id),
+ version: Number(row.version),
+ // SAFETY: the key matched, so `shared` is the object a seed wrote.
+ shared: JSON.parse(String(row.shared)) as SharedProvenance,
+ }));
}
/** Checkouts whose remote is cloneUrl, most recently used first. */
checkoutsOf(cloneUrl: string): string[] {
@@ -2104,6 +2116,9 @@ export class ReviewStore {
}));
warnings = [...(change.warnings ?? []), ...warnings];
+ if (change.copied && snapshot.shared)
+ snapshot.shared.importedVersion = snapshot.version;
+
const result: Result = {
...change.result,
reviewId: id,
diff --git a/packages/review/src/sharing/host.ts b/packages/review/src/sharing/host.ts
index 39c8e53968..528f5353c4 100644
--- a/packages/review/src/sharing/host.ts
+++ b/packages/review/src/sharing/host.ts
@@ -213,7 +213,7 @@ export function mountSharingHost(
// A failed import retries, and a deleted copy imports again.
if (!known || known === "ready" || known === "error") {
- const copy = store.copyOf(id);
+ const copy = store.copiesOf(id)[0]?.reviewId;
const status: ImportStatus = copy
? { stage: "ready", reviewId: copy }
diff --git a/packages/review/src/sharing/import.test.ts b/packages/review/src/sharing/import.test.ts
index 6a08b75070..4db9a9f265 100644
--- a/packages/review/src/sharing/import.test.ts
+++ b/packages/review/src/sharing/import.test.ts
@@ -110,6 +110,7 @@ it("copies a share into a board the recipient edits like their own", async () =>
sharedAt: 1,
cloneUrl: sender.repository.cloneUrl,
key: link,
+ importedVersion: 0,
});
expect(repositoryId).not.toBe(pins.repositoryId);
expect(JSON.stringify(copy.document)).not.toContain(pins.repositoryId);
diff --git a/packages/review/src/sharing/import.ts b/packages/review/src/sharing/import.ts
index 57aa24aea1..819f1af11d 100644
--- a/packages/review/src/sharing/import.ts
+++ b/packages/review/src/sharing/import.ts
@@ -244,7 +244,7 @@ export async function importShare(
attribution?: ShareAttribution,
): Promise {
const { store, data } = input;
- const existing = store.copyOf(key);
+ const existing = store.copiesOf(key)[0]?.reviewId;
if (existing) return existing;
const { bundle, snapshot, maps } = validateShareBundle(received);
From af84acdab8040ccb48c57e69d42b4a76076afdb7 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Sun, 1 Nov 2026 15:14:11 -0500
Subject: [PATCH 05/17] Refresh a share copy in place
Agent-Session: 64182dea-534e-45f1-8e28-c1e27a5d6bab
Agent-Session: 12a7a292-c657-465f-b5f3-c638d3396f22
Agent-Session: 61c94475-694a-4e46-8971-e0d703d3b344
Agent-Session: 2b04f44e-91c6-4568-8c57-c4a01b2828ad
Agent-Session: 196d7f0b-63a2-4186-84b0-5068df27bf9a
Agent-Session: b47f56d8-24aa-4d4f-95b3-4217b7ec9e00
Agent-Session: f2f8f3d8-5a50-4c15-8ad8-9c9308d4a110
Agent-Session: 226d831c-5d71-4f4d-8cdb-7b3c4f30284d
---
.../src/review-api/copied-board.test.ts | 48 ++++++++++++++
packages/review/src/review-api/store.ts | 62 +++++++++++++++++--
2 files changed, 106 insertions(+), 4 deletions(-)
diff --git a/packages/review/src/review-api/copied-board.test.ts b/packages/review/src/review-api/copied-board.test.ts
index dabae836b6..239193c405 100644
--- a/packages/review/src/review-api/copied-board.test.ts
+++ b/packages/review/src/review-api/copied-board.test.ts
@@ -148,6 +148,54 @@ it("lists a link's copies newest first, Trash included, until each is deleted",
expect(store.copiesOf("link").map((c) => c.reviewId)).toEqual([second]);
});
+it("refreshes a copy in place with a newer share, even in Trash, and refuses once it was edited", async () => {
+ const { reviewId } = await copy();
+
+ await store.execute(
+ { operation: { type: "lifecycle", state: "trash", reviewId } },
+ undefined,
+ { kind: "person" },
+ );
+
+ const next: Seed = {
+ ...structuredClone(seed),
+ document: [
+ { id: "markdown-3", type: "markdown", markdown: "Why, revised" },
+ ],
+ shared: { ...seed.shared, revisionId: "r2", sharedAt: 2 },
+ };
+
+ const create = {
+ operation: {
+ type: "create",
+ title: "Copy v2",
+ target: { kind: "commits", ...pins },
+ },
+ };
+
+ const result = await store.refreshCopy(reviewId, 0, create, next);
+
+ expect(result).toMatchObject({ reviewId, version: 1 });
+
+ const snapshot = store.read(reviewId);
+
+ expect(snapshot).toMatchObject({
+ title: "Copy v2",
+ trashedAt: expect.any(String),
+ shared: {
+ revisionId: "r2",
+ sharedAt: 2,
+ key: "link",
+ importedVersion: 1,
+ },
+ });
+ expect(snapshot.document).toEqual(next.document);
+ expect(store.copiesOf("link").map((c) => c.reviewId)).toEqual([reviewId]);
+ await expect(
+ store.refreshCopy(reviewId, 0, create, next),
+ ).rejects.toMatchObject({ status: 409 });
+});
+
it("still checks the images of a board made from scratch", async () => {
const { reviewId } = await store.execute({
operation: {
diff --git a/packages/review/src/review-api/store.ts b/packages/review/src/review-api/store.ts
index 827956a6ef..e7c27ee046 100644
--- a/packages/review/src/review-api/store.ts
+++ b/packages/review/src/review-api/store.ts
@@ -1612,6 +1612,51 @@ export class ReviewStore {
author,
).then((source) => this.applyPreparedCommand(source, initial, author));
+ return this.track(prepared);
+ }
+
+ /** Replaces a share copy's content with a newer revision of its link: same board id, Trash state kept.
+ * Refuses (409) once the board has moved past `baseVersion`. */
+ refreshCopy(
+ reviewId: string,
+ baseVersion: number,
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Command boundary: commandSchema.parse below rejects malformed input before mutation.
+ create: unknown,
+ seed: Seed,
+ ): Promise {
+ if (this.closing)
+ return Promise.reject(new Error("Review store is closing."));
+ const command = commandSchema.parse(create);
+ const op = command.operation;
+
+ if (
+ op.type !== "create" ||
+ op.pullRequestUrl ||
+ op.parentId ||
+ op.dependsOn ||
+ op.projectId
+ )
+ throw new ReviewInputError(
+ "A refresh takes a create command with a title and target alone.",
+ );
+ const person: Author = { kind: "person" };
+
+ return this.track(
+ this.prepareCommand(
+ command,
+ undefined,
+ Promise.resolve(undefined),
+ person,
+ ).then((source) =>
+ this.applyPreparedCommand(source, seed, person, {
+ reviewId,
+ baseVersion,
+ }),
+ ),
+ );
+ }
+
+ private track(prepared: Promise) {
this.preparing.add(prepared);
void prepared
.finally(() => this.preparing.delete(prepared))
@@ -1702,6 +1747,8 @@ export class ReviewStore {
prepared: PreparedCommand,
initial: Seed | undefined,
author: Author,
+ /** A share copy whose content the create replaces in place. */
+ into?: { reviewId: string; baseVersion: number },
) {
const {
op,
@@ -1715,6 +1762,7 @@ export class ReviewStore {
return this.enqueue(async () => {
if (
+ !into &&
op.type === "create" &&
(op.pullRequestUrl
? op.reuseExisting !== false
@@ -1786,9 +1834,11 @@ export class ReviewStore {
fromTrash: true,
});
- const id = op.type === "create" ? randomUUID() : op.reviewId;
+ const id =
+ into?.reviewId ?? (op.type === "create" ? randomUUID() : op.reviewId);
- const previous = op.type === "create" ? undefined : this.read(id);
+ const previous =
+ op.type === "create" && !into ? undefined : this.read(id);
let snapshot: Snapshot =
op.type === "create"
@@ -2006,9 +2056,11 @@ export class ReviewStore {
restore: op.type === "restore",
repin: op.type === "set_target",
copied: initial !== undefined,
+ inPlace: into !== undefined,
+ baseVersion: into?.baseVersion,
projectLive: op.type !== "restore" && !resolvedTarget,
result: {
- ...(op.type === "create" && { created: true }),
+ ...(op.type === "create" && !into && { created: true }),
targetId: applied?.targetId ?? lensTarget?.targetId,
...(applied && { type: applied.type }),
...(lensTarget && { type: lensTarget.type }),
@@ -2051,6 +2103,8 @@ export class ReviewStore {
repin?: boolean;
/** A board made from another's content, whose images stayed behind. */
copied?: boolean;
+ /** A share copy refreshed in place, in Trash or not. */
+ inPlace?: boolean;
projectLive: boolean;
/** The ids a person's save gave new blocks, by client key. */
personIds?: Record;
@@ -2171,7 +2225,7 @@ export class ReviewStore {
previous
? () => {
// A board in Trash is read-only, whoever writes.
- this.assertWritable(id);
+ if (!change.inPlace) this.assertWritable(id);
if (change.baseVersion !== undefined)
assertBaseVersion(change.baseVersion, this.currentVersion(id));
From 1f66658db01b4867fdcbc82b88fe45049a7d1b02 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Sun, 1 Nov 2026 15:19:43 -0500
Subject: [PATCH 06/17] Open, refresh or copy a shared board by the revision
its link serves
Agent-Session: 64182dea-534e-45f1-8e28-c1e27a5d6bab
Agent-Session: 12a7a292-c657-465f-b5f3-c638d3396f22
Agent-Session: 61c94475-694a-4e46-8971-e0d703d3b344
Agent-Session: 2b04f44e-91c6-4568-8c57-c4a01b2828ad
Agent-Session: 196d7f0b-63a2-4186-84b0-5068df27bf9a
Agent-Session: b47f56d8-24aa-4d4f-95b3-4217b7ec9e00
Agent-Session: f2f8f3d8-5a50-4c15-8ad8-9c9308d4a110
Agent-Session: 226d831c-5d71-4f4d-8cdb-7b3c4f30284d
---
.../src/review-api/copied-board.test.ts | 29 +++++
packages/review/src/review-api/store.ts | 11 +-
packages/review/src/sharing/import.test.ts | 111 ++++++++++++----
packages/review/src/sharing/import.ts | 121 ++++++++++++++----
4 files changed, 223 insertions(+), 49 deletions(-)
diff --git a/packages/review/src/review-api/copied-board.test.ts b/packages/review/src/review-api/copied-board.test.ts
index 239193c405..b5b7c63de3 100644
--- a/packages/review/src/review-api/copied-board.test.ts
+++ b/packages/review/src/review-api/copied-board.test.ts
@@ -196,6 +196,35 @@ it("refreshes a copy in place with a newer share, even in Trash, and refuses onc
).rejects.toMatchObject({ status: 409 });
});
+it("refreshes only a copy of the same link", async () => {
+ const { reviewId } = await copy();
+
+ const { reviewId: own } = await store.execute({
+ operation: {
+ type: "create",
+ title: "Own",
+ target: { kind: "commits", ...pins },
+ },
+ });
+
+ const create = {
+ operation: {
+ type: "create",
+ title: "Copy v2",
+ target: { kind: "commits", ...pins },
+ },
+ };
+
+ const other = { ...seed, shared: { ...seed.shared, key: "other" } };
+
+ await expect(store.refreshCopy(own, 0, create, seed)).rejects.toMatchObject({
+ status: 400,
+ });
+ await expect(
+ store.refreshCopy(reviewId, 0, create, other),
+ ).rejects.toMatchObject({ status: 400 });
+});
+
it("still checks the images of a board made from scratch", async () => {
const { reviewId } = await store.execute({
operation: {
diff --git a/packages/review/src/review-api/store.ts b/packages/review/src/review-api/store.ts
index e7c27ee046..805ee32f60 100644
--- a/packages/review/src/review-api/store.ts
+++ b/packages/review/src/review-api/store.ts
@@ -1840,6 +1840,14 @@ export class ReviewStore {
const previous =
op.type === "create" && !into ? undefined : this.read(id);
+ if (
+ into &&
+ (!previous?.shared?.key || previous.shared.key !== initial?.shared?.key)
+ )
+ throw new ReviewInputError(
+ "Only a copy of the same share link refreshes in place.",
+ );
+
let snapshot: Snapshot =
op.type === "create"
? createdSnapshot(
@@ -2225,7 +2233,8 @@ export class ReviewStore {
previous
? () => {
// A board in Trash is read-only, whoever writes.
- if (!change.inPlace) this.assertWritable(id);
+ if (change.inPlace) this.assertExists(id);
+ else this.assertWritable(id);
if (change.baseVersion !== undefined)
assertBaseVersion(change.baseVersion, this.currentVersion(id));
diff --git a/packages/review/src/sharing/import.test.ts b/packages/review/src/sharing/import.test.ts
index 4db9a9f265..d0bba5c05c 100644
--- a/packages/review/src/sharing/import.test.ts
+++ b/packages/review/src/sharing/import.test.ts
@@ -13,6 +13,8 @@ import { createShareFixture } from "../../test/fixtures/share/create.js";
import { exportShare } from "./export.js";
import {
importShare,
+ planShareCopy,
+ receiveShare,
shareKey,
sharePresentationSchema,
sharedSnapshotSchema,
@@ -30,6 +32,13 @@ afterEach(async () => {
const key = () =>
shareKey("https://app.dev.fast", randomBytes(32).toString("base64url"));
+const [R1old, R1, R2, R3] = ["r0", "r1", "r2", "r3"];
+
+const served = (id: string, latest: string) => ({
+ revision: { id, version: 1, createdAt: 1 },
+ latest,
+});
+
/** A sender's share, with a block pinned to commits of its own, and a
* recipient with an empty store. */
async function share() {
@@ -91,7 +100,7 @@ it("copies a share into a board the recipient edits like their own", async () =>
{ ...recipient, fetchRepository },
link,
bundle,
- { login: "ada", sharedAt: 1 },
+ { login: "ada", sharedAt: 1, revisionId: R1 },
);
await rename(sender.repo, `${sender.repo}-gone`);
@@ -110,6 +119,7 @@ it("copies a share into a board the recipient edits like their own", async () =>
sharedAt: 1,
cloneUrl: sender.repository.cloneUrl,
key: link,
+ revisionId: R1,
importedVersion: 0,
});
expect(repositoryId).not.toBe(pins.repositoryId);
@@ -160,40 +170,97 @@ it("copies a share into a board the recipient edits like their own", async () =>
expect(recipient.store.read(reviewId).title).toBe("Mine now");
});
-it("opens the copy a link already made, even from Trash", async () => {
+it("plans the three rules", () => {
+ const c = (over: object) => ({
+ reviewId: "a",
+ version: 3,
+ shared: { key: "k", revisionId: R1, importedVersion: 3, ...over },
+ });
+
+ expect(
+ planShareCopy([c({})], {
+ revision: { id: R1, version: 4, createdAt: 1 },
+ latest: R2,
+ }),
+ ).toEqual({ kind: "open", reviewId: "a" });
+ expect(planShareCopy([c({})], served(R2, R2))).toEqual({
+ kind: "refresh",
+ reviewId: "a",
+ version: 3,
+ });
+ expect(planShareCopy([{ ...c({}), version: 4 }], served(R2, R2))).toEqual({
+ kind: "import",
+ });
+ expect(planShareCopy([c({})], served(R1old, R2))).toEqual({
+ kind: "import",
+ });
+ expect(
+ planShareCopy(
+ [{ reviewId: "l", version: 0, shared: { key: "k" } }],
+ served(R2, R2),
+ ),
+ ).toEqual({ kind: "refresh", reviewId: "l", version: 0 });
+ expect(
+ planShareCopy(
+ [{ reviewId: "l", version: 1, shared: { key: "k" } }],
+ served(R2, R2),
+ ),
+ ).toEqual({ kind: "import" });
+});
+
+it("reopens, refreshes in place from Trash, and copies beside an edited board", async () => {
const { bundle, recipient, fetchRepository } = await share();
+ const input = { ...recipient, fetchRepository };
const link = key();
- const reviewId = await importShare(
- { ...recipient, fetchRepository },
- link,
+ const download = vi.fn(async () => ({
bundle,
- );
+ attribution: { login: "ada", sharedAt: 1 },
+ }));
+
+ const a = await receiveShare(input, link, served(R1, R1), download);
+
+ expect(await receiveShare(input, link, served(R1, R1), download)).toBe(a);
+ expect(download).toHaveBeenCalledTimes(1);
await recipient.store.execute(
- { operation: { type: "lifecycle", state: "trash", reviewId } },
+ { operation: { type: "lifecycle", state: "trash", reviewId: a } },
undefined,
{ kind: "person" },
);
- expect(
- await importShare({ ...recipient, fetchRepository }, link, bundle),
- ).toBe(reviewId);
- expect(fetchRepository).toHaveBeenCalledTimes(1);
- expect(recipient.store.list().map((board) => board.reviewId)).toEqual([
- reviewId,
- ]);
+ expect(await receiveShare(input, link, served(R2, R2), download)).toBe(a);
+ expect(recipient.store.read(a)).toMatchObject({
+ version: 1,
+ trashedAt: expect.any(String),
+ shared: { revisionId: R2, importedVersion: 1 },
+ });
- const other = await importShare(
- { ...recipient, fetchRepository },
- key(),
- bundle,
+ await recipient.store.execute(
+ { operation: { type: "lifecycle", state: "active", reviewId: a } },
+ undefined,
+ { kind: "person" },
);
-
- expect(other).not.toBe(reviewId);
- expect(recipient.store.read(other).pins!.repositoryId).toBe(
- recipient.store.read(reviewId).pins!.repositoryId,
+ await recipient.store.execute(
+ {
+ operation: {
+ type: "edit",
+ reviewId: a,
+ edit: {
+ type: "insert",
+ content: { type: "markdown", markdown: "Mine" },
+ },
+ },
+ },
+ undefined,
+ { kind: "person" },
);
+
+ const b = await receiveShare(input, link, served(R3, R3), download);
+
+ expect(b).not.toBe(a);
+ expect(recipient.store.copiesOf(link).map((x) => x.reviewId)).toEqual([b, a]);
+ expect(fetchRepository).toHaveBeenCalledTimes(1);
});
it("fetches the shared commits into the recipient's own checkout", async () => {
diff --git a/packages/review/src/sharing/import.ts b/packages/review/src/sharing/import.ts
index 819f1af11d..0d5ae0a3eb 100644
--- a/packages/review/src/sharing/import.ts
+++ b/packages/review/src/sharing/import.ts
@@ -235,18 +235,85 @@ export interface ShareImport {
onStage?: (stage: "fetching" | "validating") => void;
}
-/** Imports a share as an ordinary board, once per link: the link's copy
- * comes back while it exists, even from Trash. */
+export const FRESH_IMPORT_VERSION = 0;
+
+/** The revision a link serves and its share's latest. */
+export interface ServedRevision {
+ revision: { id: string; version: number; createdAt: number };
+ latest: string;
+}
+
+export type ShareCopyPlan =
+ | { kind: "open"; reviewId: string }
+ | { kind: "refresh"; reviewId: string; version: number }
+ | { kind: "import" };
+
+/** A copy made from the served revision opens; an unedited copy takes the
+ * latest revision in place; anything else gets a new copy. */
+export function planShareCopy(
+ copies: ReturnType,
+ served: ServedRevision,
+): ShareCopyPlan {
+ const same = copies.find(
+ (copy) => copy.shared.revisionId === served.revision.id,
+ );
+
+ if (same) return { kind: "open", reviewId: same.reviewId };
+
+ const unedited =
+ served.revision.id === served.latest &&
+ copies.find(
+ (copy) =>
+ copy.version === (copy.shared.importedVersion ?? FRESH_IMPORT_VERSION),
+ );
+
+ return unedited
+ ? {
+ kind: "refresh",
+ reviewId: unedited.reviewId,
+ version: unedited.version,
+ }
+ : { kind: "import" };
+}
+
+/** Opens, refreshes or copies a link's board by the revision it serves. */
+export async function receiveShare(
+ input: ShareImport,
+ key: string,
+ served: ServedRevision,
+ download: () => Promise<{
+ bundle: ShareBundle;
+ attribution: ShareAttribution;
+ }>,
+): Promise {
+ const plan = planShareCopy(input.store.copiesOf(key), served);
+
+ if (plan.kind === "open") return plan.reviewId;
+ const { bundle, attribution } = await download();
+ const provenance = { ...attribution, revisionId: served.revision.id };
+
+ if (plan.kind === "import")
+ return importShare(input, key, bundle, provenance);
+
+ try {
+ return await importShare(input, key, bundle, provenance, plan);
+ } catch (error) {
+ // The board was edited since the plan read it.
+ if (error instanceof ReviewInputError && error.status === 409)
+ return importShare(input, key, bundle, provenance);
+ throw error;
+ }
+}
+
+/** Imports a share as an ordinary board, or into a copy of its link. */
export async function importShare(
input: ShareImport,
key: string,
received: ShareBundle,
- attribution?: ShareAttribution,
+ provenance?: ShareAttribution & { revisionId?: string },
+ into?: { reviewId: string; version: number },
): Promise {
const { store, data } = input;
- const existing = store.copiesOf(key)[0]?.reviewId;
-
- if (existing) return existing;
const { bundle, snapshot, maps } = validateShareBundle(received);
const cloneUrl = bundle.repository?.cloneUrl;
let repositoryId: string | undefined;
@@ -319,27 +386,29 @@ export async function importShare(
for (const [id, copy] of ids) document = document.replaceAll(id, copy);
- const { reviewId } = await store.execute(
- {
- operation: {
- type: "create",
- ...(snapshot.title && { title: snapshot.title }),
- ...(pins
- ? { target: { kind: "commits", ...pins } }
- : repositoryId && {
- target: { kind: "project", repositoryId },
- }),
- },
+ const create = {
+ operation: {
+ type: "create",
+ ...(snapshot.title && { title: snapshot.title }),
+ ...(pins
+ ? { target: { kind: "commits", ...pins } }
+ : repositoryId && {
+ target: { kind: "project", repositoryId },
+ }),
},
- {
- document: JSON.parse(document),
- lenses: snapshot.lenses,
- origin: snapshot.origin,
- shared: { ...attribution, cloneUrl, key },
- copiedFrom: snapshot.reviewId,
- },
- { kind: "person" },
- );
+ };
+
+ const seed = {
+ document: JSON.parse(document),
+ lenses: snapshot.lenses,
+ origin: snapshot.origin,
+ shared: { ...provenance, cloneUrl, key },
+ copiedFrom: snapshot.reviewId,
+ };
+
+ const { reviewId } = into
+ ? await store.refreshCopy(into.reviewId, into.version, create, seed)
+ : await store.execute(create, seed, { kind: "person" });
return reviewId;
}
From 10820ddad12731bc1238b569a45a2d14e6b55bab Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Sun, 1 Nov 2026 15:20:27 -0500
Subject: [PATCH 07/17] Keep a share's commits while another copy of its link
remains
Agent-Session: 64182dea-534e-45f1-8e28-c1e27a5d6bab
Agent-Session: 12a7a292-c657-465f-b5f3-c638d3396f22
Agent-Session: 61c94475-694a-4e46-8971-e0d703d3b344
Agent-Session: 2b04f44e-91c6-4568-8c57-c4a01b2828ad
Agent-Session: 196d7f0b-63a2-4186-84b0-5068df27bf9a
Agent-Session: b47f56d8-24aa-4d4f-95b3-4217b7ec9e00
Agent-Session: f2f8f3d8-5a50-4c15-8ad8-9c9308d4a110
Agent-Session: 226d831c-5d71-4f4d-8cdb-7b3c4f30284d
---
packages/review/src/review-api/http.ts | 4 +-
packages/review/src/sharing/import.test.ts | 62 ++++++++++++++++++++++
2 files changed, 64 insertions(+), 2 deletions(-)
diff --git a/packages/review/src/review-api/http.ts b/packages/review/src/review-api/http.ts
index 72e723a034..a6227d0067 100644
--- a/packages/review/src/review-api/http.ts
+++ b/packages/review/src/review-api/http.ts
@@ -2197,7 +2197,7 @@ export function createReviewApi(
...(threadId && { threadId }),
};
- // A copy's kept commits go when it is deleted for good.
+ // A link's kept commits go when its last copy is deleted for good.
const copy =
input.operation.type === "delete" && store.has(input.operation.reviewId)
? store.read(input.operation.reviewId)
@@ -2207,7 +2207,7 @@ export function createReviewApi(
const key = copy?.shared?.key;
const kept = copy && boardRepositoryId(copy);
- if (result.deleted && key && kept)
+ if (result.deleted && key && kept && !store.copiesOf(key).length)
await Promise.resolve()
.then(() => forgetSharedCommits(store.repositoryPath(kept), key))
.catch((error) =>
diff --git a/packages/review/src/sharing/import.test.ts b/packages/review/src/sharing/import.test.ts
index d0bba5c05c..2fb21f6050 100644
--- a/packages/review/src/sharing/import.test.ts
+++ b/packages/review/src/sharing/import.test.ts
@@ -263,6 +263,68 @@ it("reopens, refreshes in place from Trash, and copies beside an edited board",
expect(fetchRepository).toHaveBeenCalledTimes(1);
});
+it("keeps a share's commits until its link's last copy is deleted", async () => {
+ const { bundle, recipient, fetchRepository, pins } = await share();
+ const input = { ...recipient, fetchRepository };
+ const link = key();
+
+ const download = async () => ({
+ bundle,
+ attribution: { login: "ada", sharedAt: 1 },
+ });
+
+ const a = await receiveShare(input, link, served(R1, R1), download);
+
+ await recipient.store.execute(
+ {
+ operation: {
+ type: "edit",
+ reviewId: a,
+ edit: {
+ type: "insert",
+ content: { type: "markdown", markdown: "Mine" },
+ },
+ },
+ },
+ undefined,
+ { kind: "person" },
+ );
+
+ const b = await receiveShare(input, link, served(R2, R2), download);
+ const root = recipient.store.repositoryPath(
+ recipient.store.read(a).pins!.repositoryId,
+ );
+
+ const git = (...args: string[]) =>
+ execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
+
+ const app = createReviewApi(recipient.store, recipient.data);
+
+ const remove = async (reviewId: string) => {
+ await recipient.store.execute(
+ { operation: { type: "lifecycle", state: "trash", reviewId } },
+ undefined,
+ { kind: "person" },
+ );
+
+ const response = await app.request("/commands", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ operation: { type: "delete", reviewId } }),
+ });
+
+ expect(response.status).toBe(200);
+ };
+
+ await remove(a);
+ expect(git("rev-parse", `refs/review/shared/${link}/${pins.head}`)).toBe(
+ pins.head,
+ );
+
+ await remove(b);
+ expect(git("for-each-ref", `refs/review/shared/${link}`)).toBe("");
+});
+
it("fetches the shared commits into the recipient's own checkout", async () => {
const { root, sender, pins, bundle, recipient, fetchRepository } =
await share();
From 5c1f8688853e31aed98836b09a6888e37a166b5c Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Sun, 1 Nov 2026 15:23:50 -0500
Subject: [PATCH 08/17] Ask the share service which revision a link serves
before opening its copy
Agent-Session: 64182dea-534e-45f1-8e28-c1e27a5d6bab
Agent-Session: 12a7a292-c657-465f-b5f3-c638d3396f22
Agent-Session: 61c94475-694a-4e46-8971-e0d703d3b344
Agent-Session: 2b04f44e-91c6-4568-8c57-c4a01b2828ad
Agent-Session: 196d7f0b-63a2-4186-84b0-5068df27bf9a
Agent-Session: b47f56d8-24aa-4d4f-95b3-4217b7ec9e00
Agent-Session: f2f8f3d8-5a50-4c15-8ad8-9c9308d4a110
Agent-Session: 226d831c-5d71-4f4d-8cdb-7b3c4f30284d
---
packages/review/src/sharing/host.test.ts | 141 +++++++++++++++++++--
packages/review/src/sharing/host.ts | 108 +++++++++-------
packages/review/src/sharing/import.test.ts | 6 +-
3 files changed, 199 insertions(+), 56 deletions(-)
diff --git a/packages/review/src/sharing/host.test.ts b/packages/review/src/sharing/host.test.ts
index c4e287186f..8a21a0c46c 100644
--- a/packages/review/src/sharing/host.test.ts
+++ b/packages/review/src/sharing/host.test.ts
@@ -16,9 +16,27 @@ import { createShareFixture } from "../../test/fixtures/share/create.js";
import { ShareClient } from "./client.js";
import { exportShare } from "./export.js";
import { mountSharingHost } from "./host.js";
+import { shareKey } from "./import.js";
import { fetchPinnedRepository } from "./repository.js";
-afterEach(() => vi.unstubAllEnvs());
+afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+});
+
+const R = randomUUID();
+
+const capability = "a".repeat(43);
+
+const url = `https://app.dev.fast/s/${capability}`;
+
+const meta = {
+ sender: { login: "sender" },
+ sharedAt: 1,
+ size: 1,
+ revision: { id: R, version: 1, createdAt: 1 },
+ latest: R,
+};
it("exposes the device URL and code while login is pending and allows retry after failure", async () => {
const root = await mkdtemp(path.join(tmpdir(), "share-login-"));
@@ -100,6 +118,10 @@ it("reports download and checkout preparation, and opens the same copy when the
}),
);
+ const sharedSpy = vi
+ .spyOn(ShareClient.prototype, "shared")
+ .mockResolvedValue(meta);
+
let fetched!: () => void;
const gate = new Promise((resolve) => {
@@ -113,7 +135,6 @@ it("reports download and checkout preparation, and opens the same copy when the
await fetchPinnedRepository(target, fixture.repo, ...rest);
},
});
- const url = `https://app.dev.fast/s/${"a".repeat(43)}`;
const start = () =>
api.request("/sharing/import", {
@@ -132,14 +153,13 @@ it("reports download and checkout preparation, and opens the same copy when the
expect(await status()).toMatchObject({ stage: "downloading" });
await start();
- expect(downloadSpy).toHaveBeenCalledTimes(1);
- const shareId = randomUUID();
+ await vi.waitFor(() => expect(downloadSpy).toHaveBeenCalledTimes(1));
download({
bundle,
attribution: { login: "sender", sharedAt: 1 },
- revision: { id: shareId, version: 0, createdAt: 1 },
- latest: shareId,
+ revision: meta.revision,
+ latest: R,
});
await vi.waitFor(async () =>
expect(await status()).toMatchObject({ stage: "fetching" }),
@@ -158,17 +178,122 @@ it("reports download and checkout preparation, and opens the same copy when the
expect(fixture.store.read(copy).shared).toMatchObject({ login: "sender" });
expect((await start()).status).toBe(202);
- expect(await status()).toMatchObject({ stage: "ready", reviewId: copy });
+ await vi.waitFor(async () =>
+ expect(await status()).toMatchObject({ stage: "ready", reviewId: copy }),
+ );
+ expect(sharedSpy).toHaveBeenCalledTimes(2);
expect(downloadSpy).toHaveBeenCalledTimes(1);
+ expect(downloadSpy).toHaveBeenCalledWith(capability, R);
} finally {
fetched();
- downloadSpy.mockRestore();
await fixture.data.close();
fixture.store.close();
await rm(root, { recursive: true, force: true });
}
});
+async function importer() {
+ const root = await mkdtemp(path.join(tmpdir(), "share-import-"));
+ vi.stubEnv("DEV_REVIEW_HOME", root);
+ const fixture = await createShareFixture(root);
+ const bundle = await exportShare(fixture);
+ const api = new Hono();
+
+ mountSharingHost(api, fixture.store, fixture.data, {
+ fetchRepository: (target, _url, ...rest) =>
+ fetchPinnedRepository(target, fixture.repo, ...rest),
+ });
+
+ const start = async (link = url) =>
+ (
+ await api.request("/sharing/import", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ url: link }),
+ })
+ ).json();
+
+ const status = async (id: string) =>
+ (await api.request(`/sharing/import/${id}`)).json();
+
+ const close = async () => {
+ await fixture.data.close();
+ fixture.store.close();
+ await rm(root, { recursive: true, force: true });
+ };
+
+ return { bundle, start, status, close };
+}
+
+it("opens the newest copy when the share service cannot answer, else reports it unavailable", async () => {
+ const { bundle, start, status, close } = await importer();
+ const sharedSpy = vi.spyOn(ShareClient.prototype, "shared");
+
+ vi.spyOn(ShareClient.prototype, "download").mockResolvedValue({
+ bundle,
+ attribution: { login: "sender", sharedAt: 1 },
+ revision: meta.revision,
+ latest: R,
+ });
+
+ try {
+ sharedSpy.mockRejectedValue(new Error("Share request failed (404)."));
+
+ const { reviewId: id } = await start();
+
+ await vi.waitFor(async () =>
+ expect(await status(id)).toMatchObject({
+ stage: "error",
+ error:
+ "This share is unavailable, revoked, or needs a newer Whiteboard version.",
+ }),
+ );
+
+ sharedSpy.mockResolvedValue(meta);
+ await start();
+ await vi.waitFor(
+ async () => expect(await status(id)).toMatchObject({ stage: "ready" }),
+ { timeout: 10000 },
+ );
+
+ const { reviewId: copy } = await status(id);
+
+ sharedSpy.mockRejectedValue(new Error("Share request failed (404)."));
+ await start();
+ await vi.waitFor(async () =>
+ expect(await status(id)).toMatchObject({
+ stage: "ready",
+ reviewId: copy,
+ }),
+ );
+ expect(sharedSpy).toHaveBeenCalledTimes(3);
+ } finally {
+ await close();
+ }
+});
+
+it("keeps a revision link's import apart from the live link's", async () => {
+ const { start, status, close } = await importer();
+
+ const sharedSpy = vi
+ .spyOn(ShareClient.prototype, "shared")
+ .mockRejectedValue(new Error("Share request failed (404)."));
+
+ try {
+ const { reviewId } = await start(`${url}?rev=${R}`);
+
+ expect(reviewId).toBe(
+ `${shareKey("https://app.dev.fast", capability)}-${R}`,
+ );
+ await vi.waitFor(async () =>
+ expect(await status(reviewId)).toMatchObject({ stage: "error" }),
+ );
+ expect(sharedSpy).toHaveBeenCalledWith(capability, R);
+ } finally {
+ await close();
+ }
+});
+
it("reports a revoke through onRevoked", async () => {
const root = await mkdtemp(path.join(tmpdir(), "share-revoke-"));
vi.stubEnv("DEV_REVIEW_HOME", root);
diff --git a/packages/review/src/sharing/host.ts b/packages/review/src/sharing/host.ts
index 528f5353c4..81367c72dc 100644
--- a/packages/review/src/sharing/host.ts
+++ b/packages/review/src/sharing/host.ts
@@ -4,7 +4,7 @@ import path from "node:path";
import { Writable } from "node:stream";
import {
- type ShareBundle,
+ type SharedMeta,
parseShareLink,
shareBundleSchema,
shareIdSchema,
@@ -33,9 +33,10 @@ import {
whiteboardFileText,
} from "./file.js";
import {
- type ShareAttribution,
+ type ShareImport,
importShare,
needsRepository,
+ receiveShare,
shareKey,
} from "./import.js";
import {
@@ -201,54 +202,40 @@ export function mountSharingHost(
});
});
+ const shareInput = (onStage: ShareImport["onStage"]): ShareImport => ({
+ store,
+ data,
+ fetchRepository: options.fetchRepository,
+ cloneRepository: options.cloneRepository,
+ onStage,
+ });
+
const startImport = (
id: string,
initial: "downloading" | "reading",
- load: () => Promise<{
- bundle: ShareBundle;
- attribution?: ShareAttribution;
- }>,
+ receive: (onStage: ShareImport["onStage"]) => Promise,
) => {
const known = imports.get(id)?.stage;
- // A failed import retries, and a deleted copy imports again.
+ // A finished import asks again: its link may serve a newer revision.
if (!known || known === "ready" || known === "error") {
- const copy = store.copiesOf(id)[0]?.reviewId;
-
- const status: ImportStatus = copy
- ? { stage: "ready", reviewId: copy }
- : { stage: initial };
+ const status: ImportStatus = { stage: initial };
imports.set(id, status);
-
- if (!copy)
- importing = importing
- .then(async () => {
- const { bundle, attribution } = await load();
-
- status.reviewId = await importShare(
- {
- store,
- data,
- fetchRepository: options.fetchRepository,
- cloneRepository: options.cloneRepository,
- onStage: (stage) => {
- status.stage = stage;
- },
- },
- id,
- bundle,
- attribution,
- );
- status.stage = "ready";
- })
- .catch((error) => {
- status.stage = "error";
- status.error =
- error instanceof ReviewInputError
- ? error.message
- : "This share is unavailable, revoked, or needs a newer Whiteboard version.";
+ importing = importing
+ .then(async () => {
+ status.reviewId = await receive((stage) => {
+ status.stage = stage;
});
+ status.stage = "ready";
+ })
+ .catch((error) => {
+ status.stage = "error";
+ status.error =
+ error instanceof ReviewInputError
+ ? error.message
+ : "This share is unavailable, revoked, or needs a newer Whiteboard version.";
+ });
}
const { reviewId: _reviewId, ...status } = imports.get(id)!;
@@ -272,10 +259,16 @@ export function mountSharingHost(
);
});
+ const key = whiteboardFileKey(text);
+
return context.json(
- startImport(whiteboardFileKey(text), "reading", async () => ({
- bundle: parseWhiteboardFile(text),
- })),
+ startImport(
+ key,
+ "reading",
+ async (onStage) =>
+ store.copiesOf(key)[0]?.reviewId ??
+ importShare(shareInput(onStage), key, parseWhiteboardFile(text)),
+ ),
202,
);
}
@@ -292,11 +285,32 @@ export function mountSharingHost(
400,
);
+ const { origin, capability, revision } = parsed;
+ const key = shareKey(origin, capability);
+ const client = new ShareClient(origin, undefined, options.fetch);
+
return context.json(
startImport(
- shareKey(parsed.origin, parsed.capability),
+ revision ? `${key}-${revision}` : key,
"downloading",
- () => new ShareClient(parsed.origin).download(parsed.capability),
+ async (onStage) => {
+ let meta: SharedMeta;
+
+ try {
+ meta = await client.shared(capability, revision);
+ } catch (error) {
+ // Offline, the link's newest copy still opens.
+ const copy = store.copiesOf(key)[0]?.reviewId;
+
+ if (!copy) throw error;
+
+ return copy;
+ }
+
+ return receiveShare(shareInput(onStage), key, meta, () =>
+ client.download(capability, meta.revision.id),
+ );
+ },
),
202,
);
@@ -502,7 +516,9 @@ export function mountSharingPublisher(
"Set DEV_REVIEW_SHARE_TOKEN for CI, or run whiteboard login first.",
409,
);
- await new ShareClient(account.origin, account.token).revoke(shareId);
+ await new ShareClient(account.origin, account.token, options.fetch).revoke(
+ shareId,
+ );
options.onRevoked?.({ shareId });
return context.json({ shareId, revoked: true });
diff --git a/packages/review/src/sharing/import.test.ts b/packages/review/src/sharing/import.test.ts
index 2fb21f6050..754bf39b29 100644
--- a/packages/review/src/sharing/import.test.ts
+++ b/packages/review/src/sharing/import.test.ts
@@ -7,6 +7,7 @@ import path from "node:path";
import type { Block } from "@dev.fast/whiteboard-document";
import { createReviewApi } from "@review/review-api/http.js";
import { openLocalReviewStore } from "@review/review-api/local-data.js";
+import type { SharedProvenance } from "@review/review-api/store.js";
import { afterEach, expect, it, vi } from "vitest";
import { createShareFixture } from "../../test/fixtures/share/create.js";
@@ -171,7 +172,7 @@ it("copies a share into a board the recipient edits like their own", async () =>
});
it("plans the three rules", () => {
- const c = (over: object) => ({
+ const c = (over: Partial) => ({
reviewId: "a",
version: 3,
shared: { key: "k", revisionId: R1, importedVersion: 3, ...over },
@@ -213,7 +214,7 @@ it("reopens, refreshes in place from Trash, and copies beside an edited board",
const input = { ...recipient, fetchRepository };
const link = key();
- const download = vi.fn(async () => ({
+ const download = vi.fn[3]>(async () => ({
bundle,
attribution: { login: "ada", sharedAt: 1 },
}));
@@ -291,6 +292,7 @@ it("keeps a share's commits until its link's last copy is deleted", async () =>
);
const b = await receiveShare(input, link, served(R2, R2), download);
+
const root = recipient.store.repositoryPath(
recipient.store.read(a).pins!.repositoryId,
);
From d1cc209a83b530250fbc4059ad7ed22a13798ec1 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Sun, 1 Nov 2026 15:28:10 -0500
Subject: [PATCH 09/17] Keep one share link per board in the share popover
Agent-Session: 64182dea-534e-45f1-8e28-c1e27a5d6bab
Agent-Session: 12a7a292-c657-465f-b5f3-c638d3396f22
Agent-Session: 61c94475-694a-4e46-8971-e0d703d3b344
Agent-Session: 2b04f44e-91c6-4568-8c57-c4a01b2828ad
Agent-Session: 196d7f0b-63a2-4186-84b0-5068df27bf9a
Agent-Session: b47f56d8-24aa-4d4f-95b3-4217b7ec9e00
Agent-Session: f2f8f3d8-5a50-4c15-8ad8-9c9308d4a110
Agent-Session: 226d831c-5d71-4f4d-8cdb-7b3c4f30284d
---
packages/review/app/src/canvas-query.tsx | 3 +-
.../review/app/src/share-control.test.tsx | 14 +++++-
packages/review/app/src/share-control.tsx | 43 +++++++++++--------
3 files changed, 39 insertions(+), 21 deletions(-)
diff --git a/packages/review/app/src/canvas-query.tsx b/packages/review/app/src/canvas-query.tsx
index 5e6517ebd1..88b6aeda1d 100644
--- a/packages/review/app/src/canvas-query.tsx
+++ b/packages/review/app/src/canvas-query.tsx
@@ -34,8 +34,7 @@ export function createCanvasQueryClient(): QueryClient {
/** Desktop shares one client across boards, so board keys name the board. */
export const canvasQueryKeys = {
sharingAccount: () => ["sharing", "account"] as const,
- shareLink: (board: string, version: number | undefined) =>
- ["sharing", "link", board, version] as const,
+ shareLink: (board: string) => ["sharing", "link", board] as const,
traceList: (
board: string,
version: number | undefined,
diff --git a/packages/review/app/src/share-control.test.tsx b/packages/review/app/src/share-control.test.tsx
index af1dca61dd..efaf729439 100644
--- a/packages/review/app/src/share-control.test.tsx
+++ b/packages/review/app/src/share-control.test.tsx
@@ -93,7 +93,9 @@ function mount(options: {
);
return Response.json({
+ shareId: "6f1c2b0e-8e0a-4b8e-9a51-0d6f1e2c3b4a",
url: "https://app.dev.fast/s/capability",
+ version: JSON.parse(String(init!.body)).version,
});
},
);
@@ -289,7 +291,7 @@ it("falls back to sign-in when the stored login has expired, then uploads after
expect(container.textContent).not.toContain("expired");
});
-it("reuses the link for a version that was already shared and uploads again for a new one", async () => {
+it("keeps one link per board: reuses it while the version holds and uploads a new version behind it", async () => {
const harness = mount({ signedIn: true });
const { container } = harness;
@@ -299,6 +301,9 @@ it("reuses the link for a version that was already shared and uploads again for
await harness.settle();
expect(publishes(harness)).toHaveLength(1);
expect(container.querySelector("input")?.value).toContain("/s/capability");
+ expect(container.textContent).toContain(
+ "This link always opens the latest share of this board.",
+ );
await harness.click("Share review");
await harness.click("Share review");
expect(container.textContent).not.toContain("Uploading");
@@ -313,6 +318,13 @@ it("reuses the link for a version that was already shared and uploads again for
expect.objectContaining({ version: 4 }),
expect.objectContaining({ version: 5 }),
]);
+ expect(container.querySelector("input")?.value).toBe(
+ "https://app.dev.fast/s/capability",
+ );
+ await harness.click("Share review");
+ await harness.click("Share review");
+ await harness.settle();
+ expect(publishes(harness)).toHaveLength(2);
});
it("keeps one upload when the popover is reopened while it is in flight", async () => {
diff --git a/packages/review/app/src/share-control.tsx b/packages/review/app/src/share-control.tsx
index f506da262d..f10e3ecb4a 100644
--- a/packages/review/app/src/share-control.tsx
+++ b/packages/review/app/src/share-control.tsx
@@ -51,6 +51,12 @@ interface ShareTarget {
requestId: string;
}
+interface SharedLink {
+ shareId: string;
+ url: string;
+ version: number;
+}
+
const actionError = (error: Error | null) =>
error &&
(error instanceof ReviewApiError && error.status < 500
@@ -88,12 +94,11 @@ export function ShareControl({
const label = "Share review";
const tooltip = useTooltip(label);
- // Links created this canvas, so reopening a shared version skips the upload.
- const link = useQuery({
- queryKey: canvasQueryKeys.shareLink(
- context?.reviewId ?? "",
- target?.version,
- ),
+ // The board's one link; a new version uploads behind the same URL.
+ const linkKey = canvasQueryKeys.shareLink(context?.reviewId ?? "");
+
+ const link = useQuery({
+ queryKey: linkKey,
queryFn: skipToken,
staleTime: Infinity,
gcTime: Infinity,
@@ -108,16 +113,12 @@ export function ShareControl({
const publish = useMutation({
mutationFn: ({ version, requestId }: ShareTarget) =>
- context!.client.post<{ url: string }>("/sharing/publish", {
+ context!.client.post("/sharing/publish", {
reviewId: context!.reviewId,
version,
requestId,
}),
- onSuccess: ({ url }, { version }) =>
- queryClient.setQueryData(
- canvasQueryKeys.shareLink(context!.reviewId, version),
- url,
- ),
+ onSuccess: (shared) => queryClient.setQueryData(linkKey, shared),
onError: (error) => {
// The host forgot a stale login; show sign-in and upload again after it.
if (error instanceof ReviewApiError && error.status === 401)
@@ -156,10 +157,13 @@ export function ShareControl({
},
}));
+ const current = link && link.version === target?.version ? link : undefined;
+
// Upload once per opening; a failure waits for Retry or a new sign-in.
useEffect(() => {
- if (open && signedIn && target && publish.isIdle && !link) upload(target);
- }, [open, signedIn, target, publish.isIdle, link]);
+ if (open && signedIn && target && publish.isIdle && !current)
+ upload(target);
+ }, [open, signedIn, target, publish.isIdle, current]);
if (!context) return null;
@@ -209,17 +213,20 @@ export function ShareControl({
)}
{signedIn ? (
- link ? (
+ current ? (
<>
event.target.select()}
/>
-