Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

init

Changed files
+908
lexicons
src
+43
api.md
··· 1 + /** 2 + * AUTHENTICATION ROUTES 3 + * 4 + * Handles OAuth authentication flow for Bluesky/ATProto accounts 5 + * All routes are on the editor.wisp.place subdomain 6 + * 7 + * Routes: 8 + * POST /api/auth/signin - Initiate OAuth sign-in flow 9 + * GET /api/auth/callback - OAuth callback handler (redirect from PDS) 10 + * GET /api/auth/status - Check current authentication status 11 + * POST /api/auth/logout - Sign out and clear session 12 + */ 13 + 14 + /** 15 + * CUSTOM DOMAIN ROUTES 16 + * 17 + * Handles custom domain (BYOD - Bring Your Own Domain) management 18 + * Users can claim custom domains with DNS verification (TXT + CNAME) 19 + * and map them to their sites 20 + * 21 + * Routes: 22 + * GET /api/check-domain - Fast verification check for routing (public) 23 + * GET /api/custom-domains - List user's custom domains 24 + * POST /api/custom-domains/check - Check domain availability and DNS config 25 + * POST /api/custom-domains/claim - Claim a custom domain 26 + * PUT /api/custom-domains/:id/site - Update site mapping 27 + * DELETE /api/custom-domains/:id - Remove a custom domain 28 + * POST /api/custom-domains/:id/verify - Manually trigger verification 29 + */ 30 + 31 + /** 32 + * WISP SITE MANAGEMENT ROUTES 33 + * 34 + * API endpoints for managing user's Wisp sites stored in ATProto repos 35 + * Handles reading site metadata, fetching content, updating sites, and uploads 36 + * All routes are on the editor.wisp.place subdomain 37 + * 38 + * Routes: 39 + * GET /wisp/sites - List all sites for authenticated user 40 + * GET /wisp/fs/:site - Get site record (metadata/manifest) 41 + * GET /wisp/fs/:site/file/* - Get individual file content by path 42 + * POST /wisp/upload-files - Upload and deploy files as a site 43 + */
+48
lexicons/fs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.wisp.fs", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Virtual filesystem manifest for a Wisp site", 8 + "record": { 9 + "type": "object", 10 + "required": ["site", "root", "createdAt"], 11 + "properties": { 12 + "site": { "type": "string" }, 13 + "root": { "type": "ref", "ref": "#directory" }, 14 + "fileCount": { "type": "integer", "minimum": 0, "maximum": 1000 }, 15 + "createdAt": { "type": "string", "format": "datetime" } 16 + } 17 + } 18 + }, 19 + "file": { 20 + "type": "object", 21 + "required": ["type", "hash"], 22 + "properties": { 23 + "type": { "type": "string", "const": "file" }, 24 + "hash": { "type": "string", "description": "Content blob hash" } 25 + } 26 + }, 27 + "directory": { 28 + "type": "object", 29 + "required": ["type", "entries"], 30 + "properties": { 31 + "type": { "type": "string", "const": "directory" }, 32 + "entries": { 33 + "type": "array", 34 + "maxLength": 500, 35 + "items": { "type": "ref", "ref": "#entry" } 36 + } 37 + } 38 + }, 39 + "entry": { 40 + "type": "object", 41 + "required": ["name", "node"], 42 + "properties": { 43 + "name": { "type": "string", "maxLength": 255 }, 44 + "node": { "type": "union", "refs": ["#file", "#directory"] } 45 + } 46 + } 47 + } 48 + }
+44
src/lexicon/index.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { 5 + type Auth, 6 + type Options as XrpcOptions, 7 + Server as XrpcServer, 8 + type StreamConfigOrHandler, 9 + type MethodConfigOrHandler, 10 + createServer as createXrpcServer, 11 + } from '@atproto/xrpc-server' 12 + import { schemas } from './lexicons.js' 13 + 14 + export function createServer(options?: XrpcOptions): Server { 15 + return new Server(options) 16 + } 17 + 18 + export class Server { 19 + xrpc: XrpcServer 20 + place: PlaceNS 21 + 22 + constructor(options?: XrpcOptions) { 23 + this.xrpc = createXrpcServer(schemas, options) 24 + this.place = new PlaceNS(this) 25 + } 26 + } 27 + 28 + export class PlaceNS { 29 + _server: Server 30 + wisp: PlaceWispNS 31 + 32 + constructor(server: Server) { 33 + this._server = server 34 + this.wisp = new PlaceWispNS(server) 35 + } 36 + } 37 + 38 + export class PlaceWispNS { 39 + _server: Server 40 + 41 + constructor(server: Server) { 42 + this._server = server 43 + } 44 + }
+125
src/lexicon/lexicons.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { 5 + type LexiconDoc, 6 + Lexicons, 7 + ValidationError, 8 + type ValidationResult, 9 + } from '@atproto/lexicon' 10 + import { type $Typed, is$typed, maybe$typed } from './util.js' 11 + 12 + export const schemaDict = { 13 + PlaceWispFs: { 14 + lexicon: 1, 15 + id: 'place.wisp.fs', 16 + defs: { 17 + main: { 18 + type: 'record', 19 + description: 'Virtual filesystem manifest for a Wisp site', 20 + record: { 21 + type: 'object', 22 + required: ['site', 'root', 'createdAt'], 23 + properties: { 24 + site: { 25 + type: 'string', 26 + }, 27 + root: { 28 + type: 'ref', 29 + ref: 'lex:place.wisp.fs#directory', 30 + }, 31 + fileCount: { 32 + type: 'integer', 33 + minimum: 0, 34 + maximum: 1000, 35 + }, 36 + createdAt: { 37 + type: 'string', 38 + format: 'datetime', 39 + }, 40 + }, 41 + }, 42 + }, 43 + file: { 44 + type: 'object', 45 + required: ['type', 'hash'], 46 + properties: { 47 + type: { 48 + type: 'string', 49 + const: 'file', 50 + }, 51 + hash: { 52 + type: 'string', 53 + description: 'Content blob hash', 54 + }, 55 + }, 56 + }, 57 + directory: { 58 + type: 'object', 59 + required: ['type', 'entries'], 60 + properties: { 61 + type: { 62 + type: 'string', 63 + const: 'directory', 64 + }, 65 + entries: { 66 + type: 'array', 67 + maxLength: 500, 68 + items: { 69 + type: 'ref', 70 + ref: 'lex:place.wisp.fs#entry', 71 + }, 72 + }, 73 + }, 74 + }, 75 + entry: { 76 + type: 'object', 77 + required: ['name', 'node'], 78 + properties: { 79 + name: { 80 + type: 'string', 81 + maxLength: 255, 82 + }, 83 + node: { 84 + type: 'union', 85 + refs: ['lex:place.wisp.fs#file', 'lex:place.wisp.fs#directory'], 86 + }, 87 + }, 88 + }, 89 + }, 90 + }, 91 + } as const satisfies Record<string, LexiconDoc> 92 + export const schemas = Object.values(schemaDict) satisfies LexiconDoc[] 93 + export const lexicons: Lexicons = new Lexicons(schemas) 94 + 95 + export function validate<T extends { $type: string }>( 96 + v: unknown, 97 + id: string, 98 + hash: string, 99 + requiredType: true, 100 + ): ValidationResult<T> 101 + export function validate<T extends { $type?: string }>( 102 + v: unknown, 103 + id: string, 104 + hash: string, 105 + requiredType?: false, 106 + ): ValidationResult<T> 107 + export function validate( 108 + v: unknown, 109 + id: string, 110 + hash: string, 111 + requiredType?: boolean, 112 + ): ValidationResult { 113 + return (requiredType ? is$typed : maybe$typed)(v, id, hash) 114 + ? lexicons.validate(`${id}#${hash}`, v) 115 + : { 116 + success: false, 117 + error: new ValidationError( 118 + `Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`, 119 + ), 120 + } 121 + } 122 + 123 + export const ids = { 124 + PlaceWispFs: 'place.wisp.fs', 125 + } as const
+79
src/lexicon/types/place/wisp/fs.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../lexicons' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 + 9 + const is$typed = _is$typed, 10 + validate = _validate 11 + const id = 'place.wisp.fs' 12 + 13 + export interface Record { 14 + $type: 'place.wisp.fs' 15 + site: string 16 + root: Directory 17 + fileCount?: number 18 + createdAt: string 19 + [k: string]: unknown 20 + } 21 + 22 + const hashRecord = 'main' 23 + 24 + export function isRecord<V>(v: V) { 25 + return is$typed(v, id, hashRecord) 26 + } 27 + 28 + export function validateRecord<V>(v: V) { 29 + return validate<Record & V>(v, id, hashRecord, true) 30 + } 31 + 32 + export interface File { 33 + $type?: 'place.wisp.fs#file' 34 + type: 'file' 35 + /** Content blob hash */ 36 + hash: string 37 + } 38 + 39 + const hashFile = 'file' 40 + 41 + export function isFile<V>(v: V) { 42 + return is$typed(v, id, hashFile) 43 + } 44 + 45 + export function validateFile<V>(v: V) { 46 + return validate<File & V>(v, id, hashFile) 47 + } 48 + 49 + export interface Directory { 50 + $type?: 'place.wisp.fs#directory' 51 + type: 'directory' 52 + entries: Entry[] 53 + } 54 + 55 + const hashDirectory = 'directory' 56 + 57 + export function isDirectory<V>(v: V) { 58 + return is$typed(v, id, hashDirectory) 59 + } 60 + 61 + export function validateDirectory<V>(v: V) { 62 + return validate<Directory & V>(v, id, hashDirectory) 63 + } 64 + 65 + export interface Entry { 66 + $type?: 'place.wisp.fs#entry' 67 + name: string 68 + node: $Typed<File> | $Typed<Directory> | { $type: string } 69 + } 70 + 71 + const hashEntry = 'entry' 72 + 73 + export function isEntry<V>(v: V) { 74 + return is$typed(v, id, hashEntry) 75 + } 76 + 77 + export function validateEntry<V>(v: V) { 78 + return validate<Entry & V>(v, id, hashEntry) 79 + }
+82
src/lexicon/util.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + 5 + import { type ValidationResult } from '@atproto/lexicon' 6 + 7 + export type OmitKey<T, K extends keyof T> = { 8 + [K2 in keyof T as K2 extends K ? never : K2]: T[K2] 9 + } 10 + 11 + export type $Typed<V, T extends string = string> = V & { $type: T } 12 + export type Un$Typed<V extends { $type?: string }> = OmitKey<V, '$type'> 13 + 14 + export type $Type<Id extends string, Hash extends string> = Hash extends 'main' 15 + ? Id 16 + : `${Id}#${Hash}` 17 + 18 + function isObject<V>(v: V): v is V & object { 19 + return v != null && typeof v === 'object' 20 + } 21 + 22 + function is$type<Id extends string, Hash extends string>( 23 + $type: unknown, 24 + id: Id, 25 + hash: Hash, 26 + ): $type is $Type<Id, Hash> { 27 + return hash === 'main' 28 + ? $type === id 29 + : // $type === `${id}#${hash}` 30 + typeof $type === 'string' && 31 + $type.length === id.length + 1 + hash.length && 32 + $type.charCodeAt(id.length) === 35 /* '#' */ && 33 + $type.startsWith(id) && 34 + $type.endsWith(hash) 35 + } 36 + 37 + export type $TypedObject< 38 + V, 39 + Id extends string, 40 + Hash extends string, 41 + > = V extends { 42 + $type: $Type<Id, Hash> 43 + } 44 + ? V 45 + : V extends { $type?: string } 46 + ? V extends { $type?: infer T extends $Type<Id, Hash> } 47 + ? V & { $type: T } 48 + : never 49 + : V & { $type: $Type<Id, Hash> } 50 + 51 + export function is$typed<V, Id extends string, Hash extends string>( 52 + v: V, 53 + id: Id, 54 + hash: Hash, 55 + ): v is $TypedObject<V, Id, Hash> { 56 + return isObject(v) && '$type' in v && is$type(v.$type, id, hash) 57 + } 58 + 59 + export function maybe$typed<V, Id extends string, Hash extends string>( 60 + v: V, 61 + id: Id, 62 + hash: Hash, 63 + ): v is V & object & { $type?: $Type<Id, Hash> } { 64 + return ( 65 + isObject(v) && 66 + ('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true) 67 + ) 68 + } 69 + 70 + export type Validator<R = unknown> = (v: unknown) => ValidationResult<R> 71 + export type ValidatorParam<V extends Validator> = 72 + V extends Validator<infer R> ? R : never 73 + 74 + /** 75 + * Utility function that allows to convert a "validate*" utility function into a 76 + * type predicate. 77 + */ 78 + export function asPredicate<V extends Validator>(validate: V) { 79 + return function <T>(v: T): v is T & ValidatorParam<V> { 80 + return validate(v).success 81 + } 82 + }
+4
src/lib/constants.ts
··· 1 + export const BASE_HOST = Bun.env.BASE_DOMAIN || "wisp.place"; 2 + export const MAX_SITE_SIZE = 300 * 1024 * 1024; //300MB 3 + export const MAX_FILE_SIZE = 100 * 1024 * 1024; //100MB 4 + export const MAX_FILE_COUNT = 2000;
+344
src/lib/db.ts
··· 1 + import { NodeOAuthClient, type ClientMetadata } from "@atproto/oauth-client-node"; 2 + import { SQL } from "bun"; 3 + import { JoseKey } from "@atproto/jwk-jose"; 4 + import { BASE_HOST } from "./constants"; 5 + 6 + export const db = new SQL( 7 + process.env.NODE_ENV === 'production' 8 + ? process.env.DATABASE_URL || (() => { 9 + throw new Error('DATABASE_URL environment variable is required in production'); 10 + })() 11 + : process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/wisp" 12 + ); 13 + 14 + await db` 15 + CREATE TABLE IF NOT EXISTS oauth_states ( 16 + key TEXT PRIMARY KEY, 17 + data TEXT NOT NULL, 18 + created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) 19 + ) 20 + `; 21 + 22 + await db` 23 + CREATE TABLE IF NOT EXISTS oauth_sessions ( 24 + sub TEXT PRIMARY KEY, 25 + data TEXT NOT NULL, 26 + updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) 27 + ) 28 + `; 29 + 30 + await db` 31 + CREATE TABLE IF NOT EXISTS oauth_keys ( 32 + kid TEXT PRIMARY KEY, 33 + jwk TEXT NOT NULL 34 + ) 35 + `; 36 + 37 + // Domains table maps subdomain -> DID 38 + await db` 39 + CREATE TABLE IF NOT EXISTS domains ( 40 + domain TEXT PRIMARY KEY, 41 + did TEXT UNIQUE NOT NULL, 42 + created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) 43 + ) 44 + `; 45 + 46 + // Custom domains table for BYOD (bring your own domain) 47 + await db` 48 + CREATE TABLE IF NOT EXISTS custom_domains ( 49 + id TEXT PRIMARY KEY, 50 + domain TEXT UNIQUE NOT NULL, 51 + did TEXT NOT NULL, 52 + rkey TEXT NOT NULL DEFAULT 'self', 53 + verified BOOLEAN DEFAULT false, 54 + last_verified_at BIGINT, 55 + created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) 56 + ) 57 + `; 58 + 59 + // Sites table - cache of place.wisp.fs records from PDS 60 + await db` 61 + CREATE TABLE IF NOT EXISTS sites ( 62 + did TEXT NOT NULL, 63 + rkey TEXT NOT NULL, 64 + display_name TEXT, 65 + created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()), 66 + updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()), 67 + PRIMARY KEY (did, rkey) 68 + ) 69 + `; 70 + 71 + const RESERVED_HANDLES = new Set([ 72 + "www", 73 + "api", 74 + "admin", 75 + "static", 76 + "public", 77 + "preview" 78 + ]); 79 + 80 + export const isValidHandle = (handle: string): boolean => { 81 + const h = handle.trim().toLowerCase(); 82 + if (h.length < 3 || h.length > 63) return false; 83 + if (!/^[a-z0-9-]+$/.test(h)) return false; 84 + if (h.startsWith('-') || h.endsWith('-')) return false; 85 + if (h.includes('--')) return false; 86 + if (RESERVED_HANDLES.has(h)) return false; 87 + return true; 88 + }; 89 + 90 + export const toDomain = (handle: string): string => `${handle.toLowerCase()}.${BASE_HOST}`; 91 + 92 + export const getDomainByDid = async (did: string): Promise<string | null> => { 93 + const rows = await db`SELECT domain FROM domains WHERE did = ${did}`; 94 + return rows[0]?.domain ?? null; 95 + }; 96 + 97 + export const getDidByDomain = async (domain: string): Promise<string | null> => { 98 + const rows = await db`SELECT did FROM domains WHERE domain = ${domain.toLowerCase()}`; 99 + return rows[0]?.did ?? null; 100 + }; 101 + 102 + export const isDomainAvailable = async (handle: string): Promise<boolean> => { 103 + const h = handle.trim().toLowerCase(); 104 + if (!isValidHandle(h)) return false; 105 + const domain = toDomain(h); 106 + const rows = await db`SELECT 1 FROM domains WHERE domain = ${domain} LIMIT 1`; 107 + return rows.length === 0; 108 + }; 109 + 110 + export const claimDomain = async (did: string, handle: string): Promise<string> => { 111 + const h = handle.trim().toLowerCase(); 112 + if (!isValidHandle(h)) throw new Error('invalid_handle'); 113 + const domain = toDomain(h); 114 + try { 115 + await db` 116 + INSERT INTO domains (domain, did) 117 + VALUES (${domain}, ${did}) 118 + `; 119 + } catch (err) { 120 + // Unique constraint violations -> already taken or DID already claimed 121 + throw new Error('conflict'); 122 + } 123 + return domain; 124 + }; 125 + 126 + export const updateDomain = async (did: string, handle: string): Promise<string> => { 127 + const h = handle.trim().toLowerCase(); 128 + if (!isValidHandle(h)) throw new Error('invalid_handle'); 129 + const domain = toDomain(h); 130 + try { 131 + const rows = await db` 132 + UPDATE domains SET domain = ${domain} 133 + WHERE did = ${did} 134 + RETURNING domain 135 + `; 136 + if (rows.length > 0) return rows[0].domain as string; 137 + // No existing row, behave like claim 138 + return await claimDomain(did, handle); 139 + } catch (err) { 140 + // Unique constraint violations -> already taken by someone else 141 + throw new Error('conflict'); 142 + } 143 + }; 144 + 145 + const stateStore = { 146 + async set(key: string, data: any) { 147 + console.debug('[stateStore] set', key) 148 + await db` 149 + INSERT INTO oauth_states (key, data) 150 + VALUES (${key}, ${JSON.stringify(data)}) 151 + ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data 152 + `; 153 + }, 154 + async get(key: string) { 155 + console.debug('[stateStore] get', key) 156 + const result = await db`SELECT data FROM oauth_states WHERE key = ${key}`; 157 + return result[0] ? JSON.parse(result[0].data) : undefined; 158 + }, 159 + async del(key: string) { 160 + console.debug('[stateStore] del', key) 161 + await db`DELETE FROM oauth_states WHERE key = ${key}`; 162 + } 163 + }; 164 + 165 + const sessionStore = { 166 + async set(sub: string, data: any) { 167 + console.debug('[sessionStore] set', sub) 168 + await db` 169 + INSERT INTO oauth_sessions (sub, data) 170 + VALUES (${sub}, ${JSON.stringify(data)}) 171 + ON CONFLICT (sub) DO UPDATE SET data = EXCLUDED.data, updated_at = EXTRACT(EPOCH FROM NOW()) 172 + `; 173 + }, 174 + async get(sub: string) { 175 + console.debug('[sessionStore] get', sub) 176 + const result = await db`SELECT data FROM oauth_sessions WHERE sub = ${sub}`; 177 + return result[0] ? JSON.parse(result[0].data) : undefined; 178 + }, 179 + async del(sub: string) { 180 + console.debug('[sessionStore] del', sub) 181 + await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`; 182 + } 183 + }; 184 + 185 + export { sessionStore }; 186 + 187 + export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => ({ 188 + client_id: `${config.domain}/client-metadata.json`, 189 + client_name: config.clientName, 190 + client_uri: config.domain, 191 + logo_uri: `${config.domain}/logo.png`, 192 + tos_uri: `${config.domain}/tos`, 193 + policy_uri: `${config.domain}/policy`, 194 + redirect_uris: [`${config.domain}/api/auth/callback`], 195 + grant_types: ['authorization_code', 'refresh_token'], 196 + response_types: ['code'], 197 + application_type: 'web', 198 + token_endpoint_auth_method: 'private_key_jwt', 199 + token_endpoint_auth_signing_alg: "ES256", 200 + scope: "atproto transition:generic", 201 + dpop_bound_access_tokens: true, 202 + jwks_uri: `${config.domain}/jwks.json`, 203 + subject_type: 'public', 204 + authorization_signed_response_alg: 'ES256' 205 + }); 206 + 207 + const persistKey = async (key: JoseKey) => { 208 + const priv = key.privateJwk; 209 + if (!priv) return; 210 + const kid = key.kid ?? crypto.randomUUID(); 211 + await db` 212 + INSERT INTO oauth_keys (kid, jwk) 213 + VALUES (${kid}, ${JSON.stringify(priv)}) 214 + ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk 215 + `; 216 + }; 217 + 218 + const loadPersistedKeys = async (): Promise<JoseKey[]> => { 219 + const rows = await db`SELECT kid, jwk FROM oauth_keys ORDER BY kid`; 220 + const keys: JoseKey[] = []; 221 + for (const row of rows) { 222 + try { 223 + const obj = JSON.parse(row.jwk); 224 + const key = await JoseKey.fromImportable(obj as any, (obj as any).kid); 225 + keys.push(key); 226 + } catch (err) { 227 + console.error('Could not parse stored JWK', err); 228 + } 229 + } 230 + return keys; 231 + }; 232 + 233 + const ensureKeys = async (): Promise<JoseKey[]> => { 234 + let keys = await loadPersistedKeys(); 235 + const needed: string[] = []; 236 + for (let i = 1; i <= 3; i++) { 237 + const kid = `key${i}`; 238 + if (!keys.some(k => k.kid === kid)) needed.push(kid); 239 + } 240 + for (const kid of needed) { 241 + const newKey = await JoseKey.generate(['ES256'], kid); 242 + await persistKey(newKey); 243 + keys.push(newKey); 244 + } 245 + keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? '')); 246 + return keys; 247 + }; 248 + 249 + let currentKeys: JoseKey[] = []; 250 + 251 + export const getCurrentKeys = () => currentKeys; 252 + 253 + export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => { 254 + if (currentKeys.length === 0) { 255 + currentKeys = await ensureKeys(); 256 + } 257 + 258 + return new NodeOAuthClient({ 259 + clientMetadata: createClientMetadata(config), 260 + keyset: currentKeys, 261 + stateStore, 262 + sessionStore 263 + }); 264 + }; 265 + 266 + export const getCustomDomainsByDid = async (did: string) => { 267 + const rows = await db`SELECT * FROM custom_domains WHERE did = ${did} ORDER BY created_at DESC`; 268 + return rows; 269 + }; 270 + 271 + export const getCustomDomainInfo = async (domain: string) => { 272 + const rows = await db`SELECT * FROM custom_domains WHERE domain = ${domain.toLowerCase()}`; 273 + return rows[0] ?? null; 274 + }; 275 + 276 + export const getCustomDomainByHash = async (hash: string) => { 277 + const rows = await db`SELECT * FROM custom_domains WHERE id = ${hash}`; 278 + return rows[0] ?? null; 279 + }; 280 + 281 + export const getCustomDomainById = async (id: string) => { 282 + const rows = await db`SELECT * FROM custom_domains WHERE id = ${id}`; 283 + return rows[0] ?? null; 284 + }; 285 + 286 + export const claimCustomDomain = async (did: string, domain: string, siteName: string, hash: string) => { 287 + const domainLower = domain.toLowerCase(); 288 + try { 289 + await db` 290 + INSERT INTO custom_domains (id, domain, did, site_name, verified, created_at) 291 + VALUES (${hash}, ${domainLower}, ${did}, ${siteName}, false, EXTRACT(EPOCH FROM NOW())) 292 + `; 293 + return { success: true, hash }; 294 + } catch (err) { 295 + console.error('Failed to claim custom domain', err); 296 + throw new Error('conflict'); 297 + } 298 + }; 299 + 300 + export const updateCustomDomainSite = async (id: string, siteName: string) => { 301 + const rows = await db` 302 + UPDATE custom_domains 303 + SET site_name = ${siteName} 304 + WHERE id = ${id} 305 + RETURNING * 306 + `; 307 + return rows[0] ?? null; 308 + }; 309 + 310 + export const updateCustomDomainVerification = async (id: string, verified: boolean) => { 311 + const rows = await db` 312 + UPDATE custom_domains 313 + SET verified = ${verified}, last_verified_at = EXTRACT(EPOCH FROM NOW()) 314 + WHERE id = ${id} 315 + RETURNING * 316 + `; 317 + return rows[0] ?? null; 318 + }; 319 + 320 + export const deleteCustomDomain = async (id: string) => { 321 + await db`DELETE FROM custom_domains WHERE id = ${id}`; 322 + }; 323 + 324 + export const getSitesByDid = async (did: string) => { 325 + const rows = await db`SELECT * FROM sites WHERE did = ${did} ORDER BY created_at DESC`; 326 + return rows; 327 + }; 328 + 329 + export const upsertSite = async (did: string, siteName: string, displayName?: string) => { 330 + try { 331 + await db` 332 + INSERT INTO sites (did, site_name, display_name, created_at, updated_at) 333 + VALUES (${did}, ${siteName}, ${displayName || null}, EXTRACT(EPOCH FROM NOW()), EXTRACT(EPOCH FROM NOW())) 334 + ON CONFLICT (did, site_name) 335 + DO UPDATE SET 336 + display_name = COALESCE(EXCLUDED.display_name, sites.display_name), 337 + updated_at = EXTRACT(EPOCH FROM NOW()) 338 + `; 339 + return { success: true }; 340 + } catch (err) { 341 + console.error('Failed to upsert site', err); 342 + return { success: false, error: err }; 343 + } 344 + };
+127
src/lib/oauth-client.ts
··· 1 + import { NodeOAuthClient, type ClientMetadata } from "@atproto/oauth-client-node"; 2 + import { JoseKey } from "@atproto/jwk-jose"; 3 + import { db } from "./db"; 4 + 5 + const stateStore = { 6 + async set(key: string, data: any) { 7 + console.debug('[stateStore] set', key) 8 + await db` 9 + INSERT INTO oauth_states (key, data) 10 + VALUES (${key}, ${JSON.stringify(data)}) 11 + ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data 12 + `; 13 + }, 14 + async get(key: string) { 15 + console.debug('[stateStore] get', key) 16 + const result = await db`SELECT data FROM oauth_states WHERE key = ${key}`; 17 + return result[0] ? JSON.parse(result[0].data) : undefined; 18 + }, 19 + async del(key: string) { 20 + console.debug('[stateStore] del', key) 21 + await db`DELETE FROM oauth_states WHERE key = ${key}`; 22 + } 23 + }; 24 + 25 + const sessionStore = { 26 + async set(sub: string, data: any) { 27 + console.debug('[sessionStore] set', sub) 28 + await db` 29 + INSERT INTO oauth_sessions (sub, data) 30 + VALUES (${sub}, ${JSON.stringify(data)}) 31 + ON CONFLICT (sub) DO UPDATE SET data = EXCLUDED.data, updated_at = EXTRACT(EPOCH FROM NOW()) 32 + `; 33 + }, 34 + async get(sub: string) { 35 + console.debug('[sessionStore] get', sub) 36 + const result = await db`SELECT data FROM oauth_sessions WHERE sub = ${sub}`; 37 + return result[0] ? JSON.parse(result[0].data) : undefined; 38 + }, 39 + async del(sub: string) { 40 + console.debug('[sessionStore] del', sub) 41 + await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`; 42 + } 43 + }; 44 + 45 + export { sessionStore }; 46 + 47 + export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => { 48 + // Use editor.wisp.place for OAuth endpoints since that's where the API routes live 49 + return { 50 + client_id: `${config.domain}/client-metadata.json`, 51 + client_name: config.clientName, 52 + client_uri: `https://wisp.place`, 53 + logo_uri: `${config.domain}/logo.png`, 54 + tos_uri: `${config.domain}/tos`, 55 + policy_uri: `${config.domain}/policy`, 56 + redirect_uris: [`${config.domain}/api/auth/callback`], 57 + grant_types: ['authorization_code', 'refresh_token'], 58 + response_types: ['code'], 59 + application_type: 'web', 60 + token_endpoint_auth_method: 'private_key_jwt', 61 + token_endpoint_auth_signing_alg: "ES256", 62 + scope: "atproto transition:generic", 63 + dpop_bound_access_tokens: true, 64 + jwks_uri: `${config.domain}/jwks.json`, 65 + subject_type: 'public', 66 + authorization_signed_response_alg: 'ES256' 67 + }; 68 + }; 69 + 70 + const persistKey = async (key: JoseKey) => { 71 + const priv = key.privateJwk; 72 + if (!priv) return; 73 + const kid = key.kid ?? crypto.randomUUID(); 74 + await db` 75 + INSERT INTO oauth_keys (kid, jwk) 76 + VALUES (${kid}, ${JSON.stringify(priv)}) 77 + ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk 78 + `; 79 + }; 80 + 81 + const loadPersistedKeys = async (): Promise<JoseKey[]> => { 82 + const rows = await db`SELECT kid, jwk FROM oauth_keys ORDER BY kid`; 83 + const keys: JoseKey[] = []; 84 + for (const row of rows) { 85 + try { 86 + const obj = JSON.parse(row.jwk); 87 + const key = await JoseKey.fromImportable(obj as any, (obj as any).kid); 88 + keys.push(key); 89 + } catch (err) { 90 + console.error('Could not parse stored JWK', err); 91 + } 92 + } 93 + return keys; 94 + }; 95 + 96 + const ensureKeys = async (): Promise<JoseKey[]> => { 97 + let keys = await loadPersistedKeys(); 98 + const needed: string[] = []; 99 + for (let i = 1; i <= 3; i++) { 100 + const kid = `key${i}`; 101 + if (!keys.some(k => k.kid === kid)) needed.push(kid); 102 + } 103 + for (const kid of needed) { 104 + const newKey = await JoseKey.generate(['ES256'], kid); 105 + await persistKey(newKey); 106 + keys.push(newKey); 107 + } 108 + keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? '')); 109 + return keys; 110 + }; 111 + 112 + let currentKeys: JoseKey[] = []; 113 + 114 + export const getCurrentKeys = () => currentKeys; 115 + 116 + export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => { 117 + if (currentKeys.length === 0) { 118 + currentKeys = await ensureKeys(); 119 + } 120 + 121 + return new NodeOAuthClient({ 122 + clientMetadata: createClientMetadata(config), 123 + keyset: currentKeys, 124 + stateStore, 125 + sessionStore 126 + }); 127 + };
+12
src/lib/types.ts
··· 1 + import type { BlobRef } from "@atproto/api"; 2 + 3 + /** 4 + * Configuration for the Wisp client 5 + * @typeParam Config 6 + */ 7 + export type Config = { 8 + /** The base domain URL with HTTPS protocol */ 9 + domain: `https://${string}`, 10 + /** Name of the client application */ 11 + clientName: string 12 + };