Testing implementation for private data in ATProto with ATPKeyserver and ATCute tools

advances for accounts edit

+34
lexicons/app/wafrn/actor/cacheAccount.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.wafrn.actor.cacheAccount", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Cache account in the appview for indexing. Requires authentication.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "properties": { 13 + "did": { "type": "string", "format": "did" }, 14 + "handle": { "type": "string", "format": "handle" }, 15 + "active": { "type": "boolean" }, 16 + "status": { 17 + "type": "string", 18 + "knownValues": ["deactivated", "suspended", "takendown"] 19 + } 20 + } 21 + } 22 + }, 23 + "output": { 24 + "encoding": "application/json", 25 + "schema": { 26 + "type": "object", 27 + "properties": { 28 + "updated_at": { "type": "string", "format": "datetime" } 29 + } 30 + } 31 + } 32 + } 33 + } 34 + }
+16
lexicons/app/wafrn/actor/defs.json
··· 49 49 "status": { "type": "string" }, 50 50 "wafrn": { "type": "ref", "ref": "#profileView" } 51 51 } 52 + }, 53 + "customField": { 54 + "type": "object", 55 + "required": ["key", "value"], 56 + "properties": { 57 + "key": { 58 + "type": "string", 59 + "description": "Field name", 60 + "maxLength": 64 61 + }, 62 + "value": { 63 + "type": "string", 64 + "description": "Field value", 65 + "maxLength": 256 66 + } 67 + } 52 68 } 53 69 } 54 70 }
+3
packages/client/app/components/UserMenu.tsx
··· 49 49 <Link to={`/profile/${handle}`}>Your profile</Link> 50 50 </li> 51 51 <li> 52 + <Link to="/settings/account">Account settings</Link> 53 + </li> 54 + <li> 52 55 <Link to="/settings/delegation">Private posts setup</Link> 53 56 </li> 54 57 <li>
+12 -4
packages/client/app/lib/oauthClient.server.ts
··· 8 8 keys.map((k) => JoseKey.fromImportable(JSON.stringify(k), k.kid)) 9 9 ) 10 10 11 - const createOAuthClient = (baseUrl?: string) => { 12 - const scope = 'atproto transition:generic' as const 11 + const AUTH_SCOPES = [ 12 + 'atproto', 13 + 'transition:generic', 14 + 'repo:*', 15 + 'blob:*/*', 16 + 'account:email', 17 + 'identity:handle' 18 + ].join(' ') 13 19 20 + const createOAuthClient = (baseUrl?: string) => { 14 21 if (env.IS_PROD && !baseUrl) { 15 22 throw new Error('baseUrl for OAuth is required in production') 16 23 } ··· 18 25 const url = baseUrl || `http://127.0.0.1:${env.PORT}` 19 26 const localParams = new URLSearchParams({ 20 27 redirect_uri: `${url}/oauth/callback`, 21 - scope 28 + scope: AUTH_SCOPES 22 29 }) 30 + console.log('localParams: ', localParams.toString()) 23 31 24 32 return new NodeOAuthClient({ 25 33 clientMetadata: { ··· 29 37 : `http://localhost?${localParams.toString()}`, 30 38 client_uri: url, 31 39 redirect_uris: [`${url}/oauth/callback`], 32 - scope, 40 + scope: AUTH_SCOPES, 33 41 grant_types: ['authorization_code', 'refresh_token'], 34 42 response_types: ['code'], 35 43 application_type: 'web',
+27 -7
packages/client/app/lib/profile.server.ts
··· 1 - import type { Handle } from '@atcute/lexicons' 1 + import type { Did, Handle } from '@atcute/lexicons' 2 2 import { 3 3 getPublicAgent, 4 4 getPublicServiceAgent, ··· 13 13 import { getRelationship } from './follow.server' 14 14 import asyncWrap from './asyncWrap' 15 15 import { handleToDid } from './idResolver.server' 16 + import type { OAuthSession } from '@atproto/oauth-client-node' 17 + import type { AppBskyActorProfile } from '@atcute/bluesky' 16 18 17 19 export async function getProfileData(request: Request, handle: Handle) { 18 20 const [session] = await asyncWrap(() => getOAuthSession(request)) 19 - const sessionAgent = session ? getSessionAgent(session) : getPublicAgent() 20 21 const serviceDid = 21 22 `did:web:${encodeURIComponent(new URL(env.API_URL).host)}` as const 22 23 const serverClient = session ··· 33 34 // Fetch all data in parallel 34 35 const [feed, profile, wafrnProfiles, relationship] = await Promise.all([ 35 36 getFeed(session, handle), 36 - ok( 37 - sessionAgent.get('app.bsky.actor.getProfile', { 38 - params: { actor: profileDid } 39 - }) 40 - ), 37 + getProfile(session, profileDid), 41 38 ok( 42 39 serverClient.get('app.wafrn.actor.getProfiles', { 43 40 params: { actors: [profileDid], includeCounts: true } ··· 62 59 feed: feed ?? [] 63 60 } 64 61 } 62 + 63 + export async function getProfile(session?: OAuthSession | null, did?: Did) { 64 + const profileDid = did ?? session?.did 65 + if (!profileDid) { 66 + throw new Error('No DID provided for getProfile') 67 + } 68 + 69 + const agent = session 70 + ? getSessionAgent(session) 71 + : getPublicServiceAgent(env.DEFAULT_PDS_URL as HTTPURL) 72 + const profileReq = await agent.get('com.atproto.repo.getRecord', { 73 + params: { 74 + collection: 'app.bsky.actor.profile', 75 + rkey: 'self', 76 + repo: profileDid 77 + } 78 + }) 79 + if (!profileReq.ok) { 80 + console.error(profileReq.data) 81 + return null 82 + } 83 + return profileReq.data.value as AppBskyActorProfile.Main 84 + }
+6 -29
packages/client/app/lib/user.server.ts
··· 1 1 import { StatusError } from './https' 2 - import { getSessionAgent, type XRPCLient } from '@www/lib/xrpcClient' 3 - import type { Did } from '@atcute/lexicons' 2 + import { getSessionAgent } from '@www/lib/xrpcClient' 4 3 import { getOAuthSession } from './oauth.server' 5 - import { didDocResolver } from './idResolver.server' 6 - import { getAtprotoHandle } from '@atcute/identity' 4 + import { ok } from '@atcute/client' 5 + import { getProfile } from './profile.server' 7 6 8 7 export async function getCurrentUser(request: Request) { 9 8 try { 10 9 const session = await getOAuthSession(request) 11 - const client = getSessionAgent(session) 12 - const didDoc = await didDocResolver.resolve(session.did) 13 - const identity = { 14 - did: session.did, 15 - didDoc, 16 - handle: getAtprotoHandle(didDoc) 17 - } 18 - const profile = await getProfile(client, session.did) 10 + const agent = getSessionAgent(session) 11 + const identity = await ok(agent.get('com.atproto.server.getSession')) 12 + const profile = await getProfile(session) 19 13 return { profile, identity } 20 14 } catch (error) { 21 15 const isStatusError = ··· 34 28 return null 35 29 } 36 30 } 37 - 38 - export async function getProfile(agent: XRPCLient, did: Did) { 39 - // if no 'app.bsky.actor.profile' record is found on the user's PDS, this method will not return an error 40 - // instead it will return an empty skeleton of a profile record with "handle" set to "handle.invalid" 41 - // const profile = await agent.getProfile( 42 - // didOrHandle ? { actor: didOrHandle } : undefined 43 - // ) 44 - const profile = await agent.get('app.bsky.actor.getProfile', { 45 - params: { actor: did } 46 - }) 47 - if (!profile.ok) { 48 - throw new Error('Failed to fetch profile') 49 - } 50 - 51 - const data = profile.data.handle === 'handle.invalid' ? null : profile.data 52 - return data 53 - }
+21 -1
packages/client/app/lib/xrpcClient.ts
··· 2 2 Client, 3 3 ok, 4 4 simpleFetchHandler, 5 + CredentialManager, 5 6 type FetchHandler 6 7 } from '@atcute/client' 7 8 ··· 11 12 import type {} from '@atcute/atproto' 12 13 import type {} from '@watproto/lexicon' 13 14 14 - import type { Did, Nsid } from '@atcute/lexicons' 15 + import type { Did, Handle, Nsid } from '@atcute/lexicons' 15 16 import { didWebToUrl, type OAuthSession } from '@atproto/oauth-client-node' 16 17 import { KeyserverClient } from '@atpkeyserver/client' 17 18 import { env } from './env.server' 18 19 19 20 export type XRPCLient = Awaited<ReturnType<typeof getSessionAgent>> 21 + 22 + /** 23 + * This agent is what you need to use to edit your account settings like email, password, handle, etc. 24 + * This has elevated privileges greater than the session agent, so use it with caution. 25 + */ 26 + export async function getLoginAgent( 27 + serviceUrl: string, 28 + handle: Handle, 29 + password: string 30 + ) { 31 + const loginManager = new CredentialManager({ 32 + service: serviceUrl 33 + }) 34 + await loginManager.login({ 35 + identifier: handle, 36 + password 37 + }) 38 + return new Client({ handler: loginManager }) 39 + } 20 40 21 41 export function getSessionAgent(session: OAuthSession) { 22 42 const client = new Client({ handler: session.fetchHandler.bind(session) })
+15
packages/client/app/routes/oauth.$.tsx
··· 3 3 import keys from '@www/jwks.json' 4 4 import { commitSession, getSession } from '@www/lib/session.server' 5 5 import { redirect } from 'react-router' 6 + import { getServiceAgent, getSessionAgent } from '@www/lib/xrpcClient' 7 + import { env } from '@www/lib/env.server' 8 + import { ok } from '@atcute/client' 6 9 7 10 const publicKeys = keys.map((k) => { 8 11 const { kty, alg, kid, crv, x, y } = k 9 12 return { kty, alg, kid, crv, x, y } 10 13 }) 14 + 15 + const serviceDid = 16 + `did:web:${encodeURIComponent(new URL(env.API_URL).host)}` as const 11 17 12 18 export async function loader({ params, request }: Route.LoaderArgs) { 13 19 const path = params['*'] ··· 27 33 type: 'success', 28 34 message: 'You are now logged in' 29 35 }) 36 + const { did, handle, status, active } = await ok( 37 + getSessionAgent(session).get('com.atproto.server.getSession') 38 + ) 39 + const agent = getServiceAgent(session, serviceDid) 40 + await agent.post('app.wafrn.actor.cacheAccount', { 41 + input: { did, handle, status, active } 42 + }) 43 + 44 + // call app.wafrn.actor.updateProfile with { last_login_at: Date.now() } 30 45 const redirectUrl = new URL(state ?? '/', request.url).toString() 31 46 return redirect(redirectUrl, { 32 47 headers: {
+9 -2
packages/client/app/routes/profile.$handle.tsx
··· 1 1 import type { Route } from './+types/profile.$handle' 2 - import type { Handle, Did, ResourceUri } from '@atcute/lexicons' 2 + import type { Handle, Did, ResourceUri, Blob } from '@atcute/lexicons' 3 3 import { Form, useLoaderData, useNavigation } from 'react-router' 4 4 import PostFeed from '@www/components/PostFeed' 5 5 import asyncWrap from '@www/lib/asyncWrap' ··· 89 89 buttonVariant = 'btn-primary' 90 90 } 91 91 92 + function formatAvatarBlob(blob: Blob) { 93 + const blobId = blob.ref?.$link 94 + return blobId 95 + ? `https://cdn.bsky.app/img/avatar/plain/${did}/${blobId}@jpeg` 96 + : '' 97 + } 98 + 92 99 return ( 93 100 <div className="px-2 py-4 max-w-4xl mx-auto"> 94 101 {/* Profile Header */} ··· 97 104 {/* Avatar */} 98 105 {profile?.avatar && ( 99 106 <img 100 - src={profile.avatar} 107 + src={formatAvatarBlob(profile.avatar as Blob<'image/png'>)} 101 108 alt={profile.displayName || handle} 102 109 className="w-20 h-20 rounded-full object-cover" 103 110 />
+276
packages/client/app/routes/settings.account.tsx
··· 1 + import { Form, data } from 'react-router' 2 + import type { Route } from './+types/settings.account' 3 + import { getOAuthSession } from '@www/lib/oauth.server' 4 + import { useRootData } from '@www/lib/useRootData' 5 + import { getLoginAgent, getSessionAgent } from '@www/lib/xrpcClient' 6 + import { ok } from '@atcute/client' 7 + import type { Handle } from '@atcute/lexicons' 8 + 9 + export async function action({ request }: Route.ActionArgs) { 10 + const session = await getOAuthSession(request) 11 + const formData = await request.formData() 12 + const action = formData.get('action') 13 + 14 + try { 15 + if (action === 'update-email') { 16 + const handle = String(formData.get('handle')) as Handle 17 + const newEmail = String(formData.get('email') ?? '') 18 + if (!newEmail) { 19 + throw new Error('"email" is required in body') 20 + } 21 + const password = String(formData.get('password') ?? '') 22 + if (!password) { 23 + throw new Error('"password" is required in body') 24 + } 25 + const agent = await getLoginAgent(session.server.issuer, handle, password) 26 + await ok( 27 + agent.post('com.atproto.server.updateEmail', { 28 + as: 'bytes', 29 + input: { 30 + email: newEmail 31 + } 32 + }) 33 + ) 34 + 35 + return data({ 36 + success: true, 37 + message: 38 + 'Email update initiated. Please check your inbox for verification.' 39 + }) 40 + } else if (action === 'update-handle') { 41 + const newHandle = String(formData.get('handle') ?? '') as Handle 42 + if (!newHandle) { 43 + throw new Error('"handle" is required in body') 44 + } 45 + const agent = getSessionAgent(session) 46 + await ok( 47 + agent.post('com.atproto.identity.updateHandle', { 48 + as: 'bytes', 49 + input: { 50 + handle: newHandle 51 + } 52 + }) 53 + ) 54 + 55 + return data({ 56 + success: true, 57 + message: 'Handle updated successfully.' 58 + }) 59 + } else if (action === 'update-password') { 60 + // // TODO: Implement password update via XRPC 61 + // const handle = String(formData.get('handle') ?? '') as Handle 62 + // const currentPassword = String(formData.get('current_password') ?? '') 63 + // const newPassword = String(formData.get('new_password') ?? '') 64 + // const confirmPassword = String(formData.get('confirm_password') ?? '') 65 + // if (newPassword !== confirmPassword) { 66 + // return data( 67 + // { 68 + // success: false, 69 + // message: 'New passwords do not match.' 70 + // }, 71 + // { status: 400 } 72 + // ) 73 + // } 74 + // return data({ 75 + // success: true, 76 + // message: 'Password updated successfully.' 77 + // }) 78 + } 79 + } catch (error) { 80 + return data( 81 + { 82 + success: false, 83 + message: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` 84 + }, 85 + { status: 500 } 86 + ) 87 + } 88 + } 89 + 90 + export default function AccountSettings({ actionData }: Route.ComponentProps) { 91 + const { user } = useRootData() 92 + 93 + if (!user) { 94 + return ( 95 + <div className="px-4 py-6 max-w-3xl mx-auto"> 96 + <div className="alert alert-error"> 97 + Please log in to access account settings. 98 + </div> 99 + </div> 100 + ) 101 + } 102 + 103 + const handle = user.identity.handle 104 + const email = user.identity.email 105 + const emailConfirmed = user.identity.emailConfirmed 106 + 107 + return ( 108 + <div className="px-4 py-6 max-w-3xl mx-auto"> 109 + <h1 className="text-2xl font-bold mb-6">Account Settings</h1> 110 + 111 + {actionData?.message && ( 112 + <div 113 + className={`alert alert-soft ${actionData.success ? 'alert-success' : 'alert-error'} mb-4`} 114 + > 115 + {actionData.message} 116 + </div> 117 + )} 118 + 119 + {/* Email Section */} 120 + <div className="card bg-base-200 shadow-xl mb-6"> 121 + <div className="card-body"> 122 + <h2 className="card-title">Email Address</h2> 123 + <p className="text-sm opacity-70 mb-4"> 124 + Update your email address for account notifications and recovery. 125 + </p> 126 + 127 + <div className="mb-4"> 128 + <p className="text-sm font-semibold mb-1">Current Email:</p> 129 + <p className="text-sm opacity-70">{email}</p> 130 + </div> 131 + 132 + <Form method="POST"> 133 + <input type="hidden" name="handle" value={handle ?? ''} /> 134 + <fieldset className="fieldset"> 135 + <legend className="fieldset-legend">New Email</legend> 136 + <input 137 + type="email" 138 + name="email" 139 + placeholder="your.new.email@example.com" 140 + className="input" 141 + required 142 + /> 143 + </fieldset> 144 + <fieldset className="fieldset"> 145 + <legend className="fieldset-legend">Your current password</legend> 146 + <input 147 + type="password" 148 + name="password" 149 + placeholder="******" 150 + className="input" 151 + required 152 + /> 153 + </fieldset> 154 + <button 155 + type="submit" 156 + name="action" 157 + value="update-email" 158 + className="btn btn-primary mt-3" 159 + > 160 + {emailConfirmed ? 'Request Email Change' : 'Update Email'} 161 + </button> 162 + </Form> 163 + </div> 164 + </div> 165 + 166 + {/* Handle Section */} 167 + <div className="card bg-base-200 shadow-xl mb-6"> 168 + <div className="card-body"> 169 + <h2 className="card-title">Handle</h2> 170 + <p className="text-sm opacity-70 mb-4"> 171 + Your handle is your unique identifier on ATProto. Changing it will 172 + update how others find and mention you. 173 + </p> 174 + 175 + <div className="mb-4"> 176 + <p className="text-sm font-semibold mb-1">Current Handle:</p> 177 + <p className="text-sm opacity-70 font-mono">@{handle}</p> 178 + </div> 179 + 180 + <Form 181 + method="POST" 182 + className="flex flex-wrap gap-1 items-center mb-4" 183 + > 184 + <fieldset className="fieldset min-w-1/2"> 185 + <legend className="fieldset-legend">New Handle</legend> 186 + <label className="input w-full"> 187 + <span className="label mr-0">@</span> 188 + <input 189 + type="text" 190 + name="handle" 191 + placeholder="your.new.handle" 192 + pattern="[a-zA-Z0-9.-]+" 193 + required 194 + /> 195 + </label> 196 + <p className="label opacity-70"> 197 + Only letters, numbers, dots, and hyphens allowed 198 + </p> 199 + </fieldset> 200 + <button 201 + type="submit" 202 + name="action" 203 + value="update-handle" 204 + className="btn btn-primary mt-2" 205 + > 206 + Update Handle 207 + </button> 208 + </Form> 209 + </div> 210 + </div> 211 + 212 + <p className="mb-4">Coming Soon</p> 213 + 214 + {/* Password Section */} 215 + <div className="card bg-base-200 shadow-xl mb-6 opacity-50 pointer-events-none"> 216 + <div className="card-body"> 217 + <h2 className="card-title">Password</h2> 218 + <p className="text-sm opacity-70 mb-4"> 219 + Change your account password. Make sure to use a strong, unique 220 + password. 221 + </p> 222 + 223 + <Form method="POST"> 224 + <fieldset className="fieldset mb-4"> 225 + <legend className="fieldset-legend">Current Password</legend> 226 + <input 227 + type="password" 228 + name="current_password" 229 + placeholder="Enter your current password" 230 + className="input input-bordered" 231 + required 232 + /> 233 + </fieldset> 234 + 235 + <fieldset className="fieldset mb-4"> 236 + <legend className="fieldset-legend">New Password</legend> 237 + <input 238 + type="password" 239 + name="new_password" 240 + placeholder="Enter your new password" 241 + className="input input-bordered" 242 + minLength={8} 243 + required 244 + /> 245 + <p className="label opacity-70">Minimum 8 characters</p> 246 + </fieldset> 247 + 248 + <fieldset className="fieldset mb-4"> 249 + <legend className="fieldset-legend">Confirm New Password</legend> 250 + <input 251 + type="password" 252 + name="confirm_password" 253 + placeholder="Confirm your new password" 254 + className="input input-bordered" 255 + minLength={8} 256 + required 257 + /> 258 + </fieldset> 259 + 260 + <div className="card-actions"> 261 + <button 262 + type="submit" 263 + name="action" 264 + disabled 265 + value="update-password" 266 + className="btn btn-warning" 267 + > 268 + Change Password 269 + </button> 270 + </div> 271 + </Form> 272 + </div> 273 + </div> 274 + </div> 275 + ) 276 + }
+1
packages/client/tsconfig.json
··· 6 6 "**/.client/**/*", 7 7 ".react-router/types/**/*" 8 8 ], 9 + "exclude": ["./build/**/*"], 9 10 "compilerOptions": { 10 11 "lib": ["DOM", "DOM.Iterable", "ES2022"], 11 12 "types": ["node", "vite/client", "@types/bun"],
+1
packages/lexicon/index.ts
··· 1 + export * as AppWafrnActorCacheAccount from "./types/app/wafrn/actor/cacheAccount.js"; 1 2 export * as AppWafrnActorDefs from "./types/app/wafrn/actor/defs.js"; 2 3 export * as AppWafrnActorGetProfiles from "./types/app/wafrn/actor/getProfiles.js"; 3 4 export * as AppWafrnActorProfile from "./types/app/wafrn/actor/profile.js";
+42
packages/lexicon/types/app/wafrn/actor/cacheAccount.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.procedure("app.wafrn.actor.cacheAccount", { 6 + params: null, 7 + input: { 8 + type: "lex", 9 + schema: /*#__PURE__*/ v.object({ 10 + active: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), 11 + did: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()), 12 + handle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.handleString()), 13 + status: /*#__PURE__*/ v.optional( 14 + /*#__PURE__*/ v.string< 15 + "deactivated" | "suspended" | "takendown" | (string & {}) 16 + >(), 17 + ), 18 + }), 19 + }, 20 + output: { 21 + type: "lex", 22 + schema: /*#__PURE__*/ v.object({ 23 + updated_at: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), 24 + }), 25 + }, 26 + }); 27 + 28 + type main$schematype = typeof _mainSchema; 29 + 30 + export interface mainSchema extends main$schematype {} 31 + 32 + export const mainSchema = _mainSchema as mainSchema; 33 + 34 + export interface $params {} 35 + export interface $input extends v.InferXRPCBodyInput<mainSchema["input"]> {} 36 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 37 + 38 + declare module "@atcute/lexicons/ambient" { 39 + interface XRPCProcedures { 40 + "app.wafrn.actor.cacheAccount": mainSchema; 41 + } 42 + }
+23
packages/lexicon/types/app/wafrn/actor/defs.ts
··· 2 2 import * as v from "@atcute/lexicons/validations"; 3 3 import * as AppWafrnActorProfile from "./profile.js"; 4 4 5 + const _customFieldSchema = /*#__PURE__*/ v.object({ 6 + $type: /*#__PURE__*/ v.optional( 7 + /*#__PURE__*/ v.literal("app.wafrn.actor.defs#customField"), 8 + ), 9 + /** 10 + * Field name 11 + * @maxLength 64 12 + */ 13 + key: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 14 + /*#__PURE__*/ v.stringLength(0, 64), 15 + ]), 16 + /** 17 + * Field value 18 + * @maxLength 256 19 + */ 20 + value: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 21 + /*#__PURE__*/ v.stringLength(0, 256), 22 + ]), 23 + }); 5 24 const _profileViewSchema = /*#__PURE__*/ v.object({ 6 25 $type: /*#__PURE__*/ v.optional( 7 26 /*#__PURE__*/ v.literal("app.wafrn.actor.defs#profileView"), ··· 48 67 }, 49 68 }); 50 69 70 + type customField$schematype = typeof _customFieldSchema; 51 71 type profileView$schematype = typeof _profileViewSchema; 52 72 type profileViewDetailed$schematype = typeof _profileViewDetailedSchema; 53 73 74 + export interface customFieldSchema extends customField$schematype {} 54 75 export interface profileViewSchema extends profileView$schematype {} 55 76 export interface profileViewDetailedSchema 56 77 extends profileViewDetailed$schematype {} 57 78 79 + export const customFieldSchema = _customFieldSchema as customFieldSchema; 58 80 export const profileViewSchema = _profileViewSchema as profileViewSchema; 59 81 export const profileViewDetailedSchema = 60 82 _profileViewDetailedSchema as profileViewDetailedSchema; 61 83 84 + export interface CustomField extends v.InferInput<typeof customFieldSchema> {} 62 85 export interface ProfileView extends v.InferInput<typeof profileViewSchema> {} 63 86 export interface ProfileViewDetailed 64 87 extends v.InferInput<typeof profileViewDetailedSchema> {}
+7 -3
packages/server/src/db/migrations/1762527898283_social_graph.ts
··· 49 49 .createTable('follow_counts') 50 50 .addColumn('did', 'text', (col) => col.primaryKey().notNull()) 51 51 .addColumn('follower_count', 'integer', (col) => col.notNull().defaultTo(0)) 52 - .addColumn('following_count', 'integer', (col) => col.notNull().defaultTo(0)) 52 + .addColumn('following_count', 'integer', (col) => 53 + col.notNull().defaultTo(0) 54 + ) 53 55 .addColumn('updated_at', 'integer', (col) => 54 56 col.notNull().defaultTo(sql`(unixepoch() * 1000)`) 55 57 ) ··· 78 80 .addColumn('description', 'text') 79 81 .addColumn('banner_url', 'text') 80 82 // Timestamps 81 - .addColumn('profile_cached_at', 'integer') 82 - .addColumn('wafrn_updated_at', 'integer', (col) => 83 + .addColumn('indexed_at', 'integer', (col) => 84 + col.notNull().defaultTo(sql`(unixepoch() * 1000)`) 85 + ) 86 + .addColumn('updated_at', 'integer', (col) => 83 87 col.notNull().defaultTo(sql`(unixepoch() * 1000)`) 84 88 ) 85 89 .addForeignKeyConstraint(
+2 -2
packages/server/src/db/schema.d.ts
··· 53 53 did: string; 54 54 display_name: string | null; 55 55 html_bio: string | null; 56 - profile_cached_at: number | null; 56 + indexed_at: Generated<number>; 57 57 server_origin: string | null; 58 - wafrn_updated_at: Generated<number>; 58 + updated_at: Generated<number>; 59 59 } 60 60 61 61 export interface PublicPosts {
+1 -1
packages/server/src/lib/account.ts
··· 2 2 import type { Accounts } from '@api/db/schema' 3 3 import type { Insertable } from 'kysely' 4 4 5 - export function createOrUpdateAccount(account: Insertable<Accounts>) { 5 + export function cacheAccount(account: Insertable<Accounts>) { 6 6 return db 7 7 .insertInto('accounts') 8 8 .values([
+6 -4
packages/server/src/lib/profile.ts
··· 7 7 * Creates the account if it doesn't exist, updates last_login_at if it does. 8 8 * This should be called on first interaction with any DID. 9 9 */ 10 - export async function ensureAccount(did: string, handle: string): Promise<void> { 10 + export async function ensureAccount( 11 + did: string, 12 + handle: string 13 + ): Promise<void> { 11 14 await db 12 15 .insertInto('accounts') 13 16 .values({ ··· 45 48 server_origin: profile.server_origin ?? null, 46 49 custom_fields: profile.custom_fields 47 50 ? JSON.stringify(profile.custom_fields) 48 - : null, 49 - wafrn_updated_at: Date.now() 51 + : null 50 52 }) 51 53 .onConflict((oc) => 52 54 oc.column('did').doUpdateSet({ ··· 55 57 custom_fields: profile.custom_fields 56 58 ? JSON.stringify(profile.custom_fields) 57 59 : null, 58 - wafrn_updated_at: Date.now() 60 + updated_at: Date.now() 59 61 }) 60 62 ) 61 63 .execute()