···11+// Change admin password
22+import { adminAuth } from './src/lib/admin-auth'
33+import { db } from './src/lib/db'
44+import { randomBytes, createHash } from 'crypto'
55+66+// Get username and new password from command line
77+const username = process.argv[2]
88+const newPassword = process.argv[3]
99+1010+if (!username || !newPassword) {
1111+ console.error('Usage: bun run change-admin-password.ts <username> <new-password>')
1212+ process.exit(1)
1313+}
1414+1515+if (newPassword.length < 8) {
1616+ console.error('Password must be at least 8 characters')
1717+ process.exit(1)
1818+}
1919+2020+// Hash password
2121+function hashPassword(password: string, salt: string): string {
2222+ return createHash('sha256').update(password + salt).digest('hex')
2323+}
2424+2525+function generateSalt(): string {
2626+ return randomBytes(32).toString('hex')
2727+}
2828+2929+// Initialize
3030+await adminAuth.init()
3131+3232+// Check if user exists
3333+const result = await db`SELECT username FROM admin_users WHERE username = ${username}`
3434+if (result.length === 0) {
3535+ console.error(`Admin user '${username}' not found`)
3636+ process.exit(1)
3737+}
3838+3939+// Update password
4040+const salt = generateSalt()
4141+const passwordHash = hashPassword(newPassword, salt)
4242+4343+await db`UPDATE admin_users SET password_hash = ${passwordHash}, salt = ${salt} WHERE username = ${username}`
4444+4545+console.log(`✓ Password updated for admin user '${username}'`)
4646+process.exit(0)
+33
claude.md
···11+ Wisp.place - Decentralized Static Site Hosting
22+33+ Architecture Overview
44+55+ Wisp.Place a two-service application that provides static site hosting on the AT
66+ Protocol. Wisp aims to be a CDN for static sites where the content is ultimately owned by the user at their repo. The microservice is responsbile for injesting firehose events and serving a on-disk cache of the latest site files.
77+88+ Service 1: Main App (Port 8000, Bun runtime, elysia.js)
99+ - User-facing editor and API
1010+ - OAuth authentication (AT Protocol)
1111+ - File upload processing (gzip + base64 encoding)
1212+ - Domain management (subdomains + custom domains)
1313+ - DNS verification worker
1414+ - React frontend
1515+1616+ Service 2: Hosting Service (Port 3001, Node.js runtime, hono.js)
1717+ - AT Protocol Firehose listener for real-time updates
1818+ - Serves hosted websites from local cache
1919+ - Multi-domain routing (custom domains, wisp.place subdomains, sites subdomain)
2020+ - Distributed locking for multi-instance coordination
2121+2222+ Tech Stack
2323+2424+ - Backend: Bun/Node.js, Elysia.js, PostgreSQL, AT Protocol SDK
2525+ - Frontend: React 19, Tailwind CSS v4, Shadcn UI
2626+2727+ Key Features
2828+2929+ - AT Protocol Integration: Sites stored as place.wisp.fs records in user repos
3030+ - File Processing: Validates, compresses (gzip), encodes (base64), uploads to user's PDS
3131+ - Domain Management: wisp.place subdomains + custom BYOD domains with DNS verification
3232+ - Real-time Sync: Firehose worker listens for site updates and caches files locally
3333+ - Atomic Updates: Safe cache swapping without downtime
+31
create-admin.ts
···11+// Quick script to create admin user with randomly generated password
22+import { adminAuth } from './src/lib/admin-auth'
33+import { randomBytes } from 'crypto'
44+55+// Generate a secure random password
66+function generatePassword(length: number = 20): string {
77+ const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'
88+ const bytes = randomBytes(length)
99+ let password = ''
1010+ for (let i = 0; i < length; i++) {
1111+ password += chars[bytes[i] % chars.length]
1212+ }
1313+ return password
1414+}
1515+1616+const username = 'admin'
1717+const password = generatePassword(20)
1818+1919+await adminAuth.init()
2020+await adminAuth.createAdmin(username, password)
2121+2222+console.log('\n╔════════════════════════════════════════════════════════════════╗')
2323+console.log('║ ADMIN USER CREATED SUCCESSFULLY ║')
2424+console.log('╚════════════════════════════════════════════════════════════════╝\n')
2525+console.log(`Username: ${username}`)
2626+console.log(`Password: ${password}`)
2727+console.log('\n⚠️ IMPORTANT: Save this password securely!')
2828+console.log('This password will not be shown again.\n')
2929+console.log('Change it with: bun run change-admin-password.ts admin NEW_PASSWORD\n')
3030+3131+process.exit(0)
+3-2
hosting-service/src/index.ts
···11import app from './server';
22import { FirehoseWorker } from './lib/firehose';
33+import { logger } from './lib/observability';
34import { mkdirSync, existsSync } from 'fs';
4556const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001;
···1112 console.log('Created cache directory:', CACHE_DIR);
1213}
13141414-// Start firehose worker
1515+// Start firehose worker with observability logger
1516const firehose = new FirehoseWorker((msg, data) => {
1616- console.log(msg, data);
1717+ logger.info(msg, data);
1718});
18191920firehose.start();
+43
hosting-service/src/lib/db.ts
···158158 }
159159}
160160161161+/**
162162+ * Generate a numeric lock ID from a string key
163163+ * PostgreSQL advisory locks use bigint (64-bit signed integer)
164164+ */
165165+function stringToLockId(key: string): bigint {
166166+ let hash = 0n;
167167+ for (let i = 0; i < key.length; i++) {
168168+ const char = BigInt(key.charCodeAt(i));
169169+ hash = ((hash << 5n) - hash + char) & 0x7FFFFFFFFFFFFFFFn; // Keep within signed int64 range
170170+ }
171171+ return hash;
172172+}
173173+174174+/**
175175+ * Acquire a distributed lock using PostgreSQL advisory locks
176176+ * Returns true if lock was acquired, false if already held by another instance
177177+ * Lock is automatically released when the transaction ends or connection closes
178178+ */
179179+export async function tryAcquireLock(key: string): Promise<boolean> {
180180+ const lockId = stringToLockId(key);
181181+182182+ try {
183183+ const result = await sql`SELECT pg_try_advisory_lock(${lockId}) as acquired`;
184184+ return result[0]?.acquired === true;
185185+ } catch (err) {
186186+ console.error('Failed to acquire lock', { key, error: err });
187187+ return false;
188188+ }
189189+}
190190+191191+/**
192192+ * Release a distributed lock
193193+ */
194194+export async function releaseLock(key: string): Promise<void> {
195195+ const lockId = stringToLockId(key);
196196+197197+ try {
198198+ await sql`SELECT pg_advisory_unlock(${lockId})`;
199199+ } catch (err) {
200200+ console.error('Failed to release lock', { key, error: err });
201201+ }
202202+}
203203+161204export { sql };
+20-4
hosting-service/src/lib/firehose.ts
···11import { existsSync, rmSync } from 'fs';
22import { getPdsForDid, downloadAndCacheSite, extractBlobCid, fetchSiteRecord } from './utils';
33-import { upsertSite } from './db';
33+import { upsertSite, tryAcquireLock, releaseLock } from './db';
44import { safeFetch } from './safe-fetch';
55import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs';
66import { Firehose } from '@atproto/sync';
···158158 }
159159160160 // Cache the record with verified CID (uses atomic swap internally)
161161+ // All instances cache locally for edge serving
161162 await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint, verifiedCid);
162163163163- // Upsert site to database
164164- await upsertSite(did, site, fsRecord.site);
164164+ // Acquire distributed lock only for database write to prevent duplicate writes
165165+ const lockKey = `db:upsert:${did}:${site}`;
166166+ const lockAcquired = await tryAcquireLock(lockKey);
165167166166- this.log('Successfully processed create/update', { did, site });
168168+ if (!lockAcquired) {
169169+ this.log('Another instance is writing to DB, skipping upsert', { did, site });
170170+ this.log('Successfully processed create/update (cached locally)', { did, site });
171171+ return;
172172+ }
173173+174174+ try {
175175+ // Upsert site to database (only one instance does this)
176176+ await upsertSite(did, site, fsRecord.site);
177177+ this.log('Successfully processed create/update (cached + DB updated)', { did, site });
178178+ } finally {
179179+ // Always release lock, even if DB write fails
180180+ await releaseLock(lockKey);
181181+ }
167182 }
168183169184 private async handleDelete(did: string, site: string) {
170185 this.log('Processing delete', { did, site });
171186187187+ // All instances should delete their local cache (no lock needed)
172188 const pdsEndpoint = await getPdsForDid(did);
173189 if (!pdsEndpoint) {
174190 this.log('Could not resolve PDS for DID', { did });