diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 282108089b..a01c7cbd1b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -440,24 +440,26 @@ The command palette is a first-class navigation surface. When you add a new user
#### Available route names
-| Route name | URL pattern | Parameters |
-| ------------------- | ------------------------------------------------- | -------------------------------- |
-| `index` | `/` | — |
-| `about` | `/about` | — |
-| `compare` | `/compare` | — |
-| `privacy` | `/privacy` | — |
-| `search` | `/search` | — |
-| `settings` | `/settings` | — |
-| `package` | `/package/:org?/:name` | `org?`, `name` |
-| `package-version` | `/package/:org?/:name/v/:version` | `org?`, `name`, `version` |
-| `code` | `/package-code/:path+` | `path` (array) |
-| `docs` | `/package-docs/:path+` | `path` (array) |
-| `changelog` | `/package-changelog/:org?/:name` | `org?`, `name` |
-| `changelog-version` | `/package-changelog/:org?/:name/v/:version` | `org?`, `name`, `version` |
-| `timeline` | `/package-timeline/:org?/:packageName/v/:version` | `org?`, `packageName`, `version` |
-| `org` | `/org/:org` | `org` |
-| `~username` | `/~:username` | `username` |
-| `~username-orgs` | `/~:username/orgs` | `username` |
+| Route name | URL pattern | Parameters |
+| ----------------------------- | ------------------------------------------------- | ------------------------------------ |
+| `index` | `/` | — |
+| `about` | `/about` | — |
+| `compare` | `/compare` | — |
+| `privacy` | `/privacy` | — |
+| `search` | `/search` | — |
+| `settings` | `/settings` | — |
+| `package` | `/package/:org?/:name` | `org?`, `name` |
+| `package-version` | `/package/:org?/:name/v/:version` | `org?`, `name`, `version` |
+| `code` | `/package-code/:path+` | `path` (array) |
+| `docs` | `/package-docs/:path+` | `path` (array) |
+| `changelog` | `/package-changelog/:org?/:name` | `org?`, `name` |
+| `changelog-version` | `/package-changelog/:org?/:name/v/:version` | `org?`, `name`, `version` |
+| `timeline` | `/package-timeline/:org?/:packageName/v/:version` | `org?`, `packageName`, `version` |
+| `org` | `/org/:org` | `org` |
+| `~username` | `/~:username` | `username` |
+| `~username-orgs` | `/~:username/orgs` | `username` |
+| `collections-new` | `/collections/new` | — |
+| `collections-identifier-rkey` | `/collections/:identifier/:rkey` | `identifier` (handle or DID), `rkey` |
### Cursor and navigation
diff --git a/app/components/Collection/Card.stories.ts b/app/components/Collection/Card.stories.ts
new file mode 100644
index 0000000000..1ba304a672
--- /dev/null
+++ b/app/components/Collection/Card.stories.ts
@@ -0,0 +1,19 @@
+import type { Meta, StoryObj } from '@storybook-vue/nuxt'
+import Card from './Card.vue'
+import { mockCollectionSummary } from '../../storybook/mocks/handlers/collections'
+
+const meta = {
+ component: Card,
+ tags: ['autodocs'],
+ args: { collection: mockCollectionSummary },
+ decorators: [() => ({ template: '
' })],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {}
+
+export const WithoutDescription: Story = {
+ args: { collection: { ...mockCollectionSummary, description: undefined } },
+}
diff --git a/app/components/Collection/Card.vue b/app/components/Collection/Card.vue
new file mode 100644
index 0000000000..c1734969dc
--- /dev/null
+++ b/app/components/Collection/Card.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+ {{ collection.name }}
+
+
+ {{ $t('collections.by', { handle: collection.handle }) }}
+
+
+ {{ collection.description }}
+
+
+ {{
+ $t('collections.item_count', { count: $n(collection.itemCount) }, collection.itemCount)
+ }}
+
+
+
+
diff --git a/app/components/Collection/Editor.stories.ts b/app/components/Collection/Editor.stories.ts
new file mode 100644
index 0000000000..eb9d107853
--- /dev/null
+++ b/app/components/Collection/Editor.stories.ts
@@ -0,0 +1,42 @@
+import type { Meta, StoryObj } from '@storybook-vue/nuxt'
+import { expect, userEvent } from 'storybook/test'
+import Editor from './Editor.vue'
+import { mockCollection } from '../../storybook/mocks/handlers/collections'
+
+const meta = {
+ component: Editor,
+ tags: ['autodocs'],
+ decorators: [() => ({ template: '
' })],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+/** Creating a new collection. */
+export const Empty: Story = {}
+
+/** Editing an existing collection. */
+export const Editing: Story = {
+ args: { initial: mockCollection },
+}
+
+/** The collection changed elsewhere while it was open. */
+export const Conflict: Story = {
+ args: {
+ ...Editing.args,
+ errorMessage:
+ 'This collection was changed somewhere else since you opened it. Reload to get the latest version.',
+ },
+}
+
+/** Adding a package by name and reordering it to the top. */
+export const AddAndReorder: Story = {
+ args: Editing.args,
+ play: async ({ canvas }) => {
+ await userEvent.type(canvas.getByRole('combobox', { name: /add a package/i }), 'zod{enter}')
+ // Only added items have this; text matching can hit a typeahead suggestion that then closes
+ const moveUp = await canvas.findByRole('button', { name: /move zod up/i })
+ await userEvent.click(moveUp)
+ await expect(canvas.getByRole('button', { name: /move zod down/i })).toBeVisible()
+ },
+}
diff --git a/app/components/Collection/Editor.vue b/app/components/Collection/Editor.vue
new file mode 100644
index 0000000000..33c7764b74
--- /dev/null
+++ b/app/components/Collection/Editor.vue
@@ -0,0 +1,307 @@
+
+
+
+
+
diff --git a/app/components/Collection/Grid.vue b/app/components/Collection/Grid.vue
new file mode 100644
index 0000000000..a6e0441aa2
--- /dev/null
+++ b/app/components/Collection/Grid.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+ {{ $t('collections.load_error') }}
+
+ {{ emptyText }}
+
+
+ {{ $t('collections.load_error') }}
+
+
+ {{ loadingMore ? $t('common.loading_more') : $t('common.load_more') }}
+
+
+
diff --git a/app/components/Collection/ItemList.stories.ts b/app/components/Collection/ItemList.stories.ts
new file mode 100644
index 0000000000..1569dc6e1b
--- /dev/null
+++ b/app/components/Collection/ItemList.stories.ts
@@ -0,0 +1,44 @@
+import type { Meta, StoryObj } from '@storybook-vue/nuxt'
+import ItemList from './ItemList.vue'
+import { mockCollection, mockPackageCards } from '../../storybook/mocks/handlers/collections'
+
+const meta = {
+ component: ItemList,
+ tags: ['autodocs'],
+ args: { items: mockCollection.items },
+ decorators: [() => ({ template: '
' })],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+/** Packages with notes, a package missing from the registry, and a nested collection. */
+export const Default: Story = {}
+
+/** Packages shown as cards once their metadata has loaded. */
+export const WithPackageCards: Story = {
+ args: { packages: mockPackageCards },
+}
+
+/** An included collection that can't be shown. */
+export const UnavailableCollection: Story = {
+ args: {
+ items: [
+ {
+ kind: 'collection',
+ subjectRef: 'at://did:plc:x/dev.npmx.collection/3m5k5baifas2z',
+ collection: null,
+ unavailableReason: 'unresolvable',
+ },
+ ],
+ },
+}
+
+/** Items written by other clients that we don't understand. */
+export const UnknownItems: Story = {
+ args: {
+ items: [
+ { kind: 'unknown', subjectRef: 'https://www.npmjs.com/package/zod', note: 'Wrong URL' },
+ ],
+ },
+}
diff --git a/app/components/Collection/ItemList.vue b/app/components/Collection/ItemList.vue
new file mode 100644
index 0000000000..eb59e397cf
--- /dev/null
+++ b/app/components/Collection/ItemList.vue
@@ -0,0 +1,101 @@
+
+
+
+
+ -
+
+
+
+
+
+ {{ item.note }}
+
+
+
+
+
+
+
+
+ {{ item.packageName }}
+
+
+ {{ item.packageName }}
+
+
+ {{ $t('collections.package_unresolved') }}
+
+
+
{{ item.note }}
+
+
+
+
+
+
+ {{ $t('collections.includes') }}
+
+
+ {{ item.collection.name }}
+
+
+ {{ $t('collections.by', { handle: item.collection.handle }) }} ·
+ {{
+ $t(
+ 'collections.item_count',
+ { count: $n(item.collection.itemCount) },
+ item.collection.itemCount,
+ )
+ }}
+
+
{{ item.note }}
+
+
+ {{ $t('collections.unavailable.unresolvable') }}
+
+
+
+
+
+ {{ $t('collections.unknown_item') }}
+ {{ item.subjectRef }}
+ {{ item.note }}
+
+
+
+
diff --git a/app/components/Collection/ItemNote.vue b/app/components/Collection/ItemNote.vue
new file mode 100644
index 0000000000..28ec5aeded
--- /dev/null
+++ b/app/components/Collection/ItemNote.vue
@@ -0,0 +1,8 @@
+
+
+
+ //
+ {{ $t('collections.note_label') }}
+
+
+
diff --git a/app/components/Collection/YourData.vue b/app/components/Collection/YourData.vue
new file mode 100644
index 0000000000..47bd584ee5
--- /dev/null
+++ b/app/components/Collection/YourData.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+ {{ $t('collections.your_data.summary') }}
+
+
+
+
+ AT Protocol
+
+
+
+
+ {{ $t('collections.your_data.docs') }}
+
+
+
+
+
diff --git a/app/components/Header/AuthModal.client.vue b/app/components/Header/AuthModal.client.vue
index dc395b1574..5ec7aad833 100644
--- a/app/components/Header/AuthModal.client.vue
+++ b/app/components/Header/AuthModal.client.vue
@@ -10,11 +10,8 @@ const errorMessage = shallowRef('')
const route = useRoute()
const { user, logout } = useAtproto()
-// https://atproto.com supports 4 locales as of 2026-02-07
const { locale } = useI18n()
-const currentLang = locale.value.split('-')[0] ?? 'en'
-const localeSubPath = ['ko', 'pt', 'ja'].includes(currentLang) ? currentLang : ''
-const atprotoLink = `https://atproto.com/${localeSubPath}`
+const atprotoLink = useAtprotoSiteUrl()
async function handleBlueskySignIn() {
await authRedirect('https://bsky.social', { redirectTo: route.fullPath, locale: locale.value })
diff --git a/app/components/Package/Card.vue b/app/components/Package/Card.vue
index 76754a6dfa..c9d5ea4b51 100644
--- a/app/components/Package/Card.vue
+++ b/app/components/Package/Card.vue
@@ -46,24 +46,27 @@ const numberFormatter = useNumberFormatter()
-
- {{ result.package.name }}
+
- {{ $t('search.exact_match') }}
-
+ {{ result.package.name }}
+ {{ $t('search.exact_match') }}
+
+
+
{
+ const lang = locale.value.split('-')[0] ?? 'en'
+ return `https://atproto.com/${['ko', 'pt', 'ja'].includes(lang) ? lang : ''}`
+ })
+}
diff --git a/app/composables/npm/usePackagesByName.ts b/app/composables/npm/usePackagesByName.ts
new file mode 100644
index 0000000000..62add37543
--- /dev/null
+++ b/app/composables/npm/usePackagesByName.ts
@@ -0,0 +1,44 @@
+import type { NpmSearchResult, PackageMetaResponse } from '#shared/types'
+import { hash } from 'ohash'
+import { mapWithConcurrency } from '#shared/utils/async'
+import { metaToSearchResult } from './search-utils'
+
+/**
+ * Card metadata for packages by exact name, as on org pages: one Algolia lookup when it's the
+ * search provider, else lightweight registry lookups. Missing packages are left out.
+ */
+export function usePackagesByName(names: MaybeRefOrGetter) {
+ const { searchProviderValue } = useSearchProvider()
+ const { getPackagesByName } = useAlgoliaSearch()
+
+ return useLazyAsyncData(
+ // Hashed: a collection can list up to 1000 names
+ () => `packages-by-name:${searchProviderValue.value}:${hash(toValue(names))}`,
+ async (_nuxtApp, { signal }) => {
+ const packageNames = toValue(names)
+ if (packageNames.length === 0) return new Map()
+
+ let results: NpmSearchResult[] = []
+ if (searchProviderValue.value === 'algolia') {
+ results = await getPackagesByName(packageNames)
+ .then(response => response.objects)
+ .catch(() => [])
+ }
+ if (results.length === 0) {
+ const metas = await mapWithConcurrency(
+ packageNames,
+ name =>
+ $fetch(`/api/registry/package-meta/${encodePackageName(name)}`, {
+ signal,
+ }).catch(() => null),
+ 10,
+ )
+ results = metas
+ .filter((meta): meta is PackageMetaResponse => meta !== null)
+ .map(metaToSearchResult)
+ }
+ return new Map(results.map(result => [result.package.name, result]))
+ },
+ { default: () => new Map() },
+ )
+}
diff --git a/app/composables/useCommandPaletteCommands.ts b/app/composables/useCommandPaletteCommands.ts
index 9c07149131..9b1583a18a 100644
--- a/app/composables/useCommandPaletteCommands.ts
+++ b/app/composables/useCommandPaletteCommands.ts
@@ -11,6 +11,7 @@ const GROUP_ORDER: CommandPaletteGroup[] = [
'actions',
'language',
'package',
+ 'collections',
'navigation',
'connections',
'links',
@@ -54,6 +55,8 @@ export function useCommandPaletteCommands() {
return packageName
? t('command_palette.groups.package_with_name', { name: packageName })
: t('command_palette.groups.package')
+ case 'collections':
+ return t('command_palette.groups.collections')
case 'navigation':
return t('command_palette.groups.navigation')
case 'connections':
diff --git a/app/composables/useCommandPaletteGlobalCommands.ts b/app/composables/useCommandPaletteGlobalCommands.ts
index 65096752b5..b3ac1336fd 100644
--- a/app/composables/useCommandPaletteGlobalCommands.ts
+++ b/app/composables/useCommandPaletteGlobalCommands.ts
@@ -82,7 +82,17 @@ export function useCommandPaletteGlobalCommands() {
const { settings } = useSettings()
const { isConnected: isNpmConnected, npmUser, disconnect: disconnectNpm } = useConnector()
const { user: atprotoUser, logout } = useAtproto()
- const { announce, close, setView } = useCommandPalette()
+ const { announce, close, setView, isOpen, query } = useCommandPalette()
+ const { collections: ownCollections, load: loadOwnCollections } = useOwnCollections()
+ // Refetched once per open, on the first query, so collections made or renamed elsewhere show up
+ // without a PDS request every time the palette opens
+ let loadedThisOpen = false
+ watch([isOpen, query, () => atprotoUser.value?.did], ([open, text, did]) => {
+ if (!open) loadedThisOpen = false
+ if (!open || loadedThisOpen || !did || !text.trim()) return
+ loadedThisOpen = true
+ void loadOwnCollections({ force: true })
+ })
function closeThen(run: () => void | Promise) {
return async () => {
@@ -337,6 +347,16 @@ export function useCommandPaletteGlobalCommands() {
activeLabel: activeLabel(route.name === 'tools-deps-stats', t('command_palette.here')),
to: { name: 'tools-deps-stats' },
},
+ {
+ id: 'collections-new',
+ group: 'navigation',
+ label: t('collections.new'),
+ keywords: [t('collections.title')],
+ iconClass: 'i-lucide:plus',
+ active: route.name === 'collections-new',
+ activeLabel: activeLabel(route.name === 'collections-new', t('command_palette.here')),
+ to: { name: 'collections-new' },
+ },
{
id: 'settings',
group: 'navigation',
@@ -669,6 +689,15 @@ export function useCommandPaletteGlobalCommands() {
}
const atprotoProfile = atprotoUser.value
+ // The route may name the owner by handle or DID
+ const isCurrentCollection = (rkey: string) => {
+ if (route.name !== 'collections-identifier-rkey') return false
+ const params = route.params as { identifier?: string; rkey?: string }
+ return (
+ params.rkey === rkey &&
+ (params.identifier === atprotoProfile?.handle || params.identifier === atprotoProfile?.did)
+ )
+ }
if (atprotoProfile != null) {
items.push(
{
@@ -713,6 +742,25 @@ export function useCommandPaletteGlobalCommands() {
)
}
+ // Only while searching, so a long list doesn't bury everything else
+ if (atprotoProfile != null && query.value.trim()) {
+ items.push(
+ ...(ownCollections.value ?? []).map((collection): CommandPaletteCommand => ({
+ id: `collection:${collection.uri}`,
+ group: 'collections',
+ label: collection.name,
+ keywords: collection.description ? [collection.description] : [],
+ iconClass: 'i-lucide:library',
+ active: isCurrentCollection(collection.rkey),
+ activeLabel: activeLabel(isCurrentCollection(collection.rkey), t('command_palette.here')),
+ to: {
+ name: 'collections-identifier-rkey',
+ params: { identifier: atprotoProfile.handle, rkey: collection.rkey },
+ },
+ })),
+ )
+ }
+
return items
})
diff --git a/app/composables/useOwnCollections.ts b/app/composables/useOwnCollections.ts
new file mode 100644
index 0000000000..d987159ea0
--- /dev/null
+++ b/app/composables/useOwnCollections.ts
@@ -0,0 +1,31 @@
+import type { OwnCollection } from '#shared/types/social'
+import { fetchOwnCollections } from '~/utils/atproto/collections'
+
+/** The signed-in user's collections, for finding them from the command palette. */
+export function useOwnCollections() {
+ const { user } = useAtproto()
+ const state = useState<{ did: string; items: OwnCollection[] } | null>(
+ 'own-collections',
+ () => null,
+ )
+ const status = useState<'idle' | 'pending' | 'error'>('own-collections:status', () => 'idle')
+
+ const collections = computed(() =>
+ state.value && state.value.did === user.value?.did ? state.value.items : null,
+ )
+
+ async function load({ force = false } = {}) {
+ const did = user.value?.did
+ if (!did || (!force && (collections.value || status.value === 'pending'))) return
+ status.value = 'pending'
+ try {
+ const items = await fetchOwnCollections()
+ if (user.value?.did === did) state.value = { did, items }
+ status.value = 'idle'
+ } catch {
+ status.value = 'error'
+ }
+ }
+
+ return { collections, status, load }
+}
diff --git a/app/composables/usePagedCollections.ts b/app/composables/usePagedCollections.ts
new file mode 100644
index 0000000000..396ee9bcb9
--- /dev/null
+++ b/app/composables/usePagedCollections.ts
@@ -0,0 +1,30 @@
+import type { CollectionSummary } from '#shared/types/social'
+
+type Page = { cursor?: string; items: CollectionSummary[] }
+
+/** A load-more list of collections, as used on profiles. */
+export function usePagedCollections(fetchPage: (cursor: string | undefined) => Promise) {
+ const items = ref([])
+ // undefined = not yet fetched, string = next page cursor, null = no more pages
+ const cursor = shallowRef(undefined)
+ const error = shallowRef(false)
+ const isLoading = shallowRef(false)
+
+ async function load() {
+ if (cursor.value === null || isLoading.value) return
+ isLoading.value = true
+ error.value = false
+ try {
+ const page = await fetchPage(cursor.value)
+ items.value.push(...page.items)
+ cursor.value = page.cursor ?? null
+ } catch {
+ error.value = true
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ const hasMore = computed(() => typeof cursor.value === 'string')
+ return { items, error, isLoading, hasMore, load }
+}
diff --git a/app/pages/collections/[identifier]/[rkey].stories.ts b/app/pages/collections/[identifier]/[rkey].stories.ts
new file mode 100644
index 0000000000..ea8b0976b0
--- /dev/null
+++ b/app/pages/collections/[identifier]/[rkey].stories.ts
@@ -0,0 +1,36 @@
+import CollectionPage from './[rkey].vue'
+import type { Meta, StoryObj } from '@storybook-vue/nuxt'
+import { pageDecorator } from '../../../../.storybook/decorators'
+import {
+ mockCollectionHandle,
+ mockCollectionHandler,
+ mockCollectionRkey,
+ mockPackageCardHandlers,
+} from '../../../storybook/mocks/handlers/collections'
+import { mockAuthSessionHandler } from '../../../storybook/mocks/handlers/profile'
+import { renderPageAt } from '../../../storybook/render-page'
+
+const meta = {
+ component: CollectionPage,
+ render: renderPageAt(
+ CollectionPage,
+ `/collections/${mockCollectionHandle}/${mockCollectionRkey}`,
+ ),
+ beforeEach({ msw }) {
+ msw.use(mockCollectionHandler, ...mockPackageCardHandlers, mockAuthSessionHandler())
+ },
+ parameters: { layout: 'fullscreen' },
+ decorators: [pageDecorator],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {}
+
+/** The owner can edit and delete. */
+export const Owner: Story = {
+ beforeEach({ msw }) {
+ msw.use(mockAuthSessionHandler(mockCollectionHandle))
+ },
+}
diff --git a/app/pages/collections/[identifier]/[rkey].vue b/app/pages/collections/[identifier]/[rkey].vue
new file mode 100644
index 0000000000..8c24691d2b
--- /dev/null
+++ b/app/pages/collections/[identifier]/[rkey].vue
@@ -0,0 +1,255 @@
+
+
+
+
+
+
+ {{ collection.name }}
+
+
+
+ {{ $t('collections.by', { handle: collection.handle }) }}
+
+
+ {{ collection.description }}
+
+
+
+ {{ $t('collections.duplicate') }}
+
+
+
+ {{ $t('common.edit') }}
+
+
+ {{ $t('common.delete') }}
+
+
+
+
+
+
+
+
+ {{ $t('collections.empty_collection') }}
+
+
+
+
+ {{ $t('collections.delete_body') }}
+ {{ deleteError }}
+
+ {{ $t('common.cancel') }}
+
+ {{ $t('common.delete') }}
+
+
+
+
+
diff --git a/app/pages/collections/new.vue b/app/pages/collections/new.vue
new file mode 100644
index 0000000000..77afa09363
--- /dev/null
+++ b/app/pages/collections/new.vue
@@ -0,0 +1,96 @@
+
+
+
+
+ {{ $t('collections.new') }}
+
+
+
+
+
+
{{ $t('collections.sign_in_to_create') }}
+
+ {{ $t('auth.modal.connect') }}
+
+
+
+
+
diff --git a/app/pages/profile/[identity]/index.vue b/app/pages/profile/[identity]/index.vue
index 7e2561d657..0b3de9f82f 100644
--- a/app/pages/profile/[identity]/index.vue
+++ b/app/pages/profile/[identity]/index.vue
@@ -2,6 +2,7 @@
import { useInfiniteScroll } from '@vueuse/core'
import { updateProfile as updateProfileUtil } from '~/utils/atproto/profile'
import { fetchProfileLikes } from '~/utils/atproto/likes'
+import { fetchUserCollections } from '~/utils/atproto/collections'
import type { CommandPaletteContextCommandInput } from '~/types/command-palette'
import { getSafeHttpUrl } from '#shared/utils/url'
@@ -81,6 +82,13 @@ async function updateProfile() {
}
}
+const isOwnProfile = computed(
+ () => !!user.value?.handle && user.value.handle === profile.value.handle,
+)
+
+const collections = usePagedCollections(cursor => fetchUserCollections(identity.value, cursor, 12))
+onMounted(() => collections.load())
+
const allLikesRecords = ref([])
// undefined = not yet fetched, string = next page cursor, null = no more pages
const likesCursor = shallowRef(undefined)
@@ -259,6 +267,39 @@ defineOgImage(
+
+
+
+ {{ $t('profile.collections') }}
+
+
+ {{ $t('collections.new') }}
+
+
+
+
+
({
+ package: {
+ name,
+ version: '1.0.0',
+ description,
+ date: '2026-10-01T00:00:00.000Z',
+ links: {},
+ license: 'MIT',
+ },
+ downloads: { weekly },
+})
+
+/** Card metadata for the resolved packages in `mockCollection`. */
+export const mockPackageCards = new Map(
+ [
+ mockCard('commander', 'the complete solution for node.js command-line programs', 180_000_000),
+ mockCard('citty', 'Elegant CLI Builder', 4_000_000),
+ ].map(card => [card.package.name, card]),
+)
+
+export const mockCollection: CollectionView = {
+ ...mockCollectionSummary,
+ items: [
+ {
+ kind: 'package',
+ subjectRef: 'https://npmx.dev/package/commander',
+ packageName: 'commander',
+ resolved: true,
+ note: 'The default choice. Mature and well documented.',
+ },
+ {
+ kind: 'package',
+ subjectRef: 'https://npmx.dev/package/citty',
+ packageName: 'citty',
+ resolved: true,
+ note: 'Tiny and typed, from UnJS.',
+ },
+ {
+ kind: 'package',
+ subjectRef: 'https://npmx.dev/package/not-a-real-parser',
+ packageName: 'not-a-real-parser',
+ resolved: false,
+ },
+ {
+ kind: 'collection',
+ subjectRef: 'at://did:plc:other/dev.npmx.collection/3m5k5baifas2b',
+ note: 'For prompts rather than flags.',
+ collection: {
+ ...mockCollectionSummary,
+ did: 'did:plc:other',
+ handle: 'other.dev',
+ rkey: '3m5k5baifas2b',
+ name: 'Interactive prompts',
+ description: undefined,
+ itemCount: 2,
+ },
+ },
+ ],
+}
+
+export const mockCollectionHandler = http.get(`/api/social/collections/:identifier/:rkey`, () =>
+ HttpResponse.json(mockCollection),
+)
+
+/** Card metadata for collection pages: Algolia finds nothing, so the registry lookup serves it. */
+export const mockPackageCardHandlers = [
+ http.post('https://*.algolia.net/1/indexes/*/objects', () => HttpResponse.json({ results: [] })),
+ http.get('/api/registry/package-meta/:name', ({ params }) => {
+ const card = mockPackageCards.get(String(params.name))
+ if (!card) return new HttpResponse(null, { status: 404 })
+ return HttpResponse.json({
+ ...card.package,
+ links: { npm: `https://www.npmjs.com/package/${card.package.name}` },
+ weeklyDownloads: card.downloads?.weekly,
+ } satisfies PackageMetaResponse)
+ }),
+]
diff --git a/app/types/command-palette.ts b/app/types/command-palette.ts
index fdc8bc4d55..21f7103347 100644
--- a/app/types/command-palette.ts
+++ b/app/types/command-palette.ts
@@ -5,6 +5,7 @@ export type CommandPaletteGroup =
| 'help'
| 'language'
| 'package'
+ | 'collections'
| 'navigation'
| 'connections'
| 'links'
diff --git a/app/utils/atproto/collections.ts b/app/utils/atproto/collections.ts
new file mode 100644
index 0000000000..a84ce16aea
--- /dev/null
+++ b/app/utils/atproto/collections.ts
@@ -0,0 +1,121 @@
+import { FetchError } from 'ofetch'
+import type { InferInput } from 'valibot'
+import type { CollectionBodySchema } from '#shared/schemas/social'
+import type { CollectionSummary, OwnCollection } from '#shared/types/social'
+import { authRedirect, handleAuthError } from '~/utils/atproto/helpers'
+
+export type CollectionBody = InferInput
+export type CollectionWriteResult = { uri: string; cid: string; did: string; rkey: string }
+export const collectionPath = (identifier: string, rkey: string) =>
+ `/api/social/collections/${encodeURIComponent(identifier)}/${encodeURIComponent(rkey)}`
+
+/** Parses a `/` query value, as used by `?from=` and `?includes=`. */
+export function parseCollectionQuery(value: unknown) {
+ const [identifier, rkey] = typeof value === 'string' ? value.split('/') : []
+ return identifier && rkey ? { identifier, rkey } : null
+}
+
+// Re-authenticates when the session lacks a newly added scope, as the likes helpers do.
+async function request(run: () => Promise, userHandle: string | null | undefined) {
+ try {
+ return await run()
+ } catch (e) {
+ if (e instanceof FetchError) await handleAuthError(e, userHandle)
+ throw e
+ }
+}
+
+/**
+ * Sessions from before collections existed lack the scope, and the re-auth redirect drops the
+ * page's state. Checking before the editor opens means the user never loses a draft to it.
+ */
+export async function ensureCollectionWriteAccess(
+ userHandle: string | null | undefined,
+ redirectTo: string,
+) {
+ try {
+ await $fetch('/api/social/collections/write-access')
+ } catch (e) {
+ // Anything else surfaces on save
+ if (e instanceof FetchError && e.data?.message === ERROR_NEED_REAUTH && userHandle) {
+ await authRedirect(userHandle, { redirectTo })
+ }
+ }
+}
+
+export const isConflict = (e: unknown) => e instanceof FetchError && e.statusCode === 409
+
+export function createCollection(body: CollectionBody, userHandle?: string | null) {
+ return request(
+ () =>
+ $fetch('/api/social/collections', {
+ method: 'POST',
+ body,
+ }),
+ userHandle,
+ )
+}
+
+export function updateCollection(
+ identifier: string,
+ rkey: string,
+ body: CollectionBody & { cid: string },
+ userHandle?: string | null,
+) {
+ return request(
+ () => $fetch(collectionPath(identifier, rkey), { method: 'PUT', body }),
+ userHandle,
+ )
+}
+
+export function deleteCollection(identifier: string, rkey: string, userHandle?: string | null) {
+ return request(
+ () => $fetch<{ uri: string }>(collectionPath(identifier, rkey), { method: 'DELETE' }),
+ userHandle,
+ )
+}
+
+export function fetchOwnCollections() {
+ return $fetch('/api/social/collections/mine')
+}
+
+export function fetchUserCollections(identifier: string, cursor?: string | null, limit = 20) {
+ return $fetch<{ cursor?: string; items: CollectionSummary[] }>(
+ `/api/social/collections/${encodeURIComponent(identifier)}`,
+ { query: { cursor: cursor ?? undefined, limit } },
+ )
+}
+
+/**
+ * Turns what a user pastes into the editor into a subjectRef: a package name, an npmx package or
+ * collection URL, or a collection AT-URI. Collection page URLs may use a handle, so resolving
+ * those needs the API; returns null when the input isn't recognizable.
+ */
+export async function resolveSubjectRefInput(input: string): Promise {
+ const value = input.trim()
+ if (!value) return null
+
+ if (value.startsWith('at://')) return value
+
+ const collectionPage = /^https?:\/\/(?:[\w-]+\.)?npmx\.dev\/collections\/([^/]+)\/([^/?#]+)/.exec(
+ value,
+ )
+ if (collectionPage) {
+ const [, identifier, rkey] = collectionPage
+ try {
+ const { uri } = await $fetch(collectionPath(identifier!, rkey!))
+ return uri
+ } catch {
+ return null
+ }
+ }
+
+ const packagePage = /^https?:\/\/(?:[\w-]+\.)?npmx\.dev\/package\/(.+?)\/?$/.exec(value)
+ if (!packagePage) return PACKAGE_SUBJECT_REF(value)
+ try {
+ return PACKAGE_SUBJECT_REF(decodeURIComponent(packagePage[1]!))
+ } catch {
+ // Malformed percent-encoding
+ return null
+ }
+}
diff --git a/docs/content/2.guide/1.features.md b/docs/content/2.guide/1.features.md
index 62b1cdac57..e1d24e4874 100644
--- a/docs/content/2.guide/1.features.md
+++ b/docs/content/2.guide/1.features.md
@@ -100,9 +100,18 @@ npmx.dev fetches repository statistics (stars, forks) from these git hosting pla
| :icon{name="i-simple-icons-tangled"} [Tangled](https://tangled.sh) | Decentralized git hosting |
| [Pushin](https://pushin.eu) | European git hosting |
+## Collect packages
+
+Collections are curated, ordered lists of npm packages, with an optional note on each one. Anyone with an Atmosphere account can make them, and they're stored in that account, not on npmx. See [Collections](/integrations/collections) for building with them from other apps.
+
+- **Create and edit** - Name a collection, describe it, and add, reorder, annotate, or remove up to 1,000 items
+- **Include other collections** - Add a collection's link to another collection to show it as a linked entry with its author and size
+- **Duplicate** - Start a new collection from a copy of any collection
+
## Explore users and organizations
- **User profiles** - View any npm user's public packages at `/~username`
+- **Profile collections** - See the collections an npmx social profile has made
- **Profile likes** - View packages liked by npmx social profiles
- **Organization pages** - Browse org packages at `/@orgname`
- **Search and filter** - Find packages within user/org lists
diff --git a/docs/content/2.guide/2.keyboard-shortcuts.md b/docs/content/2.guide/2.keyboard-shortcuts.md
index 1c35a31660..cb27aa44dd 100644
--- a/docs/content/2.guide/2.keyboard-shortcuts.md
+++ b/docs/content/2.guide/2.keyboard-shortcuts.md
@@ -15,6 +15,7 @@ The command palette lets you:
- Jump between major pages like home, compare, settings, and docs
- Open package-specific surfaces like the main page, docs, code, and diff views
+- Find your own collections by name, and edit or duplicate the collection you're viewing
- Switch settings such as language, accent color, and background theme
- Open external resources like docs, chat, source, and package links
diff --git a/docs/content/2.guide/3.url-structure.md b/docs/content/2.guide/3.url-structure.md
index d7ebfdc614..3269730721 100644
--- a/docs/content/2.guide/3.url-structure.md
+++ b/docs/content/2.guide/3.url-structure.md
@@ -45,6 +45,7 @@ npmx.dev keeps package exploration views in routes or query parameters so you ca
| Search results | `/search?q=vue` |
| Package comparison | `/compare?packages=react,preact` |
| Tools | `/tools` |
+| A collection | `/collections//` |
| Dependency stats | `/tools/deps-stats` |
| Source file or line | `/package-code/vue/v/3.5.27/...` |
| Generated docs | `/package-docs/ufo/v/1.6.3` |
diff --git a/docs/content/4.integrations/1.atmosphere.md b/docs/content/4.integrations/1.atmosphere.md
index a23de2c98b..0d6295739f 100644
--- a/docs/content/4.integrations/1.atmosphere.md
+++ b/docs/content/4.integrations/1.atmosphere.md
@@ -69,7 +69,7 @@ You can browse our Lexicons [in the repo](https://github.com/npmx-dev/npmx.dev/t
### Bluesky Social Graph
-We pull from the Bluesky App View via Constellation to surface social interactions right inside npmx. That means posts, comments, and threads show up on blog posts, you can like packages, and more. Down the road, we'd love to attach relevant articles to packages and show likes or reposts on technical content – all without leaving [npmx.dev](https://npmx.dev).
+We pull from the Bluesky App View via Constellation to surface social interactions right inside npmx. That means posts, comments, and threads show up on blog posts, you can like packages, you can make [collections](/integrations/collections) of packages that live in your own account, and more. Down the road, we'd love to attach relevant articles to packages and show likes or reposts on technical content – all without leaving [npmx.dev](https://npmx.dev).
---
diff --git a/docs/content/4.integrations/2.collections.md b/docs/content/4.integrations/2.collections.md
new file mode 100644
index 0000000000..95601de9ae
--- /dev/null
+++ b/docs/content/4.integrations/2.collections.md
@@ -0,0 +1,124 @@
+---
+title: Collections
+description: Make package collections from any atproto app
+navigation:
+ icon: i-lucide:library
+---
+
+## Collections live in your account
+
+A collection is an ordered list of npm packages with notes, stored as a `dev.npmx.collection` record in your own atproto repository. npmx.dev is one way to make and view them, not the only one. Write a record with that shape from any app, script or PDS, and it shows up on your npmx.dev profile.
+
+Collections are public, like everything else in an atproto repository.
+
+---
+
+## The record
+
+The schema is in [`lexicons/dev/npmx/collection.json`](https://github.com/npmx-dev/npmx.dev/blob/main/lexicons/dev/npmx/collection.json). Record keys are [TIDs](https://atproto.com/specs/tid).
+
+```json
+{
+ "$type": "dev.npmx.collection",
+ "name": "Arg parsers",
+ "description": "CLI argument parsers worth knowing.",
+ "items": [
+ { "subjectRef": "https://npmx.dev/package/commander", "note": "The default choice" },
+ { "subjectRef": "https://npmx.dev/package/citty" },
+ { "subjectRef": "at://did:plc:example/dev.npmx.collection/3m5k5baifas2b" }
+ ],
+ "createdAt": "2026-11-01T00:00:00.000Z"
+}
+```
+
+| Field | Required | Limits |
+| ------------- | -------- | ------------------------------------------------------ |
+| `name` | yes | 64 graphemes |
+| `description` | no | 300 graphemes |
+| `items` | yes | 1000 items, in display order |
+| `item.note` | no | 140 graphemes, 560 bytes |
+| `createdAt` | yes | [datetime](https://atproto.com/specs/lexicon#datetime) |
+
+Each item's `subjectRef` is one of:
+
+- **A package**: `https://npmx.dev/package/`, for example `https://npmx.dev/package/@vitejs/plugin-vue`. This is the same identifier npmx likes use. It names the package; it doesn't depend on npmx.dev being up.
+- **Another collection**: its AT-URI, with the author's DID (not their handle), for example `at://did:plc:example/dev.npmx.collection/3m5k5baifas2b`. The included collection stays live, so whoever owns it keeps curating it.
+
+npmx.dev shows anything else as an unrecognized item, so the author can see and fix it, rather than hiding the collection.
+
+### Showing nested collections
+
+npmx.dev shows an included collection as a link with its name, author and size, rather than merging its items into the parent list, so readers can see whose list each package came from. If your viewer expands included collections inline, stop at 2 levels deep and when a collection includes one you're already showing.
+
+### Liking a collection
+
+Likes reuse `dev.npmx.feed.like`, with the collection's AT-URI as `subjectRef`. Don't use the `subject` strong reference for collections: it pins a specific version, and collections change.
+
+```json
+{
+ "$type": "dev.npmx.feed.like",
+ "subjectRef": "at://did:plc:example/dev.npmx.collection/3m5k5baifas2a",
+ "createdAt": "2026-11-01T00:00:00.000Z"
+}
+```
+
+### Following a collection
+
+A follow is a `dev.npmx.graph.follow` record whose `subject` is the collection's AT-URI. A like says a collection is good; a follow says you want updates about it. Your own collections count as followed without a record. To follow a package, add it to a collection you follow.
+
+```json
+{
+ "$type": "dev.npmx.graph.follow",
+ "subject": "at://did:plc:example/dev.npmx.collection/3m5k5baifas2a",
+ "createdAt": "2026-11-01T00:00:00.000Z"
+}
+```
+
+---
+
+## Make one with curl
+
+Use an [app password](https://bsky.app/settings/app-passwords), not your main password. Replace `PDS` with your PDS (for most Bluesky accounts, `https://bsky.social`).
+
+```bash
+PDS=https://bsky.social
+HANDLE=you.example.com
+APP_PASSWORD=xxxx-xxxx-xxxx-xxxx
+
+# 1. Get a session
+SESSION=$(curl -s "$PDS/xrpc/com.atproto.server.createSession" \
+ -H 'Content-Type: application/json' \
+ -d "{\"identifier\":\"$HANDLE\",\"password\":\"$APP_PASSWORD\"}")
+TOKEN=$(echo "$SESSION" | jq -r .accessJwt)
+DID=$(echo "$SESSION" | jq -r .did)
+
+# 2. Create the collection
+curl -s "$PDS/xrpc/com.atproto.repo.createRecord" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H 'Content-Type: application/json' \
+ -d @- </`.
+
+To edit a collection, use `com.atproto.repo.putRecord` with the same record key, and pass the `cid` you last read as `swapRecord` so you don't overwrite someone else's edit made in the meantime.
+
+---
+
+## When it shows up
+
+- **Your profile** reads straight from your PDS, so it updates right away. **The collection page** does too for changes made on npmx.dev; changes from other apps can take up to 5 minutes.
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index 38ba8589fe..9619ee82f1 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -123,6 +123,7 @@
"npmx": "npmx",
"package": "Package",
"package_with_name": "Package ({name})",
+ "collections": "Your collections",
"versions": "Versions",
"versions_with_name": "Versions of {name}"
},
@@ -395,6 +396,7 @@
"common": {
"loading": "Loading...",
"loading_more": "Loading more...",
+ "load_more": "Load more",
"loading_packages": "Loading packages...",
"end_of_results": "End of results",
"try_again": "Try again",
@@ -418,6 +420,7 @@
"cancel": "Cancel",
"save": "Save",
"edit": "Edit",
+ "delete": "Delete",
"error": "Error",
"view_on": {
"npm": "View on npm",
@@ -456,7 +459,67 @@
"message": "It doesn't look like they're using npmx yet. Want to tell them about it?",
"share_button": "Share on Bluesky",
"compose_text": "Hey {'@'}{handle}! Have you checked out npmx.dev yet? It's a browser for the npm registry that's fast, modern, and open-source.\nhttps://npmx.dev"
- }
+ },
+ "collections": "Collections"
+ },
+ "collections": {
+ "title": "Collections",
+ "seo_title": "{name} - npmx collections",
+ "seo_description": "{name}, a collection of npm packages by {'@'}{handle}",
+ "new": "New collection",
+ "new_title": "New collection - npmx",
+ "empty": "No collections yet.",
+ "load_error": "Couldn't load collections. Please try again later.",
+ "empty_collection": "This collection is empty.",
+ "by": "by {'@'}{handle}",
+ "item_count": "{count} item | {count} items",
+ "duplicate": "Duplicate",
+ "delete_title": "Delete this collection?",
+ "delete_body": "This can't be undone.",
+ "delete_error": "Couldn't delete the collection. Please try again.",
+ "includes": "Includes",
+ "package_unresolved": "Not found on the npm registry",
+ "unknown_item": "Unrecognized item",
+ "unavailable": {
+ "unresolvable": "This collection couldn't be loaded."
+ },
+ "not_found": "Collection Not Found",
+ "not_found_message": "This collection doesn't exist, was deleted, or is hidden.",
+ "load_failed": "Couldn't load this collection",
+ "load_failed_message": "The server that stores it didn't respond. Try again in a moment.",
+ "sign_in_to_create": "Connect your Atmosphere account to create a collection.",
+ "editor": {
+ "public_notice": "Collections are saved to your Atmosphere account and are public. Anyone can see them, in npmx or any other app.",
+ "name": "Name",
+ "description": "Description",
+ "items": "Items",
+ "item_limit": "up to {max}",
+ "add_item_label": "Add a package or collection",
+ "add_item_placeholder": "Search packages...",
+ "add_item_hint": "Pick a package from the list, or paste a link to another collection and press Enter.",
+ "invalid_item": "That isn't a package from the list or a collection link.",
+ "note_placeholder": "Add a note (optional)",
+ "note_for": "Note for {name}",
+ "move_up": "Move {name} up",
+ "move_down": "Move {name} down",
+ "remove": "Remove {name}",
+ "moved": "Moved {name} to position {position} of {total}",
+ "removed": "Removed {name}",
+ "saved": "Collection saved",
+ "save_error": "Couldn't save the collection. Please try again.",
+ "conflict": "This collection was changed somewhere else since you opened it. Reload to get the latest version.",
+ "unrecognized": "Not recognized",
+ "name_required": "Give the collection a name.",
+ "unrecognized_items": "Remove the items marked “Not recognized”. npmx can only save packages and collections.",
+ "too_many_items": "Remove {count} item. A collection holds up to {max}. | Remove {count} items. A collection holds up to {max}.",
+ "too_long": "Shorten the text. The name can be up to {name} characters, the description {description} and each note {note}."
+ },
+ "your_data": {
+ "summary": "It's your data",
+ "body": "Collections live in your Atmosphere account, not on npmx. They're open, public data on the {atproto}: create, view and change them here, in any other app, or with your own scripts, and take them wherever you go. Have fun with it!",
+ "docs": "Build with collections"
+ },
+ "note_label": "Curator's note:"
},
"package": {
"not_found": "Package Not Found",
diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json
index ef69005a37..f3d18f7033 100644
--- a/i18n/locales/fr-FR.json
+++ b/i18n/locales/fr-FR.json
@@ -112,6 +112,7 @@
"npmx": "npmx",
"package": "Paquet",
"package_with_name": "Paquet ({name})",
+ "collections": "Vos collections",
"versions": "Versions",
"versions_with_name": "Versions de {name}"
},
@@ -305,6 +306,7 @@
"common": {
"loading": "Chargement...",
"loading_more": "Chargement en cours...",
+ "load_more": "Afficher plus",
"loading_packages": "Chargement des paquets...",
"end_of_results": "Fin des résultats",
"try_again": "Réessayer encore",
@@ -328,6 +330,7 @@
"cancel": "Annuler",
"save": "Enregistrer",
"edit": "Modifier",
+ "delete": "Supprimer",
"error": "Erreur",
"view_on": {
"npm": "voir sur npm",
@@ -362,7 +365,67 @@
"message": "Il semblerait qu'ils n'utilisent pas encore npmx. Vous voulez leur en parler ?",
"share_button": "Partager sur Bluesky",
"compose_text": "Salut {'@'}{handle} ! As-tu déjà testé npmx.dev ? C'est un navigateur pour le registre npm : rapide, moderne et open source.\nhttps://npmx.dev"
- }
+ },
+ "collections": "Collections"
+ },
+ "collections": {
+ "title": "Collections",
+ "seo_title": "{name} - collections npmx",
+ "seo_description": "{name}, une collection de paquets npm par {'@'}{handle}",
+ "new": "Nouvelle collection",
+ "new_title": "Nouvelle collection - npmx",
+ "empty": "Aucune collection pour l'instant.",
+ "load_error": "Impossible de charger les collections. Veuillez réessayer plus tard.",
+ "empty_collection": "Cette collection est vide.",
+ "by": "par {'@'}{handle}",
+ "item_count": "{count} élément | {count} éléments",
+ "duplicate": "Dupliquer",
+ "delete_title": "Supprimer cette collection ?",
+ "delete_body": "Cette action est irréversible.",
+ "delete_error": "Impossible de supprimer la collection. Veuillez réessayer.",
+ "includes": "Inclut",
+ "package_unresolved": "Introuvable sur le registre npm",
+ "unknown_item": "Élément non reconnu",
+ "unavailable": {
+ "unresolvable": "Impossible de charger cette collection."
+ },
+ "not_found": "Collection introuvable",
+ "not_found_message": "Cette collection n'existe pas, a été supprimée ou est masquée.",
+ "load_failed": "Impossible de charger cette collection",
+ "load_failed_message": "Le serveur qui la stocke n'a pas répondu. Réessayez dans un instant.",
+ "sign_in_to_create": "Connectez votre compte Atmosphere pour créer une collection.",
+ "editor": {
+ "public_notice": "Les collections sont enregistrées dans votre compte Atmosphere et sont publiques. Tout le monde peut les voir, sur npmx ou dans n'importe quelle autre application.",
+ "name": "Nom",
+ "description": "Description",
+ "items": "Éléments",
+ "item_limit": "jusqu'à {max}",
+ "add_item_label": "Ajouter un paquet ou une collection",
+ "add_item_placeholder": "Rechercher des paquets...",
+ "add_item_hint": "Choisissez un paquet dans la liste, ou collez le lien d’une autre collection puis appuyez sur Entrée.",
+ "invalid_item": "Ce n’est ni un paquet de la liste ni un lien de collection.",
+ "note_placeholder": "Ajouter une note (facultatif)",
+ "note_for": "Note pour {name}",
+ "move_up": "Monter {name}",
+ "move_down": "Descendre {name}",
+ "remove": "Retirer {name}",
+ "moved": "{name} déplacé en position {position} sur {total}",
+ "removed": "{name} retiré",
+ "saved": "Collection enregistrée",
+ "save_error": "Impossible d'enregistrer la collection. Veuillez réessayer.",
+ "conflict": "Cette collection a été modifiée ailleurs depuis son ouverture. Rechargez pour obtenir la dernière version.",
+ "unrecognized": "Non reconnu",
+ "name_required": "Donnez un nom à la collection.",
+ "unrecognized_items": "Retirez les éléments marqués « Non reconnu ». npmx ne peut enregistrer que des paquets et des collections.",
+ "too_many_items": "Retirez {count} élément. Une collection en contient au maximum {max}. | Retirez {count} éléments. Une collection en contient au maximum {max}.",
+ "too_long": "Raccourcissez le texte. Le nom peut compter jusqu'à {name} caractères, la description {description} et chaque note {note}."
+ },
+ "your_data": {
+ "summary": "Ce sont vos données",
+ "body": "Les collections sont stockées dans votre compte Atmosphere, pas chez npmx. Ce sont des données ouvertes et publiques sur l'{atproto} : créez-les, consultez-les et modifiez-les ici, dans n'importe quelle autre application ou avec vos propres scripts, et emportez-les où vous voulez. Amusez-vous bien !",
+ "docs": "Développer avec les collections"
+ },
+ "note_label": "Note du curateur :"
},
"package": {
"not_found": "Paquet introuvable",
diff --git a/i18n/schema.json b/i18n/schema.json
index 1e659fe60e..4c395a03c9 100644
--- a/i18n/schema.json
+++ b/i18n/schema.json
@@ -373,6 +373,9 @@
"package_with_name": {
"type": "string"
},
+ "collections": {
+ "type": "string"
+ },
"versions": {
"type": "string"
},
@@ -1189,6 +1192,9 @@
"loading_more": {
"type": "string"
},
+ "load_more": {
+ "type": "string"
+ },
"loading_packages": {
"type": "string"
},
@@ -1258,6 +1264,9 @@
"edit": {
"type": "string"
},
+ "delete": {
+ "type": "string"
+ },
"error": {
"type": "string"
},
@@ -1374,6 +1383,183 @@
}
},
"additionalProperties": false
+ },
+ "collections": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "collections": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "seo_title": {
+ "type": "string"
+ },
+ "seo_description": {
+ "type": "string"
+ },
+ "new": {
+ "type": "string"
+ },
+ "new_title": {
+ "type": "string"
+ },
+ "empty": {
+ "type": "string"
+ },
+ "load_error": {
+ "type": "string"
+ },
+ "empty_collection": {
+ "type": "string"
+ },
+ "by": {
+ "type": "string"
+ },
+ "item_count": {
+ "type": "string"
+ },
+ "duplicate": {
+ "type": "string"
+ },
+ "delete_title": {
+ "type": "string"
+ },
+ "delete_body": {
+ "type": "string"
+ },
+ "delete_error": {
+ "type": "string"
+ },
+ "includes": {
+ "type": "string"
+ },
+ "package_unresolved": {
+ "type": "string"
+ },
+ "unknown_item": {
+ "type": "string"
+ },
+ "unavailable": {
+ "type": "object",
+ "properties": {
+ "unresolvable": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "not_found": {
+ "type": "string"
+ },
+ "not_found_message": {
+ "type": "string"
+ },
+ "load_failed": {
+ "type": "string"
+ },
+ "load_failed_message": {
+ "type": "string"
+ },
+ "sign_in_to_create": {
+ "type": "string"
+ },
+ "editor": {
+ "type": "object",
+ "properties": {
+ "public_notice": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "items": {
+ "type": "string"
+ },
+ "item_limit": {
+ "type": "string"
+ },
+ "add_item_label": {
+ "type": "string"
+ },
+ "add_item_placeholder": {
+ "type": "string"
+ },
+ "add_item_hint": {
+ "type": "string"
+ },
+ "invalid_item": {
+ "type": "string"
+ },
+ "note_placeholder": {
+ "type": "string"
+ },
+ "note_for": {
+ "type": "string"
+ },
+ "move_up": {
+ "type": "string"
+ },
+ "move_down": {
+ "type": "string"
+ },
+ "remove": {
+ "type": "string"
+ },
+ "moved": {
+ "type": "string"
+ },
+ "removed": {
+ "type": "string"
+ },
+ "saved": {
+ "type": "string"
+ },
+ "save_error": {
+ "type": "string"
+ },
+ "conflict": {
+ "type": "string"
+ },
+ "unrecognized": {
+ "type": "string"
+ },
+ "name_required": {
+ "type": "string"
+ },
+ "unrecognized_items": {
+ "type": "string"
+ },
+ "too_many_items": {
+ "type": "string"
+ },
+ "too_long": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "your_data": {
+ "type": "object",
+ "properties": {
+ "summary": {
+ "type": "string"
+ },
+ "body": {
+ "type": "string"
+ },
+ "docs": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
}
},
"additionalProperties": false
diff --git a/server/api/social/collections/[identifier]/[rkey].delete.ts b/server/api/social/collections/[identifier]/[rkey].delete.ts
new file mode 100644
index 0000000000..fa1036c579
--- /dev/null
+++ b/server/api/social/collections/[identifier]/[rkey].delete.ts
@@ -0,0 +1,6 @@
+/**
+ * DELETE /api/social/collections/:identifier/:rkey
+ */
+export default eventHandlerWithOAuthSession((event, session) =>
+ deleteCollection(session, getCollectionRouteParams(event)),
+)
diff --git a/server/api/social/collections/[identifier]/[rkey].get.ts b/server/api/social/collections/[identifier]/[rkey].get.ts
new file mode 100644
index 0000000000..213b329309
--- /dev/null
+++ b/server/api/social/collections/[identifier]/[rkey].get.ts
@@ -0,0 +1,19 @@
+/**
+ * GET /api/social/collections/:identifier/:rkey
+ *
+ * A collection with its items resolved.
+ */
+export default defineEventHandler(async event => {
+ const { identifier, rkey } = getCollectionRouteParams(event)
+
+ let collection
+ try {
+ collection = await new CollectionsUtils().getCollection(identifier, rkey)
+ } catch (error: unknown) {
+ handleApiError(error, { statusCode: 502, message: 'Failed to get collection' })
+ }
+ if (!collection) {
+ throw createError({ status: 404, message: 'Collection not found' })
+ }
+ return collection
+})
diff --git a/server/api/social/collections/[identifier]/[rkey].put.ts b/server/api/social/collections/[identifier]/[rkey].put.ts
new file mode 100644
index 0000000000..f782ccc279
--- /dev/null
+++ b/server/api/social/collections/[identifier]/[rkey].put.ts
@@ -0,0 +1,15 @@
+import * as v from 'valibot'
+import { CollectionUpdateBodySchema } from '#shared/schemas/social'
+
+/**
+ * PUT /api/social/collections/:identifier/:rkey
+ *
+ * Replaces a collection owned by the logged-in user. Fails with 409 if it changed since `cid`.
+ */
+export default eventHandlerWithOAuthSession(async (event, session) =>
+ updateCollection(
+ session,
+ getCollectionRouteParams(event),
+ v.parse(CollectionUpdateBodySchema, await readBody(event)),
+ ),
+)
diff --git a/server/api/social/collections/[identifier]/index.get.ts b/server/api/social/collections/[identifier]/index.get.ts
new file mode 100644
index 0000000000..cb78ba6a2f
--- /dev/null
+++ b/server/api/social/collections/[identifier]/index.get.ts
@@ -0,0 +1,15 @@
+/**
+ * GET /api/social/collections/:identifier
+ *
+ * Lists a user's collections, newest first, straight from their PDS.
+ */
+export default defineEventHandler(async event => {
+ const identifier = getRouterParam(event, 'identifier')!
+ const { cursor, limit } = getPaginationQuery(event)
+
+ try {
+ return await new CollectionsUtils().listUserCollections(identifier, limit, cursor)
+ } catch (error: unknown) {
+ handleApiError(error, { statusCode: 502, message: 'Failed to list collections' })
+ }
+})
diff --git a/server/api/social/collections/index.post.ts b/server/api/social/collections/index.post.ts
new file mode 100644
index 0000000000..ace90f5eb9
--- /dev/null
+++ b/server/api/social/collections/index.post.ts
@@ -0,0 +1,11 @@
+import * as v from 'valibot'
+import { CollectionBodySchema } from '#shared/schemas/social'
+
+/**
+ * POST /api/social/collections
+ *
+ * Creates a collection in the logged-in user's repo.
+ */
+export default eventHandlerWithOAuthSession(async (event, session) =>
+ createCollection(session, v.parse(CollectionBodySchema, await readBody(event))),
+)
diff --git a/server/api/social/collections/mine.get.ts b/server/api/social/collections/mine.get.ts
new file mode 100644
index 0000000000..f9f0c52a28
--- /dev/null
+++ b/server/api/social/collections/mine.get.ts
@@ -0,0 +1,13 @@
+/**
+ * GET /api/social/collections/mine
+ *
+ * The logged-in user's collections with their items, for adding packages to them.
+ */
+export default eventHandlerWithOAuthSession(async (_event, session) => {
+ const { did } = requireOAuthSession(session)
+ try {
+ return await new CollectionsUtils().listOwnCollections(did)
+ } catch (error: unknown) {
+ handleApiError(error, { statusCode: 502, message: 'Failed to list your collections' })
+ }
+})
diff --git a/server/api/social/collections/write-access.get.ts b/server/api/social/collections/write-access.get.ts
new file mode 100644
index 0000000000..664c893904
--- /dev/null
+++ b/server/api/social/collections/write-access.get.ts
@@ -0,0 +1,10 @@
+/**
+ * GET /api/social/collections/write-access
+ *
+ * 403 with ERROR_NEED_REAUTH when the session predates the collections scope, so the editor can
+ * re-auth before the user writes anything.
+ */
+export default eventHandlerWithOAuthSession(async (_event, session) => {
+ await requireCollectionWriteAccess(session)
+ return { ok: true }
+})
diff --git a/server/middleware/canonical-redirects.global.ts b/server/middleware/canonical-redirects.global.ts
index 254817e708..d6d576ae0b 100644
--- a/server/middleware/canonical-redirects.global.ts
+++ b/server/middleware/canonical-redirects.global.ts
@@ -20,6 +20,7 @@ const pages = [
'/accessibility',
'/blog',
'/brand',
+ '/collections',
'/compare',
'/noodles',
'/sponsors',
diff --git a/server/utils/atproto/oauth.ts b/server/utils/atproto/oauth.ts
index ad6440b0c5..f97d8a58ac 100644
--- a/server/utils/atproto/oauth.ts
+++ b/server/utils/atproto/oauth.ts
@@ -9,13 +9,13 @@ import type { EventHandlerRequest, H3Event, SessionManager } from 'h3'
import { NodeOAuthClient, AtprotoDohHandleResolver } from '@atproto/oauth-client-node'
import { getOAuthLock } from '#server/utils/atproto/lock'
import { useOAuthStorage } from '#server/utils/atproto/storage'
-import { LIKES_SCOPE, PROFILE_SCOPE } from '#shared/utils/constants'
+import { COLLECTIONS_SCOPE, LIKES_SCOPE, PROFILE_SCOPE } from '#shared/utils/constants'
import type { UserServerSession } from '#shared/types/userSession'
// @ts-expect-error virtual file from oauth module
import { clientUri } from '#oauth/config'
// TODO: If you add writing a new record you will need to add a scope for it
-export const scope = `atproto ${LIKES_SCOPE} ${PROFILE_SCOPE}`
+export const scope = `atproto ${LIKES_SCOPE} ${PROFILE_SCOPE} ${COLLECTIONS_SCOPE}`
/**
* Resolves a did to a handle via DoH or via the http website calls
diff --git a/server/utils/atproto/utils/collection-writes.ts b/server/utils/atproto/utils/collection-writes.ts
new file mode 100644
index 0000000000..0737f04453
--- /dev/null
+++ b/server/utils/atproto/utils/collection-writes.ts
@@ -0,0 +1,151 @@
+import type { OAuthSession } from '@atproto/oauth-client-node'
+import type { InferOutput } from 'valibot'
+import { Client, isDatetimeString, toDatetimeString, type UriString } from '@atproto/lex'
+import * as dev from '#shared/types/lexicons/dev'
+import type { CollectionBodySchema } from '#shared/schemas/social'
+import type { MiniDoc } from '#shared/types/social'
+import { COLLECTION_SUBJECT_REF, COLLECTIONS_SCOPE } from '#shared/utils/constants'
+import { throwOnMissingOAuthScope } from '#server/utils/atproto/oauth'
+
+/** Writes to collections, as the signed-in user. */
+
+type CollectionBody = InferOutput
+type CollectionRef = { identifier: string; rkey: string }
+type RawRecord = { cid: string; value: unknown }
+export type CollectionWriteResult = { uri: string; cid: string; did: string; rkey: string }
+
+export function requireOAuthSession(oAuthSession: OAuthSession | undefined) {
+ const did = oAuthSession?.did.toString()
+ if (!oAuthSession || !did) {
+ throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
+ }
+ return { oAuthSession, did }
+}
+
+/** Throws ERROR_NEED_REAUTH for sessions from before the collections scope existed. */
+export async function requireCollectionWriteAccess(session: OAuthSession | undefined) {
+ const { oAuthSession } = requireOAuthSession(session)
+ await throwOnMissingOAuthScope(oAuthSession, COLLECTIONS_SCOPE)
+}
+
+async function getWriter(session: OAuthSession | undefined, scope: string) {
+ const { oAuthSession, did } = requireOAuthSession(session)
+ await throwOnMissingOAuthScope(oAuthSession, scope)
+ return { client: new Client(oAuthSession), did }
+}
+
+async function resolveOwner({ identifier }: CollectionRef) {
+ return (await new IdentityUtils().getMiniDoc(identifier)) as MiniDoc
+}
+
+async function requireOwnCollection(ref: CollectionRef, did: string) {
+ const owner = await resolveOwner(ref)
+ if (owner.did !== did) {
+ throw createError({ status: 403, message: 'You can only change your own collections' })
+ }
+ return owner
+}
+
+const conflict = () =>
+ createError({ status: 409, message: 'Collection changed since it was loaded' })
+
+/**
+ * Builds the record to write. When editing, fields we don't know about are kept so we don't
+ * strip another client's extensions; items are replaced wholesale.
+ */
+export function buildCollectionRecord(body: CollectionBody, existing?: unknown) {
+ const base =
+ existing && typeof existing === 'object' && !Array.isArray(existing)
+ ? (existing as Record)
+ : {}
+ const { $type: _, description: __, ...preserved } = base
+ const createdAt =
+ typeof base.createdAt === 'string' && isDatetimeString(base.createdAt)
+ ? base.createdAt
+ : toDatetimeString(new Date())
+
+ return {
+ ...preserved,
+ name: body.name,
+ ...(body.description ? { description: body.description } : {}),
+ items: body.items.map(item => ({
+ subjectRef: item.subjectRef as UriString,
+ ...(item.note ? { note: item.note } : {}),
+ })),
+ createdAt,
+ }
+}
+type CollectionRecordValue = ReturnType
+
+const isInvalidSwapError = (error: unknown) =>
+ (error as { error?: unknown } | null)?.error === 'InvalidSwap'
+
+/** Caches our own write so the next read sees it rather than a stale cached copy. */
+async function afterWrite(
+ did: string,
+ rkey: string,
+ cid: string,
+ value: CollectionRecordValue,
+): Promise {
+ const record: dev.npmx.collection.Main = { $type: dev.npmx.collection.$nsid, ...value }
+ // Best-effort: the PDS already has the write, so a cache error mustn't fail the request
+ await new CollectionsUtils()
+ .setStoredRecord(did, rkey, { cid, value: record })
+ .catch(error => console.error('[collections] Caching a write failed', error))
+ return { uri: COLLECTION_SUBJECT_REF(did, rkey), cid, did, rkey }
+}
+
+/**
+ * Read-modify-write against the PDS, with `swapRecord` so a concurrent edit is never clobbered.
+ * `build` may throw to reject the current record.
+ */
+async function editCollection(
+ client: Client,
+ owner: MiniDoc,
+ rkey: string,
+ build: (existing: RawRecord) => CollectionRecordValue,
+) {
+ const existing = await new CollectionsUtils().fetchRawRecord(owner, rkey)
+ if (!existing) throw createError({ status: 404, message: 'Collection not found' })
+ const value = build(existing)
+ try {
+ const result = await client.put(dev.npmx.collection, value, {
+ rkey,
+ swapRecord: existing.cid,
+ })
+ return await afterWrite(owner.did, rkey, result.cid, value)
+ } catch (error) {
+ throw isInvalidSwapError(error) ? conflict() : error
+ }
+}
+
+export async function createCollection(session: OAuthSession | undefined, body: CollectionBody) {
+ const { client, did } = await getWriter(session, COLLECTIONS_SCOPE)
+ const value = buildCollectionRecord(body)
+ const result = await client.create(dev.npmx.collection, value)
+ return await afterWrite(did, result.uri.split('/').pop()!, result.cid, value)
+}
+
+/** Replaces a collection. Fails with 409 if it changed since `cid`, the version being edited. */
+export async function updateCollection(
+ session: OAuthSession | undefined,
+ ref: CollectionRef,
+ { cid, ...body }: CollectionBody & { cid: string },
+) {
+ const { client, did } = await getWriter(session, COLLECTIONS_SCOPE)
+ const owner = await requireOwnCollection(ref, did)
+ return await editCollection(client, owner, ref.rkey, existing => {
+ if (existing.cid !== cid) throw conflict()
+ return buildCollectionRecord(body, existing.value)
+ })
+}
+
+export async function deleteCollection(session: OAuthSession | undefined, ref: CollectionRef) {
+ const { client, did } = await getWriter(session, COLLECTIONS_SCOPE)
+ await requireOwnCollection(ref, did)
+ await client.delete(dev.npmx.collection, { rkey: ref.rkey })
+ await new CollectionsUtils()
+ .setStoredRecord(did, ref.rkey, null)
+ .catch(error => console.error('[collections] Caching a delete failed', error))
+ return { uri: COLLECTION_SUBJECT_REF(did, ref.rkey) }
+}
diff --git a/server/utils/atproto/utils/collections.ts b/server/utils/atproto/utils/collections.ts
new file mode 100644
index 0000000000..2b67bdc316
--- /dev/null
+++ b/server/utils/atproto/utils/collections.ts
@@ -0,0 +1,236 @@
+import { Client } from '@atproto/lex'
+import * as com from '#shared/types/lexicons/com'
+import * as dev from '#shared/types/lexicons/dev'
+import type {
+ CollectionItemView,
+ CollectionSummary,
+ CollectionUnavailableReason,
+ CollectionView,
+ MiniDoc,
+ OwnCollection,
+} from '#shared/types/social'
+import { parseSubjectRef } from '#shared/utils/collections'
+import { mapWithConcurrency } from '#shared/utils/async'
+import { CACHE_MAX_AGE_ONE_MINUTE, COLLECTION_SUBJECT_REF } from '#shared/utils/constants'
+
+const HEADERS = { 'User-Agent': 'npmx' }
+const COLLECTION_NSID = dev.npmx.collection.$nsid
+
+const CACHE_PREFIX = 'atproto-collections:'
+const CACHE_RECORD_KEY = (did: string, rkey: string) => `${CACHE_PREFIX}${did}:${rkey}:record`
+const CACHE_MAX_AGE = CACHE_MAX_AGE_ONE_MINUTE * 5
+
+// The owner's PDS is whatever their DID doc says, so it may be slow or hostile.
+const PDS_TIMEOUT_MS = 5_000
+const RESOLVE_CONCURRENCY = 16
+
+export type StoredRecord = { uri: string; cid: string; value: dev.npmx.collection.Main }
+
+export type CollectionsDeps = {
+ cache: CacheAdapter
+ resolveIdentity: (identifier: string) => Promise
+ /** Raw record from the owner's PDS, or null when it doesn't exist. Not yet validated. */
+ fetchRecord: (
+ miniDoc: MiniDoc,
+ rkey: string,
+ ) => Promise<{ uri: string; cid: string; value: unknown } | null>
+ listRecords: (
+ miniDoc: MiniDoc,
+ limit: number,
+ cursor?: string,
+ ) => Promise<{ cursor?: string; records: StoredRecord[] }>
+ packageExists: (packageName: string) => Promise
+}
+
+type IndexedCollection = Omit
+
+/** A record's listing fields, as browse indexes them and summaries show them. */
+function toCollectionFields(
+ did: string,
+ rkey: string,
+ cid: string,
+ value: dev.npmx.collection.Main,
+): IndexedCollection {
+ return {
+ uri: COLLECTION_SUBJECT_REF(did, rkey),
+ cid,
+ did,
+ rkey,
+ name: value.name,
+ description: value.description,
+ itemCount: value.items.length,
+ createdAt: value.createdAt,
+ }
+}
+
+export type Paginated = { cursor?: string; total?: number; items: T[] }
+
+function toSummary(miniDoc: MiniDoc, rkey: string, stored: StoredRecord): CollectionSummary {
+ return {
+ ...toCollectionFields(miniDoc.did, rkey, stored.cid, stored.value),
+ handle: miniDoc.handle,
+ }
+}
+
+function defaultDeps(): CollectionsDeps {
+ const identity = new IdentityUtils()
+ return {
+ cache: getCacheAdapter('generic'),
+ resolveIdentity: identifier => identity.getMiniDoc(identifier) as Promise,
+ fetchRecord: async (miniDoc, rkey) => {
+ const client = new Client(miniDoc.pds, { headers: HEADERS })
+ const response = await client.xrpcSafe(com.atproto.repo.getRecord, {
+ params: {
+ repo: miniDoc.did as `did:${string}:${string}`,
+ collection: COLLECTION_NSID,
+ rkey,
+ },
+ signal: AbortSignal.timeout(PDS_TIMEOUT_MS),
+ })
+ if (response.success) {
+ const { uri, cid, value } = response.body
+ return cid ? { uri, cid, value } : null
+ }
+ if (response.error === 'RecordNotFound') return null
+ throw createError({ status: 502, message: `Failed to fetch collection from PDS` })
+ },
+ listRecords: async (miniDoc, limit, cursor) => {
+ const client = new Client(miniDoc.pds, { headers: HEADERS })
+ const result = await client.list(dev.npmx.collection, {
+ repo: miniDoc.did as `did:${string}:${string}`,
+ limit,
+ cursor,
+ signal: AbortSignal.timeout(PDS_TIMEOUT_MS),
+ })
+ return {
+ // PDSes return a cursor even on the last page; a short page means there's no more
+ cursor: result.records.length < limit ? undefined : result.cursor,
+ // Invalid records come from other clients; skip them rather than fail the list
+ records: result.records.flatMap(record => {
+ const rkey = record.uri.split('/').pop()
+ if (!record.valid || !rkey) return []
+ const uri = COLLECTION_SUBJECT_REF(miniDoc.did, rkey)
+ return [{ uri, cid: record.cid, value: record.value }]
+ }),
+ }
+ },
+ packageExists: name => fetchNpmPackageExists(name),
+ }
+}
+
+/**
+ * Reads dev.npmx.collection records from any PDS and resolves them into views.
+ * Every record is untrusted: it may come from any client, so it is validated on read.
+ */
+export class CollectionsUtils {
+ private readonly deps: CollectionsDeps
+
+ constructor(deps?: CollectionsDeps) {
+ this.deps = deps ?? defaultDeps()
+ }
+
+ // Wrapped in an object: RedisCacheAdapter treats falsy values (null, 0, false) as misses
+ private async cached(key: string, load: () => Promise): Promise {
+ const hit = await this.deps.cache.get<{ value: T }>(key)
+ if (hit) return hit.value
+ const value = await load()
+ await this.setCached(key, value)
+ return value
+ }
+
+ private async setCached(key: string, value: T) {
+ await this.deps.cache.set(key, { value }, CACHE_MAX_AGE)
+ }
+
+ /** Validated record from cache or the owner's PDS; null when missing or invalid. */
+ getStoredRecord(miniDoc: MiniDoc, rkey: string): Promise {
+ return this.cached(CACHE_RECORD_KEY(miniDoc.did, rkey), async () => {
+ const raw = await this.deps.fetchRecord(miniDoc, rkey)
+ const result = raw && dev.npmx.collection.$safeValidate(raw.value)
+ if (!raw || !result?.success) return null
+ // Not `raw.uri`: a hostile PDS could claim another account's collection
+ return { uri: COLLECTION_SUBJECT_REF(miniDoc.did, rkey), cid: raw.cid, value: result.value }
+ })
+ }
+
+ /** Unvalidated record straight from the PDS, bypassing the cache. For read-modify-write. */
+ async fetchRawRecord(miniDoc: MiniDoc, rkey: string) {
+ return await this.deps.fetchRecord(miniDoc, rkey)
+ }
+
+ /** Call after writing a record so the next read sees it. */
+ async setStoredRecord(did: string, rkey: string, stored: Omit | null) {
+ // Built here, not taken from the PDS's write response, for the same reason as on read
+ const record = stored && { ...stored, uri: COLLECTION_SUBJECT_REF(did, rkey) }
+ await this.setCached(CACHE_RECORD_KEY(did, rkey), record)
+ }
+
+ async getCollection(identifier: string, rkey: string): Promise {
+ const miniDoc = await this.deps.resolveIdentity(identifier)
+ const stored = await this.getStoredRecord(miniDoc, rkey)
+ if (!stored) return null
+
+ const items = await mapWithConcurrency(
+ stored.value.items,
+ item => this.resolveItem(item.subjectRef, item.note),
+ RESOLVE_CONCURRENCY,
+ )
+ return { ...toSummary(miniDoc, rkey, stored), items }
+ }
+
+ // Included collections resolve to a summary, not their items, so a view's work is bounded by
+ // its own item count with no recursion, depth limit or cycle detection.
+ private async resolveItem(
+ subjectRef: string,
+ note: string | undefined,
+ ): Promise {
+ const subject = parseSubjectRef(subjectRef)
+ if (!subject) return { kind: 'unknown', subjectRef, note }
+
+ if (subject.kind === 'package') {
+ // Optimistic on registry errors: a flaky registry shouldn't grey out a whole collection
+ const resolved = await this.deps.packageExists(subject.packageName).catch(() => true)
+ return { kind: 'package', subjectRef, note, packageName: subject.packageName, resolved }
+ }
+
+ const unavailable = (unavailableReason: CollectionUnavailableReason) =>
+ ({ kind: 'collection', subjectRef, note, collection: null, unavailableReason }) as const
+ try {
+ const miniDoc = await this.deps.resolveIdentity(subject.did)
+ const stored = await this.getStoredRecord(miniDoc, subject.rkey)
+ if (!stored) return unavailable('unresolvable')
+ return {
+ kind: 'collection',
+ subjectRef,
+ note,
+ collection: toSummary(miniDoc, subject.rkey, stored),
+ }
+ } catch {
+ return unavailable('unresolvable')
+ }
+ }
+
+ async listUserCollections(
+ identifier: string,
+ limit: number,
+ cursor?: string,
+ ): Promise> {
+ const miniDoc = await this.deps.resolveIdentity(identifier)
+ const { cursor: nextCursor, records } = await this.deps.listRecords(miniDoc, limit, cursor)
+ return {
+ cursor: nextCursor,
+ items: records.map(record => toSummary(miniDoc, record.uri.split('/').pop()!, record)),
+ }
+ }
+
+ /** The first 100 of a user's collections with their items, straight from their PDS. */
+ async listOwnCollections(did: string): Promise {
+ const miniDoc = await this.deps.resolveIdentity(did)
+ const { records } = await this.deps.listRecords(miniDoc, 100)
+ return records.map(record =>
+ Object.assign(toSummary(miniDoc, record.uri.split('/').pop()!, record), {
+ subjectRefs: record.value.items.map(item => item.subjectRef),
+ }),
+ )
+ }
+}
diff --git a/server/utils/atproto/utils/request.ts b/server/utils/atproto/utils/request.ts
new file mode 100644
index 0000000000..b6b300fe93
--- /dev/null
+++ b/server/utils/atproto/utils/request.ts
@@ -0,0 +1,23 @@
+import type { H3Event } from 'h3'
+import { getQuery, getRouterParam } from 'h3'
+import { isTidString } from '@atproto/lex'
+
+export function getPaginationQuery(event: H3Event, defaultLimit = 20, maxLimit = 100) {
+ const query = getQuery(event)
+ const cursor = typeof query.cursor === 'string' && query.cursor ? query.cursor : undefined
+ const parsedLimit = typeof query.limit === 'string' ? Number(query.limit) : NaN
+ const limit = Number.isNaN(parsedLimit)
+ ? defaultLimit
+ : Math.min(Math.max(Math.trunc(parsedLimit), 1), maxLimit)
+ return { cursor, limit }
+}
+
+/** The `identifier` and `rkey` params of `[identifier]/[rkey]` routes. */
+export function getCollectionRouteParams(event: H3Event) {
+ // The router guarantees both; getMiniDoc validates the identifier.
+ const identifier = getRouterParam(event, 'identifier')!
+ const rkey = getRouterParam(event, 'rkey')!
+ // We only ever create TID keys; anything else isn't one of ours to serve.
+ if (!isTidString(rkey)) throw createError({ status: 400, message: 'Invalid collection key' })
+ return { identifier, rkey }
+}
diff --git a/server/utils/npm.ts b/server/utils/npm.ts
index 6f33fa9515..0df9c7b4e4 100644
--- a/server/utils/npm.ts
+++ b/server/utils/npm.ts
@@ -14,6 +14,28 @@ export const fetchNpmPackage = defineCachedFunction(
},
)
+/**
+ * Whether a package exists on the registry. Throws on anything other than a 404 so a registry
+ * outage isn't cached as "doesn't exist".
+ */
+export const fetchNpmPackageExists = defineCachedFunction(
+ async (name: string): Promise => {
+ try {
+ await $fetch(`${NPM_REGISTRY}/${encodePackageName(name)}`, { method: 'HEAD' })
+ return true
+ } catch (error) {
+ if ((error as { statusCode?: number }).statusCode === 404) return false
+ throw error
+ }
+ },
+ {
+ maxAge: CACHE_MAX_AGE_ONE_HOUR,
+ swr: true,
+ name: 'npm-package-exists',
+ getKey: (name: string) => name,
+ },
+)
+
/** Registry media type for the abbreviated ("install") packument. */
const NPM_INSTALL_V1 = 'application/vnd.npm.install-v1+json'
diff --git a/shared/schemas/social.ts b/shared/schemas/social.ts
index 77b010a9e9..e30742454f 100644
--- a/shared/schemas/social.ts
+++ b/shared/schemas/social.ts
@@ -1,5 +1,12 @@
import * as v from 'valibot'
import { PackageNameSchema } from './package'
+import { parseSubjectRef } from '../utils/collections'
+import {
+ MAX_COLLECTION_DESCRIPTION_LENGTH,
+ MAX_COLLECTION_ITEMS,
+ MAX_COLLECTION_NAME_LENGTH,
+ MAX_COLLECTION_NOTE_LENGTH,
+} from '../utils/constants'
/**
* Schema for liking/unliking a package
@@ -8,6 +15,40 @@ export const PackageLikeBodySchema = v.object({
packageName: PackageNameSchema,
})
+// Limits mirror lexicons/dev/npmx/collection.json
+const CollectionItemSchema = v.object({
+ subjectRef: v.pipe(
+ v.string(),
+ v.maxBytes(300),
+ v.check(subjectRef => parseSubjectRef(subjectRef) !== null, 'Invalid package or collection'),
+ ),
+ note: v.optional(v.pipe(v.string(), v.maxBytes(560), v.maxGraphemes(MAX_COLLECTION_NOTE_LENGTH))),
+})
+
+/**
+ * Schema for creating or updating a collection through npmx.dev. Stricter than the lexicon
+ * (non-empty name, known subjectRef kinds), since we only want to write records we can render.
+ */
+export const CollectionBodySchema = v.object({
+ name: v.pipe(
+ v.string(),
+ v.trim(),
+ v.nonEmpty(),
+ v.maxLength(640),
+ v.maxGraphemes(MAX_COLLECTION_NAME_LENGTH),
+ ),
+ description: v.optional(
+ v.pipe(v.string(), v.maxLength(3000), v.maxGraphemes(MAX_COLLECTION_DESCRIPTION_LENGTH)),
+ ),
+ items: v.pipe(v.array(CollectionItemSchema), v.maxLength(MAX_COLLECTION_ITEMS)),
+})
+
+/** `cid` is the version the editor started from; a stale one makes the write fail with 409. */
+export const CollectionUpdateBodySchema = v.object({
+ ...CollectionBodySchema.entries,
+ cid: v.pipe(v.string(), v.nonEmpty()),
+})
+
// TODO: add 'avatar'
export const ProfileEditBodySchema = v.object({
displayName: v.pipe(v.string(), v.maxLength(640)),
diff --git a/shared/types/social.ts b/shared/types/social.ts
index b74e6595e8..e7f8edf3ce 100644
--- a/shared/types/social.ts
+++ b/shared/types/social.ts
@@ -26,6 +26,53 @@ export type LikesLeaderboardEntry = {
homepageLogoHeight: number | null
}
+/** A collection as listed (profile, browse), without resolved items */
+export type CollectionSummary = {
+ uri: string
+ cid: string
+ did: string
+ handle: string
+ rkey: string
+ name: string
+ description?: string
+ itemCount: number
+ createdAt: string
+}
+
+/** Why an included collection could not be shown */
+export type CollectionUnavailableReason = 'unresolvable'
+
+export type CollectionItemView =
+ | {
+ kind: 'package'
+ subjectRef: string
+ note?: string
+ packageName: string
+ // False when the package doesn't exist on the registry (typo, unpublished, spam)
+ resolved: boolean
+ }
+ | {
+ kind: 'collection'
+ subjectRef: string
+ note?: string
+ // A summary, not expanded: readers follow the link to see what's in it
+ collection: CollectionSummary | null
+ unavailableReason?: CollectionUnavailableReason
+ }
+ | {
+ // subjectRef we don't understand; records are untrusted
+ kind: 'unknown'
+ subjectRef: string
+ note?: string
+ }
+
+/** One of the viewer's own collections, with what's in it. */
+export type OwnCollection = CollectionSummary & { subjectRefs: string[] }
+
+export type CollectionView = CollectionSummary & {
+ items: CollectionItemView[]
+}
+
/**
* A shortened DID Doc for AT Protocol accounts
* Returned by Slingshot's `/xrpc/blue.microcosm.identity.resolveMiniDoc` endpoint
diff --git a/shared/utils/collections.ts b/shared/utils/collections.ts
new file mode 100644
index 0000000000..8cf6b244b0
--- /dev/null
+++ b/shared/utils/collections.ts
@@ -0,0 +1,30 @@
+import * as v from 'valibot'
+import { PackageNameSchema } from '../schemas/package'
+import { NPMX_SITE } from './constants'
+import * as dev from '../types/lexicons/dev'
+
+export type CollectionSubject =
+ | { kind: 'package'; packageName: string }
+ | { kind: 'collection'; uri: string; did: string; rkey: string }
+
+const PACKAGE_SUBJECT_PREFIX = `${NPMX_SITE}/package/`
+// DIDs only: records must not reference accounts by handle, which can change.
+const COLLECTION_AT_URI_RE = new RegExp(
+ `^at://(did:[a-z]+:[a-zA-Z0-9._:%-]+)/${dev.npmx.collection.$nsid.replaceAll('.', '\\.')}/([a-zA-Z0-9._:~-]{1,512})$`,
+)
+
+/**
+ * Classifies a collection item's or like's `subjectRef`. Returns null for anything we don't
+ * understand; records are untrusted, so callers render that as unresolvable.
+ */
+export function parseSubjectRef(subjectRef: string): CollectionSubject | null {
+ if (subjectRef.startsWith(PACKAGE_SUBJECT_PREFIX)) {
+ const packageName = subjectRef.slice(PACKAGE_SUBJECT_PREFIX.length)
+ return v.is(PackageNameSchema, packageName) ? { kind: 'package', packageName } : null
+ }
+ const match = COLLECTION_AT_URI_RE.exec(subjectRef)
+ if (match) {
+ return { kind: 'collection', uri: subjectRef, did: match[1]!, rkey: match[2]! }
+ }
+ return null
+}
diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts
index 402eb41606..26aa00ba0c 100644
--- a/shared/utils/constants.ts
+++ b/shared/utils/constants.ts
@@ -65,6 +65,16 @@ export const PACKAGE_SUBJECT_REF = (packageName: string) =>
// 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}`
export const PROFILE_SCOPE = `repo:${dev.npmx.actor.profile.$nsid}`
+export const COLLECTIONS_SCOPE = `repo:${dev.npmx.collection.$nsid}`
+export const COLLECTION_SUBJECT_REF = (did: string, rkey: string) =>
+ `at://${did}/${dev.npmx.collection.$nsid}/${rkey}`
+// Keep in sync with lexicons/dev/npmx/collection.json
+// With the per-item limits there, a full collection stays under 1MB, the usual record size cap
+export const MAX_COLLECTION_ITEMS = 1000
+// In graphemes
+export const MAX_COLLECTION_NAME_LENGTH = 64
+export const MAX_COLLECTION_DESCRIPTION_LENGTH = 300
+export const MAX_COLLECTION_NOTE_LENGTH = 140
export const NPMX_DEV_DID = 'did:plc:u5zp7npt5kpueado77kuihyz'
export const TID_CLOCK_ID = 3
diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts
index f38cf42cc5..05c2d97171 100644
--- a/test/nuxt/a11y.spec.ts
+++ b/test/nuxt/a11y.spec.ts
@@ -360,7 +360,13 @@ import FacetBarChart from '~/components/Compare/FacetBarChart.vue'
import FacetScatterChart from '~/components/Compare/FacetScatterChart.vue'
import PackageTimelineChart from '~/components/Package/TimelineChart.vue'
import PackageLikeCard from '~/components/Package/LikeCard.vue'
+import CollectionCard from '~/components/Collection/Card.vue'
+import CollectionGrid from '~/components/Collection/Grid.vue'
+import CollectionEditor from '~/components/Collection/Editor.vue'
import PackageTypeahead from '~/components/Package/Typeahead.vue'
+import CollectionItemList from '~/components/Collection/ItemList.vue'
+import CollectionYourData from '~/components/Collection/YourData.vue'
+import type { CollectionItemView, CollectionSummary } from '#shared/types/social'
import SizeIncrease from '~/components/Package/SizeIncrease.vue'
import SizeDecrease from '~/components/Package/SizeDecrease.vue'
import Likes from '~/components/Package/Likes.vue'
@@ -1266,6 +1272,125 @@ describe('component accessibility audits', () => {
})
})
+ describe('collections', () => {
+ const summary: CollectionSummary = {
+ uri: 'at://did:plc:u5zp7npt5kpueado77kuihyz/dev.npmx.collection/3m5k5baifas2a',
+ cid: 'cid',
+ did: 'did:plc:u5zp7npt5kpueado77kuihyz',
+ handle: 'npmx.dev',
+ rkey: '3m5k5baifas2a',
+ name: 'Arg parsers',
+ description: 'Pick one',
+ itemCount: 3,
+ createdAt: '2026-11-01T00:00:00.000Z',
+ }
+ const items: CollectionItemView[] = [
+ {
+ kind: 'package',
+ subjectRef: 'https://npmx.dev/package/commander',
+ packageName: 'commander',
+ resolved: true,
+ note: 'The default',
+ },
+ {
+ kind: 'package',
+ subjectRef: 'https://npmx.dev/package/does-not-exist',
+ packageName: 'does-not-exist',
+ resolved: false,
+ },
+ {
+ kind: 'collection',
+ subjectRef: 'at://did:plc:bob/dev.npmx.collection/3m5k5baifas2b',
+ collection: {
+ ...summary,
+ name: 'More parsers',
+ },
+ },
+ {
+ kind: 'collection',
+ subjectRef: 'at://did:plc:gone/dev.npmx.collection/3m5k5baifas2c',
+ collection: null,
+ unavailableReason: 'unresolvable',
+ },
+ { kind: 'unknown', subjectRef: 'https://example.com/whatever' },
+ ]
+
+ it('CollectionCard should have no accessibility violations', async () => {
+ const component = await mountSuspended(CollectionCard, { props: { collection: summary } })
+ const results = await runAxe(component)
+ expect(results.violations).toEqual([])
+ })
+
+ it('CollectionGrid should have no accessibility violations', async () => {
+ const component = await mountSuspended(CollectionGrid, {
+ props: {
+ items: [summary],
+ pending: false,
+ loadingMore: false,
+ error: false,
+ hasMore: true,
+ emptyText: 'No collections',
+ },
+ })
+ const results = await runAxe(component)
+ expect(results.violations).toEqual([])
+ })
+
+ it('CollectionItemList should have no accessibility violations', async () => {
+ const component = await mountSuspended(CollectionItemList, { props: { items } })
+ const results = await runAxe(component)
+ expect(results.violations).toEqual([])
+ })
+
+ it('CollectionItemList should have no accessibility violations with package cards', async () => {
+ const commander = {
+ package: { name: 'commander', version: '1.0.0', date: '2026-10-01', links: {} },
+ downloads: { weekly: 1000 },
+ }
+ const component = await mountSuspended(CollectionItemList, {
+ props: { items, packages: new Map([['commander', commander]]) },
+ })
+ expect(component.text()).toContain('v1.0.0')
+ const results = await runAxe(component)
+ expect(results.violations).toEqual([])
+ })
+
+ it('CollectionYourData should have no accessibility violations', async () => {
+ const component = await mountSuspended(CollectionYourData)
+ await component.find('summary').trigger('click')
+ const results = await runAxe(component)
+ expect(results.violations).toEqual([])
+ })
+
+ it('CollectionEditor should have no accessibility violations', async () => {
+ const component = await mountSuspended(CollectionEditor, {
+ props: {
+ initial: {
+ name: 'Arg parsers',
+ items: [
+ {
+ kind: 'package',
+ subjectRef: 'https://npmx.dev/package/commander',
+ packageName: 'commander',
+ resolved: true,
+ note: 'x',
+ },
+ {
+ kind: 'package',
+ subjectRef: 'https://npmx.dev/package/yargs',
+ packageName: 'yargs',
+ resolved: true,
+ },
+ ],
+ },
+ errorMessage: 'Something went wrong',
+ },
+ })
+ const results = await runAxe(component)
+ expect(results.violations).toEqual([])
+ })
+ })
+
describe('PackageLikeCard', () => {
it('should have no accessibility violations', async () => {
const component = await mountSuspended(PackageLikeCard, {
diff --git a/test/nuxt/components/Collection/Editor.spec.ts b/test/nuxt/components/Collection/Editor.spec.ts
new file mode 100644
index 0000000000..d5453ee2db
--- /dev/null
+++ b/test/nuxt/components/Collection/Editor.spec.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it, vi } from 'vite-plus/test'
+import { mountSuspended } from '@nuxt/test-utils/runtime'
+import CollectionEditor from '~/components/Collection/Editor.vue'
+import { PACKAGE_SUBJECT_REF } from '#shared/utils/constants'
+
+vi.stubGlobal('$fetch', vi.fn().mockResolvedValue({ objects: [], total: 0, time: '' }))
+
+describe('CollectionEditor', () => {
+ it('says why a collection from another app cannot be saved yet', async () => {
+ const component = await mountSuspended(CollectionEditor, {
+ props: {
+ initial: {
+ name: 'From elsewhere',
+ items: [
+ {
+ kind: 'package',
+ subjectRef: PACKAGE_SUBJECT_REF('zod'),
+ packageName: 'zod',
+ resolved: true,
+ },
+ { kind: 'unknown', subjectRef: 'https://example.com/thing' },
+ ],
+ },
+ },
+ })
+ expect(component.find('[role="alert"]').exists()).toBe(false)
+ expect(component.text()).toContain('Not recognized')
+
+ await component.find('form').trigger('submit')
+
+ expect(component.emitted('submit')).toBeUndefined()
+ expect(component.find('[role="alert"]').text()).toContain('Remove the items marked')
+
+ const removeButtons = component.findAll('button[aria-label^="Remove"]')
+ await removeButtons[1]!.trigger('click')
+ expect(component.find('[role="alert"]').exists()).toBe(false)
+
+ await component.find('form').trigger('submit')
+ expect(component.emitted('submit')).toHaveLength(1)
+ })
+})
diff --git a/test/nuxt/composables/use-command-palette-commands.spec.ts b/test/nuxt/composables/use-command-palette-commands.spec.ts
index 86c108465f..718a70d529 100644
--- a/test/nuxt/composables/use-command-palette-commands.spec.ts
+++ b/test/nuxt/composables/use-command-palette-commands.spec.ts
@@ -345,6 +345,46 @@ describe('useCommandPaletteCommands', () => {
wrapper.unmount()
})
+ it("matches the user's own collections while searching, and only then", async () => {
+ registerEndpoint('/api/social/collections/mine', () => [
+ {
+ uri: 'at://did:plc:alice.bsky.social/dev.npmx.collection/3m5k5baifas2a',
+ cid: 'cid',
+ did: 'did:plc:alice.bsky.social',
+ handle: 'alice.bsky.social',
+ rkey: '3m5k5baifas2a',
+ name: 'Arg parsers',
+ itemCount: 0,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ subjectRefs: [],
+ },
+ ])
+ const { wrapper, groupedCommands } = await captureCommandPalette({
+ atprotoHandle: 'alice.bsky.social',
+ query: 'parsers',
+ })
+ const commandPalette = useCommandPalette()
+ commandPalette.open()
+ commandPalette.query.value = 'parsers'
+
+ await vi.waitFor(() => {
+ const group = groupedCommands.value.find(g => g.id === 'collections')
+ expect(group?.items.map(command => command.to)).toEqual([
+ {
+ name: 'collections-identifier-rkey',
+ params: { identifier: 'alice.bsky.social', rkey: '3m5k5baifas2a' },
+ },
+ ])
+ })
+
+ commandPalette.query.value = ''
+ await vi.waitFor(() => {
+ expect(groupedCommands.value.find(g => g.id === 'collections')).toBeUndefined()
+ })
+
+ wrapper.unmount()
+ })
+
it('filters only version commands when the query is a valid semver range', async () => {
const { wrapper, groupedCommands, flatCommands } = await captureCommandPalette({
route: '/package/vue',
diff --git a/test/unit/server/utils/collection-writes.spec.ts b/test/unit/server/utils/collection-writes.spec.ts
new file mode 100644
index 0000000000..c2b91c2c63
--- /dev/null
+++ b/test/unit/server/utils/collection-writes.spec.ts
@@ -0,0 +1,154 @@
+import { afterAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
+import { createError } from 'h3'
+import type * as AtprotoLex from '@atproto/lex'
+import { PACKAGE_SUBJECT_REF } from '#shared/utils/constants'
+
+const OWNER = 'did:plc:owner'
+const VIEWER = 'did:plc:viewer'
+const RKEY = '3m5k5baifas2a'
+const URI = `at://${OWNER}/dev.npmx.collection/${RKEY}`
+const REF = { identifier: OWNER, rkey: RKEY }
+const ZOD = PACKAGE_SUBJECT_REF('zod')
+
+const client = { create: vi.fn(), put: vi.fn(), delete: vi.fn() }
+vi.mock('@atproto/lex', async importOriginal => ({
+ ...(await importOriginal()),
+ Client: class {
+ create = client.create
+ put = client.put
+ delete = client.delete
+ },
+}))
+const throwOnMissingOAuthScope = vi.fn()
+vi.mock('#server/utils/atproto/oauth', () => ({ throwOnMissingOAuthScope }))
+
+const utils = {
+ fetchRawRecord: vi.fn(),
+ setStoredRecord: vi.fn(async () => {}),
+ getStoredRecord: vi.fn(),
+ getViewerReaction: vi.fn(),
+ getReaction: vi.fn(),
+ recordReaction: vi.fn(async () => ({ total: 1, viewerHas: true })),
+ recordUnreaction: vi.fn(async () => ({ total: 0, viewerHas: false })),
+}
+vi.stubGlobal('createError', createError)
+vi.stubGlobal(
+ 'IdentityUtils',
+ class {
+ getMiniDoc = async () => ({ did: OWNER, handle: 'owner.test', pds: 'https://pds.test' })
+ },
+)
+// Called with `new`; returning an object makes that the instance
+vi.stubGlobal('CollectionsUtils', function CollectionsUtils() {
+ return utils
+})
+
+const writes = await import('#server/utils/atproto/utils/collection-writes')
+
+const sessionFor = (did: string) => ({ did: { toString: () => did } }) as never
+const stored = (cid: string, subjectRefs: string[], extra: Record = {}) => ({
+ uri: URI,
+ cid,
+ value: {
+ $type: 'dev.npmx.collection',
+ name: 'Validation',
+ description: 'Old description',
+ items: subjectRefs.map(subjectRef => ({ subjectRef, note: `why ${subjectRef}` })),
+ createdAt: '2026-01-01T00:00:00.000Z',
+ ...extra,
+ },
+})
+const swapError = () => Object.assign(new Error('swap'), { error: 'InvalidSwap' })
+
+afterAll(() => {
+ vi.unstubAllGlobals()
+})
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ utils.fetchRawRecord.mockResolvedValue(stored('cid-1', [ZOD]))
+ utils.getStoredRecord.mockResolvedValue(stored('cid-1', [ZOD]))
+ utils.getViewerReaction.mockResolvedValue(null)
+ client.put.mockResolvedValue({ uri: URI, cid: 'cid-2' })
+})
+
+describe('updateCollection', () => {
+ const body = {
+ name: 'Parsers',
+ items: [{ subjectRef: PACKAGE_SUBJECT_REF('commander'), note: 'default' }],
+ cid: 'cid-1',
+ }
+
+ it('requires a session and the collections scope', async () => {
+ await expect(writes.updateCollection(undefined, REF, body)).rejects.toMatchObject({
+ statusCode: 401,
+ })
+ throwOnMissingOAuthScope.mockRejectedValueOnce(createError({ status: 403 }))
+ await expect(writes.updateCollection(sessionFor(OWNER), REF, body)).rejects.toMatchObject({
+ statusCode: 403,
+ })
+ expect(client.put).not.toHaveBeenCalled()
+ })
+
+ it("rejects editing someone else's collection", async () => {
+ await expect(writes.updateCollection(sessionFor(VIEWER), REF, body)).rejects.toMatchObject({
+ statusCode: 403,
+ })
+ expect(client.put).not.toHaveBeenCalled()
+ })
+
+ it('returns 409 when the editor started from a stale version', async () => {
+ await expect(
+ writes.updateCollection(sessionFor(OWNER), REF, { ...body, cid: 'cid-0' }),
+ ).rejects.toMatchObject({ statusCode: 409 })
+ expect(client.put).not.toHaveBeenCalled()
+ })
+
+ it('returns 409 without retrying when the PDS rejects the swap', async () => {
+ client.put.mockRejectedValueOnce(swapError())
+ await expect(writes.updateCollection(sessionFor(OWNER), REF, body)).rejects.toMatchObject({
+ statusCode: 409,
+ })
+ expect(client.put).toHaveBeenCalledTimes(1)
+ })
+
+ it('writes with swapRecord, keeps createdAt and unknown fields, and caches the write', async () => {
+ utils.fetchRawRecord.mockResolvedValue(
+ stored('cid-1', [], { 'com.example.extension': { keep: true } }),
+ )
+ // The PDS's reported URI is ignored, so a hostile PDS can't claim another account's record
+ client.put.mockResolvedValue({ uri: 'at://did:plc:victim/dev.npmx.collection/x', cid: 'cid-2' })
+
+ expect(await writes.updateCollection(sessionFor(OWNER), REF, body)).toEqual({
+ uri: URI,
+ cid: 'cid-2',
+ did: OWNER,
+ rkey: RKEY,
+ })
+ const [, value, options] = client.put.mock.calls[0]!
+ expect(options).toEqual({ rkey: RKEY, swapRecord: 'cid-1' })
+ expect(value).toEqual({
+ 'com.example.extension': { keep: true },
+ 'name': 'Parsers',
+ 'items': [{ subjectRef: PACKAGE_SUBJECT_REF('commander'), note: 'default' }],
+ 'createdAt': '2026-01-01T00:00:00.000Z',
+ })
+ expect(utils.setStoredRecord).toHaveBeenCalledWith(OWNER, RKEY, {
+ cid: 'cid-2',
+ value: { $type: 'dev.npmx.collection', ...value },
+ })
+ })
+})
+
+describe('buildCollectionRecord', () => {
+ it('stamps createdAt for new records and omits empty optional fields', () => {
+ expect(
+ writes.buildCollectionRecord({ name: 'x', description: '', items: [{ subjectRef: ZOD }] }),
+ ).toEqual({ name: 'x', items: [{ subjectRef: ZOD }], createdAt: expect.any(String) })
+ })
+
+ it('replaces an invalid existing createdAt', () => {
+ const record = writes.buildCollectionRecord({ name: 'x', items: [] }, { createdAt: 'nope' })
+ expect(record.createdAt).not.toBe('nope')
+ })
+})
diff --git a/test/unit/server/utils/collections.spec.ts b/test/unit/server/utils/collections.spec.ts
new file mode 100644
index 0000000000..08bd774cda
--- /dev/null
+++ b/test/unit/server/utils/collections.spec.ts
@@ -0,0 +1,183 @@
+import { describe, expect, it, vi } from 'vite-plus/test'
+import type { CacheAdapter } from '#server/utils/cache/shared'
+import type { MiniDoc } from '#shared/types/social'
+import { COLLECTION_SUBJECT_REF, PACKAGE_SUBJECT_REF } from '#shared/utils/constants'
+
+const { CollectionsUtils } = await import('#server/utils/atproto/utils/collections')
+
+type Deps = NonNullable[0]>
+
+const ALICE = 'did:plc:alice'
+const BOB = 'did:plc:bob'
+const MALLORY = 'did:plc:mallory'
+const RKEY_A = '3m5k5baifas2a'
+const RKEY_B = '3m5k5baifas2b'
+const RKEY_C = '3m5k5baifas2c'
+
+// Mirrors RedisCacheAdapter, which returns undefined for falsy values
+function memoryCache(): CacheAdapter {
+ const store = new Map()
+ return {
+ get: async (key: string) => (store.get(key) || undefined) as T | undefined,
+ set: async (key, value) => void store.set(key, value),
+ delete: async key => void store.delete(key),
+ }
+}
+
+const miniDoc = (did: string): MiniDoc => ({
+ did,
+ handle: `${did.split(':').pop()}.test`,
+ pds: `https://pds.${did.split(':').pop()}.test`,
+ signing_key: 'key',
+})
+
+const record = (name: string, subjectRefs: string[]) => ({
+ $type: 'dev.npmx.collection',
+ name,
+ items: subjectRefs.map(subjectRef => ({ subjectRef })),
+ createdAt: '2026-11-01T00:00:00.000Z',
+})
+
+type RecordMap = Record
+const key = (did: string, rkey: string) => `${did}/${rkey}`
+
+function setup(records: RecordMap, overrides: Partial = {}) {
+ const fetchRecord = vi.fn(async (doc: MiniDoc, rkey: string) => {
+ const value = records[key(doc.did, rkey)]
+ return value ? { uri: COLLECTION_SUBJECT_REF(doc.did, rkey), cid: `cid-${rkey}`, value } : null
+ })
+ const deps: Deps = {
+ cache: memoryCache(),
+ resolveIdentity: async identifier => miniDoc(identifier),
+ fetchRecord,
+ listRecords: async () => ({ records: [] }),
+ packageExists: async name => name !== 'does-not-exist',
+ ...overrides,
+ }
+ return { utils: new CollectionsUtils(deps), fetchRecord }
+}
+
+describe('CollectionsUtils.getCollection', () => {
+ it('resolves package items and flags unknown packages', async () => {
+ const { utils } = setup({
+ [key(ALICE, RKEY_A)]: record('Parsers', [
+ PACKAGE_SUBJECT_REF('commander'),
+ PACKAGE_SUBJECT_REF('does-not-exist'),
+ 'https://example.com/whatever',
+ ]),
+ })
+
+ const view = await utils.getCollection(ALICE, RKEY_A)
+
+ expect(view?.name).toBe('Parsers')
+ expect(view?.itemCount).toBe(3)
+ expect(view?.items).toEqual([
+ expect.objectContaining({ kind: 'package', packageName: 'commander', resolved: true }),
+ expect.objectContaining({ kind: 'package', packageName: 'does-not-exist', resolved: false }),
+ expect.objectContaining({ kind: 'unknown', subjectRef: 'https://example.com/whatever' }),
+ ])
+ })
+
+ it('treats registry errors as resolved', async () => {
+ const { utils } = setup(
+ { [key(ALICE, RKEY_A)]: record('x', [PACKAGE_SUBJECT_REF('zod')]) },
+ { packageExists: async () => Promise.reject(new Error('registry down')) },
+ )
+ const view = await utils.getCollection(ALICE, RKEY_A)
+ expect(view?.items[0]).toMatchObject({ kind: 'package', resolved: true })
+ })
+
+ it('returns null for missing and invalid records', async () => {
+ const { utils } = setup({ [key(ALICE, RKEY_B)]: { name: 'no items or createdAt' } })
+ expect(await utils.getCollection(ALICE, RKEY_A)).toBeNull()
+ expect(await utils.getCollection(ALICE, RKEY_B)).toBeNull()
+ })
+
+ it("names records by the owner's DID, not the URI their PDS reports", async () => {
+ const { utils } = setup(
+ {},
+ {
+ fetchRecord: async () => ({
+ uri: COLLECTION_SUBJECT_REF(BOB, RKEY_A),
+ cid: 'cid',
+ value: record('x', []),
+ }),
+ },
+ )
+ const view = await utils.getCollection(MALLORY, RKEY_A)
+ expect(view?.uri).toBe(COLLECTION_SUBJECT_REF(MALLORY, RKEY_A))
+ })
+
+ it('builds the URI itself when caching a write', async () => {
+ const { utils, fetchRecord } = setup({})
+ await utils.setStoredRecord(MALLORY, RKEY_A, { cid: 'cid', value: record('x', []) as never })
+ const view = await utils.getCollection(MALLORY, RKEY_A)
+ expect(fetchRecord).not.toHaveBeenCalled()
+ expect(view?.uri).toBe(COLLECTION_SUBJECT_REF(MALLORY, RKEY_A))
+ })
+
+ it('shows included collections as summaries without expanding them', async () => {
+ const { utils, fetchRecord } = setup({
+ [key(ALICE, RKEY_A)]: record('A', [COLLECTION_SUBJECT_REF(BOB, RKEY_B)]),
+ [key(BOB, RKEY_B)]: record('B', [COLLECTION_SUBJECT_REF(ALICE, RKEY_A)]),
+ })
+
+ const view = await utils.getCollection(ALICE, RKEY_A)
+
+ expect(view?.items).toEqual([
+ expect.objectContaining({
+ kind: 'collection',
+ collection: expect.objectContaining({ name: 'B', handle: 'bob.test', itemCount: 1 }),
+ }),
+ ])
+ expect(view?.items[0]).not.toHaveProperty('collection.items')
+ // B's own include of A isn't followed, so cycles can't happen
+ expect(fetchRecord).toHaveBeenCalledTimes(2)
+ })
+
+ it('marks unresolvable nested collections', async () => {
+ const { utils } = setup({
+ [key(ALICE, RKEY_A)]: record('A', [COLLECTION_SUBJECT_REF(BOB, RKEY_C)]),
+ })
+
+ const view = await utils.getCollection(ALICE, RKEY_A)
+
+ expect(view?.items).toEqual([
+ expect.objectContaining({ collection: null, unavailableReason: 'unresolvable' }),
+ ])
+ })
+
+ it('caches records, including misses', async () => {
+ const { utils, fetchRecord } = setup({ [key(ALICE, RKEY_A)]: record('A', []) })
+ await utils.getCollection(ALICE, RKEY_A)
+ await utils.getCollection(ALICE, RKEY_A)
+ await utils.getCollection(ALICE, RKEY_B)
+ await utils.getCollection(ALICE, RKEY_B)
+ expect(fetchRecord).toHaveBeenCalledTimes(2)
+ })
+})
+
+describe('CollectionsUtils.listUserCollections', () => {
+ it('returns summaries with the record key', async () => {
+ const { utils } = setup(
+ {},
+ {
+ listRecords: async doc => ({
+ cursor: 'c2',
+ records: [
+ {
+ uri: COLLECTION_SUBJECT_REF(doc.did, RKEY_A),
+ cid: 'cid',
+ value: record('A', [PACKAGE_SUBJECT_REF('zod')]) as never,
+ },
+ ],
+ }),
+ },
+ )
+ const result = await utils.listUserCollections(ALICE, 20)
+ expect(result).toEqual({
+ cursor: 'c2',
+ items: [expect.objectContaining({ rkey: RKEY_A, name: 'A', itemCount: 1, did: ALICE })],
+ })
+ })
+})
diff --git a/test/unit/server/utils/npm.spec.ts b/test/unit/server/utils/npm.spec.ts
index 43d8ad5e3a..cac336983b 100644
--- a/test/unit/server/utils/npm.spec.ts
+++ b/test/unit/server/utils/npm.spec.ts
@@ -7,10 +7,12 @@ vi.stubGlobal('$fetch', $fetchMock)
vi.stubGlobal('encodePackageName', (name: string) => name.replace('/', '%2F'))
vi.stubGlobal('NPM_REGISTRY', 'https://registry.npmjs.org')
vi.stubGlobal('CACHE_MAX_AGE_FIVE_MINUTES', 300)
+vi.stubGlobal('CACHE_MAX_AGE_ONE_HOUR', 3600)
vi.stubGlobal('CACHE_MAX_AGE_ONE_DAY', 86400)
vi.stubGlobal('fetchLatestVersion', vi.fn())
-const { fetchNpmPackageAbbreviated, fetchNpmPackageVersionLibc } = await import('#server/utils/npm')
+const { fetchNpmPackageAbbreviated, fetchNpmPackageExists, fetchNpmPackageVersionLibc } =
+ await import('#server/utils/npm')
describe('fetchNpmPackageAbbreviated', () => {
beforeEach(() => {
@@ -53,3 +55,27 @@ describe('fetchNpmPackageVersionLibc', () => {
expect(await fetchNpmPackageVersionLibc('pkg', '1.0.0')).toBeUndefined()
})
})
+
+describe('fetchNpmPackageExists', () => {
+ beforeEach(() => {
+ $fetchMock.mockReset()
+ })
+
+ it('returns true when the registry has the package', async () => {
+ $fetchMock.mockResolvedValue(undefined)
+ expect(await fetchNpmPackageExists('@scope/pkg')).toBe(true)
+ expect($fetchMock).toHaveBeenCalledWith('https://registry.npmjs.org/@scope%2Fpkg', {
+ method: 'HEAD',
+ })
+ })
+
+ it('returns false on 404', async () => {
+ $fetchMock.mockRejectedValue(Object.assign(new Error('Not Found'), { statusCode: 404 }))
+ expect(await fetchNpmPackageExists('nope')).toBe(false)
+ })
+
+ it('throws on other errors so they are not cached', async () => {
+ $fetchMock.mockRejectedValue(Object.assign(new Error('Bad Gateway'), { statusCode: 502 }))
+ await expect(fetchNpmPackageExists('zod')).rejects.toThrow('Bad Gateway')
+ })
+})
diff --git a/test/unit/shared/utils/collections.spec.ts b/test/unit/shared/utils/collections.spec.ts
new file mode 100644
index 0000000000..9cc15171f5
--- /dev/null
+++ b/test/unit/shared/utils/collections.spec.ts
@@ -0,0 +1,135 @@
+import { describe, expect, it } from 'vite-plus/test'
+import * as v from 'valibot'
+import { toDatetimeString, type UriString } from '@atproto/lex'
+import * as dev from '#shared/types/lexicons/dev'
+import { CollectionBodySchema } from '#shared/schemas/social'
+import { parseSubjectRef } from '#shared/utils/collections'
+import {
+ COLLECTION_SUBJECT_REF,
+ MAX_COLLECTION_ITEMS,
+ PACKAGE_SUBJECT_REF,
+} from '#shared/utils/constants'
+
+const DID = 'did:plc:u5zp7npt5kpueado77kuihyz'
+const RKEY = '3m5k5baifas2t'
+const COLLECTION_URI = COLLECTION_SUBJECT_REF(DID, RKEY)
+
+describe('parseSubjectRef', () => {
+ it('parses unscoped and scoped package refs', () => {
+ expect(parseSubjectRef(PACKAGE_SUBJECT_REF('zod'))).toEqual({
+ kind: 'package',
+ packageName: 'zod',
+ })
+ expect(parseSubjectRef(PACKAGE_SUBJECT_REF('@vitejs/plugin-vue'))).toEqual({
+ kind: 'package',
+ packageName: '@vitejs/plugin-vue',
+ })
+ })
+
+ it('parses collection AT-URIs', () => {
+ expect(parseSubjectRef(COLLECTION_URI)).toEqual({
+ kind: 'collection',
+ uri: COLLECTION_URI,
+ did: DID,
+ rkey: RKEY,
+ })
+ })
+
+ it.each([
+ ['empty package name', 'https://npmx.dev/package/'],
+ ['versioned package URL', 'https://npmx.dev/package/zod/v/3.0.0'],
+ ['npmjs.com URL', 'https://www.npmjs.com/package/zod'],
+ ['handle instead of DID', `at://npmx.dev/dev.npmx.collection/${RKEY}`],
+ ['other collection type', `at://${DID}/app.bsky.graph.list/${RKEY}`],
+ ['random URI', 'https://example.com'],
+ ])('rejects %s', (_, subjectRef) => {
+ expect(parseSubjectRef(subjectRef)).toBeNull()
+ })
+})
+
+const record = (items: Array<{ subjectRef: string; note?: string }>) => ({
+ $type: dev.npmx.collection.$nsid,
+ name: 'Arg parsers',
+ items,
+ createdAt: new Date().toISOString(),
+})
+
+describe('dev.npmx.collection lexicon', () => {
+ it('accepts packages and nested collections', () => {
+ const result = dev.npmx.collection.$safeValidate(
+ record([
+ { subjectRef: PACKAGE_SUBJECT_REF('commander'), note: 'The default' },
+ { subjectRef: COLLECTION_URI },
+ ]),
+ )
+ expect(result.success).toBe(true)
+ })
+
+ it('builds a record', () => {
+ const built = dev.npmx.collection.$build({
+ name: 'Arg parsers',
+ items: [{ subjectRef: PACKAGE_SUBJECT_REF('commander') as UriString }],
+ createdAt: toDatetimeString(new Date()),
+ })
+ expect(built.$type).toBe(dev.npmx.collection.$nsid)
+ })
+
+ it('rejects more than the maximum number of items', () => {
+ const items = Array.from({ length: MAX_COLLECTION_ITEMS + 1 }, (_, i) => ({
+ subjectRef: PACKAGE_SUBJECT_REF(`pkg-${i}`),
+ }))
+ expect(dev.npmx.collection.$safeValidate(record(items)).success).toBe(false)
+ })
+
+ it('keeps the largest valid collection under the 1MB record limit', () => {
+ // Every field at its byte limit; JSON is larger than the CBOR a PDS stores
+ const items = Array.from({ length: MAX_COLLECTION_ITEMS }, () => ({
+ subjectRef: `https://npmx.dev/package/${'a'.repeat(300 - 25)}` as UriString,
+ // 140 graphemes of 4 bytes each
+ note: '😀'.repeat(140),
+ }))
+ const largest = { ...record(items), name: 'a'.repeat(64), description: 'a'.repeat(300) }
+ expect(dev.npmx.collection.$safeValidate(largest).success).toBe(true)
+ expect(new TextEncoder().encode(JSON.stringify(largest)).length).toBeLessThan(1_000_000)
+ })
+
+ it('rejects a subjectRef that is not a URI', () => {
+ expect(dev.npmx.collection.$safeValidate(record([{ subjectRef: 'zod' }])).success).toBe(false)
+ })
+
+ it('rejects a missing name', () => {
+ const { name: _, ...withoutName } = record([])
+ expect(dev.npmx.collection.$safeValidate(withoutName).success).toBe(false)
+ })
+})
+
+describe('CollectionBodySchema', () => {
+ it('accepts a valid body', () => {
+ const result = v.safeParse(CollectionBodySchema, {
+ name: ' Arg parsers ',
+ items: [{ subjectRef: PACKAGE_SUBJECT_REF('commander') }, { subjectRef: COLLECTION_URI }],
+ })
+ expect(result.success && result.output.name).toBe('Arg parsers')
+ })
+
+ it.each([
+ ['blank name', { name: ' ', items: [] }],
+ ['name over 64 graphemes', { name: 'a'.repeat(65), items: [] }],
+ ['unknown subjectRef kind', { name: 'x', items: [{ subjectRef: 'https://example.com' }] }],
+ [
+ 'note over 140 graphemes',
+ { name: 'x', items: [{ subjectRef: PACKAGE_SUBJECT_REF('zod'), note: 'a'.repeat(141) }] },
+ ],
+ [
+ 'too many items',
+ {
+ name: 'x',
+ items: Array.from({ length: MAX_COLLECTION_ITEMS + 1 }, () => ({
+ subjectRef: PACKAGE_SUBJECT_REF('zod'),
+ })),
+ },
+ ],
+ ])('rejects %s', (_, body) => {
+ expect(v.safeParse(CollectionBodySchema, body).success).toBe(false)
+ })
+})