Scratch space for learning atproto app development

Add did->handle resolution

+9 -2
package.json
···
"test": "vitest run"
},
"dependencies": {
+
"@atproto/identity": "^0.4.0",
"@atproto/jwk-jose": "0.1.2-rc.0",
"@atproto/lexicon": "0.4.1-rc.0",
"@atproto/oauth-client-node": "0.0.2-rc.2",
···
"vitest": "^2.0.0"
},
"lint-staged": {
-
"*.{js,ts,cjs,mjs,d.cts,d.mts,json,jsonc}": ["biome check --apply --no-errors-on-unmatched"]
+
"*.{js,ts,cjs,mjs,d.cts,d.mts,json,jsonc}": [
+
"biome check --apply --no-errors-on-unmatched"
+
]
},
"tsup": {
-
"entry": ["src", "!src/**/__tests__/**", "!src/**/*.test.*"],
+
"entry": [
+
"src",
+
"!src/**/__tests__/**",
+
"!src/**/*.test.*"
+
],
"splitting": false,
"sourcemap": true,
"clean": true
+2
src/config.ts
···
import type pino from 'pino'
import type { Database } from '#/db'
import type { Ingester } from '#/firehose/ingester'
+
import { Resolver } from '#/ident/types'
export type AppContext = {
db: Database
ingester: Ingester
logger: pino.Logger
oauthClient: OAuthClient
+
resolver: Resolver
}
+4 -3
src/db/migrations.ts
···
migrations['001'] = {
async up(db: Kysely<unknown>) {
await db.schema
-
.createTable('user')
+
.createTable('did_cache')
.addColumn('did', 'varchar', (col) => col.primaryKey())
-
.addColumn('handle', 'varchar', (col) => col.notNull())
-
.addColumn('indexedAt', 'varchar', (col) => col.notNull())
+
.addColumn('doc', 'varchar', (col) => col.notNull())
+
.addColumn('updatedAt', 'varchar', (col) => col.notNull())
.execute()
await db.schema
.createTable('status')
···
await db.schema.dropTable('auth_state').execute()
await db.schema.dropTable('auth_session').execute()
await db.schema.dropTable('post').execute()
+
await db.schema.dropTable('did_cache').execute()
},
}
+4 -4
src/db/schema.ts
···
export type DatabaseSchema = {
-
user: User
+
did_cache: DidCache
status: Status
auth_session: AuthSession
auth_state: AuthState
}
-
export type User = {
+
export type DidCache = {
did: string
-
handle: string
-
indexedAt: string
+
doc: string
+
updatedAt: string
}
export type Status = {
+88
src/ident/resolver.ts
···
+
import { IdResolver, DidDocument, CacheResult } from '@atproto/identity'
+
import type { Database } from '#/db'
+
+
const HOUR = 60e3 * 60
+
const DAY = HOUR * 24
+
+
export function createResolver(db: Database) {
+
const resolver = new IdResolver({
+
didCache: {
+
async cacheDid(did: string, doc: DidDocument): Promise<void> {
+
await db
+
.insertInto('did_cache')
+
.values({
+
did,
+
doc: JSON.stringify(doc),
+
updatedAt: new Date().toISOString(),
+
})
+
.onConflict((oc) =>
+
oc.column('did').doUpdateSet({
+
doc: JSON.stringify(doc),
+
updatedAt: new Date().toISOString(),
+
})
+
)
+
.execute()
+
},
+
+
async checkCache(did: string): Promise<CacheResult | null> {
+
const row = await db
+
.selectFrom('did_cache')
+
.selectAll()
+
.where('did', '=', did)
+
.executeTakeFirst()
+
if (!row) return null
+
const now = Date.now()
+
const updatedAt = +new Date(row.updatedAt)
+
return {
+
did,
+
doc: JSON.parse(row.doc),
+
updatedAt,
+
stale: now > updatedAt + HOUR,
+
expired: now > updatedAt + DAY,
+
}
+
},
+
+
async refreshCache(
+
did: string,
+
getDoc: () => Promise<DidDocument | null>
+
): Promise<void> {
+
const doc = await getDoc()
+
if (doc) {
+
await this.cacheDid(did, doc)
+
}
+
},
+
+
async clearEntry(did: string): Promise<void> {
+
await db.deleteFrom('did_cache').where('did', '=', did).execute()
+
},
+
+
async clear(): Promise<void> {
+
await db.deleteFrom('did_cache').execute()
+
},
+
},
+
})
+
+
return {
+
async resolveDidToHandle(did: string): Promise<string> {
+
const didDoc = await resolver.did.resolveAtprotoData(did)
+
const resolvedHandle = await resolver.handle.resolve(didDoc.handle)
+
if (resolvedHandle === did) {
+
return didDoc.handle
+
}
+
return did
+
},
+
+
async resolveDidsToHandles(
+
dids: string[]
+
): Promise<Record<string, string>> {
+
const didHandleMap: Record<string, string> = {}
+
const resolves = await Promise.all(
+
dids.map((did) => this.resolveDidToHandle(did).catch((_) => did))
+
)
+
for (let i = 0; i < dids.length; i++) {
+
didHandleMap[dids[i]] = resolves[i]
+
}
+
return didHandleMap
+
},
+
}
+
}
+4
src/ident/types.ts
···
+
export interface Resolver {
+
resolveDidToHandle(did: string): Promise<string>
+
resolveDidsToHandles(dids: string[]): Promise<Record<string, string>>
+
}
+4 -4
src/pages/home.ts
···
type Props = {
statuses: Status[]
+
didHandleMap: Record<string, string>
profile?: { displayName?: string; handle: string }
}
···
})
}
-
function content({ statuses, profile }: Props) {
+
function content({ statuses, didHandleMap, profile }: Props) {
return html`<div id="root">
<div class="error"></div>
<div id="header">
···
</div>
</div>
${statuses.map((status, i) => {
+
const handle = didHandleMap[status.authorDid] || status.authorDid
return html`
<div class=${i === 0 ? 'status-line no-line' : 'status-line'}>
<div>
<div class="status">${status.status}</div>
</div>
<div class="desc">
-
<a class="author" href=${toBskyLink(status.authorDid)}
-
>@${status.authorDid}</a
-
>
+
<a class="author" href=${toBskyLink(handle)}>@${handle}</a>
is feeling ${status.status} on ${ts(status)}
</div>
</div>
+7 -2
src/routes/index.ts
···
.orderBy('indexedAt', 'desc')
.limit(10)
.execute()
+
const didHandleMap = await ctx.resolver.resolveDidsToHandles(
+
statuses.map((s) => s.authorDid)
+
)
if (!agent) {
-
return res.type('html').send(page(home({ statuses })))
+
return res.type('html').send(page(home({ statuses, didHandleMap })))
}
const { data: profile } = await agent.getProfile({ actor: session.did })
-
return res.type('html').send(page(home({ statuses, profile })))
+
return res
+
.type('html')
+
.send(page(home({ statuses, didHandleMap, profile })))
})
)
+5 -2
src/server.ts
···
import errorHandler from '#/middleware/errorHandler'
import requestLogger from '#/middleware/requestLogger'
import { createRouter } from '#/routes'
-
import { createClient } from './auth/client'
-
import type { AppContext } from './config'
+
import { createClient } from '#/auth/client'
+
import { createResolver } from '#/ident/resolver'
+
import type { AppContext } from '#/config'
export class Server {
constructor(
···
await migrateToLatest(db)
const ingester = new Ingester(db)
const oauthClient = await createClient(db)
+
const resolver = await createResolver(db)
ingester.start()
const ctx = {
db,
ingester,
logger,
oauthClient,
+
resolver,
}
const app: Express = express()
+24
yarn.lock
···
dependencies:
zod "^3.23.8"
+
"@atproto/identity@^0.4.0":
+
version "0.4.0"
+
resolved "https://registry.yarnpkg.com/@atproto/identity/-/identity-0.4.0.tgz#f8a4d450a20606d221c4ec05b856c0ce55f0a3a7"
+
integrity sha512-KKdVlqBgkFuTUx3KFiiQe0LuK9kopej1bhKm6SHRPEYbSEPFmRZQMY9TAjWJQrvQt8DpQzz6kVGjASFEjd3teQ==
+
dependencies:
+
"@atproto/common-web" "^0.3.0"
+
"@atproto/crypto" "^0.4.0"
+
axios "^0.27.2"
+
"@atproto/jwk-jose@0.1.2-rc.0":
version "0.1.2-rc.0"
resolved "https://registry.yarnpkg.com/@atproto/jwk-jose/-/jwk-jose-0.1.2-rc.0.tgz#2fc1e74fc88f9dca807338131ae3fe0994bfee5f"
···
resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.2.2.tgz#a95a9b269bfd2f69d22b17a321686f551152bcef"
integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==
+
axios@^0.27.2:
+
version "0.27.2"
+
resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972"
+
integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==
+
dependencies:
+
follow-redirects "^1.14.9"
+
form-data "^4.0.0"
+
balanced-match@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
···
parseurl "~1.3.3"
statuses "2.0.1"
unpipe "~1.0.0"
+
+
follow-redirects@^1.14.9:
+
version "1.15.6"
+
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b"
+
integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==
foreground-child@^3.1.0:
version "3.3.0"
···
integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
+
name string-width-cjs
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
···
safe-buffer "~5.2.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+
name strip-ansi-cjs
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==