+2
-2
src/lib/db.test.ts
+2
-2
src/lib/db.test.ts
···
66
66
// Now try to claim it with testDid1 - should fail
67
67
try {
68
68
await claimCustomDomain(testDid1, testDomain, hash3)
69
-
expect.fail('Should have thrown an error when trying to claim a verified domain')
69
+
expect('Should have thrown an error when trying to claim a verified domain').fail()
70
70
} catch (err) {
71
-
expect(err.message).toBe('conflict')
71
+
expect((err as Error).message).toBe('conflict')
72
72
}
73
73
74
74
// Verify the domain is still owned by testDid2 and verified
+1
-242
src/lib/db.ts
+1
-242
src/lib/db.ts
···
1
-
import { NodeOAuthClient, type ClientMetadata } from "@atproto/oauth-client-node";
2
1
import { SQL } from "bun";
3
-
import { JoseKey } from "@atproto/jwk-jose";
4
2
import { BASE_HOST } from "./constants";
5
3
6
4
export const db = new SQL(
···
221
219
return rows[0] ?? null;
222
220
};
223
221
224
-
export const getAllWispDomains = async (did: string) => {
222
+
export const getAllWispDomains = async (did: string): Promise<Array<{ domain: string; rkey: string | null }>> => {
225
223
const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC`;
226
224
return rows;
227
225
};
···
338
336
339
337
export const deleteWispDomain = async (domain: string): Promise<void> => {
340
338
await db`DELETE FROM domains WHERE domain = ${domain}`;
341
-
};
342
-
343
-
// Session timeout configuration (30 days in seconds)
344
-
const SESSION_TIMEOUT = 30 * 24 * 60 * 60; // 2592000 seconds
345
-
// OAuth state timeout (1 hour in seconds)
346
-
const STATE_TIMEOUT = 60 * 60; // 3600 seconds
347
-
348
-
const stateStore = {
349
-
async set(key: string, data: any) {
350
-
const expiresAt = Math.floor(Date.now() / 1000) + STATE_TIMEOUT;
351
-
await db`
352
-
INSERT INTO oauth_states (key, data, created_at, expires_at)
353
-
VALUES (${key}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt})
354
-
ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data, expires_at = ${expiresAt}
355
-
`;
356
-
},
357
-
async get(key: string) {
358
-
const now = Math.floor(Date.now() / 1000);
359
-
const result = await db`
360
-
SELECT data, expires_at
361
-
FROM oauth_states
362
-
WHERE key = ${key}
363
-
`;
364
-
if (!result[0]) return undefined;
365
-
366
-
// Check if expired
367
-
const expiresAt = Number(result[0].expires_at);
368
-
if (expiresAt && now > expiresAt) {
369
-
await db`DELETE FROM oauth_states WHERE key = ${key}`;
370
-
return undefined;
371
-
}
372
-
373
-
return JSON.parse(result[0].data);
374
-
},
375
-
async del(key: string) {
376
-
await db`DELETE FROM oauth_states WHERE key = ${key}`;
377
-
}
378
-
};
379
-
380
-
const sessionStore = {
381
-
async set(sub: string, data: any) {
382
-
const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TIMEOUT;
383
-
await db`
384
-
INSERT INTO oauth_sessions (sub, data, updated_at, expires_at)
385
-
VALUES (${sub}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt})
386
-
ON CONFLICT (sub) DO UPDATE SET
387
-
data = EXCLUDED.data,
388
-
updated_at = EXTRACT(EPOCH FROM NOW()),
389
-
expires_at = ${expiresAt}
390
-
`;
391
-
},
392
-
async get(sub: string) {
393
-
const now = Math.floor(Date.now() / 1000);
394
-
const result = await db`
395
-
SELECT data, expires_at
396
-
FROM oauth_sessions
397
-
WHERE sub = ${sub}
398
-
`;
399
-
if (!result[0]) return undefined;
400
-
401
-
// Check if expired
402
-
const expiresAt = Number(result[0].expires_at);
403
-
if (expiresAt && now > expiresAt) {
404
-
console.log('[sessionStore] Session expired, deleting', sub);
405
-
await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
406
-
return undefined;
407
-
}
408
-
409
-
return JSON.parse(result[0].data);
410
-
},
411
-
async del(sub: string) {
412
-
await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
413
-
}
414
-
};
415
-
416
-
export { sessionStore };
417
-
418
-
// Cleanup expired sessions and states
419
-
export const cleanupExpiredSessions = async () => {
420
-
const now = Math.floor(Date.now() / 1000);
421
-
try {
422
-
const sessionsDeleted = await db`
423
-
DELETE FROM oauth_sessions WHERE expires_at < ${now}
424
-
`;
425
-
const statesDeleted = await db`
426
-
DELETE FROM oauth_states WHERE expires_at IS NOT NULL AND expires_at < ${now}
427
-
`;
428
-
console.log(`[Cleanup] Deleted ${sessionsDeleted.length} expired sessions and ${statesDeleted.length} expired states`);
429
-
return { sessions: sessionsDeleted.length, states: statesDeleted.length };
430
-
} catch (err) {
431
-
console.error('[Cleanup] Failed to cleanup expired data:', err);
432
-
return { sessions: 0, states: 0 };
433
-
}
434
-
};
435
-
436
-
export const createClientMetadata = (config: { domain: `http://${string}` | `https://${string}`, clientName: string }): ClientMetadata => {
437
-
const isLocalDev = process.env.LOCAL_DEV === 'true';
438
-
439
-
if (isLocalDev) {
440
-
// Loopback client for local development
441
-
// For loopback, scopes and redirect_uri must be in client_id query string
442
-
const redirectUri = 'http://127.0.0.1:8000/api/auth/callback';
443
-
const scope = 'atproto transition:generic';
444
-
const params = new URLSearchParams();
445
-
params.append('redirect_uri', redirectUri);
446
-
params.append('scope', scope);
447
-
448
-
return {
449
-
client_id: `http://localhost?${params.toString()}`,
450
-
client_name: config.clientName,
451
-
client_uri: config.domain,
452
-
redirect_uris: [redirectUri],
453
-
grant_types: ['authorization_code', 'refresh_token'],
454
-
response_types: ['code'],
455
-
application_type: 'web',
456
-
token_endpoint_auth_method: 'none',
457
-
scope: scope,
458
-
dpop_bound_access_tokens: false,
459
-
subject_type: 'public'
460
-
};
461
-
}
462
-
463
-
// Production client with private_key_jwt
464
-
return {
465
-
client_id: `${config.domain}/client-metadata.json`,
466
-
client_name: config.clientName,
467
-
client_uri: config.domain,
468
-
logo_uri: `${config.domain}/logo.png`,
469
-
tos_uri: `${config.domain}/tos`,
470
-
policy_uri: `${config.domain}/policy`,
471
-
redirect_uris: [`${config.domain}/api/auth/callback`],
472
-
grant_types: ['authorization_code', 'refresh_token'],
473
-
response_types: ['code'],
474
-
application_type: 'web',
475
-
token_endpoint_auth_method: 'private_key_jwt',
476
-
token_endpoint_auth_signing_alg: "ES256",
477
-
scope: "atproto transition:generic",
478
-
dpop_bound_access_tokens: true,
479
-
jwks_uri: `${config.domain}/jwks.json`,
480
-
subject_type: 'public',
481
-
authorization_signed_response_alg: 'ES256'
482
-
};
483
-
};
484
-
485
-
const persistKey = async (key: JoseKey) => {
486
-
const priv = key.privateJwk;
487
-
if (!priv) return;
488
-
const kid = key.kid ?? crypto.randomUUID();
489
-
await db`
490
-
INSERT INTO oauth_keys (kid, jwk, created_at)
491
-
VALUES (${kid}, ${JSON.stringify(priv)}, EXTRACT(EPOCH FROM NOW()))
492
-
ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk
493
-
`;
494
-
};
495
-
496
-
const loadPersistedKeys = async (): Promise<JoseKey[]> => {
497
-
const rows = await db`SELECT kid, jwk, created_at FROM oauth_keys ORDER BY kid`;
498
-
const keys: JoseKey[] = [];
499
-
for (const row of rows) {
500
-
try {
501
-
const obj = JSON.parse(row.jwk);
502
-
const key = await JoseKey.fromImportable(obj as any, (obj as any).kid);
503
-
keys.push(key);
504
-
} catch (err) {
505
-
console.error('Could not parse stored JWK', err);
506
-
}
507
-
}
508
-
return keys;
509
-
};
510
-
511
-
const ensureKeys = async (): Promise<JoseKey[]> => {
512
-
let keys = await loadPersistedKeys();
513
-
const needed: string[] = [];
514
-
for (let i = 1; i <= 3; i++) {
515
-
const kid = `key${i}`;
516
-
if (!keys.some(k => k.kid === kid)) needed.push(kid);
517
-
}
518
-
for (const kid of needed) {
519
-
const newKey = await JoseKey.generate(['ES256'], kid);
520
-
await persistKey(newKey);
521
-
keys.push(newKey);
522
-
}
523
-
keys.sort((a, b) => (a.kid ?? '').localeCompare(b.kid ?? ''));
524
-
return keys;
525
-
};
526
-
527
-
// Load keys from database every time (stateless - safe for horizontal scaling)
528
-
export const getCurrentKeys = async (): Promise<JoseKey[]> => {
529
-
return await loadPersistedKeys();
530
-
};
531
-
532
-
// Key rotation - rotate keys older than 30 days (monthly rotation)
533
-
const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
534
-
535
-
export const rotateKeysIfNeeded = async (): Promise<boolean> => {
536
-
const now = Math.floor(Date.now() / 1000);
537
-
const cutoffTime = now - KEY_MAX_AGE;
538
-
539
-
try {
540
-
// Find keys older than 30 days
541
-
const oldKeys = await db`
542
-
SELECT kid, created_at FROM oauth_keys
543
-
WHERE created_at IS NOT NULL AND created_at < ${cutoffTime}
544
-
ORDER BY created_at ASC
545
-
`;
546
-
547
-
if (oldKeys.length === 0) {
548
-
console.log('[KeyRotation] No keys need rotation');
549
-
return false;
550
-
}
551
-
552
-
console.log(`[KeyRotation] Found ${oldKeys.length} key(s) older than 30 days, rotating oldest key`);
553
-
554
-
// Rotate the oldest key
555
-
const oldestKey = oldKeys[0];
556
-
const oldKid = oldestKey.kid;
557
-
558
-
// Generate new key with same kid
559
-
const newKey = await JoseKey.generate(['ES256'], oldKid);
560
-
await persistKey(newKey);
561
-
562
-
console.log(`[KeyRotation] Rotated key ${oldKid}`);
563
-
564
-
return true;
565
-
} catch (err) {
566
-
console.error('[KeyRotation] Failed to rotate keys:', err);
567
-
return false;
568
-
}
569
-
};
570
-
571
-
export const getOAuthClient = async (config: { domain: `http://${string}` | `https://${string}`, clientName: string }) => {
572
-
const keys = await ensureKeys();
573
-
574
-
return new NodeOAuthClient({
575
-
clientMetadata: createClientMetadata(config),
576
-
keyset: keys,
577
-
stateStore,
578
-
sessionStore
579
-
});
580
339
};
581
340
582
341
export const getCustomDomainsByDid = async (did: string) => {
+4
-3
src/lib/oauth-client.ts
+4
-3
src/lib/oauth-client.ts
···
126
126
token_endpoint_auth_method: 'none',
127
127
scope: scope,
128
128
dpop_bound_access_tokens: false,
129
-
subject_type: 'public'
130
-
};
129
+
subject_type: 'public',
130
+
authorization_signed_response_alg: 'ES256'
131
+
} as ClientMetadata;
131
132
}
132
133
133
134
// Production client with private_key_jwt
···
149
150
jwks_uri: `${config.domain}/jwks.json`,
150
151
subject_type: 'public',
151
152
authorization_signed_response_alg: 'ES256'
152
-
};
153
+
} as ClientMetadata;
153
154
};
154
155
155
156
const persistKey = async (key: JoseKey) => {
+12
-12
src/lib/wisp-utils.test.ts
+12
-12
src/lib/wisp-utils.test.ts
···
22
22
function createMockBlobRef(mimeType: string, size: number): BlobRef {
23
23
// Create a properly formatted CID
24
24
const cid = CID.parse(TEST_CID_STRING)
25
-
return new BlobRef(cid, mimeType, size)
25
+
return new BlobRef(cid as any, mimeType, size)
26
26
}
27
27
28
28
describe('shouldCompressFile', () => {
···
727
727
describe('extractBlobMap', () => {
728
728
test('should extract blob map from flat directory structure', () => {
729
729
const mockCid = CID.parse(TEST_CID_STRING)
730
-
const mockBlob = new BlobRef(mockCid, 'text/html', 100)
730
+
const mockBlob = new BlobRef(mockCid as any, 'text/html', 100)
731
731
732
732
const directory: Directory = {
733
733
$type: 'place.wisp.fs#directory',
···
758
758
const mockCid1 = CID.parse(TEST_CID_STRING)
759
759
const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
760
760
761
-
const mockBlob1 = new BlobRef(mockCid1, 'text/html', 100)
762
-
const mockBlob2 = new BlobRef(mockCid2, 'text/css', 50)
761
+
const mockBlob1 = new BlobRef(mockCid1 as any, 'text/html', 100)
762
+
const mockBlob2 = new BlobRef(mockCid2 as any, 'text/css', 50)
763
763
764
764
const directory: Directory = {
765
765
$type: 'place.wisp.fs#directory',
···
805
805
806
806
test('should handle deeply nested directory structures', () => {
807
807
const mockCid = CID.parse(TEST_CID_STRING)
808
-
const mockBlob = new BlobRef(mockCid, 'text/javascript', 200)
808
+
const mockBlob = new BlobRef(mockCid as any, 'text/javascript', 200)
809
809
810
810
const directory: Directory = {
811
811
$type: 'place.wisp.fs#directory',
···
863
863
// This test verifies the fix: AT Protocol SDK returns BlobRef instances,
864
864
// not plain objects with $type and $link properties
865
865
const mockCid = CID.parse(TEST_CID_STRING)
866
-
const mockBlob = new BlobRef(mockCid, 'application/octet-stream', 500)
866
+
const mockBlob = new BlobRef(mockCid as any, 'application/octet-stream', 500)
867
867
868
868
const directory: Directory = {
869
869
$type: 'place.wisp.fs#directory',
···
892
892
const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
893
893
const mockCid3 = CID.parse('bafkreieb3ixgchss44kw7xiavnkns47emdfsqbhcdfluo3p6n3o53fl3vq')
894
894
895
-
const mockBlob1 = new BlobRef(mockCid1, 'image/png', 1000)
896
-
const mockBlob2 = new BlobRef(mockCid2, 'image/png', 2000)
897
-
const mockBlob3 = new BlobRef(mockCid3, 'image/png', 3000)
895
+
const mockBlob1 = new BlobRef(mockCid1 as any, 'image/png', 1000)
896
+
const mockBlob2 = new BlobRef(mockCid2 as any, 'image/png', 2000)
897
+
const mockBlob3 = new BlobRef(mockCid3 as any, 'image/png', 3000)
898
898
899
899
const directory: Directory = {
900
900
$type: 'place.wisp.fs#directory',
···
958
958
node: {
959
959
$type: 'place.wisp.fs#file',
960
960
type: 'file',
961
-
blob: new BlobRef(mockCid1, 'text/html', 100),
961
+
blob: new BlobRef(mockCid1 as any, 'text/html', 100),
962
962
},
963
963
},
964
964
{
···
972
972
node: {
973
973
$type: 'place.wisp.fs#file',
974
974
type: 'file',
975
-
blob: new BlobRef(mockCid2, 'text/css', 50),
975
+
blob: new BlobRef(mockCid2 as any, 'text/css', 50),
976
976
},
977
977
},
978
978
],
···
983
983
node: {
984
984
$type: 'place.wisp.fs#file',
985
985
type: 'file',
986
-
blob: new BlobRef(mockCid3, 'text/markdown', 200),
986
+
blob: new BlobRef(mockCid3 as any, 'text/markdown', 200),
987
987
},
988
988
},
989
989
],
+4
-1
src/lib/wisp-utils.ts
+4
-1
src/lib/wisp-utils.ts
···
446
446
const remainingPath = pathParts.slice(1).join('/');
447
447
return {
448
448
name: entry.name,
449
-
node: replaceDirectoryWithSubfs(entry.node as Directory, remainingPath, subfsUri)
449
+
node: {
450
+
...replaceDirectoryWithSubfs(entry.node as Directory, remainingPath, subfsUri),
451
+
$type: 'place.wisp.fs#directory' as const
452
+
}
450
453
};
451
454
}
452
455
}
+2
-2
src/routes/auth.ts
+2
-2
src/routes/auth.ts
···
51
51
})
52
52
53
53
// Sync sites from PDS to database cache
54
-
logger.debug('[Auth] Syncing sites from PDS for', session.did)
54
+
logger.debug('[Auth] Syncing sites from PDS for', session.did as any)
55
55
try {
56
56
const syncResult = await syncSitesFromPDS(session.did, session)
57
57
logger.debug(`[Auth] Sync complete: ${syncResult.synced} sites synced`)
···
92
92
if (did && typeof did === 'string') {
93
93
try {
94
94
await client.revoke(did)
95
-
logger.debug('[Auth] Revoked OAuth session for', did)
95
+
logger.debug('[Auth] Revoked OAuth session for', did as any)
96
96
} catch (err) {
97
97
logger.error('[Auth] Failed to revoke session', err)
98
98
// Continue with logout even if revoke fails
+1
-1
src/routes/domain.ts
+1
-1
src/routes/domain.ts
···
362
362
});
363
363
} catch (err) {
364
364
// Record might not exist in PDS, continue anyway
365
-
logger.warn('[Domain] Could not delete wisp domain from PDS', err);
365
+
logger.warn('[Domain] Could not delete wisp domain from PDS', err as any);
366
366
}
367
367
368
368
return { success: true };