Statusphere, but in atcute and SvelteKit
atproto svelte sveltekit drizzle atcute typescript
19
fork

Configure Feed

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

refactor: don't have app sessions

Mary 20348b76 ba3be958

+63 -195
-8
drizzle/0000_massive_morg.sql drizzle/0000_absurd_kitty_pryde.sql
··· 1 - CREATE TABLE `app_session` ( 2 - `id` text PRIMARY KEY NOT NULL, 3 - `did` text NOT NULL, 4 - `created_at` integer NOT NULL, 5 - `last_seen_at` integer NOT NULL 6 - ); 7 - --> statement-breakpoint 8 - CREATE INDEX `app_session_did_idx` ON `app_session` (`did`);--> statement-breakpoint 9 1 CREATE TABLE `identity` ( 10 2 `did` text PRIMARY KEY NOT NULL, 11 3 `handle` text NOT NULL,
+1 -47
drizzle/meta/0000_snapshot.json
··· 1 1 { 2 2 "version": "6", 3 3 "dialect": "sqlite", 4 - "id": "03f1c343-6be8-467e-a6c5-10bba6559e6a", 4 + "id": "78dc97fb-2759-4aba-b927-e6b7d1d2ac73", 5 5 "prevId": "00000000-0000-0000-0000-000000000000", 6 6 "tables": { 7 - "app_session": { 8 - "name": "app_session", 9 - "columns": { 10 - "id": { 11 - "name": "id", 12 - "type": "text", 13 - "primaryKey": true, 14 - "notNull": true, 15 - "autoincrement": false 16 - }, 17 - "did": { 18 - "name": "did", 19 - "type": "text", 20 - "primaryKey": false, 21 - "notNull": true, 22 - "autoincrement": false 23 - }, 24 - "created_at": { 25 - "name": "created_at", 26 - "type": "integer", 27 - "primaryKey": false, 28 - "notNull": true, 29 - "autoincrement": false 30 - }, 31 - "last_seen_at": { 32 - "name": "last_seen_at", 33 - "type": "integer", 34 - "primaryKey": false, 35 - "notNull": true, 36 - "autoincrement": false 37 - } 38 - }, 39 - "indexes": { 40 - "app_session_did_idx": { 41 - "name": "app_session_did_idx", 42 - "columns": [ 43 - "did" 44 - ], 45 - "isUnique": false 46 - } 47 - }, 48 - "foreignKeys": {}, 49 - "compositePrimaryKeys": {}, 50 - "uniqueConstraints": {}, 51 - "checkConstraints": {} 52 - }, 53 7 "identity": { 54 8 "name": "identity", 55 9 "columns": {
+2 -2
drizzle/meta/_journal.json
··· 5 5 { 6 6 "idx": 0, 7 7 "version": "6", 8 - "when": 1765701254575, 9 - "tag": "0000_massive_morg", 8 + "when": 1765706992228, 9 + "tag": "0000_absurd_kitty_pryde", 10 10 "breakpoints": true 11 11 } 12 12 ]
+2 -2
src/app.d.ts
··· 1 1 // See https://svelte.dev/docs/kit/types#app.d.ts 2 2 3 - import type { AppSession } from '$lib/server/auth/app-session'; 3 + import type { AuthContext } from '$lib/server/auth'; 4 4 5 5 // for information about these interfaces 6 6 declare global { 7 7 namespace App { 8 8 // interface Error {} 9 9 interface Locals { 10 - session?: AppSession | null; 10 + auth?: AuthContext; 11 11 } 12 12 // interface PageData {} 13 13 // interface PageState {}
-20
src/hooks.server.ts
··· 1 1 import { env } from '$env/dynamic/private'; 2 - import type { Handle } from '@sveltejs/kit'; 3 2 4 3 import { TapClient } from '@atcute/tap'; 5 4 6 - import { APP_SESSION_COOKIE, getAppSession } from '$lib/server/auth/app-session'; 7 - import { getSignedCookie } from '$lib/server/auth/signed-cookie'; 8 5 import { runTapSubscription } from '$lib/server/tap'; 9 6 10 7 if (!env.TAP_URL) { ··· 21 18 console.error(err); 22 19 }); 23 20 } 24 - 25 - export const handle: Handle = async ({ event, resolve }) => { 26 - const { locals, cookies } = event; 27 - 28 - const sessionId = getSignedCookie(cookies, APP_SESSION_COOKIE); 29 - if (sessionId) { 30 - const session = await getAppSession(sessionId); 31 - 32 - if (session) { 33 - locals.session = session; 34 - } else { 35 - cookies.delete(APP_SESSION_COOKIE, { path: '/' }); 36 - } 37 - } 38 - 39 - return resolve(event); 40 - };
+3 -10
src/lib/auth.remote.ts
··· 6 6 7 7 import { form, getRequestEvent } from '$app/server'; 8 8 9 - import { APP_SESSION_COOKIE, deleteAppSession } from './server/auth/app-session'; 9 + import { SESSION_COOKIE } from './server/auth'; 10 10 import { oauth } from './server/oauth'; 11 11 12 12 const actorIdentifierString = v.custom<ActorIdentifier>( ··· 41 41 ); 42 42 43 43 export const doLogout = form(async () => { 44 - const { 45 - locals: { session }, 46 - cookies, 47 - } = getRequestEvent(); 44 + const { cookies } = getRequestEvent(); 48 45 49 - if (session) { 50 - await deleteAppSession(session.id); 51 - } 52 - 53 - cookies.delete(APP_SESSION_COOKIE, { path: '/' }); 46 + cookies.delete(SESSION_COOKIE, { path: '/' }); 54 47 redirect(303, '/'); 55 48 });
-53
src/lib/server/auth/app-session.ts
··· 1 - import { eq } from 'drizzle-orm'; 2 - import { nanoid } from 'nanoid'; 3 - 4 - import type { Did } from '@atcute/lexicons/syntax'; 5 - 6 - import { db } from '$lib/server/db'; 7 - import { appSession } from '$lib/server/db/schema'; 8 - 9 - export const APP_SESSION_COOKIE = 'statusphere_session'; 10 - 11 - export type AppSession = { 12 - id: string; 13 - did: Did; 14 - }; 15 - 16 - export const createAppSession = async (did: Did): Promise<AppSession> => { 17 - const id = nanoid(32); 18 - const ts = Date.now(); 19 - 20 - await db 21 - .insert(appSession) 22 - .values({ 23 - id, 24 - did, 25 - createdAt: ts, 26 - lastSeenAt: ts, 27 - }) 28 - .run(); 29 - 30 - return { id, did }; 31 - }; 32 - 33 - export const deleteAppSession = async (id: string): Promise<void> => { 34 - await db.delete(appSession).where(eq(appSession.id, id)).run(); 35 - }; 36 - 37 - export const getAppSession = async (id: string): Promise<AppSession | null> => { 38 - const row = await db.select().from(appSession).where(eq(appSession.id, id)).get(); 39 - if (!row) { 40 - return null; 41 - } 42 - 43 - const now = Date.now(); 44 - if (row.lastSeenAt && now - row.lastSeenAt > 15 * 60 * 1000) { 45 - touchAppSession(id); 46 - } 47 - 48 - return { id: row.id, did: row.did as Did }; 49 - }; 50 - 51 - const touchAppSession = async (id: string): Promise<void> => { 52 - await db.update(appSession).set({ lastSeenAt: Date.now() }).where(eq(appSession.id, id)).run(); 53 - };
+41 -12
src/lib/server/auth/index.ts
··· 1 1 import { Client } from '@atcute/client'; 2 + import type { Did } from '@atcute/lexicons'; 2 3 import { 3 4 AuthMethodUnsatisfiableError, 4 5 TokenInvalidError, ··· 8 9 9 10 import { getRequestEvent } from '$app/server'; 10 11 11 - import { APP_SESSION_COOKIE, deleteAppSession } from '$lib/server/auth/app-session'; 12 + import { getSignedCookie } from '$lib/server/auth/signed-cookie'; 12 13 import { oauth } from '$lib/server/oauth'; 14 + import { error } from '@sveltejs/kit'; 15 + 16 + export const SESSION_COOKIE = 'statusphere_session'; 17 + 18 + export interface Session { 19 + did: Did; 20 + } 21 + 22 + export interface AuthContext { 23 + session: Session; 24 + client: Client; 25 + } 13 26 14 27 const isSessionInvalidError = (err: unknown): boolean => { 15 28 return ( ··· 20 33 ); 21 34 }; 22 35 23 - export const getAuthedClient = async (): Promise<Client> => { 24 - const { 25 - locals: { session: sessionInfo }, 26 - cookies, 27 - } = getRequestEvent(); 36 + /** 37 + * requires an authenticated session, throwing if not signed in or session is invalid. 38 + * caches the result in locals for successive calls within the same request. 39 + * @returns authenticated session and client 40 + * @throws if not signed in or OAuth session is invalid 41 + */ 42 + export const requireAuth = async (): Promise<AuthContext> => { 43 + const { locals, cookies } = getRequestEvent(); 44 + 45 + // return cached result if available 46 + if (locals.auth) { 47 + return locals.auth; 48 + } 28 49 29 - if (!sessionInfo) { 30 - throw new Error(`not signed in`); 50 + const did = getSignedCookie(cookies, SESSION_COOKIE) as Did | null; 51 + if (!did) { 52 + error(401, `not signed in`); 31 53 } 32 54 33 55 try { 34 - const session = await oauth.restore(sessionInfo.did); 56 + const session = await oauth.restore(did); 35 57 const client = new Client({ handler: session }); 36 58 37 - return client; 59 + const auth: AuthContext = { 60 + session: { did }, 61 + client, 62 + }; 63 + 64 + locals.auth = auth; 65 + return auth; 38 66 } catch (err) { 39 67 if (isSessionInvalidError(err)) { 40 - await deleteAppSession(sessionInfo.id); 41 - cookies.delete(APP_SESSION_COOKIE, { path: '/' }); 68 + cookies.delete(SESSION_COOKIE, { path: '/' }); 69 + 70 + error(401, `session expired`); 42 71 } 43 72 44 73 throw err;
-11
src/lib/server/db/schema.ts
··· 20 20 (table) => [index('oauth_session_updated_at_idx').on(table.updatedAt)], 21 21 ); 22 22 23 - export const appSession = sqliteTable( 24 - 'app_session', 25 - { 26 - id: text('id').primaryKey(), 27 - did: text('did').notNull(), 28 - createdAt: integer('created_at').notNull(), 29 - lastSeenAt: integer('last_seen_at').notNull(), 30 - }, 31 - (table) => [index('app_session_did_idx').on(table.did)], 32 - ); 33 - 34 23 export const identity = sqliteTable('identity', { 35 24 did: text('did').primaryKey(), 36 25 handle: text('handle').notNull(),
+11 -19
src/lib/status.remote.ts
··· 8 8 import * as TID from '@atcute/tid'; 9 9 import { and, desc, eq, inArray, lt, or } from 'drizzle-orm'; 10 10 11 - import { form, getRequestEvent, query } from '$app/server'; 11 + import { form, query } from '$app/server'; 12 12 13 13 import type { XyzStatusphereStatus } from '$lib/lexicons'; 14 - import { getAuthedClient } from '$lib/server/auth'; 14 + import { requireAuth } from '$lib/server/auth'; 15 15 import { db, schema } from '$lib/server/db'; 16 16 import { statusOptions } from '$lib/status-options'; 17 17 ··· 23 23 24 24 /** returns the current user's profile, or null if not signed in */ 25 25 export const getCurrentUser = query(async (): Promise<CurrentUser | null> => { 26 - const { 27 - locals: { session }, 28 - } = getRequestEvent(); 29 - 30 - if (!session) { 26 + let did: Did; 27 + try { 28 + const auth = await requireAuth(); 29 + did = auth.session.did; 30 + } catch { 31 31 return null; 32 32 } 33 33 34 34 const [identity, profile] = await Promise.all([ 35 - db.select().from(schema.identity).where(eq(schema.identity.did, session.did)).get(), 36 - db.select().from(schema.profile).where(eq(schema.profile.did, session.did)).get(), 35 + db.select().from(schema.identity).where(eq(schema.identity.did, did)).get(), 36 + db.select().from(schema.profile).where(eq(schema.profile.did, did)).get(), 37 37 ]); 38 38 39 39 return { 40 - did: session.did, 40 + did, 41 41 handle: (identity?.handle ?? 'handle.invalid') as Handle, 42 42 displayName: profile?.displayName ?? undefined, 43 43 }; ··· 75 75 status: v.pipe(v.string(), v.minLength(1), v.maxLength(32), v.maxGraphemes(1)), 76 76 }), 77 77 async ({ status }, issue) => { 78 - const { 79 - locals: { session }, 80 - } = getRequestEvent(); 81 - 82 - if (!session) { 83 - invalid(`not signed in`); 84 - } 78 + const { session, client } = await requireAuth(); 85 79 86 80 if (!statusOptions.includes(status)) { 87 81 invalid(issue.status(`invalid status`)); 88 82 } 89 - 90 - const client = await getAuthedClient(); 91 83 92 84 const rkey = TID.now(); 93 85 const createdAt = new Date().toISOString();
+3 -11
src/routes/oauth/callback/+server.ts
··· 1 1 import { redirect } from '@sveltejs/kit'; 2 2 3 - import { APP_SESSION_COOKIE, createAppSession, deleteAppSession } from '$lib/server/auth/app-session'; 4 - import { getSignedCookie, setSignedCookie } from '$lib/server/auth/signed-cookie'; 3 + import { SESSION_COOKIE } from '$lib/server/auth'; 4 + import { setSignedCookie } from '$lib/server/auth/signed-cookie'; 5 5 import { oauth } from '$lib/server/oauth'; 6 6 7 7 export const GET = async ({ url, cookies }) => { 8 - { 9 - const existingSessionId = getSignedCookie(cookies, APP_SESSION_COOKIE); 10 - if (existingSessionId) { 11 - await deleteAppSession(existingSessionId); 12 - } 13 - } 14 - 15 8 const { session } = await oauth.callback(url.searchParams); 16 9 17 - const appSession = await createAppSession(session.did); 18 10 const secure = url.protocol === 'https:'; 19 11 20 - setSignedCookie(cookies, APP_SESSION_COOKIE, appSession.id, { 12 + setSignedCookie(cookies, SESSION_COOKIE, session.did, { 21 13 httpOnly: true, 22 14 secure: secure, 23 15 sameSite: 'lax' as const,