From b1bf46b72432dd0ab89a729423e85cb6b98fef8a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 28 Oct 2026 14:14:46 +0000 Subject: [PATCH] Every share link has a six-digit document code for voice calls (v0.27.0) Anthony: "for voice calls people should be able to say 'open document number 4373' ... assign each shared document (view+) a unique code that lasts for the expiration date the user optionally selects ... make sure we don't have collisions ... 6". - shares.code: six digits (100000-999999, no leading zero to mishear), unique among links that still work (unique index; an expired link gives its code back), backfilled for existing links; it lives and dies with its link (revoke or expiry). - readm3.com/c/ and GET /api/v1/codes/ open the document read-only, no account, 20 lookups a minute per address (a code is short enough to guess). - Share dialog: "Link and code expire" (never / 1 / 7 / 30 / 90 days); the new link shows "Document code 437 301: on a call, say open document 437301"; existing links list their code and expiry. llms.txt documents the codes. - Test: the storage-failure editor test waits for the editor's document before typing (typing earlier raced the editor's load). Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- server/api.ts | 8 ++++++++ server/operations.ts | 9 +++++++-- server/store.ts | 40 ++++++++++++++++++++++++++++++++++++- server/test/sharing.test.ts | 33 ++++++++++++++++++++++++++++++ site/build.ts | 1 + site/server.ts | 2 +- site/sharing.ts | 17 ++++++++++++---- site/test/sync.spec.ts | 25 +++++++++++++++++++++++ site/test/viewer.spec.ts | 2 ++ site/viewer.ts | 8 ++++++-- src/cli.ts | 2 +- 12 files changed, 137 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 5a55fb4..957406f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/readm3", - "version": "0.26.3", + "version": "0.27.0", "description": "Markdown editing and sharing for people and teams. Web/PWA, CLI, API and MCP with version history, view/edit links, and a terminal reader.", "type": "module", "license": "MIT", diff --git a/server/api.ts b/server/api.ts index 54b3fc8..1e72d87 100644 --- a/server/api.ts +++ b/server/api.ts @@ -204,6 +204,14 @@ export function createApi( if (handled) return handled; throw new HttpError(404, "API route not found."); } + // A share link's six-digit voice code opens the document read-only. A code is short + // enough to guess, so lookups are few per address. + const code = path.match(/^codes\/(\d{6})$/); + if (code) { + if (request.method !== "GET") throw new HttpError(405, "Document codes are read-only."); + limit(`code:${ip}`, 20); + return json(store.viewByCode(code[1]!)); + } const shared = path.match(/^shared\/([A-Za-z0-9_-]{43})$/); if (shared) { const link = store.get<{ diff --git a/server/operations.ts b/server/operations.ts index e7119f3..faa9842 100644 --- a/server/operations.ts +++ b/server/operations.ts @@ -427,7 +427,7 @@ export function operate( case "shares_list": owner(); return store.all( - "SELECT id,label,role,versionId,expiresAt,createdAt FROM shares WHERE documentId=? ORDER BY createdAt DESC", + "SELECT id,label,role,versionId,expiresAt,createdAt,code FROM shares WHERE documentId=? ORDER BY createdAt DESC", docId(), ); case "shares_create": { @@ -460,8 +460,10 @@ export function operate( const normalized = expiresAt ? new Date(expiresAt).toISOString() : null; const token = secret(); const shareId = id(); + // The voice code lives as long as the link: "open document 437301" on a call. + const code = store.shareCode(); store.run( - "INSERT INTO shares VALUES (?,?,?,?,?,?,?,?)", + "INSERT INTO shares (id,documentId,tokenHash,versionId,role,label,expiresAt,createdAt,code) VALUES (?,?,?,?,?,?,?,?,?)", shareId, doc.id, checksum(token), @@ -470,6 +472,7 @@ export function operate( text(args.label ?? "Shared link", "Label"), normalized, now(), + code, ); // What the link exposes, so the caller can warn: the pinned version, or the latest. const shared = store.get<{ source: string }>( @@ -481,6 +484,8 @@ export function operate( role: access, versionId, expiresAt: normalized, + code, + codeUrl: `${origin}/c/${code}`, url: `${origin}/s/${token}`, // A PDF or image is base64: nothing in it reads as a secret, and scanning it only misfires. sensitive: scanSensitive(binaryType(doc.title) ? "" : shared?.source ?? ""), diff --git a/server/store.ts b/server/store.ts index ef268e4..3fd1401 100644 --- a/server/store.ts +++ b/server/store.ts @@ -1,5 +1,5 @@ import { Database } from "bun:sqlite"; -import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto"; import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { binaryType } from "../src/code.ts"; @@ -108,6 +108,44 @@ export class Store { const pasteColumns = this.db.query("PRAGMA table_info(pastes)").all() as { name: string }[]; if (!pasteColumns.some((column) => column.name === "language")) this.db.exec("ALTER TABLE pastes ADD COLUMN language TEXT"); + const shareColumns = this.db.query("PRAGMA table_info(shares)").all() as { name: string }[]; + if (!shareColumns.some((column) => column.name === "code")) + this.db.exec("ALTER TABLE shares ADD COLUMN code TEXT"); + this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS shares_code ON shares(code)"); + // Links made before codes existed get one now. + for (const { id: shareId } of this.all<{ id: string }>("SELECT id FROM shares WHERE code IS NULL AND (expiresAt IS NULL OR expiresAt>?)", now())) + this.run("UPDATE shares SET code=? WHERE id=?", this.shareCode(), shareId); + } + + /** + * A share link's voice code: six digits, said on a call ("open document 437301"), + * unique among the links that still work. An expired link gives its code back first. + * 900,000 codes (100000-999999, no leading zero to mishear); the unique index is the + * last word if two links are made at once. + */ + shareCode(): string { + this.run("UPDATE shares SET code=NULL WHERE code IS NOT NULL AND expiresAt IS NOT NULL AND expiresAt<=?", now()); + for (let attempt = 0; attempt < 50; attempt++) { + const code = String(randomInt(100_000, 1_000_000)); + if (!this.get("SELECT id FROM shares WHERE code=?", code)) return code; + } + throw new HttpError(503, "No free document code right now; try again."); + } + + /** What a voice code opens: the shared document, read-only, as its link would show it. */ + viewByCode(code: string) { + const link = this.get<{ documentId: string; versionId: string | null; role: string }>( + "SELECT documentId,versionId,role FROM shares WHERE code=? AND (expiresAt IS NULL OR expiresAt>?)", code, now(), + ); + if (!link) throw new HttpError(404, "No document has that code, or its link expired."); + const doc = this.get("SELECT * FROM documents WHERE id=?", link.documentId); + if (!doc) throw new HttpError(404, "No document has that code, or its link expired."); + const version = this.get>( + "SELECT v.*,COALESCE(u.displayName,'Link editor') AS author FROM versions v LEFT JOIN users u ON u.id=v.authorId WHERE v.documentId=? AND v.id=?", + doc.id, link.versionId || doc.currentVersion, + ); + if (!version) throw new HttpError(404, "Version not found."); + return { ...doc, ...(link.versionId ? { title: version.title as string } : {}), version, canEdit: false, canManage: false, sharedRole: "view", pinned: !!link.versionId, code }; } get(sql: string, ...args: (string | number | null)[]): T | null { return this.db.query(sql).get(...args) as T | null; diff --git a/server/test/sharing.test.ts b/server/test/sharing.test.ts index 9982755..9f74fc4 100644 --- a/server/test/sharing.test.ts +++ b/server/test/sharing.test.ts @@ -707,3 +707,36 @@ test("a shared image is its bytes as base64, up to 6 MB, never an empty file", a f.store.close(); } }); + +test("every share link gets a six-digit voice code that opens it read-only and dies with the link", async () => { + const f = fixture(); + try { + const owner = await f.account(); + const doc = (await owner.action("documents_create", { orgId: owner.org.id, title: "Plan.md", source: "# Ships Friday" })).data; + const link = (await owner.action("shares_create", { documentId: doc.id, role: "edit" })).data; + expect(link.code).toMatch(/^[1-9]\d{5}$/); + expect(link.codeUrl).toBe(`http://localhost/c/${link.code}`); + const opened = await f.call(`codes/${link.code}`); + expect(opened.status).toBe(200); + // An edit link's code still only reads. + expect(opened.data).toMatchObject({ id: doc.id, canEdit: false, canManage: false, code: link.code }); + expect(opened.data.version.source).toBe("# Ships Friday"); + expect((await owner.action("shares_list", { documentId: doc.id })).data[0].code).toBe(link.code); + // Unique among links. + const codes = new Set([link.code]); + for (let i = 0; i < 15; i++) codes.add((await owner.action("shares_create", { documentId: doc.id })).data.code); + expect(codes.size).toBe(16); + // Revoked: the code stops working. + await owner.action("shares_revoke", { documentId: doc.id, shareId: link.id }); + expect((await f.call(`codes/${link.code}`)).status).toBe(404); + // Expired: the code stops working and is freed for another link. + const soon = (await owner.action("shares_create", { documentId: doc.id, expiresAt: new Date(Date.now() + 60_000).toISOString() })).data; + f.store.run("UPDATE shares SET expiresAt=? WHERE id=?", new Date(Date.now() - 1000).toISOString(), soon.id); + expect((await f.call(`codes/${soon.code}`)).status).toBe(404); + f.store.shareCode(); + expect(f.store.get<{ code: string | null }>("SELECT code FROM shares WHERE id=?", soon.id)!.code).toBeNull(); + expect((await f.call("codes/12345")).status).not.toBe(200); + } finally { + f.store.close(); + } +}); diff --git a/site/build.ts b/site/build.ts index 7adfa9c..438afd4 100644 --- a/site/build.ts +++ b/site/build.ts @@ -457,6 +457,7 @@ terminal. - Desktop app: ${REPO}/releases/latest (the same editor for Linux as AppImage and Flatpak, macOS dmg, Windows installer); \`readm3 edit PATH...\` opens files and folders in it - Summaries: Summarize in ${SITE}/viewer, or POST /api/v1/summaries/document { path, text } signed in (10 free documents a week, then pay as you go) - Tables: CSV and TSV (also .tab, .psv) open as a sortable, filterable grid in the terminal reader (hqtui data grid), ${SITE}/viewer, the desktop app and paste links; \`readm3 --print data.csv\` prints an aligned table. Filter language: free text, col:text, col=v, col!=v, col>n, col<=n, !negate, #3 for the third column +- Document codes: every share link has a six-digit code that lasts as long as the link (optional expiry); ${SITE}/c/ or GET /api/v1/codes/ opens it read-only, no account (for voice: "open document 437301") - Ask AI: ${SITE}/talk, or Ask AI in ${SITE}/viewer: questions about a file, a folder or the workspace; POST /api/v1/ask { question, files: [{ path, text }], history? } signed in (10 free questions a day, then pay as you go) - Paste: ${SITE}/paste (anonymous pastes of any text, PDF or image behind an expiring secret link; CLI, curl, API and MCP examples) - Site: ${SITE} diff --git a/site/server.ts b/site/server.ts index 78897b0..03fd8cb 100644 --- a/site/server.ts +++ b/site/server.ts @@ -226,7 +226,7 @@ export function serveSite(options: { accounts?: Accounts; coinpay?: ConstructorP return (await api(new Request(target, { method: request.method, headers: request.headers }), ip))!; } - const sharedPage = /^\/(s|p)\/[A-Za-z0-9_-]{43}$/.test(url.pathname); + const sharedPage = /^\/(s|p)\/[A-Za-z0-9_-]{43}$/.test(url.pathname) || /^\/c\/\d{6}$/.test(url.pathname); const file = resolve(sharedPage ? "/viewer" : url.pathname); if (!file) { return new Response("Not found", { status: 404, headers: { "content-type": "text/plain" } }); diff --git a/site/sharing.ts b/site/sharing.ts index 856a331..149d1ec 100644 --- a/site/sharing.ts +++ b/site/sharing.ts @@ -47,6 +47,9 @@ export function pasteDialog(title: string, source: string) { }; } +/** "437301" as it is read out: "437 301". */ +const spokenCode = (code: string) => `${code.slice(0, 3)} ${code.slice(3)}`; + export async function publish( title: string, source: string, @@ -89,7 +92,7 @@ export async function sharing( ) { const [links, people, teams] = await Promise.all([ action< - { id: string; label: string; role: string; versionId: string | null }[] + { id: string; label: string; role: string; versionId: string | null; code: string | null; expiresAt: string | null }[] >("shares_list", { documentId: doc.id }), action<{ userId: string; username: string; role: string }[]>( "permissions_list", @@ -100,7 +103,7 @@ export async function sharing( }).catch(() => []), ]); const box = modal(`Share ${doc.title}`); - box.body.innerHTML = `

Viewers can read. Editors can save new versions. Only you and the super admin can change permissions or delete the file.

People

Organization and team access

Ownership

`; + box.body.innerHTML = `

Viewers can read. Editors can save new versions. Only you and the super admin can change permissions or delete the file.

People

Organization and team access

Ownership

`; const error = box.body.querySelector(".error")!; function bind(selector: string, fn: (data: FormData) => Promise, gate?: SensitiveGate) { const form = box.body.querySelector(selector)!; @@ -145,10 +148,12 @@ export async function sharing( const linkGate = sensitiveGate(linkForm.querySelector(".sensitive")!, linkForm.querySelector("button")!); linkGate.check(doc.version.source); bind("#link-form", async (data) => { - const link = await action<{ url: string; id: string }>("shares_create", { + const days = Number(data.get("expires") || 0); + const link = await action<{ url: string; id: string; code: string; codeUrl: string; expiresAt: string | null }>("shares_create", { documentId: doc.id, role: data.get("role"), versionId: data.get("version") || undefined, + expiresAt: days ? new Date(Date.now() + days * 86_400_000).toISOString() : undefined, }); const target = box.body.querySelector("#new-link")!; target.replaceChildren(); @@ -157,6 +162,10 @@ export async function sharing( "Copy this link now. For privacy, its secret is not stored on the server."; target.append(note); outputLink(target, link.url); + const voice = document.createElement("p"); + voice.className = "share-code"; + voice.textContent = `Document code ${spokenCode(link.code)}: on a call, say “open document ${link.code}”, or open ${link.codeUrl}. Read-only${link.expiresAt ? `, until ${new Date(link.expiresAt).toLocaleDateString()}` : ""}.`; + target.append(voice); smallAction( "Revoke this link", () => action("shares_revoke", { documentId: doc.id, shareId: link.id }), @@ -165,7 +174,7 @@ export async function sharing( }, linkGate); for (const link of links) { const row = document.createElement("p"); - row.textContent = `${link.label} · ${link.role}${link.versionId ? " · pinned version" : ""} `; + row.textContent = `${link.label} · ${link.role}${link.versionId ? " · pinned version" : ""}${link.code ? ` · code ${spokenCode(link.code)}` : ""}${link.expiresAt ? ` · until ${new Date(link.expiresAt).toLocaleDateString()}` : ""} `; smallAction( "Revoke", () => action("shares_revoke", { documentId: doc.id, shareId: link.id }), diff --git a/site/test/sync.spec.ts b/site/test/sync.spec.ts index 733d913..305a722 100644 --- a/site/test/sync.spec.ts +++ b/site/test/sync.spec.ts @@ -216,6 +216,31 @@ test("a file whose name sync cannot carry stays on this device and the rest stil await expect(page.locator('#file-list button[title="notes 10:44.md"]')).toBeVisible(); }); +test("a share link shows its six-digit document code, and readm3.com/c/ opens the document", async ({ page, context, browser }) => { + await register(context, username()); + await page.goto("/viewer"); + await setPassphrase(page); + await page.locator("#files-input").setInputFiles({ name: "Plan.md", mimeType: "text/markdown", buffer: Buffer.from("# Ships Friday\n") }); + await page.locator("#share").click(); + await page.getByRole("dialog", { name: "Save to your workspace" }).getByRole("button", { name: "Save online" }).click(); + await expect(page).toHaveURL(/\/viewer\?doc=/); + // Saving online goes straight on to its sharing settings. + const dialog = page.getByRole("dialog", { name: "Share Plan.md" }); + await dialog.getByLabel("Link and code expire").selectOption("7", { timeout: 5000 }); + await dialog.getByRole("button", { name: "Create share link" }).click(); + const line = dialog.locator(".share-code"); + await expect(line).toContainText("Document code"); + await expect(line).toContainText("until"); + const code = (await line.textContent())!.match(/open document (\d{6})/)![1]!; + // Anyone with the code, signed in or not, reads it. + const other = await browser.newContext(); + try { + const reader = await other.newPage(); + await reader.goto(`${new URL(page.url()).origin}/c/${code}`); + await expect(reader.locator("#document-content")).toContainText("Ships Friday"); + } finally { await other.close(); } +}); + test("signed in, a folder past the old 5,000-file cap opens whole with its subfolders folded", async ({ page, context }) => { await register(context, username()); await page.addInitScript(() => { diff --git a/site/test/viewer.spec.ts b/site/test/viewer.spec.ts index 65c417f..20ce26c 100644 --- a/site/test/viewer.spec.ts +++ b/site/test/viewer.spec.ts @@ -667,6 +667,8 @@ test("storage failure still permits reading, editing, and downloading", async ({ await expect(page.locator("#document-content")).toContainText("Your docs. Your space."); await expect(page.locator("#save-status")).toContainText("storage unavailable"); await page.locator("#edit-mode").click(); + // Type once the editor holds the document, as a person would; earlier keys race its load. + await expect(editorInput(page)).toContainText("Your docs. Your space."); await editorInput(page).fill("# Keep this draft"); const downloading = page.waitForEvent("download"); await page.locator("#download").click(); diff --git a/site/viewer.ts b/site/viewer.ts index 540f75c..20d8017 100644 --- a/site/viewer.ts +++ b/site/viewer.ts @@ -43,6 +43,8 @@ let cloudDocument: CloudDocument | null = null; const shareToken = location.pathname.match(/^\/s\/([A-Za-z0-9_-]{43})$/)?.[1]; const cloudId = new URLSearchParams(location.search).get("doc"); const pasteToken = location.pathname.match(/^\/p\/([A-Za-z0-9_-]{43})$/)?.[1]; +/** readm3.com/c/437301: a share link's voice code, read-only. */ +const shareCode = location.pathname.match(/^\/c\/(\d{6})$/)?.[1]; interface PasteView { id: string; title: string; source: string; bytes: number; language: string; mime: string; createdAt: string; expiresAt: string; } let pasteDocument: PasteView | null = null; let cloudSaving = false; @@ -1344,9 +1346,11 @@ async function start() { } return; } - if (shareToken || cloudId) { + if (shareToken || shareCode || cloudId) { try { - const doc = shareToken ? await request(`shared/${shareToken}`) : await request(`documents/${encodeURIComponent(cloudId!)}`); + const doc = shareToken ? await request(`shared/${shareToken}`) + : shareCode ? await request(`codes/${shareCode}`) + : await request(`documents/${encodeURIComponent(cloudId!)}`); adoptCloud(doc); bindEvents(); if (doc.canEdit && new URLSearchParams(location.search).get("edit") === "1") setMode(true); diff --git a/src/cli.ts b/src/cli.ts index 55e7eea..60cc95b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,7 +12,7 @@ import { run, type ViewerOptions } from "./viewer.ts"; import { isCloudCommand, cloudMain, CLOUD_USAGE } from "./cloud-cli.ts"; import { isDesktopCommand, desktopMain, installedDesktop, prefersDesktop, DESKTOP_USAGE } from "./desktop-cli.ts"; -export const VERSION = "0.26.3"; +export const VERSION = "0.27.0"; /** The help text, exported so a test can hold it to the flags it documents. */ export const USAGE = `readm3 — a plain text editor for Markdown and code