From 4830f1df8c3584016e88c602eb673f0703f01752 Mon Sep 17 00:00:00 2001
From: Bailey Townsend
Date: Sat, 31 Jan 2026 23:21:09 -0600
Subject: [PATCH 01/19] generic cache. local file in dev, redis in prod
---
nuxt.config.ts | 4 ++
server/utils/cache.ts | 109 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 113 insertions(+)
create mode 100644 server/utils/cache.ts
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 8ba28e7b4..2cbb8e80c 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -168,6 +168,10 @@ export default defineNuxtConfig({
driver: 'fsLite',
base: './.cache/atproto-oauth/session',
},
+ 'generic-cache': {
+ driver: 'fsLite',
+ base: './.cache/generic',
+ },
},
typescript: {
tsConfig: {
diff --git a/server/utils/cache.ts b/server/utils/cache.ts
new file mode 100644
index 000000000..0526ff447
--- /dev/null
+++ b/server/utils/cache.ts
@@ -0,0 +1,109 @@
+import { Redis } from '@upstash/redis'
+
+/**
+ * Generic cache adapter to allow using a local cache during development and redis in production
+ */
+export interface CacheAdapter {
+ get(key: string): Promise
+ set(key: string, value: T, ttl?: number): Promise
+ delete(key: string): Promise
+}
+
+/**
+ * Local cache data entry
+ */
+interface LocalCachedEntry {
+ value: T
+ ttl?: number
+ cachedAt: number
+}
+
+/**
+ * Checks to see if a cache entry is stale locally
+ * @param entry - The entry from the locla cache
+ * @returns
+ */
+function isCacheEntryStale(entry: LocalCachedEntry): boolean {
+ if (!entry.ttl) return false
+ const now = Date.now()
+ const expiresAt = entry.cachedAt + entry.ttl * 1000
+ return now > expiresAt
+}
+
+/**
+ * Local implmentation of a cache to be used during development
+ */
+export class StorageCacheAdapter implements CacheAdapter {
+ private readonly storage = useStorage('generic-cache')
+
+ async get(key: string): Promise {
+ const result = await this.storage.getItem>(key)
+ if (!result) return
+ if (isCacheEntryStale(result)) {
+ await this.storage.removeItem(key)
+ return
+ }
+ return result.value
+ }
+
+ async set(key: string, value: T, ttl?: number): Promise {
+ await this.storage.setItem(key, { value, ttl, cachedAt: Date.now() })
+ }
+
+ async delete(key: string): Promise {
+ await this.storage.removeItem(key)
+ }
+}
+
+/**
+ * Redis cache storage with TTL handled by redis for use in production
+ */
+export class RedisCacheAdatper implements CacheAdapter {
+ private readonly redis: Redis
+ private readonly prefix: string
+
+ formatKey(key: string): string {
+ return `${this.prefix}:${key}`
+ }
+
+ constructor(redis: Redis, prefix: string) {
+ this.redis = redis
+ this.prefix = prefix
+ }
+
+ async get(key: string): Promise {
+ const formattedKey = this.formatKey(key)
+ const value = await this.redis.get(formattedKey)
+ if (!value) return
+ return value
+ }
+
+ async set(key: string, value: T, ttl?: number): Promise {
+ const formattedKey = this.formatKey(key)
+ if (ttl) {
+ await this.redis.setex(formattedKey, ttl, value)
+ } else {
+ await this.redis.set(formattedKey, value)
+ }
+ }
+
+ async delete(key: string): Promise {
+ const formattedKey = this.formatKey(key)
+ await this.redis.del(formattedKey)
+ }
+}
+
+export function getCache(prefix: string): CacheAdapter {
+ const config = useRuntimeConfig()
+
+ if (!import.meta.dev && config.upstash?.redisRestUrl && config.upstash?.redisRestToken) {
+ const redis = new Redis({
+ url: config.upstash.redisRestUrl,
+ token: config.upstash.redisRestToken,
+ })
+ return new RedisCacheAdatper(redis, prefix)
+ }
+
+ console.log('using storage')
+ return new StorageCacheAdapter()
+}
From 5637945736c116136e3601a94e726e99d6f043e9 Mon Sep 17 00:00:00 2001
From: Bailey Townsend
Date: Sun, 1 Feb 2026 00:52:07 -0600
Subject: [PATCH 02/19] Ideally should be the logic to read/set likes for npmx
---
server/api/auth/atproto.get.ts | 2 +-
server/api/likes/[...pkg].get.ts | 4 +
server/utils/atproto/utils/likes.ts | 154 ++++++++++++++++++++++++++++
server/utils/cache.ts | 4 +-
shared/utils/constants.ts | 4 +
5 files changed, 164 insertions(+), 4 deletions(-)
create mode 100644 server/api/likes/[...pkg].get.ts
create mode 100644 server/utils/atproto/utils/likes.ts
diff --git a/server/api/auth/atproto.get.ts b/server/api/auth/atproto.get.ts
index 287823ce7..66bc145f9 100644
--- a/server/api/auth/atproto.get.ts
+++ b/server/api/auth/atproto.get.ts
@@ -10,7 +10,7 @@ export default defineEventHandler(async event => {
if (!config.sessionPassword) {
throw createError({
status: 500,
- message: 'NUXT_SESSION_PASSWORD not set',
+ message: UNSET_NUXT_SESSION_PASSWORD,
})
}
diff --git a/server/api/likes/[...pkg].get.ts b/server/api/likes/[...pkg].get.ts
new file mode 100644
index 000000000..040201e4c
--- /dev/null
+++ b/server/api/likes/[...pkg].get.ts
@@ -0,0 +1,4 @@
+export default defineEventHandler(async _ => {
+ //TODO: Use the new thing I wrote last night
+ return 0
+})
diff --git a/server/utils/atproto/utils/likes.ts b/server/utils/atproto/utils/likes.ts
new file mode 100644
index 000000000..9ce678a4f
--- /dev/null
+++ b/server/utils/atproto/utils/likes.ts
@@ -0,0 +1,154 @@
+import { getCacheAdatper } from '../../cache'
+import { $nsid as likeNsid } from '#shared/types/lexicons/dev/npmx/feed/like.defs'
+import { SUBJECT_REF_PREFIX } from '~~/shared/utils/constants'
+
+/**
+ * Likes for a npm package on npmx
+ */
+export type PackageLikes = {
+ // The total likes found for the package
+ totalLikes: number
+ // If the logged in user has liked the package, false if not logged in
+ userHasLiked: boolean
+}
+
+const CACHE_PREFIX = 'atproto-likes:'
+const CACHE_PACKAGE_TOTAL_KEY = (packageName: string) => `${CACHE_PREFIX}:${packageName}:total`
+const CACHE_USER_LIKES_KEY = (packageName: string, did: string) =>
+ `${CACHE_PREFIX}${packageName}users:${did}`
+
+const CACHE_MAX_AGE = CACHE_MAX_AGE_ONE_MINUTE * 5
+
+export class PackageLikesService {
+ private readonly constellation: Constellation
+ private readonly cache: CacheAdapter
+
+ constructor(cachedFunction: CachedFetchFunction) {
+ this.constellation = new Constellation(cachedFunction)
+ this.cache = getCacheAdatper(CACHE_PREFIX)
+ }
+
+ /**
+ * Gets the true total count of likes for a npm package from the network
+ * @param subjectRef
+ * @returns
+ */
+ private async constellationLikes(subjectRef: string) {
+ // TODO: I need to see what failed fetch calls do here
+ const { data: totalLinks } = await this.constellation.getLinksDistinctDids(
+ subjectRef,
+ likeNsid,
+ '.subjectRef',
+ //Limit doesn't matter here since we are just counting the total likes
+ 1,
+ undefined,
+ CACHE_MAX_AGE_ONE_MINUTE * 10,
+ )
+ return totalLinks.total
+ }
+
+ /**
+ * Checks if the user has liked the npm package from the network
+ * @param subjectRef
+ * @param usersDid
+ * @returns
+ */
+ private async constellationUserHasLiked(subjectRef: string, usersDid: string) {
+ const { data: userLikes } = await this.constellation.getBackLinks(
+ subjectRef,
+ likeNsid,
+ 'subjectRef',
+ //Limit doesn't matter here since we are just counting the total likes
+ 1,
+ undefined,
+ false,
+ [[usersDid]],
+ )
+ //TODO: need to double check this logic
+ return userLikes.total > 0
+ }
+
+ /**
+ * Gets the likes for a npm package on npmx. Tries a local cahce first, if not found uses constellation
+ * @param packageName
+ * @param usersDid
+ * @returns
+ */
+ async getLikes(packageName: string, usersDid?: string) {
+ //TODO: May need to do some clean up on the package name, and maybe even hash it? some of the charcteres may be a bit odd as keys
+ const cache = getCacheAdatper(CACHE_PREFIX)
+
+ const cachedLikes = await cache.get(packageName)
+ if (cachedLikes) {
+ return cachedLikes
+ }
+
+ const subjectRef = `${SUBJECT_REF_PREFIX}/${packageName}`
+
+ const totalLikes = await this.constellationLikes(subjectRef)
+
+ let userHasLiked = false
+
+ if (usersDid) {
+ userHasLiked = await this.constellationUserHasLiked(subjectRef, usersDid)
+ }
+
+ const packageLikes = {
+ totalPackageLikes: totalLikes,
+ userHasLiked,
+ }
+ if (userHasLiked && usersDid) {
+ await cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), true, CACHE_MAX_AGE)
+ }
+
+ const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName)
+ await cache.set(totalLikesKey, packageLikes.totalPackageLikes, CACHE_MAX_AGE)
+ return packageLikes
+ }
+
+ /**
+ * Gets the definite answer if the user has liked a npm package. Either from the cache or the network
+ * @param packageName
+ * @param usersDid
+ * @returns
+ */
+ async hasTheUserLikedThePackage(packageName: string, usersDid: string) {
+ const cache = getCacheAdatper(CACHE_PREFIX)
+ const cached = await cache.get(CACHE_USER_LIKES_KEY(packageName, usersDid))
+ if (cached !== undefined) {
+ return cached
+ }
+ const userHasLiked = await this.constellationUserHasLiked(
+ `${SUBJECT_REF_PREFIX}/${packageName}`,
+ usersDid,
+ )
+ await cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), userHasLiked, CACHE_MAX_AGE)
+ return userHasLiked
+ }
+
+ /**
+ * It is asummed it has been checked by this point that if a user has liked a package and the new like was made as a record
+ * to the user's atproto repostiory
+ * @param packageName
+ * @param usersDid
+ */
+ async likeAPackageAndRetunLikes(packageName: string, usersDid: string): Promise {
+ const cache = getCacheAdatper(CACHE_PREFIX)
+
+ const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName)
+ let totalLikes = await cache.get(totalLikesKey)
+ // If a cahce entry was found for total likes increase by 1
+ if (totalLikes !== undefined) {
+ await cache.set(totalLikesKey, totalLikes + 1, CACHE_MAX_AGE)
+ } else {
+ const subjectRef = `${SUBJECT_REF_PREFIX}/${packageName}`
+ totalLikes = await this.constellationLikes(subjectRef)
+ }
+ // We already know the user has not liked the package so set in the cache
+ await cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), true, CACHE_MAX_AGE)
+ return {
+ totalLikes: totalLikes,
+ userHasLiked: true,
+ }
+ }
+}
diff --git a/server/utils/cache.ts b/server/utils/cache.ts
index 0526ff447..21910753b 100644
--- a/server/utils/cache.ts
+++ b/server/utils/cache.ts
@@ -93,7 +93,7 @@ export class RedisCacheAdatper implements CacheAdapter {
}
}
-export function getCache(prefix: string): CacheAdapter {
+export function getCacheAdatper(prefix: string): CacheAdapter {
const config = useRuntimeConfig()
if (!import.meta.dev && config.upstash?.redisRestUrl && config.upstash?.redisRestToken) {
@@ -103,7 +103,5 @@ export function getCache(prefix: string): CacheAdapter {
})
return new RedisCacheAdatper(redis, prefix)
}
-
- console.log('using storage')
return new StorageCacheAdapter()
}
diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts
index 582383508..91a764fc0 100644
--- a/shared/utils/constants.ts
+++ b/shared/utils/constants.ts
@@ -30,6 +30,10 @@ export const ERROR_GRAVATAR_EMAIL_UNAVAILABLE = "User's email not accessible."
export const CONSTELLATION_HOST = 'constellation.microcosm.blue'
export const SLINGSHOT_HOST = 'slingshot.microcosm.blue'
+// ATProtocol
+// Refrence prefix used to link packages to things that are not inherently atproto
+export const SUBJECT_REF_PREFIX = 'https://npmx.dev'
+
// Theming
export const ACCENT_COLORS = {
rose: 'oklch(0.797 0.084 11.056)',
From e708ea50ef8e335d7016521b9047c37fad82e50c Mon Sep 17 00:00:00 2001
From: Bailey Townsend
Date: Sun, 1 Feb 2026 18:12:30 -0600
Subject: [PATCH 03/19] made the get cache actually work
---
server/api/likes/[...pkg].get.ts | 23 +++++++++++--
server/utils/atproto/utils/likes.ts | 50 ++++++++++++++---------------
2 files changed, 45 insertions(+), 28 deletions(-)
diff --git a/server/api/likes/[...pkg].get.ts b/server/api/likes/[...pkg].get.ts
index 040201e4c..a43fbe0fc 100644
--- a/server/api/likes/[...pkg].get.ts
+++ b/server/api/likes/[...pkg].get.ts
@@ -1,4 +1,21 @@
-export default defineEventHandler(async _ => {
- //TODO: Use the new thing I wrote last night
- return 0
+export default eventHandlerWithOAuthSession(async (event, oAuthSession, _) => {
+ const packageName = getRouterParam(event, 'pkg')
+ if (!packageName) {
+ throw createError({
+ status: 400,
+ message: 'package name not provided',
+ })
+ }
+ const cachedFetch = event.context.cachedFetch
+ if (!cachedFetch) {
+ // TODO: Probably needs to add in a normal fetch if not provided
+ // but ideally should not happen
+ throw createError({
+ status: 500,
+ message: 'cachedFetch not provided in context',
+ })
+ }
+
+ const likesUtlil = new PackageLikesUtils(cachedFetch)
+ return await likesUtlil.getLikes(packageName, oAuthSession?.did.toString())
})
diff --git a/server/utils/atproto/utils/likes.ts b/server/utils/atproto/utils/likes.ts
index 9ce678a4f..606255605 100644
--- a/server/utils/atproto/utils/likes.ts
+++ b/server/utils/atproto/utils/likes.ts
@@ -15,11 +15,11 @@ export type PackageLikes = {
const CACHE_PREFIX = 'atproto-likes:'
const CACHE_PACKAGE_TOTAL_KEY = (packageName: string) => `${CACHE_PREFIX}:${packageName}:total`
const CACHE_USER_LIKES_KEY = (packageName: string, did: string) =>
- `${CACHE_PREFIX}${packageName}users:${did}`
+ `${CACHE_PREFIX}${packageName}:users:${did}`
const CACHE_MAX_AGE = CACHE_MAX_AGE_ONE_MINUTE * 5
-export class PackageLikesService {
+export class PackageLikesUtils {
private readonly constellation: Constellation
private readonly cache: CacheAdapter
@@ -74,35 +74,38 @@ export class PackageLikesService {
* @param usersDid
* @returns
*/
- async getLikes(packageName: string, usersDid?: string) {
+ async getLikes(packageName: string, usersDid?: string | undefined) {
//TODO: May need to do some clean up on the package name, and maybe even hash it? some of the charcteres may be a bit odd as keys
- const cache = getCacheAdatper(CACHE_PREFIX)
+ const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName)
+ const subjectRef = `${SUBJECT_REF_PREFIX}/${packageName}`
- const cachedLikes = await cache.get(packageName)
+ const cachedLikes = await this.cache.get(totalLikesKey)
+ let totalLikes = 0
if (cachedLikes) {
- return cachedLikes
+ totalLikes = cachedLikes
+ } else {
+ totalLikes = await this.constellationLikes(subjectRef)
+ await this.cache.set(totalLikesKey, totalLikes, CACHE_MAX_AGE)
}
- const subjectRef = `${SUBJECT_REF_PREFIX}/${packageName}`
-
- const totalLikes = await this.constellationLikes(subjectRef)
-
let userHasLiked = false
-
if (usersDid) {
- userHasLiked = await this.constellationUserHasLiked(subjectRef, usersDid)
+ const userCachedLike = await this.cache.get(
+ CACHE_USER_LIKES_KEY(packageName, usersDid),
+ )
+ if (userCachedLike) {
+ userHasLiked = userCachedLike
+ } else {
+ userHasLiked = await this.constellationUserHasLiked(subjectRef, usersDid)
+ await this.cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), true, CACHE_MAX_AGE)
+ }
}
const packageLikes = {
totalPackageLikes: totalLikes,
userHasLiked,
}
- if (userHasLiked && usersDid) {
- await cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), true, CACHE_MAX_AGE)
- }
- const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName)
- await cache.set(totalLikesKey, packageLikes.totalPackageLikes, CACHE_MAX_AGE)
return packageLikes
}
@@ -113,8 +116,7 @@ export class PackageLikesService {
* @returns
*/
async hasTheUserLikedThePackage(packageName: string, usersDid: string) {
- const cache = getCacheAdatper(CACHE_PREFIX)
- const cached = await cache.get(CACHE_USER_LIKES_KEY(packageName, usersDid))
+ const cached = await this.cache.get(CACHE_USER_LIKES_KEY(packageName, usersDid))
if (cached !== undefined) {
return cached
}
@@ -122,7 +124,7 @@ export class PackageLikesService {
`${SUBJECT_REF_PREFIX}/${packageName}`,
usersDid,
)
- await cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), userHasLiked, CACHE_MAX_AGE)
+ await this.cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), userHasLiked, CACHE_MAX_AGE)
return userHasLiked
}
@@ -133,19 +135,17 @@ export class PackageLikesService {
* @param usersDid
*/
async likeAPackageAndRetunLikes(packageName: string, usersDid: string): Promise {
- const cache = getCacheAdatper(CACHE_PREFIX)
-
const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName)
- let totalLikes = await cache.get(totalLikesKey)
+ let totalLikes = await this.cache.get(totalLikesKey)
// If a cahce entry was found for total likes increase by 1
if (totalLikes !== undefined) {
- await cache.set(totalLikesKey, totalLikes + 1, CACHE_MAX_AGE)
+ await this.cache.set(totalLikesKey, totalLikes + 1, CACHE_MAX_AGE)
} else {
const subjectRef = `${SUBJECT_REF_PREFIX}/${packageName}`
totalLikes = await this.constellationLikes(subjectRef)
}
// We already know the user has not liked the package so set in the cache
- await cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), true, CACHE_MAX_AGE)
+ await this.cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), true, CACHE_MAX_AGE)
return {
totalLikes: totalLikes,
userHasLiked: true,
From 551e01f3f560620adfd35dfc8fa363babd099c45 Mon Sep 17 00:00:00 2001
From: Bailey Townsend
Date: Sun, 1 Feb 2026 18:35:29 -0600
Subject: [PATCH 04/19] Should be proper read of likes on ui
---
app/pages/package/[...package].vue | 41 +++++++++++++++++++++++++++++
server/utils/atproto/utils/likes.ts | 14 ++++------
shared/utils/constants.ts | 5 ++--
3 files changed, 49 insertions(+), 11 deletions(-)
diff --git a/app/pages/package/[...package].vue b/app/pages/package/[...package].vue
index 734a7e060..78b59dbca 100644
--- a/app/pages/package/[...package].vue
+++ b/app/pages/package/[...package].vue
@@ -352,6 +352,27 @@ const canonicalUrl = computed(() => {
return requestedVersion.value ? `${base}/v/${requestedVersion.value}` : base
})
+//atproto
+const { user } = useAtproto()
+const showAuthModal = ref(false)
+
+const { data: likesData } = useFetch(() => `/api/likes/${packageName.value}`, {
+ default: () => ({ totalPackageLikes: 0, userHasLiked: false }),
+})
+
+// const { mutate: likePackage } = useLikePackage(subjectRef)
+
+const likeAction = async () => {
+ if (user.value?.handle == null) {
+ showAuthModal.value = true
+ } else {
+ // const result = await likePackage()
+ // if (result?.likes) {
+ // likesData.value = result.likes
+ // }
+ }
+}
+
useHead({
link: [{ rel: 'canonical', href: canonicalUrl }],
})
@@ -501,6 +522,26 @@ defineOgImageComponent('Package', {
+
+
{{ $t('common.go_back_home') }}
+
diff --git a/server/api/auth/social/like.post.ts b/server/api/auth/social/like.post.ts
new file mode 100644
index 000000000..9d6a1c818
--- /dev/null
+++ b/server/api/auth/social/like.post.ts
@@ -0,0 +1,60 @@
+import { Client } from '@atproto/lex'
+import { main as likeRecord } from '#shared/types/lexicons/dev/npmx/feed/like.defs'
+import * as dev from '#shared/types/lexicons/dev'
+import type { UriString } from '@atproto/lex'
+
+export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
+ const loggedInUsersDid = oAuthSession?.did.toString()
+
+ if (!oAuthSession || !loggedInUsersDid) {
+ throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
+ }
+
+ const body = await readBody<{ packageName: string }>(event)
+
+ if (!body.packageName) {
+ throw createError({
+ status: 400,
+ message: 'packageName is required',
+ })
+ }
+
+ const cachedFetch = event.context.cachedFetch
+ if (!cachedFetch) {
+ // TODO: Probably needs to add in a normal fetch if not provided
+ // but ideally should not happen
+ throw createError({
+ status: 500,
+ message: 'cachedFetch not provided in context',
+ })
+ }
+
+ const likesUtil = new PackageLikesUtils(cachedFetch)
+
+ const hasLiked = await likesUtil.hasTheUserLikedThePackage(body.packageName, loggedInUsersDid)
+ if (hasLiked) {
+ throw createError({
+ status: 400,
+ message: 'User has already liked the package',
+ })
+ }
+
+ const subjectRef = PACKAGE_SUBJECT_REF(body.packageName)
+ const client = new Client(oAuthSession)
+
+ const like = dev.npmx.feed.like.$build({
+ createdAt: new Date().toISOString(),
+ //TODO test this?
+ subjectRef: subjectRef as UriString,
+ })
+
+ const result = await client.create(likeRecord, like)
+ if (!result) {
+ throw createError({
+ status: 500,
+ message: 'Failed to create like',
+ })
+ }
+
+ return await likesUtil.likeAPackageAndRetunLikes(body.packageName, loggedInUsersDid)
+})
diff --git a/server/api/likes/[...pkg].get.ts b/server/api/social/likes/[...pkg].get.ts
similarity index 80%
rename from server/api/likes/[...pkg].get.ts
rename to server/api/social/likes/[...pkg].get.ts
index a43fbe0fc..e6847babc 100644
--- a/server/api/likes/[...pkg].get.ts
+++ b/server/api/social/likes/[...pkg].get.ts
@@ -16,6 +16,6 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession, _) => {
})
}
- const likesUtlil = new PackageLikesUtils(cachedFetch)
- return await likesUtlil.getLikes(packageName, oAuthSession?.did.toString())
+ const likesUtil = new PackageLikesUtils(cachedFetch)
+ return await likesUtil.getLikes(packageName, oAuthSession?.did.toString())
})
diff --git a/server/utils/atproto/oauth.ts b/server/utils/atproto/oauth.ts
index fed470666..bd15ae130 100644
--- a/server/utils/atproto/oauth.ts
+++ b/server/utils/atproto/oauth.ts
@@ -9,7 +9,7 @@ import { OAuthMetadataSchema } from '#shared/schemas/oauth'
// @ts-expect-error virtual file from oauth module
import { clientUri } from '#oauth/config'
// TODO: limit scope as features gets added. atproto just allows login so no scary login screen till we have scopes
-export const scope = 'atproto'
+export const scope = 'atproto repo:dev.npmx.feed.like'
export function getOauthClientMetadata() {
const dev = import.meta.dev
diff --git a/server/utils/atproto/utils/likes.ts b/server/utils/atproto/utils/likes.ts
index 56e7ddb51..9bae7a533 100644
--- a/server/utils/atproto/utils/likes.ts
+++ b/server/utils/atproto/utils/likes.ts
@@ -94,16 +94,18 @@ export class PackageLikesUtils {
userHasLiked = userCachedLike
} else {
userHasLiked = await this.constellationUserHasLiked(subjectRef, usersDid)
- await this.cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), true, CACHE_MAX_AGE)
+ await this.cache.set(
+ CACHE_USER_LIKES_KEY(packageName, usersDid),
+ userHasLiked,
+ CACHE_MAX_AGE,
+ )
}
}
- const packageLikes = {
- totalPackageLikes: totalLikes,
+ return {
+ totalLikes: totalLikes,
userHasLiked,
- }
-
- return packageLikes
+ } as PackageLikes
}
/**
@@ -132,19 +134,19 @@ export class PackageLikesUtils {
*/
async likeAPackageAndRetunLikes(packageName: string, usersDid: string): Promise {
const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName)
+ const subjectRef = PACKAGE_SUBJECT_REF(packageName)
+
let totalLikes = await this.cache.get(totalLikesKey)
- // If a cahce entry was found for total likes increase by 1
- if (totalLikes !== undefined) {
- await this.cache.set(totalLikesKey, totalLikes + 1, CACHE_MAX_AGE)
- } else {
- const subjectRef = PACKAGE_SUBJECT_REF(packageName)
+ if (!totalLikes) {
totalLikes = await this.constellationLikes(subjectRef)
+ totalLikes = totalLikes + 1
+ await this.cache.set(totalLikesKey, totalLikes, CACHE_MAX_AGE)
}
// We already know the user has not liked the package so set in the cache
await this.cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), true, CACHE_MAX_AGE)
return {
totalLikes: totalLikes,
userHasLiked: true,
- }
+ } as PackageLikes
}
}
From 26411a9b41df2fe1182235b2a5cf496cff9f7cc9 Mon Sep 17 00:00:00 2001
From: Bailey Townsend
Date: Sun, 1 Feb 2026 21:02:05 -0600
Subject: [PATCH 06/19] wip
---
server/api/auth/session.get.ts | 7 +++++++
server/api/auth/social/like.post.ts | 14 ++++++++++++--
server/utils/atproto/oauth.ts | 5 +++--
shared/utils/constants.ts | 5 +++++
4 files changed, 27 insertions(+), 4 deletions(-)
diff --git a/server/api/auth/session.get.ts b/server/api/auth/session.get.ts
index 64903ca70..4e348f05a 100644
--- a/server/api/auth/session.get.ts
+++ b/server/api/auth/session.get.ts
@@ -7,5 +7,12 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSe
return null
}
+ if (oAuthSession) {
+ let tokenInfo = await oAuthSession.getTokenInfo()
+ console.log('scopes', tokenInfo.scope)
+
+ // return null
+ }
+
return result.output
})
diff --git a/server/api/auth/social/like.post.ts b/server/api/auth/social/like.post.ts
index 9d6a1c818..38741007a 100644
--- a/server/api/auth/social/like.post.ts
+++ b/server/api/auth/social/like.post.ts
@@ -1,7 +1,8 @@
import { Client } from '@atproto/lex'
-import { main as likeRecord } from '#shared/types/lexicons/dev/npmx/feed/like.defs'
+// import { main as likeRecord } from '#shared/types/lexicons/dev/npmx/feed/like.defs'
import * as dev from '#shared/types/lexicons/dev'
import type { UriString } from '@atproto/lex'
+import { ERROR_NEED_REAUTH, LIKES_SCOPE } from '~~/shared/utils/constants'
export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
const loggedInUsersDid = oAuthSession?.did.toString()
@@ -39,6 +40,15 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
})
}
+ //Checks if the user has a scope to like packages
+ const tokenInfo = await oAuthSession.getTokenInfo()
+ if (!tokenInfo.scope.includes(LIKES_SCOPE)) {
+ throw createError({
+ status: 403,
+ message: ERROR_NEED_REAUTH,
+ })
+ }
+
const subjectRef = PACKAGE_SUBJECT_REF(body.packageName)
const client = new Client(oAuthSession)
@@ -48,7 +58,7 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
subjectRef: subjectRef as UriString,
})
- const result = await client.create(likeRecord, like)
+ const result = await client.create(dev.npmx.feed.like, like)
if (!result) {
throw createError({
status: 500,
diff --git a/server/utils/atproto/oauth.ts b/server/utils/atproto/oauth.ts
index bd15ae130..44a7f808c 100644
--- a/server/utils/atproto/oauth.ts
+++ b/server/utils/atproto/oauth.ts
@@ -4,12 +4,13 @@ import { NodeOAuthClient } from '@atproto/oauth-client-node'
import { parse } from 'valibot'
import { getOAuthLock } from '#server/utils/atproto/lock'
import { useOAuthStorage } from '#server/utils/atproto/storage'
-import { UNSET_NUXT_SESSION_PASSWORD } from '#shared/utils/constants'
+import { LIKES_SCOPE, UNSET_NUXT_SESSION_PASSWORD } from '#shared/utils/constants'
import { OAuthMetadataSchema } from '#shared/schemas/oauth'
// @ts-expect-error virtual file from oauth module
import { clientUri } from '#oauth/config'
// TODO: limit scope as features gets added. atproto just allows login so no scary login screen till we have scopes
-export const scope = 'atproto repo:dev.npmx.feed.like'
+// export const scope = 'atproto'
+export const scope = `atproto ${LIKES_SCOPE}`
export function getOauthClientMetadata() {
const dev = import.meta.dev
diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts
index c2171be76..1e85d3009 100644
--- a/shared/utils/constants.ts
+++ b/shared/utils/constants.ts
@@ -1,3 +1,5 @@
+import * as dev from '#shared/types/lexicons/dev'
+
// Duration
export const CACHE_MAX_AGE_ONE_MINUTE = 60
export const CACHE_MAX_AGE_FIVE_MINUTES = 60 * 5
@@ -25,6 +27,7 @@ export const ERROR_SKILL_FILE_NOT_FOUND = 'Skill file not found.'
export const ERROR_GRAVATAR_FETCH_FAILED = 'Failed to fetch Gravatar profile.'
/** @public */
export const ERROR_GRAVATAR_EMAIL_UNAVAILABLE = "User's email not accessible."
+export const ERROR_NEED_REAUTH = 'User needs to reauthenticate'
// microcosm services
export const CONSTELLATION_HOST = 'constellation.microcosm.blue'
@@ -34,6 +37,8 @@ export const SLINGSHOT_HOST = 'slingshot.microcosm.blue'
// Refrences used to link packages to things that are not inherently atproto
export const PACKAGE_SUBJECT_REF = (packageName: string) =>
`https://npmx.dev/package/${packageName}`
+// 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
+export const LIKES_SCOPE = `repo:${dev.npmx.feed.like.$nsid}`
// Theming
export const ACCENT_COLORS = {
From cd6207c753dea1b105cb3b4a76b37113efa5da8a Mon Sep 17 00:00:00 2001
From: Bailey Townsend
Date: Sun, 1 Feb 2026 21:26:03 -0600
Subject: [PATCH 07/19] scope upgrade works
---
app/components/Header/AuthModal.client.vue | 26 ++++-------------
app/composables/useAtproto.ts | 33 ++++++++++++++++++++--
app/pages/package/[...package].vue | 7 +++--
3 files changed, 39 insertions(+), 27 deletions(-)
diff --git a/app/components/Header/AuthModal.client.vue b/app/components/Header/AuthModal.client.vue
index ada2811f9..bf275f551 100644
--- a/app/components/Header/AuthModal.client.vue
+++ b/app/components/Header/AuthModal.client.vue
@@ -1,37 +1,21 @@
diff --git a/app/composables/useAtproto.ts b/app/composables/useAtproto.ts
index 7d8d4afe2..badb1d396 100644
--- a/app/composables/useAtproto.ts
+++ b/app/composables/useAtproto.ts
@@ -1,6 +1,31 @@
+import { ERROR_NEED_REAUTH } from '#imports'
+import type { FetchError } from 'ofetch'
import type { UserSession } from '#shared/schemas/userSession'
import type { PackageLikes } from '~~/server/utils/atproto/utils/likes'
+export async function authRedirect(identifier: string, create: boolean = false) {
+ let query = { handle: identifier } as {}
+ if (create) {
+ query = { ...query, create: 'true' }
+ }
+ await navigateTo(
+ {
+ path: '/api/auth/atproto',
+ query,
+ },
+ { external: true },
+ )
+}
+
+export async function handleAuthError(e: unknown, userHandle?: string | null): Promise {
+ const fetchError = e as FetchError
+ const errorMessage = fetchError?.data?.message
+ if (errorMessage === ERROR_NEED_REAUTH && userHandle) {
+ await authRedirect(userHandle)
+ }
+ throw e
+}
+
export function useAtproto() {
const { data: user, pending, clear } = useFetch('/api/auth/session')
@@ -16,6 +41,7 @@ export function useAtproto() {
}
export function useLikePackage(packageName: string) {
+ const { user } = useAtproto()
const data = ref(null)
const error = ref(null)
const pending = ref(false)
@@ -25,15 +51,16 @@ export function useLikePackage(packageName: string) {
error.value = null
try {
- const result = await $fetch('/api/auth/social/like', {
+ const result = await $fetch('/api/auth/social/like', {
method: 'POST',
body: { packageName },
})
+
data.value = result
return result
} catch (e) {
- error.value = e as Error
- throw e
+ error.value = e as FetchError
+ await handleAuthError(e, user.value?.handle)
} finally {
pending.value = false
}
diff --git a/app/pages/package/[...package].vue b/app/pages/package/[...package].vue
index 78ec2fe89..38a3a53e3 100644
--- a/app/pages/package/[...package].vue
+++ b/app/pages/package/[...package].vue
@@ -12,6 +12,7 @@ import { areUrlsEquivalent } from '#shared/utils/url'
import { isEditableElement } from '~/utils/input'
import { formatBytes } from '~/utils/formatters'
import { NuxtLink } from '#components'
+import { useModal } from '~/composables/useModal'
definePageMeta({
name: 'package',
@@ -354,7 +355,8 @@ const canonicalUrl = computed(() => {
//atproto
const { user } = useAtproto()
-const showAuthModal = ref(false)
+
+const authModal = useModal('auth-modal')
const { data: likesData } = useFetch(() => `/api/social/likes/${packageName.value}`, {
default: () => ({ totalLikes: 0, userHasLiked: false }),
@@ -364,7 +366,7 @@ const { mutate: likePackage } = useLikePackage(packageName.value)
const likeAction = async () => {
if (user.value?.handle == null) {
- showAuthModal.value = true
+ authModal.open()
} else {
const result = await likePackage()
if (result?.totalLikes) {
@@ -1111,7 +1113,6 @@ defineOgImageComponent('Package', {
{{ $t('common.go_back_home') }}
-
From 784bcc77a1f8ea249e1decdd5b8d501f4bab7b41 Mon Sep 17 00:00:00 2001
From: Bailey Townsend
Date: Sun, 1 Feb 2026 21:34:22 -0600
Subject: [PATCH 08/19] feels so close
---
app/composables/useAtproto.ts | 16 ++++++++
app/pages/package/[...package].vue | 5 ++-
server/api/auth/session.get.ts | 7 ----
server/api/auth/social/like.delete.ts | 49 ++++++++++++++++++++++++
server/api/auth/social/like.post.ts | 15 ++------
server/utils/atproto/oauth.ts | 19 ++++++++-
server/utils/atproto/utils/likes.ts | 55 +++++++++++++++++++++++++++
7 files changed, 144 insertions(+), 22 deletions(-)
create mode 100644 server/api/auth/social/like.delete.ts
diff --git a/app/composables/useAtproto.ts b/app/composables/useAtproto.ts
index badb1d396..777f8838d 100644
--- a/app/composables/useAtproto.ts
+++ b/app/composables/useAtproto.ts
@@ -68,3 +68,19 @@ export function useLikePackage(packageName: string) {
return { data, error, pending, mutate }
}
+
+export function useUnlikePackage(packageName: string) {
+ const { user } = useAtproto()
+
+ const { data, error, pending, execute } = useFetch('/api/auth/social/like', {
+ method: 'DELETE',
+ body: { packageName },
+ immediate: false,
+ watch: false,
+ onResponseError: async ({ error: e }) => {
+ await handleAuthError(e, user.value?.handle)
+ },
+ })
+
+ return { data, error, pending, mutate: execute }
+}
diff --git a/app/pages/package/[...package].vue b/app/pages/package/[...package].vue
index 38a3a53e3..9a2a768a6 100644
--- a/app/pages/package/[...package].vue
+++ b/app/pages/package/[...package].vue
@@ -363,13 +363,14 @@ const { data: likesData } = useFetch(() => `/api/social/likes/${packageName.valu
})
const { mutate: likePackage } = useLikePackage(packageName.value)
+const { mutate: unlikePackage } = useUnlikePackage(packageName.value)
const likeAction = async () => {
if (user.value?.handle == null) {
authModal.open()
} else {
- const result = await likePackage()
- if (result?.totalLikes) {
+ const result = likesData.value?.userHasLiked ? await unlikePackage() : await likePackage()
+ if (result?.totalLikes != null) {
likesData.value = result
}
}
diff --git a/server/api/auth/session.get.ts b/server/api/auth/session.get.ts
index 4e348f05a..64903ca70 100644
--- a/server/api/auth/session.get.ts
+++ b/server/api/auth/session.get.ts
@@ -7,12 +7,5 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSe
return null
}
- if (oAuthSession) {
- let tokenInfo = await oAuthSession.getTokenInfo()
- console.log('scopes', tokenInfo.scope)
-
- // return null
- }
-
return result.output
})
diff --git a/server/api/auth/social/like.delete.ts b/server/api/auth/social/like.delete.ts
new file mode 100644
index 000000000..749998588
--- /dev/null
+++ b/server/api/auth/social/like.delete.ts
@@ -0,0 +1,49 @@
+import { Client } from '@atproto/lex'
+import * as dev from '#shared/types/lexicons/dev'
+import { LIKES_SCOPE } from '~~/shared/utils/constants'
+import { checkOAuthScope } from '~~/server/utils/atproto/oauth'
+
+export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
+ const loggedInUsersDid = oAuthSession?.did.toString()
+
+ if (!oAuthSession || !loggedInUsersDid) {
+ throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
+ }
+
+ const body = await readBody<{ packageName: string }>(event)
+
+ if (!body.packageName) {
+ throw createError({
+ status: 400,
+ message: 'packageName is required',
+ })
+ }
+
+ const cachedFetch = event.context.cachedFetch
+ if (!cachedFetch) {
+ // TODO: Probably needs to add in a normal fetch if not provided
+ // but ideally should not happen
+ throw createError({
+ status: 500,
+ message: 'cachedFetch not provided in context',
+ })
+ }
+
+ const likesUtil = new PackageLikesUtils(cachedFetch)
+
+ const getTheUsersLikedRecord = await likesUtil.getTheUsersLikedRecord(
+ body.packageName,
+ loggedInUsersDid,
+ )
+ if (getTheUsersLikedRecord) {
+ //Checks if the user has a scope to like packages
+ await checkOAuthScope(oAuthSession, LIKES_SCOPE)
+ const client = new Client(oAuthSession)
+
+ var result = await client.delete(dev.npmx.feed.like, {
+ rkey: getTheUsersLikedRecord.rkey,
+ })
+ console.log(result)
+ return await likesUtil.unlikeAPackageAndReturnLikes(body.packageName, loggedInUsersDid)
+ }
+})
diff --git a/server/api/auth/social/like.post.ts b/server/api/auth/social/like.post.ts
index 38741007a..93db37522 100644
--- a/server/api/auth/social/like.post.ts
+++ b/server/api/auth/social/like.post.ts
@@ -1,8 +1,8 @@
import { Client } from '@atproto/lex'
-// import { main as likeRecord } from '#shared/types/lexicons/dev/npmx/feed/like.defs'
import * as dev from '#shared/types/lexicons/dev'
import type { UriString } from '@atproto/lex'
-import { ERROR_NEED_REAUTH, LIKES_SCOPE } from '~~/shared/utils/constants'
+import { LIKES_SCOPE } from '~~/shared/utils/constants'
+import { checkOAuthScope } from '~~/server/utils/atproto/oauth'
export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
const loggedInUsersDid = oAuthSession?.did.toString()
@@ -41,20 +41,13 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
}
//Checks if the user has a scope to like packages
- const tokenInfo = await oAuthSession.getTokenInfo()
- if (!tokenInfo.scope.includes(LIKES_SCOPE)) {
- throw createError({
- status: 403,
- message: ERROR_NEED_REAUTH,
- })
- }
+ await checkOAuthScope(oAuthSession, LIKES_SCOPE)
const subjectRef = PACKAGE_SUBJECT_REF(body.packageName)
const client = new Client(oAuthSession)
const like = dev.npmx.feed.like.$build({
createdAt: new Date().toISOString(),
- //TODO test this?
subjectRef: subjectRef as UriString,
})
@@ -62,7 +55,7 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
if (!result) {
throw createError({
status: 500,
- message: 'Failed to create like',
+ message: 'Failed to create a like',
})
}
diff --git a/server/utils/atproto/oauth.ts b/server/utils/atproto/oauth.ts
index 44a7f808c..b6dee5f60 100644
--- a/server/utils/atproto/oauth.ts
+++ b/server/utils/atproto/oauth.ts
@@ -8,8 +8,7 @@ import { LIKES_SCOPE, UNSET_NUXT_SESSION_PASSWORD } from '#shared/utils/constant
import { OAuthMetadataSchema } from '#shared/schemas/oauth'
// @ts-expect-error virtual file from oauth module
import { clientUri } from '#oauth/config'
-// TODO: limit scope as features gets added. atproto just allows login so no scary login screen till we have scopes
-// export const scope = 'atproto'
+// TODO: If you add writing a new record you will need to add a scope for it
export const scope = `atproto ${LIKES_SCOPE}`
export function getOauthClientMetadata() {
@@ -61,6 +60,22 @@ async function getOAuthSession(event: H3Event): Promise(
handler: EventHandlerWithOAuthSession,
) {
diff --git a/server/utils/atproto/utils/likes.ts b/server/utils/atproto/utils/likes.ts
index 9bae7a533..914ce2702 100644
--- a/server/utils/atproto/utils/likes.ts
+++ b/server/utils/atproto/utils/likes.ts
@@ -1,5 +1,6 @@
import { getCacheAdatper } from '../../cache'
import { $nsid as likeNsid } from '#shared/types/lexicons/dev/npmx/feed/like.defs'
+import type { Backlink } from '~~/shared/utils/constellation'
/**
* Likes for a npm package on npmx
@@ -40,6 +41,8 @@ export class PackageLikesUtils {
'.subjectRef',
//Limit doesn't matter here since we are just counting the total likes
1,
+ undefined,
+ 0,
)
return totalLinks.total
}
@@ -60,6 +63,7 @@ export class PackageLikesUtils {
undefined,
false,
[[usersDid]],
+ 0,
)
//TODO: need to double check this logic
return userLikes.total > 0
@@ -149,4 +153,55 @@ export class PackageLikesUtils {
userHasLiked: true,
} as PackageLikes
}
+
+ /**
+ * We need to get the record the user has that they liked the package
+ * @param packageName
+ * @param usersDid
+ * @returns
+ */
+ async getTheUsersLikedRecord(
+ packageName: string,
+ usersDid: string,
+ ): Promise {
+ const subjectRef = PACKAGE_SUBJECT_REF(packageName)
+ const { data: userLikes } = await this.constellation.getBackLinks(
+ subjectRef,
+ likeNsid,
+ 'subjectRef',
+ //Limit doesn't matter here since we are just counting the total likes
+ 1,
+ undefined,
+ false,
+ [[usersDid]],
+ 0,
+ )
+ if (userLikes.total > 0 && userLikes.records.length > 0) {
+ return userLikes.records[0]
+ }
+ }
+
+ /**
+ * At this point you should have checked if the user had a record for the package on the network and removed it before updating the cache
+ * @param packageName
+ * @param usersDid
+ * @returns
+ */
+ async unlikeAPackageAndReturnLikes(packageName: string, usersDid: string): Promise {
+ const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName)
+ const subjectRef = PACKAGE_SUBJECT_REF(packageName)
+
+ let totalLikes = await this.cache.get(totalLikesKey)
+ if (!totalLikes) {
+ totalLikes = await this.constellationLikes(subjectRef)
+ }
+ totalLikes = totalLikes - 1
+ await this.cache.set(totalLikesKey, totalLikes, CACHE_MAX_AGE)
+
+ await this.cache.delete(CACHE_USER_LIKES_KEY(packageName, usersDid))
+ return {
+ totalLikes: totalLikes,
+ userHasLiked: false,
+ } as PackageLikes
+ }
}
From b3d3995e2caf51fd3d611a0b917c3cf1f09188a9 Mon Sep 17 00:00:00 2001
From: Bailey Townsend
Date: Sun, 1 Feb 2026 23:25:10 -0600
Subject: [PATCH 09/19] rebase?
---
app/composables/useAtproto.ts | 31 ++++++++++-----
server/api/auth/social/like.delete.ts | 18 ++-------
server/api/auth/social/like.post.ts | 14 +------
server/api/social/likes/[...pkg].get.ts | 11 +----
server/utils/atproto/utils/likes.ts | 53 +++++++++++++++++++++----
5 files changed, 75 insertions(+), 52 deletions(-)
diff --git a/app/composables/useAtproto.ts b/app/composables/useAtproto.ts
index 777f8838d..b4b806a93 100644
--- a/app/composables/useAtproto.ts
+++ b/app/composables/useAtproto.ts
@@ -71,16 +71,29 @@ export function useLikePackage(packageName: string) {
export function useUnlikePackage(packageName: string) {
const { user } = useAtproto()
+ const data = ref(null)
+ const error = ref(null)
+ const pending = ref(false)
+
+ const mutate = async () => {
+ pending.value = true
+ error.value = null
+
+ try {
+ const result = await $fetch('/api/auth/social/like', {
+ method: 'DELETE',
+ body: { packageName },
+ })
- const { data, error, pending, execute } = useFetch('/api/auth/social/like', {
- method: 'DELETE',
- body: { packageName },
- immediate: false,
- watch: false,
- onResponseError: async ({ error: e }) => {
+ data.value = result
+ return result
+ } catch (e) {
+ error.value = e as FetchError
await handleAuthError(e, user.value?.handle)
- },
- })
+ } finally {
+ pending.value = false
+ }
+ }
- return { data, error, pending, mutate: execute }
+ return { data, error, pending, mutate }
}
diff --git a/server/api/auth/social/like.delete.ts b/server/api/auth/social/like.delete.ts
index 749998588..cd343a936 100644
--- a/server/api/auth/social/like.delete.ts
+++ b/server/api/auth/social/like.delete.ts
@@ -19,17 +19,7 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
})
}
- const cachedFetch = event.context.cachedFetch
- if (!cachedFetch) {
- // TODO: Probably needs to add in a normal fetch if not provided
- // but ideally should not happen
- throw createError({
- status: 500,
- message: 'cachedFetch not provided in context',
- })
- }
-
- const likesUtil = new PackageLikesUtils(cachedFetch)
+ const likesUtil = new PackageLikesUtils()
const getTheUsersLikedRecord = await likesUtil.getTheUsersLikedRecord(
body.packageName,
@@ -40,10 +30,10 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
await checkOAuthScope(oAuthSession, LIKES_SCOPE)
const client = new Client(oAuthSession)
- var result = await client.delete(dev.npmx.feed.like, {
+ await client.delete(dev.npmx.feed.like, {
rkey: getTheUsersLikedRecord.rkey,
})
- console.log(result)
- return await likesUtil.unlikeAPackageAndReturnLikes(body.packageName, loggedInUsersDid)
+ var result = await likesUtil.unlikeAPackageAndReturnLikes(body.packageName, loggedInUsersDid)
+ return result
}
})
diff --git a/server/api/auth/social/like.post.ts b/server/api/auth/social/like.post.ts
index 93db37522..75cb8c377 100644
--- a/server/api/auth/social/like.post.ts
+++ b/server/api/auth/social/like.post.ts
@@ -20,17 +20,7 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
})
}
- const cachedFetch = event.context.cachedFetch
- if (!cachedFetch) {
- // TODO: Probably needs to add in a normal fetch if not provided
- // but ideally should not happen
- throw createError({
- status: 500,
- message: 'cachedFetch not provided in context',
- })
- }
-
- const likesUtil = new PackageLikesUtils(cachedFetch)
+ const likesUtil = new PackageLikesUtils()
const hasLiked = await likesUtil.hasTheUserLikedThePackage(body.packageName, loggedInUsersDid)
if (hasLiked) {
@@ -59,5 +49,5 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
})
}
- return await likesUtil.likeAPackageAndRetunLikes(body.packageName, loggedInUsersDid)
+ return await likesUtil.likeAPackageAndRetunLikes(body.packageName, loggedInUsersDid, result.uri)
})
diff --git a/server/api/social/likes/[...pkg].get.ts b/server/api/social/likes/[...pkg].get.ts
index e6847babc..b0da21675 100644
--- a/server/api/social/likes/[...pkg].get.ts
+++ b/server/api/social/likes/[...pkg].get.ts
@@ -6,16 +6,7 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession, _) => {
message: 'package name not provided',
})
}
- const cachedFetch = event.context.cachedFetch
- if (!cachedFetch) {
- // TODO: Probably needs to add in a normal fetch if not provided
- // but ideally should not happen
- throw createError({
- status: 500,
- message: 'cachedFetch not provided in context',
- })
- }
- const likesUtil = new PackageLikesUtils(cachedFetch)
+ const likesUtil = new PackageLikesUtils()
return await likesUtil.getLikes(packageName, oAuthSession?.did.toString())
})
diff --git a/server/utils/atproto/utils/likes.ts b/server/utils/atproto/utils/likes.ts
index 914ce2702..bb02e5c4c 100644
--- a/server/utils/atproto/utils/likes.ts
+++ b/server/utils/atproto/utils/likes.ts
@@ -12,19 +12,35 @@ export type PackageLikes = {
userHasLiked: boolean
}
+//Cache keys and helpers
const CACHE_PREFIX = 'atproto-likes:'
const CACHE_PACKAGE_TOTAL_KEY = (packageName: string) => `${CACHE_PREFIX}:${packageName}:total`
const CACHE_USER_LIKES_KEY = (packageName: string, did: string) =>
- `${CACHE_PREFIX}${packageName}:users:${did}`
+ `${CACHE_PREFIX}${packageName}:users:${did}:liked`
+const CACHE_USERS_BACK_LINK = (packageName: string, did: string) =>
+ `${CACHE_PREFIX}${packageName}:users:${did}:backlink`
const CACHE_MAX_AGE = CACHE_MAX_AGE_ONE_MINUTE * 5
+/**
+ * Logic to handle liking, unliking, and seeing if a user has liked a package on npmx
+ */
export class PackageLikesUtils {
private readonly constellation: Constellation
private readonly cache: CacheAdapter
- constructor(cachedFunction: CachedFetchFunction) {
- this.constellation = new Constellation(cachedFunction)
+ constructor() {
+ this.constellation = new Constellation(
+ // Passes in a fetch wrapped as cachedfetch since are already doing some heavy caching here
+ async (
+ url: string,
+ options: Parameters[1] = {},
+ _ttl?: number,
+ ): Promise> => {
+ const data = (await $fetch(url, options)) as T
+ return { data, isStale: false, cachedAt: null }
+ },
+ )
this.cache = getCacheAdatper(CACHE_PREFIX)
}
@@ -34,7 +50,6 @@ export class PackageLikesUtils {
* @returns
*/
private async constellationLikes(subjectRef: string) {
- // TODO: I need to see what failed fetch calls do here
const { data: totalLinks } = await this.constellation.getLinksDistinctDids(
subjectRef,
likeNsid,
@@ -65,7 +80,6 @@ export class PackageLikesUtils {
[[usersDid]],
0,
)
- //TODO: need to double check this logic
return userLikes.total > 0
}
@@ -135,18 +149,34 @@ export class PackageLikesUtils {
* to the user's atproto repostiory
* @param packageName
* @param usersDid
+ * @param atUri - The URI of the like record
*/
- async likeAPackageAndRetunLikes(packageName: string, usersDid: string): Promise {
+ async likeAPackageAndRetunLikes(
+ packageName: string,
+ usersDid: string,
+ atUri: string,
+ ): Promise {
const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName)
const subjectRef = PACKAGE_SUBJECT_REF(packageName)
+ const splitAtUri = atUri.replace('at://', '').split('/')
+ const backLink = {
+ did: usersDid,
+ collection: splitAtUri[1],
+ rkey: splitAtUri[2],
+ } as Backlink
+
+ // We store the backlink incase a user is liking and unlikign rapidly. constellation takes a few seconds to capture the backlink
+ const usersBackLinkKey = CACHE_USERS_BACK_LINK(packageName, usersDid)
+ await this.cache.set(usersBackLinkKey, backLink, CACHE_MAX_AGE)
+
let totalLikes = await this.cache.get(totalLikesKey)
if (!totalLikes) {
totalLikes = await this.constellationLikes(subjectRef)
totalLikes = totalLikes + 1
await this.cache.set(totalLikesKey, totalLikes, CACHE_MAX_AGE)
}
- // We already know the user has not liked the package so set in the cache
+ // We already know the user has not liked the package before so set in the cache
await this.cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), true, CACHE_MAX_AGE)
return {
totalLikes: totalLikes,
@@ -164,6 +194,12 @@ export class PackageLikesUtils {
packageName: string,
usersDid: string,
): Promise {
+ const usersBackLinkKey = CACHE_USERS_BACK_LINK(packageName, usersDid)
+ const backLink = await this.cache.get(usersBackLinkKey)
+ if (backLink) {
+ return backLink
+ }
+
const subjectRef = PACKAGE_SUBJECT_REF(packageName)
const { data: userLikes } = await this.constellation.getBackLinks(
subjectRef,
@@ -198,7 +234,10 @@ export class PackageLikesUtils {
totalLikes = totalLikes - 1
await this.cache.set(totalLikesKey, totalLikes, CACHE_MAX_AGE)
+ //Clean up
await this.cache.delete(CACHE_USER_LIKES_KEY(packageName, usersDid))
+ await this.cache.delete(CACHE_USERS_BACK_LINK(packageName, usersDid))
+
return {
totalLikes: totalLikes,
userHasLiked: false,
From a282987656d8b4a5add3acb39770fa3ce5544838 Mon Sep 17 00:00:00 2001
From: Bailey Townsend
Date: Mon, 2 Feb 2026 00:15:16 -0600
Subject: [PATCH 10/19] Hopefully resolves the tests?
---
nuxt.config.ts | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 2cbb8e80c..d9705d7a4 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -274,6 +274,11 @@ export default defineNuxtConfig({
'virtua/vue',
'semver',
'validate-npm-package-name',
+ '@atproto/lex',
+ '@atproto/lex-data',
+ '@atproto/lex-json',
+ '@atproto/lex-schema',
+ '@atproto/lex-client',
],
},
},
From 53b3268a2ff1cb8aec3244a3ac86592725c2bdb2 Mon Sep 17 00:00:00 2001
From: Bailey Townsend
Date: Mon, 2 Feb 2026 07:51:47 -0600
Subject: [PATCH 11/19] Some small changes
---
app/composables/useAtproto.ts | 2 +-
server/api/auth/social/like.delete.ts | 9 +++++----
server/api/auth/social/like.post.ts | 12 ++++++------
server/utils/atproto/oauth.ts | 3 ++-
server/utils/atproto/utils/likes.ts | 10 +++++-----
5 files changed, 19 insertions(+), 17 deletions(-)
diff --git a/app/composables/useAtproto.ts b/app/composables/useAtproto.ts
index b4b806a93..d1791ee41 100644
--- a/app/composables/useAtproto.ts
+++ b/app/composables/useAtproto.ts
@@ -1,7 +1,7 @@
import { ERROR_NEED_REAUTH } from '#imports'
import type { FetchError } from 'ofetch'
import type { UserSession } from '#shared/schemas/userSession'
-import type { PackageLikes } from '~~/server/utils/atproto/utils/likes'
+import type { PackageLikes } from '#server/utils/atproto/utils/likes'
export async function authRedirect(identifier: string, create: boolean = false) {
let query = { handle: identifier } as {}
diff --git a/server/api/auth/social/like.delete.ts b/server/api/auth/social/like.delete.ts
index cd343a936..508b20d73 100644
--- a/server/api/auth/social/like.delete.ts
+++ b/server/api/auth/social/like.delete.ts
@@ -1,7 +1,6 @@
import { Client } from '@atproto/lex'
import * as dev from '#shared/types/lexicons/dev'
-import { LIKES_SCOPE } from '~~/shared/utils/constants'
-import { checkOAuthScope } from '~~/server/utils/atproto/oauth'
+import { throwOnMissingOAuthScope } from '#server/utils/atproto/oauth'
export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
const loggedInUsersDid = oAuthSession?.did.toString()
@@ -10,6 +9,9 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
+ //Checks if the user has a scope to like packages
+ await throwOnMissingOAuthScope(oAuthSession, LIKES_SCOPE)
+
const body = await readBody<{ packageName: string }>(event)
if (!body.packageName) {
@@ -25,9 +27,8 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
body.packageName,
loggedInUsersDid,
)
+
if (getTheUsersLikedRecord) {
- //Checks if the user has a scope to like packages
- await checkOAuthScope(oAuthSession, LIKES_SCOPE)
const client = new Client(oAuthSession)
await client.delete(dev.npmx.feed.like, {
diff --git a/server/api/auth/social/like.post.ts b/server/api/auth/social/like.post.ts
index 75cb8c377..f779fd717 100644
--- a/server/api/auth/social/like.post.ts
+++ b/server/api/auth/social/like.post.ts
@@ -1,8 +1,8 @@
import { Client } from '@atproto/lex'
import * as dev from '#shared/types/lexicons/dev'
import type { UriString } from '@atproto/lex'
-import { LIKES_SCOPE } from '~~/shared/utils/constants'
-import { checkOAuthScope } from '~~/server/utils/atproto/oauth'
+import { LIKES_SCOPE } from '#shared/utils/constants'
+import { throwOnMissingOAuthScope } from '~~/server/utils/atproto/oauth'
export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
const loggedInUsersDid = oAuthSession?.did.toString()
@@ -11,6 +11,9 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
+ //Checks if the user has a scope to like packages
+ await throwOnMissingOAuthScope(oAuthSession, LIKES_SCOPE)
+
const body = await readBody<{ packageName: string }>(event)
if (!body.packageName) {
@@ -30,9 +33,6 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
})
}
- //Checks if the user has a scope to like packages
- await checkOAuthScope(oAuthSession, LIKES_SCOPE)
-
const subjectRef = PACKAGE_SUBJECT_REF(body.packageName)
const client = new Client(oAuthSession)
@@ -49,5 +49,5 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
})
}
- return await likesUtil.likeAPackageAndRetunLikes(body.packageName, loggedInUsersDid, result.uri)
+ return await likesUtil.likeAPackageAndReturnLikes(body.packageName, loggedInUsersDid, result.uri)
})
diff --git a/server/utils/atproto/oauth.ts b/server/utils/atproto/oauth.ts
index b6dee5f60..e889ec452 100644
--- a/server/utils/atproto/oauth.ts
+++ b/server/utils/atproto/oauth.ts
@@ -61,12 +61,13 @@ async function getOAuthSession(event: H3Event): Promise
Date: Mon, 2 Feb 2026 07:58:47 -0600
Subject: [PATCH 12/19] missed the server side one
---
app/pages/package/[...package].vue | 1 +
1 file changed, 1 insertion(+)
diff --git a/app/pages/package/[...package].vue b/app/pages/package/[...package].vue
index 9a2a768a6..8e32e7ebb 100644
--- a/app/pages/package/[...package].vue
+++ b/app/pages/package/[...package].vue
@@ -360,6 +360,7 @@ const authModal = useModal('auth-modal')
const { data: likesData } = useFetch(() => `/api/social/likes/${packageName.value}`, {
default: () => ({ totalLikes: 0, userHasLiked: false }),
+ server: false,
})
const { mutate: likePackage } = useLikePackage(packageName.value)
From f3d422e71952222aa7174eb2274a8eaea7cf9e2b Mon Sep 17 00:00:00 2001
From: Bailey Townsend
Date: Mon, 2 Feb 2026 10:07:40 -0600
Subject: [PATCH 13/19] uses valibot to check body schema
---
app/composables/useAtproto.ts | 1 -
server/api/auth/social/like.delete.ts | 11 +++--------
server/api/auth/social/like.post.ts | 11 +++--------
shared/schemas/social.ts | 11 +++++++++++
4 files changed, 17 insertions(+), 17 deletions(-)
create mode 100644 shared/schemas/social.ts
diff --git a/app/composables/useAtproto.ts b/app/composables/useAtproto.ts
index d1791ee41..7b8e0cdb2 100644
--- a/app/composables/useAtproto.ts
+++ b/app/composables/useAtproto.ts
@@ -1,7 +1,6 @@
import { ERROR_NEED_REAUTH } from '#imports'
import type { FetchError } from 'ofetch'
import type { UserSession } from '#shared/schemas/userSession'
-import type { PackageLikes } from '#server/utils/atproto/utils/likes'
export async function authRedirect(identifier: string, create: boolean = false) {
let query = { handle: identifier } as {}
diff --git a/server/api/auth/social/like.delete.ts b/server/api/auth/social/like.delete.ts
index 508b20d73..57fded3ad 100644
--- a/server/api/auth/social/like.delete.ts
+++ b/server/api/auth/social/like.delete.ts
@@ -1,5 +1,7 @@
+import * as v from 'valibot'
import { Client } from '@atproto/lex'
import * as dev from '#shared/types/lexicons/dev'
+import { PackageLikeBodySchema } from '#shared/schemas/social'
import { throwOnMissingOAuthScope } from '#server/utils/atproto/oauth'
export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
@@ -12,14 +14,7 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
//Checks if the user has a scope to like packages
await throwOnMissingOAuthScope(oAuthSession, LIKES_SCOPE)
- const body = await readBody<{ packageName: string }>(event)
-
- if (!body.packageName) {
- throw createError({
- status: 400,
- message: 'packageName is required',
- })
- }
+ const body = v.parse(PackageLikeBodySchema, await readBody(event))
const likesUtil = new PackageLikesUtils()
diff --git a/server/api/auth/social/like.post.ts b/server/api/auth/social/like.post.ts
index f779fd717..187480dff 100644
--- a/server/api/auth/social/like.post.ts
+++ b/server/api/auth/social/like.post.ts
@@ -1,7 +1,9 @@
+import * as v from 'valibot'
import { Client } from '@atproto/lex'
import * as dev from '#shared/types/lexicons/dev'
import type { UriString } from '@atproto/lex'
import { LIKES_SCOPE } from '#shared/utils/constants'
+import { PackageLikeBodySchema } from '#shared/schemas/social'
import { throwOnMissingOAuthScope } from '~~/server/utils/atproto/oauth'
export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
@@ -14,14 +16,7 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => {
//Checks if the user has a scope to like packages
await throwOnMissingOAuthScope(oAuthSession, LIKES_SCOPE)
- const body = await readBody<{ packageName: string }>(event)
-
- if (!body.packageName) {
- throw createError({
- status: 400,
- message: 'packageName is required',
- })
- }
+ const body = v.parse(PackageLikeBodySchema, await readBody(event))
const likesUtil = new PackageLikesUtils()
diff --git a/shared/schemas/social.ts b/shared/schemas/social.ts
new file mode 100644
index 000000000..942ee9120
--- /dev/null
+++ b/shared/schemas/social.ts
@@ -0,0 +1,11 @@
+import * as v from 'valibot'
+import { PackageNameSchema } from './package'
+
+/**
+ * Schema for liking/unliking a package
+ */
+export const PackageLikeBodySchema = v.object({
+ packageName: PackageNameSchema,
+})
+
+export type PackageLikeBody = v.InferOutput
From 4cbfb053544eda807337758ca3ace09e1c410c89 Mon Sep 17 00:00:00 2001
From: Bailey Townsend
Date: Mon, 2 Feb 2026 17:18:31 -0600
Subject: [PATCH 14/19] moved useComposables
---
app/components/Header/AccountMenu.client.vue | 1 +
app/components/Header/AuthModal.client.vue | 2 +-
app/components/Header/MobileMenu.vue | 1 +
app/composables/atproto/useAtproto.ts | 30 ++++++
app/composables/atproto/useLikePackage.ts | 34 +++++++
app/composables/atproto/useUnlikePackage.ts | 34 +++++++
app/composables/useAtproto.ts | 98 --------------------
app/pages/package/[...package].vue | 3 +
app/utils/atproto/helpers.ts | 13 +++
9 files changed, 117 insertions(+), 99 deletions(-)
create mode 100644 app/composables/atproto/useAtproto.ts
create mode 100644 app/composables/atproto/useLikePackage.ts
create mode 100644 app/composables/atproto/useUnlikePackage.ts
delete mode 100644 app/composables/useAtproto.ts
create mode 100644 app/utils/atproto/helpers.ts
diff --git a/app/components/Header/AccountMenu.client.vue b/app/components/Header/AccountMenu.client.vue
index 124bb0465..111313a86 100644
--- a/app/components/Header/AccountMenu.client.vue
+++ b/app/components/Header/AccountMenu.client.vue
@@ -1,4 +1,5 @@