diff --git a/backend/migrations/0073_share_drafts.sql b/backend/migrations/0073_share_drafts.sql new file mode 100644 index 00000000..c18b90b3 --- /dev/null +++ b/backend/migrations/0073_share_drafts.sql @@ -0,0 +1,24 @@ +-- Cross-device linkblog share drafts (D1 only, no PDS record — drafts are +-- private unposted words; they become public only via the linkblog write path). +-- Modeled on magazines (0059): ?since= deltas + tombstones, hourly cron GC. +-- +-- Two clocks, mirroring what the client already keeps: `updated_at` is the +-- server clock (unix seconds) that drives the delta cursor, and +-- `client_updated_at` is the client's ms clock that drives last-write-wins on +-- upsert and the drafts-list sort. `draft` is an opaque JSON blob (blocks + +-- article metadata + repostUri/itemKey) the backend never interprets. +CREATE TABLE IF NOT EXISTS share_drafts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_did TEXT NOT NULL, + article_url TEXT NOT NULL, -- dedupe key, same as the linkblog's + draft TEXT NOT NULL, -- JSON: full ShareDraft + client_updated_at INTEGER NOT NULL, -- client ms clock; LWW + list sort + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + deleted_at INTEGER, + UNIQUE(user_did, article_url), + FOREIGN KEY (user_did) REFERENCES users(did) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_share_drafts_user ON share_drafts(user_did); +CREATE INDEX IF NOT EXISTS idx_share_drafts_user_updated ON share_drafts(user_did, updated_at); diff --git a/backend/src/index.ts b/backend/src/index.ts index 6b0c3361..7a666598 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -34,6 +34,11 @@ import { handleDeletePublication, handleRestorePublication, } from './routes/linkblog'; +import { + handleGetShareDrafts, + handleUpsertShareDraft, + handleDeleteShareDraft, +} from './routes/share-drafts'; import { handleAtmosphereSubscription } from './routes/atmosphere'; import { handleCreateSubscription, @@ -385,6 +390,24 @@ async function route( response = await handleDiscover(request, env); break; + // Unposted share drafts — private to the account, D1 only, delta-synced so + // a draft started on one device can be finished on another. + case url.pathname === '/api/linkblog/drafts': + if (!session) return unauthorizedResponse(headers); + if (request.method === 'GET') { + response = await handleGetShareDrafts(request, env); + } else if (request.method === 'PUT') { + response = await handleUpsertShareDraft(request, env); + } else if (request.method === 'DELETE') { + response = await handleDeleteShareDraft(request, env); + } else { + response = new Response(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { 'Content-Type': 'application/json' }, + }); + } + break; + // Linkblog endpoints — sharing as portable site.standard.document records case url.pathname === '/api/linkblog/share': if (!session) return unauthorizedResponse(headers); @@ -939,6 +962,23 @@ async function runScheduled( reportError(error, { tags: { source: 'cron', phase: 'magazine-tombstone-purge' } }); } + // Purge old share-draft tombstones (same 90-day retention, same reason: + // a client that was offline across the deletion still has to replay it). + try { + const shareDraftCutoff = Math.floor(now / 1000) - 90 * 24 * 60 * 60; + await env.DB.prepare( + 'DELETE FROM share_drafts WHERE deleted_at IS NOT NULL AND deleted_at < ?' + ) + .bind(shareDraftCutoff) + .run(); + } catch (error) { + log.error('cron_phase_failed', { + phase: 'share-draft-tombstone-purge', + ...serializeError(error), + }); + reportError(error, { tags: { source: 'cron', phase: 'share-draft-tombstone-purge' } }); + } + d1CleanupDuration = Date.now() - cleanupStart; log.info('cron_d1_cleanup', { oauthStatesDeleted: oauthDeleted, diff --git a/backend/src/routes/share-drafts.ts b/backend/src/routes/share-drafts.ts new file mode 100644 index 00000000..490fb777 --- /dev/null +++ b/backend/src/routes/share-drafts.ts @@ -0,0 +1,254 @@ +import type { Env } from '../types'; +import { getSessionFromRequest } from '../services/oauth'; + +// Cross-device linkblog share drafts. A draft is the unposted state of a share, +// keyed by the external article URL — the same key the linkblog dedups on. The +// body is an opaque JSON blob (blocks + article metadata + repostUri/itemKey) +// the backend never interprets, exactly like magazines' params/items. D1 only: +// drafts are private to the account and never become a PDS record; they go +// public only when the user posts, through the linkblog write path. +// +// Sync is delta-based like magazines: `?since=` returns rows changed at or +// after the client's cursor, tombstones included so deletions replay to other +// devices. The inclusive boundary is deliberate: updated_at has second +// precision, so a mutation can land in the same second after a client has +// checkpointed it. Clients merge the replayed boundary rows idempotently. +// +// Two clocks. `updated_at` is the server clock (unix seconds) and drives the +// delta cursor. `client_updated_at` is the client's ms clock and drives +// last-write-wins: unlike magazines (which upsert unconditionally) a draft is +// keystroke-level content, so an offline-queued write from a stale device must +// not clobber a newer edit made elsewhere. + +interface ShareDraftRow { + id: number; + article_url: string; + draft: string; + client_updated_at: number; + created_at: number; + updated_at: number; + deleted_at: number | null; +} + +const DEFAULT_LIMIT = 100; +const MAX_LIMIT = 500; +// A draft is prose plus a little article metadata; 64 KB is far more than any +// real one and keeps a runaway client from bloating rows. +const MAX_DRAFT_BYTES = 64 * 1024; +const MAX_ARTICLE_URL_LENGTH = 2048; + +function encodeCursor(updatedAt: number, id: number): string { + return btoa(`${updatedAt}:${id}`); +} + +function decodeCursor(cursor: string): { updatedAt: number; id: number } | null { + try { + const [updatedAtStr, idStr] = atob(cursor).split(':'); + const updatedAt = Number(updatedAtStr); + const id = Number(idStr); + if (isNaN(updatedAt) || isNaN(id)) return null; + return { updatedAt, id }; + } catch { + return null; + } +} + +function unauthorized(): Response { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function rowToDraft(row: ShareDraftRow) { + // A tombstone carries no body — the client only needs to know which draft + // died and when, and shipping the last words of a deleted draft back out + // would defeat the point of deleting it. + const deleted = row.deleted_at !== null; + return { + articleUrl: row.article_url, + draft: deleted ? null : JSON.parse(row.draft), + clientUpdatedAt: row.client_updated_at, + createdAt: row.created_at, + serverUpdatedAt: row.updated_at, + deletedAt: row.deleted_at, + }; +} + +// GET /api/linkblog/drafts - list drafts (full snapshot, or `?since=` delta) +export async function handleGetShareDrafts(request: Request, env: Env): Promise { + const session = await getSessionFromRequest(request, env); + if (!session) return unauthorized(); + + const url = new URL(request.url); + const cursor = url.searchParams.get('cursor'); + const limitParam = url.searchParams.get('limit'); + const limit = Math.min(Math.max(1, Number(limitParam) || DEFAULT_LIMIT), MAX_LIMIT); + const sinceParam = url.searchParams.get('since'); + const since = sinceParam !== null ? parseInt(sinceParam, 10) : NaN; + + try { + let query = `SELECT id, article_url, draft, client_updated_at, created_at, updated_at, deleted_at + FROM share_drafts + WHERE user_did = ?`; + const params: (string | number)[] = [session.did]; + + if (Number.isFinite(since)) { + // Overlap the checkpoint second. A strict `>` can permanently miss a row + // written later in the same second as the last sync. Pagination remains + // collision-safe through the (updated_at, id) page cursor below. + query += ' AND updated_at >= ?'; + params.push(since); + } else { + query += ' AND deleted_at IS NULL'; + } + + if (cursor) { + const parsed = decodeCursor(cursor); + if (!parsed) return json({ error: 'Invalid cursor' }, 400); + query += ' AND (updated_at < ? OR (updated_at = ? AND id < ?))'; + params.push(parsed.updatedAt, parsed.updatedAt, parsed.id); + } + + query += ' ORDER BY updated_at DESC, id DESC LIMIT ?'; + params.push(limit + 1); + + const result = await env.DB.prepare(query) + .bind(...params) + .all(); + + const hasMore = result.results.length > limit; + const rows = hasMore ? result.results.slice(0, limit) : result.results; + const drafts = rows.map(rowToDraft); + const nextCursor = hasMore + ? encodeCursor(rows[rows.length - 1].updated_at, rows[rows.length - 1].id) + : undefined; + + return json({ drafts, cursor: nextCursor }); + } catch (error) { + console.error('Failed to get share drafts:', error); + return json({ error: 'Failed to get share drafts' }, 500); + } +} + +interface UpsertShareDraftRequest { + articleUrl: string; + draft: unknown; + updatedAt: number; +} + +// PUT /api/linkblog/drafts - create or replace a draft (last write wins) +export async function handleUpsertShareDraft(request: Request, env: Env): Promise { + const session = await getSessionFromRequest(request, env); + if (!session) return unauthorized(); + + let body: UpsertShareDraftRequest; + try { + body = (await request.json()) as UpsertShareDraftRequest; + } catch { + return json({ error: 'Invalid JSON body' }, 400); + } + + const { articleUrl, draft, updatedAt } = body; + if (!articleUrl || typeof articleUrl !== 'string' || articleUrl.length > MAX_ARTICLE_URL_LENGTH) { + return json({ error: 'articleUrl is required' }, 400); + } + if (typeof draft !== 'object' || draft === null || Array.isArray(draft)) { + return json({ error: 'draft must be an object' }, 400); + } + const clientUpdatedAt = Number(updatedAt); + if (!Number.isFinite(clientUpdatedAt)) { + return json({ error: 'updatedAt must be a number' }, 400); + } + + const serialized = JSON.stringify(draft); + if (new TextEncoder().encode(serialized).length > MAX_DRAFT_BYTES) { + return json({ error: 'Draft too large' }, 413); + } + + const now = Math.floor(Date.now() / 1000); + + try { + // The guard is what makes an offline queue safe: a write carrying an older + // client clock than the row already here is dropped rather than applied. + // That still answers success — the newer content is already stored, and the + // client's next delta hands it back. + await env.DB.prepare( + `INSERT INTO share_drafts (user_did, article_url, draft, client_updated_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_did, article_url) DO UPDATE SET + draft = excluded.draft, + client_updated_at = excluded.client_updated_at, + updated_at = excluded.updated_at, + deleted_at = NULL + WHERE excluded.client_updated_at >= share_drafts.client_updated_at` + ) + .bind(session.did, articleUrl, serialized, clientUpdatedAt, now, now) + .run(); + + return json({ success: true, articleUrl }); + } catch (error) { + console.error('Failed to upsert share draft:', error); + return json({ error: 'Failed to upsert share draft' }, 500); + } +} + +interface DeleteShareDraftRequest { + articleUrl: string; + updatedAt?: number; +} + +// DELETE /api/linkblog/drafts - soft-delete (tombstone) a draft. Posting or +// discarding a draft on one device clears it on the others through this. +export async function handleDeleteShareDraft(request: Request, env: Env): Promise { + const session = await getSessionFromRequest(request, env); + if (!session) return unauthorized(); + + let body: DeleteShareDraftRequest; + try { + body = (await request.json()) as DeleteShareDraftRequest; + } catch { + return json({ error: 'Invalid JSON body' }, 400); + } + + if (!body.articleUrl || typeof body.articleUrl !== 'string') { + return json({ error: 'articleUrl is required' }, 400); + } + + const now = Math.floor(Date.now() / 1000); + const clientUpdatedAt = Number.isFinite(Number(body.updatedAt)) + ? Number(body.updatedAt) + : now * 1000; + + try { + // Stamping client_updated_at with the deletion's client ms puts the delete + // on the same LWW clock as edits, and the guard makes the rule symmetric + // with upsert: a delete queued offline before an edit made elsewhere does + // not destroy that newer edit. Losing a delete race just means the draft + // comes back and the user discards it again; losing an edit race would mean + // losing their words. + // + // The body is cleared, not just hidden: a discarded draft's text should not + // sit in the tombstone for the 90 days before the cron sweeps it. + await env.DB.prepare( + `UPDATE share_drafts + SET deleted_at = ?, updated_at = ?, client_updated_at = ?, draft = '{}' + WHERE user_did = ? AND article_url = ? AND ? >= client_updated_at` + ) + .bind(now, now, clientUpdatedAt, session.did, body.articleUrl, clientUpdatedAt) + .run(); + // Deleting a draft that was never pushed (or is already gone) is a no-op, + // not an error — the client's intent is satisfied either way. + return json({ success: true }); + } catch (error) { + console.error('Failed to delete share draft:', error); + return json({ error: 'Failed to delete share draft' }, 500); + } +} diff --git a/backend/test/share-drafts.spec.ts b/backend/test/share-drafts.spec.ts new file mode 100644 index 00000000..ac721208 --- /dev/null +++ b/backend/test/share-drafts.spec.ts @@ -0,0 +1,340 @@ +import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'; +import { describe, it, expect, beforeEach } from 'vitest'; +import worker from '../src/index'; + +const IncomingRequest = Request; + +const TEST_DID = 'did:plc:drafts123'; +const TEST_SESSION_ID = 'test-session-drafts'; +const OTHER_DID = 'did:plc:drafts456'; +const OTHER_SESSION_ID = 'test-session-drafts-other'; + +async function insertUser(did: string, handle: string, sessionId: string) { + await env.DB.prepare( + `INSERT INTO users (did, handle, pds_url, tier, created_at) VALUES (?, ?, ?, 'free', unixepoch())` + ) + .bind(did, handle, 'https://test.pds.example') + .run(); + + await env.DB.prepare( + `INSERT INTO sessions (session_id, did, handle, pds_url, access_token, refresh_token, dpop_private_key, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + sessionId, + did, + handle, + 'https://test.pds.example', + 'test-access-token', + 'test-refresh-token', + JSON.stringify({ kty: 'EC' }), + Date.now() + 3600000 + ) + .run(); +} + +type DraftRecord = { + articleUrl: string; + draft: { blocks?: { kind: string; text: string }[] } | null; + clientUpdatedAt: number; + createdAt: number; + serverUpdatedAt: number; + deletedAt: number | null; +}; + +type DraftsResponse = { drafts: DraftRecord[]; cursor?: string; error?: string }; + +async function getDrafts( + path: string, + sessionId = TEST_SESSION_ID +): Promise<{ status: number; body: DraftsResponse }> { + const ctx = createExecutionContext(); + const request = new IncomingRequest(`http://localhost${path}`, { + headers: { Cookie: `session_id=${sessionId}`, Origin: env.FRONTEND_URL }, + }); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + return { status: response.status, body: (await response.json()) as DraftsResponse }; +} + +async function mutate( + method: 'PUT' | 'DELETE', + body: unknown, + sessionId = TEST_SESSION_ID +): Promise<{ status: number; body: { success?: boolean; error?: string } }> { + const ctx = createExecutionContext(); + const request = new IncomingRequest('http://localhost/api/linkblog/drafts', { + method, + headers: { + Cookie: `session_id=${sessionId}`, + Origin: env.FRONTEND_URL, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + return { + status: response.status, + body: (await response.json()) as { success?: boolean; error?: string }, + }; +} + +function draftBody(text: string, url = 'https://example.com/a') { + return { + articleUrl: url, + articleTitle: 'A post', + blocks: [{ kind: 'text', text }], + createdAt: 1, + updatedAt: 2, + }; +} + +// The server clock only has second resolution, so rows written in the same test +// tick share an updated_at. Backdate explicitly when a delta boundary matters. +async function backdate(articleUrl: string, updatedAt: number) { + await env.DB.prepare( + 'UPDATE share_drafts SET updated_at = ? WHERE user_did = ? AND article_url = ?' + ) + .bind(updatedAt, TEST_DID, articleUrl) + .run(); +} + +describe('/api/linkblog/drafts', () => { + beforeEach(async () => { + await env.DB.prepare('DELETE FROM share_drafts').run(); + await env.DB.prepare('DELETE FROM sessions').run(); + await env.DB.prepare('DELETE FROM users').run(); + await insertUser(TEST_DID, 'drafts.bsky.social', TEST_SESSION_ID); + await insertUser(OTHER_DID, 'other.bsky.social', OTHER_SESSION_ID); + }); + + it('returns 401 without a session', async () => { + const ctx = createExecutionContext(); + const request = new IncomingRequest('http://localhost/api/linkblog/drafts', { + headers: { Origin: env.FRONTEND_URL }, + }); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + expect(response.status).toBe(401); + }); + + it('round-trips an upserted draft', async () => { + const put = await mutate('PUT', { + articleUrl: 'https://example.com/a', + draft: draftBody('hello'), + updatedAt: 1000, + }); + expect(put.status).toBe(200); + + const { status, body } = await getDrafts('/api/linkblog/drafts'); + expect(status).toBe(200); + expect(body.drafts).toHaveLength(1); + expect(body.drafts[0].articleUrl).toBe('https://example.com/a'); + expect(body.drafts[0].draft?.blocks?.[0].text).toBe('hello'); + expect(body.drafts[0].clientUpdatedAt).toBe(1000); + expect(body.drafts[0].deletedAt).toBeNull(); + }); + + it('never leaks another user’s drafts', async () => { + await mutate('PUT', { + articleUrl: 'https://example.com/a', + draft: draftBody('mine'), + updatedAt: 1000, + }); + + const { body } = await getDrafts('/api/linkblog/drafts', OTHER_SESSION_ID); + expect(body.drafts).toHaveLength(0); + }); + + it('a newer client clock replaces the stored draft', async () => { + await mutate('PUT', { + articleUrl: 'https://example.com/a', + draft: draftBody('first'), + updatedAt: 1000, + }); + await mutate('PUT', { + articleUrl: 'https://example.com/a', + draft: draftBody('second'), + updatedAt: 2000, + }); + + const { body } = await getDrafts('/api/linkblog/drafts'); + expect(body.drafts).toHaveLength(1); + expect(body.drafts[0].draft?.blocks?.[0].text).toBe('second'); + expect(body.drafts[0].clientUpdatedAt).toBe(2000); + }); + + it('an older client clock is dropped, not applied (LWW guard)', async () => { + await mutate('PUT', { + articleUrl: 'https://example.com/a', + draft: draftBody('newer'), + updatedAt: 5000, + }); + // The shape an offline queue produces: a write composed before the newer + // edit but delivered after it. + const stale = await mutate('PUT', { + articleUrl: 'https://example.com/a', + draft: draftBody('stale'), + updatedAt: 1000, + }); + expect(stale.status).toBe(200); + expect(stale.body.success).toBe(true); + + const { body } = await getDrafts('/api/linkblog/drafts'); + expect(body.drafts[0].draft?.blocks?.[0].text).toBe('newer'); + expect(body.drafts[0].clientUpdatedAt).toBe(5000); + }); + + it('delete tombstones: hidden from the snapshot, replayed in the delta', async () => { + await mutate('PUT', { + articleUrl: 'https://example.com/a', + draft: draftBody('bye'), + updatedAt: 1000, + }); + await backdate('https://example.com/a', 500); + + const del = await mutate('DELETE', { + articleUrl: 'https://example.com/a', + updatedAt: 2000, + }); + expect(del.status).toBe(200); + + const snapshot = await getDrafts('/api/linkblog/drafts'); + expect(snapshot.body.drafts).toHaveLength(0); + + const delta = await getDrafts('/api/linkblog/drafts?since=500'); + expect(delta.body.drafts).toHaveLength(1); + expect(delta.body.drafts[0].deletedAt).not.toBeNull(); + // A tombstone carries no words. + expect(delta.body.drafts[0].draft).toBeNull(); + }); + + it('replays the checkpoint second so a later same-second mutation is not missed', async () => { + await mutate('PUT', { + articleUrl: 'https://example.com/a', + draft: draftBody('first'), + updatedAt: 1000, + }); + await backdate('https://example.com/a', 700); + + const checkpoint = await getDrafts('/api/linkblog/drafts'); + expect(checkpoint.body.drafts[0].serverUpdatedAt).toBe(700); + + await mutate('PUT', { + articleUrl: 'https://example.com/b', + draft: draftBody('same second, later', 'https://example.com/b'), + updatedAt: 2000, + }); + await backdate('https://example.com/b', 700); + + const delta = await getDrafts('/api/linkblog/drafts?since=700'); + expect(delta.body.drafts.map((draft) => draft.articleUrl).sort()).toEqual([ + 'https://example.com/a', + 'https://example.com/b', + ]); + }); + + it('deleting a draft that was never pushed succeeds', async () => { + const del = await mutate('DELETE', { articleUrl: 'https://example.com/never', updatedAt: 1 }); + expect(del.status).toBe(200); + expect(del.body.success).toBe(true); + }); + + it('a newer write after a delete resurrects the draft', async () => { + await mutate('PUT', { + articleUrl: 'https://example.com/a', + draft: draftBody('v1'), + updatedAt: 1000, + }); + await mutate('DELETE', { articleUrl: 'https://example.com/a', updatedAt: 2000 }); + await mutate('PUT', { + articleUrl: 'https://example.com/a', + draft: draftBody('v2'), + updatedAt: 3000, + }); + + const { body } = await getDrafts('/api/linkblog/drafts'); + expect(body.drafts).toHaveLength(1); + expect(body.drafts[0].draft?.blocks?.[0].text).toBe('v2'); + expect(body.drafts[0].deletedAt).toBeNull(); + }); + + it('a write queued before a delete does not resurrect it', async () => { + await mutate('PUT', { + articleUrl: 'https://example.com/a', + draft: draftBody('v1'), + updatedAt: 1000, + }); + // Post clears the draft at t=2000; the composer's trailing throttle then + // fires with the pre-post content it captured at t=1500. + await mutate('DELETE', { articleUrl: 'https://example.com/a', updatedAt: 2000 }); + await mutate('PUT', { + articleUrl: 'https://example.com/a', + draft: draftBody('late'), + updatedAt: 1500, + }); + + const { body } = await getDrafts('/api/linkblog/drafts'); + expect(body.drafts).toHaveLength(0); + }); + + it('a stale delete does not destroy a newer edit', async () => { + await mutate('PUT', { + articleUrl: 'https://example.com/a', + draft: draftBody('newer edit'), + updatedAt: 5000, + }); + await mutate('DELETE', { articleUrl: 'https://example.com/a', updatedAt: 1000 }); + + const { body } = await getDrafts('/api/linkblog/drafts'); + expect(body.drafts).toHaveLength(1); + expect(body.drafts[0].draft?.blocks?.[0].text).toBe('newer edit'); + }); + + it('paginates with a cursor', async () => { + for (let i = 0; i < 5; i++) { + await mutate('PUT', { + articleUrl: `https://example.com/${i}`, + draft: draftBody(`draft ${i}`, `https://example.com/${i}`), + updatedAt: 1000 + i, + }); + await backdate(`https://example.com/${i}`, 1000 + i); + } + + const first = await getDrafts('/api/linkblog/drafts?limit=2'); + expect(first.body.drafts).toHaveLength(2); + expect(first.body.cursor).toBeTruthy(); + + const seen = [...first.body.drafts.map((d) => d.articleUrl)]; + let cursor = first.body.cursor; + while (cursor) { + const page = await getDrafts( + `/api/linkblog/drafts?limit=2&cursor=${encodeURIComponent(cursor)}` + ); + seen.push(...page.body.drafts.map((d) => d.articleUrl)); + cursor = page.body.cursor; + } + + expect(seen).toHaveLength(5); + expect(new Set(seen).size).toBe(5); + }); + + it('rejects a malformed or oversized write', async () => { + expect((await mutate('PUT', { draft: draftBody('x'), updatedAt: 1 })).status).toBe(400); + expect( + (await mutate('PUT', { articleUrl: 'https://example.com/a', draft: 'nope', updatedAt: 1 })) + .status + ).toBe(400); + expect( + (await mutate('PUT', { articleUrl: 'https://example.com/a', draft: draftBody('x') })).status + ).toBe(400); + + const huge = await mutate('PUT', { + articleUrl: 'https://example.com/a', + draft: draftBody('x'.repeat(70 * 1024)), + updatedAt: 1, + }); + expect(huge.status).toBe(413); + }); +}); diff --git a/e2e/seed.ts b/e2e/seed.ts index 13d72c5f..79172e18 100644 --- a/e2e/seed.ts +++ b/e2e/seed.ts @@ -215,10 +215,40 @@ export async function seedItemLabel(user: TestUser, opts: SeedItemLabelOpts): Pr return rkey; } +export interface SeedShareDraftOpts { + articleUrl: string; + articleTitle?: string; + text: string; + /** Client ms clock. Drives last-write-wins and the drafts-list sort. */ + updatedAt?: number; +} + +/** + * Write a share draft straight into D1, standing in for "the user typed this on + * another device." The `draft` column is the opaque ShareDraft blob the client + * stores; the backend never interprets it. + */ +export async function seedShareDraft(user: TestUser, opts: SeedShareDraftOpts): Promise { + const nowSeconds = Math.floor(Date.now() / 1000); + const updatedAt = opts.updatedAt ?? Date.now(); + const draft = JSON.stringify({ + articleUrl: opts.articleUrl, + articleTitle: opts.articleTitle, + blocks: [{ kind: 'text', text: opts.text }], + createdAt: updatedAt, + updatedAt, + }); + + await execD1([ + `INSERT OR REPLACE INTO share_drafts (user_did, article_url, draft, client_updated_at, created_at, updated_at) VALUES (${sqlString(user.did)}, ${sqlString(opts.articleUrl)}, ${sqlString(draft)}, ${updatedAt}, ${nowSeconds}, ${nowSeconds})`, + ]); +} + export async function cleanupTestData(user: TestUser) { await execD1([ `DELETE FROM item_labels_cache WHERE user_did = '${user.did}'`, `DELETE FROM saved_articles WHERE user_did = '${user.did}'`, + `DELETE FROM share_drafts WHERE user_did = '${user.did}'`, `DELETE FROM subscriptions_cache WHERE user_did = '${user.did}'`, `DELETE FROM user_settings WHERE user_did = '${user.did}'`, `DELETE FROM sessions WHERE did = '${user.did}'`, diff --git a/e2e/share-drafts.spec.ts b/e2e/share-drafts.spec.ts new file mode 100644 index 00000000..f25b58fb --- /dev/null +++ b/e2e/share-drafts.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from './fixtures'; +import { seedShareDraft } from './seed'; + +// Share drafts are durable and cross-device: D1 is the store of record and +// IndexedDB is a cache. These specs exercise the two directions of that claim +// against the real backend — a draft written elsewhere shows up here, and a +// draft discarded here stays discarded after the cache is gone. + +const ARTICLE_URL = 'https://example.com/drafts/cross-device'; +const ARTICLE_TITLE = 'A Piece Worth Linking'; +const DRAFT_TEXT = 'Started this on the phone and never finished the thought.'; + +/** Wipe the Dexie cache so the next load has nothing but the server to go on. */ +async function evictCache(page: import('@playwright/test').Page) { + // The delete is queued behind this document's open connection; the reload + // closes it, the delete runs, and the fresh document then opens an empty DB. + await page.evaluate(() => { + indexedDB.deleteDatabase('skyreader'); + }); + await page.reload(); +} + +test.describe('Share drafts', () => { + test('a draft written on another device appears here, and survives an evicted cache', async ({ + authedPage, + testUser, + }) => { + await seedShareDraft(testUser, { + articleUrl: ARTICLE_URL, + articleTitle: ARTICLE_TITLE, + text: DRAFT_TEXT, + }); + + await authedPage.goto('/linkblog'); + + // The entry renders in the draft shape — chip, headline, the words typed + // elsewhere. Generous timeout: the drafts sync rides the background refresh. + const entry = authedPage.locator('article.entry.draft'); + await expect(entry).toHaveCount(1, { timeout: 15_000 }); + await expect(entry.getByText(ARTICLE_TITLE)).toBeVisible(); + await expect(entry.getByText(DRAFT_TEXT)).toBeVisible(); + + // Nothing about this came from IndexedDB the first time, but prove it can't + // have: wipe the cache and load again. + await evictCache(authedPage); + await expect(authedPage.locator('article.entry.draft')).toHaveCount(1, { timeout: 15_000 }); + await expect(authedPage.getByText(DRAFT_TEXT)).toBeVisible(); + }); + + test('discarding a draft tombstones it on the server', async ({ authedPage, testUser }) => { + await seedShareDraft(testUser, { + articleUrl: ARTICLE_URL, + articleTitle: ARTICLE_TITLE, + text: DRAFT_TEXT, + }); + + await authedPage.goto('/linkblog'); + const entry = authedPage.locator('article.entry.draft'); + await expect(entry).toHaveCount(1, { timeout: 15_000 }); + + // Discard is two-step (arm, then confirm) — the same one-way-action pattern + // the rest of the linkblog uses. + const deleteRequest = authedPage.waitForResponse( + (res) => res.url().includes('/api/linkblog/drafts') && res.request().method() === 'DELETE' + ); + await entry.locator('.menu-trigger').click(); + await authedPage.getByRole('menuitem', { name: 'Discard draft' }).click(); + await authedPage.getByRole('menuitem', { name: 'Discard draft?' }).click(); + await deleteRequest; + + await expect(authedPage.locator('article.entry.draft')).toHaveCount(0); + + // The delete has to have reached D1, not just the local cache: come back + // with an empty cache and the draft must still be gone. + await evictCache(authedPage); + await expect(authedPage.locator('article.entry.draft')).toHaveCount(0); + await expect(authedPage.getByText(DRAFT_TEXT)).toHaveCount(0); + }); +}); diff --git a/frontend/src/lib/components/feed/LinkblogEntry.svelte b/frontend/src/lib/components/feed/LinkblogEntry.svelte index 6b0cc594..314bc302 100644 --- a/frontend/src/lib/components/feed/LinkblogEntry.svelte +++ b/frontend/src/lib/components/feed/LinkblogEntry.svelte @@ -30,7 +30,7 @@ -->