+21
-11
hosting-service/src/lib/firehose.ts
+21
-11
hosting-service/src/lib/firehose.ts
···
175
175
176
176
try {
177
177
if (commit.operation === 'create' || commit.operation === 'update') {
178
-
await this.handleCreateOrUpdate(did, commit.rkey, commit.record);
178
+
// Pass the CID from the event for verification
179
+
await this.handleCreateOrUpdate(did, commit.rkey, commit.record, commit.cid);
179
180
} else if (commit.operation === 'delete') {
180
181
await this.handleDelete(did, commit.rkey);
181
182
}
···
189
190
}
190
191
}
191
192
192
-
private async handleCreateOrUpdate(did: string, site: string, record: any) {
193
+
private async handleCreateOrUpdate(did: string, site: string, record: any, eventCid?: string) {
193
194
this.log('Processing create/update', { did, site });
194
195
195
196
if (!this.validateRecord(record)) {
···
207
208
208
209
this.log('Resolved PDS', { did, pdsEndpoint });
209
210
210
-
// Verify record exists on PDS
211
+
// Verify record exists on PDS and fetch its CID
212
+
let verifiedCid: string;
211
213
try {
212
-
const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`;
213
-
const recordRes = await safeFetch(recordUrl);
214
+
const result = await fetchSiteRecord(did, site);
214
215
215
-
if (!recordRes.ok) {
216
-
this.log('Record not found on PDS, skipping cache', {
216
+
if (!result) {
217
+
this.log('Record not found on PDS, skipping cache', { did, site });
218
+
return;
219
+
}
220
+
221
+
verifiedCid = result.cid;
222
+
223
+
// Verify event CID matches PDS CID (prevent cache poisoning)
224
+
if (eventCid && eventCid !== verifiedCid) {
225
+
this.log('CID mismatch detected - potential spoofed event', {
217
226
did,
218
227
site,
219
-
status: recordRes.status,
228
+
eventCid,
229
+
verifiedCid
220
230
});
221
231
return;
222
232
}
223
233
224
-
this.log('Record verified on PDS', { did, site });
234
+
this.log('Record verified on PDS', { did, site, cid: verifiedCid });
225
235
} catch (err) {
226
236
this.log('Failed to verify record on PDS', {
227
237
did,
···
231
241
return;
232
242
}
233
243
234
-
// Cache the record
235
-
await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint);
244
+
// Cache the record with verified CID
245
+
await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint, verifiedCid);
236
246
237
247
// Upsert site to database
238
248
await upsertSite(did, site, fsRecord.site);
+7
-4
hosting-service/src/lib/html-rewriter.ts
+7
-4
hosting-service/src/lib/html-rewriter.ts
···
77
77
let rewritten = html;
78
78
79
79
// Rewrite each attribute type
80
+
// Use more specific patterns to prevent ReDoS attacks
80
81
for (const attr of REWRITABLE_ATTRIBUTES) {
81
82
if (attr === 'srcset') {
82
-
// Special handling for srcset
83
+
// Special handling for srcset - use possessive quantifiers via atomic grouping simulation
84
+
// Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS
83
85
const srcsetRegex = new RegExp(
84
-
`\\b${attr}\\s*=\\s*"([^"]*)"`,
86
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
85
87
'gi'
86
88
);
87
89
rewritten = rewritten.replace(srcsetRegex, (match, value) => {
···
90
92
});
91
93
} else {
92
94
// Regular attributes with quoted values
95
+
// Limit whitespace to prevent catastrophic backtracking
93
96
const doubleQuoteRegex = new RegExp(
94
-
`\\b${attr}\\s*=\\s*"([^"]*)"`,
97
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
95
98
'gi'
96
99
);
97
100
const singleQuoteRegex = new RegExp(
98
-
`\\b${attr}\\s*=\\s*'([^']*)'`,
101
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`,
99
102
'gi'
100
103
);
101
104
+73
-5
hosting-service/src/lib/utils.ts
+73
-5
hosting-service/src/lib/utils.ts
···
1
1
import { AtpAgent } from '@atproto/api';
2
2
import type { WispFsRecord, Directory, Entry, File } from './types';
3
-
import { existsSync, mkdirSync } from 'fs';
4
-
import { writeFile } from 'fs/promises';
3
+
import { existsSync, mkdirSync, readFileSync } from 'fs';
4
+
import { writeFile, readFile } from 'fs/promises';
5
5
import { safeFetchJson, safeFetchBlob } from './safe-fetch';
6
6
import { CID } from 'multiformats/cid';
7
+
import { createHash } from 'crypto';
7
8
8
9
const CACHE_DIR = './cache/sites';
10
+
const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL
11
+
12
+
interface CacheMetadata {
13
+
recordCid: string;
14
+
cachedAt: number;
15
+
did: string;
16
+
rkey: string;
17
+
}
9
18
10
19
// Type guards for different blob reference formats
11
20
interface IpldLink {
···
97
106
}
98
107
}
99
108
100
-
export async function fetchSiteRecord(did: string, rkey: string): Promise<WispFsRecord | null> {
109
+
export async function fetchSiteRecord(did: string, rkey: string): Promise<{ record: WispFsRecord; cid: string } | null> {
101
110
try {
102
111
const pdsEndpoint = await getPdsForDid(did);
103
112
if (!pdsEndpoint) return null;
104
113
105
114
const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(rkey)}`;
106
115
const data = await safeFetchJson(url);
107
-
return data.value as WispFsRecord;
116
+
117
+
// Return both the record and its CID for verification
118
+
return {
119
+
record: data.value as WispFsRecord,
120
+
cid: data.cid || ''
121
+
};
108
122
} catch (err) {
109
123
console.error('Failed to fetch site record', did, rkey, err);
110
124
return null;
···
140
154
return null;
141
155
}
142
156
143
-
export async function downloadAndCacheSite(did: string, rkey: string, record: WispFsRecord, pdsEndpoint: string): Promise<void> {
157
+
export async function downloadAndCacheSite(did: string, rkey: string, record: WispFsRecord, pdsEndpoint: string, recordCid: string): Promise<void> {
144
158
console.log('Caching site', did, rkey);
145
159
146
160
// Validate record structure
···
155
169
}
156
170
157
171
await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, '');
172
+
173
+
// Save cache metadata with CID for verification
174
+
await saveCacheMetadata(did, rkey, recordCid);
158
175
}
159
176
160
177
async function cacheFiles(
···
236
253
export function isCached(did: string, site: string): boolean {
237
254
return existsSync(`${CACHE_DIR}/${did}/${site}`);
238
255
}
256
+
257
+
async function saveCacheMetadata(did: string, rkey: string, recordCid: string): Promise<void> {
258
+
const metadata: CacheMetadata = {
259
+
recordCid,
260
+
cachedAt: Date.now(),
261
+
did,
262
+
rkey
263
+
};
264
+
265
+
const metadataPath = `${CACHE_DIR}/${did}/${rkey}/.metadata.json`;
266
+
const metadataDir = metadataPath.substring(0, metadataPath.lastIndexOf('/'));
267
+
268
+
if (!existsSync(metadataDir)) {
269
+
mkdirSync(metadataDir, { recursive: true });
270
+
}
271
+
272
+
await writeFile(metadataPath, JSON.stringify(metadata, null, 2));
273
+
}
274
+
275
+
async function getCacheMetadata(did: string, rkey: string): Promise<CacheMetadata | null> {
276
+
try {
277
+
const metadataPath = `${CACHE_DIR}/${did}/${rkey}/.metadata.json`;
278
+
if (!existsSync(metadataPath)) return null;
279
+
280
+
const content = await readFile(metadataPath, 'utf-8');
281
+
return JSON.parse(content) as CacheMetadata;
282
+
} catch (err) {
283
+
console.error('Failed to read cache metadata', err);
284
+
return null;
285
+
}
286
+
}
287
+
288
+
export async function isCacheValid(did: string, rkey: string, currentRecordCid?: string): Promise<boolean> {
289
+
const metadata = await getCacheMetadata(did, rkey);
290
+
if (!metadata) return false;
291
+
292
+
// Check if cache has expired (14 days TTL)
293
+
const cacheAge = Date.now() - metadata.cachedAt;
294
+
if (cacheAge > CACHE_TTL) {
295
+
console.log('[Cache] Cache expired for', did, rkey);
296
+
return false;
297
+
}
298
+
299
+
// If current CID is provided, verify it matches
300
+
if (currentRecordCid && metadata.recordCid !== currentRecordCid) {
301
+
console.log('[Cache] CID mismatch for', did, rkey, 'cached:', metadata.recordCid, 'current:', currentRecordCid);
302
+
return false;
303
+
}
304
+
305
+
return true;
306
+
}
+42
-1
src/index.ts
+42
-1
src/index.ts
···
8
8
import {
9
9
createClientMetadata,
10
10
getOAuthClient,
11
-
getCurrentKeys
11
+
getCurrentKeys,
12
+
cleanupExpiredSessions,
13
+
rotateKeysIfNeeded
12
14
} from './lib/oauth-client'
13
15
import { authRoutes } from './routes/auth'
14
16
import { wispRoutes } from './routes/wisp'
···
22
24
23
25
const client = await getOAuthClient(config)
24
26
27
+
// Periodic maintenance: cleanup expired sessions and rotate keys
28
+
// Run every hour
29
+
const runMaintenance = async () => {
30
+
console.log('[Maintenance] Running periodic maintenance...')
31
+
await cleanupExpiredSessions()
32
+
await rotateKeysIfNeeded()
33
+
}
34
+
35
+
// Run maintenance on startup
36
+
runMaintenance()
37
+
38
+
// Schedule maintenance to run every hour
39
+
setInterval(runMaintenance, 60 * 60 * 1000)
40
+
25
41
export const app = new Elysia()
42
+
// Security headers middleware
43
+
.onAfterHandle(({ set }) => {
44
+
// Prevent clickjacking attacks
45
+
set.headers['X-Frame-Options'] = 'DENY'
46
+
// Prevent MIME type sniffing
47
+
set.headers['X-Content-Type-Options'] = 'nosniff'
48
+
// Strict Transport Security (HSTS) - enforce HTTPS
49
+
set.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
50
+
// Referrer policy - limit referrer information
51
+
set.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
52
+
// Content Security Policy
53
+
set.headers['Content-Security-Policy'] =
54
+
"default-src 'self'; " +
55
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
56
+
"style-src 'self' 'unsafe-inline'; " +
57
+
"img-src 'self' data: https:; " +
58
+
"font-src 'self' data:; " +
59
+
"connect-src 'self' https:; " +
60
+
"frame-ancestors 'none'; " +
61
+
"base-uri 'self'; " +
62
+
"form-action 'self'"
63
+
// Additional security headers
64
+
set.headers['X-XSS-Protection'] = '1; mode=block'
65
+
set.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'
66
+
})
26
67
.use(
27
68
openapi({
28
69
references: fromTypes()
+136
-16
src/lib/db.ts
+136
-16
src/lib/db.ts
···
23
23
CREATE TABLE IF NOT EXISTS oauth_sessions (
24
24
sub TEXT PRIMARY KEY,
25
25
data TEXT NOT NULL,
26
-
updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
26
+
updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()),
27
+
expires_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) + 2592000
27
28
)
28
29
`;
29
30
30
31
await db`
31
32
CREATE TABLE IF NOT EXISTS oauth_keys (
32
33
kid TEXT PRIMARY KEY,
33
-
jwk TEXT NOT NULL
34
+
jwk TEXT NOT NULL,
35
+
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
34
36
)
35
37
`;
36
38
···
44
46
)
45
47
`;
46
48
47
-
// Add rkey column if it doesn't exist (for existing databases)
49
+
// Add columns if they don't exist (for existing databases)
48
50
try {
49
51
await db`ALTER TABLE domains ADD COLUMN IF NOT EXISTS rkey TEXT`;
52
+
} catch (err) {
53
+
// Column might already exist, ignore
54
+
}
55
+
56
+
try {
57
+
await db`ALTER TABLE oauth_sessions ADD COLUMN IF NOT EXISTS expires_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) + 2592000`;
58
+
} catch (err) {
59
+
// Column might already exist, ignore
60
+
}
61
+
62
+
try {
63
+
await db`ALTER TABLE oauth_keys ADD COLUMN IF NOT EXISTS created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())`;
64
+
} catch (err) {
65
+
// Column might already exist, ignore
66
+
}
67
+
68
+
try {
69
+
await db`ALTER TABLE oauth_states ADD COLUMN IF NOT EXISTS expires_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) + 3600`;
50
70
} catch (err) {
51
71
// Column might already exist, ignore
52
72
}
···
205
225
return rows[0]?.rkey ?? null;
206
226
};
207
227
228
+
// Session timeout configuration (30 days in seconds)
229
+
const SESSION_TIMEOUT = 30 * 24 * 60 * 60; // 2592000 seconds
230
+
// OAuth state timeout (1 hour in seconds)
231
+
const STATE_TIMEOUT = 60 * 60; // 3600 seconds
232
+
208
233
const stateStore = {
209
234
async set(key: string, data: any) {
210
235
console.debug('[stateStore] set', key)
236
+
const expiresAt = Math.floor(Date.now() / 1000) + STATE_TIMEOUT;
211
237
await db`
212
-
INSERT INTO oauth_states (key, data)
213
-
VALUES (${key}, ${JSON.stringify(data)})
214
-
ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data
238
+
INSERT INTO oauth_states (key, data, created_at, expires_at)
239
+
VALUES (${key}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt})
240
+
ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data, expires_at = ${expiresAt}
215
241
`;
216
242
},
217
243
async get(key: string) {
218
244
console.debug('[stateStore] get', key)
219
-
const result = await db`SELECT data FROM oauth_states WHERE key = ${key}`;
220
-
return result[0] ? JSON.parse(result[0].data) : undefined;
245
+
const now = Math.floor(Date.now() / 1000);
246
+
const result = await db`
247
+
SELECT data, expires_at
248
+
FROM oauth_states
249
+
WHERE key = ${key}
250
+
`;
251
+
if (!result[0]) return undefined;
252
+
253
+
// Check if expired
254
+
const expiresAt = Number(result[0].expires_at);
255
+
if (expiresAt && now > expiresAt) {
256
+
console.debug('[stateStore] State expired, deleting', key);
257
+
await db`DELETE FROM oauth_states WHERE key = ${key}`;
258
+
return undefined;
259
+
}
260
+
261
+
return JSON.parse(result[0].data);
221
262
},
222
263
async del(key: string) {
223
264
console.debug('[stateStore] del', key)
···
228
269
const sessionStore = {
229
270
async set(sub: string, data: any) {
230
271
console.debug('[sessionStore] set', sub)
272
+
const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TIMEOUT;
231
273
await db`
232
-
INSERT INTO oauth_sessions (sub, data)
233
-
VALUES (${sub}, ${JSON.stringify(data)})
234
-
ON CONFLICT (sub) DO UPDATE SET data = EXCLUDED.data, updated_at = EXTRACT(EPOCH FROM NOW())
274
+
INSERT INTO oauth_sessions (sub, data, updated_at, expires_at)
275
+
VALUES (${sub}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt})
276
+
ON CONFLICT (sub) DO UPDATE SET
277
+
data = EXCLUDED.data,
278
+
updated_at = EXTRACT(EPOCH FROM NOW()),
279
+
expires_at = ${expiresAt}
235
280
`;
236
281
},
237
282
async get(sub: string) {
238
283
console.debug('[sessionStore] get', sub)
239
-
const result = await db`SELECT data FROM oauth_sessions WHERE sub = ${sub}`;
240
-
return result[0] ? JSON.parse(result[0].data) : undefined;
284
+
const now = Math.floor(Date.now() / 1000);
285
+
const result = await db`
286
+
SELECT data, expires_at
287
+
FROM oauth_sessions
288
+
WHERE sub = ${sub}
289
+
`;
290
+
if (!result[0]) return undefined;
291
+
292
+
// Check if expired
293
+
const expiresAt = Number(result[0].expires_at);
294
+
if (expiresAt && now > expiresAt) {
295
+
console.log('[sessionStore] Session expired, deleting', sub);
296
+
await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
297
+
return undefined;
298
+
}
299
+
300
+
return JSON.parse(result[0].data);
241
301
},
242
302
async del(sub: string) {
243
303
console.debug('[sessionStore] del', sub)
···
247
307
248
308
export { sessionStore };
249
309
310
+
// Cleanup expired sessions and states
311
+
export const cleanupExpiredSessions = async () => {
312
+
const now = Math.floor(Date.now() / 1000);
313
+
try {
314
+
const sessionsDeleted = await db`
315
+
DELETE FROM oauth_sessions WHERE expires_at < ${now}
316
+
`;
317
+
const statesDeleted = await db`
318
+
DELETE FROM oauth_states WHERE expires_at IS NOT NULL AND expires_at < ${now}
319
+
`;
320
+
console.log(`[Cleanup] Deleted ${sessionsDeleted.length} expired sessions and ${statesDeleted.length} expired states`);
321
+
return { sessions: sessionsDeleted.length, states: statesDeleted.length };
322
+
} catch (err) {
323
+
console.error('[Cleanup] Failed to cleanup expired data:', err);
324
+
return { sessions: 0, states: 0 };
325
+
}
326
+
};
327
+
250
328
export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => ({
251
329
client_id: `${config.domain}/client-metadata.json`,
252
330
client_name: config.clientName,
···
272
350
if (!priv) return;
273
351
const kid = key.kid ?? crypto.randomUUID();
274
352
await db`
275
-
INSERT INTO oauth_keys (kid, jwk)
276
-
VALUES (${kid}, ${JSON.stringify(priv)})
353
+
INSERT INTO oauth_keys (kid, jwk, created_at)
354
+
VALUES (${kid}, ${JSON.stringify(priv)}, EXTRACT(EPOCH FROM NOW()))
277
355
ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk
278
356
`;
279
357
};
280
358
281
359
const loadPersistedKeys = async (): Promise<JoseKey[]> => {
282
-
const rows = await db`SELECT kid, jwk FROM oauth_keys ORDER BY kid`;
360
+
const rows = await db`SELECT kid, jwk, created_at FROM oauth_keys ORDER BY kid`;
283
361
const keys: JoseKey[] = [];
284
362
for (const row of rows) {
285
363
try {
···
312
390
let currentKeys: JoseKey[] = [];
313
391
314
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
396
+
397
+
export const rotateKeysIfNeeded = async (): Promise<boolean> => {
398
+
const now = Math.floor(Date.now() / 1000);
399
+
const cutoffTime = now - KEY_MAX_AGE;
400
+
401
+
try {
402
+
// Find keys older than 30 days
403
+
const oldKeys = await db`
404
+
SELECT kid, created_at FROM oauth_keys
405
+
WHERE created_at IS NOT NULL AND created_at < ${cutoffTime}
406
+
ORDER BY created_at ASC
407
+
`;
408
+
409
+
if (oldKeys.length === 0) {
410
+
console.log('[KeyRotation] No keys need rotation');
411
+
return false;
412
+
}
413
+
414
+
console.log(`[KeyRotation] Found ${oldKeys.length} key(s) older than 30 days, rotating oldest key`);
415
+
416
+
// Rotate the oldest key
417
+
const oldestKey = oldKeys[0];
418
+
const oldKid = oldestKey.kid;
419
+
420
+
// Generate new key with same kid
421
+
const newKey = await JoseKey.generate(['ES256'], oldKid);
422
+
await persistKey(newKey);
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);
432
+
return false;
433
+
}
434
+
};
315
435
316
436
export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
317
437
if (currentKeys.length === 0) {
+37
src/lib/logger.ts
+37
src/lib/logger.ts
···
1
+
// Secure logging utility - only verbose in development mode
2
+
const isDev = process.env.NODE_ENV !== 'production';
3
+
4
+
export const logger = {
5
+
// Always log these (safe for production)
6
+
info: (...args: any[]) => {
7
+
console.log(...args);
8
+
},
9
+
10
+
// Only log in development (may contain sensitive info)
11
+
debug: (...args: any[]) => {
12
+
if (isDev) {
13
+
console.debug(...args);
14
+
}
15
+
},
16
+
17
+
// Safe error logging - sanitizes in production
18
+
error: (message: string, error?: any) => {
19
+
if (isDev) {
20
+
// Development: log full error details
21
+
console.error(message, error);
22
+
} else {
23
+
// Production: log only the message, not error details
24
+
console.error(message);
25
+
}
26
+
},
27
+
28
+
// Log error with context but sanitize sensitive data in production
29
+
errorWithContext: (message: string, context?: Record<string, any>, error?: any) => {
30
+
if (isDev) {
31
+
console.error(message, context, error);
32
+
} else {
33
+
// In production, only log the message
34
+
console.error(message);
35
+
}
36
+
}
37
+
};
+115
-14
src/lib/oauth-client.ts
+115
-14
src/lib/oauth-client.ts
···
1
1
import { NodeOAuthClient, type ClientMetadata } from "@atproto/oauth-client-node";
2
2
import { JoseKey } from "@atproto/jwk-jose";
3
3
import { db } from "./db";
4
+
import { logger } from "./logger";
5
+
6
+
// Session timeout configuration (30 days in seconds)
7
+
const SESSION_TIMEOUT = 30 * 24 * 60 * 60; // 2592000 seconds
8
+
// OAuth state timeout (1 hour in seconds)
9
+
const STATE_TIMEOUT = 60 * 60; // 3600 seconds
4
10
5
11
const stateStore = {
6
12
async set(key: string, data: any) {
7
13
console.debug('[stateStore] set', key)
14
+
const expiresAt = Math.floor(Date.now() / 1000) + STATE_TIMEOUT;
8
15
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
16
+
INSERT INTO oauth_states (key, data, created_at, expires_at)
17
+
VALUES (${key}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt})
18
+
ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data, expires_at = ${expiresAt}
12
19
`;
13
20
},
14
21
async get(key: string) {
15
22
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;
23
+
const now = Math.floor(Date.now() / 1000);
24
+
const result = await db`
25
+
SELECT data, expires_at
26
+
FROM oauth_states
27
+
WHERE key = ${key}
28
+
`;
29
+
if (!result[0]) return undefined;
30
+
31
+
// Check if expired
32
+
const expiresAt = Number(result[0].expires_at);
33
+
if (expiresAt && now > expiresAt) {
34
+
console.debug('[stateStore] State expired, deleting', key);
35
+
await db`DELETE FROM oauth_states WHERE key = ${key}`;
36
+
return undefined;
37
+
}
38
+
39
+
return JSON.parse(result[0].data);
18
40
},
19
41
async del(key: string) {
20
42
console.debug('[stateStore] del', key)
···
25
47
const sessionStore = {
26
48
async set(sub: string, data: any) {
27
49
console.debug('[sessionStore] set', sub)
50
+
const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TIMEOUT;
28
51
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())
52
+
INSERT INTO oauth_sessions (sub, data, updated_at, expires_at)
53
+
VALUES (${sub}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt})
54
+
ON CONFLICT (sub) DO UPDATE SET
55
+
data = EXCLUDED.data,
56
+
updated_at = EXTRACT(EPOCH FROM NOW()),
57
+
expires_at = ${expiresAt}
32
58
`;
33
59
},
34
60
async get(sub: string) {
35
61
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;
62
+
const now = Math.floor(Date.now() / 1000);
63
+
const result = await db`
64
+
SELECT data, expires_at
65
+
FROM oauth_sessions
66
+
WHERE sub = ${sub}
67
+
`;
68
+
if (!result[0]) return undefined;
69
+
70
+
// Check if expired
71
+
const expiresAt = Number(result[0].expires_at);
72
+
if (expiresAt && now > expiresAt) {
73
+
logger.debug('[sessionStore] Session expired, deleting', sub);
74
+
await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
75
+
return undefined;
76
+
}
77
+
78
+
return JSON.parse(result[0].data);
38
79
},
39
80
async del(sub: string) {
40
81
console.debug('[sessionStore] del', sub)
···
44
85
45
86
export { sessionStore };
46
87
88
+
// Cleanup expired sessions and states
89
+
export const cleanupExpiredSessions = async () => {
90
+
const now = Math.floor(Date.now() / 1000);
91
+
try {
92
+
const sessionsDeleted = await db`
93
+
DELETE FROM oauth_sessions WHERE expires_at < ${now}
94
+
`;
95
+
const statesDeleted = await db`
96
+
DELETE FROM oauth_states WHERE expires_at IS NOT NULL AND expires_at < ${now}
97
+
`;
98
+
logger.info(`[Cleanup] Deleted ${sessionsDeleted.length} expired sessions and ${statesDeleted.length} expired states`);
99
+
return { sessions: sessionsDeleted.length, states: statesDeleted.length };
100
+
} catch (err) {
101
+
logger.error('[Cleanup] Failed to cleanup expired data', err);
102
+
return { sessions: 0, states: 0 };
103
+
}
104
+
};
105
+
47
106
export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => {
48
107
// Use editor.wisp.place for OAuth endpoints since that's where the API routes live
49
108
return {
···
72
131
if (!priv) return;
73
132
const kid = key.kid ?? crypto.randomUUID();
74
133
await db`
75
-
INSERT INTO oauth_keys (kid, jwk)
76
-
VALUES (${kid}, ${JSON.stringify(priv)})
134
+
INSERT INTO oauth_keys (kid, jwk, created_at)
135
+
VALUES (${kid}, ${JSON.stringify(priv)}, EXTRACT(EPOCH FROM NOW()))
77
136
ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk
78
137
`;
79
138
};
80
139
81
140
const loadPersistedKeys = async (): Promise<JoseKey[]> => {
82
-
const rows = await db`SELECT kid, jwk FROM oauth_keys ORDER BY kid`;
141
+
const rows = await db`SELECT kid, jwk, created_at FROM oauth_keys ORDER BY kid`;
83
142
const keys: JoseKey[] = [];
84
143
for (const row of rows) {
85
144
try {
···
87
146
const key = await JoseKey.fromImportable(obj as any, (obj as any).kid);
88
147
keys.push(key);
89
148
} catch (err) {
90
-
console.error('Could not parse stored JWK', err);
149
+
logger.error('[OAuth] Could not parse stored JWK', err);
91
150
}
92
151
}
93
152
return keys;
···
112
171
let currentKeys: JoseKey[] = [];
113
172
114
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
177
+
178
+
export const rotateKeysIfNeeded = async (): Promise<boolean> => {
179
+
const now = Math.floor(Date.now() / 1000);
180
+
const cutoffTime = now - KEY_MAX_AGE;
181
+
182
+
try {
183
+
// Find keys older than 30 days
184
+
const oldKeys = await db`
185
+
SELECT kid, created_at FROM oauth_keys
186
+
WHERE created_at IS NOT NULL AND created_at < ${cutoffTime}
187
+
ORDER BY created_at ASC
188
+
`;
189
+
190
+
if (oldKeys.length === 0) {
191
+
logger.debug('[KeyRotation] No keys need rotation');
192
+
return false;
193
+
}
194
+
195
+
logger.info(`[KeyRotation] Found ${oldKeys.length} key(s) older than 30 days, rotating oldest key`);
196
+
197
+
// Rotate the oldest key
198
+
const oldestKey = oldKeys[0];
199
+
const oldKid = oldestKey.kid;
200
+
201
+
// Generate new key with same kid
202
+
const newKey = await JoseKey.generate(['ES256'], oldKid);
203
+
await persistKey(newKey);
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);
213
+
return false;
214
+
}
215
+
};
115
216
116
217
export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
117
218
if (currentKeys.length === 0) {
+2
-1
src/lib/wisp-auth.ts
+2
-1
src/lib/wisp-auth.ts
···
2
2
import { NodeOAuthClient } from "@atproto/oauth-client-node";
3
3
import type { OAuthSession } from "@atproto/oauth-client-node";
4
4
import { Cookie } from "elysia";
5
+
import { logger } from "./logger";
5
6
6
7
7
8
export interface AuthenticatedContext {
···
20
21
const session = await client.restore(did, "auto");
21
22
return session ? { did, session } : null;
22
23
} catch (err) {
23
-
console.error('Authentication error:', err);
24
+
logger.error('[Auth] Authentication error', err);
24
25
return null;
25
26
}
26
27
};
+12
-11
src/routes/auth.ts
+12
-11
src/routes/auth.ts
···
3
3
import { getSitesByDid, getDomainByDid } from '../lib/db'
4
4
import { syncSitesFromPDS } from '../lib/sync-sites'
5
5
import { authenticateRequest } from '../lib/wisp-auth'
6
+
import { logger } from '../lib/logger'
6
7
7
8
export const authRoutes = (client: NodeOAuthClient) => new Elysia()
8
9
.post('/api/auth/signin', async (c) => {
···
12
13
const url = await client.authorize(handle, { state })
13
14
return { url: url.toString() }
14
15
} catch (err) {
15
-
console.error('Signin error', err)
16
+
logger.error('[Auth] Signin error', err)
16
17
return { error: 'Authentication failed' }
17
18
}
18
19
})
···
25
26
const { session } = await client.callback(params)
26
27
27
28
if (!session) {
28
-
console.error('[Auth] OAuth callback failed: no session returned')
29
+
logger.error('[Auth] OAuth callback failed: no session returned')
29
30
return c.redirect('/?error=auth_failed')
30
31
}
31
32
···
33
34
cookieSession.did.value = session.did
34
35
35
36
// Sync sites from PDS to database cache
36
-
console.log('[Auth] Syncing sites from PDS for', session.did)
37
+
logger.debug('[Auth] Syncing sites from PDS for', session.did)
37
38
try {
38
39
const syncResult = await syncSitesFromPDS(session.did, session)
39
-
console.log(`[Auth] Sync complete: ${syncResult.synced} sites synced`)
40
+
logger.debug(`[Auth] Sync complete: ${syncResult.synced} sites synced`)
40
41
if (syncResult.errors.length > 0) {
41
-
console.warn('[Auth] Sync errors:', syncResult.errors)
42
+
logger.debug('[Auth] Sync errors:', syncResult.errors)
42
43
}
43
44
} catch (err) {
44
-
console.error('[Auth] Failed to sync sites:', err)
45
+
logger.error('[Auth] Failed to sync sites', err)
45
46
// Don't fail auth if sync fails, just log it
46
47
}
47
48
···
57
58
return c.redirect('/editor')
58
59
} catch (err) {
59
60
// This catches state validation failures and other OAuth errors
60
-
console.error('[Auth] OAuth callback error:', err)
61
+
logger.error('[Auth] OAuth callback error', err)
61
62
return c.redirect('/?error=auth_failed')
62
63
}
63
64
})
···
74
75
if (did && typeof did === 'string') {
75
76
try {
76
77
await client.revoke(did)
77
-
console.log('[Auth] Revoked OAuth session for', did)
78
+
logger.debug('[Auth] Revoked OAuth session for', did)
78
79
} catch (err) {
79
-
console.error('[Auth] Failed to revoke session:', err)
80
+
logger.error('[Auth] Failed to revoke session', err)
80
81
// Continue with logout even if revoke fails
81
82
}
82
83
}
83
84
84
85
return { success: true }
85
86
} catch (err) {
86
-
console.error('[Auth] Logout error:', err)
87
+
logger.error('[Auth] Logout error', err)
87
88
return { error: 'Logout failed' }
88
89
}
89
90
})
···
100
101
did: auth.did
101
102
}
102
103
} catch (err) {
103
-
console.error('[Auth] Status check error:', err)
104
+
logger.error('[Auth] Status check error', err)
104
105
return { authenticated: false }
105
106
}
106
107
})
+11
-10
src/routes/domain.ts
+11
-10
src/routes/domain.ts
···
20
20
} from '../lib/db'
21
21
import { createHash } from 'crypto'
22
22
import { verifyCustomDomain } from '../lib/dns-verify'
23
+
import { logger } from '../lib/logger'
23
24
24
25
export const domainRoutes = (client: NodeOAuthClient) =>
25
26
new Elysia({ prefix: '/api/domain' })
···
43
44
domain: toDomain(handle)
44
45
};
45
46
} catch (err) {
46
-
console.error("domain/check error", err);
47
+
logger.error('[Domain] Check error', err);
47
48
return {
48
49
available: false
49
50
};
···
69
70
return { registered: false };
70
71
}
71
72
} catch (err) {
72
-
console.error("domain/registered error", err);
73
+
logger.error('[Domain] Registered check error', err);
73
74
set.status = 500;
74
75
return { error: 'Failed to check domain' };
75
76
}
···
118
119
119
120
return { success: true, domain };
120
121
} catch (err) {
121
-
console.error("domain/claim error", err);
122
+
logger.error('[Domain] Claim error', err);
122
123
throw new Error(`Failed to claim: ${err instanceof Error ? err.message : 'Unknown error'}`);
123
124
}
124
125
})
···
160
161
161
162
return { success: true, domain };
162
163
} catch (err) {
163
-
console.error("domain/update error", err);
164
+
logger.error('[Domain] Update error', err);
164
165
throw new Error(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`);
165
166
}
166
167
})
···
193
194
verified: false
194
195
};
195
196
} catch (err) {
196
-
console.error('custom domain add error', err);
197
+
logger.error('[Domain] Custom domain add error', err);
197
198
throw new Error(`Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}`);
198
199
}
199
200
})
···
208
209
}
209
210
210
211
// Verify DNS records (TXT + CNAME)
211
-
console.log(`Verifying custom domain: ${domainInfo.domain}`);
212
+
logger.debug(`[Domain] Verifying custom domain: ${domainInfo.domain}`);
212
213
const result = await verifyCustomDomain(domainInfo.domain, auth.did, id);
213
214
214
215
// Update verification status in database
···
221
222
found: result.found
222
223
};
223
224
} catch (err) {
224
-
console.error('custom domain verify error', err);
225
+
logger.error('[Domain] Custom domain verify error', err);
225
226
throw new Error(`Failed to verify domain: ${err instanceof Error ? err.message : 'Unknown error'}`);
226
227
}
227
228
})
···
244
245
245
246
return { success: true };
246
247
} catch (err) {
247
-
console.error('custom domain delete error', err);
248
+
logger.error('[Domain] Custom domain delete error', err);
248
249
throw new Error(`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`);
249
250
}
250
251
})
···
257
258
258
259
return { success: true };
259
260
} catch (err) {
260
-
console.error('wisp domain map error', err);
261
+
logger.error('[Domain] Wisp domain map error', err);
261
262
throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`);
262
263
}
263
264
})
···
281
282
282
283
return { success: true };
283
284
} catch (err) {
284
-
console.error('custom domain map error', err);
285
+
logger.error('[Domain] Custom domain map error', err);
285
286
throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`);
286
287
}
287
288
});
+8
-7
src/routes/user.ts
+8
-7
src/routes/user.ts
···
4
4
import { Agent } from '@atproto/api'
5
5
import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo } from '../lib/db'
6
6
import { syncSitesFromPDS } from '../lib/sync-sites'
7
+
import { logger } from '../lib/logger'
7
8
8
9
export const userRoutes = (client: NodeOAuthClient) =>
9
10
new Elysia({ prefix: '/api/user' })
···
27
28
sitesCount: sites.length
28
29
}
29
30
} catch (err) {
30
-
console.error('user/status error', err)
31
+
logger.error('[User] Status error', err)
31
32
throw new Error('Failed to get user status')
32
33
}
33
34
})
···
41
42
const profile = await agent.getProfile({ actor: auth.did })
42
43
handle = profile.data.handle
43
44
} catch (err) {
44
-
console.error('Failed to fetch profile:', err)
45
+
logger.error('[User] Failed to fetch profile', err)
45
46
}
46
47
47
48
return {
···
49
50
handle
50
51
}
51
52
} catch (err) {
52
-
console.error('user/info error', err)
53
+
logger.error('[User] Info error', err)
53
54
throw new Error('Failed to get user info')
54
55
}
55
56
})
···
58
59
const sites = await getSitesByDid(auth.did)
59
60
return { sites }
60
61
} catch (err) {
61
-
console.error('user/sites error', err)
62
+
logger.error('[User] Sites error', err)
62
63
throw new Error('Failed to get sites')
63
64
}
64
65
})
···
78
79
customDomains
79
80
}
80
81
} catch (err) {
81
-
console.error('user/domains error', err)
82
+
logger.error('[User] Domains error', err)
82
83
throw new Error('Failed to get domains')
83
84
}
84
85
})
85
86
.post('/sync', async ({ auth }) => {
86
87
try {
87
-
console.log('[User] Manual sync requested for', auth.did)
88
+
logger.debug('[User] Manual sync requested for', auth.did)
88
89
const result = await syncSitesFromPDS(auth.did, auth.session)
89
90
90
91
return {
···
93
94
errors: result.errors
94
95
}
95
96
} catch (err) {
96
-
console.error('user/sync error', err)
97
+
logger.error('[User] Sync error', err)
97
98
throw new Error('Failed to sync sites')
98
99
}
99
100
})
+8
-15
src/routes/wisp.ts
+8
-15
src/routes/wisp.ts
···
10
10
updateFileBlobs
11
11
} from '../lib/wisp-utils'
12
12
import { upsertSite } from '../lib/db'
13
+
import { logger } from '../lib/logger'
13
14
14
-
/**
15
-
* Validate site name (rkey) according to AT Protocol specifications
16
-
* - Must be 1-512 characters
17
-
* - Can only contain: alphanumeric, dots, dashes, underscores, tildes, colons
18
-
* - Cannot be just "." or ".."
19
-
* - Cannot contain path traversal sequences
20
-
*/
21
15
function isValidSiteName(siteName: string): boolean {
22
16
if (!siteName || typeof siteName !== 'string') return false;
23
17
···
235
229
returnedMimeType: returnedBlobRef.mimeType
236
230
};
237
231
} catch (uploadError) {
238
-
console.error(`❌ Upload failed for ${file.name}:`, uploadError);
232
+
logger.error('[Wisp] Upload failed for file', uploadError);
239
233
throw uploadError;
240
234
}
241
235
});
···
265
259
record: manifest
266
260
});
267
261
} catch (putRecordError: any) {
268
-
console.error('\n❌ Failed to create record on PDS');
269
-
console.error('Error:', putRecordError.message);
262
+
logger.error('[Wisp] Failed to create record on PDS');
263
+
logger.error('[Wisp] Record creation error', putRecordError);
270
264
271
265
throw putRecordError;
272
266
}
···
284
278
285
279
return result;
286
280
} catch (error) {
287
-
console.error('❌ Upload error:', error);
288
-
console.error('Error details:', {
281
+
logger.error('[Wisp] Upload error', error);
282
+
logger.errorWithContext('[Wisp] Upload error details', {
289
283
message: error instanceof Error ? error.message : 'Unknown error',
290
-
stack: error instanceof Error ? error.stack : undefined,
291
284
name: error instanceof Error ? error.name : undefined
292
-
});
285
+
}, error);
293
286
throw new Error(`Failed to upload files: ${error instanceof Error ? error.message : 'Unknown error'}`);
294
287
}
295
288
}
296
-
)
289
+
)