AppView in a box as a Vite plugin thing hatk.dev
2
fork

Configure Feed

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

feat: encrypted session cookie with handle, simplify scope error handling

- Replace HMAC-signed cookie with AES-GCM encrypted JSON containing
both DID and handle, using HKDF key derivation
- Remove wrapCallXrpcWithScopeRedirect — no longer needed now that
SvelteKit form actions handle scope errors with redirect() directly
- Simplify getViewer() to sync globalThis read (no more async cookie
parsing or $app/server imports)
- Widen viewer type to { did, handle? } across XrpcContext and handlers
- Extract scopeMissingResponse() helper, replacing 5 duplicate blocks
- Set __hatk_viewer in production SSR path around renderPage
- Add client-side 401 redirect for query endpoints (not just procedures)
- Fix unused import/param lint warnings

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

+227 -145
+4 -4
packages/hatk/package.json
··· 1 1 { 2 2 "name": "@hatk/hatk", 3 - "version": "0.0.1-alpha.34", 3 + "version": "0.0.1-alpha.35", 4 4 "license": "MIT", 5 5 "bin": { 6 6 "hatk": "dist/cli.js" ··· 41 41 "vitest": "^4", 42 42 "yaml": "^2.7.0" 43 43 }, 44 - "peerDependencies": { 45 - "vite": "^8.0.0" 46 - }, 47 44 "devDependencies": { 48 45 "@types/better-sqlite3": "^7.6.13", 49 46 "@types/react": "^19.2.14", 47 + "vite": "^8.0.0" 48 + }, 49 + "peerDependencies": { 50 50 "vite": "^8.0.0" 51 51 } 52 52 }
+10 -33
packages/hatk/src/cli.ts
··· 1410 1410 ` 1411 1411 } 1412 1412 1413 - writeFileSync(join(dir, 'AGENTS.md'), agentsMd, 1414 - ) 1413 + writeFileSync(join(dir, 'AGENTS.md'), agentsMd) 1415 1414 1416 1415 console.log(`Created ${name}/`) 1417 1416 console.log(` hatk.config.ts`) ··· 1778 1777 const typeExports: string[] = [] 1779 1778 for (const { nsid, defType } of entries) { 1780 1779 if (!defType) continue 1781 - if (nsid === 'dev.hatk.createRecord' || nsid === 'dev.hatk.deleteRecord' || nsid === 'dev.hatk.putRecord') continue 1780 + if (nsid === 'dev.hatk.createRecord' || nsid === 'dev.hatk.deleteRecord' || nsid === 'dev.hatk.putRecord') 1781 + continue 1782 1782 typeExports.push(capitalize(varNames.get(nsid)!)) 1783 1783 } 1784 1784 if (recordEntries.length > 0) { 1785 1785 typeExports.push('RecordRegistry', 'CreateRecord', 'DeleteRecord', 'PutRecord') 1786 1786 } 1787 1787 // Named defs (views, objects) — collect from emittedDefNames minus main types 1788 - const mainTypeNames = new Set(entries.filter(e => e.defType).map(e => capitalize(varNames.get(e.nsid)!))) 1788 + const mainTypeNames = new Set(entries.filter((e) => e.defType).map((e) => capitalize(varNames.get(e.nsid)!))) 1789 1789 for (const name of emittedDefNames) { 1790 1790 if (!mainTypeNames.has(name) && !typeExports.includes(name)) { 1791 1791 typeExports.push(name) ··· 1799 1799 // SSR: uses globalThis.__hatk_callXrpc bridge (direct handler invocation) 1800 1800 // Client: fetches via HTTP (GET for queries, POST for procedures, raw POST for blobs) 1801 1801 if (procedureNsids.length > 0) { 1802 - clientOut += `\nconst _procedures = new Set([${procedureNsids.map(n => `'${n}'`).join(', ')}])\n` 1802 + clientOut += `\nconst _procedures = new Set([${procedureNsids.map((n) => `'${n}'`).join(', ')}])\n` 1803 1803 } 1804 1804 if (blobInputNsids.length > 0) { 1805 - clientOut += `const _blobInputs = new Set([${blobInputNsids.map(n => `'${n}'`).join(', ')}])\n` 1805 + clientOut += `const _blobInputs = new Set([${blobInputNsids.map((n) => `'${n}'`).join(', ')}])\n` 1806 1806 } 1807 1807 1808 1808 clientOut += `\ntype CallArg<K extends keyof XrpcSchema> =\n` ··· 1840 1840 if (procedureNsids.length > 0) { 1841 1841 clientOut += ` if (_procedures.has(nsid)) {\n` 1842 1842 clientOut += ` const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(arg) })\n` 1843 + clientOut += ` if (res.status === 401) { window.location.href = '/oauth/login'; return new Promise(() => {}) as any }\n` 1843 1844 clientOut += ` if (!res.ok) throw new Error(\`XRPC \${nsid} failed: \${res.status}\`)\n` 1844 1845 clientOut += ` return res.json() as Promise<OutputOf<K>>\n` 1845 1846 clientOut += ` }\n` ··· 1848 1849 clientOut += ` if (v != null) url.searchParams.set(k, String(v))\n` 1849 1850 clientOut += ` }\n` 1850 1851 clientOut += ` const res = await fetch(url)\n` 1852 + clientOut += ` if (res.status === 401) { window.location.href = '/oauth/login'; return new Promise(() => {}) as any }\n` 1851 1853 clientOut += ` if (!res.ok) throw new Error(\`XRPC \${nsid} failed: \${res.status}\`)\n` 1852 1854 clientOut += ` return res.json() as Promise<OutputOf<K>>\n` 1853 1855 clientOut += `}\n` 1854 1856 1855 - // getViewer — async, resolves from cookies on server via getRequestEvent() 1856 - clientOut += `\nexport async function getViewer(): Promise<{ did: string } | null> {\n` 1857 - clientOut += ` if (typeof window === 'undefined') {\n` 1858 - clientOut += ` try {\n` 1859 - clientOut += ` const parse = (globalThis as any).__hatk_parseSessionCookie\n` 1860 - clientOut += ` if (parse) {\n` 1861 - clientOut += ` const { getRequestEvent } = await import('$app/server')\n` 1862 - clientOut += ` const event = getRequestEvent()\n` 1863 - clientOut += ` const cookieName = (globalThis as any).__hatk_sessionCookieName ?? '__hatk_session'\n` 1864 - clientOut += ` const cookieValue = event.cookies.get(cookieName)\n` 1865 - clientOut += ` if (cookieValue) {\n` 1866 - clientOut += ` const request = new Request('http://localhost', {\n` 1867 - clientOut += ` headers: { cookie: \`\${cookieName}=\${cookieValue}\` },\n` 1868 - clientOut += ` })\n` 1869 - clientOut += ` return parse(request)\n` 1870 - clientOut += ` }\n` 1871 - clientOut += ` }\n` 1872 - clientOut += ` } catch {}\n` 1873 - clientOut += ` return (globalThis as any).__hatk_viewer ?? null\n` 1874 - clientOut += ` }\n` 1875 - clientOut += ` try {\n` 1876 - clientOut += ` const mod = (globalThis as any).__hatk_auth\n` 1877 - clientOut += ` if (mod?.viewerDid) {\n` 1878 - clientOut += ` const did = mod.viewerDid()\n` 1879 - clientOut += ` if (did) return { did }\n` 1880 - clientOut += ` }\n` 1881 - clientOut += ` } catch {}\n` 1857 + // getViewer — returns the viewer set by layout load (server) or $effect (client) 1858 + clientOut += `\nexport function getViewer(): { did: string; handle: string } | null {\n` 1882 1859 clientOut += ` return (globalThis as any).__hatk_viewer ?? null\n` 1883 1860 clientOut += `}\n` 1884 1861
+2 -1
packages/hatk/src/dev-entry.ts
··· 100 100 plcUrl: config.plc, 101 101 collections: collectionSet, 102 102 config: config.backfill, 103 - }).then(() => rebuildAllIndexes(Array.from(collectionSet))) 103 + }) 104 + .then(() => rebuildAllIndexes(Array.from(collectionSet))) 104 105 .catch((err) => console.error('[backfill]', err.message)) 105 106 106 107 // Export the handler for Vite middleware
+9 -5
packages/hatk/src/main.ts
··· 2 2 import { mkdirSync, writeFileSync, existsSync } from 'node:fs' 3 3 import { dirname, resolve } from 'node:path' 4 4 import { registerHatkResolveHook } from './resolve-hatk.ts' 5 - import { pathToFileURL } from 'node:url' 6 - import { registerHooks } from 'node:module' 7 5 import { log } from './logger.ts' 8 6 import { loadConfig } from './config.ts' 9 7 import { loadLexicons, storeLexicons, discoverCollections, buildSchemas } from './database/schema.ts' 10 8 import { discoverViews } from './views.ts' 11 - import { initDatabase, getCursor, querySQL, getSqlDialect, getSchemaDump, migrateSchema, getDatabasePort } from './database/db.ts' 9 + import { initDatabase, getCursor, querySQL, getSqlDialect, getSchemaDump, migrateSchema } from './database/db.ts' 12 10 import { createAdapter } from './database/adapter-factory.ts' 13 11 import { getDialect } from './database/dialect.ts' 14 12 import { setSearchPort } from './database/fts.ts' ··· 110 108 await initSetup(resolve(configDir, 'setup')) 111 109 await loadOnLoginHook(resolve(configDir, 'hooks')) 112 110 await initFeeds(resolve(configDir, 'feeds')) 113 - log(`[main] Feeds initialized: ${listFeeds().map((f) => f.name).join(', ') || 'none'}`) 111 + log( 112 + `[main] Feeds initialized: ${ 113 + listFeeds() 114 + .map((f) => f.name) 115 + .join(', ') || 'none' 116 + }`, 117 + ) 114 118 await initXrpc(resolve(configDir, 'xrpc')) 115 119 log(`[main] XRPC handlers initialized: ${listXrpc().join(', ') || 'none'}`) 116 120 await initOpengraph(resolve(configDir, 'og')) ··· 193 197 onResync: runBackfillAndRestart, 194 198 }) 195 199 196 - // Expose server bridge on globalThis so SvelteKit SSR can call XRPC handlers directly 200 + // Expose server bridge on globalThis so SvelteKit SSR can call XRPC handlers directly. 197 201 ;(globalThis as any).__hatk_callXrpc = callXrpc 198 202 ;(globalThis as any).__hatk_parseSessionCookie = parseSessionCookie 199 203 ;(globalThis as any).__hatk_sessionCookieName = getSessionCookieName()
+37 -36
packages/hatk/src/oauth/session.ts
··· 1 - // SSR session cookie — signed HttpOnly cookie for server-side viewer resolution. 2 - // Separate from OAuth protocol flows but uses the same server keypair. 1 + // SSR session cookie — AES-GCM encrypted HttpOnly cookie for server-side viewer resolution. 2 + // Separate from OAuth protocol flows but uses the same server keypair for key derivation. 3 3 4 4 import { base64UrlEncode, base64UrlDecode } from './crypto.ts' 5 5 ··· 7 7 let _cookieName = '__hatk_session' 8 8 const MAX_AGE = 30 * 24 * 60 * 60 // 30 days in seconds 9 9 10 + export type SessionData = { did: string; handle: string } 11 + 10 12 export function getSessionCookieName(): string { 11 13 return _cookieName 12 14 } ··· 16 18 if (cookieName) _cookieName = cookieName 17 19 } 18 20 19 - async function hmacKey(usage: 'sign' | 'verify'): Promise<CryptoKey> { 20 - return crypto.subtle.importKey( 21 - 'raw', 22 - new TextEncoder().encode(JSON.stringify(_privateJwk, Object.keys(_privateJwk).sort())), 23 - { name: 'HMAC', hash: 'SHA-256' }, 21 + async function aesKey(): Promise<CryptoKey> { 22 + const raw = new TextEncoder().encode(JSON.stringify(_privateJwk, Object.keys(_privateJwk).sort())) 23 + const keyMaterial = await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']) 24 + return crypto.subtle.deriveKey( 25 + { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: new TextEncoder().encode('hatk-session-cookie') }, 26 + keyMaterial, 27 + { name: 'AES-GCM', length: 256 }, 24 28 false, 25 - [usage], 29 + ['encrypt', 'decrypt'], 26 30 ) 27 31 } 28 32 29 - export async function createSessionCookie(did: string): Promise<string> { 30 - const timestamp = Math.floor(Date.now() / 1000) 31 - const payload = `${did}.${timestamp}` 32 - const key = await hmacKey('sign') 33 - const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload)) 34 - return `${payload}.${base64UrlEncode(new Uint8Array(sig))}` 33 + export async function createSessionCookie(data: SessionData): Promise<string> { 34 + const payload = JSON.stringify({ ...data, ts: Math.floor(Date.now() / 1000) }) 35 + const iv = crypto.getRandomValues(new Uint8Array(12)) 36 + const key = await aesKey() 37 + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(payload)) 38 + return `${base64UrlEncode(iv)}.${base64UrlEncode(new Uint8Array(ciphertext))}` 35 39 } 36 40 37 41 export function sessionCookieHeader(value: string, secure: boolean): string { 38 - const parts = [ 39 - `${_cookieName}=${value}`, 40 - 'HttpOnly', 41 - 'SameSite=Lax', 42 - 'Path=/', 43 - `Max-Age=${MAX_AGE}`, 44 - ] 42 + const parts = [`${_cookieName}=${value}`, 'HttpOnly', 'SameSite=Lax', 'Path=/', `Max-Age=${MAX_AGE}`] 45 43 if (secure) parts.push('Secure') 46 44 return parts.join('; ') 47 45 } ··· 50 48 return `${_cookieName}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0` 51 49 } 52 50 53 - export async function parseSessionCookie(request: Request): Promise<{ did: string } | null> { 51 + export async function parseSessionCookie(request: Request): Promise<SessionData | null> { 54 52 const cookieHeader = request.headers.get('cookie') 55 53 if (!cookieHeader) return null 56 - const match = cookieHeader.split(';').map(c => c.trim()).find(c => c.startsWith(`${_cookieName}=`)) 54 + const match = cookieHeader 55 + .split(';') 56 + .map((c) => c.trim()) 57 + .find((c) => c.startsWith(`${_cookieName}=`)) 57 58 if (!match) return null 58 59 const value = match.slice(_cookieName.length + 1) 59 60 const parts = value.split('.') 60 - // Format: did:plc:xxx.timestamp.signature — DID contains dots so take last 2 parts 61 - if (parts.length < 3) return null 62 - const signature = parts.pop()! 63 - const timestamp = parts.pop()! 64 - const did = parts.join('.') 65 - const ts = Number(timestamp) 66 - if (isNaN(ts) || (Date.now() / 1000 - ts) > MAX_AGE) return null 67 - const payload = `${did}.${timestamp}` 68 - const key = await hmacKey('verify') 69 - const sigBytes = base64UrlDecode(signature) as Uint8Array<ArrayBuffer> 70 - const valid = await crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(payload)) 71 - if (!valid) return null 72 - return { did } 61 + if (parts.length !== 2) return null 62 + try { 63 + const iv = base64UrlDecode(parts[0]) as Uint8Array<ArrayBuffer> 64 + const ciphertext = base64UrlDecode(parts[1]) as Uint8Array<ArrayBuffer> 65 + const key = await aesKey() 66 + const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext) 67 + const data = JSON.parse(new TextDecoder().decode(plaintext)) 68 + if (!data.did || !data.handle || !data.ts) return null 69 + if (Date.now() / 1000 - data.ts > MAX_AGE) return null 70 + return { did: data.did, handle: data.handle } 71 + } catch { 72 + return null 73 + } 73 74 }
+34 -7
packages/hatk/src/pds-proxy.ts
··· 1 1 // Shared PDS proxy functions — used by both HTTP route handlers and XRPC handlers. 2 2 3 3 import type { OAuthConfig } from './config.ts' 4 - import { getSession, getServerKey } from './oauth/db.ts' 4 + import { getSession, getServerKey, deleteSession } from './oauth/db.ts' 5 5 import { createDpopProof } from './oauth/dpop.ts' 6 6 import { refreshPdsSession } from './oauth/server.ts' 7 7 import { validateRecord } from '@bigmoves/lexicon' ··· 10 10 import { emit } from './logger.ts' 11 11 12 12 export class ProxyError extends Error { 13 - constructor(public status: number, message: string) { 13 + constructor( 14 + public status: number, 15 + message: string, 16 + ) { 14 17 super(message) 18 + } 19 + } 20 + 21 + export class ScopeMissingProxyError extends ProxyError { 22 + constructor() { 23 + super(401, 'ScopeMissingError') 15 24 } 16 25 } 17 26 ··· 48 57 } 49 58 } 50 59 51 - // Step 2: handle expired PDS token — refresh and retry 60 + // Step 2: handle insufficient scope — clear session so user re-authenticates with updated scopes 61 + if (result.body.error === 'ScopeMissingError') { 62 + await deleteSession(session.did) 63 + throw new ScopeMissingProxyError() 64 + } 65 + 66 + // Step 3: handle expired PDS token — refresh and retry 52 67 if (result.body.error === 'invalid_token') { 53 68 const refreshed = await refreshPdsSession(oauthConfig, session) 54 69 if (refreshed) { ··· 130 145 ): Promise<{ uri?: string; cid?: string }> { 131 146 const validationError = validateRecord(getLexiconArray(), input.collection, input.record) 132 147 if (validationError) { 133 - throw new ProxyError(400, `InvalidRecord: ${validationError.path ? validationError.path + ': ' : ''}${validationError.message}`) 148 + throw new ProxyError( 149 + 400, 150 + `InvalidRecord: ${validationError.path ? validationError.path + ': ' : ''}${validationError.message}`, 151 + ) 134 152 } 135 153 136 154 const session = await getSession(viewer.did) ··· 150 168 try { 151 169 await insertRecord(input.collection, String(pdsRes.body.uri), String(pdsRes.body.cid), viewer.did, input.record) 152 170 } catch (err: unknown) { 153 - emit('pds-proxy', 'local_index_error', { op: 'createRecord', error: err instanceof Error ? err.message : String(err) }) 171 + emit('pds-proxy', 'local_index_error', { 172 + op: 'createRecord', 173 + error: err instanceof Error ? err.message : String(err), 174 + }) 154 175 } 155 176 156 177 return pdsRes.body as { uri?: string; cid?: string } ··· 178 199 const uri = `at://${viewer.did}/${input.collection}/${input.rkey}` 179 200 await dbDeleteRecord(input.collection, uri) 180 201 } catch (err: unknown) { 181 - emit('pds-proxy', 'local_index_error', { op: 'deleteRecord', error: err instanceof Error ? err.message : String(err) }) 202 + emit('pds-proxy', 'local_index_error', { 203 + op: 'deleteRecord', 204 + error: err instanceof Error ? err.message : String(err), 205 + }) 182 206 } 183 207 184 208 return pdsRes.body ··· 191 215 ): Promise<{ uri?: string; cid?: string }> { 192 216 const validationError = validateRecord(getLexiconArray(), input.collection, input.record) 193 217 if (validationError) { 194 - throw new ProxyError(400, `InvalidRecord: ${validationError.path ? validationError.path + ': ' : ''}${validationError.message}`) 218 + throw new ProxyError( 219 + 400, 220 + `InvalidRecord: ${validationError.path ? validationError.path + ': ' : ''}${validationError.message}`, 221 + ) 195 222 } 196 223 197 224 const session = await getSession(viewer.did)
+116 -43
packages/hatk/src/server.ts
··· 26 26 import { handleOpengraphRequest, buildOgMeta } from './opengraph.ts' 27 27 import { getLabelDefinitions, rescanLabels } from './labels.ts' 28 28 import { triggerAutoBackfill } from './indexer.ts' 29 - import { log, emit, timer } from './logger.ts' 29 + import { emit, timer } from './logger.ts' 30 30 import { 31 31 getAuthServerMetadata, 32 32 getProtectedResourceMetadata, ··· 39 39 handleToken, 40 40 authenticate, 41 41 } from './oauth/server.ts' 42 - import { createSessionCookie, sessionCookieHeader, clearSessionCookieHeader, parseSessionCookie } from './oauth/session.ts' 42 + import { 43 + createSessionCookie, 44 + sessionCookieHeader, 45 + clearSessionCookieHeader, 46 + parseSessionCookie, 47 + } from './oauth/session.ts' 43 48 import { getOAuthRequest } from './oauth/db.ts' 44 49 import type { OAuthConfig } from './config.ts' 45 - import { pdsCreateRecord, pdsDeleteRecord, pdsPutRecord, pdsUploadBlob, ProxyError } from './pds-proxy.ts' 50 + import { 51 + pdsCreateRecord, 52 + pdsDeleteRecord, 53 + pdsPutRecord, 54 + pdsUploadBlob, 55 + ProxyError, 56 + ScopeMissingProxyError, 57 + } from './pds-proxy.ts' 46 58 import { json, jsonError, cors, withCors, file, notFound } from './response.ts' 47 59 import { serve } from './adapter.ts' 48 60 import { renderPage } from './renderer.ts' 49 61 62 + function scopeMissingResponse(acceptEncoding: string | null): Response { 63 + const res = withCors(jsonError(401, 'ScopeMissingError', acceptEncoding)) 64 + res.headers.append('Set-Cookie', clearSessionCookieHeader()) 65 + return res 66 + } 67 + 50 68 const MIME: Record<string, string> = { 51 69 '.html': 'text/html', 52 70 '.js': 'application/javascript', ··· 201 219 202 220 function requireAdmin(viewer: { did: string } | null, acceptEncoding: string | null): Response | null { 203 221 if (!viewer) return withCors(jsonError(401, 'Authentication required', acceptEncoding)) 204 - if (!devMode && !admins.includes(viewer.did)) return withCors(jsonError(403, 'Admin access required', acceptEncoding)) 222 + if (!devMode && !admins.includes(viewer.did)) 223 + return withCors(jsonError(403, 'Admin access required', acceptEncoding)) 205 224 return null // auth OK 206 225 } 207 226 ··· 220 239 const requestOrigin = `${request.headers.get('x-forwarded-proto') || 'http'}://${request.headers.get('host') || 'localhost'}` 221 240 222 241 // Authenticate viewer (optional — unauthenticated requests still work) 223 - let viewer: { did: string } | null = config.resolveViewer?.(request) ?? null 242 + let viewer: { did: string; handle?: string } | null = config.resolveViewer?.(request) ?? null 224 243 if (!viewer && oauth) { 225 244 try { 226 245 viewer = await authenticate( ··· 361 380 if (url.pathname === coreXrpc('putPreference') && request.method === 'POST') { 362 381 if (!viewer) return withCors(jsonError(401, 'Authentication required', acceptEncoding)) 363 382 const body = JSON.parse(await request.text()) 364 - if (!body.key || typeof body.key !== 'string') return withCors(jsonError(400, 'Missing or invalid key', acceptEncoding)) 383 + if (!body.key || typeof body.key !== 'string') 384 + return withCors(jsonError(400, 'Missing or invalid key', acceptEncoding)) 365 385 if (body.value === undefined) return withCors(jsonError(400, 'Missing value', acceptEncoding)) 366 386 await putPreference(viewer.did, body.key, body.value) 367 387 return withCors(json({ success: true }, 200, acceptEncoding)) ··· 371 391 372 392 // POST /admin/repos/add — enqueue DIDs for backfill 373 393 if (url.pathname === '/admin/repos/add' && request.method === 'POST') { 374 - const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 394 + const denied = requireAdmin(viewer, acceptEncoding) 395 + if (denied) return denied 375 396 const { dids } = JSON.parse(await request.text()) 376 397 if (!Array.isArray(dids)) return withCors(jsonError(400, 'Missing dids array', acceptEncoding)) 377 398 for (const did of dids) { ··· 383 404 384 405 // POST /admin/labels/rescan — retroactively apply label rules 385 406 if (url.pathname === '/admin/labels/rescan' && request.method === 'POST') { 386 - const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 407 + const denied = requireAdmin(viewer, acceptEncoding) 408 + if (denied) return denied 387 409 const result = await rescanLabels(collections) 388 410 return withCors(json(result, 200, acceptEncoding)) 389 411 } ··· 392 414 393 415 // GET /admin/whoami — check if current viewer is admin 394 416 if (url.pathname === '/admin/whoami') { 395 - const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 417 + const denied = requireAdmin(viewer, acceptEncoding) 418 + if (denied) return denied 396 419 return withCors(json({ did: viewer!.did, admin: true }, 200, acceptEncoding)) 397 420 } 398 421 399 422 // GET /admin/labels/definitions — get available label definitions 400 423 if (url.pathname === '/admin/labels/definitions') { 401 - const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 424 + const denied = requireAdmin(viewer, acceptEncoding) 425 + if (denied) return denied 402 426 return withCors(json({ definitions: getLabelDefinitions() }, 200, acceptEncoding)) 403 427 } 404 428 405 429 // POST /admin/labels — apply a label 406 430 if (url.pathname === '/admin/labels' && request.method === 'POST') { 407 - const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 431 + const denied = requireAdmin(viewer, acceptEncoding) 432 + if (denied) return denied 408 433 const { uri, val } = JSON.parse(await request.text()) 409 434 if (!uri || !val) return withCors(jsonError(400, 'Missing uri or val', acceptEncoding)) 410 435 await insertLabels([{ src: 'admin', uri, val }]) ··· 413 438 414 439 // POST /admin/labels/reset — delete all labels of a given type 415 440 if (url.pathname === '/admin/labels/reset' && request.method === 'POST') { 416 - const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 441 + const denied = requireAdmin(viewer, acceptEncoding) 442 + if (denied) return denied 417 443 const { val } = JSON.parse(await request.text()) 418 444 if (!val) return withCors(jsonError(400, 'Missing val', acceptEncoding)) 419 445 const result = await querySQL(`SELECT COUNT(*)::INTEGER as count FROM _labels WHERE val = $1`, [val]) ··· 424 450 425 451 // POST /admin/labels/negate — negate a label 426 452 if (url.pathname === '/admin/labels/negate' && request.method === 'POST') { 427 - const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 453 + const denied = requireAdmin(viewer, acceptEncoding) 454 + if (denied) return denied 428 455 const { uri, val } = JSON.parse(await request.text()) 429 456 if (!uri || !val) return withCors(jsonError(400, 'Missing uri or val', acceptEncoding)) 430 457 await insertLabels([{ src: 'admin', uri, val, neg: true }]) ··· 433 460 434 461 // POST /admin/takedown — takedown an account 435 462 if (url.pathname === '/admin/takedown' && request.method === 'POST') { 436 - const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 463 + const denied = requireAdmin(viewer, acceptEncoding) 464 + if (denied) return denied 437 465 const { did } = JSON.parse(await request.text()) 438 466 if (!did) return withCors(jsonError(400, 'Missing did', acceptEncoding)) 439 467 await setRepoStatus(did, 'takendown') ··· 442 470 443 471 // POST /admin/reverse-takedown — reverse a takedown 444 472 if (url.pathname === '/admin/reverse-takedown' && request.method === 'POST') { 445 - const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 473 + const denied = requireAdmin(viewer, acceptEncoding) 474 + if (denied) return denied 446 475 const { did } = JSON.parse(await request.text()) 447 476 if (!did) return withCors(jsonError(400, 'Missing did', acceptEncoding)) 448 477 await setRepoStatus(did, 'active') ··· 451 480 452 481 // GET /admin/search — search records or accounts 453 482 if (url.pathname === '/admin/search') { 454 - const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 483 + const denied = requireAdmin(viewer, acceptEncoding) 484 + if (denied) return denied 455 485 const q = url.searchParams.get('q') || '' 456 486 const type = url.searchParams.get('type') || 'records' 457 487 const limit = parseInt(url.searchParams.get('limit') || '20') ··· 494 524 const rec = await getRecordByUri(q) 495 525 if (rec) { 496 526 const labelsMap = await queryLabelsForUris([rec.uri]) 497 - return withCors(json({ 498 - records: [{ ...reshapeRow(rec, rec?.__childData), labels: labelsMap.get(rec.uri) || [] }], 499 - }, 200, acceptEncoding)) 527 + return withCors( 528 + json( 529 + { 530 + records: [{ ...reshapeRow(rec, rec?.__childData), labels: labelsMap.get(rec.uri) || [] }], 531 + }, 532 + 200, 533 + acceptEncoding, 534 + ), 535 + ) 500 536 } else { 501 537 return withCors(json({ records: [] }, 200, acceptEncoding)) 502 538 } ··· 541 577 542 578 // POST /admin/repos/resync — re-download repos 543 579 if (url.pathname === '/admin/repos/resync' && request.method === 'POST') { 544 - const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 580 + const denied = requireAdmin(viewer, acceptEncoding) 581 + if (denied) return denied 545 582 const bodyText = await request.text() 546 583 const { dids } = bodyText ? JSON.parse(bodyText) : ({} as { dids?: string[] }) 547 584 let repoList: string[] ··· 560 597 561 598 // POST /admin/repos/remove — remove DIDs from tracking 562 599 if (url.pathname === '/admin/repos/remove' && request.method === 'POST') { 563 - const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 600 + const denied = requireAdmin(viewer, acceptEncoding) 601 + if (denied) return denied 564 602 const { dids } = JSON.parse(await request.text()) 565 603 if (!Array.isArray(dids)) return withCors(jsonError(400, 'Missing dids array', acceptEncoding)) 566 604 for (const did of dids) { ··· 571 609 572 610 // GET /admin/info — aggregate status + db size + collection counts 573 611 if (url.pathname === '/admin/info') { 574 - const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 612 + const denied = requireAdmin(viewer, acceptEncoding) 613 + if (denied) return denied 575 614 const rows = await querySQL(`SELECT status, COUNT(*)::INTEGER as count FROM _repos GROUP BY status`) 576 615 const counts: Record<string, number> = {} 577 616 for (const row of rows) counts[row.status as string] = Number(row.count) ··· 585 624 heapTotal: `${(mem.heapTotal / 1024 / 1024).toFixed(1)} MiB`, 586 625 external: `${(mem.external / 1024 / 1024).toFixed(1)} MiB`, 587 626 } 588 - return withCors(json({ repos: counts, duckdb: dbInfo, node, collections: collectionCounts }, 200, acceptEncoding)) 627 + return withCors( 628 + json({ repos: counts, duckdb: dbInfo, node, collections: collectionCounts }, 200, acceptEncoding), 629 + ) 589 630 } 590 631 591 632 // GET /admin/info/:did — repo status info 592 633 if (url.pathname.startsWith('/admin/info/did:')) { 593 - const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 634 + const denied = requireAdmin(viewer, acceptEncoding) 635 + if (denied) return denied 594 636 const did = url.pathname.slice('/admin/info/'.length) 595 637 const status = await getRepoStatus(did) 596 638 if (!status) return withCors(jsonError(404, 'Repo not found', acceptEncoding)) 597 639 const retryInfo = await getRepoRetryInfo(did) 598 - return withCors(json({ 599 - did, 600 - status, 601 - retry_count: retryInfo?.retryCount ?? 0, 602 - retry_after: retryInfo?.retryAfter ?? 0, 603 - }, 200, acceptEncoding)) 640 + return withCors( 641 + json( 642 + { 643 + did, 644 + status, 645 + retry_count: retryInfo?.retryCount ?? 0, 646 + retry_after: retryInfo?.retryAfter ?? 0, 647 + }, 648 + 200, 649 + acceptEncoding, 650 + ), 651 + ) 604 652 } 605 653 606 654 // GET /admin/repos — paginated repo listing 607 655 if (url.pathname === '/admin/repos' && request.method === 'GET') { 608 - const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 656 + const denied = requireAdmin(viewer, acceptEncoding) 657 + if (denied) return denied 609 658 const limit = parseInt(url.searchParams.get('limit') || '50') 610 659 const offset = parseInt(url.searchParams.get('offset') || '0') 611 660 const status = url.searchParams.get('status') || undefined ··· 616 665 617 666 // GET /admin/schema — full DuckDB DDL dump + lexicons 618 667 if (url.pathname === '/admin/schema') { 619 - const denied = requireAdmin(viewer, acceptEncoding); if (denied) return denied 668 + const denied = requireAdmin(viewer, acceptEncoding) 669 + if (denied) return denied 620 670 const { getAllLexicons } = await import('./database/schema.ts') 621 671 const ddl = await getSchemaDump() 622 672 return withCors(json({ ddl, lexicons: getAllLexicons() }, 200, acceptEncoding)) ··· 641 691 const status = await getRepoStatus(did) 642 692 if (!status) return withCors(jsonError(404, 'Repo not found', acceptEncoding)) 643 693 const retryInfo = await getRepoRetryInfo(did) 644 - return withCors(json({ 645 - did, 646 - status, 647 - retry_count: retryInfo?.retryCount ?? 0, 648 - retry_after: retryInfo?.retryAfter ?? 0, 649 - }, 200, acceptEncoding)) 694 + return withCors( 695 + json( 696 + { 697 + did, 698 + status, 699 + retry_count: retryInfo?.retryCount ?? 0, 700 + retry_after: retryInfo?.retryAfter ?? 0, 701 + }, 702 + 200, 703 + acceptEncoding, 704 + ), 705 + ) 650 706 } 651 707 652 708 // --- OAuth Endpoints --- ··· 669 725 if (url.pathname === '/__dev/login' && devMode && oauth) { 670 726 const did = url.searchParams.get('did') 671 727 if (!did) return withCors(jsonError(400, 'did required', acceptEncoding)) 672 - const cookieValue = await createSessionCookie(did) 728 + const handleRows = await querySQL('SELECT handle FROM _repos WHERE did = $1', [did]) 729 + const handle = handleRows[0]?.handle ?? did 730 + const cookieValue = await createSessionCookie({ did, handle }) 673 731 const secure = url.protocol === 'https:' 674 732 return new Response(JSON.stringify({ ok: true }), { 675 733 status: 200, ··· 686 744 if (!handle) return withCors(jsonError(400, 'handle required', acceptEncoding)) 687 745 try { 688 746 const redirectUrl = await serverLogin(oauth, handle) 689 - return new Response(null, { status: 302, headers: { Location: redirectUrl } }) 747 + return new Response(null, { 748 + status: 302, 749 + headers: { Location: redirectUrl, 'Set-Cookie': clearSessionCookieHeader() }, 750 + }) 690 751 } catch (err: unknown) { 691 752 const message = err instanceof Error ? err.message : 'Login failed' 692 753 return withCors(jsonError(400, message, acceptEncoding)) ··· 733 794 if (!code) return withCors(jsonError(400, 'Missing code', acceptEncoding)) 734 795 const result = await handleCallback(oauth, code, state, iss) 735 796 const isSecure = requestOrigin.startsWith('https') 736 - const cookie = await createSessionCookie(result.did) 797 + const handleRows = await querySQL('SELECT handle FROM _repos WHERE did = $1', [result.did]) 798 + const handle = handleRows[0]?.handle ?? result.did 799 + const cookie = await createSessionCookie({ did: result.did, handle }) 737 800 // Server-initiated login stores redirectUri as '/' — redirect cleanly without code/iss params 738 801 const redirectTo = result.clientRedirectUri.startsWith('/?code=') ? '/' : result.clientRedirectUri 739 802 return new Response(null, { ··· 778 841 const result = await pdsCreateRecord(oauth, viewer, body) 779 842 return withCors(json(result, 200, acceptEncoding)) 780 843 } catch (err: any) { 844 + if (err instanceof ScopeMissingProxyError) return scopeMissingResponse(acceptEncoding) 781 845 if (err instanceof ProxyError) return withCors(jsonError(err.status, err.message, acceptEncoding)) 782 846 throw err 783 847 } ··· 791 855 const result = await pdsDeleteRecord(oauth, viewer, body) 792 856 return withCors(json(result, 200, acceptEncoding)) 793 857 } catch (err: any) { 858 + if (err instanceof ScopeMissingProxyError) return scopeMissingResponse(acceptEncoding) 794 859 if (err instanceof ProxyError) return withCors(jsonError(err.status, err.message, acceptEncoding)) 795 860 throw err 796 861 } ··· 804 869 const result = await pdsPutRecord(oauth, viewer, body) 805 870 return withCors(json(result, 200, acceptEncoding)) 806 871 } catch (err: any) { 872 + if (err instanceof ScopeMissingProxyError) return scopeMissingResponse(acceptEncoding) 807 873 if (err instanceof ProxyError) return withCors(jsonError(err.status, err.message, acceptEncoding)) 808 874 throw err 809 875 } ··· 818 884 const result = await pdsUploadBlob(oauth, viewer, rawBody, contentType) 819 885 return withCors(json(result, 200, acceptEncoding)) 820 886 } catch (err: any) { 887 + if (err instanceof ScopeMissingProxyError) return scopeMissingResponse(acceptEncoding) 821 888 if (err instanceof ProxyError) return withCors(jsonError(err.status, err.message, acceptEncoding)) 822 889 throw err 823 890 } ··· 881 948 const result = await executeXrpc(method, params, cursor, limit, viewer, input) 882 949 if (result) return withCors(json(result, 200, acceptEncoding)) 883 950 } catch (err: any) { 951 + if (err instanceof ScopeMissingProxyError) return scopeMissingResponse(acceptEncoding) 884 952 if (err instanceof InvalidRequestError) { 885 953 return withCors(jsonError(err.status, err.errorName || err.message, acceptEncoding)) 886 954 } ··· 915 983 const ogMeta = buildOgMeta(url.pathname, requestOrigin) 916 984 917 985 // Try SSR first 918 - const renderedHtml = await renderPage(template, request, ogMeta) 986 + ;(globalThis as any).__hatk_viewer = viewer 987 + let renderedHtml: string | null 988 + try { 989 + renderedHtml = await renderPage(template, request, ogMeta) 990 + } finally { 991 + ;(globalThis as any).__hatk_viewer = null 992 + } 919 993 if (renderedHtml) { 920 994 return withCors(file(Buffer.from(renderedHtml), 'text/html')) 921 995 } ··· 961 1035 const handler = createHandler({ collections, publicDir, oauth, admins, resolveViewer, onResync }) 962 1036 return serve(handler, port) 963 1037 } 964 -
+11 -8
packages/hatk/src/vite-plugin.ts
··· 73 73 74 74 // Rewrite $hatk imports in source code so SSR module runners can resolve them. 75 75 // vite-plus's fetchModule bypasses resolve.alias for bare imports. 76 - transform(code: string, id: string) { 76 + transform(code: string, _id: string) { 77 77 if (!code.includes('$hatk')) return 78 78 const hatk = resolve('hatk.generated.ts') 79 79 const hatkClient = resolve('hatk.generated.client.ts') ··· 87 87 resolve: { 88 88 alias: { 89 89 '$hatk/client': resolve('hatk.generated.client.ts'), 90 - '$hatk': resolve('hatk.generated.ts'), 90 + $hatk: resolve('hatk.generated.ts'), 91 91 }, 92 92 }, 93 93 environments: { ··· 154 154 ;(globalThis as any).__hatk_callXrpc = mod.callXrpc 155 155 156 156 // Capture cookie parser and name for SSR viewer resolution 157 - const ssrParseSessionCookie: ((request: Request) => Promise<{ did: string } | null>) | null = mod.parseSessionCookie ?? null 157 + const ssrParseSessionCookie: ((request: Request) => Promise<{ did: string } | null>) | null = 158 + mod.parseSessionCookie ?? null 158 159 ;(globalThis as any).__hatk_parseSessionCookie = ssrParseSessionCookie 159 160 ;(globalThis as any).__hatk_sessionCookieName = mod.getSessionCookieName?.() ?? '__hatk_session' 160 161 ··· 263 264 if (!reloadTimer) { 264 265 reloadTimer = setTimeout(() => { 265 266 reloadTimer = null 266 - reloadServer!().then(() => { 267 - console.log('[hatk] Server handlers reloaded') 268 - }).catch((err: any) => { 269 - console.error('[hatk] Failed to reload server handlers:', err.message) 270 - }) 267 + reloadServer!() 268 + .then(() => { 269 + console.log('[hatk] Server handlers reloaded') 270 + }) 271 + .catch((err: any) => { 272 + console.error('[hatk] Failed to reload server handlers:', err.message) 273 + }) 271 274 }, 50) 272 275 } 273 276 }
+4 -8
packages/hatk/src/xrpc.ts
··· 79 79 input: I 80 80 cursor?: string 81 81 limit: number 82 - viewer: { did: string } | null 82 + viewer: { did: string; handle?: string } | null 83 83 packCursor: (primary: string | number, cid: string) => string 84 84 unpackCursor: (cursor: string) => { primary: string; cid: string } | null 85 85 isTakendown: (did: string) => Promise<boolean> ··· 108 108 params: Record<string, string>, 109 109 cursor: string | undefined, 110 110 limit: number, 111 - viewer: { did: string } | null, 111 + viewer: { did: string; handle?: string } | null, 112 112 input?: unknown, 113 113 ) => Promise<any> 114 114 } ··· 316 316 } 317 317 318 318 /** Call a registered XRPC handler directly (no HTTP). For use in SSR renderers. */ 319 - export async function callXrpc( 320 - nsid: string, 321 - params: Record<string, any> = {}, 322 - input?: unknown, 323 - ): Promise<any> { 319 + export async function callXrpc(nsid: string, params: Record<string, any> = {}, input?: unknown): Promise<any> { 324 320 const viewer = (globalThis as any).__hatk_viewer ?? null 325 321 // In externalized module context (e.g. SSR), delegate to the runner's callXrpc via globalThis. 326 322 // The runner's module instance has all registered handlers; this (Node's) instance may not. ··· 348 344 params: Record<string, string>, 349 345 cursor: string | undefined, 350 346 limit: number, 351 - viewer: { did: string } | null, 347 + viewer: { did: string; handle?: string } | null, 352 348 input?: unknown, 353 349 ) => Promise<any>, 354 350 ): void {