From a3246b4dd2b47cb0b1c1cecc0a6db12aa668b09d Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Tue, 11 Aug 2026 10:57:44 -0400 Subject: [PATCH] feat: a public link can pin a version or follow the newest --- src/client/app.js | 174 ++++++++++++++++++++++++++-------------- src/http.ts | 19 ++++- src/schemas.ts | 13 +-- src/store.ts | 154 +++++++++++++++++++++++------------ tests/artifacts.test.ts | 57 ++++++++----- tests/server.test.ts | 49 ++++++++++- 6 files changed, 320 insertions(+), 146 deletions(-) diff --git a/src/client/app.js b/src/client/app.js index 10b70ef..0418585 100644 --- a/src/client/app.js +++ b/src/client/app.js @@ -1488,46 +1488,79 @@ import { mountSidebar } from "./sidebar.js"; btn.title = paneVersions.length + " versions"; } // ---- publishing ---------------------------------------------------------- - // A published version is readable by anyone holding its link, so the state is - // shown, not just available in a menu: the chip is how you can tell at a - // glance that what you're looking at is public. + // A document has at most ONE public link, and that link is either pinned to a + // version or following the newest. Private, frozen, live: three states, so + // the chip can say which one you are looking at instead of merely that a link + // exists somewhere. + var panePublication = null; // { token, mode, version } for the open document function publicLink(token) { return location.origin + "/s/" + token; } - function publicToken() { + /** Whether the link, as configured, serves the revision currently on screen. */ + function showingPublished() { var cur = currentVersion(); - return (cur && cur.token) || null; + if (!panePublication || !cur) return false; + if (panePublication.mode === "latest") return cur.version === newestVersion(); + return cur.version === panePublication.version; + } + function newestVersion() { + return paneVersions.length ? paneVersions[0].version : (paneDoc && paneDoc.version) || 0; } function paintPublicChip() { var chip = $("panePublic"); - chip.hidden = paneMode !== "doc" || !publicToken(); + chip.hidden = paneMode !== "doc" || !showingPublished(); chip.textContent = "Public"; - } - function setVersionToken(version, token) { - for (var i = 0; i < paneVersions.length; i++) - if (paneVersions[i].version === version) paneVersions[i].token = token; - paintPublicChip(); + chip.title = + panePublication && panePublication.mode === "latest" + ? "Public link — follows the newest version. Click to copy." + : "Public link — pinned to this version. Click to copy."; } function copyPublicLink() { - var token = publicToken(); - if (!token) return; + if (!panePublication) return; var chip = $("panePublic"); - navigator.clipboard.writeText(publicLink(token)).then(function () { + navigator.clipboard.writeText(publicLink(panePublication.token)).then(function () { + if (chip.hidden) return; // copied from the menu while viewing another revision chip.textContent = "Link copied"; setTimeout(paintPublicChip, 1400); }); } + /** The version the pane is acting on: the history knows best, the card will do. */ + function actingVersion() { + return currentVersion() || (paneDoc && paneDoc.version ? paneDoc : null); + } + function postPublication(version, mode) { + return fetch("/api/conversations/" + encodeURIComponent(convId) + "/publications", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: paneDoc.name, version: version, mode: mode }), + }) + .then(function (r) { + return r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status)); + }) + .then(function (d) { + panePublication = { token: d.token, mode: d.mode, version: d.version }; + paintPublicChip(); + return d; + }) + .catch(function () { + dialogs.confirm({ + title: "Could not publish", + body: "Nothing was changed. Try again in a moment.", + ok: "OK", + }); + }); + } /** - * Publish the version on screen. + * Publish the document, frozen at the revision on screen. * * Confirmed first, and worded plainly: this is the one action in the pane * that hands a document to people who were never in the conversation, and it - * cannot be undone for anyone who already has the link. + * cannot be undone for anyone who already has the link. Pinned is the default + * because a link that quietly starts serving something else is the surprise + * worth not defaulting to; following is one menu item away. */ function publishCurrent() { - // The history is the better source (it knows the version number for - // certain), but a card opened before that fetch lands carries one too. - var cur = currentVersion() || (paneDoc && paneDoc.version ? paneDoc : null); + var cur = actingVersion(); if (!cur || !convId) return; dialogs .confirm({ @@ -1535,36 +1568,42 @@ import { mountSidebar } from "./sidebar.js"; body: "Anyone with the link will be able to read " + (paneDoc.title || paneDoc.name) + - " without signing in. You can unpublish it at any time, but not un-share a link someone already has.", + " without signing in. The link stays on this version until you change it, and you can " + + "unpublish at any time — though not un-share a link someone already has.", ok: "Publish", }) .then(function (ok) { if (!ok) return; - return fetch("/api/conversations/" + encodeURIComponent(convId) + "/publications", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: cur.name, version: cur.version }), - }) - .then(function (r) { - return r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status)); - }) - .then(function (d) { - setVersionToken(cur.version, d.token); - copyPublicLink(); // published and on the clipboard, in one press - }) - .catch(function () { - dialogs.confirm({ - title: "Could not publish", - body: "The document was not published. Try again in a moment.", - ok: "OK", - }); - }); + return postPublication(cur.version, "pinned").then(function (d) { + if (d) copyPublicLink(); // published and on the clipboard, in one press + }); + }); + } + /** + * Switch what the existing link serves, keeping the link itself. + * + * The token survives a mode change, which is the point: "actually, let it + * follow along" should not mean re-sending a URL to everyone you gave it to. + */ + function setPublishMode(mode) { + var cur = actingVersion(); + if (!panePublication || !cur || !convId) return; + if (mode === "pinned") return void postPublication(cur.version, "pinned"); + dialogs + .confirm({ + title: "Let the link follow the newest version?", + body: + "Anyone holding the link will see whatever this document becomes, including revisions " + + "you have not written yet. The link itself does not change.", + ok: "Follow the newest", + }) + .then(function (ok) { + if (ok) postPublication(cur.version, "latest"); }); } function unpublishCurrent() { - var cur = currentVersion(); - var token = publicToken(); - if (!cur || !token || !convId) return; + if (!panePublication || !convId) return; + var token = panePublication.token; dialogs .confirm({ title: "Unpublish this document?", @@ -1581,11 +1620,40 @@ import { mountSidebar } from "./sidebar.js"; encodeURIComponent(token), { method: "DELETE" }, ).then(function () { - setVersionToken(cur.version, null); + panePublication = null; + paintPublicChip(); }); }); } + /** The pane's overflow menu, which depends on whether the document is shared. */ + function paneMenuItems() { + var items = []; + if (!panePublication) items.push({ label: "Publish\u2026", onClick: publishCurrent }); + else { + items.push({ label: "Copy public link", onClick: copyPublicLink }); + items.push( + panePublication.mode === "latest" + ? { + label: "Pin the link to this version", + onClick: function () { + setPublishMode("pinned"); + }, + } + : { + label: "Let the link follow the newest", + onClick: function () { + setPublishMode("latest"); + }, + }, + ); + items.push({ label: "Unpublish", danger: true, onClick: unpublishCurrent }); + } + items.push({ label: "Download as Markdown", onClick: downloadMd }); + items.push({ label: "Print / Save as PDF", onClick: printPane }); + return items; + } function loadPaneVersions(doc) { + panePublication = null; paintPaneVersions([]); if (!doc || !convId) return; var want = doc.sha256; @@ -1600,6 +1668,7 @@ import { mountSidebar } from "./sidebar.js"; }) .then(function (d) { if (!paneDoc || paneDoc.sha256 !== want) return; // opened something else meanwhile + panePublication = d.publication || null; paintPaneVersions(d.versions || []); }) .catch(function () {}); @@ -1634,6 +1703,7 @@ import { mountSidebar } from "./sidebar.js"; function closePane() { paneDoc = null; paneVersions = []; + panePublication = null; $("pane").hidden = true; $("paneBody").innerHTML = ""; $("app").classList.remove("pane-open", "pane-full"); @@ -1803,23 +1873,7 @@ import { mountSidebar } from "./sidebar.js"; if (!paneDoc) return; e.stopPropagation(); // the menu's own outside-click handler closes it var r = more.getBoundingClientRect(); - showContextMenu( - r.right, - r.bottom + 6, - publicToken() - ? [ - { label: "Copy public link", onClick: copyPublicLink }, - { label: "Unpublish", danger: true, onClick: unpublishCurrent }, - { label: "Download as Markdown", onClick: downloadMd }, - { label: "Print / Save as PDF", onClick: printPane }, - ] - : [ - { label: "Publish\u2026", onClick: publishCurrent }, - { label: "Download as Markdown", onClick: downloadMd }, - { label: "Print / Save as PDF", onClick: printPane }, - ], - { align: "right", trigger: more }, - ); + showContextMenu(r.right, r.bottom + 6, paneMenuItems(), { align: "right", trigger: more }); }); document.addEventListener("keydown", function (e) { if (e.key !== "Escape" || $("pane").hidden) return; diff --git a/src/http.ts b/src/http.ts index ac4f43a..90f5ff0 100644 --- a/src/http.ts +++ b/src/http.ts @@ -697,7 +697,15 @@ export function apiRoutes(deps: { store: Store; blobs: BlobStore; kick?: () => v const denied = guardConv(req, req.params.id); if (denied) return denied; const name = new URL(req.url).searchParams.get("name"); - if (name) return Response.json({ versions: store.artifactVersions(req.params.id, name) }); + if (name) { + // The link belongs to the DOCUMENT, so it is reported once beside the + // history rather than smeared across the version rows. + const pub = store.publicationFor(req.params.id, name); + return Response.json({ + versions: store.artifactVersions(req.params.id, name), + publication: pub && { token: pub.token, mode: pub.mode, version: pub.version }, + }); + } return Response.json({ artifacts: store.listArtifacts(req.params.id) }); }, }, @@ -711,9 +719,14 @@ export function apiRoutes(deps: { store: Store; blobs: BlobStore; kick?: () => v const denied = guardConv(req, req.params.id); if (denied) return Promise.resolve(denied); return withBody(PublishBody, (data) => { - const pub = store.publish(req.params.id, data.name, data.version); + const pub = store.publish(req.params.id, data.name, data.version, data.mode); if (!pub) return Response.json({ error: "not found" }, { status: 404 }); - return Response.json({ token: pub.token, url: `/s/${pub.token}` }); + return Response.json({ + token: pub.token, + url: `/s/${pub.token}`, + mode: pub.mode, + version: pub.version, + }); })(req); }, }, diff --git a/src/schemas.ts b/src/schemas.ts index 1274f64..b0657c5 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -78,14 +78,17 @@ export const ModelPatchBody = v.object({ export type ModelPatchBody = v.InferOutput; /** - * POST /api/conversations/:id/publications — put one version of a document - * behind a public link. The version is required rather than defaulted to the - * newest: publishing is a deliberate act about a specific set of bytes, and - * "whatever is newest" would make the link's contents depend on when it was - * pressed. + * POST /api/conversations/:id/publications — put a document behind a public + * link, or re-point the one it has. + * + * `version` is required even for a following link: it is the version the owner + * was looking at when they pressed the button, which is what makes "publish" + * mean something definite either way. `mode` defaults to "pinned" because the + * frozen link is the one that cannot surprise you later. */ export const PublishBody = v.object({ name: v.pipe(v.string(), v.minLength(1), v.maxLength(200)), version: v.pipe(v.number(), v.integer(), v.minValue(1)), + mode: v.optional(v.picklist(["pinned", "latest"]), "pinned"), }); export type PublishBody = v.InferOutput; diff --git a/src/store.ts b/src/store.ts index a550849..51d2602 100644 --- a/src/store.ts +++ b/src/store.ts @@ -146,8 +146,6 @@ export interface ArtifactVersion { size: number; messageId: string | null; createdAt: number; - /** The public link's token, when this version has been published. */ - token?: string | null; } /** A document at its newest version, with a count of how many exist. */ @@ -155,11 +153,16 @@ export interface ArtifactSummary extends ArtifactVersion { versions: number; } -/** A document version its owner has put behind a public link. */ +/** Whether a link is frozen at a version or follows the newest one. */ +export type PublicationMode = "pinned" | "latest"; + +/** A document its owner has put behind a public link. */ export interface Publication { token: string; conversationId: string; name: string; + mode: PublicationMode; + /** Pinned: the version served. Latest: the version currently newest. */ version: number; sha256: string; title: string | null; @@ -355,16 +358,28 @@ CREATE INDEX IF NOT EXISTS idx_artifacts_conv ON artifacts (conversation_id, cre -- Documents their owner has published to a public link. The token IS the -- capability: unguessable, and revoking the row revokes the link. -- --- A publication names one VERSION, and copies the bytes' address out of the --- artifact row rather than joining back to it at read time. Two reasons. A --- reader following a link should get the document that was shared, not whatever --- the conversation has rewritten it into since. And serving a public page must --- not have to touch conversation-owned tables at all — the sha256 is everything --- the public path needs, so the public path can't reach anything else. +-- ONE link per document, with a mode, rather than a link per version. A +-- document is either private, shared frozen, or shared live — three states a +-- person can hold in their head. A link per version would mean a document could +-- be public in several ways at once, and revoking "the" link would not be a +-- thing you could do. +-- +-- pinned — serves the stored version, whose bytes are copied into +-- sha256/mime/size here. The conversation can rewrite the document +-- freely; a reader keeps getting what was actually shared. +-- latest — serves whatever the newest version is at the moment of the +-- request. The stored version records what it was published from, +-- so the owner can see where the link started. +-- +-- A live link is the one case where serving a public request has to read the +-- artifacts table. That's still bounded by the token: it resolves the newest +-- version OF THE DOCUMENT THE PUBLICATION NAMES, and nothing else is reachable +-- from a token, correct or guessed. CREATE TABLE IF NOT EXISTS publications ( token TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, name TEXT NOT NULL, + mode TEXT NOT NULL DEFAULT 'pinned', version INTEGER NOT NULL, sha256 TEXT NOT NULL, title TEXT, @@ -372,10 +387,10 @@ CREATE TABLE IF NOT EXISTS publications ( size INTEGER NOT NULL, created_at INTEGER NOT NULL ); --- One link per version: publishing twice hands back the link that already --- exists rather than minting a second one nobody can keep track of. +-- One link per document: publishing again re-points the link that exists +-- instead of minting a second one nobody can keep track of. CREATE UNIQUE INDEX IF NOT EXISTS idx_publications_doc - ON publications (conversation_id, name, version); + ON publications (conversation_id, name); CREATE INDEX IF NOT EXISTS idx_publications_conv ON publications (conversation_id); -- Auth sessions (indiko OAuth). The cookie holds an opaque high-entropy id; the @@ -518,6 +533,24 @@ export class Store { } catch { // column already exists } + // Migration: publications began as one row per VERSION and are now one row + // per DOCUMENT with a mode. Reshaping the index means collapsing any doc + // that had several links down to its newest — the table is a day old and + // nothing depends on the discarded rows, so this is a cheap correction + // rather than a data migration. + try { + this.db.exec("ALTER TABLE publications ADD COLUMN mode TEXT NOT NULL DEFAULT 'pinned'"); + this.db.exec("DROP INDEX IF EXISTS idx_publications_doc"); + this.db.exec( + `DELETE FROM publications WHERE rowid NOT IN + (SELECT MAX(rowid) FROM publications GROUP BY conversation_id, name)`, + ); + this.db.exec( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_publications_doc ON publications (conversation_id, name)", + ); + } catch { + // already reshaped + } // Index the foreign key so the gallery's per-project chat COUNT, the // project-detail chat list, and unfiling on delete don't scan every // conversation. Created after the ALTER so the column exists. @@ -894,20 +927,12 @@ export class Store { ]; } - /** - * Every version of one document, newest first — each carrying its public - * token when that version has been published, so a reader of this list can - * tell which revisions are shared without a second query per row. - */ + /** Every version of one document, newest first. */ artifactVersions(conversationId: string, name: string): ArtifactVersion[] { return this.db .prepare( - `SELECT a.name, a.version, a.sha256, a.title, a.mime, a.size, - a.message_id AS messageId, a.created_at AS createdAt, p.token - FROM artifacts a - LEFT JOIN publications p - ON p.conversation_id = a.conversation_id AND p.name = a.name AND p.version = a.version - WHERE a.conversation_id = ? AND a.name = ? ORDER BY a.version DESC`, + `SELECT name, version, sha256, title, mime, size, message_id AS messageId, created_at AS createdAt + FROM artifacts WHERE conversation_id = ? AND name = ? ORDER BY version DESC`, ) .all(conversationId, name) as ArtifactVersion[]; } @@ -935,40 +960,50 @@ export class Store { // ---- publications ------------------------------------------------------ /** - * Put one version of a document behind a public link, or hand back the link - * it already has. + * Put a document behind a public link, or re-point the one it already has. * - * Idempotent on purpose: "publish" is a state the document is in, not an - * event, so pressing it twice should not scatter two live links for the same - * bytes — one of which the owner would never think to revoke. + * "Published" is a state a document is in, not an event, so this is an upsert + * keyed on the document: pressing Publish again from a different version + * moves the existing link rather than scattering a second one the owner would + * never think to revoke. The token survives, which is what makes "pin it to + * this version instead" and "let it follow the newest" changes to a link + * people already have, rather than a new link to re-send. */ - publish(conversationId: string, name: string, version: number): Publication | null { + publish( + conversationId: string, + name: string, + version: number, + mode: PublicationMode = "pinned", + ): Publication | null { const doc = this.getArtifact(conversationId, name, version); if (!doc) return null; - const existing = this.publicationFor(conversationId, name, version); - if (existing) return existing; - const token = randomUUID().replace(/-/g, ""); + const existing = this.publicationFor(conversationId, name); const row: Publication = { - token, + token: existing?.token ?? randomUUID().replace(/-/g, ""), conversationId, name: doc.name, + mode, version: doc.version, sha256: doc.sha256, title: doc.title, mime: doc.mime, size: doc.size, - createdAt: Date.now(), + createdAt: existing?.createdAt ?? Date.now(), }; this.db .prepare( `INSERT INTO publications - (token, conversation_id, name, version, sha256, title, mime, size, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (token, conversation_id, name, mode, version, sha256, title, mime, size, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(conversation_id, name) DO UPDATE SET + mode = excluded.mode, version = excluded.version, sha256 = excluded.sha256, + title = excluded.title, mime = excluded.mime, size = excluded.size`, ) .run( row.token, row.conversationId, row.name, + row.mode, row.version, row.sha256, row.title, @@ -976,31 +1011,46 @@ export class Store { row.size, row.createdAt, ); - return row; + return this.getPublication(row.token); } - /** A public document by its token — the only lookup the public path needs. */ + /** + * A public document by its token — the only lookup the public path needs. + * + * A live publication is resolved here rather than at every call site, so + * "which bytes does this link serve" has exactly one answer in the codebase. + * A pinned one is already the answer. + */ getPublication(token: string): Publication | null { - return ( - (this.db - .prepare( - `SELECT token, conversation_id AS conversationId, name, version, sha256, title, mime, size, - created_at AS createdAt - FROM publications WHERE token = ?`, - ) - .get(token) as Publication | null) ?? null - ); + const row = this.readPublication("token = ?", token); + if (!row || row.mode !== "latest") return row; + const newest = this.getArtifact(row.conversationId, row.name); + if (!newest) return row; // nothing newer to serve; the stored copy stands + return { + ...row, + version: newest.version, + sha256: newest.sha256, + title: newest.title, + mime: newest.mime, + size: newest.size, + }; + } + + /** The link a document has, if any — resolved the same way as by token. */ + publicationFor(conversationId: string, name: string): Publication | null { + const row = this.readPublication("conversation_id = ? AND name = ?", conversationId, name); + return row ? this.getPublication(row.token) : null; } - publicationFor(conversationId: string, name: string, version: number): Publication | null { + private readPublication(where: string, ...args: unknown[]): Publication | null { return ( (this.db .prepare( - `SELECT token, conversation_id AS conversationId, name, version, sha256, title, mime, size, - created_at AS createdAt - FROM publications WHERE conversation_id = ? AND name = ? AND version = ?`, + `SELECT token, conversation_id AS conversationId, name, mode, version, sha256, title, + mime, size, created_at AS createdAt + FROM publications WHERE ${where}`, ) - .get(conversationId, name, version) as Publication | null) ?? null + .get(...(args as [])) as Publication | null) ?? null ); } diff --git a/tests/artifacts.test.ts b/tests/artifacts.test.ts index 8967261..6bd03af 100644 --- a/tests/artifacts.test.ts +++ b/tests/artifacts.test.ts @@ -125,30 +125,51 @@ test("a conversation with no files lists none", () => { // ---- publishing ------------------------------------------------------------ -test("publishing a version mints one link, and pressing it again returns the same one", () => { +test("publishing mints one link per DOCUMENT, and publishing again re-points it", () => { const store = fresh(); record(store, "c1", "report.md", "a".repeat(64), "Draft"); record(store, "c1", "report.md", "b".repeat(64), "Final"); const first = store.publish("c1", "report.md", 2)!; expect(first.token).toMatch(/^[0-9a-f]{32}$/); - expect(first).toMatchObject({ name: "report.md", version: 2, sha256: "b".repeat(64) }); - // Idempotent: publishing twice must not scatter two live links for one document. - expect(store.publish("c1", "report.md", 2)!.token).toBe(first.token); - - // A different version is a different document to share, so it gets its own. - const older = store.publish("c1", "report.md", 1)!; - expect(older.token).not.toBe(first.token); - expect(older.sha256).toBe("a".repeat(64)); + expect(first).toMatchObject({ mode: "pinned", version: 2, sha256: "b".repeat(64) }); + + // Publishing an older version moves the SAME link rather than minting a + // second one: a document is public in one way at a time. + const repinned = store.publish("c1", "report.md", 1)!; + expect(repinned.token).toBe(first.token); + expect(repinned).toMatchObject({ version: 1, sha256: "a".repeat(64) }); + expect(store.publicationFor("c1", "report.md")!.token).toBe(first.token); }); -test("publishing pins the bytes, so a later rewrite doesn't change what was shared", () => { +test("a pinned link keeps serving what was shared; a following one moves on", () => { const store = fresh(); record(store, "c1", "report.md", "a".repeat(64), "Draft"); - const pub = store.publish("c1", "report.md", 1)!; + + const pinned = store.publish("c1", "report.md", 1, "pinned")!; record(store, "c1", "report.md", "b".repeat(64), "Rewritten"); + expect(store.getPublication(pinned.token)).toMatchObject({ + version: 1, + sha256: "a".repeat(64), + title: "Draft", + }); - expect(store.getPublication(pub.token)!.sha256).toBe("a".repeat(64)); + // Same token, different contract: the link now resolves at read time. + const live = store.publish("c1", "report.md", 1, "latest")!; + expect(live.token).toBe(pinned.token); + expect(store.getPublication(live.token)).toMatchObject({ + version: 2, + sha256: "b".repeat(64), + title: "Rewritten", + }); + + // …and keeps moving as the document does. + record(store, "c1", "report.md", "c".repeat(64), "Rewritten again"); + expect(store.getPublication(live.token)!.sha256).toBe("c".repeat(64)); + + // Pinning it again freezes it where the owner chose, link intact. + store.publish("c1", "report.md", 2, "pinned"); + expect(store.getPublication(live.token)).toMatchObject({ version: 2, sha256: "b".repeat(64) }); }); test("a version that doesn't exist cannot be published", () => { @@ -158,25 +179,17 @@ test("a version that doesn't exist cannot be published", () => { expect(store.publish("c1", "nope.md", 1)).toBeNull(); }); -test("version history carries the public token, and unpublishing takes it away", () => { +test("unpublishing needs the conversation, not just the token", () => { const store = fresh(); record(store, "c1", "report.md", "a".repeat(64)); - record(store, "c1", "report.md", "b".repeat(64)); const pub = store.publish("c1", "report.md", 1)!; - const versions = store.artifactVersions("c1", "report.md"); - expect(versions.map((v) => [v.version, v.token ?? null])).toEqual([ - [2, null], - [1, pub.token], - ]); - - // Scoped to the conversation: a token alone is not authority to revoke. expect(store.unpublish("other", pub.token)).toBe(false); expect(store.getPublication(pub.token)).not.toBeNull(); expect(store.unpublish("c1", pub.token)).toBe(true); expect(store.getPublication(pub.token)).toBeNull(); - expect(store.artifactVersions("c1", "report.md")[1]!.token ?? null).toBeNull(); + expect(store.publicationFor("c1", "report.md")).toBeNull(); }); test("deleting a conversation revokes the links it published", () => { diff --git a/tests/server.test.ts b/tests/server.test.ts index 0010851..577c076 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -954,11 +954,11 @@ test("publishing a document hands back a link, and unpublishing revokes it", asy expect(await raw.text()).toContain("Hello from a published doc."); expect(raw.headers.get("X-Content-Type-Options")).toBe("nosniff"); - // The owner's version list now shows which revision is public. - const { versions } = (await ( + // The owner's history now reports the document's link beside it. + const listed = (await ( await fetch(`${base}/api/conversations/${conv}/artifacts?name=report.md`) - ).json()) as { versions: Array<{ token: string | null }> }; - expect(versions[0]!.token).toBe(token); + ).json()) as { publication: { token: string; mode: string; version: number } | null }; + expect(listed.publication).toEqual({ token, mode: "pinned", version: 1 }); const gone = await fetch(`${base}/api/conversations/${conv}/publications/${token}`, { method: "DELETE", @@ -1006,3 +1006,44 @@ test("publishing a version that doesn't exist is a 404, not an empty link", asyn }); expect(res.status).toBe(404); }); + +test("a following link serves the newest version, and keeps its token when repinned", async () => { + const conv = "share-live"; + const write = async (text: string) => { + const bytes = new TextEncoder().encode(text); + const { sha256 } = await blobs.put(bytes); + store.recordBlob(sha256, "text/markdown", bytes.length); + store.recordArtifact({ + conversationId: conv, + name: "live.md", + sha256, + mime: "text/markdown", + size: bytes.length, + }); + }; + await write("first draft"); + + const publish = (version: number, mode: string) => + fetch(`${base}/api/conversations/${conv}/publications`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "live.md", version, mode }), + }).then((r) => r.json() as Promise<{ token: string; mode: string; version: number }>); + + const live = await publish(1, "latest"); + expect(live.mode).toBe("latest"); + expect(await (await fetch(`${base}/api/public/${live.token}/raw`)).text()).toBe("first draft"); + + // The document moves on; the link moves with it, without being re-shared. + await write("second draft"); + expect(await (await fetch(`${base}/api/public/${live.token}/raw`)).text()).toBe("second draft"); + const meta = (await (await fetch(`${base}/api/public/${live.token}`)).json()) as { + version: number; + }; + expect(meta.version).toBe(2); + + // Freezing it keeps the same URL — that's the point of one link per document. + const pinned = await publish(1, "pinned"); + expect(pinned.token).toBe(live.token); + expect(await (await fetch(`${base}/api/public/${live.token}/raw`)).text()).toBe("first draft"); +}); -- 2.51.2