From 41b4ac808787362a6f001982d2e463ac4c145aa0 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Wed, 1 Jul 2026 12:48:53 -0700 Subject: [PATCH 01/22] docs: add account feed model spec --- specs/README.md | 1 + specs/account-feeds.md | 175 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 specs/account-feeds.md diff --git a/specs/README.md b/specs/README.md index 9b868c17f7..a35427d575 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`: account and publisher feed model for OpenClaw discovery. - `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..93066c2cc8 --- /dev/null +++ b/specs/account-feeds.md @@ -0,0 +1,175 @@ +--- +summary: "ClawHub account and publisher feed model for OpenClaw discovery." +read_when: + - Adding account-backed or publisher-backed feed APIs + - Changing ClawHub publisher identity, profile, or feed projection behavior + - Wiring OpenClaw clients to ClawHub account or publisher feeds +--- + +# Account Feeds + +ClawHub account feeds are stable, ClawHub-authored projections of public account +and publisher activity for OpenClaw discovery. + +They are not a replacement for the hosted catalog feed in +`specs/hosted-catalog-feed.md`. They give OpenClaw clients and ClawHub users a +way to follow a person, organization, or publisher identity and discover that +publisher's public work through a stable machine-readable feed. + +## Model + +ClawHub should treat account identity and publisher identity as related but +separate product facts: + +- An account is the signed-in ClawHub user or organization record. +- A publisher is the public identity that owns packages, skills, and profile + surfaces. +- A publisher may be backed by a personal account or an organization account. +- Feed URLs and feed ids must use stable opaque ids, not mutable display names, + handles, slugs, or profile URLs as authority. +- Display names, handles, avatars, and profile copy are presentation fields and + may change without changing feed identity. + +The first account-feed contract should support both account-scoped and +publisher-scoped feeds until product usage proves one is unnecessary. + +Draft endpoints: + +- `GET /v1/accounts/{accountId}` +- `GET /v1/accounts/{accountId}/feed` +- `GET /v1/publishers/{publisherId}` +- `GET /v1/publishers/{publisherId}/feed` + +The account and publisher detail endpoints should expose enough public metadata +for clients to display identity, profile links, and follow state. The feed +endpoints should expose ordered public feed entries for discovery. + +## Feed Shape + +Draft feed metadata: + +```json +{ + "schemaVersion": 1, + "feedId": "clawhub.account.", + "publisherId": "pub_", + "accountId": "acct_", + "displayName": "Example Publisher", + "generatedAt": "2026-07-01T00:00:00.000Z", + "sequence": 1, + "entries": [] +} +``` + +Required stable fields: + +- `schemaVersion`: feed wire version. +- `feedId`: stable feed identity. +- `accountId`: stable account identity when the feed is account-scoped. +- `publisherId`: stable publisher identity when the feed is publisher-scoped. +- `generatedAt`: generation time for this feed body. +- `sequence`: monotonic feed sequence for cache, replay, and rollback checks. +- `entries`: ordered public entries. + +The feed body should not include credentials, private source URLs, bootstrap +trust keys, unpublished package metadata, or reviewer-only moderation details. + +## Signing And Cache Boundaries + +Account feed authenticity comes from a ClawHub-authored feed envelope, not from +user-submitted feed contents. + +The signed material should include: + +- feed id +- schema version +- sequence +- generated time +- previous sequence or previous feed revision when available +- envelope key id +- exact feed payload digest + +Persisted feed bodies are cache material. They become useful for OpenClaw only +after envelope verification and source-profile trust checks in the OpenClaw +client. A cached feed body alone must not grant install eligibility or create a +new trust root. + +ClawHub should define pagination or continuation semantics before this becomes +a public API. A popular publisher should not require clients to fetch an +unbounded feed. + +## Identity And Trust Boundaries + +Account feed support must keep these signals separate: + +- publisher identity +- claimed account state +- verified account state +- ClawHub official publisher state +- OpenClaw registry review state +- local approval state +- scan state +- package artifact integrity +- OpenClaw install eligibility + +Following a feed is a discovery and notification signal only. It must not imply +official status, registry inclusion, local approval, scan success, package +integrity, or install eligibility. + +Official publisher state remains governed by `specs/official-publishers.md`. +Uploaded skill or package metadata must not be able to mark a publisher or feed +official. + +## API Requirements + +The public API contract should define: + +- stable ids and canonical URLs +- pagination and continuation tokens +- cache validators and max-age behavior +- monotonic sequence behavior +- idempotent client refresh behavior +- error responses for missing, private, suspended, revoked, or stale feeds +- replay and backfill behavior for clients that miss updates +- rate limits for feed reads and follower-triggered refreshes + +Errors should distinguish "not found", "not public", "temporarily unavailable", +and "publisher suspended or revoked" without exposing private review evidence. + +## Skill Author Experience + +The first publisher setup path should be short and obvious in ClawHub: + +1. Sign in. +2. Confirm or create a publisher identity. +3. Publish public work. +4. See the publisher profile and feed URL. + +Ordinary community publishing must not require official status. Official, +reviewed, scanned, and locally approved states are stronger signals layered on +top of the normal publishing path. + +## Audit Requirements + +ClawHub should record audit events for trust-changing feed operations: + +- feed signing key changes +- feed publication sequence changes +- account-to-publisher link changes +- publisher ownership changes +- visibility changes +- suspension, revocation, and reinstatement +- OpenClaw registry export events + +Each event should include actor, time, reason, affected ids, prior state, new +state, and the related feed revision when applicable. + +## Open Questions + +- Are account ids and publisher ids already distinct enough in current ClawHub + data, or does this require a schema clarification first? +- Should OpenClaw consume publisher-scoped feeds before account-scoped feeds? +- What is the public/private visibility model for account metadata? +- Should account feed entries include packages and skills from day one, or start + with one content type? +- Which existing profile URLs become the canonical human-readable feed surface? From e9d785cefa6499bc942df3eb7703550856885f3b Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Thu, 2 Jul 2026 09:55:01 -0700 Subject: [PATCH 02/22] feat: add account feed API projection --- convex/accountFeeds.test.ts | 193 ++++++++ convex/accountFeeds.ts | 273 ++++++++++++ convex/http.ts | 14 + convex/httpApiV1.accountFeeds.test.ts | 140 ++++++ convex/httpApiV1.ts | 8 + convex/httpApiV1/accountFeedsV1.ts | 105 +++++ packages/schema/dist/accountFeed.d.ts | 40 ++ packages/schema/dist/accountFeed.js | 46 ++ packages/schema/dist/accountFeed.js.map | 1 + packages/schema/dist/routes.d.ts | 1 + packages/schema/dist/routes.js | 3 +- packages/schema/src/accountFeed.test.ts | 60 +++ packages/schema/src/accountFeed.ts | 54 +++ packages/schema/src/index.ts | 1 + packages/schema/src/routes.ts | 1 + public/api/v1/openapi.json | 555 ++++++++++++++++++++---- specs/account-feeds.md | 27 +- src/__tests__/openapi-contract.test.ts | 22 + 18 files changed, 1459 insertions(+), 85 deletions(-) create mode 100644 convex/accountFeeds.test.ts create mode 100644 convex/accountFeeds.ts create mode 100644 convex/httpApiV1.accountFeeds.test.ts create mode 100644 convex/httpApiV1/accountFeedsV1.ts create mode 100644 packages/schema/dist/accountFeed.d.ts create mode 100644 packages/schema/dist/accountFeed.js create mode 100644 packages/schema/dist/accountFeed.js.map create mode 100644 packages/schema/src/accountFeed.test.ts create mode 100644 packages/schema/src/accountFeed.ts diff --git a/convex/accountFeeds.test.ts b/convex/accountFeeds.test.ts new file mode 100644 index 0000000000..ab68e9cafe --- /dev/null +++ b/convex/accountFeeds.test.ts @@ -0,0 +1,193 @@ +/* @vitest-environment node */ +import { describe, expect, it, vi } from "vitest"; +import { getAccountDetail, getPublisherFeed } from "./accountFeeds"; + +type InternalHandler = (ctx: unknown, args: unknown) => Promise; + +const getAccountDetailHandler = (getAccountDetail as unknown as { _handler: InternalHandler }) + ._handler; +const getPublisherFeedHandler = (getPublisherFeed as unknown as { _handler: InternalHandler }) + ._handler; + +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[]]; + let index = 0; + return { + withIndex: vi.fn(() => ({ + order: vi.fn(() => ({ + paginate: vi.fn(async () => { + const page = normalizedPages[index] ?? []; + index += 1; + return { + page, + isDone: index >= normalizedPages.length, + continueCursor: index >= normalizedPages.length ? null : `cursor-${index}`, + }; + }), + })), + })), + }; +} + +function makePublisher() { + return { + _id: doc<"publishers">("publishers:alice"), + handle: "alice", + displayName: "Alice", + linkedUserId: undefined, + deletedAt: undefined, + deactivatedAt: undefined, + }; +} + +describe("account feed projection", () => { + it("rejects account ids that do not normalize to the users table", async () => { + const get = vi.fn(); + const normalizeId = vi.fn((table: string, id: string) => + table === "users" && id.startsWith("users:") ? doc<"users">(id) : null, + ); + + const result = await getAccountDetailHandler( + { db: { get, normalizeId } }, + { accountId: "publishers:alice" }, + ); + + expect(result).toBeNull(); + expect(get).not.toHaveBeenCalled(); + }); + + 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 { entries: unknown[]; nextCursor: string | null }; + + expect(result.entries).toEqual([ + expect.objectContaining({ + kind: "plugin", + id: "packages:plugin", + name: "@alice/plugin", + }), + ]); + expect(result.nextCursor).toBeNull(); + }); + + 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 { entries: unknown[]; nextCursor: string | null }; + + expect(result.entries).toEqual([ + expect.objectContaining({ + id: "packages:public", + name: "@alice/public", + }), + ]); + expect(result.nextCursor).toBeNull(); + }); + + it("bounds scans when package rows keep filtering out", 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], + [privatePackage], + [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 { entries: unknown[]; nextCursor: string | null }; + + expect(result.entries).toEqual([]); + expect(result.nextCursor).toBeNull(); + }); +}); diff --git a/convex/accountFeeds.ts b/convex/accountFeeds.ts new file mode 100644 index 0000000000..95512198a1 --- /dev/null +++ b/convex/accountFeeds.ts @@ -0,0 +1,273 @@ +import { + ACCOUNT_FEED_DEFAULT_LIMIT, + ACCOUNT_FEED_MAX_LIMIT, + ACCOUNT_FEED_SCHEMA_VERSION, + accountFeedId, + type AccountFeed, + type AccountFeedEntry, +} from "clawhub-schema"; +import { v } from "convex/values"; +import type { Doc } from "./_generated/dataModel"; +import type { QueryCtx } from "./_generated/server"; +import { internalQuery } from "./functions"; +import { isPublicSkillDoc } from "./lib/globalStats"; +import { isPackageBlockedFromPublic } from "./lib/packageSecurity"; +import { toPublicPublisher, toPublicUser } from "./lib/public"; + +const ACCOUNT_FEED_MAX_SOURCE_PAGES = 3; + +function clampLimit(limit: number | undefined) { + const value = Number.isFinite(limit) ? Math.trunc(limit as number) : ACCOUNT_FEED_DEFAULT_LIMIT; + return Math.min(Math.max(value, 1), ACCOUNT_FEED_MAX_LIMIT); +} + +async function safeGetUser(ctx: Pick, id: string) { + const userId = ctx.db.normalizeId("users", id); + if (!userId) return null; + try { + return await ctx.db.get(userId); + } catch { + return null; + } +} + +async function safeGetPublisher(ctx: Pick, id: string) { + const publisherId = ctx.db.normalizeId("publishers", id); + if (!publisherId) return null; + try { + return await ctx.db.get(publisherId); + } catch { + return null; + } +} + +function isActiveUser(user: Doc<"users"> | null | undefined): user is Doc<"users"> { + return Boolean(user && !user.deletedAt && !user.deactivatedAt); +} + +function isActivePublisher( + publisher: Doc<"publishers"> | null | undefined, +): publisher is Doc<"publishers"> { + return Boolean(publisher && !publisher.deletedAt && !publisher.deactivatedAt); +} + +function skillEntry(publisher: Doc<"publishers">, skill: Doc<"skills">): AccountFeedEntry | null { + if (!isPublicSkillDoc(skill)) return null; + return { + kind: "skill", + id: String(skill._id), + name: skill.slug, + displayName: skill.displayName, + summary: skill.summary ?? null, + url: `/${encodeURIComponent(publisher.handle)}/${encodeURIComponent(skill.slug)}`, + updatedAt: skill.updatedAt, + }; +} + +function pluginPath(name: string) { + const trimmed = name.trim(); + if (!trimmed.startsWith("@")) return `/plugins/${encodeURIComponent(trimmed)}`; + const slashIndex = trimmed.indexOf("/"); + if (slashIndex <= 1 || slashIndex === trimmed.length - 1) { + return `/plugins/${encodeURIComponent(trimmed)}`; + } + const scope = trimmed.slice(1, slashIndex); + const packageName = trimmed.slice(slashIndex + 1); + if (packageName.includes("/")) return `/plugins/${encodeURIComponent(trimmed)}`; + return `/plugins/@${encodeURIComponent(scope)}/${encodeURIComponent(packageName)}`; +} + +function packageEntry(pkg: Doc<"packages">): AccountFeedEntry | 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: pkg.summary ?? null, + url: pluginPath(pkg.name), + updatedAt: pkg.updatedAt, + }; +} + +function buildFeed(params: { + scope: "account" | "publisher"; + stableId: string; + user: Doc<"users"> | null; + publisher: Doc<"publishers">; + entries: AccountFeedEntry[]; + nextCursor: string | null; +}): AccountFeed { + return { + schemaVersion: ACCOUNT_FEED_SCHEMA_VERSION, + feedId: accountFeedId(params.scope, params.stableId), + scope: params.scope, + accountId: params.user ? String(params.user._id) : null, + publisherId: String(params.publisher._id), + handle: params.publisher.handle ?? params.user?.handle ?? null, + displayName: + params.publisher.displayName || params.user?.displayName || params.user?.name || "", + generatedAt: new Date().toISOString(), + sequence: 0, + entries: params.entries, + nextCursor: params.nextCursor, + }; +} + +async function collectSkillEntries(ctx: QueryCtx, publisher: Doc<"publishers">, limit: number) { + const entries: AccountFeedEntry[] = []; + let cursor: string | null = null; + let isDone = false; + let pagesRead = 0; + + while (!isDone && pagesRead < ACCOUNT_FEED_MAX_SOURCE_PAGES && entries.length < limit) { + const page = await ctx.db + .query("skills") + .withIndex("by_owner_publisher_active_updated", (q) => + q.eq("ownerPublisherId", publisher._id).eq("softDeletedAt", undefined), + ) + .order("desc") + .paginate({ cursor, numItems: limit }); + pagesRead += 1; + + for (const skill of page.page) { + const entry = skillEntry(publisher, skill); + if (entry) entries.push(entry); + if (entries.length >= limit) break; + } + isDone = page.isDone; + cursor = page.isDone ? null : page.continueCursor; + } + + return entries; +} + +async function collectPackageEntries(ctx: QueryCtx, publisher: Doc<"publishers">, limit: number) { + const entries: AccountFeedEntry[] = []; + let cursor: string | null = null; + let isDone = false; + let pagesRead = 0; + + while (!isDone && pagesRead < ACCOUNT_FEED_MAX_SOURCE_PAGES && entries.length < limit) { + const page = await ctx.db + .query("packages") + .withIndex("by_owner_publisher_active_updated", (q) => + q.eq("ownerPublisherId", publisher._id).eq("softDeletedAt", undefined), + ) + .order("desc") + .paginate({ cursor, numItems: limit }); + pagesRead += 1; + + for (const pkg of page.page) { + const entry = packageEntry(pkg); + if (entry) entries.push(entry); + if (entries.length >= limit) break; + } + isDone = page.isDone; + cursor = page.isDone ? null : page.continueCursor; + } + + return entries; +} + +async function buildPublisherFeed( + ctx: QueryCtx, + publisher: Doc<"publishers">, + user: Doc<"users"> | null, + scope: "account" | "publisher", + stableId: string, + limit: number, +) { + const [skillEntries, packageEntries] = await Promise.all([ + collectSkillEntries(ctx, publisher, limit), + collectPackageEntries(ctx, publisher, limit), + ]); + + const sortedCandidates = [...skillEntries, ...packageEntries].sort( + (left, right) => right.updatedAt - left.updatedAt || left.name.localeCompare(right.name), + ); + const entries = sortedCandidates.slice(0, limit); + + return buildFeed({ scope, stableId, user, publisher, entries, nextCursor: null }); +} + +export const getAccountDetail = internalQuery({ + args: { accountId: v.string() }, + handler: async (ctx, args) => { + const user = await safeGetUser(ctx, args.accountId); + if (!isActiveUser(user)) return null; + const publisher = user.personalPublisherId + ? await safeGetPublisher(ctx, String(user.personalPublisherId)) + : null; + return { + account: toPublicUser(user), + publisher: toPublicPublisher(publisher), + feedUrl: `/api/v1/accounts/${encodeURIComponent(String(user._id))}/feed`, + }; + }, +}); + +export const getPublisherDetail = internalQuery({ + args: { publisherId: v.string() }, + handler: async (ctx, args) => { + const publisher = await safeGetPublisher(ctx, args.publisherId); + if (!isActivePublisher(publisher)) return null; + const user = publisher.linkedUserId + ? await safeGetUser(ctx, String(publisher.linkedUserId)) + : null; + return { + publisher: toPublicPublisher(publisher), + account: toPublicUser(user), + feedUrl: `/api/v1/publishers/${encodeURIComponent(String(publisher._id))}/feed`, + }; + }, +}); + +export const getAccountFeed = internalQuery({ + args: { + accountId: v.string(), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const user = await safeGetUser(ctx, args.accountId); + if (!isActiveUser(user) || !user.personalPublisherId) return null; + const publisher = await safeGetPublisher(ctx, String(user.personalPublisherId)); + if (!isActivePublisher(publisher)) return null; + return await buildPublisherFeed( + ctx, + publisher, + user, + "account", + String(user._id), + clampLimit(args.limit), + ); + }, +}); + +export const getPublisherFeed = internalQuery({ + args: { + publisherId: v.string(), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const publisher = await safeGetPublisher(ctx, args.publisherId); + if (!isActivePublisher(publisher)) return null; + const user = publisher.linkedUserId + ? await safeGetUser(ctx, String(publisher.linkedUserId)) + : null; + return await buildPublisherFeed( + ctx, + publisher, + isActiveUser(user) ? user : null, + "publisher", + String(publisher._id), + clampLimit(args.limit), + ); + }, +}); diff --git a/convex/http.ts b/convex/http.ts index cd9d76e476..e431656c8e 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -32,6 +32,8 @@ import { packagesPostRouterV1Http, pluginsGetRouterV1Http, createPublisherV1Http, + accountsGetRouterV1Http, + publishersGetRouterV1Http, publishPackageV1Http, publishSkillV1Http, resolveSkillVersionV1Http, @@ -343,6 +345,18 @@ http.route({ handler: createPublisherV1Http, }); +http.route({ + pathPrefix: `${ApiRoutes.accounts}/`, + method: "GET", + handler: accountsGetRouterV1Http, +}); + +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..923134aadd --- /dev/null +++ b/convex/httpApiV1.accountFeeds.test.ts @@ -0,0 +1,140 @@ +/* @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 { + accountsGetRouterV1Handler, + 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 okRate(); + }); + + return { ...partial, runQuery, runMutation } as unknown as ActionCtx; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("account feed HTTP routes", () => { + it("serves public account detail", async () => { + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + expect(args).toEqual({ accountId: "users:alice" }); + return { + account: { _id: "users:alice", handle: "alice" }, + publisher: { _id: "publishers:alice", handle: "alice" }, + feedUrl: "/api/v1/accounts/users%3Aalice/feed", + }; + }); + + const response = await accountsGetRouterV1Handler( + makeCtx({ runQuery }), + new Request("https://example.com/api/v1/accounts/users%3Aalice"), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + account: { _id: "users:alice", handle: "alice" }, + feedUrl: "/api/v1/accounts/users%3Aalice/feed", + }); + expect(runQuery).toHaveBeenCalledWith( + (internal as unknown as { accountFeeds: { getAccountDetail: unknown } }).accountFeeds + .getAccountDetail, + { accountId: "users:alice" }, + ); + }); + + it("serves bounded account feeds with public cache headers", async () => { + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + expect(args).toEqual({ accountId: "users:alice", limit: 100 }); + return { + schemaVersion: 1, + feedId: "clawhub.account.users:alice", + scope: "account", + accountId: "users:alice", + publisherId: "publishers:alice", + handle: "alice", + displayName: "Alice", + generatedAt: "2026-07-02T00:00:00.000Z", + sequence: 0, + entries: [], + nextCursor: null, + }; + }); + + const response = await accountsGetRouterV1Handler( + makeCtx({ runQuery }), + new Request("https://example.com/api/v1/accounts/users%3Aalice/feed?limit=500&cursor=next"), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toContain("s-maxage=300"); + expect(await response.json()).not.toHaveProperty("official"); + }); + + it("does not double-decode account path ids", async () => { + const response = await accountsGetRouterV1Handler( + makeCtx({}), + new Request("https://example.com/api/v1/accounts/foo%25"), + ); + + expect(response.status).toBe(404); + expect(await response.text()).toBe("Account not found"); + }); + + it("maps malformed account path escapes to 404", async () => { + const response = await accountsGetRouterV1Handler( + makeCtx({}), + new Request("https://example.com/api/v1/accounts/%"), + ); + + expect(response.status).toBe(404); + expect(await response.text()).toBe("Not found"); + }); + + it("maps missing publisher feeds to 404", async () => { + const response = await publishersGetRouterV1Handler( + makeCtx({}), + new Request("https://example.com/api/v1/publishers/publishers%3Amissing/feed"), + ); + + expect(response.status).toBe(404); + expect(await response.text()).toBe("Publisher feed not found"); + }); + + it("maps malformed publisher path escapes to 404", async () => { + const response = await publishersGetRouterV1Handler( + makeCtx({}), + new Request("https://example.com/api/v1/publishers/%/feed"), + ); + + expect(response.status).toBe(404); + expect(await response.text()).toBe("Not found"); + }); +}); diff --git a/convex/httpApiV1.ts b/convex/httpApiV1.ts index d93c213a25..5b3446c0a9 100644 --- a/convex/httpApiV1.ts +++ b/convex/httpApiV1.ts @@ -1,4 +1,8 @@ import { httpAction } from "./functions"; +import { + accountsGetRouterV1Handler, + publishersGetRouterV1Handler, +} from "./httpApiV1/accountFeedsV1"; import { catalogClawsFeedV1Handler, catalogFeedV1Handler, @@ -72,6 +76,8 @@ export const listCodePluginsV1Http = httpAction(listCodePluginsV1Handler); export const listBundlePluginsV1Http = httpAction(listBundlePluginsV1Handler); export const verifyDocsSessionV1Http = httpAction(verifyDocsSessionV1Handler); export const createPublisherV1Http = httpAction(createPublisherV1Handler); +export const accountsGetRouterV1Http = httpAction(accountsGetRouterV1Handler); +export const publishersGetRouterV1Http = httpAction(publishersGetRouterV1Handler); export const contentRightsV1Http = httpAction(contentRightsV1Handler); export const skillsShCatalogTestV1Http = httpAction(skillsShCatalogTestV1Handler); export const skillsShCatalogPublicV1Http = httpAction(skillsShCatalogPublicV1Handler); @@ -125,6 +131,8 @@ export const __handlers = { listBundlePluginsV1Handler, verifyDocsSessionV1Handler, createPublisherV1Handler, + accountsGetRouterV1Handler, + publishersGetRouterV1Handler, contentRightsV1Handler, skillsShCatalogTestV1Handler, skillsShCatalogPublicV1Handler, diff --git a/convex/httpApiV1/accountFeedsV1.ts b/convex/httpApiV1/accountFeedsV1.ts new file mode 100644 index 0000000000..633e3edbf0 --- /dev/null +++ b/convex/httpApiV1/accountFeedsV1.ts @@ -0,0 +1,105 @@ +import { ACCOUNT_FEED_MAX_LIMIT } 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, toOptionalNumber } from "./shared"; + +const accountFeedRefs = internal as unknown as { + accountFeeds: { + getAccountDetail: unknown; + getAccountFeed: unknown; + getPublisherDetail: unknown; + getPublisherFeed: unknown; + }; +}; + +async function runQueryRef( + ctx: Pick, + ref: unknown, + args: unknown, +): Promise { + return (await ctx.runQuery(ref as never, args as never)) as T; +} + +function parseFeedReadParams(request: Request) { + const url = new URL(request.url); + const limitRaw = toOptionalNumber(url.searchParams.get("limit")); + const limit = + limitRaw === undefined ? undefined : Math.min(Math.max(limitRaw, 1), ACCOUNT_FEED_MAX_LIMIT); + return { limit }; +} + +const PUBLIC_FEED_HEADERS = { + "Cache-Control": "public, max-age=60, s-maxage=300", + "X-Content-Type-Options": "nosniff", +}; + +function feedHeaders(rateHeaders: HeadersInit) { + return mergeHeaders(rateHeaders, PUBLIC_FEED_HEADERS); +} + +function safePathSegments(request: Request, prefix: string) { + try { + return getPathSegments(request, prefix); + } catch (error) { + if (error instanceof URIError) return null; + throw error; + } +} + +export async function accountsGetRouterV1Handler(ctx: ActionCtx, request: Request) { + const rate = await applyRateLimit(ctx, request, "read"); + if (!rate.ok) return rate.response; + + const segments = safePathSegments(request, "/api/v1/accounts/"); + if (!segments || (segments.length !== 1 && !(segments.length === 2 && segments[1] === "feed"))) { + return text("Not found", 404, rate.headers); + } + + const accountId = (segments[0] ?? "").trim(); + if (!accountId) return text("Account not found", 404, rate.headers); + + if (segments.length === 1) { + const detail = await runQueryRef(ctx, accountFeedRefs.accountFeeds.getAccountDetail, { + accountId, + }); + if (!detail) return text("Account not found", 404, rate.headers); + return json(detail, 200, rate.headers); + } + + const feed = await runQueryRef(ctx, accountFeedRefs.accountFeeds.getAccountFeed, { + accountId, + ...parseFeedReadParams(request), + }); + if (!feed) return text("Account feed not found", 404, rate.headers); + return json(feed, 200, feedHeaders(rate.headers)); +} + +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, accountFeedRefs.accountFeeds.getPublisherDetail, { + publisherId, + }); + if (!detail) return text("Publisher not found", 404, rate.headers); + return json(detail, 200, rate.headers); + } + + const feed = await runQueryRef(ctx, accountFeedRefs.accountFeeds.getPublisherFeed, { + publisherId, + ...parseFeedReadParams(request), + }); + if (!feed) return text("Publisher feed not found", 404, rate.headers); + return json(feed, 200, feedHeaders(rate.headers)); +} diff --git a/packages/schema/dist/accountFeed.d.ts b/packages/schema/dist/accountFeed.d.ts new file mode 100644 index 0000000000..64b1a7c021 --- /dev/null +++ b/packages/schema/dist/accountFeed.d.ts @@ -0,0 +1,40 @@ +import { type inferred } from "arktype"; +export declare const ACCOUNT_FEED_SCHEMA_VERSION = 1; +export declare const ACCOUNT_FEED_DEFAULT_LIMIT = 50; +export declare const ACCOUNT_FEED_MAX_LIMIT = 100; +export declare const AccountFeedEntryKindSchema: import("arktype/internal/variants/string.ts").StringType<"skill" | "plugin", {}>; +export type AccountFeedEntryKind = (typeof AccountFeedEntryKindSchema)[inferred]; +export declare const AccountFeedEntrySchema: 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 AccountFeedEntry = (typeof AccountFeedEntrySchema)[inferred]; +export declare const AccountFeedSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + schemaVersion: number; + feedId: string; + scope: "account" | "publisher"; + accountId: string | null; + publisherId: string | null; + 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 AccountFeed = (typeof AccountFeedSchema)[inferred]; +export declare function accountFeedId(scope: "account" | "publisher", stableId: string): string; +export declare function parseAccountFeed(value: unknown): AccountFeed; diff --git a/packages/schema/dist/accountFeed.js b/packages/schema/dist/accountFeed.js new file mode 100644 index 0000000000..511d1ecf84 --- /dev/null +++ b/packages/schema/dist/accountFeed.js @@ -0,0 +1,46 @@ +import { type } from "arktype"; +export const ACCOUNT_FEED_SCHEMA_VERSION = 1; +export const ACCOUNT_FEED_DEFAULT_LIMIT = 50; +export const ACCOUNT_FEED_MAX_LIMIT = 100; +export const AccountFeedEntryKindSchema = type('"skill"|"plugin"'); +export const AccountFeedEntrySchema = type({ + "+": "reject", + kind: AccountFeedEntryKindSchema, + id: "string", + name: "string", + displayName: "string", + summary: "string|null", + url: "string", + updatedAt: "number", +}); +export const AccountFeedSchema = type({ + "+": "reject", + schemaVersion: "number", + feedId: "string", + scope: '"account"|"publisher"', + accountId: "string|null", + publisherId: "string|null", + handle: "string|null", + displayName: "string", + generatedAt: "string", + sequence: "number", + entries: AccountFeedEntrySchema.array(), + nextCursor: "string|null", +}); +export function accountFeedId(scope, stableId) { + return `clawhub.${scope}.${stableId}`; +} +export function parseAccountFeed(value) { + const feed = AccountFeedSchema.assert(value); + if (feed.schemaVersion !== ACCOUNT_FEED_SCHEMA_VERSION) { + throw new Error(`Unsupported account feed schema version: ${feed.schemaVersion}`); + } + if (feed.sequence < 0 || !Number.isSafeInteger(feed.sequence)) { + throw new Error("Account feed sequence must be a non-negative integer"); + } + if (!Number.isFinite(Date.parse(feed.generatedAt))) { + throw new Error("Account feed generatedAt must be a valid ISO date"); + } + return feed; +} +//# sourceMappingURL=accountFeed.js.map diff --git a/packages/schema/dist/accountFeed.js.map b/packages/schema/dist/accountFeed.js.map new file mode 100644 index 0000000000..a1bdc3d998 --- /dev/null +++ b/packages/schema/dist/accountFeed.js.map @@ -0,0 +1 @@ +{"version":3,"file":"accountFeed.js","sourceRoot":"","sources":["../src/accountFeed.ts"],"names":[],"mappings":""} diff --git a/packages/schema/dist/routes.d.ts b/packages/schema/dist/routes.d.ts index 6bff2dc09d..75f9037f1c 100644 --- a/packages/schema/dist/routes.d.ts +++ b/packages/schema/dist/routes.d.ts @@ -29,6 +29,7 @@ export declare const ApiRoutes: { readonly catalogSkillsFeed: "/api/v1/feeds/skills"; readonly catalogClawsFeed: "/api/v1/feeds/claws"; readonly promotionsFeed: "/api/v1/feeds/promotions"; + readonly accounts: "/api/v1/accounts"; readonly stars: "/api/v1/stars"; readonly transfers: "/api/v1/transfers"; readonly publishers: "/api/v1/publishers"; diff --git a/packages/schema/dist/routes.js b/packages/schema/dist/routes.js index 3f099612fe..6bc8caa0d7 100644 --- a/packages/schema/dist/routes.js +++ b/packages/schema/dist/routes.js @@ -29,6 +29,7 @@ export const ApiRoutes = { catalogSkillsFeed: "/api/v1/feeds/skills", catalogClawsFeed: "/api/v1/feeds/claws", promotionsFeed: "/api/v1/feeds/promotions", + accounts: "/api/v1/accounts", stars: "/api/v1/stars", transfers: "/api/v1/transfers", publishers: "/api/v1/publishers", @@ -37,4 +38,4 @@ export const ApiRoutes = { whoami: "/api/v1/whoami", skillsExport: "/api/v1/skills/export", }; -//# sourceMappingURL=routes.js.map \ No newline at end of file +//# sourceMappingURL=routes.js.map diff --git a/packages/schema/src/accountFeed.test.ts b/packages/schema/src/accountFeed.test.ts new file mode 100644 index 0000000000..bfb2d0c429 --- /dev/null +++ b/packages/schema/src/accountFeed.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + ACCOUNT_FEED_SCHEMA_VERSION, + accountFeedId, + parseAccountFeed, + type AccountFeed, +} from "./accountFeed.js"; + +function makeFeed(overrides: Partial = {}): AccountFeed { + return { + schemaVersion: ACCOUNT_FEED_SCHEMA_VERSION, + feedId: accountFeedId("publisher", "publishers:demo"), + scope: "publisher", + accountId: null, + publisherId: "publishers:demo", + handle: "demo", + displayName: "Demo Publisher", + generatedAt: "2026-07-02T00:00:00.000Z", + sequence: 0, + entries: [ + { + kind: "skill", + id: "skills:demo", + name: "demo-skill", + displayName: "Demo Skill", + summary: null, + url: "/demo/demo-skill", + updatedAt: 10, + }, + ], + nextCursor: null, + ...overrides, + }; +} + +describe("account feed schema", () => { + it("builds stable account and publisher feed ids", () => { + expect(accountFeedId("account", "users:alice")).toBe("clawhub.account.users:alice"); + expect(accountFeedId("publisher", "publishers:alice")).toBe( + "clawhub.publisher.publishers:alice", + ); + }); + + it("accepts the first account feed contract", () => { + expect(parseAccountFeed(makeFeed()).entries[0]?.kind).toBe("skill"); + }); + + it("rejects unsupported versions and malformed entries", () => { + expect(() => parseAccountFeed(makeFeed({ schemaVersion: 2 }))).toThrow( + "Unsupported account feed schema version", + ); + expect(() => + parseAccountFeed( + makeFeed({ + entries: [{ kind: "skill", id: "skills:demo" }] as never, + }), + ), + ).toThrow(); + }); +}); diff --git a/packages/schema/src/accountFeed.ts b/packages/schema/src/accountFeed.ts new file mode 100644 index 0000000000..a4822ca8ad --- /dev/null +++ b/packages/schema/src/accountFeed.ts @@ -0,0 +1,54 @@ +import { type inferred, type } from "arktype"; + +export const ACCOUNT_FEED_SCHEMA_VERSION = 1; +export const ACCOUNT_FEED_DEFAULT_LIMIT = 50; +export const ACCOUNT_FEED_MAX_LIMIT = 100; + +export const AccountFeedEntryKindSchema = type('"skill"|"plugin"'); +export type AccountFeedEntryKind = (typeof AccountFeedEntryKindSchema)[inferred]; + +export const AccountFeedEntrySchema = type({ + "+": "reject", + kind: AccountFeedEntryKindSchema, + id: "string", + name: "string", + displayName: "string", + summary: "string|null", + url: "string", + updatedAt: "number", +}); +export type AccountFeedEntry = (typeof AccountFeedEntrySchema)[inferred]; + +export const AccountFeedSchema = type({ + "+": "reject", + schemaVersion: "number", + feedId: "string", + scope: '"account"|"publisher"', + accountId: "string|null", + publisherId: "string|null", + handle: "string|null", + displayName: "string", + generatedAt: "string", + sequence: "number", + entries: AccountFeedEntrySchema.array(), + nextCursor: "string|null", +}); +export type AccountFeed = (typeof AccountFeedSchema)[inferred]; + +export function accountFeedId(scope: "account" | "publisher", stableId: string) { + return `clawhub.${scope}.${stableId}`; +} + +export function parseAccountFeed(value: unknown): AccountFeed { + const feed = AccountFeedSchema.assert(value); + if (feed.schemaVersion !== ACCOUNT_FEED_SCHEMA_VERSION) { + throw new Error(`Unsupported account feed schema version: ${feed.schemaVersion}`); + } + if (feed.sequence < 0 || !Number.isSafeInteger(feed.sequence)) { + throw new Error("Account feed sequence must be a non-negative integer"); + } + if (!Number.isFinite(Date.parse(feed.generatedAt))) { + throw new Error("Account feed generatedAt must be a valid ISO date"); + } + 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..c7b8583aaf 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -30,6 +30,7 @@ export const ApiRoutes = { catalogSkillsFeed: "/api/v1/feeds/skills", catalogClawsFeed: "/api/v1/feeds/claws", promotionsFeed: "/api/v1/feeds/promotions", + accounts: "/api/v1/accounts", stars: "/api/v1/stars", transfers: "/api/v1/transfers", publishers: "/api/v1/publishers", diff --git a/public/api/v1/openapi.json b/public/api/v1/openapi.json index c0a148a125..360d34e699 100644 --- a/public/api/v1/openapi.json +++ b/public/api/v1/openapi.json @@ -1473,10 +1473,328 @@ } } } + }, + "AccountFeedPublicUser": { + "type": "object", + "additionalProperties": true, + "properties": { + "_id": { + "type": "string" + }, + "handle": { + "type": [ + "string", + "null" + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "image": { + "type": [ + "string", + "null" + ] + }, + "bio": { + "type": [ + "string", + "null" + ] + } + } + }, + "AccountFeedPublicPublisher": { + "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" + ] + }, + "linkedUserId": { + "type": [ + "string", + "null" + ] + } + } + }, + "AccountFeedDetailResponse": { + "type": "object", + "additionalProperties": false, + "required": [ + "feedUrl" + ], + "properties": { + "account": { + "anyOf": [ + { + "$ref": "#/components/schemas/AccountFeedPublicUser" + }, + { + "type": "null" + } + ] + }, + "publisher": { + "anyOf": [ + { + "$ref": "#/components/schemas/AccountFeedPublicPublisher" + }, + { + "type": "null" + } + ] + }, + "feedUrl": { + "type": "string" + } + } + }, + "AccountFeedEntry": { + "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" + } + } + }, + "AccountFeed": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "feedId", + "scope", + "accountId", + "publisherId", + "handle", + "displayName", + "generatedAt", + "sequence", + "entries", + "nextCursor" + ], + "properties": { + "schemaVersion": { + "type": "integer", + "enum": [ + 1 + ] + }, + "feedId": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "account", + "publisher" + ] + }, + "accountId": { + "type": [ + "string", + "null" + ] + }, + "publisherId": { + "type": [ + "string", + "null" + ] + }, + "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/AccountFeedEntry" + } + }, + "nextCursor": { + "type": [ + "string", + "null" + ], + "description": "Reserved for future pagination; null in the first account-feed API slice." + } + } } } }, "paths": { + "/api/v1/accounts/{accountId}": { + "get": { + "summary": "Get account feed identity", + "parameters": [ + { + "name": "accountId", + "in": "path", + "required": true, + "description": "Stable ClawHub account id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Account feed identity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFeedDetailResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlainTextError" + } + } + } + } + } + } + }, + "/api/v1/accounts/{accountId}/feed": { + "get": { + "summary": "Get account feed", + "parameters": [ + { + "name": "accountId", + "in": "path", + "required": true, + "description": "Stable ClawHub account id.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + } + ], + "responses": { + "200": { + "description": "Account feed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountFeed" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlainTextError" + } + } + } + } + } + } + }, "/api/v1/bundle-plugins": { "get": { "summary": "List bundle plugin catalog packages", @@ -1814,6 +2132,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 +2840,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 +2852,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 +2907,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 +2929,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 +2941,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 +2982,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 +3004,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/AccountFeedDetailResponse" + } + } + } + }, + "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 +3062,24 @@ "schema": { "type": "integer", "minimum": 1, - "maximum": 100 - } - }, - { - "name": "channel", - "in": "query", - "required": false, - "schema": { - "type": "string", - "enum": [ - "official", - "community", - "private" - ] - } - }, - { - "name": "isOfficial", - "in": "query", - "required": false, - "schema": { - "type": "boolean" + "maximum": 100, + "default": 50 } } ], "responses": { "200": { - "description": "Plugin catalog search results", + "description": "Publisher feed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackageSearchResponse" + "$ref": "#/components/schemas/AccountFeed" } } } }, - "400": { - "description": "Invalid request", + "404": { + "description": "Not found", "content": { "text/plain": { "schema": { diff --git a/specs/account-feeds.md b/specs/account-feeds.md index 93066c2cc8..9f1f92d9e0 100644 --- a/specs/account-feeds.md +++ b/specs/account-feeds.md @@ -33,17 +33,23 @@ separate product facts: The first account-feed contract should support both account-scoped and publisher-scoped feeds until product usage proves one is unnecessary. -Draft endpoints: +First-slice public endpoints: -- `GET /v1/accounts/{accountId}` -- `GET /v1/accounts/{accountId}/feed` -- `GET /v1/publishers/{publisherId}` -- `GET /v1/publishers/{publisherId}/feed` +- `GET /api/v1/accounts/{accountId}` +- `GET /api/v1/accounts/{accountId}/feed` +- `GET /api/v1/publishers/{publisherId}` +- `GET /api/v1/publishers/{publisherId}/feed` The account and publisher detail endpoints should expose enough public metadata for clients to display identity, profile links, and follow state. The feed endpoints should expose ordered public feed entries for discovery. +The initial implementation is an unsigned, bounded, live projection over active +public accounts, publishers, skills, and packages. It does not add official +state, registry review state, follow state, scan authority, install authority, +or feed signing. Signed ClawHub envelopes and publication cache semantics remain +future trust-stack work. + ## Feed Shape Draft feed metadata: @@ -52,12 +58,14 @@ Draft feed metadata: { "schemaVersion": 1, "feedId": "clawhub.account.", - "publisherId": "pub_", - "accountId": "acct_", + "scope": "publisher", + "publisherId": "publishers:", + "accountId": "users:", "displayName": "Example Publisher", "generatedAt": "2026-07-01T00:00:00.000Z", - "sequence": 1, - "entries": [] + "sequence": 0, + "entries": [], + "nextCursor": null } ``` @@ -70,6 +78,7 @@ Required stable fields: - `generatedAt`: generation time for this feed body. - `sequence`: monotonic feed sequence for cache, replay, and rollback checks. - `entries`: ordered public entries. +- `nextCursor`: reserved for future pagination; `null` in the first API slice. The feed body should not include credentials, private source URLs, bootstrap trust keys, unpublished package metadata, or reviewer-only moderation details. diff --git a/src/__tests__/openapi-contract.test.ts b/src/__tests__/openapi-contract.test.ts index 6db0046e0a..5376074a0e 100644 --- a/src/__tests__/openapi-contract.test.ts +++ b/src/__tests__/openapi-contract.test.ts @@ -80,4 +80,26 @@ describe("OpenAPI contract", () => { expect(property(property(handoffSchema, "properties"), "scan")).toBeUndefined(); expect(property(property(handoffSchema, "properties"), "scanStatus")).toBeUndefined(); }); + + it("documents account feeds without 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")).toBeTruthy(); + expect(property(paths, "/api/v1/publishers/{publisherId}/feed")).toBeTruthy(); + + const feedSchema = property(schemas, "AccountFeed"); + const entrySchema = property(schemas, "AccountFeedEntry"); + const feedProperties = property(feedSchema, "properties"); + const entryProperties = property(entrySchema, "properties"); + + expect(property(feedProperties, "feedId")).toBeTruthy(); + 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(); + }); }); From 2d53f7401e3fc982a6cbe58a8a9863a529a1bc93 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Wed, 15 Jul 2026 08:25:14 -0700 Subject: [PATCH 03/22] fix: harden account feed contract --- convex/accountFeeds.test.ts | 40 +++++++++++++++++++++++++ convex/accountFeeds.ts | 5 +++- convex/httpApiV1.accountFeeds.test.ts | 19 +++++++++++- convex/httpApiV1/accountFeedsV1.ts | 29 +++++++++++++----- packages/schema/dist/accountFeed.js | 33 +++++++++++++++++++- packages/schema/dist/accountFeed.js.map | 2 +- packages/schema/src/accountFeed.test.ts | 25 ++++++++++++++++ packages/schema/src/accountFeed.ts | 30 +++++++++++++++++++ specs/account-feeds.md | 9 ++++++ 9 files changed, 180 insertions(+), 12 deletions(-) diff --git a/convex/accountFeeds.test.ts b/convex/accountFeeds.test.ts index ab68e9cafe..6b934c63ea 100644 --- a/convex/accountFeeds.test.ts +++ b/convex/accountFeeds.test.ts @@ -105,6 +105,46 @@ describe("account feed projection", () => { expect(result.nextCursor).toBeNull(); }); + 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 = { diff --git a/convex/accountFeeds.ts b/convex/accountFeeds.ts index 95512198a1..a43b084f2f 100644 --- a/convex/accountFeeds.ts +++ b/convex/accountFeeds.ts @@ -190,7 +190,10 @@ async function buildPublisherFeed( ]); const sortedCandidates = [...skillEntries, ...packageEntries].sort( - (left, right) => right.updatedAt - left.updatedAt || left.name.localeCompare(right.name), + (left, right) => + right.updatedAt - left.updatedAt || + left.kind.localeCompare(right.kind) || + left.id.localeCompare(right.id), ); const entries = sortedCandidates.slice(0, limit); diff --git a/convex/httpApiV1.accountFeeds.test.ts b/convex/httpApiV1.accountFeeds.test.ts index 923134aadd..2a582642d3 100644 --- a/convex/httpApiV1.accountFeeds.test.ts +++ b/convex/httpApiV1.accountFeeds.test.ts @@ -90,7 +90,7 @@ describe("account feed HTTP routes", () => { const response = await accountsGetRouterV1Handler( makeCtx({ runQuery }), - new Request("https://example.com/api/v1/accounts/users%3Aalice/feed?limit=500&cursor=next"), + new Request("https://example.com/api/v1/accounts/users%3Aalice/feed?limit=500"), ); expect(response.status).toBe(200); @@ -98,6 +98,23 @@ describe("account feed HTTP routes", () => { expect(await response.json()).not.toHaveProperty("official"); }); + it("rejects unsupported cursors and malformed limits", async () => { + const ctx = makeCtx({}); + const cursorResponse = await accountsGetRouterV1Handler( + ctx, + new Request("https://example.com/api/v1/accounts/users%3Aalice/feed?cursor=next"), + ); + expect(cursorResponse.status).toBe(400); + expect(await cursorResponse.text()).toBe("Cursor pagination is not available"); + + const limitResponse = await accountsGetRouterV1Handler( + ctx, + new Request("https://example.com/api/v1/accounts/users%3Aalice/feed?limit=10items"), + ); + expect(limitResponse.status).toBe(400); + expect(await limitResponse.text()).toBe("Invalid feed limit"); + }); + it("does not double-decode account path ids", async () => { const response = await accountsGetRouterV1Handler( makeCtx({}), diff --git a/convex/httpApiV1/accountFeedsV1.ts b/convex/httpApiV1/accountFeedsV1.ts index 633e3edbf0..d3fa95b948 100644 --- a/convex/httpApiV1/accountFeedsV1.ts +++ b/convex/httpApiV1/accountFeedsV1.ts @@ -3,7 +3,7 @@ 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, toOptionalNumber } from "./shared"; +import { getPathSegments, json, text } from "./shared"; const accountFeedRefs = internal as unknown as { accountFeeds: { @@ -22,12 +22,21 @@ async function runQueryRef( return (await ctx.runQuery(ref as never, args as never)) as T; } -function parseFeedReadParams(request: Request) { +function parseFeedReadParams(request: Request, rateHeaders: HeadersInit) { const url = new URL(request.url); - const limitRaw = toOptionalNumber(url.searchParams.get("limit")); - const limit = - limitRaw === undefined ? undefined : Math.min(Math.max(limitRaw, 1), ACCOUNT_FEED_MAX_LIMIT); - return { limit }; + if (url.searchParams.has("cursor")) { + return { response: text("Cursor pagination is not available", 400, rateHeaders) } as const; + } + const limitValue = url.searchParams.get("limit"); + if (limitValue === null) return { args: {} } as const; + if (!/^[1-9]\d*$/.test(limitValue)) { + return { response: text("Invalid feed limit", 400, rateHeaders) } as const; + } + const parsedLimit = Number(limitValue); + if (!Number.isSafeInteger(parsedLimit)) { + return { response: text("Invalid feed limit", 400, rateHeaders) } as const; + } + return { args: { limit: Math.min(parsedLimit, ACCOUNT_FEED_MAX_LIMIT) } } as const; } const PUBLIC_FEED_HEADERS = { @@ -68,9 +77,11 @@ export async function accountsGetRouterV1Handler(ctx: ActionCtx, request: Reques return json(detail, 200, rate.headers); } + const params = parseFeedReadParams(request, rate.headers); + if ("response" in params) return params.response; const feed = await runQueryRef(ctx, accountFeedRefs.accountFeeds.getAccountFeed, { accountId, - ...parseFeedReadParams(request), + ...params.args, }); if (!feed) return text("Account feed not found", 404, rate.headers); return json(feed, 200, feedHeaders(rate.headers)); @@ -96,9 +107,11 @@ export async function publishersGetRouterV1Handler(ctx: ActionCtx, request: Requ return json(detail, 200, rate.headers); } + const params = parseFeedReadParams(request, rate.headers); + if ("response" in params) return params.response; const feed = await runQueryRef(ctx, accountFeedRefs.accountFeeds.getPublisherFeed, { publisherId, - ...parseFeedReadParams(request), + ...params.args, }); if (!feed) return text("Publisher feed not found", 404, rate.headers); return json(feed, 200, feedHeaders(rate.headers)); diff --git a/packages/schema/dist/accountFeed.js b/packages/schema/dist/accountFeed.js index 511d1ecf84..c3ec4b980a 100644 --- a/packages/schema/dist/accountFeed.js +++ b/packages/schema/dist/accountFeed.js @@ -35,12 +35,43 @@ export function parseAccountFeed(value) { if (feed.schemaVersion !== ACCOUNT_FEED_SCHEMA_VERSION) { throw new Error(`Unsupported account feed schema version: ${feed.schemaVersion}`); } + const stableId = feed.scope === "account" ? feed.accountId : feed.publisherId; + if (!stableId) { + throw new Error(`${feed.scope} feed must include its stable identity`); + } + if (feed.feedId !== accountFeedId(feed.scope, stableId)) { + throw new Error("Account feed id does not match its scope and stable identity"); + } if (feed.sequence < 0 || !Number.isSafeInteger(feed.sequence)) { throw new Error("Account feed sequence must be a non-negative integer"); } if (!Number.isFinite(Date.parse(feed.generatedAt))) { throw new Error("Account feed generatedAt must be a valid ISO date"); } + for (const entry of feed.entries) { + if (!entry.id || !entry.name || !entry.displayName) { + throw new Error("Account feed entry identity fields must be non-empty"); + } + if (!Number.isFinite(entry.updatedAt) || entry.updatedAt < 0) { + throw new Error("Account feed entry updatedAt must be a non-negative finite number"); + } + if (entry.url.startsWith("/")) { + if (entry.url.startsWith("//")) { + throw new Error("Account feed entry URL must not be protocol-relative"); + } + continue; + } + let url; + try { + url = new URL(entry.url); + } + catch { + throw new Error("Account feed entry URL must be absolute HTTPS or origin-relative"); + } + if (url.protocol !== "https:") { + throw new Error("Account feed entry URL must be absolute HTTPS or origin-relative"); + } + } return feed; } -//# sourceMappingURL=accountFeed.js.map +//# 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 index a1bdc3d998..6605522ffb 100644 --- a/packages/schema/dist/accountFeed.js.map +++ b/packages/schema/dist/accountFeed.js.map @@ -1 +1 @@ -{"version":3,"file":"accountFeed.js","sourceRoot":"","sources":["../src/accountFeed.ts"],"names":[],"mappings":""} +{"version":3,"file":"accountFeed.js","sourceRoot":"","sources":["../src/accountFeed.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,IAAI,EAAE,MAAM,SAAS,CAAC;AAE9C,MAAM,CAAC,MAAM,2BAA2B,GAAG,CAAC,CAAC;AAC7C,MAAM,CAAC,MAAM,0BAA0B,GAAG,EAAE,CAAC;AAC7C,MAAM,CAAC,MAAM,sBAAsB,GAAG,GAAG,CAAC;AAE1C,MAAM,CAAC,MAAM,0BAA0B,GAAG,IAAI,CAAC,kBAAkB,CAAC,CAAC;AAGnE,MAAM,CAAC,MAAM,sBAAsB,GAAG,IAAI,CAAC;IACzC,GAAG,EAAE,QAAQ;IACb,IAAI,EAAE,0BAA0B;IAChC,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,iBAAiB,GAAG,IAAI,CAAC;IACpC,GAAG,EAAE,QAAQ;IACb,aAAa,EAAE,QAAQ;IACvB,MAAM,EAAE,QAAQ;IAChB,KAAK,EAAE,uBAAuB;IAC9B,SAAS,EAAE,aAAa;IACxB,WAAW,EAAE,aAAa;IAC1B,MAAM,EAAE,aAAa;IACrB,WAAW,EAAE,QAAQ;IACrB,WAAW,EAAE,QAAQ;IACrB,QAAQ,EAAE,QAAQ;IAClB,OAAO,EAAE,sBAAsB,CAAC,KAAK,EAAE;IACvC,UAAU,EAAE,aAAa;CAC1B,CAAC,CAAC;AAGH,MAAM,UAAU,aAAa,CAAC,KAA8B,EAAE,QAAgB;IAC5E,OAAO,WAAW,KAAK,IAAI,QAAQ,EAAE,CAAC;AACxC,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,MAAM,IAAI,GAAG,iBAAiB,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC7C,IAAI,IAAI,CAAC,aAAa,KAAK,2BAA2B,EAAE,CAAC;QACvD,MAAM,IAAI,KAAK,CAAC,4CAA4C,IAAI,CAAC,aAAa,EAAE,CAAC,CAAC;IACpF,CAAC;IACD,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC;IAC9E,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,KAAK,CAAC,GAAG,IAAI,CAAC,KAAK,wCAAwC,CAAC,CAAC;IACzE,CAAC;IACD,IAAI,IAAI,CAAC,MAAM,KAAK,aAAa,CAAC,IAAI,CAAC,KAAK,EAAE,QAAQ,CAAC,EAAE,CAAC;QACxD,MAAM,IAAI,KAAK,CAAC,8DAA8D,CAAC,CAAC;IAClF,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,sDAAsD,CAAC,CAAC;IAC1E,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,EAAE,CAAC;QACnD,MAAM,IAAI,KAAK,CAAC,mDAAmD,CAAC,CAAC;IACvE,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,sDAAsD,CAAC,CAAC;QAC1E,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,mEAAmE,CAAC,CAAC;QACvF,CAAC;QACD,IAAI,KAAK,CAAC,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;YAC9B,IAAI,KAAK,CAAC,GAAG,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;gBAC/B,MAAM,IAAI,KAAK,CAAC,sDAAsD,CAAC,CAAC;YAC1E,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,kEAAkE,CAAC,CAAC;QACtF,CAAC;QACD,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;YAC9B,MAAM,IAAI,KAAK,CAAC,kEAAkE,CAAC,CAAC;QACtF,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"} \ No newline at end of file diff --git a/packages/schema/src/accountFeed.test.ts b/packages/schema/src/accountFeed.test.ts index bfb2d0c429..b9be6b9e72 100644 --- a/packages/schema/src/accountFeed.test.ts +++ b/packages/schema/src/accountFeed.test.ts @@ -57,4 +57,29 @@ describe("account feed schema", () => { ), ).toThrow(); }); + + it("binds feed identity to its scope", () => { + expect(() => parseAccountFeed(makeFeed({ publisherId: null }))).toThrow( + "publisher feed must include its stable identity", + ); + expect(() => parseAccountFeed(makeFeed({ feedId: "clawhub.publisher.other" }))).toThrow( + "feed id does not match", + ); + }); + + it("validates entry timestamps and URL references", () => { + const entry = makeFeed().entries[0]!; + expect(() => + parseAccountFeed(makeFeed({ entries: [{ ...entry, updatedAt: Number.NaN }] })), + ).toThrow("updatedAt"); + expect(() => + parseAccountFeed(makeFeed({ entries: [{ ...entry, url: "//evil.example/skill" }] })), + ).toThrow("protocol-relative"); + expect(() => + parseAccountFeed(makeFeed({ entries: [{ ...entry, url: "http://example.com/skill" }] })), + ).toThrow("absolute HTTPS"); + expect( + parseAccountFeed(makeFeed({ entries: [{ ...entry, url: "https://example.com/skill" }] })), + ).toBeDefined(); + }); }); diff --git a/packages/schema/src/accountFeed.ts b/packages/schema/src/accountFeed.ts index a4822ca8ad..fd8a02ef28 100644 --- a/packages/schema/src/accountFeed.ts +++ b/packages/schema/src/accountFeed.ts @@ -44,11 +44,41 @@ export function parseAccountFeed(value: unknown): AccountFeed { if (feed.schemaVersion !== ACCOUNT_FEED_SCHEMA_VERSION) { throw new Error(`Unsupported account feed schema version: ${feed.schemaVersion}`); } + const stableId = feed.scope === "account" ? feed.accountId : feed.publisherId; + if (!stableId) { + throw new Error(`${feed.scope} feed must include its stable identity`); + } + if (feed.feedId !== accountFeedId(feed.scope, stableId)) { + throw new Error("Account feed id does not match its scope and stable identity"); + } if (feed.sequence < 0 || !Number.isSafeInteger(feed.sequence)) { throw new Error("Account feed sequence must be a non-negative integer"); } if (!Number.isFinite(Date.parse(feed.generatedAt))) { throw new Error("Account feed generatedAt must be a valid ISO date"); } + for (const entry of feed.entries) { + if (!entry.id || !entry.name || !entry.displayName) { + throw new Error("Account feed entry identity fields must be non-empty"); + } + if (!Number.isFinite(entry.updatedAt) || entry.updatedAt < 0) { + throw new Error("Account feed entry updatedAt must be a non-negative finite number"); + } + if (entry.url.startsWith("/")) { + if (entry.url.startsWith("//")) { + throw new Error("Account feed entry URL must not be protocol-relative"); + } + continue; + } + let url: URL; + try { + url = new URL(entry.url); + } catch { + throw new Error("Account feed entry URL must be absolute HTTPS or origin-relative"); + } + if (url.protocol !== "https:") { + throw new Error("Account feed entry URL must be absolute HTTPS or origin-relative"); + } + } return feed; } diff --git a/specs/account-feeds.md b/specs/account-feeds.md index 9f1f92d9e0..388b6dcc9d 100644 --- a/specs/account-feeds.md +++ b/specs/account-feeds.md @@ -80,6 +80,15 @@ Required stable fields: - `entries`: ordered public entries. - `nextCursor`: reserved for future pagination; `null` in the first API slice. +Entry `url` values are either absolute HTTPS URLs or origin-relative URL +references resolved against the feed request origin. `updatedAt` values are +finite, non-negative Unix epoch times in milliseconds. Entries are newest +first, with stable kind and object identity tie-breakers for equal timestamps. + +The first API slice rejects a supplied `cursor` with `400` rather than silently +restarting at the first page. It clamps valid positive integer limits to the +server maximum and rejects malformed limits. + The feed body should not include credentials, private source URLs, bootstrap trust keys, unpublished package metadata, or reviewer-only moderation details. From 70dfcd20c66731c65c4dd49d2f83c0a2180214c4 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Wed, 15 Jul 2026 09:00:15 -0700 Subject: [PATCH 04/22] fix: harden account feed projections --- convex/accountFeeds.test.ts | 80 +++++++++++++++++++++++++++++- convex/accountFeeds.ts | 21 ++++---- convex/httpApiV1/accountFeedsV1.ts | 4 +- 3 files changed, 93 insertions(+), 12 deletions(-) diff --git a/convex/accountFeeds.test.ts b/convex/accountFeeds.test.ts index 6b934c63ea..e80a50c804 100644 --- a/convex/accountFeeds.test.ts +++ b/convex/accountFeeds.test.ts @@ -1,11 +1,13 @@ /* @vitest-environment node */ import { describe, expect, it, vi } from "vitest"; -import { getAccountDetail, getPublisherFeed } from "./accountFeeds"; +import { getAccountDetail, getPublisherDetail, getPublisherFeed } from "./accountFeeds"; type InternalHandler = (ctx: unknown, args: unknown) => Promise; const getAccountDetailHandler = (getAccountDetail as unknown as { _handler: InternalHandler }) ._handler; +const getPublisherDetailHandler = (getPublisherDetail as unknown as { _handler: InternalHandler }) + ._handler; const getPublisherFeedHandler = (getPublisherFeed as unknown as { _handler: InternalHandler }) ._handler; @@ -60,6 +62,52 @@ describe("account feed projection", () => { expect(get).not.toHaveBeenCalled(); }); + it("does not expose inactive linked identities in public detail responses", 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(), + linkedUserId: user._id, + deactivatedAt: 10, + }; + 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; + }); + + const accountDetail = (await getAccountDetailHandler( + { db: { get, normalizeId } }, + { accountId: String(user._id) }, + )) as { publisher: unknown }; + expect(accountDetail.publisher).toBeNull(); + + const activePublisher = { ...publisher, deactivatedAt: undefined }; + get.mockImplementation(async (id: string) => { + if (id === user._id) return { ...user, deactivatedAt: 10 }; + if (id === activePublisher._id) return activePublisher; + return null; + }); + const publisherDetail = (await getPublisherDetailHandler( + { db: { get, normalizeId } }, + { publisherId: String(activePublisher._id) }, + )) as { account: unknown }; + expect(publisherDetail.account).toBeNull(); + }); + it("filters skill-family package rows from publisher feeds", async () => { const publisher = makePublisher(); const skillPackage = { @@ -100,11 +148,41 @@ describe("account feed projection", () => { kind: "plugin", id: "packages:plugin", name: "@alice/plugin", + url: "/alice/plugins/plugin", }), ]); expect(result.nextCursor).toBeNull(); }); + 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("uses stable entry identity to break equal timestamp ties", async () => { const publisher = makePublisher(); const packages = [ diff --git a/convex/accountFeeds.ts b/convex/accountFeeds.ts index a43b084f2f..f1d8440a40 100644 --- a/convex/accountFeeds.ts +++ b/convex/accountFeeds.ts @@ -59,25 +59,26 @@ function skillEntry(publisher: Doc<"publishers">, skill: Doc<"skills">): Account name: skill.slug, displayName: skill.displayName, summary: skill.summary ?? null, - url: `/${encodeURIComponent(publisher.handle)}/${encodeURIComponent(skill.slug)}`, + url: `/${encodeURIComponent(publisher.handle)}/skills/${encodeURIComponent(skill.slug)}`, updatedAt: skill.updatedAt, }; } -function pluginPath(name: string) { +function pluginPath(publisher: Doc<"publishers">, name: string) { const trimmed = name.trim(); - if (!trimmed.startsWith("@")) return `/plugins/${encodeURIComponent(trimmed)}`; + 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 scope = trimmed.slice(1, slashIndex); const packageName = trimmed.slice(slashIndex + 1); if (packageName.includes("/")) return `/plugins/${encodeURIComponent(trimmed)}`; - return `/plugins/@${encodeURIComponent(scope)}/${encodeURIComponent(packageName)}`; + return `/${encodeURIComponent(publisher.handle)}/plugins/${encodeURIComponent(packageName)}`; } -function packageEntry(pkg: Doc<"packages">): AccountFeedEntry | null { +function packageEntry(publisher: Doc<"publishers">, pkg: Doc<"packages">): AccountFeedEntry | null { if ( pkg.family === "skill" || pkg.channel === "private" || @@ -91,7 +92,7 @@ function packageEntry(pkg: Doc<"packages">): AccountFeedEntry | null { name: pkg.name, displayName: pkg.displayName, summary: pkg.summary ?? null, - url: pluginPath(pkg.name), + url: pluginPath(publisher, pkg.name), updatedAt: pkg.updatedAt, }; } @@ -165,7 +166,7 @@ async function collectPackageEntries(ctx: QueryCtx, publisher: Doc<"publishers"> pagesRead += 1; for (const pkg of page.page) { - const entry = packageEntry(pkg); + const entry = packageEntry(publisher, pkg); if (entry) entries.push(entry); if (entries.length >= limit) break; } @@ -210,7 +211,7 @@ export const getAccountDetail = internalQuery({ : null; return { account: toPublicUser(user), - publisher: toPublicPublisher(publisher), + publisher: toPublicPublisher(isActivePublisher(publisher) ? publisher : null), feedUrl: `/api/v1/accounts/${encodeURIComponent(String(user._id))}/feed`, }; }, @@ -226,7 +227,7 @@ export const getPublisherDetail = internalQuery({ : null; return { publisher: toPublicPublisher(publisher), - account: toPublicUser(user), + account: toPublicUser(isActiveUser(user) ? user : null), feedUrl: `/api/v1/publishers/${encodeURIComponent(String(publisher._id))}/feed`, }; }, diff --git a/convex/httpApiV1/accountFeedsV1.ts b/convex/httpApiV1/accountFeedsV1.ts index d3fa95b948..7df79d2fbe 100644 --- a/convex/httpApiV1/accountFeedsV1.ts +++ b/convex/httpApiV1/accountFeedsV1.ts @@ -22,7 +22,9 @@ async function runQueryRef( return (await ctx.runQuery(ref as never, args as never)) as T; } -function parseFeedReadParams(request: Request, rateHeaders: HeadersInit) { +type ParsedFeedReadParams = { response: Response } | { args: { limit?: number } }; + +function parseFeedReadParams(request: Request, rateHeaders: HeadersInit): ParsedFeedReadParams { const url = new URL(request.url); if (url.searchParams.has("cursor")) { return { response: text("Cursor pagination is not available", 400, rateHeaders) } as const; From 709d07ca6cfeb6a3f6da061ff0387514ebf429af Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Thu, 16 Jul 2026 07:43:46 -0700 Subject: [PATCH 05/22] fix: align publisher feeds with ClawHub identity --- convex/accountFeeds.test.ts | 171 ++++++++-- convex/accountFeeds.ts | 431 ++++++++++++++++-------- convex/http.ts | 7 - convex/httpApiV1.accountFeeds.test.ts | 157 +++++---- convex/httpApiV1.ts | 7 +- convex/httpApiV1/accountFeedsV1.ts | 195 ++++++++--- convex/lib/publishers.ts | 40 ++- convex/lib/retentionPolicy.ts | 1 + convex/publishers.ts | 50 +-- convex/schema.ts | 26 +- packages/schema/dist/accountFeed.d.ts | 26 +- packages/schema/dist/accountFeed.js | 64 ++-- packages/schema/dist/accountFeed.js.map | 2 +- packages/schema/dist/routes.d.ts | 1 - packages/schema/dist/routes.js | 3 +- packages/schema/src/accountFeed.test.ts | 87 ++--- packages/schema/src/accountFeed.ts | 72 ++-- packages/schema/src/routes.ts | 1 - public/api/v1/openapi.json | 227 +++---------- specs/README.md | 2 +- specs/account-feeds.md | 229 ++++--------- src/__tests__/openapi-contract.test.ts | 10 +- 22 files changed, 978 insertions(+), 831 deletions(-) diff --git a/convex/accountFeeds.test.ts b/convex/accountFeeds.test.ts index e80a50c804..779007544f 100644 --- a/convex/accountFeeds.test.ts +++ b/convex/accountFeeds.test.ts @@ -1,17 +1,18 @@ /* @vitest-environment node */ import { describe, expect, it, vi } from "vitest"; -import { getAccountDetail, getPublisherDetail, getPublisherFeed } from "./accountFeeds"; +import { + buildPublisherFeedProjectionImpl, + getPublisherDetail, + publishPublisherFeedRevisionImpl, +} from "./accountFeeds"; type InternalHandler = (ctx: unknown, args: unknown) => Promise; -const getAccountDetailHandler = (getAccountDetail as unknown as { _handler: InternalHandler }) - ._handler; const getPublisherDetailHandler = (getPublisherDetail as unknown as { _handler: InternalHandler }) ._handler; -const getPublisherFeedHandler = (getPublisherFeed as unknown as { _handler: InternalHandler }) - ._handler; +const getPublisherFeedHandler = buildPublisherFeedProjectionImpl as InternalHandler; -function doc(id: string) { +function doc(id: string) { return id as unknown as import("./_generated/dataModel").Id; } @@ -46,23 +47,23 @@ function makePublisher() { }; } -describe("account feed projection", () => { - it("rejects account ids that do not normalize to the users table", async () => { +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 === "users" && id.startsWith("users:") ? doc<"users">(id) : null, + table === "publishers" && id.startsWith("publishers:") ? doc<"publishers">(id) : null, ); - const result = await getAccountDetailHandler( + const result = await getPublisherDetailHandler( { db: { get, normalizeId } }, - { accountId: "publishers:alice" }, + { publisherId: "users:alice" }, ); expect(result).toBeNull(); expect(get).not.toHaveBeenCalled(); }); - it("does not expose inactive linked identities in public detail responses", async () => { + it("does not expose personal publishers with inactive linked users", async () => { const user = { _id: doc<"users">("users:alice"), _creationTime: 1, @@ -75,8 +76,8 @@ describe("account feed projection", () => { }; const publisher = { ...makePublisher(), + kind: "user", linkedUserId: user._id, - deactivatedAt: 10, }; const get = vi.fn(async (id: string): Promise | null> => { if (id === user._id) return user; @@ -89,23 +90,16 @@ describe("account feed projection", () => { return null; }); - const accountDetail = (await getAccountDetailHandler( - { db: { get, normalizeId } }, - { accountId: String(user._id) }, - )) as { publisher: unknown }; - expect(accountDetail.publisher).toBeNull(); - - const activePublisher = { ...publisher, deactivatedAt: undefined }; get.mockImplementation(async (id: string) => { if (id === user._id) return { ...user, deactivatedAt: 10 }; - if (id === activePublisher._id) return activePublisher; + if (id === publisher._id) return publisher; return null; }); - const publisherDetail = (await getPublisherDetailHandler( + const publisherDetail = await getPublisherDetailHandler( { db: { get, normalizeId } }, - { publisherId: String(activePublisher._id) }, - )) as { account: unknown }; - expect(publisherDetail.account).toBeNull(); + { publisherId: String(publisher._id) }, + ); + expect(publisherDetail).toBeNull(); }); it("filters skill-family package rows from publisher feeds", async () => { @@ -141,8 +135,9 @@ describe("account feed projection", () => { const result = (await getPublisherFeedHandler( { db: { get, normalizeId, query } }, { publisherId: String(publisher._id), limit: 10 }, - )) as { entries: unknown[]; nextCursor: string | null }; + )) as { status: string; entries: unknown[] }; + expect(result.status).toBe("complete"); expect(result.entries).toEqual([ expect.objectContaining({ kind: "plugin", @@ -151,7 +146,6 @@ describe("account feed projection", () => { url: "/alice/plugins/plugin", }), ]); - expect(result.nextCursor).toBeNull(); }); it("uses canonical publisher routes for skill entries", async () => { @@ -183,6 +177,79 @@ describe("account feed projection", () => { 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 = [ @@ -255,7 +322,7 @@ describe("account feed projection", () => { const result = (await getPublisherFeedHandler( { db: { get, normalizeId, query } }, { publisherId: String(publisher._id), limit: 1 }, - )) as { entries: unknown[]; nextCursor: string | null }; + )) as { status: string; entries: unknown[] }; expect(result.entries).toEqual([ expect.objectContaining({ @@ -263,7 +330,7 @@ describe("account feed projection", () => { name: "@alice/public", }), ]); - expect(result.nextCursor).toBeNull(); + expect(result.status).toBe("complete"); }); it("bounds scans when package rows keep filtering out", async () => { @@ -303,9 +370,49 @@ describe("account feed projection", () => { const result = (await getPublisherFeedHandler( { db: { get, normalizeId, query } }, { publisherId: String(publisher._id), limit: 1 }, - )) as { entries: unknown[]; nextCursor: string | null }; + )) 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(result.entries).toEqual([]); - expect(result.nextCursor).toBeNull(); + 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 index f1d8440a40..8a85cef4bf 100644 --- a/convex/accountFeeds.ts +++ b/convex/accountFeeds.ts @@ -1,37 +1,38 @@ import { - ACCOUNT_FEED_DEFAULT_LIMIT, - ACCOUNT_FEED_MAX_LIMIT, - ACCOUNT_FEED_SCHEMA_VERSION, - accountFeedId, - type AccountFeed, - type AccountFeedEntry, + PUBLISHER_FEED_SCHEMA_VERSION, + publisherFeedId, + type PublisherFeed, + type PublisherFeedEntry, } from "clawhub-schema"; import { v } from "convex/values"; -import type { Doc } from "./_generated/dataModel"; -import type { QueryCtx } from "./_generated/server"; -import { internalQuery } from "./functions"; +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 { toPublicPublisher, toPublicUser } from "./lib/public"; +import { getPublicPublisherVisibility } from "./lib/publishers"; -const ACCOUNT_FEED_MAX_SOURCE_PAGES = 3; +const PUBLISHER_FEED_MAX_SOURCE_PAGES = 3; +const PUBLISHER_FEED_SNAPSHOT_MAX_ENTRIES = 400; +const PUBLISHER_FEED_SUMMARY_MAX_CHARS = 500; +type PublisherFeedReadCtx = Pick; -function clampLimit(limit: number | undefined) { - const value = Number.isFinite(limit) ? Math.trunc(limit as number) : ACCOUNT_FEED_DEFAULT_LIMIT; - return Math.min(Math.max(value, 1), ACCOUNT_FEED_MAX_LIMIT); +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 safeGetUser(ctx: Pick, id: string) { - const userId = ctx.db.normalizeId("users", id); - if (!userId) return null; - try { - return await ctx.db.get(userId); - } catch { - return null; - } +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: Pick, id: string) { +async function safeGetPublisher(ctx: PublisherFeedReadCtx, id: string) { const publisherId = ctx.db.normalizeId("publishers", id); if (!publisherId) return null; try { @@ -41,24 +42,14 @@ async function safeGetPublisher(ctx: Pick, id: string) { } } -function isActiveUser(user: Doc<"users"> | null | undefined): user is Doc<"users"> { - return Boolean(user && !user.deletedAt && !user.deactivatedAt); -} - -function isActivePublisher( - publisher: Doc<"publishers"> | null | undefined, -): publisher is Doc<"publishers"> { - return Boolean(publisher && !publisher.deletedAt && !publisher.deactivatedAt); -} - -function skillEntry(publisher: Doc<"publishers">, skill: Doc<"skills">): AccountFeedEntry | 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: skill.summary ?? null, + summary: boundedSummary(skill.summary), url: `/${encodeURIComponent(publisher.handle)}/skills/${encodeURIComponent(skill.slug)}`, updatedAt: skill.updatedAt, }; @@ -78,7 +69,10 @@ function pluginPath(publisher: Doc<"publishers">, name: string) { return `/${encodeURIComponent(publisher.handle)}/plugins/${encodeURIComponent(packageName)}`; } -function packageEntry(publisher: Doc<"publishers">, pkg: Doc<"packages">): AccountFeedEntry | null { +function packageEntry( + publisher: Doc<"publishers">, + pkg: Doc<"packages">, +): PublisherFeedEntry | null { if ( pkg.family === "skill" || pkg.channel === "private" || @@ -91,187 +85,346 @@ function packageEntry(publisher: Doc<"publishers">, pkg: Doc<"packages">): Accou id: String(pkg._id), name: pkg.name, displayName: pkg.displayName, - summary: pkg.summary ?? null, + summary: boundedSummary(pkg.summary), url: pluginPath(publisher, pkg.name), updatedAt: pkg.updatedAt, }; } function buildFeed(params: { - scope: "account" | "publisher"; - stableId: string; - user: Doc<"users"> | null; - publisher: Doc<"publishers">; - entries: AccountFeedEntry[]; - nextCursor: string | null; -}): AccountFeed { + publisherId: string; + feedId: string; + handle: string | null; + displayName: string; + entries: PublisherFeedEntry[]; + generatedAt: string; + sequence: number; +}): PublisherFeed { return { - schemaVersion: ACCOUNT_FEED_SCHEMA_VERSION, - feedId: accountFeedId(params.scope, params.stableId), - scope: params.scope, - accountId: params.user ? String(params.user._id) : null, - publisherId: String(params.publisher._id), - handle: params.publisher.handle ?? params.user?.handle ?? null, - displayName: - params.publisher.displayName || params.user?.displayName || params.user?.name || "", - generatedAt: new Date().toISOString(), - sequence: 0, + 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: params.nextCursor, + nextCursor: null, }; } -async function collectSkillEntries(ctx: QueryCtx, publisher: Doc<"publishers">, limit: number) { - const entries: AccountFeedEntry[] = []; +type CollectedEntries = { + entries: PublisherFeedEntry[]; + exhausted: boolean; +}; + +async function collectSkillEntries( + ctx: PublisherFeedReadCtx, + publisher: Doc<"publishers">, + limit: number, +): Promise { + const entries: PublisherFeedEntry[] = []; let cursor: string | null = null; let isDone = false; let pagesRead = 0; - while (!isDone && pagesRead < ACCOUNT_FEED_MAX_SOURCE_PAGES && entries.length < limit) { + while (!isDone && pagesRead < PUBLISHER_FEED_MAX_SOURCE_PAGES && entries.length <= limit) { const page = await ctx.db .query("skills") .withIndex("by_owner_publisher_active_updated", (q) => q.eq("ownerPublisherId", publisher._id).eq("softDeletedAt", undefined), ) .order("desc") - .paginate({ cursor, numItems: limit }); + .paginate({ cursor, numItems: limit + 1 }); pagesRead += 1; for (const skill of page.page) { const entry = skillEntry(publisher, skill); if (entry) entries.push(entry); - if (entries.length >= limit) break; + if (entries.length > limit) break; } isDone = page.isDone; cursor = page.isDone ? null : page.continueCursor; } - return entries; + return { entries, exhausted: isDone }; } -async function collectPackageEntries(ctx: QueryCtx, publisher: Doc<"publishers">, limit: number) { - const entries: AccountFeedEntry[] = []; +async function collectPackageEntries( + ctx: PublisherFeedReadCtx, + publisher: Doc<"publishers">, + limit: number, +): Promise { + const entries: PublisherFeedEntry[] = []; let cursor: string | null = null; let isDone = false; let pagesRead = 0; - while (!isDone && pagesRead < ACCOUNT_FEED_MAX_SOURCE_PAGES && entries.length < limit) { + while (!isDone && pagesRead < PUBLISHER_FEED_MAX_SOURCE_PAGES && entries.length <= limit) { const page = await ctx.db .query("packages") .withIndex("by_owner_publisher_active_updated", (q) => q.eq("ownerPublisherId", publisher._id).eq("softDeletedAt", undefined), ) .order("desc") - .paginate({ cursor, numItems: limit }); + .paginate({ cursor, numItems: limit + 1 }); pagesRead += 1; for (const pkg of page.page) { const entry = packageEntry(publisher, pkg); if (entry) entries.push(entry); - if (entries.length >= limit) break; + if (entries.length > limit) break; } isDone = page.isDone; cursor = page.isDone ? null : page.continueCursor; } - return entries; + return { entries, exhausted: isDone }; +} + +async function collectLegacySkillEntries( + ctx: PublisherFeedReadCtx, + publisher: Doc<"publishers">, + ownerUserId: Doc<"users">["_id"], + limit: number, +): Promise { + const entries: PublisherFeedEntry[] = []; + let cursor: string | null = null; + let isDone = false; + let pagesRead = 0; + + while (!isDone && pagesRead < PUBLISHER_FEED_MAX_SOURCE_PAGES && entries.length <= limit) { + const page = await ctx.db + .query("skills") + .withIndex("by_owner_active_updated", (q) => + q.eq("ownerUserId", ownerUserId).eq("softDeletedAt", undefined), + ) + .order("desc") + .paginate({ cursor, numItems: limit + 1 }); + pagesRead += 1; + for (const skill of page.page) { + if (skill.ownerPublisherId && skill.ownerPublisherId !== publisher._id) continue; + const entry = skillEntry(publisher, skill); + if (entry) entries.push(entry); + if (entries.length > limit) break; + } + isDone = page.isDone; + cursor = page.isDone ? null : page.continueCursor; + } + return { entries, exhausted: isDone }; +} + +async function collectLegacyPackageEntries( + ctx: PublisherFeedReadCtx, + publisher: Doc<"publishers">, + ownerUserId: Doc<"users">["_id"], + limit: number, +): Promise { + const entries: PublisherFeedEntry[] = []; + let cursor: string | null = null; + let isDone = false; + let pagesRead = 0; + + while (!isDone && pagesRead < PUBLISHER_FEED_MAX_SOURCE_PAGES && entries.length <= limit) { + const page = await ctx.db + .query("packages") + .withIndex("by_owner_active_updated", (q) => + q.eq("ownerUserId", ownerUserId).eq("softDeletedAt", undefined), + ) + .order("desc") + .paginate({ cursor, numItems: limit + 1 }); + pagesRead += 1; + for (const pkg of page.page) { + if (pkg.ownerPublisherId && pkg.ownerPublisherId !== publisher._id) continue; + const entry = packageEntry(publisher, pkg); + if (entry) entries.push(entry); + if (entries.length > limit) break; + } + isDone = page.isDone; + cursor = page.isDone ? null : page.continueCursor; + } + return { entries, exhausted: isDone }; } async function buildPublisherFeed( - ctx: QueryCtx, + ctx: PublisherFeedReadCtx, publisher: Doc<"publishers">, - user: Doc<"users"> | null, - scope: "account" | "publisher", - stableId: string, + legacyOwnerUserId: Doc<"users">["_id"] | null, limit: number, ) { - const [skillEntries, packageEntries] = await Promise.all([ - collectSkillEntries(ctx, publisher, limit), - collectPackageEntries(ctx, publisher, limit), - ]); + 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 sortedCandidates = [...skillEntries, ...packageEntries].sort( + 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 entries = sortedCandidates.slice(0, limit); + const exhausted = + skillEntries.exhausted && + packageEntries.exhausted && + legacySkillEntries.exhausted && + legacyPackageEntries.exhausted; + if (!exhausted || sortedCandidates.length > limit) { + return { status: "capacity-exceeded" as const }; + } - return buildFeed({ scope, stableId, user, publisher, entries, nextCursor: null }); + 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 getAccountDetail = internalQuery({ - args: { accountId: v.string() }, - handler: async (ctx, args) => { - const user = await safeGetUser(ctx, args.accountId); - if (!isActiveUser(user)) return null; - const publisher = user.personalPublisherId - ? await safeGetPublisher(ctx, String(user.personalPublisherId)) - : null; - return { - account: toPublicUser(user), - publisher: toPublicPublisher(isActivePublisher(publisher) ? publisher : null), - feedUrl: `/api/v1/accounts/${encodeURIComponent(String(user._id))}/feed`, - }; - }, -}); - export const getPublisherDetail = internalQuery({ args: { publisherId: v.string() }, handler: async (ctx, args) => { const publisher = await safeGetPublisher(ctx, args.publisherId); - if (!isActivePublisher(publisher)) return null; - const user = publisher.linkedUserId - ? await safeGetUser(ctx, String(publisher.linkedUserId)) - : null; + const visibility = await getPublicPublisherVisibility(ctx, publisher); + if (!visibility) return null; return { - publisher: toPublicPublisher(publisher), - account: toPublicUser(isActiveUser(user) ? user : null), - feedUrl: `/api/v1/publishers/${encodeURIComponent(String(publisher._id))}/feed`, + 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 getAccountFeed = internalQuery({ - args: { - accountId: v.string(), - limit: v.optional(v.number()), - }, +export const getPublisherFeedPublication = internalQuery({ + args: { publisherId: v.string() }, handler: async (ctx, args) => { - const user = await safeGetUser(ctx, args.accountId); - if (!isActiveUser(user) || !user.personalPublisherId) return null; - const publisher = await safeGetPublisher(ctx, String(user.personalPublisherId)); - if (!isActivePublisher(publisher)) return null; - return await buildPublisherFeed( - ctx, - publisher, - user, - "account", - String(user._id), - clampLimit(args.limit), - ); + 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(); }, }); -export const getPublisherFeed = internalQuery({ - args: { - publisherId: v.string(), - limit: v.optional(v.number()), - }, - handler: async (ctx, args) => { - const publisher = await safeGetPublisher(ctx, args.publisherId); - if (!isActivePublisher(publisher)) return null; - const user = publisher.linkedUserId - ? await safeGetUser(ctx, String(publisher.linkedUserId)) - : null; - return await buildPublisherFeed( - ctx, - publisher, - isActiveUser(user) ? user : null, - "publisher", - String(publisher._id), - clampLimit(args.limit), - ); - }, +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 e431656c8e..b8a8ae5d49 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -32,7 +32,6 @@ import { packagesPostRouterV1Http, pluginsGetRouterV1Http, createPublisherV1Http, - accountsGetRouterV1Http, publishersGetRouterV1Http, publishPackageV1Http, publishSkillV1Http, @@ -345,12 +344,6 @@ http.route({ handler: createPublisherV1Http, }); -http.route({ - pathPrefix: `${ApiRoutes.accounts}/`, - method: "GET", - handler: accountsGetRouterV1Http, -}); - http.route({ pathPrefix: `${ApiRoutes.publishers}/`, method: "GET", diff --git a/convex/httpApiV1.accountFeeds.test.ts b/convex/httpApiV1.accountFeeds.test.ts index 2a582642d3..d117beb71a 100644 --- a/convex/httpApiV1.accountFeeds.test.ts +++ b/convex/httpApiV1.accountFeeds.test.ts @@ -2,10 +2,7 @@ import type { RateLimitArgs, RateLimitReturns } from "@convex-dev/rate-limiter"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { internal } from "./_generated/api"; -import { - accountsGetRouterV1Handler, - publishersGetRouterV1Handler, -} from "./httpApiV1/accountFeedsV1"; +import { publishersGetRouterV1Handler } from "./httpApiV1/accountFeedsV1"; type ActionCtx = import("./_generated/server").ActionCtx; @@ -30,7 +27,7 @@ function makeCtx(partial: Record) { ? partial.runMutation : vi.fn(async (_mutation: unknown, args: Record) => { if (isRateLimitArgs(args)) return okRate(); - return okRate(); + return null; }); return { ...partial, runQuery, runMutation } as unknown as ActionCtx; @@ -40,118 +37,136 @@ beforeEach(() => { vi.clearAllMocks(); }); -describe("account feed HTTP routes", () => { - it("serves public account detail", async () => { +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({ accountId: "users:alice" }); + expect(args).toEqual({ publisherId: "publishers:alice" }); return { - account: { _id: "users:alice", handle: "alice" }, publisher: { _id: "publishers:alice", handle: "alice" }, - feedUrl: "/api/v1/accounts/users%3Aalice/feed", + feedUrl: "/api/v1/publishers/publishers%3Aalice/feed", }; }); - const response = await accountsGetRouterV1Handler( + const response = await publishersGetRouterV1Handler( makeCtx({ runQuery }), - new Request("https://example.com/api/v1/accounts/users%3Aalice"), + new Request("https://example.com/api/v1/publishers/publishers%3Aalice"), ); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ - account: { _id: "users:alice", handle: "alice" }, - feedUrl: "/api/v1/accounts/users%3Aalice/feed", + publisher: { _id: "publishers:alice", handle: "alice" }, + feedUrl: "/api/v1/publishers/publishers%3Aalice/feed", }); expect(runQuery).toHaveBeenCalledWith( - (internal as unknown as { accountFeeds: { getAccountDetail: unknown } }).accountFeeds - .getAccountDetail, - { accountId: "users:alice" }, + (internal as unknown as { accountFeeds: { getPublisherDetail: unknown } }).accountFeeds + .getPublisherDetail, + { publisherId: "publishers:alice" }, ); }); - it("serves bounded account feeds with public cache headers", async () => { - const runQuery = vi.fn(async (_query: unknown, args: Record) => { + 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(); - expect(args).toEqual({ accountId: "users:alice", limit: 100 }); - return { - schemaVersion: 1, - feedId: "clawhub.account.users:alice", - scope: "account", - accountId: "users:alice", - publisherId: "publishers:alice", - handle: "alice", - displayName: "Alice", - generatedAt: "2026-07-02T00:00:00.000Z", - sequence: 0, - entries: [], - nextCursor: null, - }; + return storedFeed; }); - const response = await accountsGetRouterV1Handler( - makeCtx({ runQuery }), - new Request("https://example.com/api/v1/accounts/users%3Aalice/feed?limit=500"), + 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")).toContain("s-maxage=300"); - expect(await response.json()).not.toHaveProperty("official"); + 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 unsupported cursors and malformed limits", async () => { + it("rejects malformed cursors and limits", async () => { const ctx = makeCtx({}); - const cursorResponse = await accountsGetRouterV1Handler( + const cursorResponse = await publishersGetRouterV1Handler( ctx, - new Request("https://example.com/api/v1/accounts/users%3Aalice/feed?cursor=next"), + new Request("https://example.com/api/v1/publishers/publishers%3Aalice/feed?cursor=next"), ); expect(cursorResponse.status).toBe(400); - expect(await cursorResponse.text()).toBe("Cursor pagination is not available"); + expect(await cursorResponse.text()).toBe("Invalid publisher feed cursor"); - const limitResponse = await accountsGetRouterV1Handler( + const limitResponse = await publishersGetRouterV1Handler( ctx, - new Request("https://example.com/api/v1/accounts/users%3Aalice/feed?limit=10items"), + 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("does not double-decode account path ids", async () => { - const response = await accountsGetRouterV1Handler( - makeCtx({}), - new Request("https://example.com/api/v1/accounts/foo%25"), - ); - - expect(response.status).toBe(404); - expect(await response.text()).toBe("Account not found"); - }); - - it("maps malformed account path escapes to 404", async () => { - const response = await accountsGetRouterV1Handler( - makeCtx({}), - new Request("https://example.com/api/v1/accounts/%"), + 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(404); - expect(await response.text()).toBe("Not found"); + expect(response.status).toBe(400); + expect(await response.text()).toBe("Invalid publisher feed cursor offset"); }); - it("maps missing publisher feeds to 404", async () => { - const response = await publishersGetRouterV1Handler( + 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"); - expect(response.status).toBe(404); - expect(await response.text()).toBe("Publisher feed not found"); - }); - - it("maps malformed publisher path escapes to 404", async () => { - const response = await publishersGetRouterV1Handler( + const malformed = await publishersGetRouterV1Handler( makeCtx({}), new Request("https://example.com/api/v1/publishers/%/feed"), ); - - expect(response.status).toBe(404); - expect(await response.text()).toBe("Not found"); + expect(malformed.status).toBe(404); + expect(await malformed.text()).toBe("Not found"); }); }); diff --git a/convex/httpApiV1.ts b/convex/httpApiV1.ts index 5b3446c0a9..850f29bf5b 100644 --- a/convex/httpApiV1.ts +++ b/convex/httpApiV1.ts @@ -1,8 +1,5 @@ import { httpAction } from "./functions"; -import { - accountsGetRouterV1Handler, - publishersGetRouterV1Handler, -} from "./httpApiV1/accountFeedsV1"; +import { publishersGetRouterV1Handler } from "./httpApiV1/accountFeedsV1"; import { catalogClawsFeedV1Handler, catalogFeedV1Handler, @@ -76,7 +73,6 @@ export const listCodePluginsV1Http = httpAction(listCodePluginsV1Handler); export const listBundlePluginsV1Http = httpAction(listBundlePluginsV1Handler); export const verifyDocsSessionV1Http = httpAction(verifyDocsSessionV1Handler); export const createPublisherV1Http = httpAction(createPublisherV1Handler); -export const accountsGetRouterV1Http = httpAction(accountsGetRouterV1Handler); export const publishersGetRouterV1Http = httpAction(publishersGetRouterV1Handler); export const contentRightsV1Http = httpAction(contentRightsV1Handler); export const skillsShCatalogTestV1Http = httpAction(skillsShCatalogTestV1Handler); @@ -131,7 +127,6 @@ export const __handlers = { listBundlePluginsV1Handler, verifyDocsSessionV1Handler, createPublisherV1Handler, - accountsGetRouterV1Handler, publishersGetRouterV1Handler, contentRightsV1Handler, skillsShCatalogTestV1Handler, diff --git a/convex/httpApiV1/accountFeedsV1.ts b/convex/httpApiV1/accountFeedsV1.ts index 7df79d2fbe..901bdac897 100644 --- a/convex/httpApiV1/accountFeedsV1.ts +++ b/convex/httpApiV1/accountFeedsV1.ts @@ -1,19 +1,34 @@ -import { ACCOUNT_FEED_MAX_LIMIT } from "clawhub-schema"; +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 accountFeedRefs = internal as unknown as { +const publisherFeedRefs = internal as unknown as { accountFeeds: { - getAccountDetail: unknown; - getAccountFeed: unknown; getPublisherDetail: unknown; - getPublisherFeed: 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, @@ -22,32 +37,76 @@ async function runQueryRef( return (await ctx.runQuery(ref as never, args as never)) as T; } -type ParsedFeedReadParams = { response: Response } | { args: { limit?: number } }; +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); - if (url.searchParams.has("cursor")) { - return { response: text("Cursor pagination is not available", 400, rateHeaders) } as const; - } const limitValue = url.searchParams.get("limit"); - if (limitValue === null) return { args: {} } as const; - if (!/^[1-9]\d*$/.test(limitValue)) { - return { response: text("Invalid feed limit", 400, rateHeaders) } as const; + 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 parsedLimit = Number(limitValue); - if (!Number.isSafeInteger(parsedLimit)) { - return { response: text("Invalid feed limit", 400, rateHeaders) } as const; + + 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: Math.min(parsedLimit, ACCOUNT_FEED_MAX_LIMIT) } } as const; + return { args: { limit, cursor } }; } -const PUBLIC_FEED_HEADERS = { - "Cache-Control": "public, max-age=60, s-maxage=300", +const FEED_HEADERS = { + "Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff", }; function feedHeaders(rateHeaders: HeadersInit) { - return mergeHeaders(rateHeaders, PUBLIC_FEED_HEADERS); + return mergeHeaders(rateHeaders, FEED_HEADERS); } function safePathSegments(request: Request, prefix: string) { @@ -59,34 +118,27 @@ function safePathSegments(request: Request, prefix: string) { } } -export async function accountsGetRouterV1Handler(ctx: ActionCtx, request: Request) { - const rate = await applyRateLimit(ctx, request, "read"); - if (!rate.ok) return rate.response; - - const segments = safePathSegments(request, "/api/v1/accounts/"); - if (!segments || (segments.length !== 1 && !(segments.length === 2 && segments[1] === "feed"))) { - return text("Not found", 404, rate.headers); - } - - const accountId = (segments[0] ?? "").trim(); - if (!accountId) return text("Account not found", 404, rate.headers); - - if (segments.length === 1) { - const detail = await runQueryRef(ctx, accountFeedRefs.accountFeeds.getAccountDetail, { - accountId, - }); - if (!detail) return text("Account 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 feed = await runQueryRef(ctx, accountFeedRefs.accountFeeds.getAccountFeed, { - accountId, - ...params.args, - }); - if (!feed) return text("Account feed not found", 404, rate.headers); - return json(feed, 200, feedHeaders(rate.headers)); +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) { @@ -102,7 +154,7 @@ export async function publishersGetRouterV1Handler(ctx: ActionCtx, request: Requ if (!publisherId) return text("Publisher not found", 404, rate.headers); if (segments.length === 1) { - const detail = await runQueryRef(ctx, accountFeedRefs.accountFeeds.getPublisherDetail, { + const detail = await runQueryRef(ctx, publisherFeedRefs.accountFeeds.getPublisherDetail, { publisherId, }); if (!detail) return text("Publisher not found", 404, rate.headers); @@ -111,10 +163,47 @@ export async function publishersGetRouterV1Handler(ctx: ActionCtx, request: Requ const params = parseFeedReadParams(request, rate.headers); if ("response" in params) return params.response; - const feed = await runQueryRef(ctx, accountFeedRefs.accountFeeds.getPublisherFeed, { - publisherId, - ...params.args, - }); + 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); - return json(feed, 200, feedHeaders(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.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 index 64b1a7c021..edf595cba7 100644 --- a/packages/schema/dist/accountFeed.d.ts +++ b/packages/schema/dist/accountFeed.d.ts @@ -1,10 +1,10 @@ import { type inferred } from "arktype"; -export declare const ACCOUNT_FEED_SCHEMA_VERSION = 1; -export declare const ACCOUNT_FEED_DEFAULT_LIMIT = 50; -export declare const ACCOUNT_FEED_MAX_LIMIT = 100; -export declare const AccountFeedEntryKindSchema: import("arktype/internal/variants/string.ts").StringType<"skill" | "plugin", {}>; -export type AccountFeedEntryKind = (typeof AccountFeedEntryKindSchema)[inferred]; -export declare const AccountFeedEntrySchema: import("arktype/internal/variants/object.ts").ObjectType<{ +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; @@ -13,13 +13,11 @@ export declare const AccountFeedEntrySchema: import("arktype/internal/variants/o url: string; updatedAt: number; }, {}>; -export type AccountFeedEntry = (typeof AccountFeedEntrySchema)[inferred]; -export declare const AccountFeedSchema: import("arktype/internal/variants/object.ts").ObjectType<{ +export type PublisherFeedEntry = (typeof PublisherFeedEntrySchema)[inferred]; +export declare const PublisherFeedSchema: import("arktype/internal/variants/object.ts").ObjectType<{ schemaVersion: number; feedId: string; - scope: "account" | "publisher"; - accountId: string | null; - publisherId: string | null; + publisherId: string; handle: string | null; displayName: string; generatedAt: string; @@ -35,6 +33,6 @@ export declare const AccountFeedSchema: import("arktype/internal/variants/object }[]; nextCursor: string | null; }, {}>; -export type AccountFeed = (typeof AccountFeedSchema)[inferred]; -export declare function accountFeedId(scope: "account" | "publisher", stableId: string): string; -export declare function parseAccountFeed(value: unknown): AccountFeed; +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 index c3ec4b980a..c8aad8485a 100644 --- a/packages/schema/dist/accountFeed.js +++ b/packages/schema/dist/accountFeed.js @@ -1,11 +1,11 @@ import { type } from "arktype"; -export const ACCOUNT_FEED_SCHEMA_VERSION = 1; -export const ACCOUNT_FEED_DEFAULT_LIMIT = 50; -export const ACCOUNT_FEED_MAX_LIMIT = 100; -export const AccountFeedEntryKindSchema = type('"skill"|"plugin"'); -export const AccountFeedEntrySchema = type({ +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: AccountFeedEntryKindSchema, + kind: PublisherFeedEntryKindSchema, id: "string", name: "string", displayName: "string", @@ -13,51 +13,55 @@ export const AccountFeedEntrySchema = type({ url: "string", updatedAt: "number", }); -export const AccountFeedSchema = type({ +export const PublisherFeedSchema = type({ "+": "reject", schemaVersion: "number", feedId: "string", - scope: '"account"|"publisher"', - accountId: "string|null", - publisherId: "string|null", + publisherId: "string", handle: "string|null", displayName: "string", generatedAt: "string", sequence: "number", - entries: AccountFeedEntrySchema.array(), + entries: PublisherFeedEntrySchema.array(), nextCursor: "string|null", }); -export function accountFeedId(scope, stableId) { - return `clawhub.${scope}.${stableId}`; +export function publisherFeedId(publisherId) { + return `clawhub.publisher.${publisherId}`; } -export function parseAccountFeed(value) { - const feed = AccountFeedSchema.assert(value); - if (feed.schemaVersion !== ACCOUNT_FEED_SCHEMA_VERSION) { - throw new Error(`Unsupported account feed schema version: ${feed.schemaVersion}`); +function containsAsciiControlCharacter(value) { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit <= 0x1f || codeUnit === 0x7f) + return true; } - const stableId = feed.scope === "account" ? feed.accountId : feed.publisherId; - if (!stableId) { - throw new Error(`${feed.scope} feed must include its stable identity`); + 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.feedId !== accountFeedId(feed.scope, stableId)) { - throw new Error("Account feed id does not match its scope and stable identity"); + 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("Account feed sequence must be a non-negative integer"); + throw new Error("Publisher feed sequence must be a non-negative integer"); } if (!Number.isFinite(Date.parse(feed.generatedAt))) { - throw new Error("Account feed generatedAt must be a valid ISO date"); + 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("Account feed entry identity fields must be non-empty"); + throw new Error("Publisher feed entry identity fields must be non-empty"); } if (!Number.isFinite(entry.updatedAt) || entry.updatedAt < 0) { - throw new Error("Account feed entry updatedAt must be a non-negative finite number"); + throw new Error("Publisher feed entry updatedAt must be a non-negative finite number"); } if (entry.url.startsWith("/")) { - if (entry.url.startsWith("//")) { - throw new Error("Account feed entry URL must not be protocol-relative"); + 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; } @@ -66,10 +70,10 @@ export function parseAccountFeed(value) { url = new URL(entry.url); } catch { - throw new Error("Account feed entry URL must be absolute HTTPS or origin-relative"); + throw new Error("Publisher feed entry URL must be absolute HTTPS or origin-relative"); } if (url.protocol !== "https:") { - throw new Error("Account feed entry URL must be absolute HTTPS or origin-relative"); + throw new Error("Publisher feed entry URL must be absolute HTTPS or origin-relative"); } } return feed; diff --git a/packages/schema/dist/accountFeed.js.map b/packages/schema/dist/accountFeed.js.map index 6605522ffb..a23d1255b1 100644 --- a/packages/schema/dist/accountFeed.js.map +++ b/packages/schema/dist/accountFeed.js.map @@ -1 +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,2BAA2B,GAAG,CAAC,CAAC;AAC7C,MAAM,CAAC,MAAM,0BAA0B,GAAG,EAAE,CAAC;AAC7C,MAAM,CAAC,MAAM,sBAAsB,GAAG,GAAG,CAAC;AAE1C,MAAM,CAAC,MAAM,0BAA0B,GAAG,IAAI,CAAC,kBAAkB,CAAC,CAAC;AAGnE,MAAM,CAAC,MAAM,sBAAsB,GAAG,IAAI,CAAC;IACzC,GAAG,EAAE,QAAQ;IACb,IAAI,EAAE,0BAA0B;IAChC,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,iBAAiB,GAAG,IAAI,CAAC;IACpC,GAAG,EAAE,QAAQ;IACb,aAAa,EAAE,QAAQ;IACvB,MAAM,EAAE,QAAQ;IAChB,KAAK,EAAE,uBAAuB;IAC9B,SAAS,EAAE,aAAa;IACxB,WAAW,EAAE,aAAa;IAC1B,MAAM,EAAE,aAAa;IACrB,WAAW,EAAE,QAAQ;IACrB,WAAW,EAAE,QAAQ;IACrB,QAAQ,EAAE,QAAQ;IAClB,OAAO,EAAE,sBAAsB,CAAC,KAAK,EAAE;IACvC,UAAU,EAAE,aAAa;CAC1B,CAAC,CAAC;AAGH,MAAM,UAAU,aAAa,CAAC,KAA8B,EAAE,QAAgB;IAC5E,OAAO,WAAW,KAAK,IAAI,QAAQ,EAAE,CAAC;AACxC,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,MAAM,IAAI,GAAG,iBAAiB,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC7C,IAAI,IAAI,CAAC,aAAa,KAAK,2BAA2B,EAAE,CAAC;QACvD,MAAM,IAAI,KAAK,CAAC,4CAA4C,IAAI,CAAC,aAAa,EAAE,CAAC,CAAC;IACpF,CAAC;IACD,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC;IAC9E,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,KAAK,CAAC,GAAG,IAAI,CAAC,KAAK,wCAAwC,CAAC,CAAC;IACzE,CAAC;IACD,IAAI,IAAI,CAAC,MAAM,KAAK,aAAa,CAAC,IAAI,CAAC,KAAK,EAAE,QAAQ,CAAC,EAAE,CAAC;QACxD,MAAM,IAAI,KAAK,CAAC,8DAA8D,CAAC,CAAC;IAClF,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,sDAAsD,CAAC,CAAC;IAC1E,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,EAAE,CAAC;QACnD,MAAM,IAAI,KAAK,CAAC,mDAAmD,CAAC,CAAC;IACvE,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,sDAAsD,CAAC,CAAC;QAC1E,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,mEAAmE,CAAC,CAAC;QACvF,CAAC;QACD,IAAI,KAAK,CAAC,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;YAC9B,IAAI,KAAK,CAAC,GAAG,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;gBAC/B,MAAM,IAAI,KAAK,CAAC,sDAAsD,CAAC,CAAC;YAC1E,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,kEAAkE,CAAC,CAAC;QACtF,CAAC;QACD,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;YAC9B,MAAM,IAAI,KAAK,CAAC,kEAAkE,CAAC,CAAC;QACtF,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"} \ No newline at end of file +{"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/routes.d.ts b/packages/schema/dist/routes.d.ts index 75f9037f1c..6bff2dc09d 100644 --- a/packages/schema/dist/routes.d.ts +++ b/packages/schema/dist/routes.d.ts @@ -29,7 +29,6 @@ export declare const ApiRoutes: { readonly catalogSkillsFeed: "/api/v1/feeds/skills"; readonly catalogClawsFeed: "/api/v1/feeds/claws"; readonly promotionsFeed: "/api/v1/feeds/promotions"; - readonly accounts: "/api/v1/accounts"; readonly stars: "/api/v1/stars"; readonly transfers: "/api/v1/transfers"; readonly publishers: "/api/v1/publishers"; diff --git a/packages/schema/dist/routes.js b/packages/schema/dist/routes.js index 6bc8caa0d7..3f099612fe 100644 --- a/packages/schema/dist/routes.js +++ b/packages/schema/dist/routes.js @@ -29,7 +29,6 @@ export const ApiRoutes = { catalogSkillsFeed: "/api/v1/feeds/skills", catalogClawsFeed: "/api/v1/feeds/claws", promotionsFeed: "/api/v1/feeds/promotions", - accounts: "/api/v1/accounts", stars: "/api/v1/stars", transfers: "/api/v1/transfers", publishers: "/api/v1/publishers", @@ -38,4 +37,4 @@ export const ApiRoutes = { whoami: "/api/v1/whoami", skillsExport: "/api/v1/skills/export", }; -//# sourceMappingURL=routes.js.map +//# sourceMappingURL=routes.js.map \ No newline at end of file diff --git a/packages/schema/src/accountFeed.test.ts b/packages/schema/src/accountFeed.test.ts index b9be6b9e72..3c6690f625 100644 --- a/packages/schema/src/accountFeed.test.ts +++ b/packages/schema/src/accountFeed.test.ts @@ -1,30 +1,28 @@ import { describe, expect, it } from "vitest"; import { - ACCOUNT_FEED_SCHEMA_VERSION, - accountFeedId, - parseAccountFeed, - type AccountFeed, -} from "./accountFeed.js"; + PUBLISHER_FEED_SCHEMA_VERSION, + parsePublisherFeed, + publisherFeedId, + type PublisherFeed, +} from "./accountFeed"; -function makeFeed(overrides: Partial = {}): AccountFeed { +function makeFeed(overrides: Partial = {}): PublisherFeed { return { - schemaVersion: ACCOUNT_FEED_SCHEMA_VERSION, - feedId: accountFeedId("publisher", "publishers:demo"), - scope: "publisher", - accountId: null, + schemaVersion: PUBLISHER_FEED_SCHEMA_VERSION, + feedId: publisherFeedId("publishers:demo"), publisherId: "publishers:demo", handle: "demo", - displayName: "Demo Publisher", - generatedAt: "2026-07-02T00:00:00.000Z", - sequence: 0, + displayName: "Demo", + generatedAt: "2026-07-16T00:00:00.000Z", + sequence: 1, entries: [ { kind: "skill", id: "skills:demo", - name: "demo-skill", - displayName: "Demo Skill", + name: "demo", + displayName: "Demo", summary: null, - url: "/demo/demo-skill", + url: "/demo/skills/demo", updatedAt: 10, }, ], @@ -33,53 +31,40 @@ function makeFeed(overrides: Partial = {}): AccountFeed { }; } -describe("account feed schema", () => { - it("builds stable account and publisher feed ids", () => { - expect(accountFeedId("account", "users:alice")).toBe("clawhub.account.users:alice"); - expect(accountFeedId("publisher", "publishers:alice")).toBe( - "clawhub.publisher.publishers:alice", - ); - }); - - it("accepts the first account feed contract", () => { - expect(parseAccountFeed(makeFeed()).entries[0]?.kind).toBe("skill"); +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 malformed entries", () => { - expect(() => parseAccountFeed(makeFeed({ schemaVersion: 2 }))).toThrow( - "Unsupported account feed schema version", + it("rejects unsupported versions and mismatched identity", () => { + expect(() => parsePublisherFeed(makeFeed({ schemaVersion: 2 }))).toThrow( + "Unsupported publisher feed schema version", ); - expect(() => - parseAccountFeed( - makeFeed({ - entries: [{ kind: "skill", id: "skills:demo" }] as never, - }), - ), - ).toThrow(); - }); - - it("binds feed identity to its scope", () => { - expect(() => parseAccountFeed(makeFeed({ publisherId: null }))).toThrow( - "publisher feed must include its stable identity", + expect(() => parsePublisherFeed(makeFeed({ publisherId: "" }))).toThrow( + "stable publisher identity", ); - expect(() => parseAccountFeed(makeFeed({ feedId: "clawhub.publisher.other" }))).toThrow( - "feed id does not match", + expect(() => parsePublisherFeed(makeFeed({ feedId: "clawhub.publisher.other" }))).toThrow( + "stable publisher identity", ); }); - it("validates entry timestamps and URL references", () => { + it("rejects invalid ordering and URL fields", () => { const entry = makeFeed().entries[0]!; expect(() => - parseAccountFeed(makeFeed({ entries: [{ ...entry, updatedAt: Number.NaN }] })), + 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(() => - parseAccountFeed(makeFeed({ entries: [{ ...entry, url: "//evil.example/skill" }] })), - ).toThrow("protocol-relative"); - expect(() => - parseAccountFeed(makeFeed({ entries: [{ ...entry, url: "http://example.com/skill" }] })), + parsePublisherFeed(makeFeed({ entries: [{ ...entry, url: "http://example.com/skill" }] })), ).toThrow("absolute HTTPS"); expect( - parseAccountFeed(makeFeed({ entries: [{ ...entry, url: "https://example.com/skill" }] })), - ).toBeDefined(); + 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 index fd8a02ef28..b8c882fa45 100644 --- a/packages/schema/src/accountFeed.ts +++ b/packages/schema/src/accountFeed.ts @@ -1,15 +1,15 @@ import { type inferred, type } from "arktype"; -export const ACCOUNT_FEED_SCHEMA_VERSION = 1; -export const ACCOUNT_FEED_DEFAULT_LIMIT = 50; -export const ACCOUNT_FEED_MAX_LIMIT = 100; +export const PUBLISHER_FEED_SCHEMA_VERSION = 1; +export const PUBLISHER_FEED_DEFAULT_LIMIT = 50; +export const PUBLISHER_FEED_MAX_LIMIT = 100; -export const AccountFeedEntryKindSchema = type('"skill"|"plugin"'); -export type AccountFeedEntryKind = (typeof AccountFeedEntryKindSchema)[inferred]; +export const PublisherFeedEntryKindSchema = type('"skill"|"plugin"'); +export type PublisherFeedEntryKind = (typeof PublisherFeedEntryKindSchema)[inferred]; -export const AccountFeedEntrySchema = type({ +export const PublisherFeedEntrySchema = type({ "+": "reject", - kind: AccountFeedEntryKindSchema, + kind: PublisherFeedEntryKindSchema, id: "string", name: "string", displayName: "string", @@ -17,56 +17,62 @@ export const AccountFeedEntrySchema = type({ url: "string", updatedAt: "number", }); -export type AccountFeedEntry = (typeof AccountFeedEntrySchema)[inferred]; +export type PublisherFeedEntry = (typeof PublisherFeedEntrySchema)[inferred]; -export const AccountFeedSchema = type({ +export const PublisherFeedSchema = type({ "+": "reject", schemaVersion: "number", feedId: "string", - scope: '"account"|"publisher"', - accountId: "string|null", - publisherId: "string|null", + publisherId: "string", handle: "string|null", displayName: "string", generatedAt: "string", sequence: "number", - entries: AccountFeedEntrySchema.array(), + entries: PublisherFeedEntrySchema.array(), nextCursor: "string|null", }); -export type AccountFeed = (typeof AccountFeedSchema)[inferred]; +export type PublisherFeed = (typeof PublisherFeedSchema)[inferred]; -export function accountFeedId(scope: "account" | "publisher", stableId: string) { - return `clawhub.${scope}.${stableId}`; +export function publisherFeedId(publisherId: string) { + return `clawhub.publisher.${publisherId}`; } -export function parseAccountFeed(value: unknown): AccountFeed { - const feed = AccountFeedSchema.assert(value); - if (feed.schemaVersion !== ACCOUNT_FEED_SCHEMA_VERSION) { - throw new Error(`Unsupported account feed schema version: ${feed.schemaVersion}`); +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; } - const stableId = feed.scope === "account" ? feed.accountId : feed.publisherId; - if (!stableId) { - throw new Error(`${feed.scope} feed must include its stable identity`); + 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.feedId !== accountFeedId(feed.scope, stableId)) { - throw new Error("Account feed id does not match its scope and stable identity"); + 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("Account feed sequence must be a non-negative integer"); + throw new Error("Publisher feed sequence must be a non-negative integer"); } if (!Number.isFinite(Date.parse(feed.generatedAt))) { - throw new Error("Account feed generatedAt must be a valid ISO date"); + 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("Account feed entry identity fields must be non-empty"); + throw new Error("Publisher feed entry identity fields must be non-empty"); } if (!Number.isFinite(entry.updatedAt) || entry.updatedAt < 0) { - throw new Error("Account feed entry updatedAt must be a non-negative finite number"); + throw new Error("Publisher feed entry updatedAt must be a non-negative finite number"); } if (entry.url.startsWith("/")) { - if (entry.url.startsWith("//")) { - throw new Error("Account feed entry URL must not be protocol-relative"); + 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; } @@ -74,10 +80,10 @@ export function parseAccountFeed(value: unknown): AccountFeed { try { url = new URL(entry.url); } catch { - throw new Error("Account feed entry URL must be absolute HTTPS or origin-relative"); + throw new Error("Publisher feed entry URL must be absolute HTTPS or origin-relative"); } if (url.protocol !== "https:") { - throw new Error("Account feed entry URL must be absolute HTTPS or origin-relative"); + throw new Error("Publisher feed entry URL must be absolute HTTPS or origin-relative"); } } return feed; diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index c7b8583aaf..28387d88f0 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -30,7 +30,6 @@ export const ApiRoutes = { catalogSkillsFeed: "/api/v1/feeds/skills", catalogClawsFeed: "/api/v1/feeds/claws", promotionsFeed: "/api/v1/feeds/promotions", - accounts: "/api/v1/accounts", stars: "/api/v1/stars", transfers: "/api/v1/transfers", publishers: "/api/v1/publishers", diff --git a/public/api/v1/openapi.json b/public/api/v1/openapi.json index 360d34e699..202e7cc0b2 100644 --- a/public/api/v1/openapi.json +++ b/public/api/v1/openapi.json @@ -1474,46 +1474,7 @@ } } }, - "AccountFeedPublicUser": { - "type": "object", - "additionalProperties": true, - "properties": { - "_id": { - "type": "string" - }, - "handle": { - "type": [ - "string", - "null" - ] - }, - "displayName": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": [ - "string", - "null" - ] - }, - "image": { - "type": [ - "string", - "null" - ] - }, - "bio": { - "type": [ - "string", - "null" - ] - } - } - }, - "AccountFeedPublicPublisher": { + "PublisherFeedPublicPublisher": { "type": "object", "additionalProperties": true, "properties": { @@ -1544,48 +1505,26 @@ "string", "null" ] - }, - "linkedUserId": { - "type": [ - "string", - "null" - ] } } }, - "AccountFeedDetailResponse": { + "PublisherFeedDetailResponse": { "type": "object", "additionalProperties": false, "required": [ + "publisher", "feedUrl" ], "properties": { - "account": { - "anyOf": [ - { - "$ref": "#/components/schemas/AccountFeedPublicUser" - }, - { - "type": "null" - } - ] - }, "publisher": { - "anyOf": [ - { - "$ref": "#/components/schemas/AccountFeedPublicPublisher" - }, - { - "type": "null" - } - ] + "$ref": "#/components/schemas/PublisherFeedPublicPublisher" }, "feedUrl": { "type": "string" } } }, - "AccountFeedEntry": { + "PublisherFeedEntry": { "type": "object", "additionalProperties": false, "required": [ @@ -1628,14 +1567,12 @@ } } }, - "AccountFeed": { + "PublisherFeed": { "type": "object", "additionalProperties": false, "required": [ "schemaVersion", "feedId", - "scope", - "accountId", "publisherId", "handle", "displayName", @@ -1654,24 +1591,8 @@ "feedId": { "type": "string" }, - "scope": { - "type": "string", - "enum": [ - "account", - "publisher" - ] - }, - "accountId": { - "type": [ - "string", - "null" - ] - }, "publisherId": { - "type": [ - "string", - "null" - ] + "type": "string" }, "handle": { "type": [ @@ -1693,7 +1614,7 @@ "entries": { "type": "array", "items": { - "$ref": "#/components/schemas/AccountFeedEntry" + "$ref": "#/components/schemas/PublisherFeedEntry" } }, "nextCursor": { @@ -1701,100 +1622,13 @@ "string", "null" ], - "description": "Reserved for future pagination; null in the first account-feed API slice." + "description": "Opaque continuation cursor; null only when the coherent publisher projection is complete." } } } } }, "paths": { - "/api/v1/accounts/{accountId}": { - "get": { - "summary": "Get account feed identity", - "parameters": [ - { - "name": "accountId", - "in": "path", - "required": true, - "description": "Stable ClawHub account id.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Account feed identity", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AccountFeedDetailResponse" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/PlainTextError" - } - } - } - } - } - } - }, - "/api/v1/accounts/{accountId}/feed": { - "get": { - "summary": "Get account feed", - "parameters": [ - { - "name": "accountId", - "in": "path", - "required": true, - "description": "Stable ClawHub account id.", - "schema": { - "type": "string" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - } - } - ], - "responses": { - "200": { - "description": "Account feed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AccountFeed" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/PlainTextError" - } - } - } - } - } - } - }, "/api/v1/bundle-plugins": { "get": { "summary": "List bundle plugin catalog packages", @@ -3024,7 +2858,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFeedDetailResponse" + "$ref": "#/components/schemas/PublisherFeedDetailResponse" } } } @@ -3065,6 +2899,15 @@ "maximum": 100, "default": 50 } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor bound to one immutable publisher-feed sequence.", + "schema": { + "type": "string" + } } ], "responses": { @@ -3073,7 +2916,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AccountFeed" + "$ref": "#/components/schemas/PublisherFeed" + } + } + } + }, + "400": { + "description": "Malformed or mismatched cursor or limit", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlainTextError" } } } @@ -3087,6 +2940,26 @@ } } } + }, + "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": { + "$ref": "#/components/schemas/PlainTextError" + } + } + } } } } diff --git a/specs/README.md b/specs/README.md index a35427d575..a49540c5b3 100644 --- a/specs/README.md +++ b/specs/README.md @@ -19,7 +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`: account and publisher feed model for OpenClaw discovery. +- `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 index 388b6dcc9d..5ef8fbbd5e 100644 --- a/specs/account-feeds.md +++ b/specs/account-feeds.md @@ -1,193 +1,90 @@ --- -summary: "ClawHub account and publisher feed model for OpenClaw discovery." +summary: "ClawHub publisher feed model for public discovery." read_when: - - Adding account-backed or publisher-backed feed APIs - - Changing ClawHub publisher identity, profile, or feed projection behavior - - Wiring OpenClaw clients to ClawHub account or publisher feeds + - Adding or changing publisher feed APIs + - Changing publisher identity or visibility + - Wiring clients to publisher feeds --- -# Account Feeds - -ClawHub account feeds are stable, ClawHub-authored projections of public account -and publisher activity for OpenClaw discovery. - -They are not a replacement for the hosted catalog feed in -`specs/hosted-catalog-feed.md`. They give OpenClaw clients and ClawHub users a -way to follow a person, organization, or publisher identity and discover that -publisher's public work through a stable machine-readable feed. - -## Model - -ClawHub should treat account identity and publisher identity as related but -separate product facts: - -- An account is the signed-in ClawHub user or organization record. -- A publisher is the public identity that owns packages, skills, and profile - surfaces. -- A publisher may be backed by a personal account or an organization account. -- Feed URLs and feed ids must use stable opaque ids, not mutable display names, - handles, slugs, or profile URLs as authority. -- Display names, handles, avatars, and profile copy are presentation fields and - may change without changing feed identity. - -The first account-feed contract should support both account-scoped and -publisher-scoped feeds until product usage proves one is unnecessary. - -First-slice public endpoints: - -- `GET /api/v1/accounts/{accountId}` -- `GET /api/v1/accounts/{accountId}/feed` -- `GET /api/v1/publishers/{publisherId}` -- `GET /api/v1/publishers/{publisherId}/feed` - -The account and publisher detail endpoints should expose enough public metadata -for clients to display identity, profile links, and follow state. The feed -endpoints should expose ordered public feed entries for discovery. - -The initial implementation is an unsigned, bounded, live projection over active -public accounts, publishers, skills, and packages. It does not add official -state, registry review state, follow state, scan authority, install authority, -or feed signing. Signed ClawHub envelopes and publication cache semantics remain -future trust-stack work. - -## Feed Shape - -Draft feed metadata: - -```json -{ - "schemaVersion": 1, - "feedId": "clawhub.account.", - "scope": "publisher", - "publisherId": "publishers:", - "accountId": "users:", - "displayName": "Example Publisher", - "generatedAt": "2026-07-01T00:00:00.000Z", - "sequence": 0, - "entries": [], - "nextCursor": null -} -``` - -Required stable fields: - -- `schemaVersion`: feed wire version. -- `feedId`: stable feed identity. -- `accountId`: stable account identity when the feed is account-scoped. -- `publisherId`: stable publisher identity when the feed is publisher-scoped. -- `generatedAt`: generation time for this feed body. -- `sequence`: monotonic feed sequence for cache, replay, and rollback checks. -- `entries`: ordered public entries. -- `nextCursor`: reserved for future pagination; `null` in the first API slice. - -Entry `url` values are either absolute HTTPS URLs or origin-relative URL -references resolved against the feed request origin. `updatedAt` values are -finite, non-negative Unix epoch times in milliseconds. Entries are newest -first, with stable kind and object identity tie-breakers for equal timestamps. - -The first API slice rejects a supplied `cursor` with `400` rather than silently -restarting at the first page. It clamps valid positive integer limits to the -server maximum and rejects malformed limits. +# Publisher Feeds -The feed body should not include credentials, private source URLs, bootstrap -trust keys, unpublished package metadata, or reviewer-only moderation details. +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. -## Signing And Cache Boundaries +Publisher feeds do not grant trust, approval, scan success, artifact integrity, +or install authority. Consumers resolve an entry through an accepted catalog +before installation. -Account feed authenticity comes from a ClawHub-authored feed envelope, not from -user-submitted feed contents. +## Routes -The signed material should include: - -- feed id -- schema version -- sequence -- generated time -- previous sequence or previous feed revision when available -- envelope key id -- exact feed payload digest - -Persisted feed bodies are cache material. They become useful for OpenClaw only -after envelope verification and source-profile trust checks in the OpenClaw -client. A cached feed body alone must not grant install eligibility or create a -new trust root. +```text +GET /api/v1/publishers/{publisherId} +GET /api/v1/publishers/{publisherId}/feed?limit=50&cursor= +``` -ClawHub should define pagination or continuation semantics before this becomes -a public API. A popular publisher should not require clients to fetch an -unbounded feed. +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 And Trust Boundaries +## Identity -Account feed support must keep these signals separate: +Feed identity is stable and publisher-only: -- publisher identity -- claimed account state -- verified account state -- ClawHub official publisher state -- OpenClaw registry review state -- local approval state -- scan state -- package artifact integrity -- OpenClaw install eligibility +```text +clawhub.publisher. +``` -Following a feed is a discovery and notification signal only. It must not imply -official status, registry inclusion, local approval, scan success, package -integrity, or install eligibility. +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. -Official publisher state remains governed by `specs/official-publishers.md`. -Uploaded skill or package metadata must not be able to mark a publisher or feed -official. +## Revisions And Pagination -## API Requirements +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. -The public API contract should define: +Pages are slices of that stored revision. The opaque cursor binds: -- stable ids and canonical URLs -- pagination and continuation tokens -- cache validators and max-age behavior -- monotonic sequence behavior -- idempotent client refresh behavior -- error responses for missing, private, suspended, revoked, or stale feeds -- replay and backfill behavior for clients that miss updates -- rate limits for feed reads and follower-triggered refreshes +- publisher id; +- feed sequence; +- next entry offset. -Errors should distinguish "not found", "not public", "temporarily unavailable", -and "publisher suspended or revoked" without exposing private review evidence. +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. -## Skill Author Experience +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. -The first publisher setup path should be short and obvious in ClawHub: +## Entry Shape -1. Sign in. -2. Confirm or create a publisher identity. -3. Publish public work. -4. See the publisher profile and feed URL. +Entries contain only: -Ordinary community publishing must not require official status. Official, -reviewed, scanned, and locally approved states are stronger signals layered on -top of the normal publishing path. +- `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. -## Audit Requirements +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. -ClawHub should record audit events for trust-changing feed operations: - -- feed signing key changes -- feed publication sequence changes -- account-to-publisher link changes -- publisher ownership changes -- visibility changes -- suspension, revocation, and reinstatement -- OpenClaw registry export events +## Follow Boundary -Each event should include actor, time, reason, affected ids, prior state, new -state, and the related feed revision when applicable. +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. -## Open Questions +## Future Signing -- Are account ids and publisher ids already distinct enough in current ClawHub - data, or does this require a schema clarification first? -- Should OpenClaw consume publisher-scoped feeds before account-scoped feeds? -- What is the public/private visibility model for account metadata? -- Should account feed entries include packages and skills from day one, or start - with one content type? -- Which existing profile URLs become the canonical human-readable feed surface? +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 5376074a0e..6861c2d16b 100644 --- a/src/__tests__/openapi-contract.test.ts +++ b/src/__tests__/openapi-contract.test.ts @@ -81,21 +81,23 @@ describe("OpenAPI contract", () => { expect(property(property(handoffSchema, "properties"), "scanStatus")).toBeUndefined(); }); - it("documents account feeds without trust or install authority fields", async () => { + 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")).toBeTruthy(); + expect(property(paths, "/api/v1/accounts/{accountId}/feed")).toBeUndefined(); expect(property(paths, "/api/v1/publishers/{publisherId}/feed")).toBeTruthy(); - const feedSchema = property(schemas, "AccountFeed"); - const entrySchema = property(schemas, "AccountFeedEntry"); + 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(); From bf83f7c0367df9cb5e0fceebc62ba558fb8c1085 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Mon, 20 Jul 2026 14:09:56 -0700 Subject: [PATCH 06/22] fix(feeds): resolve publisher handles in public details --- convex/accountFeeds.test.ts | 22 ++++++++++++++++++++++ convex/accountFeeds.ts | 19 +++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/convex/accountFeeds.test.ts b/convex/accountFeeds.test.ts index 779007544f..64688321b9 100644 --- a/convex/accountFeeds.test.ts +++ b/convex/accountFeeds.test.ts @@ -63,6 +63,28 @@ describe("publisher feed projection", () => { 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"), diff --git a/convex/accountFeeds.ts b/convex/accountFeeds.ts index 8a85cef4bf..c7be05f824 100644 --- a/convex/accountFeeds.ts +++ b/convex/accountFeeds.ts @@ -10,7 +10,7 @@ import type { MutationCtx, QueryCtx } from "./_generated/server"; import { internalMutation, internalQuery } from "./functions"; import { isPublicSkillDoc } from "./lib/globalStats"; import { isPackageBlockedFromPublic } from "./lib/packageSecurity"; -import { getPublicPublisherVisibility } from "./lib/publishers"; +import { getPublicPublisherVisibility, normalizePublisherHandle } from "./lib/publishers"; const PUBLISHER_FEED_MAX_SOURCE_PAGES = 3; const PUBLISHER_FEED_SNAPSHOT_MAX_ENTRIES = 400; @@ -42,6 +42,21 @@ async function safeGetPublisher(ctx: PublisherFeedReadCtx, id: string) { } } +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 { @@ -301,7 +316,7 @@ async function buildPublisherFeed( export const getPublisherDetail = internalQuery({ args: { publisherId: v.string() }, handler: async (ctx, args) => { - const publisher = await safeGetPublisher(ctx, args.publisherId); + const publisher = await safeResolvePublisherDetail(ctx, args.publisherId); const visibility = await getPublicPublisherVisibility(ctx, publisher); if (!visibility) return null; return { From 5b4f9d8e2502c7bc04a3bdd6a5ecdfcef88f869d Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Mon, 20 Jul 2026 16:04:08 -0700 Subject: [PATCH 07/22] test(feeds): cover publisher feed cleanup --- convex/publishers.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) 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) => { From 8f11aca05cacfcef4a759c3d92d9b7cbb702a8f0 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Tue, 28 Jul 2026 14:12:43 -0700 Subject: [PATCH 08/22] fix(feeds): export publisher feed schema types --- packages/schema/dist/index.d.ts | 1 + packages/schema/dist/index.js | 1 + packages/schema/dist/index.js.map | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) 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 From 2172b6badd2b433e8c305de870a2af47b823be28 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Tue, 28 Jul 2026 15:23:26 -0700 Subject: [PATCH 09/22] fix(feeds): avoid multiple paginated queries --- convex/accountFeeds.test.ts | 36 +++------ convex/accountFeeds.ts | 141 +++++++++++++----------------------- 2 files changed, 61 insertions(+), 116 deletions(-) diff --git a/convex/accountFeeds.test.ts b/convex/accountFeeds.test.ts index 64688321b9..ec858b4c4b 100644 --- a/convex/accountFeeds.test.ts +++ b/convex/accountFeeds.test.ts @@ -18,19 +18,11 @@ function doc(id: strin function makeQuery(pages: unknown[] | unknown[][]) { const normalizedPages = Array.isArray(pages[0]) ? (pages as unknown[][]) : [pages as unknown[]]; - let index = 0; + const rows = normalizedPages.flat(); return { withIndex: vi.fn(() => ({ order: vi.fn(() => ({ - paginate: vi.fn(async () => { - const page = normalizedPages[index] ?? []; - index += 1; - return { - page, - isDone: index >= normalizedPages.length, - continueCursor: index >= normalizedPages.length ? null : `cursor-${index}`, - }; - }), + take: vi.fn(async (limit: number) => rows.slice(0, limit)), })), })), }; @@ -355,7 +347,7 @@ describe("publisher feed projection", () => { expect(result.status).toBe("complete"); }); - it("bounds scans when package rows keep filtering out", async () => { + it("fails closed when the bounded source read cannot prove completeness", async () => { const publisher = makePublisher(); const privatePackage = { _id: doc<"packages">("packages:private"), @@ -367,26 +359,16 @@ describe("publisher feed projection", () => { 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], - [privatePackage], - [privatePackage], - [publicPackage], - ]); + 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( diff --git a/convex/accountFeeds.ts b/convex/accountFeeds.ts index c7be05f824..d3be01448d 100644 --- a/convex/accountFeeds.ts +++ b/convex/accountFeeds.ts @@ -12,7 +12,6 @@ import { isPublicSkillDoc } from "./lib/globalStats"; import { isPackageBlockedFromPublic } from "./lib/packageSecurity"; import { getPublicPublisherVisibility, normalizePublisherHandle } from "./lib/publishers"; -const PUBLISHER_FEED_MAX_SOURCE_PAGES = 3; const PUBLISHER_FEED_SNAPSHOT_MAX_ENTRIES = 400; const PUBLISHER_FEED_SUMMARY_MAX_CHARS = 500; type PublisherFeedReadCtx = Pick; @@ -139,30 +138,21 @@ async function collectSkillEntries( limit: number, ): Promise { const entries: PublisherFeedEntry[] = []; - let cursor: string | null = null; - let isDone = false; - let pagesRead = 0; - - while (!isDone && pagesRead < PUBLISHER_FEED_MAX_SOURCE_PAGES && entries.length <= limit) { - const page = await ctx.db - .query("skills") - .withIndex("by_owner_publisher_active_updated", (q) => - q.eq("ownerPublisherId", publisher._id).eq("softDeletedAt", undefined), - ) - .order("desc") - .paginate({ cursor, numItems: limit + 1 }); - pagesRead += 1; - - for (const skill of page.page) { - const entry = skillEntry(publisher, skill); - if (entry) entries.push(entry); - if (entries.length > limit) break; - } - isDone = page.isDone; - cursor = page.isDone ? null : page.continueCursor; + 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: isDone }; + return { entries, exhausted: skills.length <= limit }; } async function collectPackageEntries( @@ -171,30 +161,21 @@ async function collectPackageEntries( limit: number, ): Promise { const entries: PublisherFeedEntry[] = []; - let cursor: string | null = null; - let isDone = false; - let pagesRead = 0; - - while (!isDone && pagesRead < PUBLISHER_FEED_MAX_SOURCE_PAGES && entries.length <= limit) { - const page = await ctx.db - .query("packages") - .withIndex("by_owner_publisher_active_updated", (q) => - q.eq("ownerPublisherId", publisher._id).eq("softDeletedAt", undefined), - ) - .order("desc") - .paginate({ cursor, numItems: limit + 1 }); - pagesRead += 1; - - for (const pkg of page.page) { - const entry = packageEntry(publisher, pkg); - if (entry) entries.push(entry); - if (entries.length > limit) break; - } - isDone = page.isDone; - cursor = page.isDone ? null : page.continueCursor; + 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: isDone }; + return { entries, exhausted: packages.length <= limit }; } async function collectLegacySkillEntries( @@ -204,29 +185,20 @@ async function collectLegacySkillEntries( limit: number, ): Promise { const entries: PublisherFeedEntry[] = []; - let cursor: string | null = null; - let isDone = false; - let pagesRead = 0; - - while (!isDone && pagesRead < PUBLISHER_FEED_MAX_SOURCE_PAGES && entries.length <= limit) { - const page = await ctx.db - .query("skills") - .withIndex("by_owner_active_updated", (q) => - q.eq("ownerUserId", ownerUserId).eq("softDeletedAt", undefined), - ) - .order("desc") - .paginate({ cursor, numItems: limit + 1 }); - pagesRead += 1; - for (const skill of page.page) { - if (skill.ownerPublisherId && skill.ownerPublisherId !== publisher._id) continue; - const entry = skillEntry(publisher, skill); - if (entry) entries.push(entry); - if (entries.length > limit) break; - } - isDone = page.isDone; - cursor = page.isDone ? null : page.continueCursor; + 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: isDone }; + return { entries, exhausted: skills.length <= limit }; } async function collectLegacyPackageEntries( @@ -236,29 +208,20 @@ async function collectLegacyPackageEntries( limit: number, ): Promise { const entries: PublisherFeedEntry[] = []; - let cursor: string | null = null; - let isDone = false; - let pagesRead = 0; - - while (!isDone && pagesRead < PUBLISHER_FEED_MAX_SOURCE_PAGES && entries.length <= limit) { - const page = await ctx.db - .query("packages") - .withIndex("by_owner_active_updated", (q) => - q.eq("ownerUserId", ownerUserId).eq("softDeletedAt", undefined), - ) - .order("desc") - .paginate({ cursor, numItems: limit + 1 }); - pagesRead += 1; - for (const pkg of page.page) { - if (pkg.ownerPublisherId && pkg.ownerPublisherId !== publisher._id) continue; - const entry = packageEntry(publisher, pkg); - if (entry) entries.push(entry); - if (entries.length > limit) break; - } - isDone = page.isDone; - cursor = page.isDone ? null : page.continueCursor; + 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: isDone }; + return { entries, exhausted: packages.length <= limit }; } async function buildPublisherFeed( From 9402c73709c90d830ef57bf9f2410f1d17f160ae Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Wed, 1 Jul 2026 14:10:05 -0700 Subject: [PATCH 10/22] feat: add publisher follow graph API --- convex/_generated/api.d.ts | 4 + convex/http.ts | 21 ++ convex/httpApiV1.handlers.test.ts | 135 +++++++++++ convex/httpApiV1.ts | 11 + convex/httpApiV1/publisherFollowsV1.ts | 127 +++++++++++ convex/lib/retentionPolicy.ts | 1 + convex/publisherFollows.test.ts | 299 +++++++++++++++++++++++++ convex/publisherFollows.ts | 251 +++++++++++++++++++++ convex/schema.ts | 12 + packages/schema/dist/routes.d.ts | 1 + packages/schema/dist/routes.js | 3 +- packages/schema/src/routes.ts | 1 + specs/README.md | 1 + specs/follow-graph-notifications.md | 168 ++++++++++++++ 14 files changed, 1034 insertions(+), 1 deletion(-) create mode 100644 convex/httpApiV1/publisherFollowsV1.ts create mode 100644 convex/publisherFollows.test.ts create mode 100644 convex/publisherFollows.ts create mode 100644 specs/follow-graph-notifications.md 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/http.ts b/convex/http.ts index b8a8ae5d49..88eee5bde2 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -31,6 +31,9 @@ import { packagesGetRouterV1Http, packagesPostRouterV1Http, pluginsGetRouterV1Http, + publisherFollowsDeleteV1Http, + publisherFollowsGetV1Http, + publisherFollowsPostV1Http, createPublisherV1Http, publishersGetRouterV1Http, publishPackageV1Http, @@ -350,6 +353,24 @@ http.route({ 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.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index cecc4c2ec2..aa617a3848 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -9655,6 +9655,141 @@ 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", { + 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", + limit: 10, + }, + ); + }); + + 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 850f29bf5b..265512a968 100644 --- a/convex/httpApiV1.ts +++ b/convex/httpApiV1.ts @@ -28,6 +28,11 @@ import { promotionsGetRouterV1Handler, promotionsPostRouterV1Handler, } from "./httpApiV1/promotionsV1"; +import { + publisherFollowsDeleteV1Handler, + publisherFollowsGetV1Handler, + publisherFollowsPostV1Handler, +} from "./httpApiV1/publisherFollowsV1"; import { createPublisherV1Handler } from "./httpApiV1/publishersV1"; import { skillsShCatalogPublicV1Handler, @@ -74,6 +79,9 @@ 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); @@ -128,6 +136,9 @@ export const __handlers = { verifyDocsSessionV1Handler, createPublisherV1Handler, publishersGetRouterV1Handler, + publisherFollowsGetV1Handler, + publisherFollowsPostV1Handler, + publisherFollowsDeleteV1Handler, contentRightsV1Handler, skillsShCatalogTestV1Handler, skillsShCatalogPublicV1Handler, diff --git a/convex/httpApiV1/publisherFollowsV1.ts b/convex/httpApiV1/publisherFollowsV1.ts new file mode 100644 index 0000000000..63ed049f45 --- /dev/null +++ b/convex/httpApiV1/publisherFollowsV1.ts @@ -0,0 +1,127 @@ +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, + toOptionalNumber, +} 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)); +} + +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 limit = toOptionalNumber(url.searchParams.get("limit")); + const result = await ctx.runQuery( + publisherFollowInternalRefs.publisherFollows.listFollowedPublishersInternal as never, + { followerUserId: auth.userId, ...(limit ? { limit } : {}) } as never, + ); + return json(result, 200, rate.headers); +} + +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/retentionPolicy.ts b/convex/lib/retentionPolicy.ts index c6a2c808a3..118ebc9cf6 100644 --- a/convex/lib/retentionPolicy.ts +++ b/convex/lib/retentionPolicy.ts @@ -239,6 +239,7 @@ export const RETENTION_POLICIES = { 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..434ee5d959 --- /dev/null +++ b/convex/publisherFollows.test.ts @@ -0,0 +1,299 @@ +/* @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 { 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; limit?: number }, + { items: Array<{ publisher: { handle: string }; notifications: "all" | "none" }> } + > +)._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 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: async () => { + const pages = params.listPages ?? [params.listRows ?? []]; + const page = pages[pageIndex] ?? []; + pageIndex += 1; + return { + page, + isDone: pageIndex >= pages.length, + continueCursor: pageIndex >= pages.length ? "" : `cursor:${pageIndex}`, + }; + }, + }), + }; + }, + }; + }); + return { + ctx: { db: { get, insert, patch, delete: deleteDoc, query } }, + db: { get, insert, patch, deleteDoc, 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("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 } = 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" }), + }), + ]); + }); +}); diff --git a/convex/publisherFollows.ts b/convex/publisherFollows.ts new file mode 100644 index 0000000000..d1d13e1505 --- /dev/null +++ b/convex/publisherFollows.ts @@ -0,0 +1,251 @@ +import { v } from "convex/values"; +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 } 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; + +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 notifications = args.notifications ?? DEFAULT_NOTIFICATION_PREFERENCE; + const existing = await getExistingFollow(ctx, args.followerUserId, args.publisherId); + 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 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">; limit?: number }, +) { + const limit = clampListLimit(args.limit); + const items = []; + let cursor: string | null = null; + let isDone = false; + + while (items.length < limit && !isDone) { + const page = await ctx.db + .query("publisherFollows") + .withIndex("by_follower", (q) => q.eq("followerUserId", args.followerUserId)) + .order("desc") + .paginate({ cursor, numItems: LIST_SCAN_BATCH_SIZE }); + + for (const follow of page.page) { + const publisher = await ctx.db.get(follow.publisherId); + if (!publisher || !isPublisherActive(publisher)) 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; + } + + return { ok: true as const, items }; +} + +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: { limit: v.optional(v.number()) }, + handler: async (ctx, args) => { + const { userId } = await requireUser(ctx); + return await listPublisherFollowsForUser(ctx, { followerUserId: userId, limit: args.limit }); + }, +}); + +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"), limit: v.optional(v.number()) }, + handler: async (ctx, args) => await listPublisherFollowsForUser(ctx, args), +}); diff --git a/convex/schema.ts b/convex/schema.ts index 59cd357948..063991a99f 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -2952,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(), @@ -4189,6 +4200,7 @@ export default defineSchema({ publisherFeedPublications, stars, promotions, + publisherFollows, auditLogs, systemSettings, skillsShCatalogControls, 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..f2dd0f2bc9 100644 --- a/packages/schema/dist/routes.js +++ b/packages/schema/dist/routes.js @@ -32,9 +32,10 @@ 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", skillsExport: "/api/v1/skills/export", }; -//# sourceMappingURL=routes.js.map \ No newline at end of file +//# sourceMappingURL=routes.js.map 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/specs/README.md b/specs/README.md index a49540c5b3..19eac4aaa5 100644 --- a/specs/README.md +++ b/specs/README.md @@ -20,6 +20,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). +- `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/follow-graph-notifications.md b/specs/follow-graph-notifications.md new file mode 100644 index 0000000000..2440226cd1 --- /dev/null +++ b/specs/follow-graph-notifications.md @@ -0,0 +1,168 @@ +--- +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. + +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. + +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? From 0e4af4cfd5eab0c96703d5e1fc29256c5ccdb060 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Wed, 15 Jul 2026 08:34:08 -0700 Subject: [PATCH 11/22] fix: bound private publisher follow reads --- convex/httpApiV1.handlers.test.ts | 29 +++++ convex/httpApiV1/publisherFollowsV1.ts | 56 ++++++--- convex/publisherFollows.test.ts | 152 ++++++++++++++++++++++--- convex/publisherFollows.ts | 45 ++++++-- specs/follow-graph-notifications.md | 5 + 5 files changed, 251 insertions(+), 36 deletions(-) diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index aa617a3848..9aac71fca2 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -9755,6 +9755,35 @@ describe("httpApiV1 handlers", () => { ); }); + 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 delete is idempotent", async () => { vi.mocked(requireApiTokenUser).mockResolvedValue({ userId: "users:1", diff --git a/convex/httpApiV1/publisherFollowsV1.ts b/convex/httpApiV1/publisherFollowsV1.ts index 63ed049f45..745f6ddb20 100644 --- a/convex/httpApiV1/publisherFollowsV1.ts +++ b/convex/httpApiV1/publisherFollowsV1.ts @@ -2,13 +2,7 @@ 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, - toOptionalNumber, -} from "./shared"; +import { json, parseJsonPayload, requireApiTokenUserOrResponse, text } from "./shared"; const publisherFollowInternalRefs = internal as unknown as { publisherFollows: { @@ -40,6 +34,34 @@ function isJsonObject(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } +function parseListParams(url: URL, headers: HeadersInit) { + 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; @@ -48,12 +70,20 @@ export async function publisherFollowsGetV1Handler(ctx: ActionCtx, request: Requ if (!auth.ok) return auth.response; const url = new URL(request.url); - const limit = toOptionalNumber(url.searchParams.get("limit")); - const result = await ctx.runQuery( - publisherFollowInternalRefs.publisherFollows.listFollowedPublishersInternal as never, - { followerUserId: auth.userId, ...(limit ? { limit } : {}) } as never, - ); - return json(result, 200, rate.headers); + 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) { diff --git a/convex/publisherFollows.test.ts b/convex/publisherFollows.test.ts index 434ee5d959..295e430fb8 100644 --- a/convex/publisherFollows.test.ts +++ b/convex/publisherFollows.test.ts @@ -35,8 +35,12 @@ const unfollowPublisherInternalHandler = ( )._handler; const listFollowedPublishersInternalHandler = ( listFollowedPublishersInternal as unknown as WrappedHandler< - { followerUserId: string; limit?: number }, - { items: Array<{ publisher: { handle: string }; notifications: "all" | "none" }> } + { followerUserId: string; cursor?: string | null; limit?: number; query?: string }, + { + items: Array<{ publisher: { handle: string }; notifications: "all" | "none" }>; + continueCursor: string; + isDone: boolean; + } > )._handler; @@ -71,6 +75,16 @@ function makeCtx(params: { 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 { @@ -82,16 +96,7 @@ function makeCtx(params: { unique: async () => params.existingFollow ?? null, order: () => ({ take: async () => params.listRows ?? [], - paginate: async () => { - const pages = params.listPages ?? [params.listRows ?? []]; - const page = pages[pageIndex] ?? []; - pageIndex += 1; - return { - page, - isDone: pageIndex >= pages.length, - continueCursor: pageIndex >= pages.length ? "" : `cursor:${pageIndex}`, - }; - }, + paginate, }), }; }, @@ -99,7 +104,7 @@ function makeCtx(params: { }); return { ctx: { db: { get, insert, patch, delete: deleteDoc, query } }, - db: { get, insert, patch, deleteDoc, query }, + db: { get, insert, patch, deleteDoc, paginate, query }, }; } @@ -163,6 +168,26 @@ describe("publisher follows", () => { 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("unfollow is idempotent and audits only real deletes", async () => { const missing = makeCtx({ existingFollow: null }); await expect( @@ -259,7 +284,7 @@ describe("publisher follows", () => { }); it("continues scanning until active follows fill the requested list", async () => { - const { ctx } = makeCtx({ + const { ctx, db } = makeCtx({ listPages: [ [ { @@ -295,5 +320,104 @@ describe("publisher follows", () => { 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); }); }); diff --git a/convex/publisherFollows.ts b/convex/publisherFollows.ts index d1d13e1505..ba43a50cad 100644 --- a/convex/publisherFollows.ts +++ b/convex/publisherFollows.ts @@ -10,6 +10,7 @@ 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; type NotificationPreference = "all" | "none"; @@ -66,8 +67,9 @@ async function followPublisherForUser( }, ) { const publisher = await requireActivePublisher(ctx, args.publisherId); - const notifications = args.notifications ?? DEFAULT_NOTIFICATION_PREFERENCE; const existing = await getExistingFollow(ctx, args.followerUserId, args.publisherId); + const notifications = + args.notifications ?? existing?.notifications ?? DEFAULT_NOTIFICATION_PREFERENCE; const now = Date.now(); if (existing) { @@ -149,23 +151,33 @@ async function unfollowPublisherForUser( async function listPublisherFollowsForUser( ctx: QueryCtx, - args: { followerUserId: Id<"users">; limit?: number }, + 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: string | null = null; + let cursor = args.cursor ?? null; let isDone = false; + let scannedPages = 0; - while (items.length < limit && !isDone) { + 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: LIST_SCAN_BATCH_SIZE }); + .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: { @@ -181,9 +193,10 @@ async function listPublisherFollowsForUser( cursor = page.continueCursor; isDone = page.isDone; + scannedPages += 1; } - return { ok: true as const, items }; + return { ok: true as const, items, continueCursor: isDone ? "" : cursor, isDone }; } export const isFollowingPublisher = query({ @@ -224,10 +237,19 @@ export const unfollowPublisher = mutation({ }); export const listFollowedPublishers = query({ - args: { limit: v.optional(v.number()) }, + 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, limit: args.limit }); + return await listPublisherFollowsForUser(ctx, { + followerUserId: userId, + cursor: args.cursor, + limit: args.limit, + query: args.query, + }); }, }); @@ -246,6 +268,11 @@ export const unfollowPublisherInternal = internalMutation({ }); export const listFollowedPublishersInternal = internalQuery({ - args: { followerUserId: v.id("users"), limit: v.optional(v.number()) }, + 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), }); diff --git a/specs/follow-graph-notifications.md b/specs/follow-graph-notifications.md index 2440226cd1..d87ae103e8 100644 --- a/specs/follow-graph-notifications.md +++ b/specs/follow-graph-notifications.md @@ -49,6 +49,11 @@ 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 From 000b386b5ed7c7166f7b2caec3bb61924143e57e Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Wed, 15 Jul 2026 08:38:30 -0700 Subject: [PATCH 12/22] test: cover publisher follow list cursors --- convex/httpApiV1.handlers.test.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index 9aac71fca2..3eae62ffd8 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -9735,7 +9735,7 @@ describe("httpApiV1 handlers", () => { const response = await __handlers.publisherFollowsGetV1Handler( makeCtx({ runQuery, runMutation }), - new Request("https://example.com/api/v1/publisher-follows?limit=10", { + new Request("https://example.com/api/v1/publisher-follows?limit=10&cursor=older&q=open", { method: "GET", headers: { Authorization: "Bearer clh_test" }, }), @@ -9750,7 +9750,9 @@ describe("httpApiV1 handlers", () => { internal.publisherFollows.listFollowedPublishersInternal, { followerUserId: "users:1", + cursor: "older", limit: 10, + query: "open", }, ); }); @@ -9784,6 +9786,25 @@ describe("httpApiV1 handlers", () => { 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", From 87e397469fd9dc214a4abc1948a75cb8b8bae5cc Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Wed, 15 Jul 2026 08:53:17 -0700 Subject: [PATCH 13/22] fix: type publisher follow list parameters --- convex/httpApiV1/publisherFollowsV1.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/convex/httpApiV1/publisherFollowsV1.ts b/convex/httpApiV1/publisherFollowsV1.ts index 745f6ddb20..995958caa1 100644 --- a/convex/httpApiV1/publisherFollowsV1.ts +++ b/convex/httpApiV1/publisherFollowsV1.ts @@ -34,7 +34,11 @@ function isJsonObject(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } -function parseListParams(url: URL, headers: HeadersInit) { +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; From 86a906f8d0427aa76c8064b02c1aea6597253b36 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Thu, 16 Jul 2026 07:59:52 -0700 Subject: [PATCH 14/22] docs: specify publisher follow HTTP contract --- docs/http-api.md | 29 +++++++++++++++++++++++++++++ packages/schema/dist/routes.js | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/http-api.md b/docs/http-api.md index 34b0464a4a..f17ed6dccb 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -95,6 +95,35 @@ 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. + +### `GET /api/v1/publishers/{publisherId}/followers` + +Returns public publisher identities that follow the publisher. The underlying +user ids are never exposed. Supports `limit` and opaque `cursor` pagination. + +### `GET /api/v1/publishers/{publisherId}/following` + +Returns public publishers followed by a personal publisher. Organization +publishers currently return an empty list because follows are user-owned. +Supports `limit` and opaque `cursor` pagination. + +### `/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, and users cannot follow their own personal publisher. + ### `GET /api/v1/search` Query params: diff --git a/packages/schema/dist/routes.js b/packages/schema/dist/routes.js index f2dd0f2bc9..37def056d3 100644 --- a/packages/schema/dist/routes.js +++ b/packages/schema/dist/routes.js @@ -38,4 +38,4 @@ export const ApiRoutes = { whoami: "/api/v1/whoami", skillsExport: "/api/v1/skills/export", }; -//# sourceMappingURL=routes.js.map +//# sourceMappingURL=routes.js.map \ No newline at end of file From 4577e3056b11d8b7b894c9a9ddf91d83a39d6185 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Thu, 16 Jul 2026 08:19:26 -0700 Subject: [PATCH 15/22] test: cover publisher follow deletion cleanup --- convex/publishers.test.ts | 18 +++++++++++++++--- convex/users.test.ts | 2 +- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/convex/publishers.test.ts b/convex/publishers.test.ts index d6849f056d..c1220b0b26 100644 --- a/convex/publishers.test.ts +++ b/convex/publishers.test.ts @@ -1076,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: { @@ -1101,6 +1102,9 @@ describe("publishers membership controls", () => { if (table === "officialPublishers") { return emptyOfficialPublishersQuery(); } + if (table === "publisherFeedPublications") { + return emptyPublisherFeedPublicationQuery(); + } if (table === "publisherInvites") { return emptyPublisherInvitesQuery(); } @@ -1158,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({ @@ -1341,6 +1348,7 @@ describe("publishers membership controls", () => { }); return { ctx: { + runMutation: vi.fn(async () => ({ deleted: 0, scheduled: false })), scheduler: { runAfter: vi.fn() }, db: { get: vi.fn(async (id: string) => { @@ -1468,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", @@ -1506,6 +1515,9 @@ describe("publishers membership controls", () => { if (table === "officialPublishers") { return emptyOfficialPublishersQuery(); } + if (table === "publisherFeedPublications") { + return emptyPublisherFeedPublicationQuery(); + } if (table === "publisherInvites") { return emptyPublisherInvitesQuery(); } 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", From 4ad5a1bf118bc50e577ca6cacef96f813ac5b42f Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Thu, 16 Jul 2026 09:07:13 -0700 Subject: [PATCH 16/22] fix: bound followed publisher sets --- convex/publisherFollows.test.ts | 19 +++++++++++++++++++ convex/publisherFollows.ts | 11 +++++++++++ docs/http-api.md | 3 ++- specs/follow-graph-notifications.md | 5 +++++ 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/convex/publisherFollows.test.ts b/convex/publisherFollows.test.ts index 295e430fb8..f6ce9506bf 100644 --- a/convex/publisherFollows.test.ts +++ b/convex/publisherFollows.test.ts @@ -188,6 +188,25 @@ describe("publisher follows", () => { 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( diff --git a/convex/publisherFollows.ts b/convex/publisherFollows.ts index ba43a50cad..62f87476c5 100644 --- a/convex/publisherFollows.ts +++ b/convex/publisherFollows.ts @@ -11,6 +11,8 @@ 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; +export const MAX_FOLLOWED_PUBLISHERS = 100; type NotificationPreference = "all" | "none"; @@ -80,6 +82,15 @@ async function followPublisherForUser( return toFollowResult(existing); } + const followed = await ctx.db + .query("publisherFollows") + .withIndex("by_follower_and_updatedAt", (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, diff --git a/docs/http-api.md b/docs/http-api.md index f17ed6dccb..d1d43586ca 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -122,7 +122,8 @@ Supports `limit` and opaque `cursor` pagination. 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, and users cannot follow their own personal publisher. +idempotent, users cannot follow their own personal publisher, and each user may +follow up to 100 publishers. ### `GET /api/v1/search` diff --git a/specs/follow-graph-notifications.md b/specs/follow-graph-notifications.md index d87ae103e8..3d6ce3d9b5 100644 --- a/specs/follow-graph-notifications.md +++ b/specs/follow-graph-notifications.md @@ -35,6 +35,11 @@ through stable publisher ids. 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 From d81cfbf48bbdd030f356e30929caad2160ce77f5 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Thu, 16 Jul 2026 09:10:58 -0700 Subject: [PATCH 17/22] refactor: share publisher follow limit --- convex/lib/publishers.ts | 1 + convex/publisherFollows.ts | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/convex/lib/publishers.ts b/convex/lib/publishers.ts index 0d5a779f0a..610f1f7dc4 100644 --- a/convex/lib/publishers.ts +++ b/convex/lib/publishers.ts @@ -649,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/publisherFollows.ts b/convex/publisherFollows.ts index 62f87476c5..26ba17421a 100644 --- a/convex/publisherFollows.ts +++ b/convex/publisherFollows.ts @@ -3,7 +3,7 @@ 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 } from "./lib/publishers"; +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; @@ -12,7 +12,6 @@ const MAX_LIST_LIMIT = 100; const LIST_SCAN_BATCH_SIZE = 100; const MAX_LIST_SCAN_PAGES = 4; const DELETE_BATCH_SIZE = 200; -export const MAX_FOLLOWED_PUBLISHERS = 100; type NotificationPreference = "all" | "none"; From 1a9c1abdcd41c9b3906d768221c54e17dee9d5c6 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Tue, 28 Jul 2026 13:24:38 -0700 Subject: [PATCH 18/22] refactor: keep publisher follows private --- docs/http-api.md | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/docs/http-api.md b/docs/http-api.md index d1d43586ca..86628098c3 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -106,24 +106,14 @@ 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. -### `GET /api/v1/publishers/{publisherId}/followers` - -Returns public publisher identities that follow the publisher. The underlying -user ids are never exposed. Supports `limit` and opaque `cursor` pagination. - -### `GET /api/v1/publishers/{publisherId}/following` - -Returns public publishers followed by a personal publisher. Organization -publishers currently return an empty list because follows are user-owned. -Supports `limit` and opaque `cursor` pagination. - ### `/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 up to 100 publishers. Follow state is private; this API does not expose +public follower or following lists. ### `GET /api/v1/search` From 1d96551b15ad668b195c519c4d3a7339c684b9a9 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Tue, 28 Jul 2026 13:27:41 -0700 Subject: [PATCH 19/22] fix: clean up private publisher follows --- convex/publisherFollows.test.ts | 48 ++++++++++++++++++++++++++++-- convex/publisherFollows.ts | 52 ++++++++++++++++++++++++++++++++- convex/publishers.ts | 6 ++++ convex/users.ts | 9 ++++++ 4 files changed, 112 insertions(+), 3 deletions(-) diff --git a/convex/publisherFollows.test.ts b/convex/publisherFollows.test.ts index f6ce9506bf..4d14e47a7d 100644 --- a/convex/publisherFollows.test.ts +++ b/convex/publisherFollows.test.ts @@ -14,8 +14,13 @@ vi.mock("./functions", () => ({ query: (def: { handler: unknown }) => ({ _handler: def.handler }), })); -const { followPublisherInternal, listFollowedPublishersInternal, unfollowPublisherInternal } = - await import("./publisherFollows"); +const { + deletePublisherFollowsForFollowerInternal, + deletePublisherFollowsForPublisherInternal, + followPublisherInternal, + listFollowedPublishersInternal, + unfollowPublisherInternal, +} = await import("./publisherFollows"); type WrappedHandler = { _handler: (ctx: unknown, args: TArgs) => Promise; @@ -43,6 +48,18 @@ const listFollowedPublishersInternalHandler = ( } > )._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 { @@ -439,4 +456,31 @@ describe("publisher follows", () => { }); expect(db.paginate).toHaveBeenCalledTimes(4); }); + + it.each([ + ["follower", deletePublisherFollowsForFollowerInternalHandler, { followerUserId: "users:1" }], + ["publisher", deletePublisherFollowsForPublisherInternalHandler, { publisherId: "publishers:1" }], + ] as const)("deletes %s follow edges in resumable batches", async (_kind, handler, 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 result = await handler( + { db: { query, delete: deleteDoc }, scheduler: { runAfter } }, + args, + ); + + 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 index 26ba17421a..62b5458f43 100644 --- a/convex/publisherFollows.ts +++ b/convex/publisherFollows.ts @@ -1,4 +1,5 @@ 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"; @@ -83,7 +84,7 @@ async function followPublisherForUser( const followed = await ctx.db .query("publisherFollows") - .withIndex("by_follower_and_updatedAt", (q) => q.eq("followerUserId", args.followerUserId)) + .withIndex("by_follower", (q) => q.eq("followerUserId", args.followerUserId)) .order("desc") .take(MAX_FOLLOWED_PUBLISHERS); if (followed.length >= MAX_FOLLOWED_PUBLISHERS) { @@ -286,3 +287,52 @@ export const listFollowedPublishersInternal = internalQuery({ }, 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.ts b/convex/publishers.ts index c1cdd3a962..423f9c736a 100644 --- a/convex/publishers.ts +++ b/convex/publishers.ts @@ -1986,6 +1986,10 @@ async function inspectPublisherHardDeleteRows(ctx: MutationCtx, publisherId: Id< 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 @@ -2014,6 +2018,8 @@ async function hardDeletePublisherRows(ctx: MutationCtx, publisherId: Id<"publis invites: preview.invites.length, official: Boolean(preview.official), feedPublication: Boolean(preview.feedPublication), + publisherFollows: deletedFollows.deleted, + publisherFollowsCleanupScheduled: deletedFollows.scheduled, }; } 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, }; } From 6f69454917289436488682682f2b4d5d2c81072d Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Tue, 28 Jul 2026 14:12:55 -0700 Subject: [PATCH 20/22] style: format publisher follow tests --- convex/publisherFollows.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/convex/publisherFollows.test.ts b/convex/publisherFollows.test.ts index 4d14e47a7d..57f1086a0d 100644 --- a/convex/publisherFollows.test.ts +++ b/convex/publisherFollows.test.ts @@ -459,7 +459,11 @@ describe("publisher follows", () => { it.each([ ["follower", deletePublisherFollowsForFollowerInternalHandler, { followerUserId: "users:1" }], - ["publisher", deletePublisherFollowsForPublisherInternalHandler, { publisherId: "publishers:1" }], + [ + "publisher", + deletePublisherFollowsForPublisherInternalHandler, + { publisherId: "publishers:1" }, + ], ] as const)("deletes %s follow edges in resumable batches", async (_kind, handler, args) => { const deleteDoc = vi.fn(); const runAfter = vi.fn(); From 86849e9a345a6ba58897392163b7e29cfd84b20f Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Tue, 28 Jul 2026 14:22:53 -0700 Subject: [PATCH 21/22] test: reuse publisher feed cleanup query helper --- convex/publishers.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/convex/publishers.test.ts b/convex/publishers.test.ts index c1220b0b26..3187d2b090 100644 --- a/convex/publishers.test.ts +++ b/convex/publishers.test.ts @@ -1103,7 +1103,7 @@ describe("publishers membership controls", () => { return emptyOfficialPublishersQuery(); } if (table === "publisherFeedPublications") { - return emptyPublisherFeedPublicationQuery(); + return emptyPublisherFeedPublicationsQuery(); } if (table === "publisherInvites") { return emptyPublisherInvitesQuery(); @@ -1516,7 +1516,7 @@ describe("publishers membership controls", () => { return emptyOfficialPublishersQuery(); } if (table === "publisherFeedPublications") { - return emptyPublisherFeedPublicationQuery(); + return emptyPublisherFeedPublicationsQuery(); } if (table === "publisherInvites") { return emptyPublisherInvitesQuery(); From b769649968977daf8e9efd19cb39c6795b532f2d Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Tue, 28 Jul 2026 15:45:40 -0700 Subject: [PATCH 22/22] test(feeds): narrow follow cleanup cases --- convex/publisherFollows.test.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/convex/publisherFollows.test.ts b/convex/publisherFollows.test.ts index 57f1086a0d..1e4a1fe4cb 100644 --- a/convex/publisherFollows.test.ts +++ b/convex/publisherFollows.test.ts @@ -458,13 +458,9 @@ describe("publisher follows", () => { }); it.each([ - ["follower", deletePublisherFollowsForFollowerInternalHandler, { followerUserId: "users:1" }], - [ - "publisher", - deletePublisherFollowsForPublisherInternalHandler, - { publisherId: "publishers:1" }, - ], - ] as const)("deletes %s follow edges in resumable batches", async (_kind, handler, args) => { + ["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 () => ({ @@ -474,10 +470,15 @@ describe("publisher follows", () => { })); const query = vi.fn(() => ({ withIndex: () => ({ paginate }) })); - const result = await handler( - { db: { query, delete: deleteDoc }, scheduler: { runAfter } }, - args, - ); + 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);