Skip to content

Commit 3bf1cde

Browse files
committed
add profile lexicon + scope, add profile button to AuthModal, add profile server utils + get endpoint, create profile on first login
1 parent 8ce9561 commit 3bf1cde

File tree

10 files changed

+289
-18
lines changed

10 files changed

+289
-18
lines changed

app/components/Header/AuthModal.client.vue

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import { useAtproto } from '~/composables/atproto/useAtproto'
33
import { authRedirect } from '~/utils/atproto/helpers'
44
5+
const authModal = useModal('auth-modal')
6+
57
const handleInput = shallowRef('')
68
const route = useRoute()
79
const { user, logout } = useAtproto()
@@ -33,12 +35,27 @@ async function handleLogin() {
3335
</p>
3436
</div>
3537
</div>
36-
<button
37-
class="w-full px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-accent/70"
38-
@click="logout"
39-
>
40-
{{ $t('auth.modal.disconnect') }}
41-
</button>
38+
39+
<div class="flex flex-col space-y-4">
40+
<NuxtLink
41+
:to="{ name: 'profile-handle', params: { handle: user.handle } }"
42+
:prefetch-on="prefetch ? 'visibility' : 'interaction'"
43+
>
44+
<button
45+
class="w-full px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
46+
@click="authModal.close()"
47+
>
48+
Profile
49+
</button>
50+
</NuxtLink>
51+
52+
<button
53+
class="w-full px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
54+
@click="logout"
55+
>
56+
{{ $t('auth.modal.disconnect') }}
57+
</button>
58+
</div>
4259
</div>
4360

4461
<!-- Disconnected state -->
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<script setup lang="ts">
2+
import { debounce } from 'perfect-debounce'
3+
import { normalizeSearchParam } from '#shared/utils/url'
4+
5+
const route = useRoute('/profile/[handle]')
6+
const router = useRouter()
7+
8+
const handle = computed(() => route.params.handle)
9+
10+
const { data: profile }: { data?: NPMXProfile } = useFetch(
11+
() => `/api/social/profile/${handle.value}`,
12+
{
13+
default: () => ({ profile: { displayName: handle.value } }),
14+
server: false,
15+
},
16+
)
17+
18+
useSeoMeta({
19+
title: () => `${handle.value} - npmx`,
20+
description: () => `npmx profile by ${handle.value}`,
21+
})
22+
23+
/**
24+
defineOgImageComponent('Default', {
25+
title: () => `~${username.value}`,
26+
description: () => (results.value ? `${results.value.total} packages` : 'npm user profile'),
27+
primaryColor: '#60a5fa',
28+
})
29+
**/
30+
</script>
31+
32+
<template>
33+
<main class="container flex-1 flex flex-col py-8 sm:py-12 w-full">
34+
<!-- Header -->
35+
<header class="mb-8 pb-8 border-b border-border">
36+
<div class="flex flex-wrap items-center gap-4">
37+
<div>
38+
<h1 class="font-mono text-2xl sm:text-3xl font-medium">{{ profile.displayName }}</h1>
39+
<h2>@{{ handle }}</h2>
40+
<p v-if="profile.description">{{ profile.description }}</p>
41+
</div>
42+
</div>
43+
</header>
44+
45+
<!-- Empty state (no packages found for user) -->
46+
<div class="flex-1 flex items-center justify-center">
47+
<div class="text-center">
48+
<p class="text-fg-muted font-mono">
49+
{{ $t('user.page.no_packages') }} <span class="text-fg">~{{ handle }}</span>
50+
</p>
51+
<p class="text-fg-subtle text-sm mt-2">{{ $t('user.page.no_packages_hint') }}</p>
52+
</div>
53+
</div>
54+
</main>
55+
</template>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"lexicon": 1,
3+
"id": "dev.npmx.actor.profile",
4+
"defs": {
5+
"main": {
6+
"key": "literal:self",
7+
"type": "record",
8+
"record": {
9+
"type": "object",
10+
"properties": {
11+
"avatar": {
12+
"type": "blob",
13+
"accept": ["image/png", "image/jpeg"],
14+
"maxSize": 1000000,
15+
"description": "AKA, 'profile picture'"
16+
},
17+
"website": {
18+
"type": "string",
19+
"format": "uri"
20+
},
21+
"description": {
22+
"type": "string",
23+
"maxLength": 2560,
24+
"description": "Free-form profile description text.",
25+
"maxGraphemes": 256
26+
},
27+
"displayName": {
28+
"type": "string",
29+
"maxLength": 640,
30+
"maxGraphemes": 64
31+
}
32+
}
33+
},
34+
"description": "A declaration of an npmx account profile."
35+
}
36+
}
37+
}

server/api/auth/atproto.get.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -119,21 +119,57 @@ export default defineEventHandler(async event => {
119119
const agent = new Agent(authSession)
120120
event.context.agent = agent
121121

122-
const response = await fetch(
123-
`https://${SLINGSHOT_HOST}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${agent.did}`,
122+
const miniDocResponse = await fetch(
123+
`https://${SLINGSHOT_HOST}/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${agent.did}`,
124124
{ headers: { 'User-Agent': 'npmx' } },
125125
)
126-
if (response.ok) {
127-
const miniDoc: PublicUserSession = await response.json()
126+
127+
if (miniDocResponse.ok) {
128+
const miniDoc: PublicUserSession = await miniDocResponse.json()
128129

129130
let avatar: string | undefined = await getAvatar(authSession.did, miniDoc.pds)
130131

131-
await session.update({
132-
public: {
133-
...miniDoc,
134-
avatar,
135-
},
136-
})
132+
// get existing npmx profile OR create a new one
133+
const profileUri = `at://${agent.did}/dev.npmx.actor.profile/self`
134+
const profileResponse = await fetch(
135+
`https://${SLINGSHOT_HOST}/xrpc/blue.microcosm.repo.getRecordByUri?at_uri=${profileUri}`,
136+
{ headers: { 'User-Agent': 'npmx' } },
137+
)
138+
139+
if (profileResponse.ok) {
140+
const profile = await profileResponse.json()
141+
await session.update({
142+
public: {
143+
...miniDoc,
144+
avatar,
145+
},
146+
profile: profile.value,
147+
})
148+
} else {
149+
const profile = {
150+
website: '',
151+
displayName: miniDoc.handle,
152+
description: '',
153+
}
154+
155+
await agent.com.atproto.repo.createRecord({
156+
repo: miniDoc.handle,
157+
collection: 'dev.npmx.actor.profile',
158+
rkey: 'self',
159+
record: {
160+
$type: 'dev.npmx.actor.profile',
161+
...profile,
162+
},
163+
})
164+
165+
await session.update({
166+
public: {
167+
...miniDoc,
168+
avatar,
169+
},
170+
profile: profile,
171+
})
172+
}
137173
} else {
138174
//If slingshot fails we still want to set some key info we need.
139175
const pdsBase = (await authSession.getTokenInfo()).aud
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export default defineEventHandler(async event => {
2+
const handle = getRouterParam(event, 'handle')
3+
if (!handle) {
4+
throw createError({
5+
status: 400,
6+
message: 'handle not provided',
7+
})
8+
}
9+
10+
const profileUtil = new ProfileUtils()
11+
const profile = await profileUtil.getProfile(handle)
12+
console.log('ENDPOINT', { handle, profile })
13+
return profile
14+
})

server/utils/atproto/oauth.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ import { NodeOAuthClient, AtprotoDohHandleResolver } from '@atproto/oauth-client
44
import { parse } from 'valibot'
55
import { getOAuthLock } from '#server/utils/atproto/lock'
66
import { useOAuthStorage } from '#server/utils/atproto/storage'
7-
import { LIKES_SCOPE } from '#shared/utils/constants'
7+
import { LIKES_SCOPE, PROFILE_SCOPE } from '#shared/utils/constants'
88
import { OAuthMetadataSchema } from '#shared/schemas/oauth'
99
// @ts-expect-error virtual file from oauth module
1010
import { clientUri } from '#oauth/config'
1111
// TODO: If you add writing a new record you will need to add a scope for it
12-
export const scope = `atproto ${LIKES_SCOPE}`
12+
export const scope = `atproto ${LIKES_SCOPE} ${PROFILE_SCOPE}`
1313

1414
/**
1515
* Resolves a did to a handle via DoH or via the http website calls
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { MiniDoc, NPMXProfile } from '~~/shared/types/social'
2+
3+
//Cache keys and helpers
4+
const CACHE_PREFIX = 'atproto-profile:'
5+
const CACHE_PROFILE_MINI_DOC = (handle: string) => `${CACHE_PREFIX}${handle}:minidoc`
6+
const CACHE_PROFILE_KEY = (did: string) => `${CACHE_PREFIX}${did}:profile`
7+
8+
const CACHE_MAX_AGE = CACHE_MAX_AGE_ONE_MINUTE * 5
9+
10+
/**
11+
* Logic to handle and update profile queries
12+
*/
13+
export class ProfileUtils {
14+
private readonly constellation: Constellation
15+
private readonly cache: CacheAdapter
16+
17+
constructor() {
18+
this.constellation = new Constellation(
19+
// Passes in a fetch wrapped as cachedfetch since are already doing some heavy caching here
20+
async <T = unknown>(
21+
url: string,
22+
options: Parameters<typeof $fetch>[1] = {},
23+
_ttl?: number,
24+
): Promise<CachedFetchResult<T>> => {
25+
const data = (await $fetch<T>(url, options)) as T
26+
return { data, isStale: false, cachedAt: null }
27+
},
28+
)
29+
this.cache = getCacheAdapter('generic')
30+
}
31+
32+
private async slingshotMiniDoc(handle: string) {
33+
const miniDocKey = CACHE_PROFILE_MINI_DOC(handle)
34+
const cachedMiniDoc = await this.cache.get<MiniDoc>(miniDocKey)
35+
36+
let miniDoc
37+
if (cachedMiniDoc) {
38+
miniDoc = cachedMiniDoc
39+
} else {
40+
const resolveUrl = `https://${SLINGSHOT_HOST}/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(handle)}`
41+
console.log({ resolveUrl })
42+
const response = await fetch(resolveUrl, {
43+
headers: { 'User-Agent': 'npmx' },
44+
})
45+
const value = (await response.json()) as MiniDoc
46+
47+
miniDoc = value
48+
await this.cache.set(miniDocKey, value, CACHE_MAX_AGE)
49+
}
50+
console.log({ miniDoc })
51+
52+
return miniDoc
53+
}
54+
55+
/**
56+
* Gets an npmx profile based on a handle
57+
* @param handle
58+
* @returns
59+
*/
60+
async getProfile(handle: string) {
61+
const profileKey = CACHE_PROFILE_KEY(handle)
62+
const cachedProfile = await this.cache.get<NPMXProfile>(profileKey)
63+
64+
let profile: NPMXProfile | undefined
65+
if (cachedProfile) {
66+
profile = cachedProfile
67+
} else {
68+
const miniDoc = await this.slingshotMiniDoc(handle)
69+
const profileUri = `at://${miniDoc.did}/dev.npmx.actor.profile/self`
70+
const response = await fetch(
71+
`https://${SLINGSHOT_HOST}/xrpc/blue.microcosm.repo.getRecordByUri?at_uri=${profileUri}`,
72+
{
73+
headers: { 'User-Agent': 'npmx' },
74+
},
75+
)
76+
if (response.ok) {
77+
const { value } = (await response.json()) as { value: NPMXProfile }
78+
profile = value
79+
await this.cache.set(profileKey, profile, CACHE_MAX_AGE)
80+
}
81+
}
82+
83+
return profile
84+
}
85+
}

shared/types/social.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,24 @@ export type PackageLikes = {
77
// If the logged in user has liked the package, false if not logged in
88
userHasLiked: boolean
99
}
10+
11+
/**
12+
* A shortened DID Doc for AT Protocol accounts
13+
* Returned by Slingshot's `/xrpc/blue.microcosm.identity.resolveMiniDoc` endpoint
14+
*/
15+
export type MiniDoc = {
16+
did: string
17+
handle: string
18+
pds: string
19+
signing_key: string
20+
}
21+
22+
/**
23+
* NPMX Profile details
24+
* TODO: avatar
25+
*/
26+
export type NPMXProfile = {
27+
displayName: string
28+
website?: string
29+
description?: string
30+
}

shared/types/userSession.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ export interface UserServerSession {
77
pds: string
88
avatar?: string
99
}
10+
profile: {
11+
website?: string
12+
description?: string
13+
displayName?: string
14+
}
1015
// Only to be used in the atproto session and state stores
1116
// Will need to change to Record<string, T> and add a current logged in user if we ever want to support
1217
// multiple did logins per server session

shared/utils/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const PACKAGE_SUBJECT_REF = (packageName: string) =>
4343
`https://npmx.dev/package/${packageName}`
4444
// OAuth scopes as we add new ones we need to check these on certain actions. If not redirect the user to login again to upgrade the scopes
4545
export const LIKES_SCOPE = `repo:${dev.npmx.feed.like.$nsid}`
46+
export const PROFILE_SCOPE = `repo:${dev.npmx.actor.profile.$nsid}`
4647

4748
// Theming
4849
export const ACCENT_COLORS = {

0 commit comments

Comments
 (0)