-32
hosting-service/src/index.ts
-32
hosting-service/src/index.ts
···
1
import { serve } from 'bun';
2
import app from './server';
3
import { FirehoseWorker } from './lib/firehose';
4
-
import { DNSVerificationWorker } from './lib/dns-verification-worker';
5
import { mkdirSync, existsSync } from 'fs';
6
7
const PORT = process.env.PORT || 3001;
···
20
21
firehose.start();
22
23
-
// Start DNS verification worker (runs every hour)
24
-
const dnsVerifier = new DNSVerificationWorker(
25
-
60 * 60 * 1000, // 1 hour
26
-
(msg, data) => {
27
-
console.log('[DNS Verifier]', msg, data || '');
28
-
}
29
-
);
30
-
31
-
dnsVerifier.start();
32
-
33
// Add health check endpoint
34
app.get('/health', (c) => {
35
const firehoseHealth = firehose.getHealth();
36
-
const dnsVerifierHealth = dnsVerifier.getHealth();
37
return c.json({
38
status: 'ok',
39
firehose: firehoseHealth,
40
-
dnsVerifier: dnsVerifierHealth,
41
});
42
});
43
44
-
// Add manual DNS verification trigger (for testing/admin)
45
-
app.post('/admin/verify-dns', async (c) => {
46
-
try {
47
-
await dnsVerifier.trigger();
48
-
return c.json({
49
-
success: true,
50
-
message: 'DNS verification triggered',
51
-
});
52
-
} catch (error) {
53
-
return c.json({
54
-
success: false,
55
-
error: error instanceof Error ? error.message : String(error),
56
-
}, 500);
57
-
}
58
-
});
59
-
60
// Start HTTP server
61
const server = serve({
62
port: PORT,
···
70
Health: http://localhost:${PORT}/health
71
Cache: ${CACHE_DIR}
72
Firehose: Connected to Jetstream
73
-
DNS Verifier: Checking every hour
74
`);
75
76
// Graceful shutdown
77
process.on('SIGINT', () => {
78
console.log('\n🛑 Shutting down...');
79
firehose.stop();
80
-
dnsVerifier.stop();
81
server.stop();
82
process.exit(0);
83
});
···
85
process.on('SIGTERM', () => {
86
console.log('\n🛑 Shutting down...');
87
firehose.stop();
88
-
dnsVerifier.stop();
89
server.stop();
90
process.exit(0);
91
});
···
1
import { serve } from 'bun';
2
import app from './server';
3
import { FirehoseWorker } from './lib/firehose';
4
import { mkdirSync, existsSync } from 'fs';
5
6
const PORT = process.env.PORT || 3001;
···
19
20
firehose.start();
21
22
// Add health check endpoint
23
app.get('/health', (c) => {
24
const firehoseHealth = firehose.getHealth();
25
return c.json({
26
status: 'ok',
27
firehose: firehoseHealth,
28
});
29
});
30
31
// Start HTTP server
32
const server = serve({
33
port: PORT,
···
41
Health: http://localhost:${PORT}/health
42
Cache: ${CACHE_DIR}
43
Firehose: Connected to Jetstream
44
`);
45
46
// Graceful shutdown
47
process.on('SIGINT', () => {
48
console.log('\n🛑 Shutting down...');
49
firehose.stop();
50
server.stop();
51
process.exit(0);
52
});
···
54
process.on('SIGTERM', () => {
55
console.log('\n🛑 Shutting down...');
56
firehose.stop();
57
server.stop();
58
process.exit(0);
59
});
+2
-2
hosting-service/src/lib/dns-verification-worker.ts
src/lib/dns-verification-worker.ts
+2
-2
hosting-service/src/lib/dns-verification-worker.ts
src/lib/dns-verification-worker.ts
+3
-19
hosting-service/src/lib/utils.ts
+3
-19
hosting-service/src/lib/utils.ts
···
4
import { writeFile, readFile } from 'fs/promises';
5
import { safeFetchJson, safeFetchBlob } from './safe-fetch';
6
import { CID } from 'multiformats/cid';
7
-
import { createHash } from 'crypto';
8
9
const CACHE_DIR = './cache/sites';
10
const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL
···
16
rkey: string;
17
}
18
19
-
// Type guards for different blob reference formats
20
interface IpldLink {
21
$link: string;
22
}
···
63
let doc;
64
65
if (did.startsWith('did:plc:')) {
66
-
// Resolve did:plc from plc.directory
67
doc = await safeFetchJson(`https://plc.directory/${encodeURIComponent(did)}`);
68
} else if (did.startsWith('did:web:')) {
69
-
// Resolve did:web from the domain
70
const didUrl = didWebToHttps(did);
71
doc = await safeFetchJson(didUrl);
72
} else {
···
85
}
86
87
function didWebToHttps(did: string): string {
88
-
// did:web:example.com -> https://example.com/.well-known/did.json
89
-
// did:web:example.com:path:to:did -> https://example.com/path/to/did/did.json
90
-
91
const didParts = did.split(':');
92
if (didParts.length < 3 || didParts[0] !== 'did' || didParts[1] !== 'web') {
93
throw new Error('Invalid did:web format');
···
97
const pathParts = didParts.slice(3);
98
99
if (pathParts.length === 0) {
100
-
// No path, use .well-known
101
return `https://${domain}/.well-known/did.json`;
102
} else {
103
-
// Has path
104
const path = pathParts.join('/');
105
return `https://${domain}/${path}/did.json`;
106
}
···
114
const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(rkey)}`;
115
const data = await safeFetchJson(url);
116
117
-
// Return both the record and its CID for verification
118
return {
119
record: data.value as WispFsRecord,
120
cid: data.cid || ''
···
126
}
127
128
export function extractBlobCid(blobRef: unknown): string | null {
129
-
// Check if it's a direct IPLD link
130
if (isIpldLink(blobRef)) {
131
return blobRef.$link;
132
}
133
134
-
// Check if it's a typed blob ref with a ref property
135
if (isTypedBlobRef(blobRef)) {
136
const ref = blobRef.ref;
137
138
-
// Check if ref is a CID object
139
-
if (CID.isCID(ref)) {
140
-
return ref.toString();
141
}
142
143
-
// Check if ref is an IPLD link object
144
if (isIpldLink(ref)) {
145
return ref.$link;
146
}
147
}
148
149
-
// Check if it's an untyped blob ref with a cid string
150
if (isUntypedBlobRef(blobRef)) {
151
return blobRef.cid;
152
}
···
157
export async function downloadAndCacheSite(did: string, rkey: string, record: WispFsRecord, pdsEndpoint: string, recordCid: string): Promise<void> {
158
console.log('Caching site', did, rkey);
159
160
-
// Validate record structure
161
if (!record.root) {
162
console.error('Record missing root directory:', JSON.stringify(record, null, 2));
163
throw new Error('Invalid record structure: missing root directory');
···
170
171
await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, '');
172
173
-
// Save cache metadata with CID for verification
174
await saveCacheMetadata(did, rkey, recordCid);
175
}
176
···
4
import { writeFile, readFile } from 'fs/promises';
5
import { safeFetchJson, safeFetchBlob } from './safe-fetch';
6
import { CID } from 'multiformats/cid';
7
8
const CACHE_DIR = './cache/sites';
9
const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL
···
15
rkey: string;
16
}
17
18
interface IpldLink {
19
$link: string;
20
}
···
61
let doc;
62
63
if (did.startsWith('did:plc:')) {
64
doc = await safeFetchJson(`https://plc.directory/${encodeURIComponent(did)}`);
65
} else if (did.startsWith('did:web:')) {
66
const didUrl = didWebToHttps(did);
67
doc = await safeFetchJson(didUrl);
68
} else {
···
81
}
82
83
function didWebToHttps(did: string): string {
84
const didParts = did.split(':');
85
if (didParts.length < 3 || didParts[0] !== 'did' || didParts[1] !== 'web') {
86
throw new Error('Invalid did:web format');
···
90
const pathParts = didParts.slice(3);
91
92
if (pathParts.length === 0) {
93
return `https://${domain}/.well-known/did.json`;
94
} else {
95
const path = pathParts.join('/');
96
return `https://${domain}/${path}/did.json`;
97
}
···
105
const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(rkey)}`;
106
const data = await safeFetchJson(url);
107
108
return {
109
record: data.value as WispFsRecord,
110
cid: data.cid || ''
···
116
}
117
118
export function extractBlobCid(blobRef: unknown): string | null {
119
if (isIpldLink(blobRef)) {
120
return blobRef.$link;
121
}
122
123
if (isTypedBlobRef(blobRef)) {
124
const ref = blobRef.ref;
125
126
+
const cid = CID.asCID(ref);
127
+
if (cid) {
128
+
return cid.toString();
129
}
130
131
if (isIpldLink(ref)) {
132
return ref.$link;
133
}
134
}
135
136
if (isUntypedBlobRef(blobRef)) {
137
return blobRef.cid;
138
}
···
143
export async function downloadAndCacheSite(did: string, rkey: string, record: WispFsRecord, pdsEndpoint: string, recordCid: string): Promise<void> {
144
console.log('Caching site', did, rkey);
145
146
if (!record.root) {
147
console.error('Record missing root directory:', JSON.stringify(record, null, 2));
148
throw new Error('Invalid record structure: missing root directory');
···
155
156
await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, '');
157
158
await saveCacheMetadata(did, rkey, recordCid);
159
}
160
+49
-6
public/editor/editor.tsx
+49
-6
public/editor/editor.tsx
···
94
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
95
const [isUploading, setIsUploading] = useState(false)
96
const [uploadProgress, setUploadProgress] = useState('')
97
98
// Custom domain modal state
99
const [addDomainModalOpen, setAddDomainModalOpen] = useState(false)
···
232
const data = await response.json()
233
if (data.success) {
234
setUploadProgress('Upload complete!')
235
setSiteName('')
236
setSelectedFiles(null)
237
238
// Refresh sites list
239
await fetchSites()
240
241
-
// Reset form
242
setTimeout(() => {
243
setUploadProgress('')
244
setIsUploading(false)
245
-
}, 1500)
246
} else {
247
throw new Error(data.error || 'Upload failed')
248
}
···
714
onChange={(e) => setSiteName(e.target.value)}
715
disabled={isUploading}
716
/>
717
</div>
718
719
<div className="grid md:grid-cols-2 gap-4">
···
774
</div>
775
776
{uploadProgress && (
777
-
<div className="p-4 bg-muted rounded-lg">
778
-
<div className="flex items-center gap-2">
779
-
<Loader2 className="w-4 h-4 animate-spin" />
780
-
<span className="text-sm">{uploadProgress}</span>
781
</div>
782
</div>
783
)}
784
···
94
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
95
const [isUploading, setIsUploading] = useState(false)
96
const [uploadProgress, setUploadProgress] = useState('')
97
+
const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([])
98
+
const [uploadedCount, setUploadedCount] = useState(0)
99
100
// Custom domain modal state
101
const [addDomainModalOpen, setAddDomainModalOpen] = useState(false)
···
234
const data = await response.json()
235
if (data.success) {
236
setUploadProgress('Upload complete!')
237
+
setSkippedFiles(data.skippedFiles || [])
238
+
setUploadedCount(data.uploadedCount || data.fileCount || 0)
239
setSiteName('')
240
setSelectedFiles(null)
241
242
// Refresh sites list
243
await fetchSites()
244
245
+
// Reset form - give more time if there are skipped files
246
+
const resetDelay = data.skippedFiles && data.skippedFiles.length > 0 ? 4000 : 1500
247
setTimeout(() => {
248
setUploadProgress('')
249
+
setSkippedFiles([])
250
+
setUploadedCount(0)
251
setIsUploading(false)
252
+
}, resetDelay)
253
} else {
254
throw new Error(data.error || 'Upload failed')
255
}
···
721
onChange={(e) => setSiteName(e.target.value)}
722
disabled={isUploading}
723
/>
724
+
<p className="text-xs text-muted-foreground">
725
+
File limits: 100MB per file, 300MB total
726
+
</p>
727
</div>
728
729
<div className="grid md:grid-cols-2 gap-4">
···
784
</div>
785
786
{uploadProgress && (
787
+
<div className="space-y-3">
788
+
<div className="p-4 bg-muted rounded-lg">
789
+
<div className="flex items-center gap-2">
790
+
<Loader2 className="w-4 h-4 animate-spin" />
791
+
<span className="text-sm">{uploadProgress}</span>
792
+
</div>
793
</div>
794
+
795
+
{skippedFiles.length > 0 && (
796
+
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
797
+
<div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2">
798
+
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
799
+
<div className="flex-1">
800
+
<span className="font-medium">
801
+
{skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped
802
+
</span>
803
+
{uploadedCount > 0 && (
804
+
<span className="text-sm ml-2">
805
+
({uploadedCount} uploaded successfully)
806
+
</span>
807
+
)}
808
+
</div>
809
+
</div>
810
+
<div className="ml-6 space-y-1 max-h-32 overflow-y-auto">
811
+
{skippedFiles.slice(0, 5).map((file, idx) => (
812
+
<div key={idx} className="text-xs">
813
+
<span className="font-mono">{file.name}</span>
814
+
<span className="text-muted-foreground"> - {file.reason}</span>
815
+
</div>
816
+
))}
817
+
{skippedFiles.length > 5 && (
818
+
<div className="text-xs text-muted-foreground">
819
+
...and {skippedFiles.length - 5} more
820
+
</div>
821
+
)}
822
+
</div>
823
+
</div>
824
+
)}
825
</div>
826
)}
827
+58
-11
public/onboarding/onboarding.tsx
+58
-11
public/onboarding/onboarding.tsx
···
10
} from '@public/components/ui/card'
11
import { Input } from '@public/components/ui/input'
12
import { Label } from '@public/components/ui/label'
13
-
import { Globe, Upload, CheckCircle2, Loader2 } from 'lucide-react'
14
import Layout from '@public/layouts'
15
16
type OnboardingStep = 'domain' | 'upload' | 'complete'
···
28
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
29
const [isUploading, setIsUploading] = useState(false)
30
const [uploadProgress, setUploadProgress] = useState('')
31
32
// Check domain availability as user types
33
useEffect(() => {
···
117
const data = await response.json()
118
if (data.success) {
119
setUploadProgress('Upload complete!')
120
-
// Redirect to the claimed domain
121
-
setTimeout(() => {
122
-
window.location.href = `https://${claimedDomain}`
123
-
}, 1500)
124
} else {
125
throw new Error(data.error || 'Upload failed')
126
}
···
355
<p className="text-xs text-muted-foreground">
356
Supported: HTML, CSS, JS, images, fonts, and more
357
</p>
358
</div>
359
360
{uploadProgress && (
361
-
<div className="p-4 bg-muted rounded-lg">
362
-
<div className="flex items-center gap-2">
363
-
<Loader2 className="w-4 h-4 animate-spin" />
364
-
<span className="text-sm">
365
-
{uploadProgress}
366
-
</span>
367
</div>
368
</div>
369
)}
370
···
10
} from '@public/components/ui/card'
11
import { Input } from '@public/components/ui/input'
12
import { Label } from '@public/components/ui/label'
13
+
import { Globe, Upload, CheckCircle2, Loader2, AlertCircle } from 'lucide-react'
14
import Layout from '@public/layouts'
15
16
type OnboardingStep = 'domain' | 'upload' | 'complete'
···
28
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
29
const [isUploading, setIsUploading] = useState(false)
30
const [uploadProgress, setUploadProgress] = useState('')
31
+
const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([])
32
+
const [uploadedCount, setUploadedCount] = useState(0)
33
34
// Check domain availability as user types
35
useEffect(() => {
···
119
const data = await response.json()
120
if (data.success) {
121
setUploadProgress('Upload complete!')
122
+
setSkippedFiles(data.skippedFiles || [])
123
+
setUploadedCount(data.uploadedCount || data.fileCount || 0)
124
+
125
+
// If there are skipped files, show them briefly before redirecting
126
+
if (data.skippedFiles && data.skippedFiles.length > 0) {
127
+
setTimeout(() => {
128
+
window.location.href = `https://${claimedDomain}`
129
+
}, 3000) // Give more time to see skipped files
130
+
} else {
131
+
setTimeout(() => {
132
+
window.location.href = `https://${claimedDomain}`
133
+
}, 1500)
134
+
}
135
} else {
136
throw new Error(data.error || 'Upload failed')
137
}
···
366
<p className="text-xs text-muted-foreground">
367
Supported: HTML, CSS, JS, images, fonts, and more
368
</p>
369
+
<p className="text-xs text-muted-foreground">
370
+
Limits: 100MB per file, 300MB total
371
+
</p>
372
</div>
373
374
{uploadProgress && (
375
+
<div className="space-y-3">
376
+
<div className="p-4 bg-muted rounded-lg">
377
+
<div className="flex items-center gap-2">
378
+
<Loader2 className="w-4 h-4 animate-spin" />
379
+
<span className="text-sm">
380
+
{uploadProgress}
381
+
</span>
382
+
</div>
383
</div>
384
+
385
+
{skippedFiles.length > 0 && (
386
+
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
387
+
<div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2">
388
+
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
389
+
<div className="flex-1">
390
+
<span className="font-medium">
391
+
{skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped
392
+
</span>
393
+
{uploadedCount > 0 && (
394
+
<span className="text-sm ml-2">
395
+
({uploadedCount} uploaded successfully)
396
+
</span>
397
+
)}
398
+
</div>
399
+
</div>
400
+
<div className="ml-6 space-y-1 max-h-32 overflow-y-auto">
401
+
{skippedFiles.slice(0, 5).map((file, idx) => (
402
+
<div key={idx} className="text-xs">
403
+
<span className="font-mono">{file.name}</span>
404
+
<span className="text-muted-foreground"> - {file.reason}</span>
405
+
</div>
406
+
))}
407
+
{skippedFiles.length > 5 && (
408
+
<div className="text-xs text-muted-foreground">
409
+
...and {skippedFiles.length - 5} more
410
+
</div>
411
+
)}
412
+
</div>
413
+
</div>
414
+
)}
415
</div>
416
)}
417
+37
-7
src/index.ts
+37
-7
src/index.ts
···
17
import { domainRoutes } from './routes/domain'
18
import { userRoutes } from './routes/user'
19
import { csrfProtection } from './lib/csrf'
20
21
const config: Config = {
22
domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`,
···
39
// Schedule maintenance to run every hour
40
setInterval(runMaintenance, 60 * 60 * 1000)
41
42
export const app = new Elysia()
43
// Security headers middleware
44
.onAfterHandle(({ set }) => {
···
66
set.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'
67
})
68
.use(
69
-
openapi({
70
-
references: fromTypes()
71
-
})
72
-
)
73
-
.use(
74
await staticPlugin({
75
prefix: '/'
76
})
···
83
.get('/client-metadata.json', (c) => {
84
return createClientMetadata(config)
85
})
86
-
.get('/jwks.json', (c) => {
87
-
const keys = getCurrentKeys()
88
if (!keys.length) return { keys: [] }
89
90
return {
···
93
const { ...pub } = jwk
94
return pub
95
})
96
}
97
})
98
.use(cors({
···
17
import { domainRoutes } from './routes/domain'
18
import { userRoutes } from './routes/user'
19
import { csrfProtection } from './lib/csrf'
20
+
import { DNSVerificationWorker } from './lib/dns-verification-worker'
21
+
import { logger } from './lib/logger'
22
23
const config: Config = {
24
domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`,
···
41
// Schedule maintenance to run every hour
42
setInterval(runMaintenance, 60 * 60 * 1000)
43
44
+
// Start DNS verification worker (runs every hour)
45
+
const dnsVerifier = new DNSVerificationWorker(
46
+
60 * 60 * 1000, // 1 hour
47
+
(msg, data) => {
48
+
logger.info('[DNS Verifier]', msg, data || '')
49
+
}
50
+
)
51
+
52
+
dnsVerifier.start()
53
+
logger.info('[DNS Verifier] Started - checking custom domains every hour')
54
+
55
export const app = new Elysia()
56
// Security headers middleware
57
.onAfterHandle(({ set }) => {
···
79
set.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'
80
})
81
.use(
82
await staticPlugin({
83
prefix: '/'
84
})
···
91
.get('/client-metadata.json', (c) => {
92
return createClientMetadata(config)
93
})
94
+
.get('/jwks.json', async (c) => {
95
+
const keys = await getCurrentKeys()
96
if (!keys.length) return { keys: [] }
97
98
return {
···
101
const { ...pub } = jwk
102
return pub
103
})
104
+
}
105
+
})
106
+
.get('/api/health', () => {
107
+
const dnsVerifierHealth = dnsVerifier.getHealth()
108
+
return {
109
+
status: 'ok',
110
+
timestamp: new Date().toISOString(),
111
+
dnsVerifier: dnsVerifierHealth
112
+
}
113
+
})
114
+
.post('/api/admin/verify-dns', async () => {
115
+
try {
116
+
await dnsVerifier.trigger()
117
+
return {
118
+
success: true,
119
+
message: 'DNS verification triggered'
120
+
}
121
+
} catch (error) {
122
+
return {
123
+
success: false,
124
+
error: error instanceof Error ? error.message : String(error)
125
+
}
126
}
127
})
128
.use(cors({
+6
-10
src/lib/db.ts
+6
-10
src/lib/db.ts
···
387
return keys;
388
};
389
390
-
let currentKeys: JoseKey[] = [];
391
-
392
-
export const getCurrentKeys = () => currentKeys;
393
394
// Key rotation - rotate keys older than 30 days (monthly rotation)
395
const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
···
423
424
console.log(`[KeyRotation] Rotated key ${oldKid}`);
425
426
-
// Reload keys into memory
427
-
currentKeys = await ensureKeys();
428
-
429
return true;
430
} catch (err) {
431
console.error('[KeyRotation] Failed to rotate keys:', err);
···
434
};
435
436
export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
437
-
if (currentKeys.length === 0) {
438
-
currentKeys = await ensureKeys();
439
-
}
440
441
return new NodeOAuthClient({
442
clientMetadata: createClientMetadata(config),
443
-
keyset: currentKeys,
444
stateStore,
445
sessionStore
446
});
···
387
return keys;
388
};
389
390
+
// Load keys from database every time (stateless - safe for horizontal scaling)
391
+
export const getCurrentKeys = async (): Promise<JoseKey[]> => {
392
+
return await loadPersistedKeys();
393
+
};
394
395
// Key rotation - rotate keys older than 30 days (monthly rotation)
396
const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
···
424
425
console.log(`[KeyRotation] Rotated key ${oldKid}`);
426
427
return true;
428
} catch (err) {
429
console.error('[KeyRotation] Failed to rotate keys:', err);
···
432
};
433
434
export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
435
+
const keys = await ensureKeys();
436
437
return new NodeOAuthClient({
438
clientMetadata: createClientMetadata(config),
439
+
keyset: keys,
440
stateStore,
441
sessionStore
442
});
+6
-10
src/lib/oauth-client.ts
+6
-10
src/lib/oauth-client.ts
···
168
return keys;
169
};
170
171
-
let currentKeys: JoseKey[] = [];
172
-
173
-
export const getCurrentKeys = () => currentKeys;
174
175
// Key rotation - rotate keys older than 30 days (monthly rotation)
176
const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
···
204
205
logger.info(`[KeyRotation] Rotated key ${oldKid}`);
206
207
-
// Reload keys into memory
208
-
currentKeys = await ensureKeys();
209
-
210
return true;
211
} catch (err) {
212
logger.error('[KeyRotation] Failed to rotate keys', err);
···
215
};
216
217
export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
218
-
if (currentKeys.length === 0) {
219
-
currentKeys = await ensureKeys();
220
-
}
221
222
return new NodeOAuthClient({
223
clientMetadata: createClientMetadata(config),
224
-
keyset: currentKeys,
225
stateStore,
226
sessionStore
227
});
···
168
return keys;
169
};
170
171
+
// Load keys from database every time (stateless - safe for horizontal scaling)
172
+
export const getCurrentKeys = async (): Promise<JoseKey[]> => {
173
+
return await loadPersistedKeys();
174
+
};
175
176
// Key rotation - rotate keys older than 30 days (monthly rotation)
177
const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
···
205
206
logger.info(`[KeyRotation] Rotated key ${oldKid}`);
207
208
return true;
209
} catch (err) {
210
logger.error('[KeyRotation] Failed to rotate keys', err);
···
213
};
214
215
export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
216
+
const keys = await ensureKeys();
217
218
return new NodeOAuthClient({
219
clientMetadata: createClientMetadata(config),
220
+
keyset: keys,
221
stateStore,
222
sessionStore
223
});
+11
-1
src/routes/wisp.ts
+11
-1
src/routes/wisp.ts
···
101
// Elysia gives us File objects directly, handle both single file and array
102
const fileArray = Array.isArray(files) ? files : [files];
103
const uploadedFiles: UploadedFile[] = [];
104
105
// Define allowed file extensions for static site hosting
106
const allowedExtensions = new Set([
···
135
136
// Skip excluded files
137
if (excludedFiles.has(fileExtension)) {
138
continue;
139
}
140
141
// Skip files that aren't in allowed extensions
142
if (!allowedExtensions.has(fileExtension)) {
143
continue;
144
}
145
146
// Skip files that are too large (limit to 100MB per file)
147
const maxSize = 100 * 1024 * 1024; // 100MB
148
if (file.size > maxSize) {
149
continue;
150
}
151
···
198
cid: record.data.cid,
199
fileCount: 0,
200
siteName,
201
message: 'Site created but no valid web files were found to upload'
202
};
203
}
···
273
uri: record.data.uri,
274
cid: record.data.cid,
275
fileCount,
276
-
siteName
277
};
278
279
return result;
···
101
// Elysia gives us File objects directly, handle both single file and array
102
const fileArray = Array.isArray(files) ? files : [files];
103
const uploadedFiles: UploadedFile[] = [];
104
+
const skippedFiles: Array<{ name: string; reason: string }> = [];
105
106
// Define allowed file extensions for static site hosting
107
const allowedExtensions = new Set([
···
136
137
// Skip excluded files
138
if (excludedFiles.has(fileExtension)) {
139
+
skippedFiles.push({ name: file.name, reason: 'excluded file type' });
140
continue;
141
}
142
143
// Skip files that aren't in allowed extensions
144
if (!allowedExtensions.has(fileExtension)) {
145
+
skippedFiles.push({ name: file.name, reason: 'unsupported file type' });
146
continue;
147
}
148
149
// Skip files that are too large (limit to 100MB per file)
150
const maxSize = 100 * 1024 * 1024; // 100MB
151
if (file.size > maxSize) {
152
+
skippedFiles.push({
153
+
name: file.name,
154
+
reason: `file too large (${(file.size / 1024 / 1024).toFixed(2)}MB, max 100MB)`
155
+
});
156
continue;
157
}
158
···
205
cid: record.data.cid,
206
fileCount: 0,
207
siteName,
208
+
skippedFiles,
209
message: 'Site created but no valid web files were found to upload'
210
};
211
}
···
281
uri: record.data.uri,
282
cid: record.data.cid,
283
fileCount,
284
+
siteName,
285
+
skippedFiles,
286
+
uploadedCount: uploadedFiles.length
287
};
288
289
return result;