diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index f5e158b5bb..17d93e412f 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -150,6 +150,38 @@ jobs:
node ./wrangler-env.ts d1 migrations apply APP_DB --remote --config
"$WRANGLER_CONFIG"
+ - name: 🪪 Materialize stable user IDs
+ shell: bash
+ env:
+ DEPLOY_URL: ${{ vars.APP_BASE_URL }}
+ CAPABILITY_REINDEX_SECRET: ${{ secrets.CAPABILITY_REINDEX_SECRET }}
+ run: |
+ set -euo pipefail
+ if [ -z "${DEPLOY_URL:-}" ]; then
+ echo "APP_BASE_URL is required for the stable user id migration." >&2
+ exit 1
+ fi
+ if [ -z "${CAPABILITY_REINDEX_SECRET:-}" ]; then
+ echo "CAPABILITY_REINDEX_SECRET is required for the stable user id migration." >&2
+ exit 1
+ fi
+
+ migration_url="${DEPLOY_URL%/}/__maintenance/backfill-stable-user-ids"
+ status="$(curl --silent --show-error --location --max-time 180 \
+ -X POST \
+ -H "Authorization: Bearer ${CAPABILITY_REINDEX_SECRET}" \
+ -H "Accept: application/json" \
+ --output stable-user-id-migration.json \
+ --write-out "%{http_code}" \
+ "$migration_url")"
+ cat stable-user-id-migration.json
+ echo
+ if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
+ echo "Stable user id migration failed with HTTP ${status}." >&2
+ exit 1
+ fi
+ node -e "const fs = require('node:fs'); const result = JSON.parse(fs.readFileSync('stable-user-id-migration.json', 'utf8')); if (result.ok !== true || result.failed !== 0) { console.error('stable-user-id-migration-incomplete', result); process.exit(1); } console.log('stable-user-id-migration-complete', result);"
+
- name: ☁️ Deploy to Cloudflare Workers
id: deploy
env:
diff --git a/docs/contributing/adding-capabilities.md b/docs/contributing/adding-capabilities.md
index 1d930076bc..3c4bb6a5d8 100644
--- a/docs/contributing/adding-capabilities.md
+++ b/docs/contributing/adding-capabilities.md
@@ -176,20 +176,21 @@ metadata or session-scoped data.
### Admin domain
-The `admin` domain is for MCP-accessible account administration and the narrow
-platform-feedback reviewer surface. Capabilities in this domain must set
+The `admin` domain is for MCP-accessible account administration and narrow
+operator review/metadata surfaces. Capabilities in this domain must set
`requiredRole: 'admin'` and must preserve the RBAC privacy boundary from
[Authorization](./architecture/authorization.md). Admin access is limited to
-user/role account metadata, sanitized audit metadata, and feedback that a user
-explicitly approved for admin review.
-
-Within the built-in `admin` MCP domain, platform feedback is the only capability
-surface that reviews user-authored content. Its list capability returns triage
-summaries without full submission details, while its get capability returns only
-the approved submission. Admin feedback capabilities must not join or expose
-unrelated account content. All other admin capabilities must never return or
-join against user content tables such as packages, secrets, values, memories,
-jobs, email, chat threads, storage buckets, OAuth grants, or remote connectors.
+user/role account metadata, sanitized audit metadata, feedback that a user
+explicitly approved for admin review, operator-owned system email, and the
+documented metadata projection for activity on public community listings.
+
+Platform feedback list/get is the only admin capability surface that reviews
+user-authored private text, and only after explicit approval. Community activity
+returns public-listing metadata, acting username, timestamps, and rating scores;
+it omits rating notes and private forked package content. Admin capabilities
+must not join or expose unrelated account content such as packages, secrets,
+values, memories, jobs, user email, chat threads, storage buckets, OAuth grants,
+or remote connectors.
The `summary` field returned by feedback list/get operations and the `details`
field returned by the get operation are untrusted user-authored content. Admin
@@ -205,12 +206,16 @@ Current admin capabilities:
- `admin_user_create`
- `admin_user_update`
- `admin_audit_log_query`
+- `admin_user_usage`
- `admin_feature_flag_list`
- `admin_feature_flag_set`
- `admin_feature_flag_override`
+- `admin_system_email_list`
+- `admin_system_email_get`
- `admin_platform_feedback_list`
- `admin_platform_feedback_get`
- `admin_platform_feedback_update`
+- `admin_community_activity_list`
When adding more admin actions, expose service-layer functions by adding new
`admin/*` capability files that call those service functions directly, set
diff --git a/docs/contributing/architecture/authorization.md b/docs/contributing/architecture/authorization.md
index 3c72e51e7b..85dec9d26b 100644
--- a/docs/contributing/architecture/authorization.md
+++ b/docs/contributing/architecture/authorization.md
@@ -14,6 +14,11 @@ crosses into the admin review surface only after the user explicitly approves
it. The exception covers that attributed submission and its triage state, never
unrelated user content.
+Community activity metadata is a fourth narrow exception. Admins may see who
+forked or rated a deliberately public community listing and when, plus rating
+scores. This boundary never exposes forked package source, rating notes,
+secrets, or unrelated account content.
+
For browser and MCP authentication mechanics, see
[Authentication](./authentication.md).
@@ -320,6 +325,28 @@ deletion, and attributed community reports can be reviewed and resolved. Those
community surfaces expose content users chose to publish or report; they do not
grant access to private package source or unrelated account content.
+**Admins can inspect activity metadata for public community listings.** The
+`admin_community_activity_list` capability returns a paginated, newest-first
+feed of fork and rating rows with listing id/name/kody id, acting username,
+timestamp, and rating scores. Existing storage does not distinguish a one-click
+install from an ordinary fork, so both appear as `fork`. The capability omits
+stable user ids, forked package/source ids, origin commits, target kody ids,
+rating notes, private package source, and all unrelated account content. Fork
+rows snapshot the public listing name and kody id so intentional listing
+deletion does not erase retained fork provenance; legacy orphan rows whose
+listing identity can no longer be recovered use explicit deleted/unknown
+placeholders.
+
+New fork and rating writes enqueue an opaque activity id for durable
+`community.activity.recorded` package-subscription delivery. The Queue consumer
+reloads only the same metadata projection and fans out only to packages whose
+owners hold the admin role at processing time. A non-admin may declare the topic
+but never receives it, and role revocation applies on the next attempt. Each
+write gets a distinct event id for package-invocation idempotency. Deleted
+activity is acknowledged as a permanent cancellation; transient lookup,
+discovery, and package-invocation infrastructure failures retry and can reach
+the dedicated DLQ.
+
**Admins can see** operator-owned system mail for reserved platform addresses
(`kody`, `support`, `abuse`, `postmaster`, `security`, and `admin`). That mail
is stored under `system:email` as platform content, not under Kent's or any
@@ -346,13 +373,20 @@ This boundary is enforced structurally:
boundary. Admin list reads use a summary projection that omits full details;
get and triage operations address only the selected approved submission. They
never join unrelated user-content tables.
-4. **A shape test pins the admin users API payload.**
+4. **Community activity has a dedicated role-gated metadata projection.**
+ `admin_community_activity_list` unions only `community_forks` and
+ `community_ratings`, uses snapshotted/public listing identity plus acting
+ username, and projects an explicit field allowlist. It never selects package
+ source, rating notes, email, stable user ids, or unrelated user-content
+ tables. Delivery reuses the same projection through fresh admin-owner
+ fan-out.
+5. **A shape test pins the admin users API payload.**
`adminUserListItemFieldNames` in `admin-users.ts` defines the allowed fields
(`id`, `username`, `email`, `email_verified`, `email_verified_at`, `plan`,
`created_at`, `updated_at`, `roles`). The unit test in
`admin-users.node.test.ts` asserts every user object in the list response has
exactly those keys — an accidental widening fails `npm run validate`.
-5. **Existing owner-only paths take no admin bypass.** Secret reveal remains
+6. **Existing owner-only paths take no admin bypass.** Secret reveal remains
session-authenticated and owner-only.
Platform feedback remains user-owned for account lifecycle operations. Account
diff --git a/docs/contributing/architecture/data-storage.md b/docs/contributing/architecture/data-storage.md
index e5a439a003..ae9a71cb15 100644
--- a/docs/contributing/architecture/data-storage.md
+++ b/docs/contributing/architecture/data-storage.md
@@ -30,6 +30,16 @@ explicitly approved enters that role-gated admin surface. The stored submitter
id makes feedback attributed rather than anonymous; the exception never grants
admins access to unrelated account data.
+Community forks and ratings remain user-owned rows for deletion and export, with
+a narrow role-gated admin metadata projection. It joins those rows only to
+public listing identity and the actor's username. Fork rows snapshot the public
+listing name and kody id so retained provenance remains intelligible after a
+listing is deleted. The projection never reads the forked Artifacts source, the
+public snapshot file tree, rating notes, or unrelated account data. Actor
+usernames resolve through the materialized stable-id index; email and stable
+user ids remain absent from activity results and events. One-click installs and
+ordinary forks share the same row shape and therefore appear as `fork`.
+
## Account deletion inventory
Account deletion is implemented in `packages/worker/src/app/account-deletion.ts`
diff --git a/docs/contributing/architecture/primitives.yaml b/docs/contributing/architecture/primitives.yaml
index b557da6e85..6fcee815fe 100644
--- a/docs/contributing/architecture/primitives.yaml
+++ b/docs/contributing/architecture/primitives.yaml
@@ -455,6 +455,21 @@ primitives:
- docs/contributing/architecture/authorization.md
- docs/guides/package-subscriptions.md
+ - id: community-activity-dispatch-queue
+ group: storage
+ name: Community activity dispatch queue
+ summary:
+ Durable opaque event delivery from persisted community activity to admin
+ packages.
+ code:
+ - packages/worker/src/community/activity-dispatch-queue
+ - packages/worker/src/community/activity-package-subscriptions
+ - packages/worker/src/community/activity-subscription-event
+ docs:
+ - docs/contributing/setup-manifest.md
+ - docs/contributing/architecture/authorization.md
+ - docs/guides/package-subscriptions.md
+
- id: community-assets-r2
group: storage
name: Community asset storage (R2)
diff --git a/docs/contributing/community-packages.md b/docs/contributing/community-packages.md
index f19c7f68e3..077e4a00f8 100644
--- a/docs/contributing/community-packages.md
+++ b/docs/contributing/community-packages.md
@@ -158,6 +158,7 @@ Core logic: `packages/worker/src/community/`
| `social-repo.ts` | D1 for profiles, follows, stars, activity, public package lists |
| `install.ts` | One-click install: fork + publish checks + projection publish |
| `repo.ts` | D1 queries |
+| `activity-*` | Admin activity feed and durable admin subscription dispatch |
| `snapshot.ts` | KV snapshot I/O |
| `fork-scan.ts` | Manifest rewrite + cross-scope `kody:@…` / `kody.dependencies` scan |
| `og-image.ts` | Community listing 1200×630 PNG on the shared `#worker/og` pipeline |
@@ -175,6 +176,19 @@ id to the forker's scope, scans cross-scope references, calls
ratings from the listing owner. `reportCommunityListing` stores denormalized
listing metadata for the admin queue.
+`listCommunityActivityForAdmin` exposes a role-gated metadata projection over
+forks and ratings. Rows contain public listing identity, acting username,
+timestamp, and rating scores; they omit rating notes, forked source/package ids,
+stable user ids, and package source. Rating rows use `updated_at`, so the feed
+shows the latest value for each user/listing rating. Since one-click install and
+agent fork both persist through `community_forks`, historical data cannot
+distinguish them and reports both as `fork`. New fork rows snapshot the public
+listing name and kody id; migration `0070-community-fork-listing-snapshots.sql`
+backfills existing rows while their listings still exist, preserving readable
+fork provenance after a later hard delete. Pre-snapshot orphan rows use explicit
+deleted/unknown placeholders. Actor usernames use the materialized stable-id
+index; neither email nor stable user id enters the feed or event.
+
`installCommunityListing` (one-click install) composes `forkCommunityListing`
with `runRepoChecks` over the fork's rewritten snapshot files and, when checks
pass, `refreshSavedPackageProjection` — the same projection step
@@ -207,6 +221,9 @@ Capabilities:
- `community_set_trusted` (admin-only via `requiredRole`)
- `community_set_featured` (admin-only via `requiredRole`)
+The admin domain also exposes `admin_community_activity_list`, guarded by
+`requiredRole: 'admin'`, for the narrow operator activity feed.
+
Register the domain in `builtinDomains` and `capabilityDomainNames` like other
builtin domains (see [Adding capabilities](./adding-kody.md)). Do not surface
community listings through the general capability/package search path.
@@ -253,6 +270,13 @@ Queue shows reporter, reason, and listing metadata. Actions use double-confirm:
`resolveCommunityReport` in `service.ts` implements dismiss, delist, and delete.
`banCommunityUser` / `unbanCommunityUser` manage community-wide bans.
+Successful fork and rating writes enqueue `{ eventId, kind, activityId }` for
+durable `community.activity.recorded` delivery. The Queue consumer reloads the
+same metadata-only projection and uses the shared admin package-subscription
+fan-out, which resolves admin package owners fresh for every attempt. The event
+therefore reaches only admin-owned subscribed packages and is suitable for
+operator notifications such as Discord.
+
## Inert fork mechanism
Forks create an **`entity_sources`** row and Artifacts snapshot but **no**
@@ -284,6 +308,15 @@ contain cross-scope static imports or foreign `kody.dependencies` entries
Empty queries sort by Bayesian score, then `publishedAt`.
+Fork counts are live `COUNT(*)` aggregates over `community_forks` grouped by
+listing id. Detail reads always count the selected listing. Browse and search
+counts are also correct for every materialized result, although unfiltered
+browse intentionally ranks only the newest 500 candidates. The reported
+production mismatch for `@kentcdodds/github` was therefore consistent with a
+data snapshot/cache artifact rather than a defect in the aggregate SQL; the
+worker integration test pins that a successful fork increments the surfaced
+count.
+
`computeCommunityBayesianScore` in `service.ts` implements the prior so a few
5-star ratings do not beat many good ratings.
diff --git a/docs/contributing/environment-variables.md b/docs/contributing/environment-variables.md
index f7c1278534..ea97bb3592 100644
--- a/docs/contributing/environment-variables.md
+++ b/docs/contributing/environment-variables.md
@@ -150,15 +150,15 @@ Worker bindings (see `packages/worker/wrangler.jsonc`):
require it because `WRANGLER_IS_LOCAL_DEV`, `SENTRY_ENVIRONMENT=test`, or a
missing non-production binding keeps search on the deterministic offline path.
-Optional Worker secret:
+Worker secrets:
- **`AI_GATEWAY_ID`** — Cloudflare AI Gateway id. When set, embedding calls use
the Workers AI binding `gateway` option so production and preview inference is
logged/routed through AI Gateway. When unset, the Worker calls Workers AI
directly.
-- **`CAPABILITY_REINDEX_SECRET`** — Bearer token for
- `POST /__maintenance/reindex-capabilities` (production deploy workflow calls
- it after healthcheck when the GitHub secret is set). Use this endpoint after
+- **`CAPABILITY_REINDEX_SECRET`** — required in production; bearer token for the
+ pre-deploy stable-user-id migration and
+ `POST /__maintenance/reindex-capabilities`. Use the reindex endpoint after
changing the embedding model, pooling, or Vectorize index dimensions; it
rebuilds built-in capability, memory, job, and saved-package vectors with
per-user `userId` metadata on user-owned rows. Local dev uses offline search
diff --git a/docs/contributing/packages-and-manifests.md b/docs/contributing/packages-and-manifests.md
index eaa927d3b2..9b5388e7a9 100644
--- a/docs/contributing/packages-and-manifests.md
+++ b/docs/contributing/packages-and-manifests.md
@@ -302,6 +302,16 @@ rerunning, making the DLQ the recovery surface. Terminal handler execution
failures remain isolated from sibling subscribers, and fan-out uses bounded
concurrency.
+Successful community fork and rating writes similarly enqueue
+`community.activity.recorded` for admin-only package-subscription delivery. The
+event contains a unique event id, public listing id/name/kody id, activity kind,
+acting username, timestamp, and rating scores when applicable. It omits stable
+user ids, email, rating notes, forked source/package identifiers, package
+source, and unrelated account content. One-click installs appear as `fork`
+because both paths share the existing `community_forks` row shape. Consumer-time
+admin role checks, lazy metadata reload, retry behavior, and terminal-handler
+isolation match platform-feedback dispatch.
+
## Package-owned workflows
Packages declare workflow entrypoints in runtime code, not in
diff --git a/docs/contributing/project-intent.md b/docs/contributing/project-intent.md
index 9a01b4371f..0d6583a4e9 100644
--- a/docs/contributing/project-intent.md
+++ b/docs/contributing/project-intent.md
@@ -44,12 +44,14 @@ shared state between users.
Optimize for:
- Per-user isolation as a first-class invariant, enforced at the storage,
- durable-object, vectorize, and runtime layers. Three narrow, documented
+ durable-object, vectorize, and runtime layers. Four narrow, documented
exceptions exist: RBAC account administration (`access = 'any'`, limited to
`user` and `role` entities), operator-owned system email for reserved platform
addresses stored under `system:email`, and attributed platform feedback that a
- user explicitly approved for role-gated admin review. The feedback exception
- covers only the approved submission and never unrelated user content. See
+ user explicitly approved for role-gated admin review, plus role-gated metadata
+ about activity on deliberately public community listings. The community
+ exception covers who forked or rated which listing and when, including rating
+ scores, but never package source, rating notes, or unrelated user content. See
[Authorization](./architecture/authorization.md).
- Fast iteration on the personal-assistant experience
- Interoperability across MCP-capable hosts
@@ -89,7 +91,8 @@ When working in this repo, do not assume:
cross-user boundaries are RBAC account administration (`:any` on `user`/`role`
only, behind explicit guards), operator-owned system email for reserved
platform addresses, and explicitly approved, attributed platform feedback
- exposed through role-gated admin review capabilities — see
+ exposed through role-gated admin review capabilities, plus role-gated
+ community activity metadata for public listings — see
[Authorization](./architecture/authorization.md).
- The main goal is enterprise-grade least-privilege design for many users.
@@ -118,8 +121,8 @@ If you are an agent working in this repo:
at the runtime layer, and by user-aware filters at the search/vector layer.
Cross-user access requires an explicit guard and one of the documented narrow
boundaries: account administration, operator-owned system email, or
- user-approved platform feedback — see
- [Authorization](./architecture/authorization.md).
+ user-approved platform feedback, or public-listing community activity metadata
+ — see [Authorization](./architecture/authorization.md).
- Avoid proposing a large static MCP tool catalog as the default direction.
- Keep interoperability with MCP hosts in mind, especially around compact tool
surfaces and clear server instructions.
diff --git a/docs/contributing/setup-manifest.md b/docs/contributing/setup-manifest.md
index 4894f5d624..48d2dc7b5b 100644
--- a/docs/contributing/setup-manifest.md
+++ b/docs/contributing/setup-manifest.md
@@ -38,6 +38,16 @@ This project uses the following resources:
infrastructure failures before routing exhausted messages to the DLQ. Stored
failures replay under the same idempotency key rather than automatically
rerunning; terminal handler execution failures stay isolated.
+- Cloudflare Queue for durable community-activity subscription dispatch
+ - Producer binding: `COMMUNITY_ACTIVITY_DISPATCH_QUEUE`
+ - Queue: `kody-community-activity-dispatch`
+ - Dead-letter queue: `kody-community-activity-dispatch-dlq`
+ - The production consumer uses the same batch, retry, and DLQ settings as
+ platform-feedback dispatch. Production CI ensures both resources.
+ - Queue messages contain only `{ eventId, kind, activityId }`. The consumer
+ reloads the metadata-only activity projection, acknowledges invalid or
+ deleted activity, and retries transient lookup, subscription-discovery, or
+ package-invocation infrastructure failures.
- Vectorize indexes for MCP capability search (`CAPABILITY_VECTOR_INDEX`)
- Production: `kody-capabilities-prod`
- Preview: `kody-capabilities-preview`
@@ -123,7 +133,8 @@ automatically:
and `preview` — so Artifacts repos are partitioned by deploy environment.)
- `AI_GATEWAY_ID` (optional Worker secret; routes Workers AI embedding calls
through the configured Cloudflare AI Gateway when set)
-- `CAPABILITY_REINDEX_SECRET` (optional Worker secret; bearer auth for
+- `CAPABILITY_REINDEX_SECRET` (required in production; bearer auth for the
+ stable-user-id deployment migration and
`POST /__maintenance/reindex-capabilities` to refresh all capability-search
vectors in Vectorize: built-in kody, memories, jobs, and saved packages. Saved
package projections also refresh when packages are saved or published.)
@@ -160,19 +171,19 @@ Configure these GitHub Actions secrets and variables for workflows:
both this zone id and the domain)
- `COOKIE_SECRET` (same format as local)
- `SECRET_STORE_KEY` (same format as local; required for deploys)
-- `APP_BASE_URL` (optional GitHub Actions **variable**, used by the production
- deploy as the fallback public app origin when no request URL is available —
- workflows, password-reset email sender hostname — and written into the
- generated Worker `vars` config before deploy. Request-scoped MCP/app URLs use
- the inbound request origin.)
+- `APP_BASE_URL` (required GitHub Actions **variable** for the production
+ stable-user-id migration and used by the deployed Worker as the fallback
+ public app origin when no request URL is available — workflows, password-reset
+ email sender hostname — and written into the generated Worker `vars` config
+ before deploy. Request-scoped MCP/app URLs use the inbound request origin.)
- `AI_GATEWAY_ID` (optional for production deploys; enables AI Gateway routing
for Workers AI embeddings)
- `AI_GATEWAY_ID_PREVIEW` (optional for preview deploys; enables AI Gateway
routing for Workers AI embeddings)
- `SENTRY_DSN` (optional; create a JavaScript/Cloudflare project in Sentry and
paste the DSN; syncs to the Worker as a secret when set in GitHub Actions)
-- `CAPABILITY_REINDEX_SECRET` (optional; triggers post-deploy Vectorize reindex
- when set; synced like other optional secrets)
+- `CAPABILITY_REINDEX_SECRET` (required for production deploys; authenticates
+ the pre-deploy stable-user-id migration and post-deploy maintenance calls)
- `OAUTH_GITHUB_CLIENT_ID` / `OAUTH_GITHUB_CLIENT_SECRET`,
`OAUTH_GOOGLE_CLIENT_ID` / `OAUTH_GOOGLE_CLIENT_SECRET`, `OAUTH_X_CLIENT_ID` /
`OAUTH_X_CLIENT_SECRET` (optional; social login provider app credentials. The
diff --git a/docs/guides/package-subscriptions.md b/docs/guides/package-subscriptions.md
index 724e6c7b91..21e8bebede 100644
--- a/docs/guides/package-subscriptions.md
+++ b/docs/guides/package-subscriptions.md
@@ -197,3 +197,49 @@ redelivery safe, but a stored failed invocation replays rather than
automatically rerunning; the DLQ is the recovery surface. Terminal handler
execution failures are isolated without preventing attempts for sibling
subscribers.
+
+## `community.activity.recorded` (admins)
+
+Successful community fork and rating writes enqueue a durable
+`community.activity.recorded` attempt. The Queue consumer dispatches only to
+packages saved by users who hold the admin role when the message is processed.
+Non-admin declarations are inert, and role revocation applies to the next
+attempt.
+
+Handlers receive activity metadata only:
+
+```ts
+type CommunityActivityRecordedEvent = {
+ event: 'community.activity.recorded'
+ event_id: string
+ activity:
+ | {
+ id: string
+ kind: 'fork'
+ listing: { id: string; name: string; kody_id: string }
+ actor: { username: string | null }
+ occurred_at: string
+ }
+ | {
+ id: string
+ kind: 'rating'
+ listing: { id: string; name: string; kody_id: string }
+ actor: { username: string | null }
+ occurred_at: string
+ stars: number
+ adaptation_effort: number
+ }
+}
+```
+
+The event omits stable user ids, email, forked package/source ids, target kody
+ids, rating notes, package source, secrets, and unrelated account content.
+One-click installs and ordinary forks both appear as `fork` because they share
+the same existing `community_forks` row shape. Rating records are upserts, so
+the reloaded activity contains the latest scores.
+
+Queue messages contain only `{ eventId, kind, activityId }`. Dispatch reloads
+the metadata projection after admin subscriber discovery. Missing activity is a
+permanent cancellation; transient lookup, discovery, and package-invocation
+infrastructure failures retry and can reach the dedicated DLQ. `event_id`
+provides a distinct package-invocation idempotency key for every recorded write.
diff --git a/docs/use/privacy.md b/docs/use/privacy.md
index acb9208b61..11fc776211 100644
--- a/docs/use/privacy.md
+++ b/docs/use/privacy.md
@@ -12,8 +12,8 @@ configuration, OAuth grants, package invocation tokens, community social graph
edges (follows, listing stars, and stored activity events), and any platform
feedback you approve for submission. All of this remains scoped to your account
except for content you deliberately make public (community listings and a public
-profile) and the narrow admin review of approved platform feedback described
-below.
+profile), the narrow admin review of approved platform feedback, and the
+community activity metadata described below.
When profile visibility is **public**, display name, bio, public package
metadata, follow counts, and public activity are visible on `/@username` and
@@ -36,10 +36,12 @@ lists users and roles; it does not expose account content.
Platform feedback you explicitly approve for admin review is a narrow
user-content exception.
-Admins also moderate public community listings and attributed community reports.
-That review covers content deliberately published or reported through community
-features, not private package source, private profiles, or unrelated account
-content.
+Admins also moderate public community listings and attributed community reports,
+and can see who forked or rated a public listing, when, and the rating scores.
+One-click installs appear as forks because both use the same activity record.
+This activity view never includes private package source, rating notes, email,
+stable user ids, private profiles, secrets, or unrelated account content.
+Admin-configured notification packages may receive the same metadata.
## Platform feedback
diff --git a/packages/worker/client/routes/privacy.tsx b/packages/worker/client/routes/privacy.tsx
index ec32f08784..2a97b1c55a 100644
--- a/packages/worker/client/routes/privacy.tsx
+++ b/packages/worker/client/routes/privacy.tsx
@@ -30,8 +30,8 @@ export function PrivacyRoute(_handle: Handle) {
chat threads, durable storage, remote connector configuration, OAuth
grants, package invocation tokens, and any platform feedback you
approve for submission. All of this remains scoped to your account
- except for the narrow admin review of approved platform feedback
- described below.
+ except for the narrow admin review of approved platform feedback and
+ the community activity metadata described below.
@@ -47,9 +47,12 @@ export function PrivacyRoute(_handle: Handle) {
Admins also moderate public community listings and attributed
- community reports. That review covers content deliberately published
- or reported through community features, not private package source or
- unrelated account content.
+ community reports, and can see who forked or rated a public listing,
+ when, and the rating scores. One-click installs appear as forks
+ because both use the same activity record. This view and
+ admin-configured notifications never include private package source,
+ rating notes, email, stable user ids, secrets, or unrelated account
+ content.
diff --git a/packages/worker/migrations/0070-community-fork-listing-snapshots.sql b/packages/worker/migrations/0070-community-fork-listing-snapshots.sql
new file mode 100644
index 0000000000..6951f34834
--- /dev/null
+++ b/packages/worker/migrations/0070-community-fork-listing-snapshots.sql
@@ -0,0 +1,47 @@
+CREATE TABLE community_forks_with_listing_snapshots (
+ id TEXT PRIMARY KEY NOT NULL,
+ listing_id TEXT NOT NULL,
+ forker_user_id TEXT NOT NULL,
+ origin_commit TEXT NOT NULL,
+ forked_package_id TEXT NOT NULL,
+ forked_source_id TEXT NOT NULL,
+ target_kody_id TEXT NOT NULL,
+ listing_name TEXT,
+ listing_kody_id TEXT,
+ created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
+);
+
+INSERT INTO community_forks_with_listing_snapshots (
+ id,
+ listing_id,
+ forker_user_id,
+ origin_commit,
+ forked_package_id,
+ forked_source_id,
+ target_kody_id,
+ listing_name,
+ listing_kody_id,
+ created_at
+)
+SELECT
+ community_forks.id,
+ community_forks.listing_id,
+ community_forks.forker_user_id,
+ community_forks.origin_commit,
+ community_forks.forked_package_id,
+ community_forks.forked_source_id,
+ community_forks.target_kody_id,
+ community_listings.name,
+ community_listings.kody_id,
+ community_forks.created_at
+FROM community_forks
+LEFT JOIN community_listings
+ ON community_listings.id = community_forks.listing_id;
+
+DROP TABLE community_forks;
+ALTER TABLE community_forks_with_listing_snapshots RENAME TO community_forks;
+
+CREATE INDEX idx_community_forks_listing_id
+ ON community_forks(listing_id);
+CREATE INDEX idx_community_forks_forker_listing
+ ON community_forks(forker_user_id, listing_id);
diff --git a/packages/worker/src/community/activity-dispatch-queue-names.ts b/packages/worker/src/community/activity-dispatch-queue-names.ts
new file mode 100644
index 0000000000..a76bd2a565
--- /dev/null
+++ b/packages/worker/src/community/activity-dispatch-queue-names.ts
@@ -0,0 +1,6 @@
+export const communityActivityDispatchQueueBinding =
+ 'COMMUNITY_ACTIVITY_DISPATCH_QUEUE'
+export const communityActivityDispatchQueueName =
+ 'kody-community-activity-dispatch'
+export const communityActivityDispatchDeadLetterQueueName =
+ 'kody-community-activity-dispatch-dlq'
diff --git a/packages/worker/src/community/activity-dispatch-queue-producer.ts b/packages/worker/src/community/activity-dispatch-queue-producer.ts
new file mode 100644
index 0000000000..7078a91a82
--- /dev/null
+++ b/packages/worker/src/community/activity-dispatch-queue-producer.ts
@@ -0,0 +1,19 @@
+import { type CommunityActivityKind } from './types.ts'
+
+export type CommunityActivityDispatchQueueMessage = {
+ eventId: string
+ kind: CommunityActivityKind
+ activityId: string
+}
+
+export async function enqueueCommunityActivityDispatch(input: {
+ queue: Pick, 'send'>
+ kind: CommunityActivityKind
+ activityId: string
+}) {
+ await input.queue.send({
+ eventId: crypto.randomUUID(),
+ kind: input.kind,
+ activityId: input.activityId,
+ })
+}
diff --git a/packages/worker/src/community/activity-dispatch-queue.node.test.ts b/packages/worker/src/community/activity-dispatch-queue.node.test.ts
new file mode 100644
index 0000000000..a2f32ef6d6
--- /dev/null
+++ b/packages/worker/src/community/activity-dispatch-queue.node.test.ts
@@ -0,0 +1,109 @@
+import { expect, test, vi } from 'vitest'
+import { consoleError } from '#worker/test-support/console-spies.ts'
+import { CommunityActivityDispatchCancelledError } from './errors.ts'
+
+const mocks = vi.hoisted(() => ({
+ dispatchCommunityActivityRecordedSubscriptionEvent: vi.fn(),
+}))
+
+vi.mock('./activity-package-subscriptions.ts', () => ({
+ dispatchCommunityActivityRecordedSubscriptionEvent:
+ mocks.dispatchCommunityActivityRecordedSubscriptionEvent,
+}))
+
+const { handleCommunityActivityDispatchQueue } =
+ await import('./activity-dispatch-queue.ts')
+
+function createQueueMessage(id: string, body: unknown) {
+ return {
+ id,
+ timestamp: new Date('2026-07-20T01:01:00.000Z'),
+ body,
+ attempts: 1,
+ ack: vi.fn(),
+ retry: vi.fn(),
+ }
+}
+
+function createBatch(messages: Array>) {
+ return {
+ queue: 'kody-community-activity-dispatch',
+ messages,
+ ackAll: vi.fn(),
+ retryAll: vi.fn(),
+ } as unknown as MessageBatch
+}
+
+test('community activity queue acks valid, invalid, and cancelled messages and retries transient failures', async () => {
+ consoleError.mockImplementation(() => {})
+ const valid = createQueueMessage('valid', {
+ eventId: 'event-1',
+ kind: 'fork',
+ activityId: 'fork-1',
+ })
+ const invalidKind = createQueueMessage('invalid-kind', {
+ eventId: 'event-2',
+ kind: 'install',
+ activityId: 'fork-2',
+ })
+ const extra = createQueueMessage('extra', {
+ eventId: 'event-3',
+ kind: 'rating',
+ activityId: 'rating-1',
+ stars: 5,
+ })
+ const deleted = createQueueMessage('deleted', {
+ eventId: 'event-4',
+ kind: 'rating',
+ activityId: 'rating-deleted',
+ })
+ const transient = createQueueMessage('transient', {
+ eventId: 'event-5',
+ kind: 'rating',
+ activityId: 'rating-2',
+ })
+ mocks.dispatchCommunityActivityRecordedSubscriptionEvent
+ .mockResolvedValueOnce([])
+ .mockRejectedValueOnce(
+ new CommunityActivityDispatchCancelledError({
+ kind: 'rating',
+ activityId: 'rating-deleted',
+ }),
+ )
+ .mockRejectedValueOnce(new Error('D1 unavailable'))
+
+ await handleCommunityActivityDispatchQueue(
+ createBatch([valid, invalidKind, extra, deleted, transient]),
+ { APP_DB: {} } as Env,
+ {} as ExecutionContext,
+ )
+
+ expect(
+ mocks.dispatchCommunityActivityRecordedSubscriptionEvent,
+ ).toHaveBeenCalledTimes(3)
+ expect(
+ mocks.dispatchCommunityActivityRecordedSubscriptionEvent,
+ ).toHaveBeenNthCalledWith(1, {
+ env: expect.anything(),
+ eventId: 'event-1',
+ kind: 'fork',
+ activityId: 'fork-1',
+ })
+ for (const message of [valid, invalidKind, extra, deleted]) {
+ expect(message.ack).toHaveBeenCalledTimes(1)
+ expect(message.retry).not.toHaveBeenCalled()
+ }
+ expect(transient.ack).not.toHaveBeenCalled()
+ expect(transient.retry).toHaveBeenCalledWith({ delaySeconds: 30 })
+ expect(consoleError).toHaveBeenCalledTimes(1)
+ expect(consoleError).toHaveBeenCalledWith(
+ 'community-activity-dispatch-queue-processing-failed',
+ expect.objectContaining({
+ queueMessageId: 'transient',
+ eventId: 'event-5',
+ kind: 'rating',
+ activityId: 'rating-2',
+ error: expect.any(Error),
+ }),
+ )
+})
diff --git a/packages/worker/src/community/activity-dispatch-queue.ts b/packages/worker/src/community/activity-dispatch-queue.ts
new file mode 100644
index 0000000000..174c4d52be
--- /dev/null
+++ b/packages/worker/src/community/activity-dispatch-queue.ts
@@ -0,0 +1,66 @@
+import { dispatchCommunityActivityRecordedSubscriptionEvent } from './activity-package-subscriptions.ts'
+import { type CommunityActivityDispatchQueueMessage } from './activity-dispatch-queue-producer.ts'
+import { CommunityActivityDispatchCancelledError } from './errors.ts'
+import { communityActivityKinds } from './types.ts'
+
+const communityActivityDispatchRetryDelaySeconds = 30
+
+function parseCommunityActivityDispatchQueueMessage(
+ body: unknown,
+): CommunityActivityDispatchQueueMessage | null {
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return null
+ const record = body as Record
+ const eventId = record['eventId']
+ const kind = record['kind']
+ const activityId = record['activityId']
+ if (
+ Object.keys(record).length !== 3 ||
+ typeof eventId !== 'string' ||
+ !eventId.trim() ||
+ typeof kind !== 'string' ||
+ !communityActivityKinds.some((candidate) => candidate === kind) ||
+ typeof activityId !== 'string' ||
+ !activityId.trim()
+ ) {
+ return null
+ }
+ return {
+ eventId: eventId.trim(),
+ kind: kind as CommunityActivityDispatchQueueMessage['kind'],
+ activityId: activityId.trim(),
+ }
+}
+
+export async function handleCommunityActivityDispatchQueue(
+ batch: MessageBatch,
+ env: Env,
+ _ctx: ExecutionContext,
+) {
+ for (const queueMessage of batch.messages) {
+ const parsed = parseCommunityActivityDispatchQueueMessage(queueMessage.body)
+ if (!parsed) {
+ queueMessage.ack()
+ continue
+ }
+ try {
+ await dispatchCommunityActivityRecordedSubscriptionEvent({
+ env,
+ ...parsed,
+ })
+ queueMessage.ack()
+ } catch (error) {
+ if (error instanceof CommunityActivityDispatchCancelledError) {
+ queueMessage.ack()
+ continue
+ }
+ console.error('community-activity-dispatch-queue-processing-failed', {
+ queueMessageId: queueMessage.id,
+ ...parsed,
+ error,
+ })
+ queueMessage.retry({
+ delaySeconds: communityActivityDispatchRetryDelaySeconds,
+ })
+ }
+ }
+}
diff --git a/packages/worker/src/community/activity-package-subscriptions.node.test.ts b/packages/worker/src/community/activity-package-subscriptions.node.test.ts
new file mode 100644
index 0000000000..80213319b2
--- /dev/null
+++ b/packages/worker/src/community/activity-package-subscriptions.node.test.ts
@@ -0,0 +1,118 @@
+import { expect, test, vi } from 'vitest'
+import { CommunityActivityDispatchCancelledError } from './errors.ts'
+import {
+ buildCommunityActivityRecordedEvent,
+ communityActivityRecordedTopic,
+} from './activity-subscription-event.ts'
+
+const mocks = vi.hoisted(() => ({
+ dispatchAdminPackageSubscriptionEvent: vi.fn(),
+ getCommunityActivityForAdmin: vi.fn(),
+}))
+
+vi.mock('#worker/package-invocations/admin-package-subscriptions.ts', () => ({
+ dispatchAdminPackageSubscriptionEvent:
+ mocks.dispatchAdminPackageSubscriptionEvent,
+}))
+
+vi.mock('./service.ts', () => ({
+ getCommunityActivityForAdmin: mocks.getCommunityActivityForAdmin,
+}))
+
+const { dispatchCommunityActivityRecordedSubscriptionEvent } =
+ await import('./activity-package-subscriptions.ts')
+
+const ratingActivity = {
+ id: 'rating-1',
+ kind: 'rating' as const,
+ listingId: 'listing-1',
+ listingName: '@owner/package',
+ listingKodyId: 'package',
+ actingUsername: 'rater',
+ occurredAt: '2026-07-20T01:00:00.000Z',
+ stars: 5,
+ adaptationEffort: 2,
+}
+
+test('community activity dispatch builds metadata-only events through admin package fan-out', async () => {
+ mocks.getCommunityActivityForAdmin.mockResolvedValue(ratingActivity)
+ mocks.dispatchAdminPackageSubscriptionEvent.mockImplementation(
+ async (input: {
+ getParams: () => Promise>
+ buildIdempotencyKey: (savedPackage: { id: string }) => string
+ [key: string]: unknown
+ }) => [
+ {
+ params: await input.getParams(),
+ idempotencyKey: input.buildIdempotencyKey({ id: 'package-1' }),
+ input,
+ },
+ ],
+ )
+
+ const result = await dispatchCommunityActivityRecordedSubscriptionEvent({
+ env: {
+ APP_DB: {} as D1Database,
+ BUNDLE_ARTIFACTS_KV: {} as KVNamespace,
+ APP_BASE_URL: 'https://heykody.dev',
+ },
+ eventId: 'event-1',
+ kind: 'rating',
+ activityId: 'rating-1',
+ })
+
+ expect(mocks.getCommunityActivityForAdmin).toHaveBeenCalledWith({
+ db: expect.anything(),
+ kind: 'rating',
+ activityId: 'rating-1',
+ })
+ expect(result[0]).toMatchObject({
+ params: buildCommunityActivityRecordedEvent({
+ eventId: 'event-1',
+ activity: ratingActivity,
+ }),
+ idempotencyKey:
+ 'community-activity:event-1:package-1:community.activity.recorded',
+ input: {
+ topic: communityActivityRecordedTopic,
+ source: 'community-activity',
+ actorTokenId: 'internal:community-activity-subscriptions',
+ retryDiscoveryFailures: true,
+ retryInvocationInfrastructureFailures: true,
+ },
+ })
+ const payload = result[0]?.params as Record
+ expect(payload).toEqual({
+ event: 'community.activity.recorded',
+ event_id: 'event-1',
+ activity: {
+ id: 'rating-1',
+ kind: 'rating',
+ listing: {
+ id: 'listing-1',
+ name: '@owner/package',
+ kody_id: 'package',
+ },
+ actor: { username: 'rater' },
+ occurred_at: '2026-07-20T01:00:00.000Z',
+ stars: 5,
+ adaptation_effort: 2,
+ },
+ })
+ expect(JSON.stringify(payload)).not.toContain('note')
+ expect(JSON.stringify(payload)).not.toContain('user_id')
+
+ mocks.getCommunityActivityForAdmin.mockResolvedValue(null)
+ await expect(
+ dispatchCommunityActivityRecordedSubscriptionEvent({
+ env: {
+ APP_DB: {} as D1Database,
+ BUNDLE_ARTIFACTS_KV: {} as KVNamespace,
+ APP_BASE_URL: 'https://heykody.dev',
+ },
+ eventId: 'event-deleted',
+ kind: 'fork',
+ activityId: 'fork-deleted',
+ }),
+ ).rejects.toBeInstanceOf(CommunityActivityDispatchCancelledError)
+})
diff --git a/packages/worker/src/community/activity-package-subscriptions.ts b/packages/worker/src/community/activity-package-subscriptions.ts
new file mode 100644
index 0000000000..0c085f92e1
--- /dev/null
+++ b/packages/worker/src/community/activity-package-subscriptions.ts
@@ -0,0 +1,49 @@
+import { getAppBaseUrl } from '#app/app-base-url.ts'
+import { dispatchAdminPackageSubscriptionEvent } from '#worker/package-invocations/admin-package-subscriptions.ts'
+import {
+ buildCommunityActivityRecordedEvent,
+ communityActivityRecordedTopic,
+} from './activity-subscription-event.ts'
+import { CommunityActivityDispatchCancelledError } from './errors.ts'
+import { getCommunityActivityForAdmin } from './service.ts'
+import { type CommunityActivityKind } from './types.ts'
+
+const communityActivitySubscriptionActorTokenId =
+ 'internal:community-activity-subscriptions'
+
+export async function dispatchCommunityActivityRecordedSubscriptionEvent(input: {
+ env: Pick
+ eventId: string
+ kind: CommunityActivityKind
+ activityId: string
+}) {
+ const baseUrl = getAppBaseUrl({ env: input.env })
+ return await dispatchAdminPackageSubscriptionEvent({
+ env: input.env,
+ baseUrl,
+ topic: communityActivityRecordedTopic,
+ getParams: async () => {
+ const activity = await getCommunityActivityForAdmin({
+ db: input.env.APP_DB,
+ kind: input.kind,
+ activityId: input.activityId,
+ })
+ if (!activity) {
+ throw new CommunityActivityDispatchCancelledError({
+ kind: input.kind,
+ activityId: input.activityId,
+ })
+ }
+ return buildCommunityActivityRecordedEvent({
+ eventId: input.eventId,
+ activity,
+ }) as Record
+ },
+ source: 'community-activity',
+ buildIdempotencyKey: (savedPackage) =>
+ `community-activity:${input.eventId}:${savedPackage.id}:${communityActivityRecordedTopic}`,
+ actorTokenId: communityActivitySubscriptionActorTokenId,
+ retryDiscoveryFailures: true,
+ retryInvocationInfrastructureFailures: true,
+ })
+}
diff --git a/packages/worker/src/community/activity-subscription-event.ts b/packages/worker/src/community/activity-subscription-event.ts
new file mode 100644
index 0000000000..18c6081cc8
--- /dev/null
+++ b/packages/worker/src/community/activity-subscription-event.ts
@@ -0,0 +1,69 @@
+import { type CommunityActivityRecord } from './types.ts'
+
+export const communityActivityRecordedTopic = 'community.activity.recorded'
+
+type CommunityActivityEventBase = {
+ id: string
+ listing: {
+ id: string
+ name: string
+ kody_id: string
+ }
+ actor: {
+ username: string | null
+ }
+ occurred_at: string
+}
+
+export type CommunityActivityRecordedEvent = {
+ event: typeof communityActivityRecordedTopic
+ event_id: string
+ activity:
+ | (CommunityActivityEventBase & { kind: 'fork' })
+ | (CommunityActivityEventBase & {
+ kind: 'rating'
+ stars: number
+ adaptation_effort: number
+ })
+}
+
+export function buildCommunityActivityRecordedEvent(input: {
+ eventId: string
+ activity: CommunityActivityRecord
+}): CommunityActivityRecordedEvent {
+ const shared = {
+ id: input.activity.id,
+ listing: {
+ id: input.activity.listingId,
+ name: input.activity.listingName,
+ kody_id: input.activity.listingKodyId,
+ },
+ actor: {
+ username: input.activity.actingUsername,
+ },
+ occurred_at: input.activity.occurredAt,
+ }
+ switch (input.activity.kind) {
+ case 'fork':
+ return {
+ event: communityActivityRecordedTopic,
+ event_id: input.eventId,
+ activity: { ...shared, kind: input.activity.kind },
+ }
+ case 'rating':
+ return {
+ event: communityActivityRecordedTopic,
+ event_id: input.eventId,
+ activity: {
+ ...shared,
+ kind: input.activity.kind,
+ stars: input.activity.stars,
+ adaptation_effort: input.activity.adaptationEffort,
+ },
+ }
+ default: {
+ const exhaustive: never = input.activity
+ throw new Error(`Unsupported community activity: ${String(exhaustive)}`)
+ }
+ }
+}
diff --git a/packages/worker/src/community/community-activity-service.node.test.ts b/packages/worker/src/community/community-activity-service.node.test.ts
new file mode 100644
index 0000000000..f31233e263
--- /dev/null
+++ b/packages/worker/src/community/community-activity-service.node.test.ts
@@ -0,0 +1,316 @@
+import { readFileSync } from 'node:fs'
+import { DatabaseSync } from 'node:sqlite'
+import { expect, test } from 'vitest'
+import {
+ countCommunityForksByListingIds,
+ insertCommunityFork,
+ upsertCommunityRating,
+} from './repo.ts'
+import {
+ getCommunityActivityForAdmin,
+ listCommunityActivityForAdmin,
+} from './service.ts'
+
+type TestD1Statement = {
+ bind(...params: Array): TestD1Statement
+ all(): Promise<{ results: Array }>
+ first(): Promise
+ run(): Promise<{ meta: { changes: number } }>
+}
+
+function createD1FromSqlite(sqlite: DatabaseSync) {
+ function createStatement(
+ query: string,
+ params: Array = [],
+ ): TestD1Statement {
+ return {
+ bind(...boundParams: Array) {
+ return createStatement(query, boundParams)
+ },
+ async all() {
+ return { results: sqlite.prepare(query).all(...params) as Array }
+ },
+ async first() {
+ return (sqlite.prepare(query).get(...params) ?? null) as T | null
+ },
+ async run() {
+ const result = sqlite.prepare(query).run(...params)
+ return { meta: { changes: result.changes } }
+ },
+ }
+ }
+ return {
+ prepare(query: string) {
+ return createStatement(query)
+ },
+ } as unknown as D1Database
+}
+
+function createCommunityDb() {
+ const sqlite = new DatabaseSync(':memory:')
+ sqlite.exec(`CREATE TABLE users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+ username TEXT NOT NULL UNIQUE,
+ email TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ stable_user_id TEXT
+ )`)
+ sqlite.exec(
+ readFileSync(
+ new URL('../../migrations/0045-community-listings.sql', import.meta.url),
+ 'utf8',
+ ),
+ )
+ sqlite.exec(
+ readFileSync(
+ new URL(
+ '../../migrations/0070-community-fork-listing-snapshots.sql',
+ import.meta.url,
+ ),
+ 'utf8',
+ ),
+ )
+ return { sqlite, db: createD1FromSqlite(sqlite) }
+}
+
+test('admin community activity reads forks and latest ratings newest-first with pagination and filters', async () => {
+ const { sqlite, db } = createCommunityDb()
+ sqlite
+ .prepare(
+ `INSERT INTO users (
+ username, email, password_hash, stable_user_id
+ ) VALUES (?, ?, 'hash', ?), (?, ?, 'hash', ?)`,
+ )
+ .run(
+ 'forker',
+ 'forker@example.com',
+ 'user-forker',
+ 'rater',
+ 'rater@example.com',
+ 'user-rater',
+ )
+ for (const listing of [
+ { id: 'listing-1', kodyId: 'alpha', name: '@owner/alpha' },
+ { id: 'listing-2', kodyId: 'beta', name: '@owner/beta' },
+ ]) {
+ sqlite
+ .prepare(
+ `INSERT INTO community_listings (
+ id, owner_user_id, package_id, source_id, kody_id, name,
+ description, tags_json, license, pinned_commit, status,
+ created_at, updated_at, published_at
+ ) VALUES (?, 'owner', ?, ?, ?, ?, 'description', '[]', 'MIT',
+ 'commit-1', 'active', '2026-07-20T00:00:00.000Z',
+ '2026-07-20T00:00:00.000Z', '2026-07-20T00:00:00.000Z')`,
+ )
+ .run(
+ listing.id,
+ `package-${listing.id}`,
+ `source-${listing.id}`,
+ listing.kodyId,
+ listing.name,
+ )
+ }
+
+ for (const fork of [
+ {
+ id: 'fork-1',
+ listingId: 'listing-1',
+ createdAt: '2026-07-20T00:01:00.000Z',
+ },
+ {
+ id: 'fork-2',
+ listingId: 'listing-1',
+ createdAt: '2026-07-20T00:02:00.000Z',
+ },
+ {
+ id: 'fork-3',
+ listingId: 'listing-2',
+ createdAt: '2026-07-20T00:03:00.000Z',
+ },
+ ]) {
+ await insertCommunityFork(db, {
+ id: fork.id,
+ listing_id: fork.listingId,
+ forker_user_id: 'user-forker',
+ origin_commit: 'commit-1',
+ forked_package_id: `package-${fork.id}`,
+ forked_source_id: `source-${fork.id}`,
+ target_kody_id: `target-${fork.id}`,
+ listing_name:
+ fork.listingId === 'listing-1' ? '@owner/alpha' : '@owner/beta',
+ listing_kody_id: fork.listingId === 'listing-1' ? 'alpha' : 'beta',
+ created_at: fork.createdAt,
+ })
+ }
+
+ const firstRating = await upsertCommunityRating(db, {
+ id: 'rating-original',
+ listing_id: 'listing-1',
+ user_id: 'user-rater',
+ stars: 4,
+ adaptation_effort: 3,
+ note: 'not exposed',
+ created_at: '2026-07-20T00:04:00.000Z',
+ updated_at: '2026-07-20T00:04:00.000Z',
+ })
+ const updatedRating = await upsertCommunityRating(db, {
+ id: 'rating-replacement',
+ listing_id: 'listing-1',
+ user_id: 'user-rater',
+ stars: 5,
+ adaptation_effort: 1,
+ note: 'still not exposed',
+ created_at: '2026-07-20T00:05:00.000Z',
+ updated_at: '2026-07-20T00:05:00.000Z',
+ })
+ expect(firstRating.id).toBe('rating-original')
+ expect(updatedRating).toMatchObject({
+ id: 'rating-original',
+ stars: 5,
+ adaptationEffort: 1,
+ updatedAt: '2026-07-20T00:05:00.000Z',
+ })
+
+ const firstPage = await listCommunityActivityForAdmin({ db, pageSize: 2 })
+ expect(firstPage).toMatchObject({ total: 4, page: 1, pageSize: 2 })
+ expect(firstPage.items).toEqual([
+ {
+ id: 'rating-original',
+ kind: 'rating',
+ listingId: 'listing-1',
+ listingName: '@owner/alpha',
+ listingKodyId: 'alpha',
+ actingUsername: 'rater',
+ occurredAt: '2026-07-20T00:05:00.000Z',
+ stars: 5,
+ adaptationEffort: 1,
+ },
+ {
+ id: 'fork-3',
+ kind: 'fork',
+ listingId: 'listing-2',
+ listingName: '@owner/beta',
+ listingKodyId: 'beta',
+ actingUsername: 'forker',
+ occurredAt: '2026-07-20T00:03:00.000Z',
+ },
+ ])
+
+ const clamped = await listCommunityActivityForAdmin({
+ db,
+ page: 99,
+ pageSize: 2,
+ })
+ expect(clamped.page).toBe(2)
+ expect(clamped.items.map((item) => item.id)).toEqual(['fork-2', 'fork-1'])
+
+ const filtered = await listCommunityActivityForAdmin({
+ db,
+ kind: 'rating',
+ listingId: 'listing-1',
+ })
+ expect(filtered.total).toBe(1)
+ expect(filtered.items).toEqual([firstPage.items[0]])
+ expect(
+ await getCommunityActivityForAdmin({
+ db,
+ kind: 'rating',
+ activityId: 'rating-original',
+ }),
+ ).toEqual(firstPage.items[0])
+
+ sqlite.prepare(`DELETE FROM community_listings WHERE id = 'listing-2'`).run()
+ const deletedListingActivity = await listCommunityActivityForAdmin({
+ db,
+ kind: 'fork',
+ listingId: 'listing-2',
+ })
+ expect(deletedListingActivity.items).toEqual([
+ {
+ id: 'fork-3',
+ kind: 'fork',
+ listingId: 'listing-2',
+ listingName: '@owner/beta',
+ listingKodyId: 'beta',
+ actingUsername: 'forker',
+ occurredAt: '2026-07-20T00:03:00.000Z',
+ },
+ ])
+
+ expect(
+ await countCommunityForksByListingIds(db, [
+ 'listing-1',
+ 'listing-2',
+ 'listing-without-forks',
+ ]),
+ ).toEqual({
+ 'listing-1': 2,
+ 'listing-2': 1,
+ 'listing-without-forks': 0,
+ })
+})
+
+test('fork snapshot migration also handles preview schemas that already have snapshot columns', () => {
+ const sqlite = new DatabaseSync(':memory:')
+ sqlite.exec(
+ readFileSync(
+ new URL('../../migrations/0045-community-listings.sql', import.meta.url),
+ 'utf8',
+ ),
+ )
+ sqlite.exec(`ALTER TABLE community_forks ADD COLUMN listing_name TEXT`)
+ sqlite.exec(`ALTER TABLE community_forks ADD COLUMN listing_kody_id TEXT`)
+ sqlite.exec(`
+ INSERT INTO community_listings (
+ id, owner_user_id, package_id, source_id, kody_id, name, description,
+ tags_json, license, pinned_commit, status, created_at, updated_at,
+ published_at
+ ) VALUES (
+ 'listing-preview', 'owner', 'package', 'source', 'current-kody',
+ '@owner/current-name', 'description', '[]', 'MIT', 'commit', 'active',
+ '2026-07-20T00:00:00.000Z', '2026-07-20T00:00:00.000Z',
+ '2026-07-20T00:00:00.000Z'
+ );
+ INSERT INTO community_forks (
+ id, listing_id, forker_user_id, origin_commit, forked_package_id,
+ forked_source_id, target_kody_id, listing_name, listing_kody_id,
+ created_at
+ ) VALUES (
+ 'fork-preview', 'listing-preview', 'forker', 'commit', 'fork-package',
+ 'fork-source', 'fork-kody', '@owner/stale-name', 'stale-kody',
+ '2026-07-20T00:01:00.000Z'
+ );
+ `)
+
+ sqlite.exec(
+ readFileSync(
+ new URL(
+ '../../migrations/0070-community-fork-listing-snapshots.sql',
+ import.meta.url,
+ ),
+ 'utf8',
+ ),
+ )
+
+ const columns = sqlite
+ .prepare(`PRAGMA table_info(community_forks)`)
+ .all() as Array<{ name: string }>
+ expect(
+ columns.filter((column) => column.name === 'listing_name'),
+ ).toHaveLength(1)
+ expect(
+ columns.filter((column) => column.name === 'listing_kody_id'),
+ ).toHaveLength(1)
+ expect(
+ sqlite
+ .prepare(
+ `SELECT listing_name, listing_kody_id FROM community_forks
+ WHERE id = 'fork-preview'`,
+ )
+ .get(),
+ ).toEqual({
+ listing_name: '@owner/current-name',
+ listing_kody_id: 'current-kody',
+ })
+})
diff --git a/packages/worker/src/community/community-flow-test-schema.ts b/packages/worker/src/community/community-flow-test-schema.ts
index b281fb3731..70285339cf 100644
--- a/packages/worker/src/community/community-flow-test-schema.ts
+++ b/packages/worker/src/community/community-flow-test-schema.ts
@@ -88,6 +88,8 @@ export async function ensureCommunityFlowSchema(db: D1Database) {
forked_package_id TEXT NOT NULL,
forked_source_id TEXT NOT NULL,
target_kody_id TEXT NOT NULL,
+ listing_name TEXT,
+ listing_kody_id TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
)`,
`CREATE TABLE IF NOT EXISTS community_ratings (
diff --git a/packages/worker/src/community/community-flow.workers.test.ts b/packages/worker/src/community/community-flow.workers.test.ts
index f3ad22be89..1af0d60eea 100644
--- a/packages/worker/src/community/community-flow.workers.test.ts
+++ b/packages/worker/src/community/community-flow.workers.test.ts
@@ -14,6 +14,7 @@ import { callerCanAccessCapability } from '#mcp/capabilities/access-control.ts'
import {
banCommunityUser,
listFeaturedCommunityListingsWithAggregates,
+ listCommunityActivityForAdmin,
resolveCommunityReport,
setCommunityListingFeatured,
setCommunityListingTrusted,
@@ -26,6 +27,7 @@ import { type EntitySourceRow } from '#worker/repo/types.ts'
import { createStableUserIdFromEmail } from '#worker/user-id.ts'
import { createArtifactsMswHandlers } from '#worker/test-support/artifacts-msw-handlers.ts'
import { silenceIncidentalRuntimeWarnings } from '#worker/test-support/incidental-runtime-warnings.ts'
+import { type CommunityActivityDispatchQueueMessage } from './activity-dispatch-queue-producer.ts'
import { createMswNodeServer } from '#worker/test-support/msw-node-server.ts'
import { ensureCommunityFlowSchema } from './community-flow-test-schema.ts'
@@ -180,11 +182,17 @@ test('community package flow works end-to-end through capability handlers', asyn
}),
{ onUnhandledRequest: 'bypass' },
)
+ const queuedActivity: Array = []
const testEnv = {
...env,
CLOUDFLARE_ACCOUNT_ID: mockAccountId,
CLOUDFLARE_API_TOKEN: 'artifacts-test-token',
CLOUDFLARE_API_BASE_URL: artifactsApiBaseUrl,
+ COMMUNITY_ACTIVITY_DISPATCH_QUEUE: {
+ async send(message: CommunityActivityDispatchQueueMessage) {
+ queuedActivity.push(message)
+ },
+ },
} as Env
const unique = crypto.randomUUID()
@@ -262,6 +270,13 @@ test('community package flow works end-to-end through capability handlers', asyn
]),
)
expect(await countSavedPackagesForUser(forker.userId)).toBe(0)
+ expect(queuedActivity).toEqual([
+ {
+ eventId: expect.any(String),
+ kind: 'fork',
+ activityId: forkResult.fork_id,
+ },
+ ])
const forkedSource = await env.APP_DB.prepare(
`SELECT id, user_id, entity_id, published_commit
@@ -309,6 +324,47 @@ test('community package flow works end-to-end through capability handlers', asyn
)
expect(ratedListing.rating_count).toBe(1)
expect(ratedListing.average_stars).toBe(5)
+ expect(ratedListing.fork_count).toBe(1)
+ expect(queuedActivity).toEqual([
+ {
+ eventId: expect.any(String),
+ kind: 'fork',
+ activityId: forkResult.fork_id,
+ },
+ {
+ eventId: expect.any(String),
+ kind: 'rating',
+ activityId: expect.any(String),
+ },
+ ])
+ const activity = await listCommunityActivityForAdmin({
+ db: testEnv.APP_DB,
+ listingId,
+ pageSize: 10,
+ })
+ expect(activity).toMatchObject({
+ total: 2,
+ page: 1,
+ pageSize: 10,
+ })
+ expect(activity.items).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ kind: 'fork',
+ listingId,
+ listingName: `@usera/${kodyId}`,
+ listingKodyId: kodyId,
+ actingUsername: 'userb',
+ }),
+ expect.objectContaining({
+ kind: 'rating',
+ listingId,
+ actingUsername: 'userb',
+ stars: 5,
+ adaptationEffort: 1,
+ }),
+ ]),
+ )
// Admin curation: trust pins to the reviewed commit and the effective
// mark drops as soon as the owner republishes new content.
@@ -505,6 +561,11 @@ test('one-click install publishes clean listings and keeps unresolvable forks in
CLOUDFLARE_ACCOUNT_ID: mockAccountId,
CLOUDFLARE_API_TOKEN: 'artifacts-test-token',
CLOUDFLARE_API_BASE_URL: artifactsApiBaseUrl,
+ COMMUNITY_ACTIVITY_DISPATCH_QUEUE: {
+ async send(_message: CommunityActivityDispatchQueueMessage) {
+ return undefined
+ },
+ },
} as Env
const unique = crypto.randomUUID()
diff --git a/packages/worker/src/community/community-service.node.test.ts b/packages/worker/src/community/community-service.node.test.ts
index cdc9845291..4cf1b30ec9 100644
--- a/packages/worker/src/community/community-service.node.test.ts
+++ b/packages/worker/src/community/community-service.node.test.ts
@@ -3,6 +3,7 @@ import type * as CommunityRepo from './repo.ts'
import { type CommunityListingRecord } from './types.ts'
const mockModule = vi.hoisted(() => ({
+ enqueueCommunityActivityDispatch: vi.fn(),
getSavedPackageById: vi.fn(),
loadPackageSourceBySourceId: vi.fn(),
getCommunityBan: vi.fn(),
@@ -38,6 +39,11 @@ const mockModule = vi.hoisted(() => ({
resolveCommunityReportRow: vi.fn(),
}))
+vi.mock('./activity-dispatch-queue-producer.ts', () => ({
+ enqueueCommunityActivityDispatch: (...args: Array) =>
+ mockModule.enqueueCommunityActivityDispatch(...args),
+}))
+
vi.mock('#worker/package-registry/repo.ts', () => ({
getSavedPackageById: (...args: Array) =>
mockModule.getSavedPackageById(...args),
@@ -163,12 +169,16 @@ const testCommunityAssets = {
truncated: false,
})),
} as unknown as R2Bucket
+const testCommunityActivityQueue = {
+ send: vi.fn(),
+} as unknown as Queue
function createEnv() {
return {
APP_DB: {} as D1Database,
BUNDLE_ARTIFACTS_KV: testBundleArtifactsKv,
COMMUNITY_ASSETS: testCommunityAssets,
+ COMMUNITY_ACTIVITY_DISPATCH_QUEUE: testCommunityActivityQueue,
} as Env
}
@@ -727,6 +737,16 @@ test('rateCommunityListing rejects owner self-ratings and persists valid ratings
targetKodyId: 'discord-gateway',
createdAt: '2026-07-01T00:00:00.000Z',
})
+ mockModule.upsertCommunityRating.mockResolvedValue({
+ id: 'rating-1',
+ listingId: 'listing-1',
+ userId: 'user-2',
+ stars: 5,
+ adaptationEffort: 2,
+ note: null,
+ createdAt: '2026-07-01T00:00:00.000Z',
+ updatedAt: '2026-07-01T00:00:00.000Z',
+ })
await expect(
rateCommunityListing({
@@ -767,6 +787,11 @@ test('rateCommunityListing rejects owner self-ratings and persists valid ratings
adaptation_effort: 2,
}),
)
+ expect(mockModule.enqueueCommunityActivityDispatch).toHaveBeenCalledWith({
+ queue: expect.anything(),
+ kind: 'rating',
+ activityId: 'rating-1',
+ })
})
test('forkCommunityListing creates inert source without saved package row', async () => {
@@ -830,8 +855,15 @@ test('forkCommunityListing creates inert source without saved package row', asyn
expect.anything(),
expect.objectContaining({
target_kody_id: 'my-discord-gateway',
+ listing_name: '@owner/discord-gateway',
+ listing_kody_id: 'discord-gateway',
}),
)
+ expect(mockModule.enqueueCommunityActivityDispatch).toHaveBeenCalledWith({
+ queue: expect.anything(),
+ kind: 'fork',
+ activityId: expect.any(String),
+ })
})
test('forkCommunityListing rejects repeat fork without a new kody_id', async () => {
diff --git a/packages/worker/src/community/errors.ts b/packages/worker/src/community/errors.ts
index a31de09c3c..78401d6ca8 100644
--- a/packages/worker/src/community/errors.ts
+++ b/packages/worker/src/community/errors.ts
@@ -4,3 +4,17 @@ export class CommunityActionError extends Error {
this.name = 'CommunityActionError'
}
}
+
+export class CommunityActivityDispatchCancelledError extends Error {
+ readonly kind: string
+ readonly activityId: string
+
+ constructor(input: { kind: string; activityId: string }) {
+ super(
+ `Community ${input.kind} activity "${input.activityId}" no longer exists.`,
+ )
+ this.name = 'CommunityActivityDispatchCancelledError'
+ this.kind = input.kind
+ this.activityId = input.activityId
+ }
+}
diff --git a/packages/worker/src/community/repo.ts b/packages/worker/src/community/repo.ts
index a696034dc2..e52bf78d9d 100644
--- a/packages/worker/src/community/repo.ts
+++ b/packages/worker/src/community/repo.ts
@@ -1,6 +1,8 @@
import { chunkArray } from '@kody-internal/shared/chunk.ts'
import { parseTagsJson } from '@kody-internal/shared/tags-json.ts'
import {
+ type CommunityActivityKind,
+ type CommunityActivityRecord,
type CommunityBanRecord,
type CommunityBanRow,
type CommunityForkRecord,
@@ -9,6 +11,7 @@ import {
type CommunityListingRow,
type CommunityListingStatus,
type CommunityRatingAggregate,
+ type CommunityRatingRecord,
type CommunityRatingRow,
type CommunityReportRecord,
type CommunityReportRow,
@@ -73,6 +76,46 @@ function mapCommunityForkRow(
}
}
+function mapCommunityRatingRow(
+ row: Record,
+): CommunityRatingRecord {
+ return {
+ id: String(row['id']),
+ listingId: String(row['listing_id']),
+ userId: String(row['user_id']),
+ stars: Number(row['stars']),
+ adaptationEffort: Number(row['adaptation_effort']),
+ note: row['note'] == null ? null : String(row['note']),
+ createdAt: String(row['created_at']),
+ updatedAt: String(row['updated_at']),
+ }
+}
+
+function mapCommunityActivityRow(
+ row: Record,
+): CommunityActivityRecord {
+ const shared = {
+ id: String(row['id']),
+ listingId: String(row['listing_id']),
+ listingName: String(row['listing_name']),
+ listingKodyId: String(row['listing_kody_id']),
+ actingUsername:
+ row['acting_username'] == null ? null : String(row['acting_username']),
+ occurredAt: String(row['occurred_at']),
+ }
+ const kind = String(row['kind'])
+ if (kind === 'fork') return { ...shared, kind }
+ if (kind === 'rating') {
+ return {
+ ...shared,
+ kind,
+ stars: Number(row['stars']),
+ adaptationEffort: Number(row['adaptation_effort']),
+ }
+ }
+ throw new Error(`Unsupported community activity kind: ${kind}`)
+}
+
function mapCommunityReportRow(
row: Record,
): CommunityReportRecord {
@@ -135,6 +178,66 @@ export const communityListingSourceJoin = `LEFT JOIN entity_sources
// sets are issued in chunks (90 leaves headroom for fixed bindings).
const maxSqlBindingsPerChunk = 90
+const communityActivityUnion = `SELECT
+ community_forks.id AS id,
+ 'fork' AS kind,
+ community_forks.listing_id AS listing_id,
+ COALESCE(
+ community_listings.name,
+ community_forks.listing_name,
+ '[deleted listing]'
+ ) AS listing_name,
+ COALESCE(
+ community_listings.kody_id,
+ community_forks.listing_kody_id,
+ '[unknown]'
+ ) AS listing_kody_id,
+ users.username AS acting_username,
+ community_forks.created_at AS occurred_at,
+ NULL AS stars,
+ NULL AS adaptation_effort
+FROM community_forks
+LEFT JOIN community_listings
+ ON community_listings.id = community_forks.listing_id
+LEFT JOIN users
+ ON users.stable_user_id = community_forks.forker_user_id
+UNION ALL
+SELECT
+ community_ratings.id AS id,
+ 'rating' AS kind,
+ community_listings.id AS listing_id,
+ community_listings.name AS listing_name,
+ community_listings.kody_id AS listing_kody_id,
+ users.username AS acting_username,
+ community_ratings.updated_at AS occurred_at,
+ community_ratings.stars AS stars,
+ community_ratings.adaptation_effort AS adaptation_effort
+FROM community_ratings
+INNER JOIN community_listings
+ ON community_listings.id = community_ratings.listing_id
+LEFT JOIN users
+ ON users.stable_user_id = community_ratings.user_id`
+
+function buildCommunityActivityFilters(input: {
+ kind?: CommunityActivityKind
+ listingId?: string
+}) {
+ const filters: Array = []
+ const bindings: Array = []
+ if (input.kind !== undefined) {
+ filters.push('activity.kind = ?')
+ bindings.push(input.kind)
+ }
+ if (input.listingId !== undefined) {
+ filters.push('activity.listing_id = ?')
+ bindings.push(input.listingId)
+ }
+ return {
+ where: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '',
+ bindings,
+ }
+}
+
const communityListingSearchTextColumns = [
'name',
'kody_id',
@@ -497,14 +600,19 @@ export async function setCommunityListingStatus(
export async function insertCommunityFork(
db: D1Database,
- row: Omit & { created_at?: string },
+ row: Omit & {
+ listing_name: string
+ listing_kody_id: string
+ created_at?: string
+ },
): Promise {
await db
.prepare(
`INSERT INTO community_forks (
id, listing_id, forker_user_id, origin_commit, forked_package_id,
- forked_source_id, target_kody_id, created_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ forked_source_id, target_kody_id, listing_name, listing_kody_id,
+ created_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
row.id,
@@ -514,6 +622,8 @@ export async function insertCommunityFork(
row.forked_package_id,
row.forked_source_id,
row.target_kody_id,
+ row.listing_name,
+ row.listing_kody_id,
row.created_at ?? new Date().toISOString(),
)
.run()
@@ -584,15 +694,82 @@ export async function countCommunityForksByListingIds(
return counts
}
+export async function listCommunityActivityRowsForAdmin(
+ db: D1Database,
+ input: {
+ page: number
+ pageSize: number
+ kind?: CommunityActivityKind
+ listingId?: string
+ },
+): Promise<{
+ total: number
+ items: Array
+}> {
+ const filters = buildCommunityActivityFilters(input)
+ const count = await db
+ .prepare(
+ `SELECT COUNT(*) AS total
+ FROM (${communityActivityUnion}) AS activity
+ ${filters.where}`,
+ )
+ .bind(...filters.bindings)
+ .first<{ total: number }>()
+ const items = await listCommunityActivityPageRowsForAdmin(db, input)
+ return { total: Number(count?.total ?? 0), items }
+}
+
+export async function listCommunityActivityPageRowsForAdmin(
+ db: D1Database,
+ input: {
+ page: number
+ pageSize: number
+ kind?: CommunityActivityKind
+ listingId?: string
+ },
+): Promise> {
+ const filters = buildCommunityActivityFilters(input)
+ const result = await db
+ .prepare(
+ `SELECT activity.*
+ FROM (${communityActivityUnion}) AS activity
+ ${filters.where}
+ ORDER BY activity.occurred_at DESC, activity.id DESC
+ LIMIT ? OFFSET ?`,
+ )
+ .bind(
+ ...filters.bindings,
+ input.pageSize,
+ (input.page - 1) * input.pageSize,
+ )
+ .all>()
+ return (result.results ?? []).map(mapCommunityActivityRow)
+}
+
+export async function getCommunityActivityByIdForAdmin(
+ db: D1Database,
+ input: { kind: CommunityActivityKind; activityId: string },
+): Promise {
+ const row = await db
+ .prepare(
+ `SELECT activity.*
+ FROM (${communityActivityUnion}) AS activity
+ WHERE activity.kind = ? AND activity.id = ?`,
+ )
+ .bind(input.kind, input.activityId)
+ .first>()
+ return row ? mapCommunityActivityRow(row) : null
+}
+
export async function upsertCommunityRating(
db: D1Database,
row: Omit & {
created_at?: string
updated_at?: string
},
-): Promise {
+): Promise {
const now = new Date().toISOString()
- await db
+ const persisted = await db
.prepare(
`INSERT INTO community_ratings (
id, listing_id, user_id, stars, adaptation_effort, note, created_at, updated_at
@@ -601,7 +778,9 @@ export async function upsertCommunityRating(
stars = excluded.stars,
adaptation_effort = excluded.adaptation_effort,
note = excluded.note,
- updated_at = excluded.updated_at`,
+ updated_at = excluded.updated_at
+ RETURNING id, listing_id, user_id, stars, adaptation_effort, note,
+ created_at, updated_at`,
)
.bind(
row.id,
@@ -613,7 +792,11 @@ export async function upsertCommunityRating(
row.created_at ?? now,
row.updated_at ?? now,
)
- .run()
+ .first>()
+ if (!persisted) {
+ throw new Error('Community rating upsert did not return a row.')
+ }
+ return mapCommunityRatingRow(persisted)
}
export async function deleteCommunityRatingsByListingId(
diff --git a/packages/worker/src/community/service.ts b/packages/worker/src/community/service.ts
index 88a308caad..223e05d516 100644
--- a/packages/worker/src/community/service.ts
+++ b/packages/worker/src/community/service.ts
@@ -19,16 +19,20 @@ import { ensureEntitySource } from '#worker/repo/source-service.ts'
import { syncArtifactSourceSnapshot } from '#worker/repo/source-sync.ts'
import { assertPackageNotPrivateForCommunityPublish } from '#worker/package-registry/package-private.ts'
import { assertKodyDescriptionLength } from '#worker/package-registry/types.ts'
+import { enqueueCommunityActivityDispatch } from './activity-dispatch-queue-producer.ts'
import { CommunityActionError } from './errors.ts'
import {
countCommunityForksByListingIds,
deleteCommunityListing,
deleteCommunityRatingsByListingId,
getCommunityBan,
+ getCommunityActivityByIdForAdmin,
getCommunityForkByListingAndUser,
getCommunityListingById,
getCommunityListingByOwnerAndPackage,
listCommunityForksByListingAndUser,
+ listCommunityActivityPageRowsForAdmin,
+ listCommunityActivityRowsForAdmin,
getCommunityRatingAggregatesByListingId,
getCommunityRatingAggregatesByListingIds,
getCommunityReportById,
@@ -71,6 +75,8 @@ import {
import {
type CommunityListingRecord,
type CommunityListingWithAggregates,
+ type CommunityActivityKind,
+ type CommunityRatingRecord,
type CommunityReportRecord,
type CommunityReportResolutionAction,
type ForkCommunityListingResult,
@@ -79,6 +85,8 @@ import {
const communityReadmeMaxChars = 20_000
const communityBayesianPriorMean = 3.25
const communityBayesianPriorWeight = 5
+const communityActivityDefaultPageSize = 20
+const communityActivityMaxPageSize = 100
const intentHeadingPattern = /^##\s+intent\b/im
// Offline deterministic embeddings produce small positive cosine scores for
@@ -94,6 +102,34 @@ export const COMMUNITY_SEARCH_VECTOR_MATCH_THRESHOLD = 0.12
// a materialized score column if the catalog ever approaches this size.
export const COMMUNITY_SEARCH_CANDIDATE_LIMIT = 500
+function normalizeCommunityActivityPage(value: number | undefined) {
+ if (value === undefined || !Number.isFinite(value)) return 1
+ return Math.max(1, Math.trunc(value))
+}
+
+function normalizeCommunityActivityPageSize(value: number | undefined) {
+ if (value === undefined || !Number.isFinite(value)) {
+ return communityActivityDefaultPageSize
+ }
+ return Math.min(communityActivityMaxPageSize, Math.max(1, Math.trunc(value)))
+}
+
+async function enqueueRecordedCommunityActivity(input: {
+ env: Env
+ kind: CommunityActivityKind
+ activityId: string
+}) {
+ try {
+ await enqueueCommunityActivityDispatch({
+ queue: input.env.COMMUNITY_ACTIVITY_DISPATCH_QUEUE,
+ kind: input.kind,
+ activityId: input.activityId,
+ })
+ } catch (error) {
+ console.error('community-activity-dispatch-enqueue-failed', error)
+ }
+}
+
async function deleteCommunityIconAssetsBestEffort(input: {
env: Env
listingId: string
@@ -749,6 +785,46 @@ export async function searchCommunityListings(input: {
.map((entry) => entry.listing)
}
+export async function listCommunityActivityForAdmin(input: {
+ db: D1Database
+ page?: number
+ pageSize?: number
+ kind?: CommunityActivityKind
+ listingId?: string
+}) {
+ let page = normalizeCommunityActivityPage(input.page)
+ const pageSize = normalizeCommunityActivityPageSize(input.pageSize)
+ const query = {
+ page,
+ pageSize,
+ kind: input.kind,
+ listingId: input.listingId,
+ }
+ const result = await listCommunityActivityRowsForAdmin(input.db, query)
+ const lastPage = Math.ceil(result.total / pageSize)
+ if (result.total > 0 && page > lastPage) {
+ page = lastPage
+ result.items = await listCommunityActivityPageRowsForAdmin(input.db, {
+ ...query,
+ page,
+ })
+ }
+ return {
+ ...result,
+ page,
+ pageSize,
+ }
+}
+
+export async function getCommunityActivityForAdmin(input: {
+ db: D1Database
+ kind: CommunityActivityKind
+ activityId: string
+}) {
+ const activity = await getCommunityActivityByIdForAdmin(input.db, input)
+ return activity
+}
+
export async function forkCommunityListing(input: {
env: Env
baseUrl: string
@@ -874,6 +950,13 @@ export async function forkCommunityListing(input: {
forked_package_id: packageId,
forked_source_id: ensuredSource.id,
target_kody_id: targetKodyId,
+ listing_name: listing.name,
+ listing_kody_id: listing.kodyId,
+ })
+ await enqueueRecordedCommunityActivity({
+ env: input.env,
+ kind: 'fork',
+ activityId: forkId,
})
invalidateCommunityPublicCache()
@@ -907,7 +990,7 @@ export async function rateCommunityListing(input: {
stars: number
adaptationEffort: number
note?: string
-}): Promise {
+}): Promise {
await assertNotCommunityBanned(input.env.APP_DB, input.userId)
const listing = await getCommunityListingById(input.env.APP_DB, {
@@ -929,7 +1012,7 @@ export async function rateCommunityListing(input: {
throw new Error('rate after forking')
}
- await upsertCommunityRating(input.env.APP_DB, {
+ const rating = await upsertCommunityRating(input.env.APP_DB, {
id: crypto.randomUUID(),
listing_id: input.listingId,
user_id: input.userId,
@@ -937,7 +1020,13 @@ export async function rateCommunityListing(input: {
adaptation_effort: input.adaptationEffort,
note: input.note?.trim() || null,
})
+ await enqueueRecordedCommunityActivity({
+ env: input.env,
+ kind: 'rating',
+ activityId: rating.id,
+ })
invalidateCommunityPublicCache()
+ return rating
}
export async function reportCommunityListing(input: {
diff --git a/packages/worker/src/community/types.ts b/packages/worker/src/community/types.ts
index 0afad941db..17bc32ee86 100644
--- a/packages/worker/src/community/types.ts
+++ b/packages/worker/src/community/types.ts
@@ -133,6 +133,29 @@ export type CommunityStargazer = {
export type CommunityListingWithAggregates = CommunityListingRecord &
CommunityListingAggregates
+export const communityActivityKinds = ['fork', 'rating'] as const
+
+export type CommunityActivityKind = (typeof communityActivityKinds)[number]
+
+type CommunityActivityRecordBase = {
+ id: string
+ listingId: string
+ listingName: string
+ listingKodyId: string
+ actingUsername: string | null
+ occurredAt: string
+}
+
+export type CommunityActivityRecord =
+ | (CommunityActivityRecordBase & {
+ kind: 'fork'
+ })
+ | (CommunityActivityRecordBase & {
+ kind: 'rating'
+ stars: number
+ adaptationEffort: number
+ })
+
export type CommunityForkRow = {
id: string
listing_id: string
diff --git a/packages/worker/src/maintenance-handler.ts b/packages/worker/src/maintenance-handler.ts
index a48f92c899..9d5e5fde72 100644
--- a/packages/worker/src/maintenance-handler.ts
+++ b/packages/worker/src/maintenance-handler.ts
@@ -72,11 +72,12 @@ export const stableUserIdBackfillBatchSize = 100
/**
* Persist the stable user id (SHA-256 of the normalized email) for every
- * legacy users row that still has a NULL `stable_user_id`, so lookups hit the
- * partial unique index (migration 0052) instead of the scan fallback. Pages
- * with a keyset on `id` in batches of 100 to stay within D1 limits on large
- * tables. Per-row failures (for example two rows whose emails normalize to
- * the same hash colliding on the unique index) are counted, not fatal.
+ * pre-materialization users row that still has a NULL `stable_user_id`. The
+ * production deploy runs this migration before installing code that requires
+ * indexed stable ids. Pages with a keyset on `id` in batches of 100 to stay
+ * within D1 limits on large tables. Per-row failures (for example two rows
+ * whose emails normalize to the same hash colliding on the unique index) are
+ * counted for the deploy step to reject.
*/
export async function backfillStableUserIds(input: {
db: D1Database
@@ -121,8 +122,8 @@ export async function backfillStableUserIds(input: {
/**
* `POST /__maintenance/backfill-stable-user-ids`: one-off/idempotent backfill
- * of `users.stable_user_id` for legacy rows, authenticated like the reindex
- * endpoints (Bearer `CAPABILITY_REINDEX_SECRET`).
+ * of `users.stable_user_id` for the production deploy migration, authenticated
+ * like the reindex endpoints (Bearer `CAPABILITY_REINDEX_SECRET`).
*/
export async function handleStableUserIdBackfillRequest(
request: Request,
diff --git a/packages/worker/src/mcp/capabilities/admin-community-activity.node.test.ts b/packages/worker/src/mcp/capabilities/admin-community-activity.node.test.ts
new file mode 100644
index 0000000000..db9c2cf9ee
--- /dev/null
+++ b/packages/worker/src/mcp/capabilities/admin-community-activity.node.test.ts
@@ -0,0 +1,99 @@
+import { expect, test, vi } from 'vitest'
+import { createMcpCallerContext } from '#mcp/context.ts'
+import {
+ auditEventSummaries,
+ logAuditEventSpy,
+} from '#worker/test-support/audit-log-spy.ts'
+import { adminCommunityActivityListCapability } from './admin/admin-community-activity-list.ts'
+
+const mocks = vi.hoisted(() => ({
+ listCommunityActivityForAdmin: vi.fn(),
+}))
+
+vi.mock('#worker/community/service.ts', () => ({
+ listCommunityActivityForAdmin: mocks.listCommunityActivityForAdmin,
+}))
+
+function createContext(roles: Array) {
+ return {
+ env: { APP_DB: {} as D1Database } as Env,
+ callerContext: createMcpCallerContext({
+ baseUrl: 'https://heykody.dev',
+ user: {
+ userId: 'actor-1',
+ username: 'actor',
+ email: 'actor@example.com',
+ roles,
+ },
+ }),
+ }
+}
+
+test('admin community activity list enforces admin role, filters, paginates, and returns only activity metadata', async () => {
+ await expect(
+ adminCommunityActivityListCapability.handler({}, createContext(['user'])),
+ ).rejects.toThrow('lacks required role "admin"')
+ expect(mocks.listCommunityActivityForAdmin).not.toHaveBeenCalled()
+ expect(logAuditEventSpy).not.toHaveBeenCalled()
+
+ mocks.listCommunityActivityForAdmin.mockResolvedValue({
+ total: 3,
+ page: 2,
+ pageSize: 2,
+ items: [
+ {
+ id: 'rating-1',
+ kind: 'rating',
+ listingId: 'listing-1',
+ listingName: '@owner/package',
+ listingKodyId: 'package',
+ actingUsername: 'rater',
+ occurredAt: '2026-07-20T01:00:00.000Z',
+ stars: 5,
+ adaptationEffort: 2,
+ },
+ ],
+ })
+
+ const result = await adminCommunityActivityListCapability.handler(
+ {
+ page: 2,
+ pageSize: 2,
+ kind: 'rating',
+ listing_id: 'listing-1',
+ },
+ createContext(['admin']),
+ )
+
+ expect(mocks.listCommunityActivityForAdmin).toHaveBeenCalledWith({
+ db: expect.anything(),
+ page: 2,
+ pageSize: 2,
+ kind: 'rating',
+ listingId: 'listing-1',
+ })
+ expect(result).toEqual({
+ total: 3,
+ page: 2,
+ pageSize: 2,
+ activity: [
+ {
+ id: 'rating-1',
+ kind: 'rating',
+ listing_id: 'listing-1',
+ listing_name: '@owner/package',
+ listing_kody_id: 'package',
+ acting_username: 'rater',
+ occurred_at: '2026-07-20T01:00:00.000Z',
+ stars: 5,
+ adaptation_effort: 2,
+ },
+ ],
+ })
+ expect(result.activity[0]).not.toHaveProperty('note')
+ expect(result.activity[0]).not.toHaveProperty('user_id')
+ expect(result.activity[0]).not.toHaveProperty('package_source')
+ expect(auditEventSummaries()).toEqual([
+ 'admin_community_activity_list:success',
+ ])
+})
diff --git a/packages/worker/src/mcp/capabilities/admin/admin-community-activity-list.ts b/packages/worker/src/mcp/capabilities/admin/admin-community-activity-list.ts
new file mode 100644
index 0000000000..a27cd7af2c
--- /dev/null
+++ b/packages/worker/src/mcp/capabilities/admin/admin-community-activity-list.ts
@@ -0,0 +1,131 @@
+import { z } from 'zod'
+import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts'
+import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts'
+import { listCommunityActivityForAdmin } from '#worker/community/service.ts'
+import {
+ communityActivityKinds,
+ type CommunityActivityRecord,
+} from '#worker/community/types.ts'
+import {
+ adminCapabilityAccess,
+ auditAdminCapabilityInvocation,
+} from './admin-shared.ts'
+
+const commonActivityFields = {
+ id: z.string(),
+ listing_id: z.string(),
+ listing_name: z.string(),
+ listing_kody_id: z.string(),
+ acting_username: z.string().nullable(),
+ occurred_at: z.string(),
+}
+
+const communityActivitySchema = z.discriminatedUnion('kind', [
+ z.object({
+ ...commonActivityFields,
+ kind: z.literal('fork'),
+ }),
+ z.object({
+ ...commonActivityFields,
+ kind: z.literal('rating'),
+ stars: z.number().int().min(1).max(5),
+ adaptation_effort: z.number().int().min(1).max(5),
+ }),
+])
+
+function formatCommunityActivity(activity: CommunityActivityRecord) {
+ const common = {
+ id: activity.id,
+ listing_id: activity.listingId,
+ listing_name: activity.listingName,
+ listing_kody_id: activity.listingKodyId,
+ acting_username: activity.actingUsername,
+ occurred_at: activity.occurredAt,
+ }
+ switch (activity.kind) {
+ case 'fork':
+ return { ...common, kind: activity.kind }
+ case 'rating':
+ return {
+ ...common,
+ kind: activity.kind,
+ stars: activity.stars,
+ adaptation_effort: activity.adaptationEffort,
+ }
+ default: {
+ const exhaustive: never = activity
+ throw new Error(`Unsupported community activity: ${String(exhaustive)}`)
+ }
+ }
+}
+
+export const adminCommunityActivityListCapability = defineDomainCapability(
+ capabilityDomainNames.admin,
+ {
+ ...adminCapabilityAccess,
+ name: 'admin_community_activity_list',
+ description:
+ 'List admin-only metadata about who forked or rated public community listings and when. Fork rows include both agent forks and one-click installs because the existing data model records both identically. Returns no package source, rating notes, secrets, or unrelated user content.',
+ keywords: [
+ 'admin',
+ 'community',
+ 'activity',
+ 'forks',
+ 'installs',
+ 'ratings',
+ ],
+ inputSchema: z.object({
+ page: z
+ .number()
+ .int()
+ .min(1)
+ .optional()
+ .describe('One-indexed activity page. Defaults to 1.'),
+ pageSize: z
+ .number()
+ .int()
+ .min(1)
+ .max(100)
+ .optional()
+ .describe(
+ 'Community activity records per page. Defaults to 20 and maxes at 100.',
+ ),
+ kind: z
+ .enum(communityActivityKinds)
+ .optional()
+ .describe('Optional exact activity kind filter.'),
+ listing_id: z
+ .string()
+ .min(1)
+ .optional()
+ .describe('Optional exact community listing id filter.'),
+ }),
+ outputSchema: z.object({
+ total: z.number().int().nonnegative(),
+ page: z.number().int().positive(),
+ pageSize: z.number().int().positive(),
+ activity: z.array(communityActivitySchema),
+ }),
+ async handler(args, ctx) {
+ return auditAdminCapabilityInvocation(
+ ctx,
+ 'admin_community_activity_list',
+ async () => {
+ const result = await listCommunityActivityForAdmin({
+ db: ctx.env.APP_DB,
+ page: args.page,
+ pageSize: args.pageSize,
+ kind: args.kind,
+ listingId: args.listing_id,
+ })
+ return {
+ total: result.total,
+ page: result.page,
+ pageSize: result.pageSize,
+ activity: result.items.map(formatCommunityActivity),
+ }
+ },
+ )
+ },
+ },
+)
diff --git a/packages/worker/src/mcp/capabilities/admin/domain.ts b/packages/worker/src/mcp/capabilities/admin/domain.ts
index 0111fb19b8..52e0d69d5c 100644
--- a/packages/worker/src/mcp/capabilities/admin/domain.ts
+++ b/packages/worker/src/mcp/capabilities/admin/domain.ts
@@ -1,6 +1,7 @@
import { defineDomain } from '#mcp/capabilities/define-domain.ts'
import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts'
import { adminAuditLogQueryCapability } from './admin-audit-log-query.ts'
+import { adminCommunityActivityListCapability } from './admin-community-activity-list.ts'
import { adminFeatureFlagListCapability } from './admin-feature-flag-list.ts'
import { adminFeatureFlagOverrideCapability } from './admin-feature-flag-override.ts'
import { adminFeatureFlagSetCapability } from './admin-feature-flag-set.ts'
@@ -18,7 +19,7 @@ import { adminUserUpdateCapability } from './admin-user-update.ts'
export const adminDomain = defineDomain({
name: capabilityDomainNames.admin,
description:
- 'Admin-only operator capabilities for account metadata, feature flags, operator-owned system email, and attributed platform feedback users explicitly submit for admin review; never exposes unrelated user content such as packages, secrets, memories, jobs, or user inbox email.',
+ 'Admin-only operator capabilities for account metadata, feature flags, operator-owned system email, attributed platform feedback users explicitly submit for admin review, and metadata about activity on public community listings; never exposes private package source or unrelated user content such as secrets, memories, jobs, or user inbox email.',
keywords: [
'admin',
'rbac',
@@ -30,6 +31,7 @@ export const adminDomain = defineDomain({
'feature flags',
'system email',
'platform feedback',
+ 'community activity',
],
capabilities: [
adminUserListCapability,
@@ -46,5 +48,6 @@ export const adminDomain = defineDomain({
adminPlatformFeedbackListCapability,
adminPlatformFeedbackGetCapability,
adminPlatformFeedbackUpdateCapability,
+ adminCommunityActivityListCapability,
],
})
diff --git a/packages/worker/src/mcp/capabilities/packages/list-package-subscriptions.ts b/packages/worker/src/mcp/capabilities/packages/list-package-subscriptions.ts
index ec70e569d6..a514612ad8 100644
--- a/packages/worker/src/mcp/capabilities/packages/list-package-subscriptions.ts
+++ b/packages/worker/src/mcp/capabilities/packages/list-package-subscriptions.ts
@@ -20,7 +20,7 @@ export const listPackageSubscriptionsCapability = defineDomainCapability(
{
name: 'package_subscriptions_list',
description:
- 'List package.json#kody.subscriptions entries for the signed-in user, optionally filtered by exact event topic. Use this to discover package event handlers such as email receipt, delivery-update, or admin platform-feedback notification subscribers before debugging dispatch or building fan-out. The admin-only platform.feedback.submitted event carries explicitly approved untrusted feedback, submitter account identity, and a trusted admin deep link for integrations such as Discord. Declaring an admin-only topic does not grant delivery; dispatch checks the package owner role fresh at delivery time.',
+ 'List package.json#kody.subscriptions entries for the signed-in user, optionally filtered by exact event topic. Use this to discover package event handlers such as email receipt, delivery-update, admin platform-feedback, or admin community-activity notification subscribers before debugging dispatch or building fan-out. Admin-only topics carry only their documented narrow metadata, and declaring one does not grant delivery; dispatch checks the package owner role fresh at delivery time.',
keywords: [
'package',
'package.json#kody.subscriptions',
@@ -37,6 +37,8 @@ export const listPackageSubscriptionsCapability = defineDomainCapability(
'system email',
'platform.feedback.submitted',
'platform feedback submitted',
+ 'community.activity.recorded',
+ 'community activity recorded',
'inbound email',
'list',
'discover',
@@ -51,7 +53,7 @@ export const listPackageSubscriptionsCapability = defineDomainCapability(
.min(1)
.optional()
.describe(
- 'Optional exact event topic filter such as "email.message.received", "email.message.delivery.updated", or "platform.feedback.submitted".',
+ 'Optional exact event topic filter such as "email.message.received", "platform.feedback.submitted", or "community.activity.recorded".',
),
}),
outputSchema: z.object({
diff --git a/packages/worker/src/queue-handler.node.test.ts b/packages/worker/src/queue-handler.node.test.ts
index c9c2da5bc5..5c480677c9 100644
--- a/packages/worker/src/queue-handler.node.test.ts
+++ b/packages/worker/src/queue-handler.node.test.ts
@@ -2,10 +2,20 @@ import { expect, test, vi } from 'vitest'
import { consoleError } from '#worker/test-support/console-spies.ts'
const mocks = vi.hoisted(() => ({
+ handleCommunityActivityDispatchQueue: vi.fn(),
handleEmailDeliveryQueue: vi.fn(),
handlePlatformFeedbackDispatchQueue: vi.fn(),
}))
+vi.mock('#worker/community/activity-dispatch-queue.ts', () => ({
+ handleCommunityActivityDispatchQueue:
+ mocks.handleCommunityActivityDispatchQueue,
+}))
+
+vi.mock('#worker/community/activity-dispatch-queue-names.ts', () => ({
+ communityActivityDispatchQueueName: 'kody-community-activity-dispatch',
+}))
+
vi.mock('#worker/email/delivery-queue.ts', () => ({
emailDeliveryQueueName: 'kody-email-delivery',
handleEmailDeliveryQueue: mocks.handleEmailDeliveryQueue,
@@ -27,16 +37,18 @@ function createBatch(queue: string) {
} as unknown as MessageBatch
}
-test('worker queue routing isolates email, platform feedback, and unknown queues', async () => {
+test('worker queue routing isolates known queues and retries unknown queues', async () => {
consoleError.mockImplementation(() => {})
const env = {} as Env
const ctx = {} as ExecutionContext
const emailBatch = createBatch('kody-email-delivery')
const feedbackBatch = createBatch('kody-platform-feedback-dispatch')
+ const communityActivityBatch = createBatch('kody-community-activity-dispatch')
const unknownBatch = createBatch('unexpected-queue')
await handleQueueBatch(emailBatch, env, ctx)
await handleQueueBatch(feedbackBatch, env, ctx)
+ await handleQueueBatch(communityActivityBatch, env, ctx)
await handleQueueBatch(unknownBatch, env, ctx)
expect(mocks.handleEmailDeliveryQueue).toHaveBeenCalledTimes(1)
@@ -51,6 +63,12 @@ test('worker queue routing isolates email, platform feedback, and unknown queues
env,
ctx,
)
+ expect(mocks.handleCommunityActivityDispatchQueue).toHaveBeenCalledTimes(1)
+ expect(mocks.handleCommunityActivityDispatchQueue).toHaveBeenCalledWith(
+ communityActivityBatch,
+ env,
+ ctx,
+ )
expect(unknownBatch.retryAll).toHaveBeenCalledWith({ delaySeconds: 30 })
expect(consoleError).toHaveBeenCalledTimes(1)
expect(consoleError).toHaveBeenCalledWith('unknown-worker-queue', {
diff --git a/packages/worker/src/queue-handler.ts b/packages/worker/src/queue-handler.ts
index 66cb244784..da00c24539 100644
--- a/packages/worker/src/queue-handler.ts
+++ b/packages/worker/src/queue-handler.ts
@@ -2,6 +2,8 @@ import {
emailDeliveryQueueName,
handleEmailDeliveryQueue,
} from '#worker/email/delivery-queue.ts'
+import { handleCommunityActivityDispatchQueue } from '#worker/community/activity-dispatch-queue.ts'
+import { communityActivityDispatchQueueName } from '#worker/community/activity-dispatch-queue-names.ts'
import { handlePlatformFeedbackDispatchQueue } from '#worker/platform-feedback/dispatch-queue.ts'
import { platformFeedbackDispatchQueueName } from '#worker/platform-feedback/dispatch-queue-names.ts'
@@ -19,6 +21,9 @@ export async function handleQueueBatch(
case platformFeedbackDispatchQueueName:
await handlePlatformFeedbackDispatchQueue(batch, env, ctx)
return
+ case communityActivityDispatchQueueName:
+ await handleCommunityActivityDispatchQueue(batch, env, ctx)
+ return
default:
console.error('unknown-worker-queue', { queue: batch.queue })
batch.retryAll({ delaySeconds: unknownQueueRetryDelaySeconds })
diff --git a/packages/worker/worker-configuration.d.ts b/packages/worker/worker-configuration.d.ts
index d0d01e0b24..0939bbede1 100644
--- a/packages/worker/worker-configuration.d.ts
+++ b/packages/worker/worker-configuration.d.ts
@@ -1,5 +1,5 @@
/* eslint-disable */
-// Generated by Wrangler by running `wrangler types --config=packages/worker/wrangler.jsonc --env=production ./packages/worker/worker-configuration.d.ts` (hash: 816b517c6a3018c2b75c6d650a825622)
+// Generated by Wrangler by running `wrangler types --config=packages/worker/wrangler.jsonc --env=production ./packages/worker/worker-configuration.d.ts` (hash: 1804a7ac08d09451a7e41600cb2d905e)
// Runtime types generated with workerd@1.20260630.1 2026-04-16 global_fetch_strictly_public,nodejs_compat
interface __BaseEnv_Env {
OAUTH_KV: KVNamespace;
@@ -11,6 +11,7 @@ interface __BaseEnv_Env {
EMAIL: SendEmail;
USAGE_EVENTS: AnalyticsEngineDataset;
PLATFORM_FEEDBACK_DISPATCH_QUEUE: Queue;
+ COMMUNITY_ACTIVITY_DISPATCH_QUEUE: Queue;
LOADER: WorkerLoader;
APP_LOADER: WorkerLoader;
AI: Ai;
diff --git a/packages/worker/wrangler.jsonc b/packages/worker/wrangler.jsonc
index a85d79fb51..d071767b50 100644
--- a/packages/worker/wrangler.jsonc
+++ b/packages/worker/wrangler.jsonc
@@ -166,6 +166,10 @@
"binding": "PLATFORM_FEEDBACK_DISPATCH_QUEUE",
"queue": "kody-platform-feedback-dispatch",
},
+ {
+ "binding": "COMMUNITY_ACTIVITY_DISPATCH_QUEUE",
+ "queue": "kody-community-activity-dispatch",
+ },
],
"consumers": [
{
@@ -182,6 +186,13 @@
"max_retries": 3,
"dead_letter_queue": "kody-platform-feedback-dispatch-dlq",
},
+ {
+ "queue": "kody-community-activity-dispatch",
+ "max_batch_size": 10,
+ "max_batch_timeout": 5,
+ "max_retries": 3,
+ "dead_letter_queue": "kody-community-activity-dispatch-dlq",
+ },
],
},
"kv_namespaces": [
diff --git a/tools/ci/production-queue-resources.node.test.ts b/tools/ci/production-queue-resources.node.test.ts
index 934f084860..e89acd836e 100644
--- a/tools/ci/production-queue-resources.node.test.ts
+++ b/tools/ci/production-queue-resources.node.test.ts
@@ -11,6 +11,10 @@ function createProductionEnv() {
binding: 'PLATFORM_FEEDBACK_DISPATCH_QUEUE',
queue: 'kody-platform-feedback-dispatch',
},
+ {
+ binding: 'COMMUNITY_ACTIVITY_DISPATCH_QUEUE',
+ queue: 'kody-community-activity-dispatch',
+ },
],
consumers: [
{
@@ -27,12 +31,19 @@ function createProductionEnv() {
max_retries: 3,
dead_letter_queue: 'kody-platform-feedback-dispatch-dlq',
},
+ {
+ queue: 'kody-community-activity-dispatch',
+ max_batch_size: 10,
+ max_batch_timeout: 5,
+ max_retries: 3,
+ dead_letter_queue: 'kody-community-activity-dispatch-dlq',
+ },
],
},
}
}
-test('production queue config requires both consumers and a consistent platform feedback producer', () => {
+test('production queue config requires all consumers and consistent producers', () => {
const wranglerConfig = parseJsonc<{
env: { production: Record }
}>(
@@ -52,6 +63,9 @@ test('production queue config requires both consumers and a consistent platform
platformFeedbackDispatchQueueName: 'kody-platform-feedback-dispatch',
platformFeedbackDispatchDeadLetterQueueName:
'kody-platform-feedback-dispatch-dlq',
+ communityActivityDispatchQueueName: 'kody-community-activity-dispatch',
+ communityActivityDispatchDeadLetterQueueName:
+ 'kody-community-activity-dispatch-dlq',
})
const missingFeedbackConsumer = createProductionEnv()
@@ -61,7 +75,7 @@ test('production queue config requires both consumers and a consistent platform
productionEnv: missingFeedbackConsumer,
configPath: 'wrangler.jsonc',
}),
- ).toThrow('exactly two production Queue consumers')
+ ).toThrow('exactly three production Queue consumers')
const mismatchedProducer = createProductionEnv()
mismatchedProducer.queues.producers[0] = {
@@ -76,4 +90,15 @@ test('production queue config requires both consumers and a consistent platform
).toThrow(
'must bind "PLATFORM_FEEDBACK_DISPATCH_QUEUE" to "kody-platform-feedback-dispatch"',
)
+
+ const missingCommunityProducer = createProductionEnv()
+ missingCommunityProducer.queues.producers.pop()
+ expect(() =>
+ parseProductionQueueResources({
+ productionEnv: missingCommunityProducer,
+ configPath: 'wrangler.jsonc',
+ }),
+ ).toThrow(
+ 'must bind "COMMUNITY_ACTIVITY_DISPATCH_QUEUE" to "kody-community-activity-dispatch"',
+ )
})
diff --git a/tools/ci/production-queue-resources.ts b/tools/ci/production-queue-resources.ts
index a2cabcb960..f0cb03d710 100644
--- a/tools/ci/production-queue-resources.ts
+++ b/tools/ci/production-queue-resources.ts
@@ -1,3 +1,8 @@
+import {
+ communityActivityDispatchDeadLetterQueueName,
+ communityActivityDispatchQueueBinding,
+ communityActivityDispatchQueueName,
+} from '../../packages/worker/src/community/activity-dispatch-queue-names.ts'
import {
platformFeedbackDispatchDeadLetterQueueName,
platformFeedbackDispatchQueueBinding,
@@ -55,9 +60,9 @@ export function parseProductionQueueResources(input: {
}
const queueConfig = queues as Record
const consumers = queueConfig.consumers
- if (!Array.isArray(consumers) || consumers.length !== 2) {
+ if (!Array.isArray(consumers) || consumers.length !== 3) {
throw new Error(
- `wrangler config "${input.configPath}" must define exactly two production Queue consumers.`,
+ `wrangler config "${input.configPath}" must define exactly three production Queue consumers.`,
)
}
const emailDelivery = readQueueConsumer({
@@ -72,6 +77,12 @@ export function parseProductionQueueResources(input: {
deadLetterQueueName: platformFeedbackDispatchDeadLetterQueueName,
configPath: input.configPath,
})
+ const communityActivityDispatch = readQueueConsumer({
+ consumers,
+ queueName: communityActivityDispatchQueueName,
+ deadLetterQueueName: communityActivityDispatchDeadLetterQueueName,
+ configPath: input.configPath,
+ })
const producers = queueConfig.producers
if (!Array.isArray(producers)) {
throw new Error(
@@ -97,11 +108,33 @@ export function parseProductionQueueResources(input: {
`wrangler config "${input.configPath}" must bind "${platformFeedbackDispatchQueueBinding}" to "${platformFeedbackDispatchQueueName}".`,
)
}
+ const communityActivityProducer = producers.find((entry) => {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
+ return false
+ return (
+ (entry as Record).binding ===
+ communityActivityDispatchQueueBinding
+ )
+ })
+ if (
+ !communityActivityProducer ||
+ typeof communityActivityProducer !== 'object' ||
+ Array.isArray(communityActivityProducer) ||
+ (communityActivityProducer as Record).queue !==
+ communityActivityDispatchQueueName
+ ) {
+ throw new Error(
+ `wrangler config "${input.configPath}" must bind "${communityActivityDispatchQueueBinding}" to "${communityActivityDispatchQueueName}".`,
+ )
+ }
return {
emailDeliveryQueueName: emailDelivery.queue,
emailDeliveryDeadLetterQueueName: emailDelivery.deadLetterQueue,
platformFeedbackDispatchQueueName: platformFeedbackDispatch.queue,
platformFeedbackDispatchDeadLetterQueueName:
platformFeedbackDispatch.deadLetterQueue,
+ communityActivityDispatchQueueName: communityActivityDispatch.queue,
+ communityActivityDispatchDeadLetterQueueName:
+ communityActivityDispatch.deadLetterQueue,
}
}
diff --git a/tools/ci/production-resources.ts b/tools/ci/production-resources.ts
index 10be3bd965..f74708780e 100644
--- a/tools/ci/production-resources.ts
+++ b/tools/ci/production-resources.ts
@@ -37,6 +37,8 @@ type ResolvedProductionBindings = {
emailDeliveryDeadLetterQueueName: string
platformFeedbackDispatchQueueName: string
platformFeedbackDispatchDeadLetterQueueName: string
+ communityActivityDispatchQueueName: string
+ communityActivityDispatchDeadLetterQueueName: string
}
function parseArgs(argv: Array): {
@@ -412,7 +414,7 @@ async function ensureProductionResources(options: CliOptions) {
kvTitleOverride: options.kvTitleOverride,
})
console.error(
- `Ensuring production resources for worker: ${bindings.workerName} (D1: ${bindings.d1DatabaseName}, OAuth KV: ${bindings.oauthKvTitle}, Bundle KV: ${bindings.bundleArtifactsKvTitle}, Community R2: ${bindings.communityAssetsBucketName}, Email R2: ${bindings.emailBlobsBucketName}, Email Queue: ${bindings.emailDeliveryQueueName}, Email DLQ: ${bindings.emailDeliveryDeadLetterQueueName}, Platform Feedback Queue: ${bindings.platformFeedbackDispatchQueueName}, Platform Feedback DLQ: ${bindings.platformFeedbackDispatchDeadLetterQueueName})`,
+ `Ensuring production resources for worker: ${bindings.workerName} (D1: ${bindings.d1DatabaseName}, OAuth KV: ${bindings.oauthKvTitle}, Bundle KV: ${bindings.bundleArtifactsKvTitle}, Community R2: ${bindings.communityAssetsBucketName}, Email R2: ${bindings.emailBlobsBucketName}, Email Queue: ${bindings.emailDeliveryQueueName}, Email DLQ: ${bindings.emailDeliveryDeadLetterQueueName}, Platform Feedback Queue: ${bindings.platformFeedbackDispatchQueueName}, Platform Feedback DLQ: ${bindings.platformFeedbackDispatchDeadLetterQueueName}, Community Activity Queue: ${bindings.communityActivityDispatchQueueName}, Community Activity DLQ: ${bindings.communityActivityDispatchDeadLetterQueueName})`,
)
const d1 = ensureD1Database({
@@ -471,6 +473,18 @@ async function ensureProductionResources(options: CliOptions) {
name: bindings.platformFeedbackDispatchDeadLetterQueueName,
dryRun: options.dryRun,
})
+ const communityActivityDispatchQueue = await ensureCloudflareQueue({
+ accountId: accountId ?? 'dry-run-account',
+ apiToken: apiToken ?? 'dry-run-token',
+ name: bindings.communityActivityDispatchQueueName,
+ dryRun: options.dryRun,
+ })
+ const communityActivityDispatchDeadLetterQueue = await ensureCloudflareQueue({
+ accountId: accountId ?? 'dry-run-account',
+ apiToken: apiToken ?? 'dry-run-token',
+ name: bindings.communityActivityDispatchDeadLetterQueueName,
+ dryRun: options.dryRun,
+ })
const emailSendingDomain = resolveEmailSendingDomain(options.dryRun)
const emailEventSubscription = await ensureEmailSendingEventSubscription({
accountId: accountId ?? 'dry-run-account',
@@ -520,6 +534,12 @@ async function ensureProductionResources(options: CliOptions) {
console.log(
`platform_feedback_dispatch_dead_letter_queue_name=${platformFeedbackDispatchDeadLetterQueue.name}`,
)
+ console.log(
+ `community_activity_dispatch_queue_name=${communityActivityDispatchQueue.name}`,
+ )
+ console.log(
+ `community_activity_dispatch_dead_letter_queue_name=${communityActivityDispatchDeadLetterQueue.name}`,
+ )
console.log(`email_event_subscription_id=${emailEventSubscription.id}`)
}