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

move dns worker to main app, better JWK handling.

Changed files
+172 -98
hosting-service
src
public
editor
onboarding
src
-32
hosting-service/src/index.ts
···
import { serve } from 'bun';
import app from './server';
import { FirehoseWorker } from './lib/firehose';
-
import { DNSVerificationWorker } from './lib/dns-verification-worker';
import { mkdirSync, existsSync } from 'fs';
const PORT = process.env.PORT || 3001;
···
firehose.start();
-
// Start DNS verification worker (runs every hour)
-
const dnsVerifier = new DNSVerificationWorker(
-
60 * 60 * 1000, // 1 hour
-
(msg, data) => {
-
console.log('[DNS Verifier]', msg, data || '');
-
}
-
);
-
-
dnsVerifier.start();
-
// Add health check endpoint
app.get('/health', (c) => {
const firehoseHealth = firehose.getHealth();
-
const dnsVerifierHealth = dnsVerifier.getHealth();
return c.json({
status: 'ok',
firehose: firehoseHealth,
-
dnsVerifier: dnsVerifierHealth,
});
});
-
// Add manual DNS verification trigger (for testing/admin)
-
app.post('/admin/verify-dns', async (c) => {
-
try {
-
await dnsVerifier.trigger();
-
return c.json({
-
success: true,
-
message: 'DNS verification triggered',
-
});
-
} catch (error) {
-
return c.json({
-
success: false,
-
error: error instanceof Error ? error.message : String(error),
-
}, 500);
-
}
-
});
-
// Start HTTP server
const server = serve({
port: PORT,
···
Health: http://localhost:${PORT}/health
Cache: ${CACHE_DIR}
Firehose: Connected to Jetstream
-
DNS Verifier: Checking every hour
`);
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\n🛑 Shutting down...');
firehose.stop();
-
dnsVerifier.stop();
server.stop();
process.exit(0);
});
···
process.on('SIGTERM', () => {
console.log('\n🛑 Shutting down...');
firehose.stop();
-
dnsVerifier.stop();
server.stop();
process.exit(0);
});
+2 -2
hosting-service/src/lib/dns-verification-worker.ts src/lib/dns-verification-worker.ts
···
-
import { verifyCustomDomain } from '../../../src/lib/dns-verify';
-
import { db } from '../../../src/lib/db';
+
import { verifyCustomDomain } from './dns-verify';
+
import { db } from './db';
interface VerificationStats {
totalChecked: number;
+3 -19
hosting-service/src/lib/utils.ts
···
import { writeFile, readFile } from 'fs/promises';
import { safeFetchJson, safeFetchBlob } from './safe-fetch';
import { CID } from 'multiformats/cid';
-
import { createHash } from 'crypto';
const CACHE_DIR = './cache/sites';
const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL
···
rkey: string;
}
-
// Type guards for different blob reference formats
interface IpldLink {
$link: string;
}
···
let doc;
if (did.startsWith('did:plc:')) {
-
// Resolve did:plc from plc.directory
doc = await safeFetchJson(`https://plc.directory/${encodeURIComponent(did)}`);
} else if (did.startsWith('did:web:')) {
-
// Resolve did:web from the domain
const didUrl = didWebToHttps(did);
doc = await safeFetchJson(didUrl);
} else {
···
}
function didWebToHttps(did: string): string {
-
// did:web:example.com -> https://example.com/.well-known/did.json
-
// did:web:example.com:path:to:did -> https://example.com/path/to/did/did.json
-
const didParts = did.split(':');
if (didParts.length < 3 || didParts[0] !== 'did' || didParts[1] !== 'web') {
throw new Error('Invalid did:web format');
···
const pathParts = didParts.slice(3);
if (pathParts.length === 0) {
-
// No path, use .well-known
return `https://${domain}/.well-known/did.json`;
} else {
-
// Has path
const path = pathParts.join('/');
return `https://${domain}/${path}/did.json`;
}
···
const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(rkey)}`;
const data = await safeFetchJson(url);
-
// Return both the record and its CID for verification
return {
record: data.value as WispFsRecord,
cid: data.cid || ''
···
}
export function extractBlobCid(blobRef: unknown): string | null {
-
// Check if it's a direct IPLD link
if (isIpldLink(blobRef)) {
return blobRef.$link;
}
-
// Check if it's a typed blob ref with a ref property
if (isTypedBlobRef(blobRef)) {
const ref = blobRef.ref;
-
// Check if ref is a CID object
-
if (CID.isCID(ref)) {
-
return ref.toString();
+
const cid = CID.asCID(ref);
+
if (cid) {
+
return cid.toString();
}
-
// Check if ref is an IPLD link object
if (isIpldLink(ref)) {
return ref.$link;
}
}
-
// Check if it's an untyped blob ref with a cid string
if (isUntypedBlobRef(blobRef)) {
return blobRef.cid;
}
···
export async function downloadAndCacheSite(did: string, rkey: string, record: WispFsRecord, pdsEndpoint: string, recordCid: string): Promise<void> {
console.log('Caching site', did, rkey);
-
// Validate record structure
if (!record.root) {
console.error('Record missing root directory:', JSON.stringify(record, null, 2));
throw new Error('Invalid record structure: missing root directory');
···
await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, '');
-
// Save cache metadata with CID for verification
await saveCacheMetadata(did, rkey, recordCid);
}
+49 -6
public/editor/editor.tsx
···
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState('')
+
const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([])
+
const [uploadedCount, setUploadedCount] = useState(0)
// Custom domain modal state
const [addDomainModalOpen, setAddDomainModalOpen] = useState(false)
···
const data = await response.json()
if (data.success) {
setUploadProgress('Upload complete!')
+
setSkippedFiles(data.skippedFiles || [])
+
setUploadedCount(data.uploadedCount || data.fileCount || 0)
setSiteName('')
setSelectedFiles(null)
// Refresh sites list
await fetchSites()
-
// Reset form
+
// Reset form - give more time if there are skipped files
+
const resetDelay = data.skippedFiles && data.skippedFiles.length > 0 ? 4000 : 1500
setTimeout(() => {
setUploadProgress('')
+
setSkippedFiles([])
+
setUploadedCount(0)
setIsUploading(false)
-
}, 1500)
+
}, resetDelay)
} else {
throw new Error(data.error || 'Upload failed')
}
···
onChange={(e) => setSiteName(e.target.value)}
disabled={isUploading}
/>
+
<p className="text-xs text-muted-foreground">
+
File limits: 100MB per file, 300MB total
+
</p>
</div>
<div className="grid md:grid-cols-2 gap-4">
···
</div>
{uploadProgress && (
-
<div className="p-4 bg-muted rounded-lg">
-
<div className="flex items-center gap-2">
-
<Loader2 className="w-4 h-4 animate-spin" />
-
<span className="text-sm">{uploadProgress}</span>
+
<div className="space-y-3">
+
<div className="p-4 bg-muted rounded-lg">
+
<div className="flex items-center gap-2">
+
<Loader2 className="w-4 h-4 animate-spin" />
+
<span className="text-sm">{uploadProgress}</span>
+
</div>
</div>
+
+
{skippedFiles.length > 0 && (
+
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
+
<div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2">
+
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
+
<div className="flex-1">
+
<span className="font-medium">
+
{skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped
+
</span>
+
{uploadedCount > 0 && (
+
<span className="text-sm ml-2">
+
({uploadedCount} uploaded successfully)
+
</span>
+
)}
+
</div>
+
</div>
+
<div className="ml-6 space-y-1 max-h-32 overflow-y-auto">
+
{skippedFiles.slice(0, 5).map((file, idx) => (
+
<div key={idx} className="text-xs">
+
<span className="font-mono">{file.name}</span>
+
<span className="text-muted-foreground"> - {file.reason}</span>
+
</div>
+
))}
+
{skippedFiles.length > 5 && (
+
<div className="text-xs text-muted-foreground">
+
...and {skippedFiles.length - 5} more
+
</div>
+
)}
+
</div>
+
</div>
+
)}
</div>
)}
+58 -11
public/onboarding/onboarding.tsx
···
} from '@public/components/ui/card'
import { Input } from '@public/components/ui/input'
import { Label } from '@public/components/ui/label'
-
import { Globe, Upload, CheckCircle2, Loader2 } from 'lucide-react'
+
import { Globe, Upload, CheckCircle2, Loader2, AlertCircle } from 'lucide-react'
import Layout from '@public/layouts'
type OnboardingStep = 'domain' | 'upload' | 'complete'
···
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState('')
+
const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([])
+
const [uploadedCount, setUploadedCount] = useState(0)
// Check domain availability as user types
useEffect(() => {
···
const data = await response.json()
if (data.success) {
setUploadProgress('Upload complete!')
-
// Redirect to the claimed domain
-
setTimeout(() => {
-
window.location.href = `https://${claimedDomain}`
-
}, 1500)
+
setSkippedFiles(data.skippedFiles || [])
+
setUploadedCount(data.uploadedCount || data.fileCount || 0)
+
+
// If there are skipped files, show them briefly before redirecting
+
if (data.skippedFiles && data.skippedFiles.length > 0) {
+
setTimeout(() => {
+
window.location.href = `https://${claimedDomain}`
+
}, 3000) // Give more time to see skipped files
+
} else {
+
setTimeout(() => {
+
window.location.href = `https://${claimedDomain}`
+
}, 1500)
+
}
} else {
throw new Error(data.error || 'Upload failed')
}
···
<p className="text-xs text-muted-foreground">
Supported: HTML, CSS, JS, images, fonts, and more
</p>
+
<p className="text-xs text-muted-foreground">
+
Limits: 100MB per file, 300MB total
+
</p>
</div>
{uploadProgress && (
-
<div className="p-4 bg-muted rounded-lg">
-
<div className="flex items-center gap-2">
-
<Loader2 className="w-4 h-4 animate-spin" />
-
<span className="text-sm">
-
{uploadProgress}
-
</span>
+
<div className="space-y-3">
+
<div className="p-4 bg-muted rounded-lg">
+
<div className="flex items-center gap-2">
+
<Loader2 className="w-4 h-4 animate-spin" />
+
<span className="text-sm">
+
{uploadProgress}
+
</span>
+
</div>
</div>
+
+
{skippedFiles.length > 0 && (
+
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
+
<div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2">
+
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
+
<div className="flex-1">
+
<span className="font-medium">
+
{skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped
+
</span>
+
{uploadedCount > 0 && (
+
<span className="text-sm ml-2">
+
({uploadedCount} uploaded successfully)
+
</span>
+
)}
+
</div>
+
</div>
+
<div className="ml-6 space-y-1 max-h-32 overflow-y-auto">
+
{skippedFiles.slice(0, 5).map((file, idx) => (
+
<div key={idx} className="text-xs">
+
<span className="font-mono">{file.name}</span>
+
<span className="text-muted-foreground"> - {file.reason}</span>
+
</div>
+
))}
+
{skippedFiles.length > 5 && (
+
<div className="text-xs text-muted-foreground">
+
...and {skippedFiles.length - 5} more
+
</div>
+
)}
+
</div>
+
</div>
+
)}
</div>
)}
+37 -7
src/index.ts
···
import { domainRoutes } from './routes/domain'
import { userRoutes } from './routes/user'
import { csrfProtection } from './lib/csrf'
+
import { DNSVerificationWorker } from './lib/dns-verification-worker'
+
import { logger } from './lib/logger'
const config: Config = {
domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`,
···
// Schedule maintenance to run every hour
setInterval(runMaintenance, 60 * 60 * 1000)
+
// Start DNS verification worker (runs every hour)
+
const dnsVerifier = new DNSVerificationWorker(
+
60 * 60 * 1000, // 1 hour
+
(msg, data) => {
+
logger.info('[DNS Verifier]', msg, data || '')
+
}
+
)
+
+
dnsVerifier.start()
+
logger.info('[DNS Verifier] Started - checking custom domains every hour')
+
export const app = new Elysia()
// Security headers middleware
.onAfterHandle(({ set }) => {
···
set.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'
})
.use(
-
openapi({
-
references: fromTypes()
-
})
-
)
-
.use(
await staticPlugin({
prefix: '/'
})
···
.get('/client-metadata.json', (c) => {
return createClientMetadata(config)
})
-
.get('/jwks.json', (c) => {
-
const keys = getCurrentKeys()
+
.get('/jwks.json', async (c) => {
+
const keys = await getCurrentKeys()
if (!keys.length) return { keys: [] }
return {
···
const { ...pub } = jwk
return pub
})
+
}
+
})
+
.get('/api/health', () => {
+
const dnsVerifierHealth = dnsVerifier.getHealth()
+
return {
+
status: 'ok',
+
timestamp: new Date().toISOString(),
+
dnsVerifier: dnsVerifierHealth
+
}
+
})
+
.post('/api/admin/verify-dns', async () => {
+
try {
+
await dnsVerifier.trigger()
+
return {
+
success: true,
+
message: 'DNS verification triggered'
+
}
+
} catch (error) {
+
return {
+
success: false,
+
error: error instanceof Error ? error.message : String(error)
+
}
}
})
.use(cors({
+6 -10
src/lib/db.ts
···
return keys;
};
-
let currentKeys: JoseKey[] = [];
-
-
export const getCurrentKeys = () => currentKeys;
+
// Load keys from database every time (stateless - safe for horizontal scaling)
+
export const getCurrentKeys = async (): Promise<JoseKey[]> => {
+
return await loadPersistedKeys();
+
};
// Key rotation - rotate keys older than 30 days (monthly rotation)
const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
···
console.log(`[KeyRotation] Rotated key ${oldKid}`);
-
// Reload keys into memory
-
currentKeys = await ensureKeys();
-
return true;
} catch (err) {
console.error('[KeyRotation] Failed to rotate keys:', err);
···
};
export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
-
if (currentKeys.length === 0) {
-
currentKeys = await ensureKeys();
-
}
+
const keys = await ensureKeys();
return new NodeOAuthClient({
clientMetadata: createClientMetadata(config),
-
keyset: currentKeys,
+
keyset: keys,
stateStore,
sessionStore
});
+6 -10
src/lib/oauth-client.ts
···
return keys;
};
-
let currentKeys: JoseKey[] = [];
-
-
export const getCurrentKeys = () => currentKeys;
+
// Load keys from database every time (stateless - safe for horizontal scaling)
+
export const getCurrentKeys = async (): Promise<JoseKey[]> => {
+
return await loadPersistedKeys();
+
};
// Key rotation - rotate keys older than 30 days (monthly rotation)
const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
···
logger.info(`[KeyRotation] Rotated key ${oldKid}`);
-
// Reload keys into memory
-
currentKeys = await ensureKeys();
-
return true;
} catch (err) {
logger.error('[KeyRotation] Failed to rotate keys', err);
···
};
export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
-
if (currentKeys.length === 0) {
-
currentKeys = await ensureKeys();
-
}
+
const keys = await ensureKeys();
return new NodeOAuthClient({
clientMetadata: createClientMetadata(config),
-
keyset: currentKeys,
+
keyset: keys,
stateStore,
sessionStore
});
+11 -1
src/routes/wisp.ts
···
// Elysia gives us File objects directly, handle both single file and array
const fileArray = Array.isArray(files) ? files : [files];
const uploadedFiles: UploadedFile[] = [];
+
const skippedFiles: Array<{ name: string; reason: string }> = [];
// Define allowed file extensions for static site hosting
const allowedExtensions = new Set([
···
// Skip excluded files
if (excludedFiles.has(fileExtension)) {
+
skippedFiles.push({ name: file.name, reason: 'excluded file type' });
continue;
}
// Skip files that aren't in allowed extensions
if (!allowedExtensions.has(fileExtension)) {
+
skippedFiles.push({ name: file.name, reason: 'unsupported file type' });
continue;
}
// Skip files that are too large (limit to 100MB per file)
const maxSize = 100 * 1024 * 1024; // 100MB
if (file.size > maxSize) {
+
skippedFiles.push({
+
name: file.name,
+
reason: `file too large (${(file.size / 1024 / 1024).toFixed(2)}MB, max 100MB)`
+
});
continue;
}
···
cid: record.data.cid,
fileCount: 0,
siteName,
+
skippedFiles,
message: 'Site created but no valid web files were found to upload'
};
}
···
uri: record.data.uri,
cid: record.data.cid,
fileCount,
-
siteName
+
siteName,
+
skippedFiles,
+
uploadedCount: uploadedFiles.length
};
return result;