diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 1a3756e2e6..289fc14bef 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -42,6 +42,7 @@ import type * as httpApiV1_contentRightsV1 from "../httpApiV1/contentRightsV1.js import type * as httpApiV1_docsSessionV1 from "../httpApiV1/docsSessionV1.js"; import type * as httpApiV1_packagesV1 from "../httpApiV1/packagesV1.js"; import type * as httpApiV1_promotionsV1 from "../httpApiV1/promotionsV1.js"; +import type * as httpApiV1_publisherFollowsV1 from "../httpApiV1/publisherFollowsV1.js"; import type * as httpApiV1_publishersV1 from "../httpApiV1/publishersV1.js"; import type * as httpApiV1_shared from "../httpApiV1/shared.js"; import type * as httpApiV1_skillsShCatalogV1 from "../httpApiV1/skillsShCatalogV1.js"; @@ -171,6 +172,7 @@ import type * as publishAttempts from "../publishAttempts.js"; import type * as publisherAbuse from "../publisherAbuse.js"; import type * as publisherAbuseDevSeed from "../publisherAbuseDevSeed.js"; import type * as publisherAbuseTemporalScan from "../publisherAbuseTemporalScan.js"; +import type * as publisherFollows from "../publisherFollows.js"; import type * as publishers from "../publishers.js"; import type * as rateLimits from "../rateLimits.js"; import type * as retention from "../retention.js"; @@ -243,6 +245,7 @@ declare const fullApi: ApiFromModules<{ "httpApiV1/docsSessionV1": typeof httpApiV1_docsSessionV1; "httpApiV1/packagesV1": typeof httpApiV1_packagesV1; "httpApiV1/promotionsV1": typeof httpApiV1_promotionsV1; + "httpApiV1/publisherFollowsV1": typeof httpApiV1_publisherFollowsV1; "httpApiV1/publishersV1": typeof httpApiV1_publishersV1; "httpApiV1/shared": typeof httpApiV1_shared; "httpApiV1/skillsShCatalogV1": typeof httpApiV1_skillsShCatalogV1; @@ -372,6 +375,7 @@ declare const fullApi: ApiFromModules<{ publisherAbuse: typeof publisherAbuse; publisherAbuseDevSeed: typeof publisherAbuseDevSeed; publisherAbuseTemporalScan: typeof publisherAbuseTemporalScan; + publisherFollows: typeof publisherFollows; publishers: typeof publishers; rateLimits: typeof rateLimits; retention: typeof retention; diff --git a/convex/accountFeeds.test.ts b/convex/accountFeeds.test.ts new file mode 100644 index 0000000000..ec858b4c4b --- /dev/null +++ b/convex/accountFeeds.test.ts @@ -0,0 +1,422 @@ +/* @vitest-environment node */ +import { describe, expect, it, vi } from "vitest"; +import { + buildPublisherFeedProjectionImpl, + getPublisherDetail, + publishPublisherFeedRevisionImpl, +} from "./accountFeeds"; + +type InternalHandler = (ctx: unknown, args: unknown) => Promise; + +const getPublisherDetailHandler = (getPublisherDetail as unknown as { _handler: InternalHandler }) + ._handler; +const getPublisherFeedHandler = buildPublisherFeedProjectionImpl as InternalHandler; + +function doc(id: string) { + return id as unknown as import("./_generated/dataModel").Id; +} + +function makeQuery(pages: unknown[] | unknown[][]) { + const normalizedPages = Array.isArray(pages[0]) ? (pages as unknown[][]) : [pages as unknown[]]; + const rows = normalizedPages.flat(); + return { + withIndex: vi.fn(() => ({ + order: vi.fn(() => ({ + take: vi.fn(async (limit: number) => rows.slice(0, limit)), + })), + })), + }; +} + +function makePublisher() { + return { + _id: doc<"publishers">("publishers:alice"), + handle: "alice", + displayName: "Alice", + linkedUserId: undefined, + deletedAt: undefined, + deactivatedAt: undefined, + }; +} + +describe("publisher feed projection", () => { + it("rejects ids that do not normalize to the publishers table", async () => { + const get = vi.fn(); + const normalizeId = vi.fn((table: string, id: string) => + table === "publishers" && id.startsWith("publishers:") ? doc<"publishers">(id) : null, + ); + + const result = await getPublisherDetailHandler( + { db: { get, normalizeId } }, + { publisherId: "users:alice" }, + ); + + expect(result).toBeNull(); + expect(get).not.toHaveBeenCalled(); + }); + + it("resolves public publisher details by mutable handle", async () => { + const publisher = makePublisher(); + const unique = vi.fn(async () => publisher); + const eq = vi.fn(() => ({ unique })); + const withIndex = vi.fn((_name: string, apply: (q: { eq: typeof eq }) => unknown) => + apply({ eq }), + ); + const query = vi.fn(() => ({ withIndex })); + + const result = await getPublisherDetailHandler( + { db: { get: vi.fn(), normalizeId: vi.fn(() => null), query } }, + { publisherId: "@Alice" }, + ); + + expect(result).toMatchObject({ + publisher: { _id: publisher._id, handle: "alice" }, + feedUrl: "/api/v1/publishers/publishers%3Aalice/feed", + }); + expect(withIndex).toHaveBeenCalledWith("by_handle", expect.any(Function)); + expect(eq).toHaveBeenCalledWith("handle", "alice"); + }); + + it("does not expose personal publishers with inactive linked users", async () => { + const user = { + _id: doc<"users">("users:alice"), + _creationTime: 1, + handle: "alice", + name: "Alice", + displayName: "Alice", + personalPublisherId: doc<"publishers">("publishers:alice"), + deletedAt: undefined, + deactivatedAt: undefined, + }; + const publisher = { + ...makePublisher(), + kind: "user", + linkedUserId: user._id, + }; + const get = vi.fn(async (id: string): Promise | null> => { + if (id === user._id) return user; + if (id === publisher._id) return publisher; + return null; + }); + const normalizeId = vi.fn((table: string, id: string) => { + if (table === "users" && id === user._id) return user._id; + if (table === "publishers" && id === publisher._id) return publisher._id; + return null; + }); + + get.mockImplementation(async (id: string) => { + if (id === user._id) return { ...user, deactivatedAt: 10 }; + if (id === publisher._id) return publisher; + return null; + }); + const publisherDetail = await getPublisherDetailHandler( + { db: { get, normalizeId } }, + { publisherId: String(publisher._id) }, + ); + expect(publisherDetail).toBeNull(); + }); + + it("filters skill-family package rows from publisher feeds", async () => { + const publisher = makePublisher(); + const skillPackage = { + _id: doc<"packages">("packages:skill-mirror"), + family: "skill", + channel: "community", + scanStatus: "clean", + name: "@alice/skill-mirror", + displayName: "Skill Mirror", + summary: null, + updatedAt: 20, + }; + const pluginPackage = { + _id: doc<"packages">("packages:plugin"), + family: "code-plugin", + channel: "community", + scanStatus: "clean", + name: "@alice/plugin", + displayName: "Plugin", + summary: null, + updatedAt: 10, + }; + const get = vi.fn(async (id: string) => (id === publisher._id ? publisher : null)); + const normalizeId = vi.fn((table: string, id: string) => + table === "publishers" && id.startsWith("publishers:") ? doc<"publishers">(id) : null, + ); + const query = vi.fn((table: string) => + table === "packages" ? makeQuery([skillPackage, pluginPackage]) : makeQuery([]), + ); + + const result = (await getPublisherFeedHandler( + { db: { get, normalizeId, query } }, + { publisherId: String(publisher._id), limit: 10 }, + )) as { status: string; entries: unknown[] }; + + expect(result.status).toBe("complete"); + expect(result.entries).toEqual([ + expect.objectContaining({ + kind: "plugin", + id: "packages:plugin", + name: "@alice/plugin", + url: "/alice/plugins/plugin", + }), + ]); + }); + + it("uses canonical publisher routes for skill entries", async () => { + const publisher = makePublisher(); + const skill = { + _id: "skills:demo", + slug: "demo", + displayName: "Demo", + summary: null, + softDeletedAt: undefined, + moderationStatus: "active", + moderationFlags: undefined, + moderationVerdict: "clean", + updatedAt: 10, + }; + const get = vi.fn(async (id: string) => (id === publisher._id ? publisher : null)); + const normalizeId = vi.fn((table: string, id: string) => + table === "publishers" && id.startsWith("publishers:") ? doc<"publishers">(id) : null, + ); + const query = vi.fn((table: string) => + table === "skills" ? makeQuery([skill]) : makeQuery([]), + ); + + const result = (await getPublisherFeedHandler( + { db: { get, normalizeId, query } }, + { publisherId: String(publisher._id), limit: 10 }, + )) as { entries: Array<{ url: string }> }; + + expect(result.entries).toEqual([expect.objectContaining({ url: "/alice/skills/demo" })]); + }); + + it("bounds summaries before persisting publisher snapshots", async () => { + const publisher = makePublisher(); + const skill = { + _id: doc<"skills">("skills:verbose"), + slug: "verbose", + displayName: "Verbose", + summary: `${"x".repeat(499)}😀${"y".repeat(1_500)}`, + softDeletedAt: undefined, + moderationStatus: "active", + moderationFlags: undefined, + moderationVerdict: "clean", + updatedAt: 10, + }; + const get = vi.fn(async (id: string) => (id === publisher._id ? publisher : null)); + const normalizeId = vi.fn((table: string, id: string) => + table === "publishers" && id === publisher._id ? publisher._id : null, + ); + const query = vi.fn((table: string) => + table === "skills" ? makeQuery([skill]) : makeQuery([]), + ); + + const result = (await getPublisherFeedHandler( + { db: { get, normalizeId, query } }, + { publisherId: String(publisher._id) }, + )) as { entries: Array<{ summary: string }> }; + + expect(result.entries[0]?.summary).toBe("x".repeat(499)); + }); + + it("includes legacy ownerUserId-only content for personal publishers", async () => { + const user = { + _id: doc<"users">("users:alice"), + deletedAt: undefined, + deactivatedAt: undefined, + }; + const publisher = { ...makePublisher(), kind: "user", linkedUserId: user._id }; + const legacySkill = { + _id: doc<"skills">("skills:legacy"), + ownerUserId: user._id, + ownerPublisherId: undefined, + slug: "legacy", + displayName: "Legacy", + summary: null, + softDeletedAt: undefined, + moderationStatus: "active", + moderationFlags: undefined, + moderationVerdict: "clean", + updatedAt: 10, + }; + const get = vi.fn(async (id: string) => { + if (id === publisher._id) return publisher; + if (id === user._id) return user; + return null; + }); + const normalizeId = vi.fn((table: string, id: string) => + table === "publishers" && id === publisher._id ? publisher._id : null, + ); + let skillQueryCount = 0; + const query = vi.fn((table: string) => { + if (table !== "skills") return makeQuery([]); + skillQueryCount += 1; + return skillQueryCount === 1 ? makeQuery([]) : makeQuery([legacySkill]); + }); + + const result = (await getPublisherFeedHandler( + { db: { get, normalizeId, query } }, + { publisherId: String(publisher._id), limit: 10 }, + )) as { status: string; entries: Array<{ id: string }> }; + + expect(result.status).toBe("complete"); + expect(result.entries.map((entry) => entry.id)).toEqual(["skills:legacy"]); + }); + + it("uses stable entry identity to break equal timestamp ties", async () => { + const publisher = makePublisher(); + const packages = [ + { + _id: doc<"packages">("packages:z"), + family: "code-plugin", + channel: "community", + scanStatus: "clean", + name: "@alice/aaa", + displayName: "A", + summary: null, + updatedAt: 10, + }, + { + _id: doc<"packages">("packages:a"), + family: "code-plugin", + channel: "community", + scanStatus: "clean", + name: "@alice/zzz", + displayName: "Z", + summary: null, + updatedAt: 10, + }, + ]; + const get = vi.fn(async (id: string) => (id === publisher._id ? publisher : null)); + const normalizeId = vi.fn((table: string, id: string) => + table === "publishers" && id.startsWith("publishers:") ? doc<"publishers">(id) : null, + ); + const query = vi.fn((table: string) => + table === "packages" ? makeQuery(packages) : makeQuery([]), + ); + + const result = (await getPublisherFeedHandler( + { db: { get, normalizeId, query } }, + { publisherId: String(publisher._id), limit: 10 }, + )) as { entries: Array<{ id: string }> }; + + expect(result.entries.map((entry) => entry.id)).toEqual(["packages:a", "packages:z"]); + }); + + it("continues past filtered package rows to find older public entries", async () => { + const publisher = makePublisher(); + const privatePackage = { + _id: doc<"packages">("packages:private"), + family: "code-plugin", + channel: "private", + scanStatus: "clean", + name: "@alice/private", + displayName: "Private", + summary: null, + updatedAt: 30, + }; + const publicPackage = { + _id: doc<"packages">("packages:public"), + family: "code-plugin", + channel: "community", + scanStatus: "clean", + name: "@alice/public", + displayName: "Public", + summary: null, + updatedAt: 10, + }; + const get = vi.fn(async (id: string) => (id === publisher._id ? publisher : null)); + const normalizeId = vi.fn((table: string, id: string) => + table === "publishers" && id.startsWith("publishers:") ? doc<"publishers">(id) : null, + ); + const packagesQuery = makeQuery([[privatePackage], [publicPackage]]); + const query = vi.fn((table: string) => (table === "packages" ? packagesQuery : makeQuery([]))); + + const result = (await getPublisherFeedHandler( + { db: { get, normalizeId, query } }, + { publisherId: String(publisher._id), limit: 1 }, + )) as { status: string; entries: unknown[] }; + + expect(result.entries).toEqual([ + expect.objectContaining({ + id: "packages:public", + name: "@alice/public", + }), + ]); + expect(result.status).toBe("complete"); + }); + + it("fails closed when the bounded source read cannot prove completeness", async () => { + const publisher = makePublisher(); + const privatePackage = { + _id: doc<"packages">("packages:private"), + family: "code-plugin", + channel: "private", + scanStatus: "clean", + name: "@alice/private", + displayName: "Private", + summary: null, + updatedAt: 30, + }; + const get = vi.fn(async (id: string) => (id === publisher._id ? publisher : null)); + const normalizeId = vi.fn((table: string, id: string) => + table === "publishers" && id.startsWith("publishers:") ? doc<"publishers">(id) : null, + ); + const packagesQuery = makeQuery( + Array.from({ length: 401 }, (_, index) => ({ + ...privatePackage, + _id: doc<"packages">(`packages:private-${index}`), + })), + ); + const query = vi.fn((table: string) => (table === "packages" ? packagesQuery : makeQuery([]))); + + const result = (await getPublisherFeedHandler( + { db: { get, normalizeId, query } }, + { publisherId: String(publisher._id), limit: 1 }, + )) as { status: string; entries?: unknown[] }; + + expect(result).toEqual({ status: "capacity-exceeded" }); + }); + + it("reuses a revision for unchanged content and increments changed content", async () => { + const publisher = { ...makePublisher(), kind: "org" }; + let existing: Record | null = null; + const query = vi.fn(() => ({ + withIndex: vi.fn(() => ({ unique: vi.fn(async () => existing) })), + })); + const insert = vi.fn(async (_table: string, value: Record) => { + existing = { _id: "publisherFeedPublications:1", ...value }; + }); + const patch = vi.fn(async (_id: string, value: Record) => { + existing = { ...existing, ...value }; + }); + const get = vi.fn(async (id: string) => (id === publisher._id ? publisher : null)); + const args = { + publisherId: publisher._id, + feedId: "clawhub.publisher.publishers:alice", + handle: "alice", + displayName: "Alice", + entries: [], + }; + + const first = (await publishPublisherFeedRevisionImpl( + { db: { get, query, insert, patch } } as never, + args, + )) as { sequence: number; generatedAt: string }; + const unchanged = (await publishPublisherFeedRevisionImpl( + { db: { get, query, insert, patch } } as never, + args, + )) as { sequence: number; generatedAt: string }; + const changed = (await publishPublisherFeedRevisionImpl( + { db: { get, query, insert, patch } } as never, + { ...args, displayName: "Alice Updated" }, + )) as { sequence: number }; + + expect(first.sequence).toBe(1); + expect(unchanged).toMatchObject({ sequence: 1, generatedAt: first.generatedAt }); + expect(changed.sequence).toBe(2); + expect(insert).toHaveBeenCalledTimes(1); + expect(patch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/convex/accountFeeds.ts b/convex/accountFeeds.ts new file mode 100644 index 0000000000..d3be01448d --- /dev/null +++ b/convex/accountFeeds.ts @@ -0,0 +1,408 @@ +import { + PUBLISHER_FEED_SCHEMA_VERSION, + publisherFeedId, + type PublisherFeed, + type PublisherFeedEntry, +} from "clawhub-schema"; +import { v } from "convex/values"; +import type { Doc, Id } from "./_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { internalMutation, internalQuery } from "./functions"; +import { isPublicSkillDoc } from "./lib/globalStats"; +import { isPackageBlockedFromPublic } from "./lib/packageSecurity"; +import { getPublicPublisherVisibility, normalizePublisherHandle } from "./lib/publishers"; + +const PUBLISHER_FEED_SNAPSHOT_MAX_ENTRIES = 400; +const PUBLISHER_FEED_SUMMARY_MAX_CHARS = 500; +type PublisherFeedReadCtx = Pick; + +function boundedSummary(value: string | null | undefined) { + if (value == null) return null; + if (value.length <= PUBLISHER_FEED_SUMMARY_MAX_CHARS) return value; + let bounded = value.slice(0, PUBLISHER_FEED_SUMMARY_MAX_CHARS); + const finalCodeUnit = bounded.charCodeAt(bounded.length - 1); + if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) bounded = bounded.slice(0, -1); + return bounded; +} + +async function sha256Hex(value: string) { + const digest = new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)), + ); + return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} +async function safeGetPublisher(ctx: PublisherFeedReadCtx, id: string) { + const publisherId = ctx.db.normalizeId("publishers", id); + if (!publisherId) return null; + try { + return await ctx.db.get(publisherId); + } catch { + return null; + } +} + +async function safeResolvePublisherDetail(ctx: PublisherFeedReadCtx, reference: string) { + const byId = await safeGetPublisher(ctx, reference); + if (byId || reference.includes(":")) return byId; + const handle = normalizePublisherHandle(reference); + if (!handle || new TextEncoder().encode(handle).length > 64) return null; + try { + return await ctx.db + .query("publishers") + .withIndex("by_handle", (q) => q.eq("handle", handle)) + .unique(); + } catch { + return null; + } +} + +function skillEntry(publisher: Doc<"publishers">, skill: Doc<"skills">): PublisherFeedEntry | null { + if (!isPublicSkillDoc(skill)) return null; + return { + kind: "skill", + id: String(skill._id), + name: skill.slug, + displayName: skill.displayName, + summary: boundedSummary(skill.summary), + url: `/${encodeURIComponent(publisher.handle)}/skills/${encodeURIComponent(skill.slug)}`, + updatedAt: skill.updatedAt, + }; +} + +function pluginPath(publisher: Doc<"publishers">, name: string) { + const trimmed = name.trim(); + if (!trimmed.startsWith("@")) { + return `/${encodeURIComponent(publisher.handle)}/plugins/${encodeURIComponent(trimmed)}`; + } + const slashIndex = trimmed.indexOf("/"); + if (slashIndex <= 1 || slashIndex === trimmed.length - 1) { + return `/plugins/${encodeURIComponent(trimmed)}`; + } + const packageName = trimmed.slice(slashIndex + 1); + if (packageName.includes("/")) return `/plugins/${encodeURIComponent(trimmed)}`; + return `/${encodeURIComponent(publisher.handle)}/plugins/${encodeURIComponent(packageName)}`; +} + +function packageEntry( + publisher: Doc<"publishers">, + pkg: Doc<"packages">, +): PublisherFeedEntry | null { + if ( + pkg.family === "skill" || + pkg.channel === "private" || + isPackageBlockedFromPublic(pkg.scanStatus) + ) { + return null; + } + return { + kind: "plugin", + id: String(pkg._id), + name: pkg.name, + displayName: pkg.displayName, + summary: boundedSummary(pkg.summary), + url: pluginPath(publisher, pkg.name), + updatedAt: pkg.updatedAt, + }; +} + +function buildFeed(params: { + publisherId: string; + feedId: string; + handle: string | null; + displayName: string; + entries: PublisherFeedEntry[]; + generatedAt: string; + sequence: number; +}): PublisherFeed { + return { + schemaVersion: PUBLISHER_FEED_SCHEMA_VERSION, + feedId: params.feedId, + publisherId: params.publisherId, + handle: params.handle, + displayName: params.displayName, + generatedAt: params.generatedAt, + sequence: params.sequence, + entries: params.entries, + nextCursor: null, + }; +} + +type CollectedEntries = { + entries: PublisherFeedEntry[]; + exhausted: boolean; +}; + +async function collectSkillEntries( + ctx: PublisherFeedReadCtx, + publisher: Doc<"publishers">, + limit: number, +): Promise { + const entries: PublisherFeedEntry[] = []; + const skills = await ctx.db + .query("skills") + .withIndex("by_owner_publisher_active_updated", (q) => + q.eq("ownerPublisherId", publisher._id).eq("softDeletedAt", undefined), + ) + .order("desc") + .take(limit + 1); + + for (const skill of skills) { + const entry = skillEntry(publisher, skill); + if (entry) entries.push(entry); + if (entries.length > limit) break; + } + + return { entries, exhausted: skills.length <= limit }; +} + +async function collectPackageEntries( + ctx: PublisherFeedReadCtx, + publisher: Doc<"publishers">, + limit: number, +): Promise { + const entries: PublisherFeedEntry[] = []; + const packages = await ctx.db + .query("packages") + .withIndex("by_owner_publisher_active_updated", (q) => + q.eq("ownerPublisherId", publisher._id).eq("softDeletedAt", undefined), + ) + .order("desc") + .take(limit + 1); + + for (const pkg of packages) { + const entry = packageEntry(publisher, pkg); + if (entry) entries.push(entry); + if (entries.length > limit) break; + } + + return { entries, exhausted: packages.length <= limit }; +} + +async function collectLegacySkillEntries( + ctx: PublisherFeedReadCtx, + publisher: Doc<"publishers">, + ownerUserId: Doc<"users">["_id"], + limit: number, +): Promise { + const entries: PublisherFeedEntry[] = []; + const skills = await ctx.db + .query("skills") + .withIndex("by_owner_active_updated", (q) => + q.eq("ownerUserId", ownerUserId).eq("softDeletedAt", undefined), + ) + .order("desc") + .take(limit + 1); + for (const skill of skills) { + if (skill.ownerPublisherId && skill.ownerPublisherId !== publisher._id) continue; + const entry = skillEntry(publisher, skill); + if (entry) entries.push(entry); + if (entries.length > limit) break; + } + return { entries, exhausted: skills.length <= limit }; +} + +async function collectLegacyPackageEntries( + ctx: PublisherFeedReadCtx, + publisher: Doc<"publishers">, + ownerUserId: Doc<"users">["_id"], + limit: number, +): Promise { + const entries: PublisherFeedEntry[] = []; + const packages = await ctx.db + .query("packages") + .withIndex("by_owner_active_updated", (q) => + q.eq("ownerUserId", ownerUserId).eq("softDeletedAt", undefined), + ) + .order("desc") + .take(limit + 1); + for (const pkg of packages) { + if (pkg.ownerPublisherId && pkg.ownerPublisherId !== publisher._id) continue; + const entry = packageEntry(publisher, pkg); + if (entry) entries.push(entry); + if (entries.length > limit) break; + } + return { entries, exhausted: packages.length <= limit }; +} + +async function buildPublisherFeed( + ctx: PublisherFeedReadCtx, + publisher: Doc<"publishers">, + legacyOwnerUserId: Doc<"users">["_id"] | null, + limit: number, +) { + const [skillEntries, packageEntries, legacySkillEntries, legacyPackageEntries] = + await Promise.all([ + collectSkillEntries(ctx, publisher, limit), + collectPackageEntries(ctx, publisher, limit), + legacyOwnerUserId + ? collectLegacySkillEntries(ctx, publisher, legacyOwnerUserId, limit) + : Promise.resolve({ entries: [], exhausted: true }), + legacyOwnerUserId + ? collectLegacyPackageEntries(ctx, publisher, legacyOwnerUserId, limit) + : Promise.resolve({ entries: [], exhausted: true }), + ]); + + const deduped = new Map(); + for (const entry of [ + ...skillEntries.entries, + ...packageEntries.entries, + ...legacySkillEntries.entries, + ...legacyPackageEntries.entries, + ]) { + deduped.set(`${entry.kind}:${entry.id}`, entry); + } + const sortedCandidates = [...deduped.values()].sort( + (left, right) => + right.updatedAt - left.updatedAt || + left.kind.localeCompare(right.kind) || + left.id.localeCompare(right.id), + ); + const exhausted = + skillEntries.exhausted && + packageEntries.exhausted && + legacySkillEntries.exhausted && + legacyPackageEntries.exhausted; + if (!exhausted || sortedCandidates.length > limit) { + return { status: "capacity-exceeded" as const }; + } + + return { + status: "complete" as const, + publisherId: publisher._id, + feedId: publisherFeedId(String(publisher._id)), + handle: publisher.handle ?? null, + displayName: publisher.displayName || publisher.handle || "", + entries: sortedCandidates, + }; +} + +export const getPublisherDetail = internalQuery({ + args: { publisherId: v.string() }, + handler: async (ctx, args) => { + const publisher = await safeResolvePublisherDetail(ctx, args.publisherId); + const visibility = await getPublicPublisherVisibility(ctx, publisher); + if (!visibility) return null; + return { + publisher: { + _id: visibility.publisher._id, + kind: visibility.publisher.kind, + handle: visibility.publisher.handle, + displayName: visibility.publisher.displayName, + image: visibility.publisher.image ?? null, + bio: visibility.publisher.bio ?? null, + }, + feedUrl: `/api/v1/publishers/${encodeURIComponent(String(visibility.publisher._id))}/feed`, + }; + }, +}); + +export const getPublisherFeedPublication = internalQuery({ + args: { publisherId: v.string() }, + handler: async (ctx, args) => { + const publisher = await safeGetPublisher(ctx, args.publisherId); + const visibility = await getPublicPublisherVisibility(ctx, publisher); + if (!visibility) return null; + return await ctx.db + .query("publisherFeedPublications") + .withIndex("by_publisher", (q) => q.eq("publisherId", visibility.publisher._id)) + .unique(); + }, +}); + +type PublishPublisherFeedRevisionArgs = { + publisherId: Id<"publishers">; + feedId: string; + handle: string | null; + displayName: string; + entries: PublisherFeedEntry[]; +}; + +export async function publishPublisherFeedRevisionImpl( + ctx: Pick, + args: PublishPublisherFeedRevisionArgs, +) { + const publisher = await ctx.db.get(args.publisherId); + const visibility = await getPublicPublisherVisibility(ctx, publisher); + if (!visibility || publisherFeedId(String(args.publisherId)) !== args.feedId) return null; + + const contentKey = await sha256Hex( + JSON.stringify({ + publisherId: String(args.publisherId), + handle: args.handle, + displayName: args.displayName, + entries: args.entries, + }), + ); + const existing = await ctx.db + .query("publisherFeedPublications") + .withIndex("by_publisher", (q) => q.eq("publisherId", args.publisherId)) + .unique(); + if (existing?.contentKey === contentKey) { + return buildFeed({ + publisherId: String(args.publisherId), + feedId: args.feedId, + handle: args.handle, + displayName: args.displayName, + entries: args.entries, + generatedAt: existing.generatedAt, + sequence: existing.sequence, + }); + } + + const generatedAt = new Date().toISOString(); + const sequence = (existing?.sequence ?? 0) + 1; + const publication = { + publisherId: args.publisherId, + feedId: args.feedId, + sequence, + generatedAt, + handle: args.handle, + displayName: args.displayName, + entries: args.entries, + contentKey, + publishedAt: Date.now(), + }; + if (existing) { + await ctx.db.patch(existing._id, publication); + } else { + await ctx.db.insert("publisherFeedPublications", publication); + } + return buildFeed({ + publisherId: String(args.publisherId), + feedId: args.feedId, + handle: args.handle, + displayName: args.displayName, + entries: args.entries, + generatedAt, + sequence, + }); +} + +export async function refreshPublisherFeedImpl( + ctx: Pick, + args: { publisherId: string }, +) { + const projection = await buildPublisherFeedProjectionImpl(ctx, args); + if (!projection || projection.status !== "complete") return projection; + return await publishPublisherFeedRevisionImpl(ctx, projection); +} + +export async function buildPublisherFeedProjectionImpl( + ctx: PublisherFeedReadCtx, + args: { publisherId: string }, +) { + const publisher = await safeGetPublisher(ctx, args.publisherId); + const visibility = await getPublicPublisherVisibility(ctx, publisher); + if (!visibility) return null; + return await buildPublisherFeed( + ctx, + visibility.publisher, + visibility.linkedUser?._id ?? null, + PUBLISHER_FEED_SNAPSHOT_MAX_ENTRIES, + ); +} + +export const refreshPublisherFeed = internalMutation({ + args: { publisherId: v.string() }, + handler: refreshPublisherFeedImpl, +}); diff --git a/convex/http.ts b/convex/http.ts index cd9d76e476..88eee5bde2 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -31,7 +31,11 @@ import { packagesGetRouterV1Http, packagesPostRouterV1Http, pluginsGetRouterV1Http, + publisherFollowsDeleteV1Http, + publisherFollowsGetV1Http, + publisherFollowsPostV1Http, createPublisherV1Http, + publishersGetRouterV1Http, publishPackageV1Http, publishSkillV1Http, resolveSkillVersionV1Http, @@ -343,6 +347,30 @@ http.route({ handler: createPublisherV1Http, }); +http.route({ + pathPrefix: `${ApiRoutes.publishers}/`, + method: "GET", + handler: publishersGetRouterV1Http, +}); + +http.route({ + path: ApiRoutes.publisherFollows, + method: "GET", + handler: publisherFollowsGetV1Http, +}); + +http.route({ + path: ApiRoutes.publisherFollows, + method: "POST", + handler: publisherFollowsPostV1Http, +}); + +http.route({ + path: ApiRoutes.publisherFollows, + method: "DELETE", + handler: publisherFollowsDeleteV1Http, +}); + http.route({ path: ApiRoutes.whoami, method: "GET", diff --git a/convex/httpApiV1.accountFeeds.test.ts b/convex/httpApiV1.accountFeeds.test.ts new file mode 100644 index 0000000000..d117beb71a --- /dev/null +++ b/convex/httpApiV1.accountFeeds.test.ts @@ -0,0 +1,172 @@ +/* @vitest-environment node */ +import type { RateLimitArgs, RateLimitReturns } from "@convex-dev/rate-limiter"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { internal } from "./_generated/api"; +import { publishersGetRouterV1Handler } from "./httpApiV1/accountFeedsV1"; + +type ActionCtx = import("./_generated/server").ActionCtx; + +function isRateLimitArgs(args: unknown): args is RateLimitArgs { + if (!args || typeof args !== "object") return false; + const value = args as Record; + return typeof value.name === "string" && "config" in value; +} + +const okRate = (): RateLimitReturns => ({ ok: true }); + +function makeCtx(partial: Record) { + const partialRunQuery = + typeof partial.runQuery === "function" + ? (partial.runQuery as (query: unknown, args: Record) => unknown) + : null; + const runQuery = vi.fn(async (query: unknown, args: Record) => + partialRunQuery ? await partialRunQuery(query, args) : null, + ); + const runMutation = + typeof partial.runMutation === "function" + ? partial.runMutation + : vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + return null; + }); + + return { ...partial, runQuery, runMutation } as unknown as ActionCtx; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("publisher feed HTTP routes", () => { + it("serves public publisher detail", async () => { + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + expect(args).toEqual({ publisherId: "publishers:alice" }); + return { + publisher: { _id: "publishers:alice", handle: "alice" }, + feedUrl: "/api/v1/publishers/publishers%3Aalice/feed", + }; + }); + + const response = await publishersGetRouterV1Handler( + makeCtx({ runQuery }), + new Request("https://example.com/api/v1/publishers/publishers%3Aalice"), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + publisher: { _id: "publishers:alice", handle: "alice" }, + feedUrl: "/api/v1/publishers/publishers%3Aalice/feed", + }); + expect(runQuery).toHaveBeenCalledWith( + (internal as unknown as { accountFeeds: { getPublisherDetail: unknown } }).accountFeeds + .getPublisherDetail, + { publisherId: "publishers:alice" }, + ); + }); + + it("serves coherent publisher feed pages with opaque continuation", async () => { + const entries = [ + { kind: "skill", id: "skills:2", displayName: "Two" }, + { kind: "skill", id: "skills:1", displayName: "One" }, + ]; + const storedFeed = { + feedId: "clawhub.publisher.publishers:alice", + publisherId: "publishers:alice", + handle: "alice", + displayName: "Alice", + generatedAt: "2026-07-16T00:00:00.000Z", + sequence: 7, + entries, + }; + const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + return storedFeed; + }); + + const response = await publishersGetRouterV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/publishers/publishers%3Aalice/feed?limit=1"), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + const first = (await response.json()) as { + sequence: number; + entries: Array<{ id: string }>; + nextCursor: string; + }; + expect(first).toMatchObject({ sequence: 7, entries: [{ id: "skills:2" }] }); + expect(first.nextCursor).toMatch(/^[A-Za-z0-9_-]+$/u); + + const continuationQuery = vi.fn(async () => storedFeed); + const next = await publishersGetRouterV1Handler( + makeCtx({ runQuery: continuationQuery }), + new Request( + `https://example.com/api/v1/publishers/publishers%3Aalice/feed?limit=1&cursor=${first.nextCursor}`, + ), + ); + expect(next.status).toBe(200); + expect(next.headers.get("cache-control")).toBe("private, no-store"); + expect(await next.json()).toMatchObject({ + sequence: 7, + entries: [{ id: "skills:1" }], + nextCursor: null, + }); + }); + + it("rejects malformed cursors and limits", async () => { + const ctx = makeCtx({}); + const cursorResponse = await publishersGetRouterV1Handler( + ctx, + new Request("https://example.com/api/v1/publishers/publishers%3Aalice/feed?cursor=next"), + ); + expect(cursorResponse.status).toBe(400); + expect(await cursorResponse.text()).toBe("Invalid publisher feed cursor"); + + const limitResponse = await publishersGetRouterV1Handler( + ctx, + new Request("https://example.com/api/v1/publishers/publishers%3Aalice/feed?limit=10items"), + ); + expect(limitResponse.status).toBe(400); + expect(await limitResponse.text()).toBe("Invalid feed limit"); + }); + + it("rejects cursor offsets outside the stored revision", async () => { + const cursor = Buffer.from( + JSON.stringify({ publisherId: "publishers:alice", sequence: 7, offset: 2 }), + ).toString("base64url"); + const publication = { + publisherId: "publishers:alice", + feedId: "clawhub.publisher.publishers:alice", + sequence: 7, + generatedAt: "2026-07-16T00:00:00.000Z", + handle: "alice", + displayName: "Alice", + entries: [{ id: "skills:one" }], + }; + const response = await publishersGetRouterV1Handler( + makeCtx({ runQuery: vi.fn(async () => publication) }), + new Request(`https://example.com/api/v1/publishers/publishers%3Aalice/feed?cursor=${cursor}`), + ); + + expect(response.status).toBe(400); + expect(await response.text()).toBe("Invalid publisher feed cursor offset"); + }); + + it("maps missing and malformed publisher feeds to 404", async () => { + const missing = await publishersGetRouterV1Handler( + makeCtx({}), + new Request("https://example.com/api/v1/publishers/publishers%3Amissing/feed"), + ); + expect(missing.status).toBe(404); + expect(await missing.text()).toBe("Publisher feed not found"); + + const malformed = await publishersGetRouterV1Handler( + makeCtx({}), + new Request("https://example.com/api/v1/publishers/%/feed"), + ); + expect(malformed.status).toBe(404); + expect(await malformed.text()).toBe("Not found"); + }); +}); diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index cecc4c2ec2..3eae62ffd8 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -9655,6 +9655,191 @@ describe("httpApiV1 handlers", () => { ); }); + it("publisher follows add succeeds for the authenticated API token user", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:1", + user: { handle: "p" }, + } as never); + const runMutation = vi.fn().mockResolvedValueOnce(okRate()).mockResolvedValueOnce({ + followId: "publisherFollows:1", + followerUserId: "users:1", + publisherId: "publishers:1", + following: true, + notifications: "none", + createdAt: 1, + updatedAt: 1, + }); + + const response = await __handlers.publisherFollowsPostV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/publisher-follows", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, + body: JSON.stringify({ publisherId: "publishers:1", notifications: "none" }), + }), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + following: true, + notifications: "none", + }); + expect(runMutation).toHaveBeenCalledWith( + internal.publisherFollows.followPublisherInternal, + expect.objectContaining({ + followerUserId: "users:1", + publisherId: "publishers:1", + notifications: "none", + }), + ); + }); + + it("publisher follows add rejects non-object JSON", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:1", + user: { handle: "p" }, + } as never); + const runMutation = vi.fn().mockResolvedValue(okRate()); + + const response = await __handlers.publisherFollowsPostV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/publisher-follows", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, + body: "null", + }), + ); + + expect(response.status).toBe(400); + expect(await response.text()).toBe("JSON body must be an object"); + expect(runMutation).toHaveBeenCalledTimes(1); + }); + + it("publisher follows list only reads the authenticated user's private follows", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:1", + user: { handle: "p" }, + } as never); + const runQuery = vi.fn().mockResolvedValue({ + ok: true, + items: [ + { + publisherId: "publishers:1", + following: true, + notifications: "all", + publisher: { handle: "openclaw", displayName: "OpenClaw" }, + }, + ], + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); + + const response = await __handlers.publisherFollowsGetV1Handler( + makeCtx({ runQuery, runMutation }), + new Request("https://example.com/api/v1/publisher-follows?limit=10&cursor=older&q=open", { + method: "GET", + headers: { Authorization: "Bearer clh_test" }, + }), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + ok: true, + items: [expect.objectContaining({ publisherId: "publishers:1" })], + }); + expect(runQuery).toHaveBeenCalledWith( + internal.publisherFollows.listFollowedPublishersInternal, + { + followerUserId: "users:1", + cursor: "older", + limit: 10, + query: "open", + }, + ); + }); + + it("publisher follows list rejects malformed query parameters before reading follows", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:1", + user: { handle: "p" }, + } as never); + const runQuery = vi.fn(); + const runMutation = vi.fn().mockResolvedValue(okRate()); + const ctx = makeCtx({ runQuery, runMutation }); + + const invalidLimit = await __handlers.publisherFollowsGetV1Handler( + ctx, + new Request("https://example.com/api/v1/publisher-follows?limit=10items", { + headers: { Authorization: "Bearer clh_test" }, + }), + ); + expect(invalidLimit.status).toBe(400); + expect(await invalidLimit.text()).toBe("Invalid follow list limit"); + + const emptyCursor = await __handlers.publisherFollowsGetV1Handler( + ctx, + new Request("https://example.com/api/v1/publisher-follows?cursor=", { + headers: { Authorization: "Bearer clh_test" }, + }), + ); + expect(emptyCursor.status).toBe(400); + expect(await emptyCursor.text()).toBe("Invalid cursor format"); + expect(runQuery).not.toHaveBeenCalled(); + }); + + it("publisher follows list rejects malformed cursors as client errors", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:1", + user: { handle: "p" }, + } as never); + const runQuery = vi.fn().mockRejectedValue(new Error("Invalid cursor format")); + + const response = await __handlers.publisherFollowsGetV1Handler( + makeCtx({ runQuery }), + new Request("https://example.com/api/v1/publisher-follows?cursor=bad", { + method: "GET", + headers: { Authorization: "Bearer clh_test" }, + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toBe("Invalid cursor format"); + }); + + it("publisher follows delete is idempotent", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:1", + user: { handle: "p" }, + } as never); + const runMutation = vi.fn().mockResolvedValueOnce(okRate()).mockResolvedValueOnce({ + ok: true, + following: false, + unfollowed: false, + alreadyUnfollowed: true, + publisherId: "publishers:1", + }); + + const response = await __handlers.publisherFollowsDeleteV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/publisher-follows?publisherId=publishers:1", { + method: "DELETE", + headers: { Authorization: "Bearer clh_test" }, + }), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + following: false, + alreadyUnfollowed: true, + }); + expect(runMutation).toHaveBeenCalledWith( + internal.publisherFollows.unfollowPublisherInternal, + expect.objectContaining({ + followerUserId: "users:1", + publisherId: "publishers:1", + }), + ); + }); + it("packages search ignores retired execution and capability filters", async () => { const runQuery = vi.fn((_, args: Record) => { if ("query" in args) return []; diff --git a/convex/httpApiV1.ts b/convex/httpApiV1.ts index d93c213a25..265512a968 100644 --- a/convex/httpApiV1.ts +++ b/convex/httpApiV1.ts @@ -1,4 +1,5 @@ import { httpAction } from "./functions"; +import { publishersGetRouterV1Handler } from "./httpApiV1/accountFeedsV1"; import { catalogClawsFeedV1Handler, catalogFeedV1Handler, @@ -27,6 +28,11 @@ import { promotionsGetRouterV1Handler, promotionsPostRouterV1Handler, } from "./httpApiV1/promotionsV1"; +import { + publisherFollowsDeleteV1Handler, + publisherFollowsGetV1Handler, + publisherFollowsPostV1Handler, +} from "./httpApiV1/publisherFollowsV1"; import { createPublisherV1Handler } from "./httpApiV1/publishersV1"; import { skillsShCatalogPublicV1Handler, @@ -72,6 +78,10 @@ export const listCodePluginsV1Http = httpAction(listCodePluginsV1Handler); export const listBundlePluginsV1Http = httpAction(listBundlePluginsV1Handler); export const verifyDocsSessionV1Http = httpAction(verifyDocsSessionV1Handler); export const createPublisherV1Http = httpAction(createPublisherV1Handler); +export const publishersGetRouterV1Http = httpAction(publishersGetRouterV1Handler); +export const publisherFollowsGetV1Http = httpAction(publisherFollowsGetV1Handler); +export const publisherFollowsPostV1Http = httpAction(publisherFollowsPostV1Handler); +export const publisherFollowsDeleteV1Http = httpAction(publisherFollowsDeleteV1Handler); export const contentRightsV1Http = httpAction(contentRightsV1Handler); export const skillsShCatalogTestV1Http = httpAction(skillsShCatalogTestV1Handler); export const skillsShCatalogPublicV1Http = httpAction(skillsShCatalogPublicV1Handler); @@ -125,6 +135,10 @@ export const __handlers = { listBundlePluginsV1Handler, verifyDocsSessionV1Handler, createPublisherV1Handler, + publishersGetRouterV1Handler, + publisherFollowsGetV1Handler, + publisherFollowsPostV1Handler, + publisherFollowsDeleteV1Handler, contentRightsV1Handler, skillsShCatalogTestV1Handler, skillsShCatalogPublicV1Handler, diff --git a/convex/httpApiV1/accountFeedsV1.ts b/convex/httpApiV1/accountFeedsV1.ts new file mode 100644 index 0000000000..901bdac897 --- /dev/null +++ b/convex/httpApiV1/accountFeedsV1.ts @@ -0,0 +1,209 @@ +import { PUBLISHER_FEED_MAX_LIMIT, PUBLISHER_FEED_SCHEMA_VERSION } from "clawhub-schema"; +import { internal } from "../_generated/api"; +import type { ActionCtx } from "../_generated/server"; +import { mergeHeaders } from "../lib/httpHeaders"; +import { applyRateLimit } from "../lib/httpRateLimit"; +import { getPathSegments, json, text } from "./shared"; + +const publisherFeedRefs = internal as unknown as { + accountFeeds: { + getPublisherDetail: unknown; + getPublisherFeedPublication: unknown; + refreshPublisherFeed: unknown; + }; +}; + +type PublisherFeedCursor = { + publisherId: string; + sequence: number; + offset: number; +}; + +type StoredPublisherFeed = { + publisherId: string; + feedId: string; + sequence: number; + generatedAt: string; + handle: string | null; + displayName: string; + entries: unknown[]; +}; + +async function runQueryRef( + ctx: Pick, + ref: unknown, + args: unknown, +): Promise { + return (await ctx.runQuery(ref as never, args as never)) as T; +} + +async function runMutationRef( + ctx: Pick, + ref: unknown, + args: unknown, +): Promise { + return (await ctx.runMutation(ref as never, args as never)) as T; +} + +function encodeFeedCursor(cursor: PublisherFeedCursor) { + return btoa(JSON.stringify(cursor)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, ""); +} + +function decodeFeedCursor(raw: string): PublisherFeedCursor | null { + if (!raw || raw.length > 512 || !/^[A-Za-z0-9_-]+$/u.test(raw)) return null; + try { + const padded = raw + .replaceAll("-", "+") + .replaceAll("_", "/") + .padEnd(Math.ceil(raw.length / 4) * 4, "="); + const parsed = JSON.parse(atob(padded)) as Partial; + if ( + typeof parsed.publisherId !== "string" || + !parsed.publisherId || + !Number.isSafeInteger(parsed.sequence) || + (parsed.sequence ?? -1) < 0 || + !Number.isSafeInteger(parsed.offset) || + (parsed.offset ?? 0) <= 0 + ) { + return null; + } + return parsed as PublisherFeedCursor; + } catch { + return null; + } +} + +type ParsedFeedReadParams = + | { response: Response } + | { args: { limit: number; cursor: PublisherFeedCursor | null } }; + +function parseFeedReadParams(request: Request, rateHeaders: HeadersInit): ParsedFeedReadParams { + const url = new URL(request.url); + const limitValue = url.searchParams.get("limit"); + let limit = Math.min(50, PUBLISHER_FEED_MAX_LIMIT); + if (limitValue !== null) { + if (!/^[1-9]\d*$/u.test(limitValue)) { + return { response: text("Invalid feed limit", 400, rateHeaders) }; + } + const parsedLimit = Number(limitValue); + if (!Number.isSafeInteger(parsedLimit)) { + return { response: text("Invalid feed limit", 400, rateHeaders) }; + } + limit = Math.min(parsedLimit, PUBLISHER_FEED_MAX_LIMIT); + } + + const cursorValue = url.searchParams.get("cursor"); + const cursor = cursorValue === null ? null : decodeFeedCursor(cursorValue); + if (cursorValue !== null && !cursor) { + return { response: text("Invalid publisher feed cursor", 400, rateHeaders) }; + } + return { args: { limit, cursor } }; +} + +const FEED_HEADERS = { + "Cache-Control": "private, no-store", + "X-Content-Type-Options": "nosniff", +}; + +function feedHeaders(rateHeaders: HeadersInit) { + return mergeHeaders(rateHeaders, FEED_HEADERS); +} + +function safePathSegments(request: Request, prefix: string) { + try { + return getPathSegments(request, prefix); + } catch (error) { + if (error instanceof URIError) return null; + throw error; + } +} + +function pagePublisherFeed(feed: StoredPublisherFeed, limit: number, offset: number) { + const entries = feed.entries.slice(offset, offset + limit); + const nextOffset = offset + entries.length; + return { + schemaVersion: PUBLISHER_FEED_SCHEMA_VERSION, + feedId: feed.feedId, + publisherId: feed.publisherId, + handle: feed.handle, + displayName: feed.displayName, + generatedAt: feed.generatedAt, + sequence: feed.sequence, + entries, + nextCursor: + nextOffset < feed.entries.length + ? encodeFeedCursor({ + publisherId: feed.publisherId, + sequence: feed.sequence, + offset: nextOffset, + }) + : null, + }; +} + +export async function publishersGetRouterV1Handler(ctx: ActionCtx, request: Request) { + const rate = await applyRateLimit(ctx, request, "read"); + if (!rate.ok) return rate.response; + + const segments = safePathSegments(request, "/api/v1/publishers/"); + if (!segments || (segments.length !== 1 && !(segments.length === 2 && segments[1] === "feed"))) { + return text("Not found", 404, rate.headers); + } + + const publisherId = (segments[0] ?? "").trim(); + if (!publisherId) return text("Publisher not found", 404, rate.headers); + + if (segments.length === 1) { + const detail = await runQueryRef(ctx, publisherFeedRefs.accountFeeds.getPublisherDetail, { + publisherId, + }); + if (!detail) return text("Publisher not found", 404, rate.headers); + return json(detail, 200, rate.headers); + } + + const params = parseFeedReadParams(request, rate.headers); + if ("response" in params) return params.response; + const { cursor, limit } = params.args; + if (cursor && cursor.publisherId !== publisherId) { + return text("Publisher feed cursor does not match publisher", 400, rate.headers); + } + + if (cursor) { + const publication = await runQueryRef( + ctx, + publisherFeedRefs.accountFeeds.getPublisherFeedPublication, + { publisherId }, + ); + if (!publication) return text("Publisher feed not found", 404, rate.headers); + if (publication.sequence !== cursor.sequence) { + return text( + "Publisher feed cursor is stale; restart from the first page", + 409, + mergeHeaders(rate.headers, { "Cache-Control": "no-store" }), + ); + } + if (cursor.offset >= publication.entries.length) { + return text("Invalid publisher feed cursor offset", 400, rate.headers); + } + return json( + pagePublisherFeed(publication, limit, cursor.offset), + 200, + feedHeaders(rate.headers), + ); + } + + const feed = await runMutationRef( + ctx, + publisherFeedRefs.accountFeeds.refreshPublisherFeed, + { publisherId }, + ); + if (!feed) return text("Publisher feed not found", 404, rate.headers); + if ("status" in feed) { + return text( + "Publisher feed exceeds the current snapshot capacity", + 503, + mergeHeaders(rate.headers, { "Cache-Control": "no-store" }), + ); + } + return json(pagePublisherFeed(feed, limit, 0), 200, feedHeaders(rate.headers)); +} diff --git a/convex/httpApiV1/publisherFollowsV1.ts b/convex/httpApiV1/publisherFollowsV1.ts new file mode 100644 index 0000000000..995958caa1 --- /dev/null +++ b/convex/httpApiV1/publisherFollowsV1.ts @@ -0,0 +1,161 @@ +import { internal } from "../_generated/api"; +import type { Id } from "../_generated/dataModel"; +import type { ActionCtx } from "../_generated/server"; +import { applyRateLimit } from "../lib/httpRateLimit"; +import { json, parseJsonPayload, requireApiTokenUserOrResponse, text } from "./shared"; + +const publisherFollowInternalRefs = internal as unknown as { + publisherFollows: { + followPublisherInternal: unknown; + unfollowPublisherInternal: unknown; + listFollowedPublishersInternal: unknown; + }; +}; + +function publisherIdFromUrl(request: Request) { + const value = new URL(request.url).searchParams.get("publisherId")?.trim(); + return value ? (value as Id<"publishers">) : undefined; +} + +function publisherIdFromPayload(payload: Record) { + const value = typeof payload.publisherId === "string" ? payload.publisherId.trim() : ""; + return value ? (value as Id<"publishers">) : undefined; +} + +function notificationsFromPayload(payload: Record) { + const value = + typeof payload.notifications === "string" ? payload.notifications.trim() : undefined; + if (!value) return undefined; + if (value === "all" || value === "none") return value; + throw new Error('notifications must be "all" or "none"'); +} + +function isJsonObject(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +type ParsedListParams = + | { response: Response } + | { args: { cursor?: string; limit?: number; query?: string } }; + +function parseListParams(url: URL, headers: HeadersInit): ParsedListParams { + const limitValue = url.searchParams.get("limit"); + if (limitValue !== null && !/^[1-9]\d*$/.test(limitValue)) { + return { response: text("Invalid follow list limit", 400, headers) } as const; + } + const limit = limitValue === null ? undefined : Number(limitValue); + if (limit !== undefined && !Number.isSafeInteger(limit)) { + return { response: text("Invalid follow list limit", 400, headers) } as const; + } + + const cursor = url.searchParams.get("cursor"); + if (url.searchParams.has("cursor") && !cursor) { + return { response: text("Invalid cursor format", 400, headers) } as const; + } + const query = url.searchParams.get("q")?.trim(); + if (query && query.length > 200) { + return { response: text("Follow list query is too long", 400, headers) } as const; + } + + return { + args: { + ...(cursor ? { cursor } : {}), + ...(limit === undefined ? {} : { limit }), + ...(query ? { query } : {}), + }, + } as const; +} + +export async function publisherFollowsGetV1Handler(ctx: ActionCtx, request: Request) { + const rate = await applyRateLimit(ctx, request, "read"); + if (!rate.ok) return rate.response; + + const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers); + if (!auth.ok) return auth.response; + + const url = new URL(request.url); + const params = parseListParams(url, rate.headers); + if ("response" in params) return params.response; + try { + const result = await ctx.runQuery( + publisherFollowInternalRefs.publisherFollows.listFollowedPublishersInternal as never, + { followerUserId: auth.userId, ...params.args } as never, + ); + return json(result, 200, rate.headers); + } catch (error) { + if (error instanceof Error && error.message.includes("Invalid cursor format")) { + return text("Invalid cursor format", 400, rate.headers); + } + throw error; + } +} + +export async function publisherFollowsPostV1Handler(ctx: ActionCtx, request: Request) { + const rate = await applyRateLimit(ctx, request, "write"); + if (!rate.ok) return rate.response; + + const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers); + if (!auth.ok) return auth.response; + + const payloadResult = await parseJsonPayload(request, rate.headers); + if (!payloadResult.ok) return payloadResult.response; + const payload = payloadResult.payload; + if (!isJsonObject(payload)) return text("JSON body must be an object", 400, rate.headers); + const publisherId = publisherIdFromPayload(payload); + if (!publisherId) return text("Missing publisherId", 400, rate.headers); + + try { + const notifications = notificationsFromPayload(payload); + const result = await ctx.runMutation( + publisherFollowInternalRefs.publisherFollows.followPublisherInternal as never, + { + followerUserId: auth.userId, + publisherId, + ...(notifications ? { notifications } : {}), + } as never, + ); + return json(result, 200, rate.headers); + } catch (error) { + return text( + errorMessage(error, "Unable to follow publisher."), + errorStatus(error), + rate.headers, + ); + } +} + +export async function publisherFollowsDeleteV1Handler(ctx: ActionCtx, request: Request) { + const rate = await applyRateLimit(ctx, request, "write"); + if (!rate.ok) return rate.response; + + const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers); + if (!auth.ok) return auth.response; + + const publisherId = publisherIdFromUrl(request); + if (!publisherId) return text("Missing publisherId", 400, rate.headers); + + try { + const result = await ctx.runMutation( + publisherFollowInternalRefs.publisherFollows.unfollowPublisherInternal as never, + { followerUserId: auth.userId, publisherId } as never, + ); + return json(result, 200, rate.headers); + } catch (error) { + return text( + errorMessage(error, "Unable to unfollow publisher."), + errorStatus(error), + rate.headers, + ); + } +} + +function errorStatus(error: unknown) { + const message = errorMessage(error, ""); + if (/not found/i.test(message)) return 404; + if (/unauthorized/i.test(message)) return 401; + return 400; +} + +function errorMessage(error: unknown, fallback: string) { + return error instanceof Error && error.message.trim() ? error.message.trim() : fallback; +} diff --git a/convex/lib/publishers.ts b/convex/lib/publishers.ts index 9af1d52dcd..610f1f7dc4 100644 --- a/convex/lib/publishers.ts +++ b/convex/lib/publishers.ts @@ -7,6 +7,11 @@ export type PublisherRole = "owner" | "admin" | "publisher"; type DbCtx = Pick; +export type PublicPublisherVisibility = { + publisher: Doc<"publishers">; + linkedUser: Doc<"users"> | null; +}; + export const PUBLISHER_HANDLE_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,38}[a-z0-9])?$/; export const PUBLISHER_HANDLE_REQUIREMENTS_MESSAGE = "Handle must be 40 characters or fewer, start and end with a lowercase letter or number, and use only lowercase letters, numbers, hyphens, dots, or underscores"; @@ -109,7 +114,7 @@ export function formatReservedOpenClawPublisherHandleMessage(handle: string) { export function isPublisherActive( publisher: Pick, "deletedAt" | "deactivatedAt"> | null | undefined, -) { +): publisher is Doc<"publishers"> { return Boolean(publisher && !publisher.deletedAt && !publisher.deactivatedAt); } @@ -232,6 +237,39 @@ export async function getPersonalPublisherForUser(ctx: DbCtx, userId: Id<"users" } } +async function getLegacyPersonalPublisherOwner( + ctx: Pick, + publisherId: Id<"publishers">, +) { + const memberships = await ctx.db + .query("publisherMembers") + .withIndex("by_publisher", (q) => q.eq("publisherId", publisherId)) + .collect(); + for (const membership of memberships) { + if (membership.role !== "owner") continue; + const user = await ctx.db.get(membership.userId); + if (user && !user.deletedAt && !user.deactivatedAt) return user; + } + return null; +} + +export async function getPublicPublisherVisibility( + ctx: Pick, + publisher: Doc<"publishers"> | null | undefined, +): Promise { + if (!isPublisherActive(publisher)) return null; + if (publisher.kind !== "user") return { publisher, linkedUser: null }; + + if (!publisher.linkedUserId) { + const legacyOwner = await getLegacyPersonalPublisherOwner(ctx, publisher._id); + return legacyOwner ? { publisher, linkedUser: legacyOwner } : null; + } + + const linkedUser = await ctx.db.get(publisher.linkedUserId); + if (!linkedUser || linkedUser.deletedAt || linkedUser.deactivatedAt) return null; + return { publisher, linkedUser }; +} + export async function ensurePersonalPublisherForUser( ctx: Pick, user: Doc<"users">, @@ -611,3 +649,4 @@ export async function getOwnerPublisher( if (!user || user.deletedAt || user.deactivatedAt) return null; return await getPersonalPublisherForUserOrFallback(ctx, user); } +export const MAX_FOLLOWED_PUBLISHERS = 100; diff --git a/convex/lib/retentionPolicy.ts b/convex/lib/retentionPolicy.ts index e975e61fff..118ebc9cf6 100644 --- a/convex/lib/retentionPolicy.ts +++ b/convex/lib/retentionPolicy.ts @@ -236,8 +236,10 @@ export const RETENTION_POLICIES = { packageModerationEventLogs: permanent("Package moderation event audit log."), officialPluginMigrations: permanent("Official plugin migration state."), catalogFeedPublications: permanent("Current published hosted catalog feed snapshot."), + publisherFeedPublications: permanent("Current coherent publisher feed revision."), stars: permanent("User star records."), promotions: permanent("Curated promotional offers; ended records stay for launch-page history."), + publisherFollows: permanent("User publisher follow preference records."), auditLogs: permanent("Audit logs are durable compliance/security history."), systemSettings: permanent("Durable operator-controlled system settings."), skillsShCatalogControls: permanent("Durable skills.sh catalog operator controls."), diff --git a/convex/publisherFollows.test.ts b/convex/publisherFollows.test.ts new file mode 100644 index 0000000000..1e4a1fe4cb --- /dev/null +++ b/convex/publisherFollows.test.ts @@ -0,0 +1,491 @@ +/* @vitest-environment node */ +import { getAuthUserId } from "@convex-dev/auth/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@convex-dev/auth/server", () => ({ + getAuthUserId: vi.fn(), + authTables: {}, +})); + +vi.mock("./functions", () => ({ + internalMutation: (def: { handler: unknown }) => ({ _handler: def.handler }), + internalQuery: (def: { handler: unknown }) => ({ _handler: def.handler }), + mutation: (def: { handler: unknown }) => ({ _handler: def.handler }), + query: (def: { handler: unknown }) => ({ _handler: def.handler }), +})); + +const { + deletePublisherFollowsForFollowerInternal, + deletePublisherFollowsForPublisherInternal, + followPublisherInternal, + listFollowedPublishersInternal, + unfollowPublisherInternal, +} = await import("./publisherFollows"); + +type WrappedHandler = { + _handler: (ctx: unknown, args: TArgs) => Promise; +}; + +const followPublisherInternalHandler = ( + followPublisherInternal as unknown as WrappedHandler< + { followerUserId: string; publisherId: string; notifications?: "all" | "none" }, + { following: boolean; notifications: "all" | "none" } + > +)._handler; +const unfollowPublisherInternalHandler = ( + unfollowPublisherInternal as unknown as WrappedHandler< + { followerUserId: string; publisherId: string }, + { following: boolean; unfollowed: boolean; alreadyUnfollowed: boolean } + > +)._handler; +const listFollowedPublishersInternalHandler = ( + listFollowedPublishersInternal as unknown as WrappedHandler< + { followerUserId: string; cursor?: string | null; limit?: number; query?: string }, + { + items: Array<{ publisher: { handle: string }; notifications: "all" | "none" }>; + continueCursor: string; + isDone: boolean; + } + > +)._handler; +const deletePublisherFollowsForFollowerInternalHandler = ( + deletePublisherFollowsForFollowerInternal as unknown as WrappedHandler< + { followerUserId: string; cursor?: string }, + { deleted: number; scheduled: boolean } + > +)._handler; +const deletePublisherFollowsForPublisherInternalHandler = ( + deletePublisherFollowsForPublisherInternal as unknown as WrappedHandler< + { publisherId: string; cursor?: string }, + { deleted: number; scheduled: boolean } + > +)._handler; + +function makePublisher(overrides: Record = {}) { + return { + _id: "publishers:1", + handle: "demo", + displayName: "Demo Publisher", + kind: "user", + image: undefined, + deletedAt: undefined, + deactivatedAt: undefined, + ...overrides, + }; +} + +function makeCtx(params: { + publisher?: Record | null; + existingFollow?: Record | null; + listRows?: Array>; + listPages?: Array>>; +}) { + const publisher = params.publisher === undefined ? makePublisher() : params.publisher; + let pageIndex = 0; + const get = vi.fn(async (id: string) => { + if (id === "publishers:1") return publisher; + if (id === "publishers:2") return makePublisher({ _id: id, handle: "active-2" }); + if (id === "publishers:hidden") return makePublisher({ _id: id, deletedAt: Date.now() }); + if (id === "users:viewer") return { _id: id, role: "user" }; + return null; + }); + const insert = vi.fn(async (table: string) => `${table}:new`); + const patch = vi.fn(); + const deleteDoc = vi.fn(); + const paginate = vi.fn(async (_opts: { cursor: string | null; numItems: number }) => { + const pages = params.listPages ?? [params.listRows ?? []]; + const page = pages[pageIndex] ?? []; + pageIndex += 1; + return { + page, + isDone: pageIndex >= pages.length, + continueCursor: pageIndex >= pages.length ? "" : `cursor:${pageIndex}`, + }; + }); + const query = vi.fn((table: string) => { + if (table !== "publisherFollows") throw new Error(`unexpected table ${table}`); + return { + withIndex: (_index: string, build?: (q: unknown) => unknown) => { + const q = { eq: vi.fn() }; + q.eq.mockReturnValue(q); + build?.(q); + return { + unique: async () => params.existingFollow ?? null, + order: () => ({ + take: async () => params.listRows ?? [], + paginate, + }), + }; + }, + }; + }); + return { + ctx: { db: { get, insert, patch, delete: deleteDoc, query } }, + db: { get, insert, patch, deleteDoc, paginate, query }, + }; +} + +describe("publisher follows", () => { + afterEach(() => { + vi.mocked(getAuthUserId).mockReset(); + }); + + it("creates a follow row with default notifications and audit log", async () => { + const { ctx, db } = makeCtx({ existingFollow: null }); + + const result = await followPublisherInternalHandler(ctx, { + followerUserId: "users:viewer", + publisherId: "publishers:1", + }); + + expect(result).toMatchObject({ + following: true, + notifications: "all", + publisherId: "publishers:1", + }); + expect(db.insert).toHaveBeenCalledWith("publisherFollows", { + followerUserId: "users:viewer", + publisherId: "publishers:1", + notifications: "all", + createdAt: expect.any(Number), + updatedAt: expect.any(Number), + }); + expect(db.insert).toHaveBeenCalledWith( + "auditLogs", + expect.objectContaining({ + actorUserId: "users:viewer", + action: "publisher.follow.create", + targetId: "publishers:1", + }), + ); + }); + + it("is idempotent and only patches an existing row when the notification preference changes", async () => { + const existingFollow = { + _id: "publisherFollows:1", + followerUserId: "users:viewer", + publisherId: "publishers:1", + notifications: "none", + createdAt: 1, + updatedAt: 1, + }; + const { ctx, db } = makeCtx({ existingFollow }); + + const result = await followPublisherInternalHandler(ctx, { + followerUserId: "users:viewer", + publisherId: "publishers:1", + notifications: "all", + }); + + expect(result).toMatchObject({ following: true, notifications: "all" }); + expect(db.patch).toHaveBeenCalledWith("publisherFollows:1", { + notifications: "all", + updatedAt: expect.any(Number), + }); + expect(db.insert).not.toHaveBeenCalledWith("publisherFollows", expect.anything()); + }); + + it("preserves an existing notification preference when a retry omits it", async () => { + const existingFollow = { + _id: "publisherFollows:1", + followerUserId: "users:viewer", + publisherId: "publishers:1", + notifications: "none", + createdAt: 1, + updatedAt: 1, + }; + const { ctx, db } = makeCtx({ existingFollow }); + + const result = await followPublisherInternalHandler(ctx, { + followerUserId: "users:viewer", + publisherId: "publishers:1", + }); + + expect(result).toMatchObject({ following: true, notifications: "none" }); + expect(db.patch).not.toHaveBeenCalled(); + }); + + it("bounds the publisher set used by discovery and timelines", async () => { + const listRows = Array.from({ length: 100 }, (_, index) => ({ + _id: `publisherFollows:${index}`, + followerUserId: "users:viewer", + publisherId: `publishers:${index}`, + createdAt: index, + updatedAt: index, + })); + const { ctx, db } = makeCtx({ existingFollow: null, listRows }); + + await expect( + followPublisherInternalHandler(ctx, { + followerUserId: "users:viewer", + publisherId: "publishers:1", + }), + ).rejects.toThrow("follow up to 100 publishers"); + expect(db.insert).not.toHaveBeenCalled(); + }); + + it("unfollow is idempotent and audits only real deletes", async () => { + const missing = makeCtx({ existingFollow: null }); + await expect( + unfollowPublisherInternalHandler(missing.ctx, { + followerUserId: "users:viewer", + publisherId: "publishers:1", + }), + ).resolves.toEqual({ + ok: true, + following: false, + unfollowed: false, + alreadyUnfollowed: true, + publisherId: "publishers:1", + }); + expect(missing.db.deleteDoc).not.toHaveBeenCalled(); + + const existing = makeCtx({ + existingFollow: { + _id: "publisherFollows:1", + followerUserId: "users:viewer", + publisherId: "publishers:1", + notifications: "all", + createdAt: 1, + updatedAt: 1, + }, + }); + await expect( + unfollowPublisherInternalHandler(existing.ctx, { + followerUserId: "users:viewer", + publisherId: "publishers:1", + }), + ).resolves.toMatchObject({ following: false, unfollowed: true }); + expect(existing.db.deleteDoc).toHaveBeenCalledWith("publisherFollows:1"); + expect(existing.db.insert).toHaveBeenCalledWith( + "auditLogs", + expect.objectContaining({ action: "publisher.follow.delete" }), + ); + }); + + it("allows stale follows to be removed after a publisher is deactivated", async () => { + const existing = makeCtx({ + publisher: makePublisher({ deactivatedAt: Date.now() }), + existingFollow: { + _id: "publisherFollows:1", + followerUserId: "users:viewer", + publisherId: "publishers:1", + notifications: "none", + createdAt: 1, + updatedAt: 1, + }, + }); + + await expect( + unfollowPublisherInternalHandler(existing.ctx, { + followerUserId: "users:viewer", + publisherId: "publishers:1", + }), + ).resolves.toMatchObject({ following: false, unfollowed: true }); + expect(existing.db.deleteDoc).toHaveBeenCalledWith("publisherFollows:1"); + }); + + it("omits inactive publishers from the private follow list", async () => { + const { ctx } = makeCtx({ + listRows: [ + { + _id: "publisherFollows:1", + followerUserId: "users:viewer", + publisherId: "publishers:1", + notifications: "all", + createdAt: 1, + updatedAt: 2, + }, + { + _id: "publisherFollows:2", + followerUserId: "users:viewer", + publisherId: "publishers:hidden", + notifications: "none", + createdAt: 1, + updatedAt: 1, + }, + ], + }); + + const result = await listFollowedPublishersInternalHandler(ctx, { + followerUserId: "users:viewer", + }); + + expect(result.items).toEqual([ + expect.objectContaining({ + notifications: "all", + publisher: expect.objectContaining({ handle: "demo" }), + }), + ]); + }); + + it("continues scanning until active follows fill the requested list", async () => { + const { ctx, db } = makeCtx({ + listPages: [ + [ + { + _id: "publisherFollows:hidden", + followerUserId: "users:viewer", + publisherId: "publishers:hidden", + notifications: "none", + createdAt: 1, + updatedAt: 3, + }, + ], + [ + { + _id: "publisherFollows:2", + followerUserId: "users:viewer", + publisherId: "publishers:2", + notifications: "all", + createdAt: 1, + updatedAt: 2, + }, + ], + ], + }); + + const result = await listFollowedPublishersInternalHandler(ctx, { + followerUserId: "users:viewer", + limit: 1, + }); + + expect(result.items).toEqual([ + expect.objectContaining({ + publisherId: "publishers:2", + publisher: expect.objectContaining({ handle: "active-2" }), + }), + ]); + expect(result).toMatchObject({ continueCursor: "", isDone: true }); + expect(db.paginate).toHaveBeenNthCalledWith(1, { cursor: null, numItems: 1 }); + expect(db.paginate).toHaveBeenNthCalledWith(2, { cursor: "cursor:1", numItems: 1 }); + }); + + it("starts the followed publisher list from a supplied cursor", async () => { + const { ctx, db } = makeCtx({ + listPages: [ + [ + { + _id: "publisherFollows:2", + followerUserId: "users:viewer", + publisherId: "publishers:2", + notifications: "all", + createdAt: 1, + updatedAt: 2, + }, + ], + ], + }); + + const result = await listFollowedPublishersInternalHandler(ctx, { + followerUserId: "users:viewer", + cursor: "cursor:older", + limit: 25, + }); + + expect(result.items).toHaveLength(1); + expect(result).toMatchObject({ continueCursor: "", isDone: true }); + expect(db.paginate).toHaveBeenCalledWith({ cursor: "cursor:older", numItems: 25 }); + }); + + it("filters followed publishers by handle or display name while scanning", async () => { + const { ctx } = makeCtx({ + listPages: [ + [ + { + _id: "publisherFollows:1", + followerUserId: "users:viewer", + publisherId: "publishers:1", + notifications: "all", + createdAt: 1, + updatedAt: 3, + }, + ], + [ + { + _id: "publisherFollows:2", + followerUserId: "users:viewer", + publisherId: "publishers:2", + notifications: "all", + createdAt: 1, + updatedAt: 2, + }, + ], + ], + }); + + const result = await listFollowedPublishersInternalHandler(ctx, { + followerUserId: "users:viewer", + limit: 1, + query: "active", + }); + + expect(result.items).toEqual([ + expect.objectContaining({ + publisherId: "publishers:2", + publisher: expect.objectContaining({ handle: "active-2" }), + }), + ]); + }); + + it("returns a cursor instead of exhausting sparse followed publisher searches", async () => { + const page = [ + { + _id: "publisherFollows:1", + followerUserId: "users:viewer", + publisherId: "publishers:1", + notifications: "all", + createdAt: 1, + updatedAt: 3, + }, + ]; + const { ctx, db } = makeCtx({ + listPages: [page, page, page, page, page], + }); + + const result = await listFollowedPublishersInternalHandler(ctx, { + followerUserId: "users:viewer", + limit: 1, + query: "no-match", + }); + + expect(result).toMatchObject({ + items: [], + continueCursor: "cursor:4", + isDone: false, + }); + expect(db.paginate).toHaveBeenCalledTimes(4); + }); + + it.each([ + ["follower", { followerUserId: "users:1" }], + ["publisher", { publisherId: "publishers:1" }], + ] as const)("deletes %s follow edges in resumable batches", async (kind, args) => { + const deleteDoc = vi.fn(); + const runAfter = vi.fn(); + const paginate = vi.fn(async () => ({ + page: [{ _id: "publisherFollows:1" }, { _id: "publisherFollows:2" }], + continueCursor: "next", + isDone: false, + })); + const query = vi.fn(() => ({ withIndex: () => ({ paginate }) })); + + const ctx = { db: { query, delete: deleteDoc }, scheduler: { runAfter } }; + const result = + kind === "follower" + ? await deletePublisherFollowsForFollowerInternalHandler(ctx, { + followerUserId: "users:1", + }) + : await deletePublisherFollowsForPublisherInternalHandler(ctx, { + publisherId: "publishers:1", + }); + + expect(result).toEqual({ deleted: 2, scheduled: true }); + expect(deleteDoc).toHaveBeenCalledTimes(2); + expect(runAfter).toHaveBeenCalledWith( + 0, + expect.anything(), + expect.objectContaining({ ...args, cursor: "next" }), + ); + }); +}); diff --git a/convex/publisherFollows.ts b/convex/publisherFollows.ts new file mode 100644 index 0000000000..62b5458f43 --- /dev/null +++ b/convex/publisherFollows.ts @@ -0,0 +1,338 @@ +import { v } from "convex/values"; +import { internal } from "./_generated/api"; +import type { Doc, Id } from "./_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { internalMutation, internalQuery, mutation, query } from "./functions"; +import { requireUser } from "./lib/access"; +import { isPublisherActive, MAX_FOLLOWED_PUBLISHERS } from "./lib/publishers"; + +const notificationPreferenceValidator = v.union(v.literal("all"), v.literal("none")); +const DEFAULT_NOTIFICATION_PREFERENCE = "all" as const; +const DEFAULT_LIST_LIMIT = 50; +const MAX_LIST_LIMIT = 100; +const LIST_SCAN_BATCH_SIZE = 100; +const MAX_LIST_SCAN_PAGES = 4; +const DELETE_BATCH_SIZE = 200; + +type NotificationPreference = "all" | "none"; + +function clampListLimit(limit: number | undefined) { + if (!Number.isFinite(limit ?? DEFAULT_LIST_LIMIT)) return DEFAULT_LIST_LIMIT; + return Math.min(Math.max(Math.trunc(limit ?? DEFAULT_LIST_LIMIT), 1), MAX_LIST_LIMIT); +} + +async function requireActivePublisher( + ctx: Pick, + publisherId: Id<"publishers">, +) { + const publisher = await ctx.db.get(publisherId); + if (!publisher || !isPublisherActive(publisher)) throw new Error("Publisher not found"); + return publisher; +} + +async function getExistingFollow( + ctx: Pick, + followerUserId: Id<"users">, + publisherId: Id<"publishers">, +) { + return await ctx.db + .query("publisherFollows") + .withIndex("by_follower_publisher", (q) => + q.eq("followerUserId", followerUserId).eq("publisherId", publisherId), + ) + .unique(); +} + +function toFollowResult( + follow: Pick< + Doc<"publisherFollows">, + "_id" | "followerUserId" | "publisherId" | "notifications" | "createdAt" | "updatedAt" + >, +) { + return { + followId: follow._id, + followerUserId: follow.followerUserId, + publisherId: follow.publisherId, + following: true, + notifications: follow.notifications, + createdAt: follow.createdAt, + updatedAt: follow.updatedAt, + }; +} + +async function followPublisherForUser( + ctx: MutationCtx, + args: { + followerUserId: Id<"users">; + publisherId: Id<"publishers">; + notifications?: NotificationPreference; + }, +) { + const publisher = await requireActivePublisher(ctx, args.publisherId); + const existing = await getExistingFollow(ctx, args.followerUserId, args.publisherId); + const notifications = + args.notifications ?? existing?.notifications ?? DEFAULT_NOTIFICATION_PREFERENCE; + const now = Date.now(); + + if (existing) { + if (existing.notifications !== notifications) { + await ctx.db.patch(existing._id, { notifications, updatedAt: now }); + return toFollowResult({ ...existing, notifications, updatedAt: now }); + } + return toFollowResult(existing); + } + + const followed = await ctx.db + .query("publisherFollows") + .withIndex("by_follower", (q) => q.eq("followerUserId", args.followerUserId)) + .order("desc") + .take(MAX_FOLLOWED_PUBLISHERS); + if (followed.length >= MAX_FOLLOWED_PUBLISHERS) { + throw new Error(`You can follow up to ${MAX_FOLLOWED_PUBLISHERS} publishers`); + } + + const followId = await ctx.db.insert("publisherFollows", { + followerUserId: args.followerUserId, + publisherId: args.publisherId, + notifications, + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("auditLogs", { + actorUserId: args.followerUserId, + action: "publisher.follow.create", + targetType: "publisher", + targetId: publisher._id, + metadata: { + handle: publisher.handle, + notifications, + }, + createdAt: now, + }); + + return toFollowResult({ + _id: followId, + followerUserId: args.followerUserId, + publisherId: args.publisherId, + notifications, + createdAt: now, + updatedAt: now, + }); +} + +async function unfollowPublisherForUser( + ctx: MutationCtx, + args: { followerUserId: Id<"users">; publisherId: Id<"publishers"> }, +) { + const existing = await getExistingFollow(ctx, args.followerUserId, args.publisherId); + if (!existing) { + return { + ok: true as const, + following: false, + unfollowed: false, + alreadyUnfollowed: true, + publisherId: args.publisherId, + }; + } + + const now = Date.now(); + const publisher = await ctx.db.get(args.publisherId); + await ctx.db.delete(existing._id); + await ctx.db.insert("auditLogs", { + actorUserId: args.followerUserId, + action: "publisher.follow.delete", + targetType: "publisher", + targetId: args.publisherId, + metadata: { + handle: publisher?.handle ?? null, + publisherActive: isPublisherActive(publisher), + notifications: existing.notifications, + }, + createdAt: now, + }); + + return { + ok: true as const, + following: false, + unfollowed: true, + alreadyUnfollowed: false, + publisherId: args.publisherId, + }; +} + +async function listPublisherFollowsForUser( + ctx: QueryCtx, + args: { followerUserId: Id<"users">; cursor?: string | null; limit?: number; query?: string }, +) { + const limit = clampListLimit(args.limit); + const normalizedQuery = args.query?.trim().toLowerCase(); + const items = []; + let cursor = args.cursor ?? null; + let isDone = false; + let scannedPages = 0; + + while (items.length < limit && !isDone && scannedPages < MAX_LIST_SCAN_PAGES) { + const remaining = Math.min(limit - items.length, LIST_SCAN_BATCH_SIZE); + const page = await ctx.db + .query("publisherFollows") + .withIndex("by_follower", (q) => q.eq("followerUserId", args.followerUserId)) + .order("desc") + .paginate({ cursor, numItems: remaining }); + + for (const follow of page.page) { + const publisher = await ctx.db.get(follow.publisherId); + if (!publisher || !isPublisherActive(publisher)) continue; + if ( + normalizedQuery && + !publisher.displayName.toLowerCase().includes(normalizedQuery) && + !publisher.handle.toLowerCase().includes(normalizedQuery) + ) { + continue; + } + items.push({ + ...toFollowResult(follow), + publisher: { + _id: publisher._id, + handle: publisher.handle, + displayName: publisher.displayName, + kind: publisher.kind, + image: publisher.image ?? null, + }, + }); + if (items.length >= limit) break; + } + + cursor = page.continueCursor; + isDone = page.isDone; + scannedPages += 1; + } + + return { ok: true as const, items, continueCursor: isDone ? "" : cursor, isDone }; +} + +export const isFollowingPublisher = query({ + args: { publisherId: v.id("publishers") }, + handler: async (ctx, args) => { + const { userId } = await requireUser(ctx); + const publisher = await ctx.db.get(args.publisherId); + if (!isPublisherActive(publisher)) return false; + const existing = await getExistingFollow(ctx, userId, args.publisherId); + return Boolean(existing); + }, +}); + +export const followPublisher = mutation({ + args: { + publisherId: v.id("publishers"), + notifications: v.optional(notificationPreferenceValidator), + }, + handler: async (ctx, args) => { + const { userId } = await requireUser(ctx); + return await followPublisherForUser(ctx, { + followerUserId: userId, + publisherId: args.publisherId, + notifications: args.notifications, + }); + }, +}); + +export const unfollowPublisher = mutation({ + args: { publisherId: v.id("publishers") }, + handler: async (ctx, args) => { + const { userId } = await requireUser(ctx); + return await unfollowPublisherForUser(ctx, { + followerUserId: userId, + publisherId: args.publisherId, + }); + }, +}); + +export const listFollowedPublishers = query({ + args: { + cursor: v.optional(v.union(v.string(), v.null())), + limit: v.optional(v.number()), + query: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const { userId } = await requireUser(ctx); + return await listPublisherFollowsForUser(ctx, { + followerUserId: userId, + cursor: args.cursor, + limit: args.limit, + query: args.query, + }); + }, +}); + +export const followPublisherInternal = internalMutation({ + args: { + followerUserId: v.id("users"), + publisherId: v.id("publishers"), + notifications: v.optional(notificationPreferenceValidator), + }, + handler: async (ctx, args) => await followPublisherForUser(ctx, args), +}); + +export const unfollowPublisherInternal = internalMutation({ + args: { followerUserId: v.id("users"), publisherId: v.id("publishers") }, + handler: async (ctx, args) => await unfollowPublisherForUser(ctx, args), +}); + +export const listFollowedPublishersInternal = internalQuery({ + args: { + followerUserId: v.id("users"), + cursor: v.optional(v.union(v.string(), v.null())), + limit: v.optional(v.number()), + query: v.optional(v.string()), + }, + handler: async (ctx, args) => await listPublisherFollowsForUser(ctx, args), +}); + +async function deleteFollowBatch( + ctx: MutationCtx, + args: + | { by: "follower"; followerUserId: Id<"users">; cursor?: string } + | { by: "publisher"; publisherId: Id<"publishers">; cursor?: string }, +) { + const page = await ( + args.by === "follower" + ? ctx.db + .query("publisherFollows") + .withIndex("by_follower", (q) => q.eq("followerUserId", args.followerUserId)) + : ctx.db + .query("publisherFollows") + .withIndex("by_publisher", (q) => q.eq("publisherId", args.publisherId)) + ).paginate({ cursor: args.cursor ?? null, numItems: DELETE_BATCH_SIZE }); + for (const follow of page.page) await ctx.db.delete(follow._id); + return page; +} + +export const deletePublisherFollowsForFollowerInternal = internalMutation({ + args: { followerUserId: v.id("users"), cursor: v.optional(v.string()) }, + handler: async (ctx, args): Promise<{ deleted: number; scheduled: boolean }> => { + const page = await deleteFollowBatch(ctx, { by: "follower", ...args }); + if (!page.isDone) { + await ctx.scheduler.runAfter( + 0, + internal.publisherFollows.deletePublisherFollowsForFollowerInternal, + { followerUserId: args.followerUserId, cursor: page.continueCursor }, + ); + } + return { deleted: page.page.length, scheduled: !page.isDone }; + }, +}); + +export const deletePublisherFollowsForPublisherInternal = internalMutation({ + args: { publisherId: v.id("publishers"), cursor: v.optional(v.string()) }, + handler: async (ctx, args): Promise<{ deleted: number; scheduled: boolean }> => { + const page = await deleteFollowBatch(ctx, { by: "publisher", ...args }); + if (!page.isDone) { + await ctx.scheduler.runAfter( + 0, + internal.publisherFollows.deletePublisherFollowsForPublisherInternal, + { publisherId: args.publisherId, cursor: page.continueCursor }, + ); + } + return { deleted: page.page.length, scheduled: !page.isDone }; + }, +}); diff --git a/convex/publishers.test.ts b/convex/publishers.test.ts index 479e914cbc..3187d2b090 100644 --- a/convex/publishers.test.ts +++ b/convex/publishers.test.ts @@ -685,6 +685,17 @@ function emptyOfficialPublishersQuery() { }; } +function emptyPublisherFeedPublicationsQuery() { + return { + withIndex: vi.fn((indexName: string) => { + if (indexName !== "by_publisher") { + throw new Error(`unexpected publisherFeedPublications index ${indexName}`); + } + return { unique: vi.fn(async () => null) }; + }), + }; +} + function emptyOwnedResourcesQuery() { return { withIndex: vi.fn(() => ({ @@ -1065,7 +1076,8 @@ describe("publishers membership controls", () => { const runMutation = vi .fn() .mockResolvedValueOnce({ hiddenCount: 2, scheduled: false }) - .mockResolvedValueOnce({ deletedCount: 1, revokedTokenCount: 1, scheduled: false }); + .mockResolvedValueOnce({ deletedCount: 1, revokedTokenCount: 1, scheduled: false }) + .mockResolvedValue({ deleted: 0, scheduled: false }); const ctx = { runMutation, db: { @@ -1090,9 +1102,15 @@ describe("publishers membership controls", () => { if (table === "officialPublishers") { return emptyOfficialPublishersQuery(); } + if (table === "publisherFeedPublications") { + return emptyPublisherFeedPublicationsQuery(); + } if (table === "publisherInvites") { return emptyPublisherInvitesQuery(); } + if (table === "publisherFeedPublications") { + return emptyPublisherFeedPublicationsQuery(); + } if (table !== "publisherMembers") throw new Error(`unexpected table ${table}`); return { withIndex: vi.fn((indexName: string) => ({ @@ -1144,7 +1162,10 @@ describe("publishers membership controls", () => { deactivatedAt: expect.any(Number), }), ); - expect(runMutation).toHaveBeenCalledTimes(2); + expect(runMutation).toHaveBeenCalledTimes(3); + expect(runMutation).toHaveBeenLastCalledWith(expect.anything(), { + publisherId: "publishers:gladia", + }); expect(insert).toHaveBeenCalledWith( "auditLogs", expect.objectContaining({ @@ -1191,6 +1212,9 @@ describe("publishers membership controls", () => { if (table === "publisherInvites") { return emptyPublisherInvitesQuery(); } + if (table === "publisherFeedPublications") { + return emptyPublisherFeedPublicationsQuery(); + } if (table !== "publisherMembers") throw new Error(`unexpected table ${table}`); return { withIndex: vi.fn(() => ({ @@ -1317,10 +1341,14 @@ describe("publishers membership controls", () => { return emptyOwnedResourcesQuery(); } if (table === "officialPublishers") return emptyOfficialPublishersQuery(); + if (table === "publisherFeedPublications") { + return emptyPublisherFeedPublicationsQuery(); + } throw new Error(`unexpected table ${table}`); }); return { ctx: { + runMutation: vi.fn(async () => ({ deleted: 0, scheduled: false })), scheduler: { runAfter: vi.fn() }, db: { get: vi.fn(async (id: string) => { @@ -1448,7 +1476,8 @@ describe("publishers membership controls", () => { const runMutation = vi .fn() .mockResolvedValueOnce({ hiddenCount: 2, scheduled: false }) - .mockResolvedValueOnce({ deletedCount: 1, revokedTokenCount: 1, scheduled: false }); + .mockResolvedValueOnce({ deletedCount: 1, revokedTokenCount: 1, scheduled: false }) + .mockResolvedValue({ deleted: 0, scheduled: false }); const actorMembership = { _id: "publisherMembers:owner", publisherId: "publishers:gladia", @@ -1486,9 +1515,15 @@ describe("publishers membership controls", () => { if (table === "officialPublishers") { return emptyOfficialPublishersQuery(); } + if (table === "publisherFeedPublications") { + return emptyPublisherFeedPublicationsQuery(); + } if (table === "publisherInvites") { return emptyPublisherInvitesQuery(); } + if (table === "publisherFeedPublications") { + return emptyPublisherFeedPublicationsQuery(); + } if (table !== "publisherMembers") throw new Error(`unexpected table ${table}`); return { withIndex: vi.fn((indexName: string) => { diff --git a/convex/publishers.ts b/convex/publishers.ts index 1c8430fd7a..423f9c736a 100644 --- a/convex/publishers.ts +++ b/convex/publishers.ts @@ -32,12 +32,14 @@ import { getPublisherMembership, getPersonalPublisherForUserOrFallback, getPersonalPublisherForUser, + getPublicPublisherVisibility, isPublisherActive, isPublisherRoleAllowed, isReservedOpenClawPublisherHandle, PUBLISHER_HANDLE_PATTERN, PUBLISHER_HANDLE_REQUIREMENTS_MESSAGE, normalizePublisherHandle, + type PublicPublisherVisibility, } from "./lib/publishers"; import { getLatestActiveReservedHandle, @@ -284,45 +286,6 @@ function hasPublisherStats(publisher: Doc<"publishers">) { ); } -type PublicPublisherVisibility = { - publisher: Doc<"publishers">; - linkedUser: Doc<"users"> | null; -}; - -async function getPublicPublisherVisibility( - ctx: Pick, - publisher: Doc<"publishers"> | null | undefined, -): Promise { - if (!publisher || publisher.deletedAt || publisher.deactivatedAt) return null; - if (publisher.kind !== "user") { - return { publisher, linkedUser: null }; - } - if (!publisher.linkedUserId) { - const legacyOwner = await getLegacyPersonalPublisherOwner(ctx, publisher._id); - return legacyOwner ? { publisher, linkedUser: legacyOwner } : null; - } - - const linkedUser = await ctx.db.get(publisher.linkedUserId); - if (!linkedUser || linkedUser.deletedAt || linkedUser.deactivatedAt) return null; - return { publisher, linkedUser }; -} - -async function getLegacyPersonalPublisherOwner( - ctx: Pick, - publisherId: Id<"publishers">, -) { - const memberships = await ctx.db - .query("publisherMembers") - .withIndex("by_publisher", (q) => q.eq("publisherId", publisherId)) - .collect(); - for (const membership of memberships) { - if (membership.role !== "owner") continue; - const user = await ctx.db.get(membership.userId); - if (user && !user.deletedAt && !user.deactivatedAt) return user; - } - return null; -} - function getPublisherDenormalizedStats(publisher: Doc<"publishers">): PublisherListStats { return { skills: publisher.publishedSkills ?? 0, @@ -2013,11 +1976,20 @@ async function inspectPublisherHardDeleteRows(ctx: MutationCtx, publisherId: Id< .withIndex("by_publisher", (q) => q.eq("publisherId", publisherId)) .unique(); - return { sources, sourceContents, members, invites, official }; + const feedPublication = await ctx.db + .query("publisherFeedPublications") + .withIndex("by_publisher", (q) => q.eq("publisherId", publisherId)) + .unique(); + + return { sources, sourceContents, members, invites, official, feedPublication }; } async function hardDeletePublisherRows(ctx: MutationCtx, publisherId: Id<"publishers">) { const preview = await inspectPublisherHardDeleteRows(ctx, publisherId); + const deletedFollows = (await ctx.runMutation( + internal.publisherFollows.deletePublisherFollowsForPublisherInternal, + { publisherId }, + )) as { deleted: number; scheduled: boolean }; for (const source of preview.sources) { const contents = await ctx.db @@ -2035,6 +2007,7 @@ async function hardDeletePublisherRows(ctx: MutationCtx, publisherId: Id<"publis for (const invite of preview.invites) await ctx.db.delete(invite._id); if (preview.official) await ctx.db.delete(preview.official._id); + if (preview.feedPublication) await ctx.db.delete(preview.feedPublication._id); await ctx.db.delete(publisherId); @@ -2044,6 +2017,9 @@ async function hardDeletePublisherRows(ctx: MutationCtx, publisherId: Id<"publis members: preview.members.length, invites: preview.invites.length, official: Boolean(preview.official), + feedPublication: Boolean(preview.feedPublication), + publisherFollows: deletedFollows.deleted, + publisherFollowsCleanupScheduled: deletedFollows.scheduled, }; } diff --git a/convex/schema.ts b/convex/schema.ts index 64446a65e4..063991a99f 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -987,10 +987,10 @@ const skills = defineTable({ }) .index("by_slug", ["slug"]) .index("by_owner", ["ownerUserId"]) + .index("by_owner_active_updated", ["ownerUserId", "softDeletedAt", "updatedAt"]) .index("by_owner_publisher", ["ownerPublisherId"]) .index("by_owner_slug", ["ownerUserId", "slug"]) .index("by_owner_publisher_slug", ["ownerPublisherId", "slug"]) - .index("by_owner_active_updated", ["ownerUserId", "softDeletedAt", "updatedAt"]) .index("by_owner_publisher_active_updated", ["ownerPublisherId", "softDeletedAt", "updatedAt"]) .index("by_owner_publisher_active_downloads", [ "ownerPublisherId", @@ -1688,6 +1688,7 @@ const packages = defineTable({ }) .index("by_name", ["normalizedName"]) .index("by_owner", ["ownerUserId"]) + .index("by_owner_active_updated", ["ownerUserId", "softDeletedAt", "updatedAt"]) .index("by_owner_publisher", ["ownerPublisherId"]) .index("by_owner_publisher_active_updated", ["ownerPublisherId", "softDeletedAt", "updatedAt"]) .index("by_owner_publisher_active_downloads", [ @@ -2881,6 +2882,28 @@ const catalogFeedPublications = defineTable({ publishedAt: v.number(), }).index("by_feed", ["feedId"]); +const publisherFeedPublications = defineTable({ + publisherId: v.id("publishers"), + feedId: v.string(), + sequence: v.number(), + generatedAt: v.string(), + handle: v.union(v.string(), v.null()), + displayName: v.string(), + entries: v.array( + v.object({ + kind: v.union(v.literal("skill"), v.literal("plugin")), + id: v.string(), + name: v.string(), + displayName: v.string(), + summary: v.union(v.string(), v.null()), + url: v.string(), + updatedAt: v.number(), + }), + ), + contentKey: v.string(), + publishedAt: v.number(), +}).index("by_publisher", ["publisherId"]); + const stars = defineTable({ skillId: v.id("skills"), userId: v.id("users"), @@ -2929,6 +2952,17 @@ const promotions = defineTable({ .index("by_slug", ["slug"]) .index("by_status_endsAt", ["status", "endsAt"]); +const publisherFollows = defineTable({ + followerUserId: v.id("users"), + publisherId: v.id("publishers"), + notifications: v.union(v.literal("all"), v.literal("none")), + createdAt: v.number(), + updatedAt: v.number(), +}) + .index("by_follower", ["followerUserId", "updatedAt"]) + .index("by_publisher", ["publisherId", "updatedAt"]) + .index("by_follower_publisher", ["followerUserId", "publisherId"]); + const auditLogs = defineTable({ actorUserId: v.optional(v.id("users")), action: v.string(), @@ -4163,8 +4197,10 @@ export default defineSchema({ packageModerationEventLogs, officialPluginMigrations, catalogFeedPublications, + publisherFeedPublications, stars, promotions, + publisherFollows, auditLogs, systemSettings, skillsShCatalogControls, diff --git a/convex/users.test.ts b/convex/users.test.ts index ba85f51c1f..2b6db738e3 100644 --- a/convex/users.test.ts +++ b/convex/users.test.ts @@ -1988,7 +1988,7 @@ describe("users profile audit logs", () => { user: { _id: "users:self" }, } as never); const { ctx, get, insert, query } = makeCtx(); - const runMutation = vi.fn(); + const runMutation = vi.fn(async () => ({ deleted: 0, scheduled: false })); (ctx as { runMutation?: typeof runMutation }).runMutation = runMutation; get.mockResolvedValue({ _id: "users:self", diff --git a/convex/users.ts b/convex/users.ts index aeffc597f6..18265d707f 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -76,6 +76,8 @@ type DeletedAccountCleanupResult = { githubOrgMemberships: number; apiTokens: number; personalPublisherDeleted: boolean; + publisherFollows: number; + publisherFollowsCleanupScheduled: boolean; }; type AccountRecoveryPurgeEligibilityReason = | "self_delete_audit" @@ -367,6 +369,11 @@ async function hardDeleteSelfDeletedAccountState( .collect(); for (const membership of githubOrgMemberships) await ctx.db.delete(membership._id); + const deletedFollows = (await ctx.runMutation( + internal.publisherFollows.deletePublisherFollowsForFollowerInternal, + { followerUserId: user._id }, + )) as { deleted: number; scheduled: boolean }; + const personalPublisher = user.personalPublisherId ? await ctx.db.get(user.personalPublisherId) : await getPersonalPublisherForUser(ctx, user._id); @@ -416,6 +423,8 @@ async function hardDeleteSelfDeletedAccountState( githubOrgMemberships: githubOrgMemberships.length, apiTokens: tokens.length, personalPublisherDeleted, + publisherFollows: deletedFollows.deleted, + publisherFollowsCleanupScheduled: deletedFollows.scheduled, }; } diff --git a/docs/http-api.md b/docs/http-api.md index 34b0464a4a..86628098c3 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -95,6 +95,26 @@ ignored for compatibility, but recognized query parameters with invalid values r ## Public endpoints (no auth) +### `GET /api/v1/publishers/{publisherId}` + +Returns a visible publisher identity and its canonical publisher-feed URL. + +### `GET /api/v1/publishers/{publisherId}/feed` + +Returns one coherent publisher-feed revision. Use `limit` (1-100) and the +opaque `cursor` returned as `nextCursor`; `nextCursor` is `null` on the final +page. A cursor is bound to one immutable sequence. A stale cursor returns `409` +and the client must restart from the first page. + +### `/api/v1/publisher-follows` + +API token required. `GET` lists the current user's followed publishers with +optional `limit`, `cursor`, and `q`. `POST` accepts `{ "publisherId": "..." }`; +`DELETE` accepts `publisherId` as a query parameter. Follow and unfollow are +idempotent, users cannot follow their own personal publisher, and each user may +follow up to 100 publishers. Follow state is private; this API does not expose +public follower or following lists. + ### `GET /api/v1/search` Query params: diff --git a/packages/schema/dist/accountFeed.d.ts b/packages/schema/dist/accountFeed.d.ts new file mode 100644 index 0000000000..edf595cba7 --- /dev/null +++ b/packages/schema/dist/accountFeed.d.ts @@ -0,0 +1,38 @@ +import { type inferred } from "arktype"; +export declare const PUBLISHER_FEED_SCHEMA_VERSION = 1; +export declare const PUBLISHER_FEED_DEFAULT_LIMIT = 50; +export declare const PUBLISHER_FEED_MAX_LIMIT = 100; +export declare const PublisherFeedEntryKindSchema: import("arktype/internal/variants/string.ts").StringType<"skill" | "plugin", {}>; +export type PublisherFeedEntryKind = (typeof PublisherFeedEntryKindSchema)[inferred]; +export declare const PublisherFeedEntrySchema: import("arktype/internal/variants/object.ts").ObjectType<{ + kind: "skill" | "plugin"; + id: string; + name: string; + displayName: string; + summary: string | null; + url: string; + updatedAt: number; +}, {}>; +export type PublisherFeedEntry = (typeof PublisherFeedEntrySchema)[inferred]; +export declare const PublisherFeedSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + schemaVersion: number; + feedId: string; + publisherId: string; + handle: string | null; + displayName: string; + generatedAt: string; + sequence: number; + entries: { + kind: "skill" | "plugin"; + id: string; + name: string; + displayName: string; + summary: string | null; + url: string; + updatedAt: number; + }[]; + nextCursor: string | null; +}, {}>; +export type PublisherFeed = (typeof PublisherFeedSchema)[inferred]; +export declare function publisherFeedId(publisherId: string): string; +export declare function parsePublisherFeed(value: unknown): PublisherFeed; diff --git a/packages/schema/dist/accountFeed.js b/packages/schema/dist/accountFeed.js new file mode 100644 index 0000000000..c8aad8485a --- /dev/null +++ b/packages/schema/dist/accountFeed.js @@ -0,0 +1,81 @@ +import { type } from "arktype"; +export const PUBLISHER_FEED_SCHEMA_VERSION = 1; +export const PUBLISHER_FEED_DEFAULT_LIMIT = 50; +export const PUBLISHER_FEED_MAX_LIMIT = 100; +export const PublisherFeedEntryKindSchema = type('"skill"|"plugin"'); +export const PublisherFeedEntrySchema = type({ + "+": "reject", + kind: PublisherFeedEntryKindSchema, + id: "string", + name: "string", + displayName: "string", + summary: "string|null", + url: "string", + updatedAt: "number", +}); +export const PublisherFeedSchema = type({ + "+": "reject", + schemaVersion: "number", + feedId: "string", + publisherId: "string", + handle: "string|null", + displayName: "string", + generatedAt: "string", + sequence: "number", + entries: PublisherFeedEntrySchema.array(), + nextCursor: "string|null", +}); +export function publisherFeedId(publisherId) { + return `clawhub.publisher.${publisherId}`; +} +function containsAsciiControlCharacter(value) { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit <= 0x1f || codeUnit === 0x7f) + return true; + } + return false; +} +export function parsePublisherFeed(value) { + const feed = PublisherFeedSchema.assert(value); + if (feed.schemaVersion !== PUBLISHER_FEED_SCHEMA_VERSION) { + throw new Error(`Unsupported publisher feed schema version: ${feed.schemaVersion}`); + } + if (!feed.publisherId || feed.feedId !== publisherFeedId(feed.publisherId)) { + throw new Error("Publisher feed id does not match its stable publisher identity"); + } + if (feed.sequence < 0 || !Number.isSafeInteger(feed.sequence)) { + throw new Error("Publisher feed sequence must be a non-negative integer"); + } + if (!Number.isFinite(Date.parse(feed.generatedAt))) { + throw new Error("Publisher feed generatedAt must be a valid ISO date"); + } + for (const entry of feed.entries) { + if (!entry.id || !entry.name || !entry.displayName) { + throw new Error("Publisher feed entry identity fields must be non-empty"); + } + if (!Number.isFinite(entry.updatedAt) || entry.updatedAt < 0) { + throw new Error("Publisher feed entry updatedAt must be a non-negative finite number"); + } + if (entry.url.startsWith("/")) { + if (entry.url.startsWith("//") || + entry.url.includes("\\") || + containsAsciiControlCharacter(entry.url)) { + throw new Error("Publisher feed entry URL must be a safe origin-relative reference"); + } + continue; + } + let url; + try { + url = new URL(entry.url); + } + catch { + throw new Error("Publisher feed entry URL must be absolute HTTPS or origin-relative"); + } + if (url.protocol !== "https:") { + throw new Error("Publisher feed entry URL must be absolute HTTPS or origin-relative"); + } + } + return feed; +} +//# sourceMappingURL=accountFeed.js.map \ No newline at end of file diff --git a/packages/schema/dist/accountFeed.js.map b/packages/schema/dist/accountFeed.js.map new file mode 100644 index 0000000000..a23d1255b1 --- /dev/null +++ b/packages/schema/dist/accountFeed.js.map @@ -0,0 +1 @@ +{"version":3,"file":"accountFeed.js","sourceRoot":"","sources":["../src/accountFeed.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,IAAI,EAAE,MAAM,SAAS,CAAC;AAE9C,MAAM,CAAC,MAAM,6BAA6B,GAAG,CAAC,CAAC;AAC/C,MAAM,CAAC,MAAM,4BAA4B,GAAG,EAAE,CAAC;AAC/C,MAAM,CAAC,MAAM,wBAAwB,GAAG,GAAG,CAAC;AAE5C,MAAM,CAAC,MAAM,4BAA4B,GAAG,IAAI,CAAC,kBAAkB,CAAC,CAAC;AAGrE,MAAM,CAAC,MAAM,wBAAwB,GAAG,IAAI,CAAC;IAC3C,GAAG,EAAE,QAAQ;IACb,IAAI,EAAE,4BAA4B;IAClC,EAAE,EAAE,QAAQ;IACZ,IAAI,EAAE,QAAQ;IACd,WAAW,EAAE,QAAQ;IACrB,OAAO,EAAE,aAAa;IACtB,GAAG,EAAE,QAAQ;IACb,SAAS,EAAE,QAAQ;CACpB,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,mBAAmB,GAAG,IAAI,CAAC;IACtC,GAAG,EAAE,QAAQ;IACb,aAAa,EAAE,QAAQ;IACvB,MAAM,EAAE,QAAQ;IAChB,WAAW,EAAE,QAAQ;IACrB,MAAM,EAAE,aAAa;IACrB,WAAW,EAAE,QAAQ;IACrB,WAAW,EAAE,QAAQ;IACrB,QAAQ,EAAE,QAAQ;IAClB,OAAO,EAAE,wBAAwB,CAAC,KAAK,EAAE;IACzC,UAAU,EAAE,aAAa;CAC1B,CAAC,CAAC;AAGH,MAAM,UAAU,eAAe,CAAC,WAAmB;IACjD,OAAO,qBAAqB,WAAW,EAAE,CAAC;AAC5C,CAAC;AAED,SAAS,6BAA6B,CAAC,KAAa;IAClD,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QACrD,MAAM,QAAQ,GAAG,KAAK,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC;QACzC,IAAI,QAAQ,IAAI,IAAI,IAAI,QAAQ,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;IACzD,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,UAAU,kBAAkB,CAAC,KAAc;IAC/C,MAAM,IAAI,GAAG,mBAAmB,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC/C,IAAI,IAAI,CAAC,aAAa,KAAK,6BAA6B,EAAE,CAAC;QACzD,MAAM,IAAI,KAAK,CAAC,8CAA8C,IAAI,CAAC,aAAa,EAAE,CAAC,CAAC;IACtF,CAAC;IACD,IAAI,CAAC,IAAI,CAAC,WAAW,IAAI,IAAI,CAAC,MAAM,KAAK,eAAe,CAAC,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC;QAC3E,MAAM,IAAI,KAAK,CAAC,gEAAgE,CAAC,CAAC;IACpF,CAAC;IACD,IAAI,IAAI,CAAC,QAAQ,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC9D,MAAM,IAAI,KAAK,CAAC,wDAAwD,CAAC,CAAC;IAC5E,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,EAAE,CAAC;QACnD,MAAM,IAAI,KAAK,CAAC,qDAAqD,CAAC,CAAC;IACzE,CAAC;IACD,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;QACjC,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC;YACnD,MAAM,IAAI,KAAK,CAAC,wDAAwD,CAAC,CAAC;QAC5E,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,SAAS,CAAC,IAAI,KAAK,CAAC,SAAS,GAAG,CAAC,EAAE,CAAC;YAC7D,MAAM,IAAI,KAAK,CAAC,qEAAqE,CAAC,CAAC;QACzF,CAAC;QACD,IAAI,KAAK,CAAC,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;YAC9B,IACE,KAAK,CAAC,GAAG,CAAC,UAAU,CAAC,IAAI,CAAC;gBAC1B,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC;gBACxB,6BAA6B,CAAC,KAAK,CAAC,GAAG,CAAC,EACxC,CAAC;gBACD,MAAM,IAAI,KAAK,CAAC,mEAAmE,CAAC,CAAC;YACvF,CAAC;YACD,SAAS;QACX,CAAC;QACD,IAAI,GAAQ,CAAC;QACb,IAAI,CAAC;YACH,GAAG,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC3B,CAAC;QAAC,MAAM,CAAC;YACP,MAAM,IAAI,KAAK,CAAC,oEAAoE,CAAC,CAAC;QACxF,CAAC;QACD,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;YAC9B,MAAM,IAAI,KAAK,CAAC,oEAAoE,CAAC,CAAC;QACxF,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"} \ No newline at end of file diff --git a/packages/schema/dist/index.d.ts b/packages/schema/dist/index.d.ts index 1e5749bbc3..a4c45aabba 100644 --- a/packages/schema/dist/index.d.ts +++ b/packages/schema/dist/index.d.ts @@ -1,5 +1,6 @@ export type { ArkValidator } from "./ark.js"; export { formatArkErrors, parseArk } from "./ark.js"; +export * from "./accountFeed.js"; export * from "./claws.js"; export * from "./clawPackage.js"; export * from "./catalogFeed.js"; diff --git a/packages/schema/dist/index.js b/packages/schema/dist/index.js index 76e4bed0e4..f257f752df 100644 --- a/packages/schema/dist/index.js +++ b/packages/schema/dist/index.js @@ -1,4 +1,5 @@ export { formatArkErrors, parseArk } from "./ark.js"; +export * from "./accountFeed.js"; export * from "./claws.js"; export * from "./clawPackage.js"; export * from "./catalogFeed.js"; diff --git a/packages/schema/dist/index.js.map b/packages/schema/dist/index.js.map index 97f6c696d8..def5358fb3 100644 --- a/packages/schema/dist/index.js.map +++ b/packages/schema/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,YAAY,CAAC;AAC3B,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC;AACjC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,0BAA0B,CAAC;AACzC,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,kBAAkB,CAAC;AACjC,cAAc,YAAY,CAAC;AAC3B,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC;AACjC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,0BAA0B,CAAC;AACzC,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"} \ No newline at end of file diff --git a/packages/schema/dist/routes.d.ts b/packages/schema/dist/routes.d.ts index 6bff2dc09d..62efd37390 100644 --- a/packages/schema/dist/routes.d.ts +++ b/packages/schema/dist/routes.d.ts @@ -32,6 +32,7 @@ export declare const ApiRoutes: { readonly stars: "/api/v1/stars"; readonly transfers: "/api/v1/transfers"; readonly publishers: "/api/v1/publishers"; + readonly publisherFollows: "/api/v1/publisher-follows"; readonly users: "/api/v1/users"; readonly contentRights: "/api/v1/content-rights"; readonly whoami: "/api/v1/whoami"; diff --git a/packages/schema/dist/routes.js b/packages/schema/dist/routes.js index 3f099612fe..37def056d3 100644 --- a/packages/schema/dist/routes.js +++ b/packages/schema/dist/routes.js @@ -32,6 +32,7 @@ export const ApiRoutes = { stars: "/api/v1/stars", transfers: "/api/v1/transfers", publishers: "/api/v1/publishers", + publisherFollows: "/api/v1/publisher-follows", users: "/api/v1/users", contentRights: "/api/v1/content-rights", whoami: "/api/v1/whoami", diff --git a/packages/schema/src/accountFeed.test.ts b/packages/schema/src/accountFeed.test.ts new file mode 100644 index 0000000000..3c6690f625 --- /dev/null +++ b/packages/schema/src/accountFeed.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + PUBLISHER_FEED_SCHEMA_VERSION, + parsePublisherFeed, + publisherFeedId, + type PublisherFeed, +} from "./accountFeed"; + +function makeFeed(overrides: Partial = {}): PublisherFeed { + return { + schemaVersion: PUBLISHER_FEED_SCHEMA_VERSION, + feedId: publisherFeedId("publishers:demo"), + publisherId: "publishers:demo", + handle: "demo", + displayName: "Demo", + generatedAt: "2026-07-16T00:00:00.000Z", + sequence: 1, + entries: [ + { + kind: "skill", + id: "skills:demo", + name: "demo", + displayName: "Demo", + summary: null, + url: "/demo/skills/demo", + updatedAt: 10, + }, + ], + nextCursor: null, + ...overrides, + }; +} + +describe("publisher feed schema", () => { + it("binds feed identity to the stable publisher id", () => { + expect(publisherFeedId("publishers:alice")).toBe("clawhub.publisher.publishers:alice"); + expect(parsePublisherFeed(makeFeed()).entries[0]?.kind).toBe("skill"); + }); + + it("rejects unsupported versions and mismatched identity", () => { + expect(() => parsePublisherFeed(makeFeed({ schemaVersion: 2 }))).toThrow( + "Unsupported publisher feed schema version", + ); + expect(() => parsePublisherFeed(makeFeed({ publisherId: "" }))).toThrow( + "stable publisher identity", + ); + expect(() => parsePublisherFeed(makeFeed({ feedId: "clawhub.publisher.other" }))).toThrow( + "stable publisher identity", + ); + }); + + it("rejects invalid ordering and URL fields", () => { + const entry = makeFeed().entries[0]!; + expect(() => + parsePublisherFeed(makeFeed({ entries: [{ ...entry, updatedAt: Number.NaN }] })), + ).toThrow("updatedAt"); + for (const url of ["//evil.example/skill", "/\\evil.example/skill", "/bad\npath"]) { + expect(() => parsePublisherFeed(makeFeed({ entries: [{ ...entry, url }] }))).toThrow( + "safe origin-relative", + ); + } + expect(() => + parsePublisherFeed(makeFeed({ entries: [{ ...entry, url: "http://example.com/skill" }] })), + ).toThrow("absolute HTTPS"); + expect( + parsePublisherFeed(makeFeed({ entries: [{ ...entry, url: "https://example.com/skill" }] })) + .entries[0]?.url, + ).toBe("https://example.com/skill"); + }); +}); diff --git a/packages/schema/src/accountFeed.ts b/packages/schema/src/accountFeed.ts new file mode 100644 index 0000000000..b8c882fa45 --- /dev/null +++ b/packages/schema/src/accountFeed.ts @@ -0,0 +1,90 @@ +import { type inferred, type } from "arktype"; + +export const PUBLISHER_FEED_SCHEMA_VERSION = 1; +export const PUBLISHER_FEED_DEFAULT_LIMIT = 50; +export const PUBLISHER_FEED_MAX_LIMIT = 100; + +export const PublisherFeedEntryKindSchema = type('"skill"|"plugin"'); +export type PublisherFeedEntryKind = (typeof PublisherFeedEntryKindSchema)[inferred]; + +export const PublisherFeedEntrySchema = type({ + "+": "reject", + kind: PublisherFeedEntryKindSchema, + id: "string", + name: "string", + displayName: "string", + summary: "string|null", + url: "string", + updatedAt: "number", +}); +export type PublisherFeedEntry = (typeof PublisherFeedEntrySchema)[inferred]; + +export const PublisherFeedSchema = type({ + "+": "reject", + schemaVersion: "number", + feedId: "string", + publisherId: "string", + handle: "string|null", + displayName: "string", + generatedAt: "string", + sequence: "number", + entries: PublisherFeedEntrySchema.array(), + nextCursor: "string|null", +}); +export type PublisherFeed = (typeof PublisherFeedSchema)[inferred]; + +export function publisherFeedId(publisherId: string) { + return `clawhub.publisher.${publisherId}`; +} + +function containsAsciiControlCharacter(value: string) { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit <= 0x1f || codeUnit === 0x7f) return true; + } + return false; +} + +export function parsePublisherFeed(value: unknown): PublisherFeed { + const feed = PublisherFeedSchema.assert(value); + if (feed.schemaVersion !== PUBLISHER_FEED_SCHEMA_VERSION) { + throw new Error(`Unsupported publisher feed schema version: ${feed.schemaVersion}`); + } + if (!feed.publisherId || feed.feedId !== publisherFeedId(feed.publisherId)) { + throw new Error("Publisher feed id does not match its stable publisher identity"); + } + if (feed.sequence < 0 || !Number.isSafeInteger(feed.sequence)) { + throw new Error("Publisher feed sequence must be a non-negative integer"); + } + if (!Number.isFinite(Date.parse(feed.generatedAt))) { + throw new Error("Publisher feed generatedAt must be a valid ISO date"); + } + for (const entry of feed.entries) { + if (!entry.id || !entry.name || !entry.displayName) { + throw new Error("Publisher feed entry identity fields must be non-empty"); + } + if (!Number.isFinite(entry.updatedAt) || entry.updatedAt < 0) { + throw new Error("Publisher feed entry updatedAt must be a non-negative finite number"); + } + if (entry.url.startsWith("/")) { + if ( + entry.url.startsWith("//") || + entry.url.includes("\\") || + containsAsciiControlCharacter(entry.url) + ) { + throw new Error("Publisher feed entry URL must be a safe origin-relative reference"); + } + continue; + } + let url: URL; + try { + url = new URL(entry.url); + } catch { + throw new Error("Publisher feed entry URL must be absolute HTTPS or origin-relative"); + } + if (url.protocol !== "https:") { + throw new Error("Publisher feed entry URL must be absolute HTTPS or origin-relative"); + } + } + return feed; +} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 1e5749bbc3..a4c45aabba 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -1,5 +1,6 @@ export type { ArkValidator } from "./ark.js"; export { formatArkErrors, parseArk } from "./ark.js"; +export * from "./accountFeed.js"; export * from "./claws.js"; export * from "./clawPackage.js"; export * from "./catalogFeed.js"; diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index 28387d88f0..eaa9f4d838 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -33,6 +33,7 @@ export const ApiRoutes = { stars: "/api/v1/stars", transfers: "/api/v1/transfers", publishers: "/api/v1/publishers", + publisherFollows: "/api/v1/publisher-follows", users: "/api/v1/users", contentRights: "/api/v1/content-rights", whoami: "/api/v1/whoami", diff --git a/public/api/v1/openapi.json b/public/api/v1/openapi.json index c0a148a125..202e7cc0b2 100644 --- a/public/api/v1/openapi.json +++ b/public/api/v1/openapi.json @@ -1473,6 +1473,158 @@ } } } + }, + "PublisherFeedPublicPublisher": { + "type": "object", + "additionalProperties": true, + "properties": { + "_id": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "user", + "org" + ] + }, + "handle": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "image": { + "type": [ + "string", + "null" + ] + }, + "bio": { + "type": [ + "string", + "null" + ] + } + } + }, + "PublisherFeedDetailResponse": { + "type": "object", + "additionalProperties": false, + "required": [ + "publisher", + "feedUrl" + ], + "properties": { + "publisher": { + "$ref": "#/components/schemas/PublisherFeedPublicPublisher" + }, + "feedUrl": { + "type": "string" + } + } + }, + "PublisherFeedEntry": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "id", + "name", + "displayName", + "summary", + "url", + "updatedAt" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "skill", + "plugin" + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "summary": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": "string" + }, + "updatedAt": { + "type": "number" + } + } + }, + "PublisherFeed": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "feedId", + "publisherId", + "handle", + "displayName", + "generatedAt", + "sequence", + "entries", + "nextCursor" + ], + "properties": { + "schemaVersion": { + "type": "integer", + "enum": [ + 1 + ] + }, + "feedId": { + "type": "string" + }, + "publisherId": { + "type": "string" + }, + "handle": { + "type": [ + "string", + "null" + ] + }, + "displayName": { + "type": "string" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "sequence": { + "type": "integer", + "minimum": 0 + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublisherFeedEntry" + } + }, + "nextCursor": { + "type": [ + "string", + "null" + ], + "description": "Opaque continuation cursor; null only when the coherent publisher projection is complete." + } + } } } }, @@ -1814,6 +1966,94 @@ } } }, + "/api/v1/packages/search": { + "get": { + "summary": "Search unified package catalog", + "description": "Searches skills and plugin packages.", + "security": [ + {}, + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "family", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "skill", + "code-plugin", + "bundle-plugin" + ] + } + }, + { + "name": "channel", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "official", + "community", + "private" + ] + } + }, + { + "name": "isOfficial", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Catalog search results", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackageSearchResponse" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlainTextError" + } + } + } + } + } + } + }, "/api/v1/packages/{name}": { "get": { "summary": "Get package detail", @@ -2434,10 +2674,10 @@ } } }, - "/api/v1/packages/search": { + "/api/v1/plugins": { "get": { - "summary": "Search unified package catalog", - "description": "Searches skills and plugin packages.", + "summary": "List plugin catalog packages", + "description": "Lists code-plugin and bundle-plugin catalog entries.", "security": [ {}, { @@ -2446,33 +2686,34 @@ ], "parameters": [ { - "name": "q", + "name": "limit", "in": "query", - "required": true, + "required": false, "schema": { - "type": "string" + "type": "integer", + "minimum": 1, + "maximum": 100 } }, { - "name": "limit", + "name": "cursor", "in": "query", "required": false, "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 + "type": "string" } }, { - "name": "family", + "name": "sort", "in": "query", "required": false, "schema": { "type": "string", "enum": [ - "skill", - "code-plugin", - "bundle-plugin" + "updated", + "recommended", + "downloads", + "installs" ] } }, @@ -2500,11 +2741,11 @@ ], "responses": { "200": { - "description": "Catalog search results", + "description": "Plugin catalog page", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackageSearchResponse" + "$ref": "#/components/schemas/PackageListResponse" } } } @@ -2522,10 +2763,10 @@ } } }, - "/api/v1/plugins": { + "/api/v1/plugins/search": { "get": { - "summary": "List plugin catalog packages", - "description": "Lists code-plugin and bundle-plugin catalog entries.", + "summary": "Search plugin catalog packages", + "description": "Searches code-plugin and bundle-plugin catalog entries.", "security": [ {}, { @@ -2534,35 +2775,21 @@ ], "parameters": [ { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 - } - }, - { - "name": "cursor", + "name": "q", "in": "query", - "required": false, + "required": true, "schema": { "type": "string" } }, { - "name": "sort", + "name": "limit", "in": "query", "required": false, "schema": { - "type": "string", - "enum": [ - "updated", - "recommended", - "downloads", - "installs" - ] + "type": "integer", + "minimum": 1, + "maximum": 100 } }, { @@ -2589,11 +2816,11 @@ ], "responses": { "200": { - "description": "Plugin catalog page", + "description": "Plugin catalog search results", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackageListResponse" + "$ref": "#/components/schemas/PackageSearchResponse" } } } @@ -2611,21 +2838,53 @@ } } }, - "/api/v1/plugins/search": { + "/api/v1/publishers/{publisherId}": { "get": { - "summary": "Search plugin catalog packages", - "description": "Searches code-plugin and bundle-plugin catalog entries.", - "security": [ - {}, + "summary": "Get publisher feed identity", + "parameters": [ { - "bearerAuth": [] + "name": "publisherId", + "in": "path", + "required": true, + "description": "Stable ClawHub publisher id.", + "schema": { + "type": "string" + } } ], + "responses": { + "200": { + "description": "Publisher feed identity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublisherFeedDetailResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlainTextError" + } + } + } + } + } + } + }, + "/api/v1/publishers/{publisherId}/feed": { + "get": { + "summary": "Get publisher feed", "parameters": [ { - "name": "q", - "in": "query", + "name": "publisherId", + "in": "path", "required": true, + "description": "Stable ClawHub publisher id.", "schema": { "type": "string" } @@ -2637,44 +2896,63 @@ "schema": { "type": "integer", "minimum": 1, - "maximum": 100 + "maximum": 100, + "default": 50 } }, { - "name": "channel", - "in": "query", - "required": false, - "schema": { - "type": "string", - "enum": [ - "official", - "community", - "private" - ] - } - }, - { - "name": "isOfficial", + "name": "cursor", "in": "query", "required": false, + "description": "Opaque cursor bound to one immutable publisher-feed sequence.", "schema": { - "type": "boolean" + "type": "string" } } ], "responses": { "200": { - "description": "Plugin catalog search results", + "description": "Publisher feed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackageSearchResponse" + "$ref": "#/components/schemas/PublisherFeed" } } } }, "400": { - "description": "Invalid request", + "description": "Malformed or mismatched cursor or limit", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlainTextError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlainTextError" + } + } + } + }, + "409": { + "description": "Cursor sequence is stale; restart from the first page", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlainTextError" + } + } + } + }, + "503": { + "description": "Publisher snapshot exceeds the current bounded publication capacity", "content": { "text/plain": { "schema": { diff --git a/specs/README.md b/specs/README.md index 9b868c17f7..19eac4aaa5 100644 --- a/specs/README.md +++ b/specs/README.md @@ -19,6 +19,8 @@ into `docs/` and leave only the design record here. - `spec.md`: product + implementation spec for the original registry model. - `orgs.md`: org, publisher membership, and scoped identity plan. +- `account-feeds.md`: publisher feed model for OpenClaw discovery (historical filename). +- `follow-graph-notifications.md`: follow graph and notification behavior for ClawHub account and publisher feeds. - `github-import.md`: GitHub import feature spec. - `github-backed-skills.md`: source-backed GitHub skills catalog and install invariants. - `diffing.md`: skill version diffing UI/API design. diff --git a/specs/account-feeds.md b/specs/account-feeds.md new file mode 100644 index 0000000000..5ef8fbbd5e --- /dev/null +++ b/specs/account-feeds.md @@ -0,0 +1,90 @@ +--- +summary: "ClawHub publisher feed model for public discovery." +read_when: + - Adding or changing publisher feed APIs + - Changing publisher identity or visibility + - Wiring clients to publisher feeds +--- + +# Publisher Feeds + +ClawHub publisher feeds are machine-readable discovery projections of a +publisher's public skills and plugins. Publishers are the public identity for +both people and organizations; there is no parallel public account-feed API. + +Publisher feeds do not grant trust, approval, scan success, artifact integrity, +or install authority. Consumers resolve an entry through an accepted catalog +before installation. + +## Routes + +```text +GET /api/v1/publishers/{publisherId} +GET /api/v1/publishers/{publisherId}/feed?limit=50&cursor= +``` + +The detail route returns bounded public publisher fields and the canonical feed +URL. It does not expose linked-user, owner, member, authentication, or moderation +records. + +## Identity + +Feed identity is stable and publisher-only: + +```text +clawhub.publisher. +``` + +Handles and display names may change without changing the feed id. Personal +publishers are visible only while their canonical linked or legacy owner user is +active. Legacy `ownerUserId` content remains discoverable during publisher +ownership migration and is deduplicated against `ownerPublisherId` rows. + +## Revisions And Pagination + +The first page builds a complete bounded publisher projection and publishes it +as an immutable logical revision in `publisherFeedPublications`. The sequence +increments only when publisher metadata or ordered entries change; unchanged +reads reuse the stored sequence and generation time. + +Pages are slices of that stored revision. The opaque cursor binds: + +- publisher id; +- feed sequence; +- next entry offset. + +All pages therefore report the same `feedId`, `sequence`, and `generatedAt`. +If a newer first-page refresh replaces the stored revision, an old cursor +returns `409` and the client restarts from page one. + +Source reads and snapshot size are bounded. If ClawHub cannot prove that the +projection is complete within those bounds, the first page returns `503 +no-store`; it never publishes a terminal page that silently omits older public +entries. + +## Entry Shape + +Entries contain only: + +- `kind`: `skill` or `plugin`; +- stable object `id`; +- current `name`, `displayName`, and bounded `summary`; +- canonical public HTTPS or safe origin-relative `url`; +- finite non-negative `updatedAt` milliseconds. + +Entries are ordered by descending `updatedAt`, then stable kind and object id. +Origin-relative URLs reject protocol-relative forms, backslashes, and control +characters before clients resolve them against the feed request origin. + +## Follow Boundary + +Following is social discovery only. Public follower/following lists and a +pull-based activity timeline belong in the follow stack. ClawHub should not +send one notification for every publisher upload. OpenClaw or Control UI may +notify locally when an update affects content installed in that instance. + +## Future Signing + +Publisher feeds may later use the same dedicated ClawHub platform feed-signing +key as the public catalog, but require a distinct publisher-feed payload type +and expected feed-id binding. The catalog payload type must not be reused. diff --git a/specs/follow-graph-notifications.md b/specs/follow-graph-notifications.md new file mode 100644 index 0000000000..3d6ce3d9b5 --- /dev/null +++ b/specs/follow-graph-notifications.md @@ -0,0 +1,178 @@ +--- +summary: "Follow graph and notification behavior for ClawHub account and publisher feeds." +read_when: + - Adding follow or unfollow behavior for accounts, publishers, or feeds + - Adding feed notification events or delivery channels + - Changing search or discovery filters for followed publishers +--- + +# Follow Graph And Notifications + +Following a ClawHub account, publisher, or feed is a discovery and notification +preference. It is not a trust grant, install grant, review decision, scan +result, or local approval. + +This spec defines the follow graph and notification boundaries for future +account and publisher feeds. + +## Product Behavior + +ClawHub should allow signed-in users to: + +- follow a public account or publisher +- unfollow a previously followed account or publisher +- see followed publishers in discovery surfaces +- filter search or browse results to people and publishers they follow +- opt into notifications for feed publication and material feed-entry changes + +The first implementation should prefer publisher-scoped follows when a public +publisher identity exists. Account-scoped follows can still be useful for person +or organization profiles, but install and package discovery should resolve +through stable publisher ids. + +## Follow Identity + +Follows must be keyed by stable ClawHub ids, not display names, handles, slugs, +profile URLs, or feed URLs. + +Follow and unfollow operations are idempotent. A user cannot follow their own +personal publisher. A publisher must pass ClawHub's canonical public visibility +check before it can be followed or returned by a list. Each user may follow up +to 100 publishers so discovery and activity reads remain bounded. + +At minimum, a follow row should preserve: + +- follower user id +- followed account id or publisher id +- followed identity kind +- creation time +- last updated time +- notification preference +- muted or paused state when supported + +Follow and unfollow operations must be idempotent. Client retries should not +duplicate rows, emit duplicate notification state, or fail because a prior +attempt already succeeded. + +Omitting a notification preference on an idempotent follow retry preserves the +existing preference. It must not silently unmute a follow. Follow-list reads +are private to the authenticated user, cursor-paginated, and bounded even when +inactive publishers or search filtering make the result sparse. + +Publisher rename, handle change, profile URL change, or ownership change must +not silently transfer a follow to an unrelated identity. If ownership changes +materially, ClawHub should preserve the stable id and emit a material-change +event or require an explicit follow reset, depending on the risk. + +## Events + +Suggested event types: + +- `publisher.feed.published` +- `publisher.feed.entry.added` +- `publisher.feed.entry.updated` +- `publisher.feed.entry.removed` +- `publisher.official_state.changed` +- `publisher.claim_state.changed` +- `publisher.suspended` +- `publisher.reinstated` +- `publisher.revoked` + +Events should carry stable ids, sequence or revision references, event time, and +enough public display metadata for notifications. They should not carry private +review evidence, secrets, raw signing keys, private source URLs, or unpublished +package metadata. + +## Notification Rules + +Notifications should link users back to ClawHub profile, feed, package, skill, +or review surfaces. They must not auto-install content or imply that a followed +publisher is safe to install from. + +Notification copy must preserve the trust boundary: + +- "followed publisher posted an update" is allowed +- "official publisher changed status" is allowed when backed by ClawHub state +- "safe to install" is not allowed based only on a follow +- "approved for you" is not allowed unless the current local context actually + has that approval + +Users should be able to pause, mute, or opt out of follow notifications without +unfollowing the publisher. + +## Search And Discovery + +Search and browse filters may use follow state to help users find publishers +they already care about. Follow state may: + +- power a "people I follow" or "publishers I follow" filter +- break ties inside an already relevant result set +- build a personalized activity feed +- prioritize notification delivery preferences + +Follow state must not: + +- make an otherwise unrelated result eligible for a query +- override moderation, safety, visibility, or deletion state +- bypass OpenClaw review +- bypass scans +- bypass package artifact integrity checks +- bypass local approval or install policy + +## Privacy + +Follow lists should be private by default unless ClawHub deliberately ships a +public social graph. + +If public follow lists are introduced later, the design must define: + +- opt-in or opt-out behavior +- profile display rules +- blocked or suspended publisher behavior +- export and deletion behavior +- abuse controls for follower-count manipulation + +Private follow state should still be usable for the current user's own search, +notifications, and profile controls. + +## Abuse Controls + +The follow and notification system should handle: + +- spam publishers posting high-frequency feed updates +- mass rename or profile churn +- compromised official or verified publishers +- follower-count manipulation +- notification fanout spikes +- repeated follow/unfollow churn +- suspended, revoked, hidden, or deleted publishers + +Notification fanout should be rate limited, deduplicated, and resumable. +ClawHub should prefer durable event processing with replay or backfill semantics +over best-effort notification sends that cannot recover missed changes. + +## Replay And Backfill + +Clients and notification workers may miss events. The contract should define how +they recover: + +- feed sequence or revision cursor +- notification event cursor +- maximum replay window +- behavior when the cursor is too old +- idempotent reprocessing behavior +- dedupe key for each emitted notification + +Replay should never create duplicate user-visible notifications for the same +event and channel. + +## Open Questions + +- Should the first shipped follow model be publisher-scoped only? +- Should account-scoped follows later aggregate all publishers controlled by an + account or organization? +- Which notification channel ships first: in-app, email, webhook, RSS-style + polling, or OpenClaw client sync? +- Should users be notified when a followed publisher is suspended, revoked, or + reinstated? +- Should follower counts be public, private, delayed, or omitted? diff --git a/src/__tests__/openapi-contract.test.ts b/src/__tests__/openapi-contract.test.ts index 6db0046e0a..6861c2d16b 100644 --- a/src/__tests__/openapi-contract.test.ts +++ b/src/__tests__/openapi-contract.test.ts @@ -80,4 +80,28 @@ describe("OpenAPI contract", () => { expect(property(property(handoffSchema, "properties"), "scan")).toBeUndefined(); expect(property(property(handoffSchema, "properties"), "scanStatus")).toBeUndefined(); }); + + it("documents publisher feeds without account, trust, or install authority fields", async () => { + const specPath = new URL("../../public/api/v1/openapi.json", import.meta.url); + const spec: unknown = JSON.parse(await readFile(specPath, "utf8")); + const paths = property(spec, "paths"); + const schemas = property(property(spec, "components"), "schemas"); + + expect(property(paths, "/api/v1/accounts/{accountId}/feed")).toBeUndefined(); + expect(property(paths, "/api/v1/publishers/{publisherId}/feed")).toBeTruthy(); + + const feedSchema = property(schemas, "PublisherFeed"); + const entrySchema = property(schemas, "PublisherFeedEntry"); + const feedProperties = property(feedSchema, "properties"); + const entryProperties = property(entrySchema, "properties"); + + expect(property(feedProperties, "feedId")).toBeTruthy(); + expect(property(feedProperties, "publisherId")).toBeTruthy(); + expect(property(feedProperties, "accountId")).toBeUndefined(); + expect(property(feedProperties, "entries")).toBeTruthy(); + expect(property(feedProperties, "official")).toBeUndefined(); + expect(property(feedProperties, "trust")).toBeUndefined(); + expect(property(entryProperties, "install")).toBeUndefined(); + expect(property(entryProperties, "publisher")).toBeUndefined(); + }); });