tangled
alpha
login
or
join now
aottr.dev
/
wisp.place-monorepo
forked from
nekomimi.pet/wisp.place-monorepo
0
fork
atom
Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
0
fork
atom
overview
issues
pulls
pipelines
better modualizaition of identity
nekomimi.pet
1 month ago
b770c6b0
1a841df1
+168
-140
8 changed files
expand all
collapse all
unified
split
.gitignore
apps
hosting-service
src
lib
utils.ts
main-app
src
routes
user.ts
cli
commands
pull.ts
serve.ts
lib
auth.ts
packages
@wisp
atproto-utils
src
identity.ts
index.ts
+1
.gitignore
···
1
1
.research/
2
2
+
cache/
2
3
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
3
4
.env
4
5
# dependencies
+2
-61
apps/hosting-service/src/lib/utils.ts
···
1
1
-
import { AtpAgent } from '@atproto/api';
2
1
import type { Record as WispFsRecord, Directory, Entry, File } from '@wisp/lexicons/types/place/wisp/fs';
3
2
import type { Record as SubfsRecord } from '@wisp/lexicons/types/place/wisp/subfs';
4
3
import type { Record as WispSettings } from '@wisp/lexicons/types/place/wisp/settings';
···
7
6
import { Readable } from 'stream';
8
7
import { safeFetchJson, safeFetchBlob } from '@wisp/safe-fetch';
9
8
import { CID } from 'multiformats';
10
10
-
import { extractBlobCid } from '@wisp/atproto-utils';
9
9
+
import { extractBlobCid, resolveDid, getPdsForDid, didWebToHttps } from '@wisp/atproto-utils';
11
10
import { sanitizePath, collectFileCidsFromEntries, countFilesInDirectory } from '@wisp/fs-utils';
12
11
import { shouldCompressMimeType } from '@wisp/atproto-utils/compression';
13
12
import { MAX_BLOB_SIZE, MAX_FILE_COUNT, MAX_SITE_SIZE } from '@wisp/constants';
14
13
import { storage } from './storage';
15
14
16
15
// Re-export shared utilities for local usage and tests
17
17
-
export { extractBlobCid, sanitizePath };
16
16
+
export { extractBlobCid, sanitizePath, resolveDid, getPdsForDid, didWebToHttps };
18
17
19
18
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
20
19
const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL
···
30
29
settings?: WispSettings | null;
31
30
}
32
31
33
33
-
34
34
-
export async function resolveDid(identifier: string): Promise<string | null> {
35
35
-
try {
36
36
-
// If it's already a DID, return it
37
37
-
if (identifier.startsWith('did:')) {
38
38
-
return identifier;
39
39
-
}
40
40
-
41
41
-
// Otherwise, resolve the handle using agent's built-in method
42
42
-
const agent = new AtpAgent({ service: 'https://public.api.bsky.app' });
43
43
-
const response = await agent.resolveHandle({ handle: identifier });
44
44
-
return response.data.did;
45
45
-
} catch (err) {
46
46
-
console.error('Failed to resolve identifier', identifier, err);
47
47
-
return null;
48
48
-
}
49
49
-
}
50
50
-
51
51
-
export async function getPdsForDid(did: string): Promise<string | null> {
52
52
-
try {
53
53
-
let doc;
54
54
-
55
55
-
if (did.startsWith('did:plc:')) {
56
56
-
doc = await safeFetchJson(`https://plc.directory/${encodeURIComponent(did)}`);
57
57
-
} else if (did.startsWith('did:web:')) {
58
58
-
const didUrl = didWebToHttps(did);
59
59
-
doc = await safeFetchJson(didUrl);
60
60
-
} else {
61
61
-
console.error('Unsupported DID method', did);
62
62
-
return null;
63
63
-
}
64
64
-
65
65
-
const services = doc.service || [];
66
66
-
const pdsService = services.find((s: any) => s.id === '#atproto_pds');
67
67
-
68
68
-
return pdsService?.serviceEndpoint || null;
69
69
-
} catch (err) {
70
70
-
console.error('Failed to get PDS for DID', did, err);
71
71
-
return null;
72
72
-
}
73
73
-
}
74
74
-
75
75
-
function didWebToHttps(did: string): string {
76
76
-
const didParts = did.split(':');
77
77
-
if (didParts.length < 3 || didParts[0] !== 'did' || didParts[1] !== 'web') {
78
78
-
throw new Error('Invalid did:web format');
79
79
-
}
80
80
-
81
81
-
const domain = didParts[2];
82
82
-
const pathParts = didParts.slice(3);
83
83
-
84
84
-
if (pathParts.length === 0) {
85
85
-
return `https://${domain}/.well-known/did.json`;
86
86
-
} else {
87
87
-
const path = pathParts.join('/');
88
88
-
return `https://${domain}/${path}/did.json`;
89
89
-
}
90
90
-
}
91
32
92
33
export async function fetchSiteRecord(did: string, rkey: string): Promise<{ record: WispFsRecord; cid: string } | null> {
93
34
try {
+4
-8
apps/main-app/src/routes/user.ts
···
4
4
import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo, getDomainsBySite, getAllWispDomains } from '../lib/db'
5
5
import { syncSitesFromPDS } from '../lib/sync-sites'
6
6
import { createLogger } from '@wisp/observability'
7
7
-
import { createDidResolver, extractAtprotoData } from '@atproto-labs/did-resolver'
7
7
+
import { getHandleForDid } from '@wisp/atproto-utils'
8
8
9
9
const logger = createLogger('main-app')
10
10
-
const didResolver = createDidResolver({})
11
10
12
11
export const userRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
13
12
new Elysia({
···
53
52
try {
54
53
let handle = 'unknown'
55
54
try {
56
56
-
const didDoc = await didResolver.resolve(auth.did)
57
57
-
const atprotoData = extractAtprotoData(didDoc)
58
58
-
59
59
-
if (atprotoData.aka) {
60
60
-
handle = atprotoData.aka
55
55
+
const resolvedHandle = await getHandleForDid(auth.did)
56
56
+
if (resolvedHandle) {
57
57
+
handle = resolvedHandle
61
58
}
62
59
} catch (err) {
63
63
-
64
60
logger.error('[User] Failed to resolve DID', err)
65
61
}
66
62
+1
-36
cli/commands/pull.ts
···
1
1
-
import { AtpAgent } from '@atproto/api';
2
1
import type { Directory, Entry, File, Record as FsRecord } from '@wisp/lexicons/types/place/wisp/fs';
3
2
import type { Record as SubfsRecord } from '@wisp/lexicons/types/place/wisp/subfs';
4
4
-
import { extractBlobCid } from '@wisp/atproto-utils';
3
3
+
import { extractBlobCid, resolveDid, getPdsForDid } from '@wisp/atproto-utils';
5
4
import { sanitizePath } from '@wisp/fs-utils';
6
5
import { existsSync, mkdirSync, writeFileSync, rmSync, renameSync, readFileSync } from 'fs';
7
6
import { dirname, join } from 'path';
···
14
13
export interface PullOptions {
15
14
site: string;
16
15
path: string;
17
17
-
}
18
18
-
19
19
-
async function resolveDid(identifier: string): Promise<string | null> {
20
20
-
if (identifier.startsWith('did:')) {
21
21
-
return identifier;
22
22
-
}
23
23
-
24
24
-
const agent = new AtpAgent({ service: 'https://public.api.bsky.app' });
25
25
-
const response = await agent.resolveHandle({ handle: identifier });
26
26
-
return response.data.did;
27
27
-
}
28
28
-
29
29
-
async function getPdsForDid(did: string): Promise<string | null> {
30
30
-
let doc: { service?: Array<{ id: string; serviceEndpoint?: string }> };
31
31
-
32
32
-
if (did.startsWith('did:plc:')) {
33
33
-
const res = await fetch(`https://plc.directory/${encodeURIComponent(did)}`);
34
34
-
doc = await res.json() as typeof doc;
35
35
-
} else if (did.startsWith('did:web:')) {
36
36
-
const didParts = did.split(':');
37
37
-
const domain = didParts[2];
38
38
-
const pathParts = didParts.slice(3);
39
39
-
const url = pathParts.length === 0
40
40
-
? `https://${domain}/.well-known/did.json`
41
41
-
: `https://${domain}/${pathParts.join('/')}/did.json`;
42
42
-
const res = await fetch(url);
43
43
-
doc = await res.json() as typeof doc;
44
44
-
} else {
45
45
-
return null;
46
46
-
}
47
47
-
48
48
-
const services = doc.service || [];
49
49
-
const pdsService = services.find((s) => s.id === '#atproto_pds');
50
50
-
return pdsService?.serviceEndpoint || null;
51
16
}
52
17
53
18
async function fetchRecord(pdsEndpoint: string, did: string, collection: string, rkey: string): Promise<any> {
+1
-34
cli/commands/serve.ts
···
1
1
-
import { AtpAgent } from '@atproto/api';
2
1
import { IdResolver } from '@atproto/identity';
3
2
import { Firehose } from '@atproto/sync';
4
3
import { Hono } from 'hono';
5
4
import { serve as honoNodeServe } from '@hono/node-server';
6
5
import type { Record as SettingsRecord } from '@wisp/lexicons/types/place/wisp/settings';
6
6
+
import { resolveDid, getPdsForDid } from '@wisp/atproto-utils';
7
7
import { existsSync, readFileSync, statSync, readdirSync } from 'fs';
8
8
import { join, extname } from 'path';
9
9
import { lookup } from 'mime-types';
···
26
26
siteDir: string;
27
27
settings: SettingsRecord | null;
28
28
redirectRules: RedirectRule[];
29
29
-
}
30
30
-
31
31
-
async function resolveDid(identifier: string): Promise<string | null> {
32
32
-
if (identifier.startsWith('did:')) {
33
33
-
return identifier;
34
34
-
}
35
35
-
const agent = new AtpAgent({ service: 'https://public.api.bsky.app' });
36
36
-
const response = await agent.resolveHandle({ handle: identifier });
37
37
-
return response.data.did;
38
38
-
}
39
39
-
40
40
-
async function getPdsForDid(did: string): Promise<string | null> {
41
41
-
let doc: { service?: Array<{ id: string; serviceEndpoint?: string }> };
42
42
-
43
43
-
if (did.startsWith('did:plc:')) {
44
44
-
const res = await fetch(`https://plc.directory/${encodeURIComponent(did)}`);
45
45
-
doc = await res.json() as typeof doc;
46
46
-
} else if (did.startsWith('did:web:')) {
47
47
-
const didParts = did.split(':');
48
48
-
const domain = didParts[2];
49
49
-
const pathParts = didParts.slice(3);
50
50
-
const url = pathParts.length === 0
51
51
-
? `https://${domain}/.well-known/did.json`
52
52
-
: `https://${domain}/${pathParts.join('/')}/did.json`;
53
53
-
const res = await fetch(url);
54
54
-
doc = await res.json() as typeof doc;
55
55
-
} else {
56
56
-
return null;
57
57
-
}
58
58
-
59
59
-
const services = doc.service || [];
60
60
-
const pdsService = services.find((s) => s.id === '#atproto_pds');
61
61
-
return pdsService?.serviceEndpoint || null;
62
29
}
63
30
64
31
async function fetchSettings(pdsEndpoint: string, did: string, rkey: string): Promise<SettingsRecord | null> {
+9
-1
cli/lib/auth.ts
···
1
1
import { NodeOAuthClient, type NodeSavedSession, type NodeSavedState, type NodeSavedStateStore, type NodeSavedSessionStore } from "@atproto/oauth-client-node";
2
2
import { Agent, CredentialSession } from "@atproto/api";
3
3
+
import { resolvePdsFromHandle } from "@wisp/atproto-utils";
3
4
import { Hono } from "hono";
4
5
import { serve as honoNodeServe } from "@hono/node-server";
5
6
import open from "open";
···
236
237
password: string,
237
238
pdsUrl?: string
238
239
): Promise<{ agent: Agent; did: string }> {
239
239
-
const serviceUrl = pdsUrl || 'https://bsky.social';
240
240
+
let serviceUrl = pdsUrl;
241
241
+
242
242
+
if (!serviceUrl) {
243
243
+
// Resolve the handle to find the correct PDS
244
244
+
console.log(`Resolving PDS for ${identifier}...`);
245
245
+
serviceUrl = await resolvePdsFromHandle(identifier);
246
246
+
console.log(`Found PDS: ${serviceUrl}`);
247
247
+
}
240
248
241
249
const credSession = new CredentialSession(new URL(serviceUrl));
242
250
await credSession.login({ identifier, password });
+140
packages/@wisp/atproto-utils/src/identity.ts
···
1
1
+
/**
2
2
+
* AT Protocol identity utilities for resolving handles and DIDs
3
3
+
*/
4
4
+
5
5
+
interface DidDocument {
6
6
+
service?: Array<{ id: string; serviceEndpoint?: string }>;
7
7
+
alsoKnownAs?: string[];
8
8
+
}
9
9
+
10
10
+
/**
11
11
+
* Convert a did:web to an HTTPS URL for fetching the DID document
12
12
+
*/
13
13
+
export function didWebToHttps(did: string): string {
14
14
+
const didParts = did.split(':');
15
15
+
if (didParts.length < 3 || didParts[0] !== 'did' || didParts[1] !== 'web') {
16
16
+
throw new Error('Invalid did:web format');
17
17
+
}
18
18
+
19
19
+
const domain = didParts[2];
20
20
+
const pathParts = didParts.slice(3);
21
21
+
22
22
+
if (pathParts.length === 0) {
23
23
+
return `https://${domain}/.well-known/did.json`;
24
24
+
} else {
25
25
+
const path = pathParts.join('/');
26
26
+
return `https://${domain}/${path}/did.json`;
27
27
+
}
28
28
+
}
29
29
+
30
30
+
/**
31
31
+
* Resolve a handle or DID to a DID
32
32
+
* If the identifier is already a DID, returns it as-is
33
33
+
* If it's a handle, resolves it to a DID using the public API
34
34
+
*/
35
35
+
export async function resolveDid(identifier: string): Promise<string | null> {
36
36
+
try {
37
37
+
// If it's already a DID, return it
38
38
+
if (identifier.startsWith('did:')) {
39
39
+
return identifier;
40
40
+
}
41
41
+
42
42
+
// Otherwise, resolve the handle using the public API
43
43
+
const url = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(identifier)}`;
44
44
+
const response = await fetch(url);
45
45
+
46
46
+
if (!response.ok) {
47
47
+
console.error('Failed to resolve handle', identifier, response.status);
48
48
+
return null;
49
49
+
}
50
50
+
51
51
+
const data = await response.json() as { did: string };
52
52
+
return data.did;
53
53
+
} catch (err) {
54
54
+
console.error('Failed to resolve identifier', identifier, err);
55
55
+
return null;
56
56
+
}
57
57
+
}
58
58
+
59
59
+
/**
60
60
+
* Fetch the DID document for a DID
61
61
+
*/
62
62
+
export async function getDidDocument(did: string): Promise<DidDocument | null> {
63
63
+
try {
64
64
+
if (did.startsWith('did:plc:')) {
65
65
+
const res = await fetch(`https://plc.directory/${encodeURIComponent(did)}`);
66
66
+
if (!res.ok) return null;
67
67
+
return await res.json() as DidDocument;
68
68
+
} else if (did.startsWith('did:web:')) {
69
69
+
const didUrl = didWebToHttps(did);
70
70
+
const res = await fetch(didUrl);
71
71
+
if (!res.ok) return null;
72
72
+
return await res.json() as DidDocument;
73
73
+
} else {
74
74
+
console.error('Unsupported DID method', did);
75
75
+
return null;
76
76
+
}
77
77
+
} catch (err) {
78
78
+
console.error('Failed to fetch DID document', did, err);
79
79
+
return null;
80
80
+
}
81
81
+
}
82
82
+
83
83
+
/**
84
84
+
* Get the PDS endpoint for a DID from its DID document
85
85
+
*/
86
86
+
export async function getPdsForDid(did: string): Promise<string | null> {
87
87
+
try {
88
88
+
const doc = await getDidDocument(did);
89
89
+
if (!doc) return null;
90
90
+
91
91
+
const services = doc.service || [];
92
92
+
const pdsService = services.find((s) => s.id === '#atproto_pds');
93
93
+
94
94
+
return pdsService?.serviceEndpoint || null;
95
95
+
} catch (err) {
96
96
+
console.error('Failed to get PDS for DID', did, err);
97
97
+
return null;
98
98
+
}
99
99
+
}
100
100
+
101
101
+
/**
102
102
+
* Get the handle (alsoKnownAs) for a DID from its DID document
103
103
+
*/
104
104
+
export async function getHandleForDid(did: string): Promise<string | null> {
105
105
+
try {
106
106
+
const doc = await getDidDocument(did);
107
107
+
if (!doc) return null;
108
108
+
109
109
+
const aka = doc.alsoKnownAs || [];
110
110
+
// Find the at:// handle
111
111
+
const atHandle = aka.find((h) => h.startsWith('at://'));
112
112
+
if (atHandle) {
113
113
+
// Remove 'at://' prefix
114
114
+
return atHandle.replace('at://', '');
115
115
+
}
116
116
+
117
117
+
return null;
118
118
+
} catch (err) {
119
119
+
console.error('Failed to get handle for DID', did, err);
120
120
+
return null;
121
121
+
}
122
122
+
}
123
123
+
124
124
+
/**
125
125
+
* Resolve a handle to find its PDS service endpoint
126
126
+
* Combines resolveDid and getPdsForDid into a single operation
127
127
+
*/
128
128
+
export async function resolvePdsFromHandle(handle: string): Promise<string> {
129
129
+
const did = await resolveDid(handle);
130
130
+
if (!did) {
131
131
+
throw new Error(`Failed to resolve handle: ${handle}`);
132
132
+
}
133
133
+
134
134
+
const pdsUrl = await getPdsForDid(did);
135
135
+
if (!pdsUrl) {
136
136
+
throw new Error(`Could not find PDS for ${handle} (${did})`);
137
137
+
}
138
138
+
139
139
+
return pdsUrl;
140
140
+
}
+10
packages/@wisp/atproto-utils/src/index.ts
···
6
6
7
7
// Subfs utilities
8
8
export { extractSubfsUris } from './subfs';
9
9
+
10
10
+
// Identity utilities
11
11
+
export {
12
12
+
resolveDid,
13
13
+
getPdsForDid,
14
14
+
getDidDocument,
15
15
+
getHandleForDid,
16
16
+
didWebToHttps,
17
17
+
resolvePdsFromHandle
18
18
+
} from './identity';