+34
lexicons/app/wafrn/actor/cacheAccount.json
+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
+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
+3
packages/client/app/components/UserMenu.tsx
+12
-4
packages/client/app/lib/oauthClient.server.ts
+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
+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
+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
+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
+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
+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
+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
+1
packages/client/tsconfig.json
+1
packages/lexicon/index.ts
+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
+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
+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> {}
+2
-2
packages/server/src/db/schema.d.ts
+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
+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
+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()