From 7980eb0174cf665051bf48baf610e15096b01f3d Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sun, 8 Nov 2026 18:14:06 -0500 Subject: [PATCH 1/9] feat(registry): add a cached check for whether a package exists Throws on anything but a 404, so a registry outage isn't cached as "doesn't exist". --- server/utils/npm.ts | 22 ++++++++++++++++++++++ test/unit/server/utils/npm.spec.ts | 28 +++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) 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/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') + }) +}) From 466aa48c9a5056c32f9fa41aa3ecdede29968d31 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sun, 8 Nov 2026 18:14:18 -0500 Subject: [PATCH 2/9] feat(social): add shared collection types, limits and validation parseSubjectRef classifies an item as a package URL or a collection AT-URI. The body schemas are stricter than the lexicon: npmx.dev only writes items it can render. --- shared/schemas/social.ts | 41 +++++++ shared/types/social.ts | 47 +++++++ shared/utils/collections.ts | 30 +++++ shared/utils/constants.ts | 10 ++ test/unit/shared/utils/collections.spec.ts | 135 +++++++++++++++++++++ 5 files changed, 263 insertions(+) create mode 100644 shared/utils/collections.ts create mode 100644 test/unit/shared/utils/collections.spec.ts 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/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) + }) +}) From 339d1a7b1c7cc601b8960e959a602665c909364e Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sun, 8 Nov 2026 18:14:25 -0500 Subject: [PATCH 3/9] feat(social): read collections from any PDS Records come from the owner's PDS and are validated on read, since any client can write them. Package items are checked against the registry, and included collections resolve to summaries without being expanded. --- .../collections/[identifier]/[rkey].get.ts | 19 ++ .../collections/[identifier]/index.get.ts | 15 ++ server/utils/atproto/utils/collections.ts | 236 ++++++++++++++++++ server/utils/atproto/utils/request.ts | 23 ++ test/unit/server/utils/collections.spec.ts | 183 ++++++++++++++ 5 files changed, 476 insertions(+) create mode 100644 server/api/social/collections/[identifier]/[rkey].get.ts create mode 100644 server/api/social/collections/[identifier]/index.get.ts create mode 100644 server/utils/atproto/utils/collections.ts create mode 100644 server/utils/atproto/utils/request.ts create mode 100644 test/unit/server/utils/collections.spec.ts 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]/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/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/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 })], + }) + }) +}) From 1865646d61b035e2917c6734ed8e9ba3145c7d6d Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sun, 8 Nov 2026 18:14:39 -0500 Subject: [PATCH 4/9] feat(social): create, edit and delete collections Writes go to the signed-in user's PDS through their OAuth session, which now asks for the collections scope. Edits are read-modify-write with swapRecord, so a concurrent change fails with 409 instead of being clobbered. Fields other apps added are kept. Our own writes update the record cache, so the next read sees them. --- .../collections/[identifier]/[rkey].delete.ts | 6 + .../collections/[identifier]/[rkey].put.ts | 15 ++ server/api/social/collections/index.post.ts | 11 ++ .../social/collections/write-access.get.ts | 10 ++ server/utils/atproto/oauth.ts | 4 +- .../utils/atproto/utils/collection-writes.ts | 151 +++++++++++++++++ .../server/utils/collection-writes.spec.ts | 154 ++++++++++++++++++ 7 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 server/api/social/collections/[identifier]/[rkey].delete.ts create mode 100644 server/api/social/collections/[identifier]/[rkey].put.ts create mode 100644 server/api/social/collections/index.post.ts create mode 100644 server/api/social/collections/write-access.get.ts create mode 100644 server/utils/atproto/utils/collection-writes.ts create mode 100644 test/unit/server/utils/collection-writes.spec.ts 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].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/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/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/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/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') + }) +}) From 263aa739b10e2a40aa0fd768a893f302fe8a10b0 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sun, 8 Nov 2026 18:15:06 -0500 Subject: [PATCH 5/9] feat(ui): view, create, edit, duplicate and delete collections /collections// shows one collection: packages missing from the registry are struck through, included collections link to their own page, and anything unrecognized is shown so its author can fix it. The page notes that collections are the user's own public atproto data. The editor orders items, adds packages through the package typeahead or by pasting a package or collection link, and takes a short note per item. It says up front that everything is public, and why a record from another app can't be saved yet. Saving an edit made stale by a change elsewhere fails with a conflict message instead of clobbering it. --- CONTRIBUTING.md | 38 ++- app/components/Collection/Editor.stories.ts | 42 +++ app/components/Collection/Editor.vue | 307 ++++++++++++++++++ app/components/Collection/ItemList.stories.ts | 39 +++ app/components/Collection/ItemList.vue | 82 +++++ app/components/Collection/YourData.vue | 27 ++ app/components/Header/AuthModal.client.vue | 5 +- app/composables/atproto/useAtprotoSiteUrl.ts | 9 + .../[identifier]/[rkey].stories.ts | 35 ++ app/pages/collections/[identifier]/[rkey].vue | 244 ++++++++++++++ app/pages/collections/new.vue | 96 ++++++ app/storybook/mocks/handlers/collections.ts | 62 ++++ app/utils/atproto/collections.ts | 110 +++++++ i18n/locales/en.json | 56 ++++ i18n/locales/fr-FR.json | 56 ++++ i18n/schema.json | 168 ++++++++++ .../middleware/canonical-redirects.global.ts | 1 + test/nuxt/a11y.spec.ts | 89 +++++ .../nuxt/components/Collection/Editor.spec.ts | 41 +++ 19 files changed, 1485 insertions(+), 22 deletions(-) create mode 100644 app/components/Collection/Editor.stories.ts create mode 100644 app/components/Collection/Editor.vue create mode 100644 app/components/Collection/ItemList.stories.ts create mode 100644 app/components/Collection/ItemList.vue create mode 100644 app/components/Collection/YourData.vue create mode 100644 app/composables/atproto/useAtprotoSiteUrl.ts create mode 100644 app/pages/collections/[identifier]/[rkey].stories.ts create mode 100644 app/pages/collections/[identifier]/[rkey].vue create mode 100644 app/pages/collections/new.vue create mode 100644 app/storybook/mocks/handlers/collections.ts create mode 100644 app/utils/atproto/collections.ts create mode 100644 test/nuxt/components/Collection/Editor.spec.ts 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/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/ItemList.stories.ts b/app/components/Collection/ItemList.stories.ts new file mode 100644 index 0000000000..859d070dc1 --- /dev/null +++ b/app/components/Collection/ItemList.stories.ts @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from '@storybook-vue/nuxt' +import ItemList from './ItemList.vue' +import { mockCollection } 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 = {} + +/** 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..98e51800d0 --- /dev/null +++ b/app/components/Collection/ItemList.vue @@ -0,0 +1,82 @@ + + + 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 @@ + + + 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/composables/atproto/useAtprotoSiteUrl.ts b/app/composables/atproto/useAtprotoSiteUrl.ts new file mode 100644 index 0000000000..234e307270 --- /dev/null +++ b/app/composables/atproto/useAtprotoSiteUrl.ts @@ -0,0 +1,9 @@ +// https://atproto.com supports 4 locales as of 2026-02-07 +/** atproto.com in the user's language, where the site has a translation. */ +export function useAtprotoSiteUrl() { + const { locale } = useI18n() + return computed(() => { + const lang = locale.value.split('-')[0] ?? 'en' + return `https://atproto.com/${['ko', 'pt', 'ja'].includes(lang) ? lang : ''}` + }) +} diff --git a/app/pages/collections/[identifier]/[rkey].stories.ts b/app/pages/collections/[identifier]/[rkey].stories.ts new file mode 100644 index 0000000000..afa42490ee --- /dev/null +++ b/app/pages/collections/[identifier]/[rkey].stories.ts @@ -0,0 +1,35 @@ +import CollectionPage from './[rkey].vue' +import type { Meta, StoryObj } from '@storybook-vue/nuxt' +import { pageDecorator } from '../../../../.storybook/decorators' +import { + mockCollectionHandle, + mockCollectionHandler, + mockCollectionRkey, +} 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, 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..f410ecd7f9 --- /dev/null +++ b/app/pages/collections/[identifier]/[rkey].vue @@ -0,0 +1,244 @@ + + + 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 @@ + + + diff --git a/app/storybook/mocks/handlers/collections.ts b/app/storybook/mocks/handlers/collections.ts new file mode 100644 index 0000000000..f766b9ba69 --- /dev/null +++ b/app/storybook/mocks/handlers/collections.ts @@ -0,0 +1,62 @@ +import { http, HttpResponse } from 'msw' +import type { CollectionSummary, CollectionView } from '#shared/types/social' + +export const mockCollectionHandle = 'mock-steward' +const mockCollectionDid = `did:plc:${mockCollectionHandle}` +export const mockCollectionRkey = '3m5k5baifas2a' + +export const mockCollectionSummary: CollectionSummary = { + uri: `at://${mockCollectionDid}/dev.npmx.collection/${mockCollectionRkey}`, + cid: 'bafyreimockcid', + did: mockCollectionDid, + handle: mockCollectionHandle, + rkey: mockCollectionRkey, + name: 'Arg parsers', + description: 'CLI argument parsers worth knowing, from batteries-included to tiny.', + itemCount: 5, + createdAt: '2026-11-01T00:00:00.000Z', +} + +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), +) diff --git a/app/utils/atproto/collections.ts b/app/utils/atproto/collections.ts new file mode 100644 index 0000000000..9ff19d4672 --- /dev/null +++ b/app/utils/atproto/collections.ts @@ -0,0 +1,110 @@ +import { FetchError } from 'ofetch' +import type { InferInput } from 'valibot' +import type { CollectionBodySchema } from '#shared/schemas/social' +import type { CollectionSummary } 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, + ) +} + +/** + * 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/i18n/locales/en.json b/i18n/locales/en.json index 38ba8589fe..90f8de4a6e 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -418,6 +418,7 @@ "cancel": "Cancel", "save": "Save", "edit": "Edit", + "delete": "Delete", "error": "Error", "view_on": { "npm": "View on npm", @@ -458,6 +459,61 @@ "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": { + "seo_title": "{name} - npmx collections", + "seo_description": "{name}, a collection of npm packages by {'@'}{handle}", + "new": "New collection", + "new_title": "New collection - npmx", + "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" + } + }, "package": { "not_found": "Package Not Found", "not_found_message": "The package could not be found.", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index ef69005a37..787a980d8d 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -328,6 +328,7 @@ "cancel": "Annuler", "save": "Enregistrer", "edit": "Modifier", + "delete": "Supprimer", "error": "Erreur", "view_on": { "npm": "voir sur npm", @@ -364,6 +365,61 @@ "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": { + "seo_title": "{name} - collections npmx", + "seo_description": "{name}, une collection de paquets npm par {'@'}{handle}", + "new": "Nouvelle collection", + "new_title": "Nouvelle collection - npmx", + "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" + } + }, "package": { "not_found": "Paquet introuvable", "not_found_message": "Le paquet n'a pas pu être trouvé.", diff --git a/i18n/schema.json b/i18n/schema.json index 1e659fe60e..f99b8f2b7f 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1258,6 +1258,9 @@ "edit": { "type": "string" }, + "delete": { + "type": "string" + }, "error": { "type": "string" }, @@ -1378,6 +1381,171 @@ }, "additionalProperties": false }, + "collections": { + "type": "object", + "properties": { + "seo_title": { + "type": "string" + }, + "seo_description": { + "type": "string" + }, + "new": { + "type": "string" + }, + "new_title": { + "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 + }, "package": { "type": "object", "properties": { 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/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index f38cf42cc5..8514248544 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -360,7 +360,11 @@ 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 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 +1270,91 @@ 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('CollectionItemList should have no accessibility violations', async () => { + const component = await mountSuspended(CollectionItemList, { props: { items } }) + 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) + }) +}) From 21b0f711b0c8e99201f26a6d44d15557528e5a64 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sun, 8 Nov 2026 18:15:22 -0500 Subject: [PATCH 6/9] feat(ui): show collections on profiles Profiles list the account's collections, newest first, with a "New collection" link on your own. --- app/components/Collection/Card.stories.ts | 19 ++++++ app/components/Collection/Card.vue | 40 +++++++++++++ app/components/Collection/Grid.vue | 71 +++++++++++++++++++++++ app/composables/usePagedCollections.ts | 30 ++++++++++ app/pages/profile/[identity]/index.vue | 41 +++++++++++++ app/utils/atproto/collections.ts | 7 +++ i18n/locales/en.json | 6 +- i18n/locales/fr-FR.json | 6 +- i18n/schema.json | 12 ++++ test/nuxt/a11y.spec.ts | 23 ++++++++ 10 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 app/components/Collection/Card.stories.ts create mode 100644 app/components/Collection/Card.vue create mode 100644 app/components/Collection/Grid.vue create mode 100644 app/composables/usePagedCollections.ts 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 @@ + + + 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 @@ + + + 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/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') }} + +
+ +
+

( + `/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 diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 90f8de4a6e..4634985576 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -395,6 +395,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", @@ -457,13 +458,16 @@ "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": { "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", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 787a980d8d..cab16f88f5 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -305,6 +305,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", @@ -363,13 +364,16 @@ "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": { "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", diff --git a/i18n/schema.json b/i18n/schema.json index f99b8f2b7f..2b633f65d6 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1189,6 +1189,9 @@ "loading_more": { "type": "string" }, + "load_more": { + "type": "string" + }, "loading_packages": { "type": "string" }, @@ -1377,6 +1380,9 @@ } }, "additionalProperties": false + }, + "collections": { + "type": "string" } }, "additionalProperties": false @@ -1396,6 +1402,12 @@ "new_title": { "type": "string" }, + "empty": { + "type": "string" + }, + "load_error": { + "type": "string" + }, "empty_collection": { "type": "string" }, diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 8514248544..93bee6c873 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -360,6 +360,8 @@ 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' @@ -1313,6 +1315,27 @@ describe('component accessibility audits', () => { { 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) From 604f0ca818b0080c37109811c3243ae0edfa9587 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sun, 8 Nov 2026 18:15:40 -0500 Subject: [PATCH 7/9] feat(ui): find your collections from the command palette Adds New collection, and while searching, a jump to any of your own collections by name. --- app/composables/useCommandPaletteCommands.ts | 3 ++ .../useCommandPaletteGlobalCommands.ts | 50 ++++++++++++++++++- app/composables/useOwnCollections.ts | 31 ++++++++++++ app/types/command-palette.ts | 1 + app/utils/atproto/collections.ts | 6 ++- i18n/locales/en.json | 2 + i18n/locales/fr-FR.json | 2 + i18n/schema.json | 6 +++ server/api/social/collections/mine.get.ts | 13 +++++ .../use-command-palette-commands.spec.ts | 40 +++++++++++++++ 10 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 app/composables/useOwnCollections.ts create mode 100644 server/api/social/collections/mine.get.ts 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/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 index 20c3a4e421..a84ce16aea 100644 --- a/app/utils/atproto/collections.ts +++ b/app/utils/atproto/collections.ts @@ -1,7 +1,7 @@ import { FetchError } from 'ofetch' import type { InferInput } from 'valibot' import type { CollectionBodySchema } from '#shared/schemas/social' -import type { CollectionSummary } from '#shared/types/social' +import type { CollectionSummary, OwnCollection } from '#shared/types/social' import { authRedirect, handleAuthError } from '~/utils/atproto/helpers' export type CollectionBody = InferInput @@ -75,6 +75,10 @@ export function deleteCollection(identifier: string, rkey: string, 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)}`, diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 4634985576..fc3b565458 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}" }, @@ -462,6 +463,7 @@ "collections": "Collections" }, "collections": { + "title": "Collections", "seo_title": "{name} - npmx collections", "seo_description": "{name}, a collection of npm packages by {'@'}{handle}", "new": "New collection", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index cab16f88f5..6f6519807f 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}" }, @@ -368,6 +369,7 @@ "collections": "Collections" }, "collections": { + "title": "Collections", "seo_title": "{name} - collections npmx", "seo_description": "{name}, une collection de paquets npm par {'@'}{handle}", "new": "Nouvelle collection", diff --git a/i18n/schema.json b/i18n/schema.json index 2b633f65d6..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" }, @@ -1390,6 +1393,9 @@ "collections": { "type": "object", "properties": { + "title": { + "type": "string" + }, "seo_title": { "type": "string" }, 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/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', From d668014e0a0fae67c4278b2a4407d3cc77578217 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sun, 8 Nov 2026 18:15:59 -0500 Subject: [PATCH 8/9] docs: document collections for users and app developers The guide covers making and editing collections, their URLs and palette commands. A new integrations page shows how any atproto app can read and write dev.npmx.collection records. --- docs/content/2.guide/1.features.md | 9 ++ docs/content/2.guide/2.keyboard-shortcuts.md | 1 + docs/content/2.guide/3.url-structure.md | 1 + docs/content/4.integrations/1.atmosphere.md | 2 +- docs/content/4.integrations/2.collections.md | 124 +++++++++++++++++++ 5 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 docs/content/4.integrations/2.collections.md 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. From eb6fcf1495b8b2dc19da01607845a52676a359f5 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Wed, 11 Nov 2026 17:57:59 -0500 Subject: [PATCH 9/9] feat(ui): show a collection's packages as package cards Card metadata comes from the same lookup as org pages. Notes sit under each card, and packages without metadata keep the plain row. --- app/components/Collection/ItemList.stories.ts | 7 ++- app/components/Collection/ItemList.vue | 31 ++++++++++--- app/components/Collection/ItemNote.vue | 8 ++++ app/components/Package/Card.vue | 37 +++++++++------- app/composables/npm/usePackagesByName.ts | 44 +++++++++++++++++++ .../[identifier]/[rkey].stories.ts | 3 +- app/pages/collections/[identifier]/[rkey].vue | 13 +++++- app/storybook/mocks/handlers/collections.ts | 35 +++++++++++++++ i18n/locales/en.json | 3 +- i18n/locales/fr-FR.json | 3 +- test/nuxt/a11y.spec.ts | 13 ++++++ 11 files changed, 169 insertions(+), 28 deletions(-) create mode 100644 app/components/Collection/ItemNote.vue create mode 100644 app/composables/npm/usePackagesByName.ts diff --git a/app/components/Collection/ItemList.stories.ts b/app/components/Collection/ItemList.stories.ts index 859d070dc1..1569dc6e1b 100644 --- a/app/components/Collection/ItemList.stories.ts +++ b/app/components/Collection/ItemList.stories.ts @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook-vue/nuxt' import ItemList from './ItemList.vue' -import { mockCollection } from '../../storybook/mocks/handlers/collections' +import { mockCollection, mockPackageCards } from '../../storybook/mocks/handlers/collections' const meta = { component: ItemList, @@ -15,6 +15,11 @@ 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: { diff --git a/app/components/Collection/ItemList.vue b/app/components/Collection/ItemList.vue index 98e51800d0..eb59e397cf 100644 --- a/app/components/Collection/ItemList.vue +++ b/app/components/Collection/ItemList.vue @@ -1,17 +1,36 @@