[READ ONLY MIRROR] Spark Social AppView Server github.com/sprksocial/server
atproto deno hono lexicon
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

XRPC MIGRATION Phase 1 (#9)

* XRPC MIGRATION Phase 1

* Update updateSubjectStatus.ts

* fix imports

* fix root route

---------

Co-authored-by: daviirodrig <30713947+daviirodrig@users.noreply.github.com>

authored by

Roscoe Rubin-Rottenberg
daviirodrig
and committed by
GitHub
e2301ccc f2f9978a

+1370 -334
+1
services/appview/package.json
··· 30 30 "express": "^4.21.2", 31 31 "hono": "^4.7.4", 32 32 "iron-session": "^8.0.4", 33 + "jose": "^6.0.11", 33 34 "lodash": "^4.17.21", 34 35 "mongoose": "^8.12.1", 35 36 "multiformats": "^9.9.0",
+7
services/appview/pnpm-lock.yaml
··· 60 60 iron-session: 61 61 specifier: ^8.0.4 62 62 version: 8.0.4 63 + jose: 64 + specifier: ^6.0.11 65 + version: 6.0.11 63 66 lodash: 64 67 specifier: ^4.17.21 65 68 version: 4.17.21 ··· 1883 1886 1884 1887 /jose@5.10.0: 1885 1888 resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} 1889 + dev: false 1890 + 1891 + /jose@6.0.11: 1892 + resolution: {integrity: sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==} 1886 1893 dev: false 1887 1894 1888 1895 /joycon@3.1.1:
+43
services/appview/src/api/com/atproto/admin/getAccountInfos.ts
··· 1 + import { Hono } from 'hono' 2 + import { authMiddleware } from '../../../../auth/middleware.js' 3 + import { AppContext } from '../../../../index.js' 4 + import { mapDefined } from '@atproto/common' 5 + import { INVALID_HANDLE } from '@atproto/syntax' 6 + import { Server } from '../../../../lexicon/index.js' 7 + import { AuthRequiredError } from '@atproto/xrpc-server' 8 + 9 + export default function (server: Server, ctx: AppContext) { 10 + server.com.atproto.admin.getAccountInfos({ 11 + auth: ctx.authVerifier.optionalStandardOrRole, 12 + handler: async ({ params, auth }) => { 13 + const { dids } = params 14 + 15 + const { includeTakedowns } = ctx.authVerifier.parseCreds(auth) 16 + if (!includeTakedowns) { 17 + throw new AuthRequiredError('Requires admin privileges') 18 + } 19 + 20 + const infos = await Promise.all(mapDefined(dids, async (did) => { 21 + const info = await ctx.db.models.Actor.findOne({ did }) 22 + if (!info) return 23 + const profileRecord = await ctx.db.models.Profile.findOne({ authorDid: did }) 24 + const profileObj = profileRecord ? { ...profileRecord.toJSON(), _id: undefined, __v: undefined } : undefined 25 + 26 + return { 27 + $type: 'com.atproto.admin.defs#accountView' as const, 28 + did, 29 + handle: info.handle ?? INVALID_HANDLE, 30 + relatedRecords: profileObj ? [profileObj] : undefined, 31 + indexedAt: info.indexedAt, 32 + } 33 + })) 34 + 35 + return { 36 + encoding: 'application/json', 37 + body: { 38 + infos: infos.filter((info): info is NonNullable<typeof info> => info != null), 39 + } 40 + } 41 + }, 42 + }) 43 + }
+94
services/appview/src/api/com/atproto/admin/getSubjectStatus.ts
··· 1 + import { Hono } from 'hono' 2 + import { authMiddleware, optionalAuthMiddleware } from '../../../../auth/middleware.js' 3 + import { AppContext } from '../../../../index.js' 4 + import { Server } from '../../../../lexicon/index.js' 5 + import { XRPCError, AuthRequiredError } from '@atproto/xrpc-server' 6 + 7 + export default function (server: Server, ctx: AppContext) { 8 + server.com.atproto.admin.getSubjectStatus({ 9 + auth: ctx.authVerifier.optionalStandardOrRole, 10 + handler: async ({ params, auth }) => { 11 + const { did, uri, blob } = params 12 + const { includeTakedowns } = ctx.authVerifier.parseCreds(auth) 13 + if (!includeTakedowns) { 14 + throw new AuthRequiredError('Requires admin privileges') 15 + } 16 + 17 + let subject: { $type: string } & Record<string, string> 18 + let takedown: { applied: boolean, ref: string | undefined } | undefined 19 + 20 + if (did) { 21 + const actor = await ctx.db.models.Actor.findOne({ did }) 22 + const repoTakedown = await ctx.db.models.RepoTakedown.findOne({ 23 + subjectDid: did 24 + }) 25 + if (!actor) { 26 + throw new XRPCError(404, 'NotFound', 'Actor not found') 27 + } 28 + subject = { 29 + $type: 'com.atproto.admin.defs#repoRef', 30 + did: actor.did, 31 + } 32 + if (repoTakedown) { 33 + takedown = { 34 + applied: repoTakedown.applied, 35 + ref: repoTakedown.ref || undefined 36 + } 37 + } 38 + } else if (uri) { 39 + const record = 40 + (await ctx.db.models.Profile.findOne({ uri })) ?? 41 + (await ctx.db.models.Post.findOne({ uri })) ?? 42 + (await ctx.db.models.Audio.findOne({ uri })) 43 + const recordTakedown = await ctx.db.models.Takedown.findOne({ 44 + subjectUri: uri, 45 + }) 46 + if (!record) { 47 + throw new XRPCError(404, 'NotFound', 'Record not found') 48 + } 49 + subject = { 50 + $type: 'com.atproto.admin.defs#recordRef', 51 + uri: record.uri, 52 + cid: record.cid, 53 + } 54 + if (recordTakedown) { 55 + takedown = { 56 + applied: recordTakedown.applied, 57 + ref: recordTakedown.ref || undefined 58 + } 59 + } 60 + } else if (blob) { 61 + const blobRecord = 62 + (await ctx.db.models.Profile.findOne({ blob })) ?? 63 + (await ctx.db.models.Post.findOne({ blob })) ?? 64 + (await ctx.db.models.Audio.findOne({ blob })) 65 + if (!blobRecord) { 66 + throw new XRPCError(404, 'NotFound', 'Blob record not found') 67 + } 68 + subject = { 69 + $type: 'com.atproto.admin.defs#repoBlobRef', 70 + did: blobRecord.authorDid, 71 + cid: blobRecord.cid, 72 + recordUri: blobRecord.uri, 73 + } 74 + const blobTakedown = await ctx.db.models.BlobTakedown.findOne({ 75 + subjectDid: blobRecord.authorDid, 76 + subjectCid: blobRecord.cid, 77 + }) 78 + if (blobTakedown) { 79 + takedown = { 80 + applied: blobTakedown.applied, 81 + ref: blobTakedown.ref || undefined 82 + } 83 + } 84 + } else { 85 + throw new XRPCError(400, 'InvalidRequest', 'Must provide did, uri, or blob') 86 + } 87 + 88 + return { 89 + encoding: 'application/json', 90 + body: { subject, takedown } 91 + } 92 + } 93 + }) 94 + }
+98
services/appview/src/api/com/atproto/admin/updateSubjectStatus.ts
··· 1 + import { Hono } from 'hono' 2 + import { zValidator } from '@hono/zod-validator' 3 + import { z } from 'zod' 4 + import { HTTPException } from 'hono/http-exception' 5 + import { TakedownService } from '../../../../services/takedown.js' 6 + import { authMiddleware } from '../../../../auth/middleware.js' 7 + import { AppContext } from '../../../../index.js' 8 + import { Server } from '../../../../lexicon/index.js' 9 + import type * as ComAtprotoAdminUpdateSubjectStatus from '../../../../lexicon/types/com/atproto/admin/updateSubjectStatus.js' 10 + import type * as ComAtprotoAdminDefs from '../../../../lexicon/types/com/atproto/admin/defs.js' 11 + import type * as ComAtprotoRepoStrongRef from '../../../../lexicon/types/com/atproto/repo/strongRef.js' 12 + import { AuthRequiredError } from '@atproto/xrpc-server' 13 + 14 + export default function (server: Server, ctx: AppContext) { 15 + server.com.atproto.admin.updateSubjectStatus({ 16 + auth: ctx.authVerifier.optionalStandardOrRole, 17 + handler: async ({ input, auth }) => { 18 + const { subject, takedown } = input.body 19 + if (!takedown || typeof takedown.applied !== 'boolean') { 20 + throw new HTTPException(400, { message: 'Invalid takedown status' }) 21 + } 22 + 23 + const { canPerformTakedown } = ctx.authVerifier.parseCreds(auth) 24 + if (!canPerformTakedown) { 25 + throw new AuthRequiredError('Requires admin privileges') 26 + } 27 + 28 + try { 29 + if (subject.$type === 'com.atproto.admin.defs#repoRef') { 30 + const repoRef = subject as ComAtprotoAdminDefs.RepoRef 31 + if (!repoRef.did) { 32 + throw new HTTPException(400, { message: 'DID is required for repo takedowns' }) 33 + } 34 + 35 + if (takedown.applied) { 36 + await ctx.takedownService.takedownRepo({ 37 + did: repoRef.did, 38 + reason: 'Moderation action', 39 + adminDid: auth.credentials.type === 'standard' ? auth.credentials.iss : 'admin', 40 + ref: takedown.ref, 41 + }) 42 + await ctx.takedownService.updateRepoTakedownApplied(repoRef.did, takedown.applied) 43 + } else { 44 + await ctx.takedownService.removeRepoTakedown(repoRef.did) 45 + } 46 + } else if (subject.$type === 'com.atproto.admin.defs#recordRef') { 47 + const recordRef = subject as ComAtprotoRepoStrongRef.Main 48 + if (!recordRef.uri || !recordRef.cid) { 49 + throw new HTTPException(400, { message: 'URI and CID are required for record takedowns' }) 50 + } 51 + 52 + if (takedown.applied) { 53 + await ctx.takedownService.takedownContent({ 54 + targetUri: recordRef.uri, 55 + targetCid: recordRef.cid, 56 + reason: 'Moderation action', 57 + adminDid: auth.credentials.type === 'standard' ? auth.credentials.iss : 'admin', 58 + }) 59 + await ctx.takedownService.updateTakedownApplied(recordRef.uri, takedown.applied) 60 + } else { 61 + await ctx.takedownService.removeTakedown(recordRef.uri) 62 + } 63 + } else if (subject.$type === 'com.atproto.admin.defs#repoBlobRef') { 64 + const repoBlobRef = subject as ComAtprotoAdminDefs.RepoBlobRef 65 + if (!repoBlobRef.did || !repoBlobRef.cid) { 66 + throw new HTTPException(400, { message: 'DID and CID are required for blob takedowns' }) 67 + } 68 + 69 + if (takedown.applied) { 70 + await ctx.takedownService.takedownBlob({ 71 + did: repoBlobRef.did, 72 + cid: repoBlobRef.cid, 73 + reason: 'Moderation action', 74 + adminDid: auth.credentials.type === 'standard' ? auth.credentials.iss : 'admin', 75 + ref: takedown.ref, 76 + }) 77 + await ctx.takedownService.updateBlobTakedownApplied(repoBlobRef.did, repoBlobRef.cid, takedown.applied) 78 + } else { 79 + await ctx.takedownService.removeBlobTakedown(repoBlobRef.did, repoBlobRef.cid) 80 + } 81 + } else { 82 + throw new HTTPException(400, { message: `Unsupported subject type: ${subject.$type}` }) 83 + } 84 + 85 + return { 86 + encoding: 'application/json', 87 + body: { 88 + subject, 89 + takedown: takedown.applied ? takedown : undefined 90 + } 91 + } 92 + } catch (err) { 93 + if (err instanceof HTTPException) throw err 94 + throw new HTTPException(500, { message: 'Internal server error' }) 95 + } 96 + } 97 + }) 98 + }
+11
services/appview/src/api/index.ts
··· 1 + import { Server } from '../lexicon/index.js' 2 + import { AppContext } from "../index.js"; 3 + import getAccountInfos from "./com/atproto/admin/getAccountInfos.js"; 4 + import getSubjectStatus from "./com/atproto/admin/getSubjectStatus.js"; 5 + import updateSubjectStatus from "./com/atproto/admin/updateSubjectStatus.js"; 6 + 7 + export default function (server: Server, ctx: AppContext) { 8 + getAccountInfos(server, ctx); 9 + getSubjectStatus(server, ctx); 10 + updateSubjectStatus(server, ctx); 11 + }
+580
services/appview/src/auth/auth-verifier.ts
··· 1 + import { KeyObject } from 'node:crypto' 2 + import { IncomingMessage, IncomingHttpHeaders } from 'node:http' 3 + import * as ui8 from 'uint8arrays' 4 + import * as jose from 'jose' 5 + import { verify } from 'hono/jwt' 6 + import { SECP256K1_JWT_ALG, parseDidKey } from '@atproto/crypto' 7 + import { 8 + AuthRequiredError, 9 + AuthVerifierContext, 10 + VerifySignatureWithKeyFn, 11 + cryptoVerifySignatureWithKey, 12 + parseReqNsid, 13 + verifyJwt, 14 + AuthOutput, 15 + } from '@atproto/xrpc-server' 16 + import { 17 + Code, 18 + DataPlaneClient, 19 + GetIdentityByDidResponse, 20 + getKeyAsDidKey, 21 + isDataplaneError, 22 + unpackIdentityKeys 23 + } from '../data-plane/client/index.js' 24 + import { SignatureAlgorithm } from 'hono/utils/jwt/jwa' 25 + 26 + interface MinimalRequest { 27 + url?: string 28 + method?: string 29 + header: (name: string) => string | undefined 30 + headers: IncomingHttpHeaders 31 + } 32 + 33 + type ReqCtx = { 34 + req: MinimalRequest 35 + } 36 + 37 + type StandardAuthOpts = { 38 + skipAudCheck?: boolean 39 + lxmCheck?: (method?: string) => boolean 40 + } 41 + 42 + export enum RoleStatus { 43 + Valid, 44 + Invalid, 45 + Missing, 46 + } 47 + 48 + type NullOutput = { 49 + credentials: { 50 + type: 'none' 51 + iss: null 52 + } 53 + } 54 + 55 + type StandardOutput = { 56 + credentials: { 57 + type: 'standard' 58 + aud: string 59 + iss: string 60 + } 61 + } 62 + 63 + type RoleOutput = { 64 + credentials: { 65 + type: 'role' 66 + admin: boolean 67 + } 68 + } 69 + 70 + // NOTE this is not currently used, but is here for future use when we support mod services in future 71 + type ModServiceOutput = { 72 + credentials: { 73 + type: 'mod_service' 74 + aud: string 75 + iss: string 76 + } 77 + } 78 + 79 + const ALLOWED_AUTH_SCOPES = new Set([ 80 + 'com.atproto.access', 81 + 'com.atproto.appPass', 82 + 'com.atproto.appPassPrivileged', 83 + ]) 84 + 85 + export type AuthVerifierOpts = { 86 + ownDid: string 87 + alternateAudienceDids: string[] 88 + modServiceDid: string 89 + adminPasses: string[] 90 + entrywayJwtPublicKey?: KeyObject 91 + } 92 + 93 + export interface ExtendedAuthVerifier { 94 + optionalStandardOrRole: (ctx: ReqCtx) => Promise<StandardOutput | RoleOutput | NullOutput> 95 + standardOrRole: (ctx: ReqCtx) => Promise<StandardOutput | RoleOutput> 96 + standard: (ctx: ReqCtx) => Promise<StandardOutput> 97 + role: (ctx: ReqCtx) => RoleOutput 98 + modService: (ctx: ReqCtx) => Promise<ModServiceOutput> 99 + roleOrModService: (ctx: ReqCtx) => Promise<RoleOutput | ModServiceOutput> 100 + parseCreds: (creds: StandardOutput | RoleOutput | ModServiceOutput | NullOutput) => { 101 + viewer: string | null 102 + includeTakedowns: boolean 103 + include3pBlocks: boolean 104 + canPerformTakedown: boolean 105 + } 106 + standardOptional: (ctx: ReqCtx) => Promise<StandardOutput | NullOutput> 107 + standardOptionalParameterized: (opts: StandardAuthOpts) => ( 108 + ctx: ReqCtx 109 + ) => Promise<StandardOutput | NullOutput> 110 + entrywaySession: (reqCtx: ReqCtx) => Promise<StandardOutput> 111 + parseRoleCreds: (req: MinimalRequest) => { 112 + status: RoleStatus 113 + admin: boolean 114 + } 115 + verifyServiceJwt: ( 116 + reqCtx: ReqCtx, 117 + opts: { 118 + iss: string[] | null 119 + aud: string | null 120 + lxmCheck?: (method?: string) => boolean 121 + } 122 + ) => Promise<{ iss: string; aud: string }> 123 + isModService: (iss: string) => boolean 124 + nullCreds: () => NullOutput 125 + } 126 + 127 + export interface AuthVerifier extends ExtendedAuthVerifier { 128 + (ctx: AuthVerifierContext): Promise<AuthOutput> 129 + ownDid: string 130 + standardAudienceDids: Set<string> 131 + modServiceDid: string 132 + adminPasses: Set<string> 133 + entrywayJwtPublicKey?: KeyObject 134 + } 135 + 136 + export function createAuthVerifier( 137 + dataplane: DataPlaneClient, 138 + opts: AuthVerifierOpts, 139 + ): AuthVerifier { 140 + const impl = new AuthVerifierImpl(dataplane, opts) 141 + 142 + // Create the callable function 143 + const verifier = async (ctx: AuthVerifierContext): Promise<AuthOutput> => { 144 + const adaptedReq = adaptRequest(ctx.req) 145 + return impl.optionalStandardOrRole({ req: adaptedReq }) 146 + } 147 + 148 + // Add properties and methods 149 + verifier.ownDid = opts.ownDid 150 + verifier.standardAudienceDids = new Set([ 151 + opts.ownDid, 152 + ...opts.alternateAudienceDids, 153 + ]) 154 + verifier.modServiceDid = opts.modServiceDid 155 + verifier.adminPasses = new Set(opts.adminPasses) 156 + verifier.entrywayJwtPublicKey = opts.entrywayJwtPublicKey 157 + 158 + // Add all methods from impl 159 + verifier.optionalStandardOrRole = impl.optionalStandardOrRole 160 + verifier.standardOrRole = impl.standardOrRole 161 + verifier.standard = impl.standard 162 + verifier.role = impl.role 163 + verifier.modService = impl.modService 164 + verifier.roleOrModService = impl.roleOrModService 165 + verifier.parseCreds = impl.parseCreds.bind(impl) 166 + verifier.standardOptional = impl.standardOptional 167 + verifier.standardOptionalParameterized = impl.standardOptionalParameterized 168 + verifier.entrywaySession = impl.entrywaySession.bind(impl) 169 + verifier.parseRoleCreds = impl.parseRoleCreds.bind(impl) 170 + verifier.verifyServiceJwt = impl.verifyServiceJwt.bind(impl) 171 + verifier.isModService = impl.isModService.bind(impl) 172 + verifier.nullCreds = impl.nullCreds.bind(impl) 173 + 174 + return verifier 175 + } 176 + 177 + // Private implementation class 178 + class AuthVerifierImpl { 179 + public ownDid: string 180 + public standardAudienceDids: Set<string> 181 + public modServiceDid: string 182 + private adminPasses: Set<string> 183 + private entrywayJwtPublicKey?: KeyObject 184 + 185 + constructor( 186 + public dataplane: DataPlaneClient, 187 + opts: AuthVerifierOpts, 188 + ) { 189 + this.ownDid = opts.ownDid 190 + this.standardAudienceDids = new Set([ 191 + opts.ownDid, 192 + ...opts.alternateAudienceDids, 193 + ]) 194 + this.modServiceDid = opts.modServiceDid 195 + this.adminPasses = new Set(opts.adminPasses) 196 + this.entrywayJwtPublicKey = opts.entrywayJwtPublicKey 197 + } 198 + 199 + // verifiers (arrow fns to preserve scope) 200 + standardOptionalParameterized = 201 + (opts: StandardAuthOpts) => 202 + async (ctx: ReqCtx): Promise<StandardOutput | NullOutput> => { 203 + // @TODO remove! basic auth + did supported just for testing. 204 + if (isBasicToken(ctx.req)) { 205 + const aud = this.ownDid 206 + const iss = ctx.req.header('appview-as-did') 207 + if (typeof iss !== 'string' || !iss.startsWith('did:')) { 208 + throw new AuthRequiredError('bad issuer') 209 + } 210 + if (!this.parseRoleCreds(ctx.req).admin) { 211 + throw new AuthRequiredError('bad credentials') 212 + } 213 + return { 214 + credentials: { type: 'standard', iss, aud }, 215 + } 216 + } else if (isBearerToken(ctx.req)) { 217 + // @NOTE temporarily accept entryway session tokens to shed load from PDS instances 218 + const token = bearerTokenFromReq(ctx.req) 219 + const header = token ? jose.decodeProtectedHeader(token) : undefined 220 + if (header?.typ === 'at+jwt') { 221 + // we should never use entryway session tokens in the case of flexible auth audiences (namely in the case of getFeed) 222 + if (opts.skipAudCheck) { 223 + throw new AuthRequiredError('Malformed token', 'InvalidToken') 224 + } 225 + return this.entrywaySession(ctx) 226 + } 227 + 228 + const { iss, aud } = await this.verifyServiceJwt(ctx, { 229 + lxmCheck: opts.lxmCheck, 230 + iss: null, 231 + aud: null, 232 + }) 233 + if (!opts.skipAudCheck && !this.standardAudienceDids.has(aud)) { 234 + throw new AuthRequiredError( 235 + 'jwt audience does not match service did', 236 + 'BadJwtAudience', 237 + ) 238 + } 239 + return { 240 + credentials: { 241 + type: 'standard', 242 + iss, 243 + aud, 244 + }, 245 + } 246 + } else { 247 + return this.nullCreds() 248 + } 249 + } 250 + 251 + standardOptional: (ctx: ReqCtx) => Promise<StandardOutput | NullOutput> = 252 + this.standardOptionalParameterized({}) 253 + 254 + standard = async (ctx: ReqCtx): Promise<StandardOutput> => { 255 + const output = await this.standardOptional(ctx) 256 + if (output.credentials.type === 'none') { 257 + throw new AuthRequiredError(undefined, 'AuthMissing') 258 + } 259 + return output as StandardOutput 260 + } 261 + 262 + role = (ctx: ReqCtx): RoleOutput => { 263 + const creds = this.parseRoleCreds(ctx.req) 264 + if (creds.status !== RoleStatus.Valid) { 265 + throw new AuthRequiredError() 266 + } 267 + return { 268 + credentials: { 269 + ...creds, 270 + type: 'role', 271 + }, 272 + } 273 + } 274 + 275 + standardOrRole = async ( 276 + ctx: ReqCtx, 277 + ): Promise<StandardOutput | RoleOutput> => { 278 + if (isBearerToken(ctx.req)) { 279 + return this.standard(ctx) 280 + } else { 281 + return this.role(ctx) 282 + } 283 + } 284 + 285 + optionalStandardOrRole = async ( 286 + ctx: ReqCtx, 287 + ): Promise<StandardOutput | RoleOutput | NullOutput> => { 288 + if (isBearerToken(ctx.req)) { 289 + return await this.standard(ctx) 290 + } else { 291 + const creds = this.parseRoleCreds(ctx.req) 292 + if (creds.status === RoleStatus.Valid) { 293 + return { 294 + credentials: { 295 + ...creds, 296 + type: 'role', 297 + }, 298 + } 299 + } else if (creds.status === RoleStatus.Missing) { 300 + return this.nullCreds() 301 + } else { 302 + throw new AuthRequiredError() 303 + } 304 + } 305 + } 306 + 307 + // @NOTE this auth verifier method is not recommended to be implemented by most appviews 308 + // this is a short term fix to remove proxy load from Bluesky's PDS and in line with possible 309 + // future plans to have the client talk directly with the appview 310 + entrywaySession = async (reqCtx: ReqCtx): Promise<StandardOutput> => { 311 + const token = bearerTokenFromReq(reqCtx.req) 312 + if (!token) { 313 + throw new AuthRequiredError(undefined, 'AuthMissing') 314 + } 315 + 316 + // if entryway jwt key not configured then do not parsed these tokens 317 + if (!this.entrywayJwtPublicKey) { 318 + throw new AuthRequiredError('Malformed token', 'InvalidToken') 319 + } 320 + 321 + const res = await jose 322 + .jwtVerify(token, this.entrywayJwtPublicKey) 323 + .catch((err) => { 324 + if (err?.['code'] === 'ERR_JWT_EXPIRED') { 325 + throw new AuthRequiredError('Token has expired', 'ExpiredToken') 326 + } 327 + throw new AuthRequiredError( 328 + 'Token could not be verified', 329 + 'InvalidToken', 330 + ) 331 + }) 332 + 333 + const { sub, aud, scope } = res.payload 334 + if (typeof sub !== 'string' || !sub.startsWith('did:')) { 335 + throw new AuthRequiredError('Malformed token', 'InvalidToken') 336 + } else if ( 337 + typeof aud !== 'string' || 338 + !aud.startsWith('did:web:') || 339 + !aud.endsWith('.bsky.network') 340 + ) { 341 + throw new AuthRequiredError('Bad token aud', 'InvalidToken') 342 + } else if (typeof scope !== 'string' || !ALLOWED_AUTH_SCOPES.has(scope)) { 343 + throw new AuthRequiredError('Bad token scope', 'InvalidToken') 344 + } 345 + 346 + return { 347 + credentials: { 348 + type: 'standard', 349 + aud: this.ownDid, 350 + iss: sub, 351 + }, 352 + } 353 + } 354 + 355 + modService = async (reqCtx: ReqCtx): Promise<ModServiceOutput> => { 356 + const { iss, aud } = await this.verifyServiceJwt(reqCtx, { 357 + aud: this.ownDid, 358 + iss: [this.modServiceDid, `${this.modServiceDid}#atproto_labeler`], 359 + }) 360 + return { credentials: { type: 'mod_service', aud, iss } } 361 + } 362 + 363 + roleOrModService = async ( 364 + reqCtx: ReqCtx, 365 + ): Promise<RoleOutput | ModServiceOutput> => { 366 + if (isBearerToken(reqCtx.req)) { 367 + return this.modService(reqCtx) 368 + } else { 369 + return this.role(reqCtx) 370 + } 371 + } 372 + 373 + parseRoleCreds(req: MinimalRequest) { 374 + const parsed = parseBasicAuth(req.header('Authorization') || '') 375 + const { Missing, Valid, Invalid } = RoleStatus 376 + if (!parsed) { 377 + return { status: Missing, admin: false, moderator: false, triage: false } 378 + } 379 + const { username, password } = parsed 380 + if (username === 'admin' && this.adminPasses.has(password)) { 381 + return { status: Valid, admin: true } 382 + } 383 + return { status: Invalid, admin: false } 384 + } 385 + 386 + // @NOTE this is not currently used, but is here for future use when we support mod services in future 387 + // and potentially for payment providers 388 + async verifyServiceJwt( 389 + reqCtx: ReqCtx, 390 + opts: { 391 + iss: string[] | null 392 + aud: string | null 393 + lxmCheck?: (method?: string) => boolean 394 + }, 395 + ) { 396 + const getSigningKey = async ( 397 + iss: string, 398 + _forceRefresh: boolean, // @TODO consider propagating to dataplane 399 + ): Promise<string> => { 400 + if (opts.iss !== null && !opts.iss.includes(iss)) { 401 + throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') 402 + } 403 + const [did, serviceId] = iss.split('#') 404 + const keyId = 405 + serviceId === 'atproto_labeler' ? 'atproto_label' : 'atproto' 406 + let identity: GetIdentityByDidResponse 407 + try { 408 + identity = await this.dataplane.getIdentityByDid({ did }) 409 + } catch (err) { 410 + if (isDataplaneError(err, Code.NotFound)) { 411 + throw new AuthRequiredError('identity unknown') 412 + } 413 + throw err 414 + } 415 + const keys = unpackIdentityKeys(identity.keys) 416 + const didKey = getKeyAsDidKey(keys, { id: keyId }) 417 + if (!didKey) { 418 + throw new AuthRequiredError('missing or bad key') 419 + } 420 + return didKey 421 + } 422 + const assertLxmCheck = () => { 423 + const lxm = parseReqNsid({ 424 + url: reqCtx.req.url, 425 + method: reqCtx.req.method 426 + } as IncomingMessage) 427 + if ( 428 + (opts.lxmCheck && !opts.lxmCheck(payload.lxm)) || 429 + (!opts.lxmCheck && payload.lxm !== lxm) 430 + ) { 431 + throw new AuthRequiredError( 432 + payload.lxm !== undefined 433 + ? `bad jwt lexicon method ("lxm"). must match: ${lxm}` 434 + : `missing jwt lexicon method ("lxm"). must match: ${lxm}`, 435 + 'BadJwtLexiconMethod', 436 + ) 437 + } 438 + } 439 + 440 + const jwtStr = bearerTokenFromReq(reqCtx.req) 441 + if (!jwtStr) { 442 + throw new AuthRequiredError('missing jwt', 'MissingJwt') 443 + } 444 + // if validating additional scopes, skip scope check in initial validation & follow up afterwards 445 + const payload = await verifyJwt( 446 + jwtStr, 447 + opts.aud, 448 + null, 449 + getSigningKey, 450 + verifySignatureWithKey, 451 + ) 452 + if ( 453 + !payload.iss.endsWith('#atproto_labeler') || 454 + payload.lxm !== undefined 455 + ) { 456 + // @TODO currently permissive of labelers who dont set lxm yet. 457 + // we'll allow ozone self-hosters to upgrade before removing this condition. 458 + assertLxmCheck() 459 + } 460 + return { iss: payload.iss, aud: payload.aud } 461 + } 462 + 463 + isModService(iss: string): boolean { 464 + return [ 465 + this.modServiceDid, 466 + `${this.modServiceDid}#atproto_labeler`, 467 + ].includes(iss) 468 + } 469 + 470 + nullCreds(): NullOutput { 471 + return { 472 + credentials: { 473 + type: 'none', 474 + iss: null, 475 + }, 476 + } 477 + } 478 + 479 + parseCreds( 480 + creds: StandardOutput | RoleOutput | ModServiceOutput | NullOutput, 481 + ) { 482 + const viewer = 483 + creds.credentials.type === 'standard' ? creds.credentials.iss : null 484 + const includeTakedownsAnd3pBlocks = 485 + (creds.credentials.type === 'role' && creds.credentials.admin) || 486 + creds.credentials.type === 'mod_service' || 487 + (creds.credentials.type === 'standard' && 488 + this.isModService(creds.credentials.iss)) 489 + const canPerformTakedown = 490 + (creds.credentials.type === 'role' && creds.credentials.admin) || 491 + creds.credentials.type === 'mod_service' 492 + 493 + return { 494 + viewer, 495 + includeTakedowns: includeTakedownsAnd3pBlocks, 496 + include3pBlocks: includeTakedownsAnd3pBlocks, 497 + canPerformTakedown, 498 + } 499 + } 500 + } 501 + 502 + // HELPERS 503 + // --------- 504 + 505 + const BEARER = 'Bearer ' 506 + const BASIC = 'Basic ' 507 + 508 + const isBearerToken = (req: MinimalRequest): boolean => { 509 + return req.header('Authorization')?.startsWith(BEARER) ?? false 510 + } 511 + 512 + const isBasicToken = (req: MinimalRequest): boolean => { 513 + return req.header('Authorization')?.startsWith(BASIC) ?? false 514 + } 515 + 516 + const bearerTokenFromReq = (req: MinimalRequest) => { 517 + const header = req.header('Authorization') || '' 518 + if (!header.startsWith(BEARER)) return null 519 + return header.slice(BEARER.length).trim() 520 + } 521 + 522 + export const parseBasicAuth = ( 523 + token: string, 524 + ): { username: string; password: string } | null => { 525 + if (!token.startsWith(BASIC)) return null 526 + const b64 = token.slice(BASIC.length) 527 + let parsed: string[] 528 + try { 529 + parsed = ui8.toString(ui8.fromString(b64, 'base64pad'), 'utf8').split(':') 530 + } catch (err) { 531 + return null 532 + } 533 + const [username, password] = parsed 534 + if (!username || !password) return null 535 + return { username, password } 536 + } 537 + 538 + export const buildBasicAuth = (username: string, password: string): string => { 539 + return ( 540 + BASIC + 541 + ui8.toString(ui8.fromString(`${username}:${password}`, 'utf8'), 'base64pad') 542 + ) 543 + } 544 + 545 + export const verifySignatureWithKey: VerifySignatureWithKeyFn = async ( 546 + didKey: string, 547 + msgBytes: Uint8Array, 548 + sigBytes: Uint8Array, 549 + alg: string 550 + ): Promise<boolean> => { 551 + if (alg === SECP256K1_JWT_ALG) { 552 + const parsed = parseDidKey(didKey) 553 + if (alg !== parsed.jwtAlg) { 554 + throw new Error(`Expected key alg ${alg}, got ${parsed.jwtAlg}`) 555 + } 556 + 557 + try { 558 + // Convert message and signature to base64 strings as expected by hono/jwt verify 559 + const message = ui8.toString(msgBytes, 'base64url') 560 + const signature = ui8.toString(sigBytes, 'base64url') 561 + 562 + await verify(message, didKey, parsed.jwtAlg as SignatureAlgorithm) 563 + return true 564 + } catch (err) { 565 + return false 566 + } 567 + } 568 + 569 + return cryptoVerifySignatureWithKey(didKey, msgBytes, sigBytes, alg) 570 + } 571 + 572 + // Helper function to adapt request 573 + function adaptRequest(req: IncomingMessage): MinimalRequest { 574 + return { 575 + url: req.url, 576 + method: req.method, 577 + header: (name: string) => req.headers[name.toLowerCase()] as string | undefined, 578 + headers: req.headers, 579 + } 580 + }
+117
services/appview/src/data-plane/client/hosts.ts
··· 1 + import mongoose, { Connection, Schema, Document } from 'mongoose' 2 + 3 + /** 4 + * Interface for a reactive list of hosts, i.e. for use with the dataplane client. 5 + */ 6 + export interface HostList { 7 + get: () => Iterable<string> 8 + onUpdate(handler: HostListHandler): void 9 + } 10 + 11 + type HostListHandler = (hosts: Iterable<string>) => void 12 + 13 + /** 14 + * Maintains a reactive HostList based on a simple setter. 15 + */ 16 + export class BasicHostList implements HostList { 17 + private hosts: Iterable<string> 18 + private handlers: HostListHandler[] = [] 19 + 20 + constructor(hosts: Iterable<string>) { 21 + this.hosts = hosts 22 + } 23 + 24 + get() { 25 + return this.hosts 26 + } 27 + 28 + set(hosts: Iterable<string>) { 29 + this.hosts = hosts 30 + this.update() 31 + } 32 + 33 + private update() { 34 + for (const handler of this.handlers) { 35 + handler(this.hosts) 36 + } 37 + } 38 + 39 + onUpdate(handler: HostListHandler) { 40 + this.handlers.push(handler) 41 + } 42 + } 43 + 44 + interface HostDocument extends Document { 45 + url: string 46 + active: boolean 47 + updatedAt: Date 48 + } 49 + 50 + const hostSchema = new Schema<HostDocument>({ 51 + url: { type: String, required: true, unique: true }, 52 + active: { type: Boolean, required: true, default: true }, 53 + updatedAt: { type: Date, required: true, default: Date.now } 54 + }) 55 + 56 + /** 57 + * Maintains a reactive HostList based on MongoDB documents. 58 + * When fallback is provided, ensures that this fallback is used whenever no hosts are available. 59 + */ 60 + export class MongoHostList implements HostList { 61 + private connection: Connection 62 + private inner = new BasicHostList(new Set()) 63 + private fallback: Set<string> 64 + private changeStream: mongoose.mongo.ChangeStream | null = null 65 + private model: mongoose.Model<HostDocument> 66 + 67 + constructor(connection: Connection, fallback?: string[]) { 68 + this.fallback = new Set(fallback) 69 + this.connection = connection 70 + this.model = this.connection.model<HostDocument>('Host', hostSchema) 71 + } 72 + 73 + async connect() { 74 + await this.updateHosts() 75 + this.startWatching() 76 + } 77 + 78 + private async updateHosts() { 79 + const hosts = new Set<string>() 80 + const activeHosts = await this.model.find({ active: true }).exec() 81 + 82 + for (const host of activeHosts) { 83 + if (URL.canParse(host.url)) { 84 + hosts.add(host.url) 85 + } 86 + } 87 + 88 + if (hosts.size) { 89 + this.inner.set(hosts) 90 + } else if (this.fallback.size) { 91 + this.inner.set(this.fallback) 92 + } 93 + } 94 + 95 + private startWatching() { 96 + this.changeStream = this.model.watch() 97 + 98 + this.changeStream.on('change', async () => { 99 + await this.updateHosts() 100 + }) 101 + } 102 + 103 + get() { 104 + return this.inner.get() 105 + } 106 + 107 + onUpdate(handler: HostListHandler) { 108 + this.inner.onUpdate(handler) 109 + } 110 + 111 + async disconnect() { 112 + if (this.changeStream) { 113 + await this.changeStream.close() 114 + this.changeStream = null 115 + } 116 + } 117 + }
+151
services/appview/src/data-plane/client/index.ts
··· 1 + import assert from 'node:assert' 2 + import { randomInt } from 'node:crypto' 3 + import mongoose from 'mongoose' 4 + import { Code, ConnectError } from './util.js' 5 + import { HostList } from './hosts.js' 6 + 7 + export * from './hosts.js' 8 + export * from './util.js' 9 + 10 + export interface DataPlaneClient { 11 + getIdentityByDid: (params: { did: string }) => Promise<GetIdentityByDidResponse> 12 + } 13 + 14 + export interface GetIdentityByDidResponse { 15 + did: string 16 + keys: Uint8Array 17 + services: Uint8Array 18 + handle?: string 19 + } 20 + 21 + const MAX_RETRIES = 3 22 + 23 + export const createDataPlaneClient = ( 24 + hostList: HostList, 25 + opts: { rejectUnauthorized?: boolean } = {} 26 + ) => { 27 + const clients = new DataPlaneClients(hostList, opts) 28 + 29 + // Create the base implementation 30 + const implementation: DataPlaneClient = { 31 + async getIdentityByDid(params) { 32 + let tries = 0 33 + let error: unknown 34 + let remainingClients = clients.get() 35 + while (tries < MAX_RETRIES) { 36 + const client = randomElement(remainingClients) 37 + assert(client, 'no clients available') 38 + try { 39 + return await client.getIdentityByDid(params) 40 + } catch (err) { 41 + if ( 42 + err instanceof Error && 43 + (err.name === 'MongoNetworkError' || err.name === 'MongoServerError') 44 + ) { 45 + tries++ 46 + error = err 47 + remainingClients = getRemainingClients(remainingClients, client) 48 + } else { 49 + throw err 50 + } 51 + } 52 + } 53 + assert(error) 54 + throw error 55 + } 56 + } 57 + 58 + // Create a proxy that wraps the implementation with retry logic 59 + return new Proxy(implementation, { 60 + get: (target, method: string) => { 61 + // Return the method from our implementation if it exists 62 + if (method in target) { 63 + return target[method as keyof DataPlaneClient] 64 + } 65 + // For any methods we haven't implemented yet, return a function that throws 66 + return () => { 67 + throw new Error(`Method ${method} not implemented`) 68 + } 69 + } 70 + }) 71 + } 72 + 73 + export { Code } 74 + 75 + /** 76 + * Uses a reactive HostList in order to maintain a pool of DataPlaneClients. 77 + * Each DataPlaneClient is cached per host so that it maintains connections 78 + * and other internal state when the underlying HostList is updated. 79 + */ 80 + class DataPlaneClients { 81 + private clients: DataPlaneClient[] = [] 82 + private clientsByHost = new Map<string, DataPlaneClient>() 83 + 84 + constructor( 85 + private hostList: HostList, 86 + private clientOpts: { rejectUnauthorized?: boolean } 87 + ) { 88 + this.refresh() 89 + this.hostList.onUpdate(() => this.refresh()) 90 + } 91 + 92 + get(): readonly DataPlaneClient[] { 93 + return this.clients 94 + } 95 + 96 + private refresh() { 97 + this.clients = [] 98 + for (const host of this.hostList.get()) { 99 + let client = this.clientsByHost.get(host) 100 + if (!client) { 101 + client = this.createClient(host) 102 + this.clientsByHost.set(host, client) 103 + } 104 + this.clients.push(client) 105 + } 106 + } 107 + 108 + private createClient(host: string): DataPlaneClient { 109 + const connection = mongoose.createConnection(host) 110 + 111 + return { 112 + async getIdentityByDid({ did }: { did: string }): Promise<GetIdentityByDidResponse> { 113 + const Actor = connection.model('Actor', new mongoose.Schema({ 114 + did: { type: String, required: true }, 115 + keys: { type: Buffer, required: true }, 116 + services: { type: Buffer, required: true }, 117 + handle: String, 118 + })) 119 + 120 + const actor = await Actor.findOne({ did }).exec() 121 + if (!actor) { 122 + throw new ConnectError('Actor not found', Code.NotFound) 123 + } 124 + 125 + if (!actor.did || !actor.keys || !actor.services) { 126 + throw new ConnectError('Invalid actor data', Code.InternalError) 127 + } 128 + 129 + return { 130 + did: actor.did, 131 + keys: new Uint8Array(actor.keys), 132 + services: new Uint8Array(actor.services), 133 + handle: actor.handle || undefined 134 + } 135 + } 136 + } 137 + } 138 + } 139 + 140 + const getRemainingClients = ( 141 + clients: readonly DataPlaneClient[], 142 + lastClient: DataPlaneClient, 143 + ) => { 144 + if (clients.length < 2) return clients // no clients to choose from 145 + return clients.filter((c) => c !== lastClient) 146 + } 147 + 148 + const randomElement = <T>(arr: readonly T[]): T | undefined => { 149 + if (arr.length === 0) return 150 + return arr[randomInt(arr.length)] 151 + }
+95
services/appview/src/data-plane/client/util.ts
··· 1 + import * as ui8 from 'uint8arrays' 2 + import { getDidKeyFromMultibase } from '@atproto/identity' 3 + 4 + export enum Code { 5 + NotFound = 'NotFound', 6 + InvalidRequest = 'InvalidRequest', 7 + Unauthorized = 'Unauthorized', 8 + Forbidden = 'Forbidden', 9 + InternalError = 'InternalError' 10 + } 11 + 12 + export class ConnectError extends Error { 13 + constructor( 14 + message: string, 15 + public code: Code, 16 + public status: number = 500 17 + ) { 18 + super(message) 19 + this.name = 'ConnectError' 20 + } 21 + } 22 + 23 + export const isDataplaneError = ( 24 + err: unknown, 25 + code?: Code, 26 + ): err is ConnectError => { 27 + if (err instanceof ConnectError) { 28 + return !code || err.code === code 29 + } 30 + return false 31 + } 32 + 33 + export const unpackIdentityServices = (servicesBytes: Uint8Array) => { 34 + const servicesStr = ui8.toString(servicesBytes, 'utf8') 35 + if (!servicesStr) return {} 36 + return JSON.parse(servicesStr) as UnpackedServices 37 + } 38 + 39 + export const unpackIdentityKeys = (keysBytes: Uint8Array) => { 40 + const keysStr = ui8.toString(keysBytes, 'utf8') 41 + if (!keysStr) return {} 42 + return JSON.parse(keysStr) as UnpackedKeys 43 + } 44 + 45 + export const getServiceEndpoint = ( 46 + services: UnpackedServices, 47 + opts: { id: string; type: string }, 48 + ) => { 49 + const endpoint = 50 + services[opts.id] && 51 + services[opts.id].Type === opts.type && 52 + validateUrl(services[opts.id].URL) 53 + return endpoint || undefined 54 + } 55 + 56 + export const getKeyAsDidKey = (keys: UnpackedKeys, opts: { id: string }) => { 57 + const key = 58 + keys[opts.id] && 59 + getDidKeyFromMultibase({ 60 + type: keys[opts.id].Type, 61 + publicKeyMultibase: keys[opts.id].PublicKeyMultibase, 62 + }) 63 + return key || undefined 64 + } 65 + 66 + type UnpackedServices = Record<string, { Type: string; URL: string }> 67 + 68 + type UnpackedKeys = Record<string, { Type: string; PublicKeyMultibase: string }> 69 + 70 + const validateUrl = (urlStr: string): string | undefined => { 71 + let url 72 + try { 73 + url = new URL(urlStr) 74 + } catch { 75 + return undefined 76 + } 77 + if (!['http:', 'https:'].includes(url.protocol)) { 78 + return undefined 79 + } else if (!url.hostname) { 80 + return undefined 81 + } else { 82 + return urlStr 83 + } 84 + } 85 + 86 + export const handleMongoError = (error: unknown): never => { 87 + if (error instanceof Error) { 88 + if (error.name === 'MongoServerError') { 89 + throw new ConnectError(error.message, Code.InternalError) 90 + } else if (error.name === 'MongoNetworkError') { 91 + throw new ConnectError('Database connection error', Code.InternalError) 92 + } 93 + } 94 + throw new ConnectError('Unknown database error', Code.InternalError) 95 + }
+71 -35
services/appview/src/db.ts services/appview/src/data-plane/server/index.ts
··· 1 1 import mongoose, { Connection, Document, Model, Schema } from 'mongoose' 2 2 import { pino } from 'pino' 3 - import { env } from './env.js' 3 + import { IdResolver, MemoryCache } from '@atproto/identity' 4 + import { env } from '../../env.js' 5 + import { DataPlaneClient, GetIdentityByDidResponse } from '../client/index.js' 6 + 7 + const HOUR = 60e3 * 60 8 + const DAY = HOUR * 24 4 9 5 10 export interface LikeDocument extends Document { 6 11 uri: string ··· 447 452 Actor: Model<ActorDocument> 448 453 } 449 454 450 - export class Database { 451 - private connection: Connection 452 - public models: DatabaseModels 455 + export class Database implements DataPlaneClient { 456 + private connection!: Connection 457 + public models!: DatabaseModels 453 458 private logger = pino({ name: 'database' }) 459 + public idResolver: IdResolver 454 460 455 461 constructor() { 456 - this.connection = mongoose.createConnection() 457 - this.models = { 458 - Like: this.connection.model<LikeDocument>('Like', likeSchema), 459 - Post: this.connection.model<PostDocument>('Post', postSchema), 460 - Follow: this.connection.model<FollowDocument>('Follow', followSchema), 461 - Block: this.connection.model<BlockDocument>('Block', blockSchema), 462 - Profile: this.connection.model<ProfileDocument>('Profile', profileSchema), 463 - Audio: this.connection.model<AudioDocument>('Audio', audioSchema), 464 - Repost: this.connection.model<RepostDocument>('Repost', repostSchema), 465 - Music: this.connection.model<MusicDocument>('Music', musicSchema), 466 - Look: this.connection.model<LookDocument>('Look', lookSchema), 467 - Generator: this.connection.model<GeneratorDocument>( 468 - 'Generator', 469 - generatorSchema, 470 - ), 471 - Takedown: this.connection.model<TakedownDocument>( 472 - 'Takedown', 473 - takedownSchema, 474 - ), 475 - RepoTakedown: this.connection.model<RepoTakedownDocument>( 476 - 'RepoTakedown', 477 - repoTakedownSchema, 478 - ), 479 - BlobTakedown: this.connection.model<BlobTakedownDocument>( 480 - 'BlobTakedown', 481 - blobTakedownSchema, 482 - ), 483 - Actor: this.connection.model<ActorDocument>('Actor', actorSchema), 484 - } 462 + this.idResolver = new IdResolver({ 463 + didCache: new MemoryCache(HOUR, DAY), 464 + }) 485 465 } 486 466 487 467 async connect(): Promise<void> { ··· 492 472 ) 493 473 494 474 try { 495 - await this.connection.openUri(uri, { 475 + this.connection = await mongoose.createConnection(uri, { 496 476 autoIndex: true, 497 477 autoCreate: true, 498 478 dbName: DB_NAME, 499 479 }) 480 + 481 + // Initialize models 482 + this.models = { 483 + Like: this.connection.model<LikeDocument>('Like', likeSchema), 484 + Post: this.connection.model<PostDocument>('Post', postSchema), 485 + Follow: this.connection.model<FollowDocument>('Follow', followSchema), 486 + Block: this.connection.model<BlockDocument>('Block', blockSchema), 487 + Profile: this.connection.model<ProfileDocument>('Profile', profileSchema), 488 + Audio: this.connection.model<AudioDocument>('Audio', audioSchema), 489 + Repost: this.connection.model<RepostDocument>('Repost', repostSchema), 490 + Music: this.connection.model<MusicDocument>('Music', musicSchema), 491 + Look: this.connection.model<LookDocument>('Look', lookSchema), 492 + Generator: this.connection.model<GeneratorDocument>('Generator', generatorSchema), 493 + Takedown: this.connection.model<TakedownDocument>('Takedown', takedownSchema), 494 + RepoTakedown: this.connection.model<RepoTakedownDocument>('RepoTakedown', repoTakedownSchema), 495 + BlobTakedown: this.connection.model<BlobTakedownDocument>('BlobTakedown', blobTakedownSchema), 496 + Actor: this.connection.model<ActorDocument>('Actor', actorSchema), 497 + } 498 + 500 499 this.logger.info('Connected to MongoDB') 501 500 } catch (error) { 502 - this.logger.error({ error }, 'MongoDB connection error') 501 + this.logger.error(error, 'Failed to connect to MongoDB') 503 502 throw error 504 503 } 505 504 } ··· 508 507 if (this.connection) { 509 508 await this.connection.close() 510 509 this.logger.info('Disconnected from MongoDB') 510 + } 511 + } 512 + 513 + // Add methods for DID resolution 514 + async resolveHandle(handle: string): Promise<string | undefined> { 515 + try { 516 + return await this.idResolver.handle.resolve(handle) 517 + } catch (err) { 518 + this.logger.error({ err, handle }, 'Failed to resolve handle') 519 + return undefined 520 + } 521 + } 522 + 523 + async resolveDid(did: string): Promise<{ did: string; handle?: string; } | undefined> { 524 + try { 525 + const data = await this.idResolver.did.resolveAtprotoData(did) 526 + return { 527 + did: data.did, 528 + handle: data.handle, 529 + } 530 + } catch (err) { 531 + this.logger.error({ err, did }, 'Failed to resolve DID') 532 + return undefined 533 + } 534 + } 535 + 536 + // Implement DataPlaneClient interface 537 + async getIdentityByDid({ did }: { did: string }): Promise<GetIdentityByDidResponse> { 538 + const actor = await this.models.Actor.findOne({ did }).exec() 539 + if (!actor) { 540 + throw new Error('Actor not found') 541 + } 542 + return { 543 + did: actor.did, 544 + handle: actor.handle || undefined, 545 + keys: new Uint8Array(), // TODO: Implement key storage 546 + services: new Uint8Array(), // TODO: Implement services storage 511 547 } 512 548 } 513 549 }
+1 -1
services/appview/src/feed/feed.ts
··· 1 1 import { Hono } from 'hono' 2 - import { AppContext } from '..' 2 + import { AppContext } from '../index.js' 3 3 import { Agent } from '@atproto/api' 4 4 import { HTTPException } from 'hono/http-exception' 5 5 import { CID } from 'multiformats/cid'
+30 -21
services/appview/src/index.ts
··· 5 5 import { HTTPException } from 'hono/http-exception' 6 6 import { logger } from 'hono/logger' 7 7 import { pino } from 'pino' 8 - import { Database } from './db.js' 8 + import { Database } from './data-plane/server/index.js' 9 9 import { env } from './env.js' 10 10 import { createFeedRouter } from './feed/feed.js' 11 + import { AuthVerifier, createAuthVerifier } from './auth/auth-verifier.js' 12 + import API from './api/index.js' 13 + import { createServer } from './lexicon/index.js' 11 14 import { 12 15 BidirectionalResolver, 13 16 createBidirectionalResolver, 14 17 createIdResolver, 15 18 } from './id-resolver.js' 16 19 import { takedownFilterMiddleware } from './middleware/takedown-filter.js' 17 - import { createGetProfileRouter } from './routes/so/sprk/actor/getProfile.js' 18 - import { createSearchActorRouter } from './routes/so/sprk/actor/searchActors.js' 19 - import { createGetAuthorFeedRouter } from './routes/so/sprk/feed/getAuthorFeed.js' 20 - import { createGetPostsRouter } from './routes/so/sprk/feed/getPosts.js' 21 - import { createGetPostThreadRouter } from './routes/so/sprk/feed/getPostThread.js' 22 - import { createGetFollowersRouter } from './routes/so/sprk/graph/getFollowers.js' 23 - import { createGetFollowsRouter } from './routes/so/sprk/graph/getFollows.js' 24 - import { createTakedownRouter } from './routes/admin/takedowns.js' 25 - import { createUpdateSubjectStatusRouter } from './routes/com/atproto/admin/updateSubjectStatus.js' 26 - import { createGetRecordRouter } from './routes/com/atproto/repo/getRecord.js' 27 - import { createGetAccountInfosRouter } from './routes/com/atproto/admin/getAccountInfos.js' 28 - import { createGetSubjectStatusRouter } from './routes/com/atproto/admin/getSubjectStatus.js' 29 - import { createResolveHandleRouter } from './routes/com/atproto/identity/resolveHandle.js' 20 + import { createGetProfileRouter } from './api/so/sprk/actor/getProfile.js' 21 + import { createSearchActorRouter } from './api/so/sprk/actor/searchActors.js' 22 + import { createGetAuthorFeedRouter } from './api/so/sprk/feed/getAuthorFeed.js' 23 + import { createGetPostsRouter } from './api/so/sprk/feed/getPosts.js' 24 + import { createGetPostThreadRouter } from './api/so/sprk/feed/getPostThread.js' 25 + import { createGetFollowersRouter } from './api/so/sprk/graph/getFollowers.js' 26 + import { createGetFollowsRouter } from './api/so/sprk/graph/getFollows.js' 27 + import { createTakedownRouter } from './api/admin/takedowns.js' 28 + import { createGetRecordRouter } from './api/com/atproto/repo/getRecord.js' 29 + import { createResolveHandleRouter } from './api/com/atproto/identity/resolveHandle.js' 30 30 import wellKnownRouter from './well-known.js' 31 31 import { TakedownService } from './services/takedown.js' 32 32 import { IndexingService } from './services/indexing.js' 33 + import { expressToHono } from './utils/express-adapter.js' 33 34 34 35 // Extend Hono's context variable map to include our services 35 36 declare module 'hono' { ··· 49 50 didResolver: DidResolver 50 51 takedownService: TakedownService 51 52 indexingService: IndexingService 53 + authVerifier: AuthVerifier 52 54 } 53 55 54 56 export class Server { ··· 72 74 // Create services 73 75 const takedownService = new TakedownService(db) 74 76 const indexingService = new IndexingService(db, resolver) 77 + const authVerifier = createAuthVerifier(db, { 78 + ownDid: serviceDid, 79 + alternateAudienceDids: [], 80 + modServiceDid: env.MOD_SERVICE_DID, 81 + adminPasses: [env.ADMIN_PASSWORD], 82 + }) 75 83 76 84 const ctx = { 77 85 db, ··· 81 89 didResolver: baseIdResolver.did, 82 90 takedownService, 83 91 indexingService, 92 + authVerifier, 84 93 } 85 94 86 95 const app = new Hono() ··· 101 110 102 111 // TODO: Remove this after getAuthorFeedRouter is properly implemented on frontend 103 112 const feedRouter = createFeedRouter(ctx) 104 - app.route('/', feedRouter) 113 + const lexServer = createServer() 114 + const server = API(lexServer, ctx) 105 115 106 116 const getPostsRouter = createGetPostsRouter(ctx) 107 117 const getPostThreadRouter = createGetPostThreadRouter(ctx) ··· 110 120 const getFollowsRouter = createGetFollowsRouter(ctx) 111 121 const getAuthorFeedRouter = createGetAuthorFeedRouter(ctx) 112 122 const searchActorRouter = createSearchActorRouter(ctx) 113 - const updateSubjectStatusRouter = createUpdateSubjectStatusRouter(ctx) 114 123 const takedownRouter = createTakedownRouter(ctx) 115 124 const getRecordRouter = createGetRecordRouter(ctx) 116 - const getAccountInfosRouter = createGetAccountInfosRouter(ctx) 117 - const getSubjectStatusRouter = createGetSubjectStatusRouter(ctx) 118 125 const resolveHandleRouter = createResolveHandleRouter(ctx) 119 126 127 + app.route('/', feedRouter) 120 128 app.route('/', getPostsRouter) 121 129 app.route('/', getPostThreadRouter) 122 130 app.route('/', getProfileRouter) ··· 124 132 app.route('/', getFollowsRouter) 125 133 app.route('/', getAuthorFeedRouter) 126 134 app.route('/', searchActorRouter) 127 - app.route('/', updateSubjectStatusRouter) 128 135 app.route('/', takedownRouter) 129 136 app.route('/', getRecordRouter) 130 - app.route('/', getAccountInfosRouter) 131 - app.route('/', getSubjectStatusRouter) 132 137 app.route('/', resolveHandleRouter) 133 138 app.route('/', wellKnownRouter()) 134 139 ··· 138 143 '✧・゚: ✧・゚:. ݁₊ ⊹ . ݁˖ . ݁ SPARK API . ݁₊ ⊹ . ݁˖ . ݁ :・゚✧:・゚✧', 139 144 ) 140 145 }) 146 + 147 + app.use(expressToHono(lexServer.xrpc.router)) 141 148 142 149 app.onError((err, c) => { 143 150 if (err instanceof HTTPException) { ··· 197 204 } 198 205 199 206 run() 207 + 208 +
+1 -1
services/appview/src/routes/admin/takedowns.ts services/appview/src/api/admin/takedowns.ts
··· 4 4 import { HTTPException } from 'hono/http-exception' 5 5 import { TakedownService } from '../../services/takedown.js' 6 6 import { authMiddleware } from '../../auth/middleware.js' 7 - import { Database } from '../../db.js' 7 + import { Database } from '../../data-plane/server/index.js' 8 8 import { AtUri } from '@atproto/syntax' 9 9 10 10 type TakedownContext = {
-45
services/appview/src/routes/com/atproto/admin/getAccountInfos.ts
··· 1 - import { Hono } from 'hono' 2 - import { authMiddleware } from '../../../../auth/middleware.js' 3 - import { AppContext } from '../../../../index.js' 4 - import { mapDefined } from '@atproto/common' 5 - import { INVALID_HANDLE } from '@atproto/syntax' 6 - 7 - export const createGetAccountInfosRouter = (ctx: AppContext) => { 8 - const router = new Hono() 9 - 10 - router.get( 11 - '/xrpc/com.atproto.admin.getAccountInfos', 12 - (c, next) => authMiddleware(c, next, true), 13 - async (c) => { 14 - const dids = c.req.queries('dids[]') 15 - if (!dids || dids.length === 0) { 16 - return c.json({ error: 'Missing or empty dids parameter' }, 400) 17 - } 18 - 19 - const timestamp = new Date().toISOString() 20 - 21 - const infos = await Promise.all( 22 - mapDefined(dids, async (did) => { 23 - await ctx.indexingService.indexHandle(did, timestamp) 24 - const actor = await ctx.db.models.Actor.findOne({ did }) 25 - if (!actor) return 26 - 27 - const profile = await ctx.db.models.Profile.findOne({ 28 - did: actor.did, 29 - }) 30 - 31 - return { 32 - did: actor.did, 33 - handle: actor.handle ?? INVALID_HANDLE, 34 - relatedRecords: [profile], 35 - indexedAt: actor.indexedAt, 36 - } 37 - }), 38 - ) 39 - 40 - return c.json(infos) 41 - }, 42 - ) 43 - 44 - return router 45 - }
-86
services/appview/src/routes/com/atproto/admin/getSubjectStatus.ts
··· 1 - import { Hono } from 'hono' 2 - import { authMiddleware, optionalAuthMiddleware } from '../../../../auth/middleware.js' 3 - import { AppContext } from '../../../../index.js' 4 - 5 - export const createGetSubjectStatusRouter = (ctx: AppContext) => { 6 - const router = new Hono() 7 - 8 - router.get('/xrpc/com.atproto.admin.getSubjectStatus', (c, next) => authMiddleware(c, next, true), async (c) => { 9 - const did = c.req.query('did') 10 - const uri = c.req.query('uri') 11 - const blob = c.req.query('blob') 12 - 13 - if (!did && !uri && !blob) { 14 - return c.json({ error: 'Missing required parameter' }, 400) 15 - } 16 - 17 - let subject 18 - let takedown 19 - if (did) { 20 - const actor = await ctx.db.models.Actor.findOne({ did }) 21 - const repoTakedown = await ctx.db.models.RepoTakedown.findOne({ 22 - subjectDid: did 23 - }) 24 - if (!actor) { 25 - return c.json({ error: 'Actor not found' }, 404) 26 - } 27 - subject = { 28 - did: actor.did, 29 - } 30 - if (repoTakedown) { 31 - takedown = { 32 - applied: repoTakedown.applied, 33 - ref: repoTakedown.ref, 34 - } 35 - } 36 - } else if (uri) { 37 - const record = 38 - (await ctx.db.models.Profile.findOne({ uri })) ?? 39 - (await ctx.db.models.Post.findOne({ uri })) ?? 40 - (await ctx.db.models.Audio.findOne({ uri })) 41 - const recordTakedown = await ctx.db.models.Takedown.findOne({ 42 - subjectUri: uri, 43 - }) 44 - if (!record) { 45 - return c.json({ error: 'Record not found' }, 404) 46 - } 47 - subject = { 48 - uri: record.uri, 49 - cid: record.cid, 50 - } 51 - if (recordTakedown) { 52 - takedown = { 53 - applied: recordTakedown.applied, 54 - ref: recordTakedown.ref, 55 - } 56 - } 57 - } else if (blob) { 58 - const blobRecord = 59 - (await ctx.db.models.Profile.findOne({ blob })) ?? 60 - (await ctx.db.models.Post.findOne({ blob })) ?? 61 - (await ctx.db.models.Audio.findOne({ blob })) 62 - if (!blobRecord) { 63 - return c.json({ error: 'Blob record not found' }, 404) 64 - } 65 - subject = { 66 - did: blobRecord.authorDid, 67 - cid: blobRecord.cid, 68 - recordUri: blobRecord.uri, 69 - } 70 - const blobTakedown = await ctx.db.models.BlobTakedown.findOne({ 71 - subjectDid: blobRecord.authorDid, 72 - subjectCid: blobRecord.cid, 73 - }) 74 - if (blobTakedown) { 75 - takedown = { 76 - applied: blobTakedown.applied, 77 - ref: blobTakedown.ref, 78 - } 79 - } 80 - } 81 - 82 - return c.json({ subject, takedown }) 83 - }) 84 - 85 - return router 86 - }
-138
services/appview/src/routes/com/atproto/admin/updateSubjectStatus.ts
··· 1 - import { Hono } from 'hono' 2 - import { zValidator } from '@hono/zod-validator' 3 - import { z } from 'zod' 4 - import { HTTPException } from 'hono/http-exception' 5 - import { TakedownService } from '../../../../services/takedown.js' 6 - import { authMiddleware } from '../../../../auth/middleware.js' 7 - import type * as ComAtprotoAdminUpdateSubjectStatus from '../../../../lexicon/types/com/atproto/admin/updateSubjectStatus.js' 8 - import type * as ComAtprotoAdminDefs from '../../../../lexicon/types/com/atproto/admin/defs.js' 9 - import type * as ComAtprotoRepoStrongRef from '../../../../lexicon/types/com/atproto/repo/strongRef.js' 10 - 11 - type UpdateSubjectStatusContext = { 12 - takedownService: TakedownService 13 - } 14 - 15 - export const createUpdateSubjectStatusRouter = ( 16 - ctx: UpdateSubjectStatusContext, 17 - ) => { 18 - const router = new Hono() 19 - const takedownService = ctx.takedownService 20 - 21 - // XRPC endpoint for Ozone integration: com.atproto.admin.updateSubjectStatus 22 - router.post( 23 - '/xrpc/com.atproto.admin.updateSubjectStatus', 24 - (c, next) => authMiddleware(c, next, true), 25 - zValidator( 26 - 'json', 27 - z.object({ 28 - subject: z.object({ 29 - $type: z.string(), 30 - did: z.string().optional(), 31 - uri: z.string().optional(), 32 - cid: z.string().optional(), 33 - }), 34 - takedown: z.object({ 35 - applied: z.boolean(), 36 - ref: z.string().optional(), 37 - }), 38 - }), 39 - ), 40 - async (c) => { 41 - const { subject, takedown } = c.req.valid('json') 42 - const adminDid = c.get('did') 43 - 44 - try { 45 - // Handle different subject types 46 - if (subject.$type === 'com.atproto.admin.defs#repoRef') { 47 - // Repository (user account) takedown 48 - if (!subject.did) { 49 - throw new HTTPException(400, { 50 - message: 'DID is required for repo takedowns', 51 - }) 52 - } 53 - 54 - if (takedown.applied) { 55 - // Apply takedown 56 - await takedownService.takedownRepo({ 57 - did: subject.did, 58 - reason: 'Moderation via Ozone', 59 - adminDid, 60 - ref: takedown.ref, 61 - }) 62 - await takedownService.updateRepoTakedownApplied(subject.did, takedown.applied) 63 - } else { 64 - // Remove takedown 65 - await takedownService.removeRepoTakedown(subject.did) 66 - } 67 - } else if (subject.$type === 'com.atproto.repo.strongRef') { 68 - // Record (post) takedown 69 - if (!subject.uri || !subject.cid) { 70 - throw new HTTPException(400, { 71 - message: 'URI and CID are required for record takedowns', 72 - }) 73 - } 74 - 75 - if (takedown.applied) { 76 - // Apply takedown 77 - await takedownService.takedownContent({ 78 - targetUri: subject.uri, 79 - targetCid: subject.cid, 80 - reason: 'Moderation via Ozone', 81 - adminDid, 82 - }) 83 - await takedownService.updateTakedownApplied(subject.uri, takedown.applied) 84 - } else { 85 - // Remove takedown 86 - await takedownService.removeTakedown(subject.uri) 87 - } 88 - } else if (subject.$type === 'com.atproto.admin.defs#repoBlobRef') { 89 - // Blob (image/attachment) takedown 90 - if (!subject.did || !subject.cid) { 91 - throw new HTTPException(400, { 92 - message: 'DID and CID are required for blob takedowns', 93 - }) 94 - } 95 - 96 - if (takedown.applied) { 97 - // Apply takedown 98 - await takedownService.takedownBlob({ 99 - did: subject.did, 100 - cid: subject.cid, 101 - reason: 'Moderation via Ozone', 102 - adminDid, 103 - ref: takedown.ref, 104 - }) 105 - await takedownService.updateBlobTakedownApplied(subject.did, subject.cid, takedown.applied) 106 - } else { 107 - // Remove takedown 108 - await takedownService.removeBlobTakedown(subject.did, subject.cid) 109 - } 110 - } else { 111 - throw new HTTPException(400, { 112 - message: `Unsupported subject type: ${subject.$type}`, 113 - }) 114 - } 115 - 116 - // Return the response format expected by Ozone 117 - return c.json({ 118 - subject, 119 - takedown: takedown.applied 120 - ? { 121 - applied: takedown.applied, 122 - ref: takedown.ref, 123 - } 124 - : undefined, 125 - }) 126 - } catch (error) { 127 - if (error instanceof HTTPException) { 128 - throw error 129 - } 130 - throw new HTTPException(500, { 131 - message: 'Failed to update subject status', 132 - }) 133 - } 134 - }, 135 - ) 136 - 137 - return router 138 - }
+2 -2
services/appview/src/routes/com/atproto/identity/resolveHandle.ts services/appview/src/api/com/atproto/identity/resolveHandle.ts
··· 1 1 import * as ident from '@atproto/syntax' 2 2 import { InvalidRequestError } from '@atproto/xrpc-server' 3 - import { Server } from '../../../../lexicon' 4 - import { AppContext } from '../../../..' 3 + import { Server } from '../../../../lexicon/index.js' 4 + import { AppContext } from '../../../../index.js' 5 5 import { Hono } from 'hono' 6 6 7 7 export const createResolveHandleRouter = (ctx: AppContext) => {
services/appview/src/routes/com/atproto/repo/getRecord.ts services/appview/src/api/com/atproto/repo/getRecord.ts
services/appview/src/routes/so/sprk/actor/getProfile.ts services/appview/src/api/so/sprk/actor/getProfile.ts
services/appview/src/routes/so/sprk/actor/searchActors.ts services/appview/src/api/so/sprk/actor/searchActors.ts
services/appview/src/routes/so/sprk/feed/getAuthorFeed.ts services/appview/src/api/so/sprk/feed/getAuthorFeed.ts
services/appview/src/routes/so/sprk/feed/getPostThread.ts services/appview/src/api/so/sprk/feed/getPostThread.ts
+1 -1
services/appview/src/routes/so/sprk/feed/getPosts.ts services/appview/src/api/so/sprk/feed/getPosts.ts
··· 3 3 import { OutputSchema as GetPostsView } from '../../../../lexicon/types/so/sprk/feed/getPosts.js' 4 4 import { AppContext } from '../../../../index.js' 5 5 import { transformPostToPostView } from '../../../../utils/post-transformer.js' 6 - import { Database } from '../../../../db.js' 6 + import { Database } from '../../../../data-plane/server/index.js' 7 7 import type * as SoSprkFeedDefs from '../../../../lexicon/types/so/sprk/feed/defs.js' 8 8 import { optionalAuthMiddleware } from '../../../../auth/middleware.js' 9 9
services/appview/src/routes/so/sprk/graph/getFollowers.ts services/appview/src/api/so/sprk/graph/getFollowers.ts
services/appview/src/routes/so/sprk/graph/getFollows.ts services/appview/src/api/so/sprk/graph/getFollows.ts
+1 -1
services/appview/src/services/indexing.ts
··· 2 2 import { CID } from 'multiformats/cid' 3 3 import { Document } from 'mongoose' 4 4 import { BidirectionalResolver } from '../id-resolver.js' 5 - import { Database } from '../db.js' 5 + import { Database } from '../data-plane/server/index.js' 6 6 import { pino } from 'pino' 7 7 import * as Post from './plugins/post.js' 8 8
+1 -1
services/appview/src/services/plugins/post.ts
··· 1 1 import { AtUri } from '@atproto/syntax' 2 2 import { CID } from 'multiformats/cid' 3 3 import { pino } from 'pino' 4 - import { Database, PostDocument } from '../../db.js' 4 + import { Database, PostDocument } from '../../data-plane/server/index.js' 5 5 6 6 const logger = pino({ name: 'post-processor' }) 7 7
+1 -1
services/appview/src/services/takedown.ts
··· 1 - import { Database } from '../db.js' 1 + import { Database } from '../data-plane/server/index.js' 2 2 3 3 export class TakedownService { 4 4 constructor(private db: Database) {}
+63
services/appview/src/utils/express-adapter.ts
··· 1 + import { Context } from 'hono' 2 + import { Request as ExpressRequest, Response as ExpressResponse } from 'express' 3 + 4 + /** 5 + * Converts an Express middleware/router to a Hono middleware 6 + * @param expressRouter The Express router or middleware to convert 7 + * @returns A Hono middleware function 8 + */ 9 + export const expressToHono = (expressRouter: any) => { 10 + return async (c: Context): Promise<Response | void> => { 11 + console.log('Incoming request:', c.req.url) 12 + 13 + // Create a mutable Express-compatible request object 14 + const req = { 15 + url: c.req.url, 16 + method: c.req.method, 17 + headers: Object.fromEntries([...c.req.raw.headers]), 18 + query: c.req.query(), 19 + params: {}, 20 + body: await c.req.json().catch(() => ({})), 21 + get: (name: string) => req.headers[name.toLowerCase()], 22 + path: new URL(c.req.url).pathname 23 + } as unknown as ExpressRequest 24 + 25 + console.log('Created Express request:', { url: req.url, method: req.method, path: req.path }) 26 + 27 + return new Promise((resolve) => { 28 + const res = { 29 + setHeader: (name: string, value: string) => { 30 + console.log('setHeader:', name, value) 31 + c.header(name, value) 32 + return res 33 + }, 34 + end: (chunk: any) => { 35 + console.log('end called with:', chunk) 36 + resolve(c.body(chunk)) 37 + }, 38 + json: (body: any) => { 39 + console.log('json called with:', body) 40 + resolve(c.json(body)) 41 + }, 42 + status: (code: number) => { 43 + console.log('status called with:', code) 44 + c.status(code as any) 45 + return res 46 + }, 47 + send: (body: any) => { 48 + console.log('send called with:', body) 49 + resolve(c.body(body)) 50 + } 51 + } as unknown as ExpressResponse 52 + 53 + console.log('Calling Express router') 54 + expressRouter(req, res, (err: any) => { 55 + console.log('Express router callback called', { err }) 56 + if (err) { 57 + c.status(500) 58 + resolve(c.json({ error: 'Internal Server Error' })) 59 + } 60 + }) 61 + }) 62 + } 63 + }
+1 -1
services/appview/src/utils/post-transformer.ts
··· 1 - import { Database, PostDocument } from '../db.js' 1 + import { Database, PostDocument } from '../data-plane/server/index.js' 2 2 import type { Label } from '../lexicon/types/com/atproto/label/defs.js' 3 3 import type { ProfileViewBasic } from '../lexicon/types/so/sprk/actor/defs.js' 4 4 import type * as SoSprkEmbedImages from '../lexicon/types/so/sprk/embed/images.js'