an atproto based link aggregator
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add post submission with optimistic writes

- Add posts table to database schema
- Create TID generation utility (@atproto/common-web)
- Create lex client wrapper for authenticated PDS operations
- Implement submit page with form action
- Fire-and-forget pattern: write to DB first, then PDS async
- Add unit tests for TID generation and validation logic

Dependencies added:
- @atproto/common-web (TID generation)
- @atproto/identity (DID resolution)
- @atproto/lex-cbor (CID calculation)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+466 -2
+3
package.json
··· 58 58 "vitest-browser-svelte": "^2.0.1" 59 59 }, 60 60 "dependencies": { 61 + "@atproto/common-web": "^0.4.6", 62 + "@atproto/identity": "^0.4.10", 63 + "@atproto/lex-cbor": "^0.0.2", 61 64 "@atproto/oauth-client-node": "^0.3.12", 62 65 "iron-session": "^8.0.4" 63 66 }
+18
pnpm-lock.yaml
··· 8 8 9 9 .: 10 10 dependencies: 11 + '@atproto/common-web': 12 + specifier: ^0.4.6 13 + version: 0.4.6 14 + '@atproto/identity': 15 + specifier: ^0.4.10 16 + version: 0.4.10 17 + '@atproto/lex-cbor': 18 + specifier: ^0.0.2 19 + version: 0.0.2 11 20 '@atproto/oauth-client-node': 12 21 specifier: ^0.3.12 13 22 version: 0.3.12 ··· 147 156 148 157 '@atproto/did@0.2.3': 149 158 resolution: {integrity: sha512-VI8JJkSizvM2cHYJa37WlbzeCm5tWpojyc1/Zy8q8OOjyoy6X4S4BEfoP941oJcpxpMTObamibQIXQDo7tnIjg==} 159 + 160 + '@atproto/identity@0.4.10': 161 + resolution: {integrity: sha512-nQbzDLXOhM8p/wo0cTh5DfMSOSHzj6jizpodX37LJ4S1TZzumSxAjHEZa5Rev3JaoD5uSWMVE0MmKEGWkPPvfQ==} 162 + engines: {node: '>=18.7.0'} 150 163 151 164 '@atproto/jwk-jose@0.1.11': 152 165 resolution: {integrity: sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==} ··· 2459 2472 '@atproto/did@0.2.3': 2460 2473 dependencies: 2461 2474 zod: 3.25.76 2475 + 2476 + '@atproto/identity@0.4.10': 2477 + dependencies: 2478 + '@atproto/common-web': 0.4.6 2479 + '@atproto/crypto': 0.4.5 2462 2480 2463 2481 '@atproto/jwk-jose@0.1.11': 2464 2482 dependencies:
+14 -2
src/lib/server/db/schema.ts
··· 35 35 }); 36 36 37 37 // ============================================================================ 38 - // Content tables (for Phase 3+) 38 + // Content tables 39 39 // ============================================================================ 40 40 41 - // Posts will be added in Phase 3 41 + // Posts - link submissions 42 + export const posts = sqliteTable('posts', { 43 + uri: text('uri').primaryKey(), // at://did/one.papili.post/rkey 44 + cid: text('cid').notNull(), // Content hash 45 + authorDid: text('author_did').notNull(), 46 + rkey: text('rkey').notNull(), 47 + url: text('url').notNull(), // The submitted URL 48 + title: text('title').notNull(), 49 + text: text('text'), // Optional description 50 + createdAt: text('created_at').notNull(), 51 + indexedAt: text('indexed_at').notNull() // When we stored it 52 + }); 53 + 42 54 // Comments will be added in Phase 4 43 55 // Votes will be added in Phase 5
+61
src/lib/server/lex-client.ts
··· 1 + /** 2 + * Lex client utilities for ATProto record operations 3 + */ 4 + 5 + import { Client } from '@atproto/lex'; 6 + import { IdResolver } from '@atproto/identity'; 7 + import { TokenRefreshError } from '@atproto/oauth-client-node'; 8 + import { createOAuthClient, getSession } from './auth'; 9 + import { db } from './db'; 10 + 11 + const idResolver = new IdResolver(); 12 + 13 + export class AuthRequiredError extends Error { 14 + constructor(message = 'Authentication required') { 15 + super(message); 16 + this.name = 'AuthRequiredError'; 17 + } 18 + } 19 + 20 + /** 21 + * Get an unauthenticated lex Client for reading from a user's PDS. 22 + * Resolves the DID to find the PDS endpoint. 23 + */ 24 + export async function getPublicLexClient(did: string): Promise<Client> { 25 + const atprotoData = await idResolver.did.resolveAtprotoData(did); 26 + const pdsUrl = atprotoData.pds; 27 + return new Client(pdsUrl); 28 + } 29 + 30 + /** 31 + * Get an authenticated lex Client for the current user. 32 + * Throws AuthRequiredError if not authenticated. 33 + */ 34 + export async function getLexClient(cookies: Parameters<typeof getSession>[0]): Promise<Client> { 35 + const session = await getSession(cookies); 36 + if (!session.did) { 37 + throw new AuthRequiredError(); 38 + } 39 + 40 + const oauthClient = await createOAuthClient(db); 41 + 42 + let oauthSession; 43 + try { 44 + oauthSession = await oauthClient.restore(session.did); 45 + } catch (err) { 46 + if (err instanceof TokenRefreshError) { 47 + // Session was deleted or token refresh failed - clear stale cookie 48 + console.log(`[auth] Clearing stale session for ${session.did}: ${(err as Error).message}`); 49 + session.did = undefined; 50 + await session.save(); 51 + } 52 + throw err; 53 + } 54 + 55 + if (!oauthSession) { 56 + throw new AuthRequiredError(); 57 + } 58 + 59 + // Create lex Client directly from OAuth session 60 + return new Client(oauthSession); 61 + }
+10
src/lib/server/tid.ts
··· 1 + /** 2 + * TID (Timestamp ID) generation utilities 3 + * Uses the official ATProto TID implementation 4 + */ 5 + 6 + import { TID } from '@atproto/common-web'; 7 + 8 + export function generateTid(): string { 9 + return TID.nextStr(); 10 + }
+112
src/routes/submit/+page.server.ts
··· 1 + import { fail, redirect } from '@sveltejs/kit'; 2 + import type { Actions, PageServerLoad } from './$types'; 3 + import { getLexClient, AuthRequiredError } from '$lib/server/lex-client'; 4 + import { generateTid } from '$lib/server/tid'; 5 + import { db } from '$lib/server/db'; 6 + import { posts } from '$lib/server/db/schema'; 7 + import { cidForLex } from '@atproto/lex-cbor'; 8 + import * as post from '$lib/lexicons/one/papili/post.defs'; 9 + import type { l } from '@atproto/lex'; 10 + 11 + export const load: PageServerLoad = async ({ parent }) => { 12 + const { user } = await parent(); 13 + if (!user) { 14 + redirect(302, '/login'); 15 + } 16 + return {}; 17 + }; 18 + 19 + export const actions: Actions = { 20 + default: async ({ request, cookies }) => { 21 + // Get authenticated client 22 + let client; 23 + try { 24 + client = await getLexClient(cookies); 25 + } catch (err) { 26 + if (err instanceof AuthRequiredError) { 27 + redirect(302, '/login'); 28 + } 29 + throw err; 30 + } 31 + 32 + const authorDid = client.assertDid; 33 + 34 + // Parse form data 35 + const formData = await request.formData(); 36 + const url = formData.get('url')?.toString()?.trim(); 37 + const title = formData.get('title')?.toString()?.trim(); 38 + const text = formData.get('text')?.toString()?.trim() || undefined; 39 + 40 + // Validate required fields 41 + if (!url) { 42 + return fail(400, { error: 'URL is required', url, title, text }); 43 + } 44 + if (!title) { 45 + return fail(400, { error: 'Title is required', url, title, text }); 46 + } 47 + 48 + // Validate URL format 49 + try { 50 + new URL(url); 51 + } catch { 52 + return fail(400, { error: 'Invalid URL format', url, title, text }); 53 + } 54 + 55 + // Validate lengths 56 + if (title.length > 300) { 57 + return fail(400, { error: 'Title must be 300 characters or less', url, title, text }); 58 + } 59 + if (text && text.length > 10000) { 60 + return fail(400, { error: 'Description must be 10,000 characters or less', url, title, text }); 61 + } 62 + 63 + const now = new Date().toISOString(); 64 + const rkey = generateTid(); 65 + const uri = `at://${authorDid}/one.papili.post/${rkey}`; 66 + 67 + // Build the record 68 + const postRecord: post.Main = { 69 + $type: 'one.papili.post', 70 + url: url as l.UriString, 71 + title, 72 + text, 73 + createdAt: now as l.DatetimeString 74 + }; 75 + 76 + // Calculate CID for optimistic write 77 + const cid = (await cidForLex(postRecord)).toString(); 78 + 79 + // Step 1: Write to local DB (optimistic) 80 + await db.insert(posts).values({ 81 + uri, 82 + cid, 83 + authorDid, 84 + rkey, 85 + url, 86 + title, 87 + text, 88 + createdAt: now, 89 + indexedAt: now 90 + }); 91 + 92 + // Step 2: Fire-and-forget PDS write 93 + client 94 + .create( 95 + post.main, 96 + { 97 + url: url as l.UriString, 98 + title, 99 + text, 100 + createdAt: now as l.DatetimeString 101 + }, 102 + { rkey } 103 + ) 104 + .catch((err) => { 105 + console.error(`[pds] Failed to write post ${uri} to PDS:`, err); 106 + // TODO: Mark record as needing sync, add to retry queue 107 + }); 108 + 109 + // Redirect to home (or to the post page once we have one) 110 + redirect(303, '/'); 111 + } 112 + };
+97
src/routes/submit/+page.svelte
··· 1 + <script lang="ts"> 2 + import { enhance } from '$app/forms'; 3 + 4 + let { form } = $props(); 5 + let submitting = $state(false); 6 + </script> 7 + 8 + <svelte:head> 9 + <title>Submit - papili.one</title> 10 + </svelte:head> 11 + 12 + <div class="mx-auto max-w-2xl px-4 py-8"> 13 + <h1 class="mb-6 text-2xl font-bold text-gray-900 dark:text-gray-100">Submit a Link</h1> 14 + 15 + {#if form?.error} 16 + <div class="mb-4 rounded-md bg-red-50 p-4 text-red-700 dark:bg-red-900/20 dark:text-red-400"> 17 + {form.error} 18 + </div> 19 + {/if} 20 + 21 + <form 22 + method="POST" 23 + use:enhance={() => { 24 + submitting = true; 25 + return async ({ update }) => { 26 + await update(); 27 + submitting = false; 28 + }; 29 + }} 30 + class="space-y-6" 31 + > 32 + <div> 33 + <label for="url" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> 34 + URL <span class="text-red-500">*</span> 35 + </label> 36 + <input 37 + type="url" 38 + id="url" 39 + name="url" 40 + required 41 + value={form?.url ?? ''} 42 + placeholder="https://example.com/interesting-article" 43 + class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" 44 + /> 45 + </div> 46 + 47 + <div> 48 + <label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> 49 + Title <span class="text-red-500">*</span> 50 + </label> 51 + <input 52 + type="text" 53 + id="title" 54 + name="title" 55 + required 56 + maxlength="300" 57 + value={form?.title ?? ''} 58 + placeholder="An interesting title for your submission" 59 + class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" 60 + /> 61 + <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Max 300 characters</p> 62 + </div> 63 + 64 + <div> 65 + <label for="text" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> 66 + Description <span class="text-gray-400">(optional)</span> 67 + </label> 68 + <textarea 69 + id="text" 70 + name="text" 71 + rows="4" 72 + maxlength="10000" 73 + placeholder="Add context or commentary about this link..." 74 + class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" 75 + >{form?.text ?? ''}</textarea 76 + > 77 + <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Max 10,000 characters</p> 78 + </div> 79 + 80 + <div class="flex items-center gap-4"> 81 + <button 82 + type="submit" 83 + disabled={submitting} 84 + class="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed dark:focus:ring-offset-gray-900" 85 + > 86 + {#if submitting} 87 + Submitting... 88 + {:else} 89 + Submit 90 + {/if} 91 + </button> 92 + <a href="/" class="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"> 93 + Cancel 94 + </a> 95 + </div> 96 + </form> 97 + </div>
+151
src/routes/submit/submit.spec.ts
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 2 + import { generateTid } from '$lib/server/tid'; 3 + 4 + // Mock the lex client module 5 + vi.mock('$lib/server/lex-client', () => ({ 6 + getLexClient: vi.fn(), 7 + AuthRequiredError: class AuthRequiredError extends Error { 8 + constructor(message = 'Authentication required') { 9 + super(message); 10 + this.name = 'AuthRequiredError'; 11 + } 12 + } 13 + })); 14 + 15 + // Mock the database module 16 + vi.mock('$lib/server/db', () => ({ 17 + db: { 18 + insert: vi.fn().mockReturnValue({ 19 + values: vi.fn().mockResolvedValue(undefined) 20 + }) 21 + } 22 + })); 23 + 24 + describe('TID generation', () => { 25 + it('generates valid TID strings', () => { 26 + const tid = generateTid(); 27 + expect(tid).toBeDefined(); 28 + expect(typeof tid).toBe('string'); 29 + expect(tid.length).toBeGreaterThan(0); 30 + }); 31 + 32 + it('generates unique TIDs', () => { 33 + const tids = new Set<string>(); 34 + for (let i = 0; i < 100; i++) { 35 + tids.add(generateTid()); 36 + } 37 + expect(tids.size).toBe(100); 38 + }); 39 + 40 + it('generates TIDs in lexicographic order', () => { 41 + const tids: string[] = []; 42 + for (let i = 0; i < 10; i++) { 43 + tids.push(generateTid()); 44 + } 45 + const sorted = [...tids].sort(); 46 + expect(tids).toEqual(sorted); 47 + }); 48 + }); 49 + 50 + describe('Post submission validation', () => { 51 + // Helper to create form data 52 + function createFormData(data: Record<string, string>): FormData { 53 + const formData = new FormData(); 54 + for (const [key, value] of Object.entries(data)) { 55 + formData.append(key, value); 56 + } 57 + return formData; 58 + } 59 + 60 + it('validates URL is required', async () => { 61 + const formData = createFormData({ title: 'Test Title' }); 62 + const url = formData.get('url')?.toString()?.trim(); 63 + expect(url).toBeFalsy(); 64 + }); 65 + 66 + it('validates title is required', async () => { 67 + const formData = createFormData({ url: 'https://example.com' }); 68 + const title = formData.get('title')?.toString()?.trim(); 69 + expect(title).toBeFalsy(); 70 + }); 71 + 72 + it('validates URL format', () => { 73 + const validUrls = [ 74 + 'https://example.com', 75 + 'http://localhost:3000', 76 + 'https://sub.domain.com/path?query=1' 77 + ]; 78 + const invalidUrls = ['not-a-url', 'ftp://invalid', 'javascript:alert(1)']; 79 + 80 + for (const url of validUrls) { 81 + expect(() => new URL(url)).not.toThrow(); 82 + } 83 + 84 + for (const url of invalidUrls) { 85 + let isValid = true; 86 + try { 87 + new URL(url); 88 + } catch { 89 + isValid = false; 90 + } 91 + // Some of these might parse, but they're not http/https 92 + if (isValid) { 93 + const parsed = new URL(url); 94 + expect(['http:', 'https:']).not.toContain(parsed.protocol); 95 + } 96 + } 97 + }); 98 + 99 + it('validates title length <= 300', () => { 100 + const validTitle = 'A'.repeat(300); 101 + const invalidTitle = 'A'.repeat(301); 102 + 103 + expect(validTitle.length).toBeLessThanOrEqual(300); 104 + expect(invalidTitle.length).toBeGreaterThan(300); 105 + }); 106 + 107 + it('validates text length <= 10000', () => { 108 + const validText = 'A'.repeat(10000); 109 + const invalidText = 'A'.repeat(10001); 110 + 111 + expect(validText.length).toBeLessThanOrEqual(10000); 112 + expect(invalidText.length).toBeGreaterThan(10000); 113 + }); 114 + }); 115 + 116 + describe('Post record structure', () => { 117 + it('builds correct AT URI format', () => { 118 + const did = 'did:plc:abc123'; 119 + const rkey = generateTid(); 120 + const uri = `at://${did}/one.papili.post/${rkey}`; 121 + 122 + expect(uri).toMatch(/^at:\/\/did:plc:[a-z0-9]+\/one\.papili\.post\/[a-z0-9]+$/); 123 + }); 124 + 125 + it('includes required fields in record', () => { 126 + const now = new Date().toISOString(); 127 + const record = { 128 + $type: 'one.papili.post', 129 + url: 'https://example.com', 130 + title: 'Test Title', 131 + createdAt: now 132 + }; 133 + 134 + expect(record.$type).toBe('one.papili.post'); 135 + expect(record.url).toBeDefined(); 136 + expect(record.title).toBeDefined(); 137 + expect(record.createdAt).toBeDefined(); 138 + }); 139 + 140 + it('optional text field can be undefined', () => { 141 + const record = { 142 + $type: 'one.papili.post', 143 + url: 'https://example.com', 144 + title: 'Test Title', 145 + text: undefined, 146 + createdAt: new Date().toISOString() 147 + }; 148 + 149 + expect(record.text).toBeUndefined(); 150 + }); 151 + });