tangled
alpha
login
or
join now
danabra.mov
/
statusphere-react
forked from
samuel.fm/statusphere-react
0
fork
atom
the statusphere demo reworked into a vite/react app in a monorepo
0
fork
atom
overview
issues
pulls
pipelines
Add did->handle resolution
Paul Frazee
2 years ago
a938fc1c
ffe97299
+151
-17
10 changed files
expand all
collapse all
unified
split
package.json
src
config.ts
db
migrations.ts
schema.ts
ident
resolver.ts
types.ts
pages
home.ts
routes
index.ts
server.ts
yarn.lock
+9
-2
package.json
···
18
"test": "vitest run"
19
},
20
"dependencies": {
0
21
"@atproto/jwk-jose": "0.1.2-rc.0",
22
"@atproto/lexicon": "0.4.1-rc.0",
23
"@atproto/oauth-client-node": "0.0.2-rc.2",
···
57
"vitest": "^2.0.0"
58
},
59
"lint-staged": {
60
-
"*.{js,ts,cjs,mjs,d.cts,d.mts,json,jsonc}": ["biome check --apply --no-errors-on-unmatched"]
0
0
61
},
62
"tsup": {
63
-
"entry": ["src", "!src/**/__tests__/**", "!src/**/*.test.*"],
0
0
0
0
64
"splitting": false,
65
"sourcemap": true,
66
"clean": true
···
18
"test": "vitest run"
19
},
20
"dependencies": {
21
+
"@atproto/identity": "^0.4.0",
22
"@atproto/jwk-jose": "0.1.2-rc.0",
23
"@atproto/lexicon": "0.4.1-rc.0",
24
"@atproto/oauth-client-node": "0.0.2-rc.2",
···
58
"vitest": "^2.0.0"
59
},
60
"lint-staged": {
61
+
"*.{js,ts,cjs,mjs,d.cts,d.mts,json,jsonc}": [
62
+
"biome check --apply --no-errors-on-unmatched"
63
+
]
64
},
65
"tsup": {
66
+
"entry": [
67
+
"src",
68
+
"!src/**/__tests__/**",
69
+
"!src/**/*.test.*"
70
+
],
71
"splitting": false,
72
"sourcemap": true,
73
"clean": true
+2
src/config.ts
···
2
import type pino from 'pino'
3
import type { Database } from '#/db'
4
import type { Ingester } from '#/firehose/ingester'
0
5
6
export type AppContext = {
7
db: Database
8
ingester: Ingester
9
logger: pino.Logger
10
oauthClient: OAuthClient
0
11
}
···
2
import type pino from 'pino'
3
import type { Database } from '#/db'
4
import type { Ingester } from '#/firehose/ingester'
5
+
import { Resolver } from '#/ident/types'
6
7
export type AppContext = {
8
db: Database
9
ingester: Ingester
10
logger: pino.Logger
11
oauthClient: OAuthClient
12
+
resolver: Resolver
13
}
+4
-3
src/db/migrations.ts
···
11
migrations['001'] = {
12
async up(db: Kysely<unknown>) {
13
await db.schema
14
-
.createTable('user')
15
.addColumn('did', 'varchar', (col) => col.primaryKey())
16
-
.addColumn('handle', 'varchar', (col) => col.notNull())
17
-
.addColumn('indexedAt', 'varchar', (col) => col.notNull())
18
.execute()
19
await db.schema
20
.createTable('status')
···
38
await db.schema.dropTable('auth_state').execute()
39
await db.schema.dropTable('auth_session').execute()
40
await db.schema.dropTable('post').execute()
0
41
},
42
}
···
11
migrations['001'] = {
12
async up(db: Kysely<unknown>) {
13
await db.schema
14
+
.createTable('did_cache')
15
.addColumn('did', 'varchar', (col) => col.primaryKey())
16
+
.addColumn('doc', 'varchar', (col) => col.notNull())
17
+
.addColumn('updatedAt', 'varchar', (col) => col.notNull())
18
.execute()
19
await db.schema
20
.createTable('status')
···
38
await db.schema.dropTable('auth_state').execute()
39
await db.schema.dropTable('auth_session').execute()
40
await db.schema.dropTable('post').execute()
41
+
await db.schema.dropTable('did_cache').execute()
42
},
43
}
+4
-4
src/db/schema.ts
···
1
export type DatabaseSchema = {
2
-
user: User
3
status: Status
4
auth_session: AuthSession
5
auth_state: AuthState
6
}
7
8
-
export type User = {
9
did: string
10
-
handle: string
11
-
indexedAt: string
12
}
13
14
export type Status = {
···
1
export type DatabaseSchema = {
2
+
did_cache: DidCache
3
status: Status
4
auth_session: AuthSession
5
auth_state: AuthState
6
}
7
8
+
export type DidCache = {
9
did: string
10
+
doc: string
11
+
updatedAt: string
12
}
13
14
export type Status = {
+88
src/ident/resolver.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import { IdResolver, DidDocument, CacheResult } from '@atproto/identity'
2
+
import type { Database } from '#/db'
3
+
4
+
const HOUR = 60e3 * 60
5
+
const DAY = HOUR * 24
6
+
7
+
export function createResolver(db: Database) {
8
+
const resolver = new IdResolver({
9
+
didCache: {
10
+
async cacheDid(did: string, doc: DidDocument): Promise<void> {
11
+
await db
12
+
.insertInto('did_cache')
13
+
.values({
14
+
did,
15
+
doc: JSON.stringify(doc),
16
+
updatedAt: new Date().toISOString(),
17
+
})
18
+
.onConflict((oc) =>
19
+
oc.column('did').doUpdateSet({
20
+
doc: JSON.stringify(doc),
21
+
updatedAt: new Date().toISOString(),
22
+
})
23
+
)
24
+
.execute()
25
+
},
26
+
27
+
async checkCache(did: string): Promise<CacheResult | null> {
28
+
const row = await db
29
+
.selectFrom('did_cache')
30
+
.selectAll()
31
+
.where('did', '=', did)
32
+
.executeTakeFirst()
33
+
if (!row) return null
34
+
const now = Date.now()
35
+
const updatedAt = +new Date(row.updatedAt)
36
+
return {
37
+
did,
38
+
doc: JSON.parse(row.doc),
39
+
updatedAt,
40
+
stale: now > updatedAt + HOUR,
41
+
expired: now > updatedAt + DAY,
42
+
}
43
+
},
44
+
45
+
async refreshCache(
46
+
did: string,
47
+
getDoc: () => Promise<DidDocument | null>
48
+
): Promise<void> {
49
+
const doc = await getDoc()
50
+
if (doc) {
51
+
await this.cacheDid(did, doc)
52
+
}
53
+
},
54
+
55
+
async clearEntry(did: string): Promise<void> {
56
+
await db.deleteFrom('did_cache').where('did', '=', did).execute()
57
+
},
58
+
59
+
async clear(): Promise<void> {
60
+
await db.deleteFrom('did_cache').execute()
61
+
},
62
+
},
63
+
})
64
+
65
+
return {
66
+
async resolveDidToHandle(did: string): Promise<string> {
67
+
const didDoc = await resolver.did.resolveAtprotoData(did)
68
+
const resolvedHandle = await resolver.handle.resolve(didDoc.handle)
69
+
if (resolvedHandle === did) {
70
+
return didDoc.handle
71
+
}
72
+
return did
73
+
},
74
+
75
+
async resolveDidsToHandles(
76
+
dids: string[]
77
+
): Promise<Record<string, string>> {
78
+
const didHandleMap: Record<string, string> = {}
79
+
const resolves = await Promise.all(
80
+
dids.map((did) => this.resolveDidToHandle(did).catch((_) => did))
81
+
)
82
+
for (let i = 0; i < dids.length; i++) {
83
+
didHandleMap[dids[i]] = resolves[i]
84
+
}
85
+
return didHandleMap
86
+
},
87
+
}
88
+
}
+4
src/ident/types.ts
···
0
0
0
0
···
1
+
export interface Resolver {
2
+
resolveDidToHandle(did: string): Promise<string>
3
+
resolveDidsToHandles(dids: string[]): Promise<Record<string, string>>
4
+
}
+4
-4
src/pages/home.ts
···
35
36
type Props = {
37
statuses: Status[]
0
38
profile?: { displayName?: string; handle: string }
39
}
40
···
45
})
46
}
47
48
-
function content({ statuses, profile }: Props) {
49
return html`<div id="root">
50
<div class="error"></div>
51
<div id="header">
···
119
</div>
120
</div>
121
${statuses.map((status, i) => {
0
122
return html`
123
<div class=${i === 0 ? 'status-line no-line' : 'status-line'}>
124
<div>
125
<div class="status">${status.status}</div>
126
</div>
127
<div class="desc">
128
-
<a class="author" href=${toBskyLink(status.authorDid)}
129
-
>@${status.authorDid}</a
130
-
>
131
is feeling ${status.status} on ${ts(status)}
132
</div>
133
</div>
···
35
36
type Props = {
37
statuses: Status[]
38
+
didHandleMap: Record<string, string>
39
profile?: { displayName?: string; handle: string }
40
}
41
···
46
})
47
}
48
49
+
function content({ statuses, didHandleMap, profile }: Props) {
50
return html`<div id="root">
51
<div class="error"></div>
52
<div id="header">
···
120
</div>
121
</div>
122
${statuses.map((status, i) => {
123
+
const handle = didHandleMap[status.authorDid] || status.authorDid
124
return html`
125
<div class=${i === 0 ? 'status-line no-line' : 'status-line'}>
126
<div>
127
<div class="status">${status.status}</div>
128
</div>
129
<div class="desc">
130
+
<a class="author" href=${toBskyLink(handle)}>@${handle}</a>
0
0
131
is feeling ${status.status} on ${ts(status)}
132
</div>
133
</div>
+7
-2
src/routes/index.ts
···
95
.orderBy('indexedAt', 'desc')
96
.limit(10)
97
.execute()
0
0
0
98
if (!agent) {
99
-
return res.type('html').send(page(home({ statuses })))
100
}
101
const { data: profile } = await agent.getProfile({ actor: session.did })
102
-
return res.type('html').send(page(home({ statuses, profile })))
0
0
103
})
104
)
105
···
95
.orderBy('indexedAt', 'desc')
96
.limit(10)
97
.execute()
98
+
const didHandleMap = await ctx.resolver.resolveDidsToHandles(
99
+
statuses.map((s) => s.authorDid)
100
+
)
101
if (!agent) {
102
+
return res.type('html').send(page(home({ statuses, didHandleMap })))
103
}
104
const { data: profile } = await agent.getProfile({ actor: session.did })
105
+
return res
106
+
.type('html')
107
+
.send(page(home({ statuses, didHandleMap, profile })))
108
})
109
)
110
+5
-2
src/server.ts
···
11
import errorHandler from '#/middleware/errorHandler'
12
import requestLogger from '#/middleware/requestLogger'
13
import { createRouter } from '#/routes'
14
-
import { createClient } from './auth/client'
15
-
import type { AppContext } from './config'
0
16
17
export class Server {
18
constructor(
···
29
await migrateToLatest(db)
30
const ingester = new Ingester(db)
31
const oauthClient = await createClient(db)
0
32
ingester.start()
33
const ctx = {
34
db,
35
ingester,
36
logger,
37
oauthClient,
0
38
}
39
40
const app: Express = express()
···
11
import errorHandler from '#/middleware/errorHandler'
12
import requestLogger from '#/middleware/requestLogger'
13
import { createRouter } from '#/routes'
14
+
import { createClient } from '#/auth/client'
15
+
import { createResolver } from '#/ident/resolver'
16
+
import type { AppContext } from '#/config'
17
18
export class Server {
19
constructor(
···
30
await migrateToLatest(db)
31
const ingester = new Ingester(db)
32
const oauthClient = await createClient(db)
33
+
const resolver = await createResolver(db)
34
ingester.start()
35
const ctx = {
36
db,
37
ingester,
38
logger,
39
oauthClient,
40
+
resolver,
41
}
42
43
const app: Express = express()
+24
yarn.lock
···
139
dependencies:
140
zod "^3.23.8"
141
0
0
0
0
0
0
0
0
0
142
"@atproto/jwk-jose@0.1.2-rc.0":
143
version "0.1.2-rc.0"
144
resolved "https://registry.yarnpkg.com/@atproto/jwk-jose/-/jwk-jose-0.1.2-rc.0.tgz#2fc1e74fc88f9dca807338131ae3fe0994bfee5f"
···
1105
resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.2.2.tgz#a95a9b269bfd2f69d22b17a321686f551152bcef"
1106
integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==
1107
0
0
0
0
0
0
0
0
1108
balanced-match@^1.0.0:
1109
version "1.0.2"
1110
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
···
1834
parseurl "~1.3.3"
1835
statuses "2.0.1"
1836
unpipe "~1.0.0"
0
0
0
0
0
1837
1838
foreground-child@^3.1.0:
1839
version "3.3.0"
···
3110
integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==
3111
3112
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
0
3113
version "4.2.3"
3114
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
3115
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
···
3144
safe-buffer "~5.2.0"
3145
3146
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
0
3147
version "6.0.1"
3148
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
3149
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
···
139
dependencies:
140
zod "^3.23.8"
141
142
+
"@atproto/identity@^0.4.0":
143
+
version "0.4.0"
144
+
resolved "https://registry.yarnpkg.com/@atproto/identity/-/identity-0.4.0.tgz#f8a4d450a20606d221c4ec05b856c0ce55f0a3a7"
145
+
integrity sha512-KKdVlqBgkFuTUx3KFiiQe0LuK9kopej1bhKm6SHRPEYbSEPFmRZQMY9TAjWJQrvQt8DpQzz6kVGjASFEjd3teQ==
146
+
dependencies:
147
+
"@atproto/common-web" "^0.3.0"
148
+
"@atproto/crypto" "^0.4.0"
149
+
axios "^0.27.2"
150
+
151
"@atproto/jwk-jose@0.1.2-rc.0":
152
version "0.1.2-rc.0"
153
resolved "https://registry.yarnpkg.com/@atproto/jwk-jose/-/jwk-jose-0.1.2-rc.0.tgz#2fc1e74fc88f9dca807338131ae3fe0994bfee5f"
···
1114
resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.2.2.tgz#a95a9b269bfd2f69d22b17a321686f551152bcef"
1115
integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==
1116
1117
+
axios@^0.27.2:
1118
+
version "0.27.2"
1119
+
resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972"
1120
+
integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==
1121
+
dependencies:
1122
+
follow-redirects "^1.14.9"
1123
+
form-data "^4.0.0"
1124
+
1125
balanced-match@^1.0.0:
1126
version "1.0.2"
1127
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
···
1851
parseurl "~1.3.3"
1852
statuses "2.0.1"
1853
unpipe "~1.0.0"
1854
+
1855
+
follow-redirects@^1.14.9:
1856
+
version "1.15.6"
1857
+
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b"
1858
+
integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==
1859
1860
foreground-child@^3.1.0:
1861
version "3.3.0"
···
3132
integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==
3133
3134
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
3135
+
name string-width-cjs
3136
version "4.2.3"
3137
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
3138
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
···
3167
safe-buffer "~5.2.0"
3168
3169
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
3170
+
name strip-ansi-cjs
3171
version "6.0.1"
3172
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
3173
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==