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..b8a8ae5d49 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -32,6 +32,7 @@ import { packagesPostRouterV1Http, pluginsGetRouterV1Http, createPublisherV1Http, + publishersGetRouterV1Http, publishPackageV1Http, publishSkillV1Http, resolveSkillVersionV1Http, @@ -343,6 +344,12 @@ http.route({ handler: createPublisherV1Http, }); +http.route({ + pathPrefix: `${ApiRoutes.publishers}/`, + method: "GET", + handler: publishersGetRouterV1Http, +}); + 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.ts b/convex/httpApiV1.ts index d93c213a25..850f29bf5b 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, @@ -72,6 +73,7 @@ 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 contentRightsV1Http = httpAction(contentRightsV1Handler); export const skillsShCatalogTestV1Http = httpAction(skillsShCatalogTestV1Handler); export const skillsShCatalogPublicV1Http = httpAction(skillsShCatalogPublicV1Handler); @@ -125,6 +127,7 @@ export const __handlers = { listBundlePluginsV1Handler, verifyDocsSessionV1Handler, createPublisherV1Handler, + publishersGetRouterV1Handler, 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/lib/publishers.ts b/convex/lib/publishers.ts index 9af1d52dcd..0d5a779f0a 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">, diff --git a/convex/lib/retentionPolicy.ts b/convex/lib/retentionPolicy.ts index e975e61fff..c6a2c808a3 100644 --- a/convex/lib/retentionPolicy.ts +++ b/convex/lib/retentionPolicy.ts @@ -236,6 +236,7 @@ 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."), auditLogs: permanent("Audit logs are durable compliance/security history."), diff --git a/convex/publishers.test.ts b/convex/publishers.test.ts index 479e914cbc..d6849f056d 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(() => ({ @@ -1093,6 +1104,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((indexName: string) => ({ @@ -1191,6 +1205,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,6 +1334,9 @@ describe("publishers membership controls", () => { return emptyOwnedResourcesQuery(); } if (table === "officialPublishers") return emptyOfficialPublishersQuery(); + if (table === "publisherFeedPublications") { + return emptyPublisherFeedPublicationsQuery(); + } throw new Error(`unexpected table ${table}`); }); return { @@ -1489,6 +1509,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((indexName: string) => { diff --git a/convex/publishers.ts b/convex/publishers.ts index 1c8430fd7a..c1cdd3a962 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,7 +1976,12 @@ 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">) { @@ -2035,6 +2003,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 +2013,7 @@ async function hardDeletePublisherRows(ctx: MutationCtx, publisherId: Id<"publis members: preview.members.length, invites: preview.invites.length, official: Boolean(preview.official), + feedPublication: Boolean(preview.feedPublication), }; } diff --git a/convex/schema.ts b/convex/schema.ts index 64446a65e4..59cd357948 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"), @@ -4163,6 +4186,7 @@ export default defineSchema({ packageModerationEventLogs, officialPluginMigrations, catalogFeedPublications, + publisherFeedPublications, stars, promotions, auditLogs, 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/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/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..a49540c5b3 100644 --- a/specs/README.md +++ b/specs/README.md @@ -19,6 +19,7 @@ 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). - `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/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(); + }); });