feat: promotions — runtime-fetchable promotional offers (#2961)

* feat: add promotions — runtime-fetchable promotional offers

Adds a standalone promotions entity so time-boxed promotional offers
can be created, activated, and expired at runtime without shipping a
CLI release.

- promotions table: slug, display fields, draft/active/ended status,
  time window, and a declarative CLI activation payload (provider,
  authChoiceId, plugin names, model refs, signup/docs/launch URLs)
- public API: GET /api/v1/promotions (active, in-window only, cached)
  and GET /api/v1/promotions/{slug} (hides drafts and pre-launch
  activations; serves ended state)
- homepage: active promotions render as cards via a public
  promotions.listActive query; section hidden when none are live
- admin writes via HTTP (POST create / {slug}/update / {slug}/status)
  and Convex mutations, both admin-gated with audit log entries
- management dashboard: Promotions page (admin-only) to create, edit,
  and activate/end promotions
- clawhub-admin CLI: promotions list/create/update/set-status

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: rebuild promotions form with proper labeled field grid

Replace the management search-row markup with Input/Textarea/Label UI
components in a dedicated responsive form grid (custom classes — the
legacy global .grid rule collides with Tailwind's grid utility).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: reject slug changes on non-draft promotions

Activated promotion slugs are referenced by external links and CLI
claim provenance; renaming them would break both. Drafts can still be
renamed freely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: refresh promotions at lifecycle boundaries

* fix: preserve published promotion history

* fix: align promotion visibility boundaries

* fix: preserve promotion timestamp integrity

* fix: harden promotion editor rendering

* fix: vary promotion queries by current time

* fix: paginate promotion history safely

* fix: keep ended promotions terminal

* fix: share promotion discovery cache

* fix: bound active promotion reads

* fix: align active promotion limits

* feat: publish promotions as a hosted feed (clawhub-promotions)

Adds a third hosted feed so OpenClaw clients can discover active
promotions through the same immutable-snapshot pipeline as the plugin
and skills catalogs (ETag/304 revalidation, CDN cache headers), with a
client cache fully separate from update checks.

- packages/schema: promotionsFeed wire contract (schemaVersion 1,
  deterministic serialization, window validation)
- convex/promotionsFeed.ts: publishInternal builds the snapshot from
  active, launched promotions (same visibility rule as the public API)
  and upserts the catalogFeedPublications row
- event-driven republication: promotions.update/setStatus schedule an
  immediate republish plus runAt jobs at future window edges, so
  activation, kill-switch, launch, and expiry all land without waiting
  for a periodic publish
- GET /api/v1/feeds/promotions served through the shared feed handler;
  vercel rewrites for /v1/feeds/promotions and /feeds/promotions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: hide pre-launch promotions on the slug endpoint regardless of status

A promotion activated and then killed before startsAt was publicly
readable by slug. Hide all non-draft promotions before their window
opens; ended promotions that did launch stay visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: publish promotions after expiry boundary

* fix: keep promotions feed publications fresh

* fix: initialize promotions feed safely

* fix: use deployable promotions function name

* fix: keep categorize dialog open while dismissing the categories dropdown

The categories dropdown was modal, which disables pointer events on the
rest of the page while open. The click that dismisses the dropdown then
targets <body>, which the parent Dialog treats as an outside interaction
and closes too — discarding unsaved category selections. Render the
dropdown non-modal so only it dismisses and Save keeps working.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: let plugin owners edit categories and topics after they are set

The categorize entry point vanished once metadata existed, leaving
owners no way to change categories or topics. Keep a compact owner-only
Edit control in the taxonomy row that reopens the categorize dialog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: remove unrelated taxonomy changes

* fix: keep canceled promotions private

* fix: reject expired promotion launches

* fix: harden promotion input boundaries

* feat: enforce CLI authoring contracts on promotion writes

The OpenClaw consumer rejects promotions whose modelRef, provider, or
authChoiceId violate its shell-safe identifier grammars, skips aliases
that are not typed identifiers, and refuses model refs outside the
declared provider prefix — so a promotion authored with, say, a spaced
alias published cleanly and then silently degraded at claim time.

Validate all of it at the write path instead: shell-safe modelRef and
identifier grammars, typed-identifier aliases, <provider>/ model-ref
prefix when a provider is declared, and npm-safe plugin names via the
registry's canonical grammar (scoped @scope/name allowed). Update the
management form hint/placeholder to teach the alias contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: format promotions test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jason (Json)
2026-07-06 10:02:52 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 240a510035
commit 2651570578
49 changed files with 4384 additions and 61 deletions
+6
View File
@@ -137,6 +137,12 @@ jobs:
bun run convex:deploy
fi
- name: Publish promotions feed snapshot
if: needs.validate-deploy-request.outputs.deploy_backend == 'true'
env:
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
run: bunx convex run promotionsFeed:publishInternal --prod
- name: Verify Convex contract
if: needs.validate-deploy-request.outputs.deploy_backend == 'true'
env:
+6
View File
@@ -37,6 +37,7 @@ import type * as httpApiV1_catalogFeedV1 from "../httpApiV1/catalogFeedV1.js";
import type * as httpApiV1_contentRightsV1 from "../httpApiV1/contentRightsV1.js";
import type * as httpApiV1_docsSessionV1 from "../httpApiV1/docsSessionV1.js";
import type * as httpApiV1_packagesV1 from "../httpApiV1/packagesV1.js";
import type * as httpApiV1_promotionsV1 from "../httpApiV1/promotionsV1.js";
import type * as httpApiV1_publishersV1 from "../httpApiV1/publishersV1.js";
import type * as httpApiV1_shared from "../httpApiV1/shared.js";
import type * as httpApiV1_skillsV1 from "../httpApiV1/skillsV1.js";
@@ -137,6 +138,8 @@ import type * as packageInspectorNode from "../packageInspectorNode.js";
import type * as packageLeaderboards from "../packageLeaderboards.js";
import type * as packagePublishTokens from "../packagePublishTokens.js";
import type * as packages from "../packages.js";
import type * as promotions from "../promotions.js";
import type * as promotionsFeed from "../promotionsFeed.js";
import type * as publisherAbuse from "../publisherAbuse.js";
import type * as publisherAbuseDevSeed from "../publisherAbuseDevSeed.js";
import type * as publishers from "../publishers.js";
@@ -195,6 +198,7 @@ declare const fullApi: ApiFromModules<{
"httpApiV1/contentRightsV1": typeof httpApiV1_contentRightsV1;
"httpApiV1/docsSessionV1": typeof httpApiV1_docsSessionV1;
"httpApiV1/packagesV1": typeof httpApiV1_packagesV1;
"httpApiV1/promotionsV1": typeof httpApiV1_promotionsV1;
"httpApiV1/publishersV1": typeof httpApiV1_publishersV1;
"httpApiV1/shared": typeof httpApiV1_shared;
"httpApiV1/skillsV1": typeof httpApiV1_skillsV1;
@@ -295,6 +299,8 @@ declare const fullApi: ApiFromModules<{
packageLeaderboards: typeof packageLeaderboards;
packagePublishTokens: typeof packagePublishTokens;
packages: typeof packages;
promotions: typeof promotions;
promotionsFeed: typeof promotionsFeed;
publisherAbuse: typeof publisherAbuse;
publisherAbuseDevSeed: typeof publisherAbuseDevSeed;
publishers: typeof publishers;
+6 -1
View File
@@ -5,6 +5,7 @@ import {
CATALOG_FEED_SOURCE_REF,
CATALOG_SKILLS_FEED_DESCRIPTION,
CATALOG_SKILLS_FEED_ID,
PROMOTIONS_FEED_ID,
serializeCatalogFeed,
type CatalogFeedEntry,
type CatalogFeedSkillEntry,
@@ -485,7 +486,11 @@ export const publish = internalAction({
export const getLatestPublication = internalQuery({
args: {
feedId: v.union(v.literal(CATALOG_FEED_ID), v.literal(CATALOG_SKILLS_FEED_ID)),
feedId: v.union(
v.literal(CATALOG_FEED_ID),
v.literal(CATALOG_SKILLS_FEED_ID),
v.literal(PROMOTIONS_FEED_ID),
),
},
handler: async (ctx, args) =>
await ctx.db
+16
View File
@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => {
const authSessionsPruneRef = Symbol("auth-sessions-prune");
const authRefreshTokensPruneRef = Symbol("auth-refresh-tokens-prune");
const publisherInvitesPruneRef = Symbol("publisher-invites-prune");
const promotionsFeedPublishRef = Symbol("promotions-feed-publish");
return {
interval,
githubSkillSyncRef,
@@ -29,6 +30,7 @@ const mocks = vi.hoisted(() => {
authSessionsPruneRef,
authRefreshTokensPruneRef,
publisherInvitesPruneRef,
promotionsFeedPublishRef,
};
});
@@ -66,6 +68,9 @@ vi.mock("./_generated/api", () => ({
notifyPublisherAbuseSignalChangesInternal: mocks.publisherAbuseSignalNotificationsRef,
processPublisherAbuseAutobansInternal: mocks.publisherAbuseAutobanRef,
},
promotionsFeed: {
publishInternal: mocks.promotionsFeedPublishRef,
},
vt: {
pollPendingScans: Symbol("vt-pending-scans"),
backfillActiveSkillsVTCache: Symbol("vt-cache-backfill"),
@@ -120,6 +125,17 @@ describe("crons", () => {
);
});
it("refreshes the promotions feed before its publication expires", async () => {
await import("./crons");
expect(mocks.interval).toHaveBeenCalledWith(
"promotions-feed-refresh",
{ hours: 6 },
mocks.promotionsFeedPublishRef,
{},
);
});
it("prunes expired skill scan requests in bounded continuation batches", async () => {
await import("./crons");
+7
View File
@@ -12,6 +12,13 @@ if (process.env.CLAWHUB_DISABLE_CRONS !== "1") {
{},
);
crons.interval(
"promotions-feed-refresh",
{ hours: 6 },
internal.promotionsFeed.publishInternal,
{},
);
crons.interval(
"trending-leaderboard",
{ minutes: 60 },
+35
View File
@@ -46,8 +46,13 @@ import {
starsPostRouterV1Http,
transfersGetRouterV1Http,
banAppealContextV1Http,
createPromotionV1Http,
listPromotionsV1Http,
promotionsGetRouterV1Http,
promotionsPostRouterV1Http,
catalogFeedV1Http,
catalogSkillsFeedV1Http,
promotionsFeedV1Http,
usersGetRouterV1Http,
usersListV1Http,
usersPostRouterV1Http,
@@ -133,6 +138,30 @@ http.route({
handler: listBundlePluginsV1Http,
});
http.route({
path: ApiRoutes.promotions,
method: "GET",
handler: listPromotionsV1Http,
});
http.route({
pathPrefix: `${ApiRoutes.promotions}/`,
method: "GET",
handler: promotionsGetRouterV1Http,
});
http.route({
path: ApiRoutes.promotions,
method: "POST",
handler: createPromotionV1Http,
});
http.route({
pathPrefix: `${ApiRoutes.promotions}/`,
method: "POST",
handler: promotionsPostRouterV1Http,
});
http.route({
path: ApiRoutes.catalogFeed,
method: "GET",
@@ -145,6 +174,12 @@ http.route({
handler: catalogSkillsFeedV1Http,
});
http.route({
path: ApiRoutes.promotionsFeed,
method: "GET",
handler: promotionsFeedV1Http,
});
http.route({
pathPrefix: `${ApiRoutes.skills}/`,
method: "GET",
+371
View File
@@ -0,0 +1,371 @@
/* @vitest-environment node */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@convex-dev/auth/server", () => ({
getAuthUserId: vi.fn(),
authTables: {},
}));
vi.mock("./lib/apiTokenAuth", () => ({
requireApiTokenUser: vi.fn(),
getOptionalApiTokenUser: vi.fn(),
getOptionalApiTokenUserId: vi.fn(),
requirePackagePublishAuth: vi.fn(),
}));
vi.mock("./lib/httpRateLimit", () => ({
applyRateLimit: vi.fn(async () => ({ ok: true, headers: {} })),
}));
const { requireApiTokenUser } = await import("./lib/apiTokenAuth");
const {
createPromotionV1Handler,
listPromotionsV1Handler,
promotionsGetRouterV1Handler,
promotionsPostRouterV1Handler,
} = await import("./httpApiV1/promotionsV1");
const { promotionsFeedV1Handler } = await import("./httpApiV1/catalogFeedV1");
type ActionCtx = import("./_generated/server").ActionCtx;
const BASE_URL = "https://clawhub.test/api/v1/promotions";
const publicPromotion = {
slug: "example-models-launch",
title: "Free Example models",
blurb: "A limited-time free model offer from Example.",
status: "active",
active: true,
startsAt: 100,
endsAt: 200,
models: [{ modelRef: "example-provider/example/model-alpha" }],
};
const validCreatePayload = {
slug: "example-models-launch",
title: "Free Example models",
blurb: "A limited-time free model offer from Example.",
startsAt: 100,
endsAt: 200,
models: [{ modelRef: "example-provider/example/model-alpha", alias: "model-alpha" }],
signupUrl: "https://signup.example.com",
};
function makeCtx() {
const runQuery = vi.fn();
const runMutation = vi.fn();
return { ctx: { runQuery, runMutation } as unknown as ActionCtx, runQuery, runMutation };
}
beforeEach(() => {
vi.mocked(requireApiTokenUser).mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
describe("listPromotionsV1Handler", () => {
it("returns active promotions publicly with cache headers", async () => {
const { ctx, runQuery } = makeCtx();
runQuery.mockResolvedValue({ promotions: [publicPromotion], nextStartsAt: null });
const response = await listPromotionsV1Handler(ctx, new Request(BASE_URL));
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toContain("public");
const body = (await response.json()) as { promotions: Array<{ slug: string }> };
expect(body.promotions[0]?.slug).toBe(publicPromotion.slug);
});
it("does not cache active promotions beyond the nearest expiry", async () => {
vi.useFakeTimers();
vi.setSystemTime(100_000);
const { ctx, runQuery } = makeCtx();
runQuery.mockResolvedValue({
promotions: [
{ ...publicPromotion, endsAt: 190_500 },
{ ...publicPromotion, slug: "later-promotion", endsAt: 400_000 },
],
nextStartsAt: null,
});
const response = await listPromotionsV1Handler(ctx, new Request(BASE_URL));
expect(response.headers.get("cache-control")).toBe(
"public, max-age=90, s-maxage=90, must-revalidate",
);
});
it("does not cache past the next scheduled promotion start", async () => {
vi.useFakeTimers();
vi.setSystemTime(100_000);
const { ctx, runQuery } = makeCtx();
runQuery.mockResolvedValue({ promotions: [], nextStartsAt: 150_500 });
const response = await listPromotionsV1Handler(ctx, new Request(BASE_URL));
expect(response.headers.get("cache-control")).toBe(
"public, max-age=50, s-maxage=50, must-revalidate",
);
});
it("requires an admin token for status=all", async () => {
const { ctx } = makeCtx();
vi.mocked(requireApiTokenUser).mockRejectedValue(new Error("Unauthorized"));
const response = await listPromotionsV1Handler(ctx, new Request(`${BASE_URL}?status=all`));
expect(response.status).toBe(401);
});
it("rejects non-admin tokens for status=all", async () => {
const { ctx } = makeCtx();
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:regular",
user: { _id: "users:regular", role: "user" },
} as never);
const response = await listPromotionsV1Handler(ctx, new Request(`${BASE_URL}?status=all`));
expect(response.status).toBe(403);
});
it("paginates status=all for admin callers", async () => {
const { ctx, runQuery } = makeCtx();
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
runQuery.mockResolvedValue({
page: [{ ...publicPromotion, slug: "older-promotion" }],
isDone: false,
continueCursor: "next-page",
});
const response = await listPromotionsV1Handler(
ctx,
new Request(`${BASE_URL}?status=all&limit=25&cursor=current-page`),
);
expect(response.status).toBe(200);
expect(runQuery.mock.calls[0]?.[1]).toEqual({
paginationOpts: { cursor: "current-page", numItems: 25 },
});
expect(await response.json()).toEqual({
promotions: [{ ...publicPromotion, slug: "older-promotion" }],
nextCursor: "next-page",
});
});
});
describe("promotionsFeedV1Handler", () => {
const FEED_URL = "https://clawhub.test/api/v1/feeds/promotions";
it("returns 503 when the feed has never been published", async () => {
const { ctx, runQuery } = makeCtx();
runQuery.mockResolvedValue(null);
const response = await promotionsFeedV1Handler(ctx, new Request(FEED_URL));
expect(response.status).toBe(503);
expect(runQuery).toHaveBeenCalledWith(expect.anything(), {
feedId: "clawhub-promotions",
});
});
it("serves the stored publication with ETag and cache headers", async () => {
const { ctx, runQuery } = makeCtx();
runQuery.mockResolvedValue({
payload: '{"id":"clawhub-promotions"}',
payloadSha256: "abc123",
publishedAt: 1_700_000_000_000,
sequence: 3,
});
const response = await promotionsFeedV1Handler(ctx, new Request(FEED_URL));
expect(response.status).toBe(200);
expect(response.headers.get("etag")).toBe('"sha256:abc123"');
expect(response.headers.get("cache-control")).toContain("public");
expect(await response.text()).toBe('{"id":"clawhub-promotions"}');
});
it("returns 304 for a matching If-None-Match", async () => {
const { ctx, runQuery } = makeCtx();
runQuery.mockResolvedValue({
payload: "{}",
payloadSha256: "abc123",
publishedAt: 1_700_000_000_000,
sequence: 3,
});
const response = await promotionsFeedV1Handler(
ctx,
new Request(FEED_URL, { headers: { "if-none-match": '"sha256:abc123"' } }),
);
expect(response.status).toBe(304);
});
});
describe("promotionsGetRouterV1Handler", () => {
it("returns 404 for unknown promotions", async () => {
const { ctx, runQuery } = makeCtx();
runQuery.mockResolvedValue(null);
const response = await promotionsGetRouterV1Handler(ctx, new Request(`${BASE_URL}/unknown`));
expect(response.status).toBe(404);
});
it("returns a public promotion by slug", async () => {
const { ctx, runQuery } = makeCtx();
runQuery.mockResolvedValue(publicPromotion);
const response = await promotionsGetRouterV1Handler(
ctx,
new Request(`${BASE_URL}/${publicPromotion.slug}`),
);
expect(response.status).toBe(200);
const body = (await response.json()) as { slug: string };
expect(body.slug).toBe(publicPromotion.slug);
});
});
describe("createPromotionV1Handler", () => {
function makeCreateRequest(payload: unknown) {
return new Request(BASE_URL, {
method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer token" },
body: JSON.stringify(payload),
});
}
it("creates a promotion for admins", async () => {
const { ctx, runMutation } = makeCtx();
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
runMutation.mockResolvedValue({ ok: true, slug: validCreatePayload.slug, status: "draft" });
const response = await createPromotionV1Handler(ctx, makeCreateRequest(validCreatePayload));
expect(response.status).toBe(200);
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
actorUserId: "users:admin",
input: expect.objectContaining({
slug: validCreatePayload.slug,
models: [{ modelRef: "example-provider/example/model-alpha", alias: "model-alpha" }],
}),
});
});
it("rejects non-admin tokens", async () => {
const { ctx, runMutation } = makeCtx();
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:regular",
user: { _id: "users:regular", role: "user" },
} as never);
const response = await createPromotionV1Handler(ctx, makeCreateRequest(validCreatePayload));
expect(response.status).toBe(403);
expect(runMutation).not.toHaveBeenCalled();
});
it("rejects payloads without models", async () => {
const { ctx, runMutation } = makeCtx();
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
const response = await createPromotionV1Handler(
ctx,
makeCreateRequest({ ...validCreatePayload, models: [] }),
);
expect(response.status).toBe(400);
expect(runMutation).not.toHaveBeenCalled();
});
it("returns 400 for mutation validation errors", async () => {
const { ctx, runMutation } = makeCtx();
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
runMutation.mockRejectedValue(new Error("ConvexError: Title too long (max 120 chars)"));
const response = await createPromotionV1Handler(ctx, makeCreateRequest(validCreatePayload));
expect(response.status).toBe(400);
expect(await response.text()).toBe("Title too long (max 120 chars)");
});
it("returns 500 without exposing unexpected mutation failures", async () => {
const { ctx, runMutation } = makeCtx();
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
runMutation.mockRejectedValue(new Error("scheduler unavailable: internal details"));
const response = await createPromotionV1Handler(ctx, makeCreateRequest(validCreatePayload));
expect(response.status).toBe(500);
expect(await response.text()).toBe("Internal Server Error");
});
});
describe("promotionsPostRouterV1Handler", () => {
function makeStatusRequest(slug: string, payload: unknown) {
return new Request(`${BASE_URL}/${slug}/status`, {
method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer token" },
body: JSON.stringify(payload),
});
}
it("sets promotion status for admins", async () => {
const { ctx, runMutation } = makeCtx();
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
runMutation.mockResolvedValue({ ok: true, slug: publicPromotion.slug, status: "active" });
const response = await promotionsPostRouterV1Handler(
ctx,
makeStatusRequest(publicPromotion.slug, { status: "active" }),
);
expect(response.status).toBe(200);
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
actorUserId: "users:admin",
slug: publicPromotion.slug,
status: "active",
});
});
it("rejects invalid statuses", async () => {
const { ctx, runMutation } = makeCtx();
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
const response = await promotionsPostRouterV1Handler(
ctx,
makeStatusRequest(publicPromotion.slug, { status: "paused" }),
);
expect(response.status).toBe(400);
expect(runMutation).not.toHaveBeenCalled();
});
it("returns 404 for unknown actions", async () => {
const { ctx } = makeCtx();
const response = await promotionsPostRouterV1Handler(
ctx,
new Request(`${BASE_URL}/${publicPromotion.slug}/rename`, {
method: "POST",
body: JSON.stringify({}),
}),
);
expect(response.status).toBe(404);
});
});
+22 -1
View File
@@ -1,5 +1,9 @@
import { httpAction } from "./functions";
import { catalogFeedV1Handler, catalogSkillsFeedV1Handler } from "./httpApiV1/catalogFeedV1";
import {
catalogFeedV1Handler,
catalogSkillsFeedV1Handler,
promotionsFeedV1Handler,
} from "./httpApiV1/catalogFeedV1";
import { contentRightsV1Handler } from "./httpApiV1/contentRightsV1";
import { verifyDocsSessionV1Handler } from "./httpApiV1/docsSessionV1";
import {
@@ -16,6 +20,12 @@ import {
pluginsGetRouterV1Handler,
publishPackageV1Handler,
} from "./httpApiV1/packagesV1";
import {
createPromotionV1Handler,
listPromotionsV1Handler,
promotionsGetRouterV1Handler,
promotionsPostRouterV1Handler,
} from "./httpApiV1/promotionsV1";
import { createPublisherV1Handler } from "./httpApiV1/publishersV1";
import {
exportSkillsV1Handler,
@@ -59,6 +69,7 @@ export const createPublisherV1Http = httpAction(createPublisherV1Handler);
export const contentRightsV1Http = httpAction(contentRightsV1Handler);
export const catalogFeedV1Http = httpAction(catalogFeedV1Handler);
export const catalogSkillsFeedV1Http = httpAction(catalogSkillsFeedV1Handler);
export const promotionsFeedV1Http = httpAction(promotionsFeedV1Handler);
export const searchSkillsV1Http = httpAction(searchSkillsV1Handler);
export const resolveSkillVersionV1Http = httpAction(resolveSkillVersionV1Handler);
@@ -78,6 +89,11 @@ export const starsPostRouterV1Http = httpAction(starsPostRouterV1Handler);
export const starsDeleteRouterV1Http = httpAction(starsDeleteRouterV1Handler);
export const transfersGetRouterV1Http = httpAction(transfersGetRouterV1Handler);
export const listPromotionsV1Http = httpAction(listPromotionsV1Handler);
export const promotionsGetRouterV1Http = httpAction(promotionsGetRouterV1Handler);
export const createPromotionV1Http = httpAction(createPromotionV1Handler);
export const promotionsPostRouterV1Http = httpAction(promotionsPostRouterV1Handler);
export const whoamiV1Http = httpAction(whoamiV1Handler);
export const usersGetRouterV1Http = httpAction(usersGetRouterV1Handler);
export const usersPostRouterV1Http = httpAction(usersPostRouterV1Handler);
@@ -123,4 +139,9 @@ export const __handlers = {
usersPostRouterV1Handler,
usersListV1Handler,
banAppealContextV1Handler,
listPromotionsV1Handler,
promotionsGetRouterV1Handler,
createPromotionV1Handler,
promotionsPostRouterV1Handler,
promotionsFeedV1Handler,
};
+9 -2
View File
@@ -1,4 +1,4 @@
import { CATALOG_FEED_ID, CATALOG_SKILLS_FEED_ID } from "clawhub-schema";
import { CATALOG_FEED_ID, CATALOG_SKILLS_FEED_ID, PROMOTIONS_FEED_ID } from "clawhub-schema";
import { internal } from "../_generated/api";
import type { ActionCtx } from "../_generated/server";
import { corsHeaders, mergeHeaders } from "../lib/httpHeaders";
@@ -27,7 +27,10 @@ function matchesLastModified(request: Request, publishedAt: number) {
export async function catalogFeedV1Handler(
ctx: ActionCtx,
request: Request,
feedId: typeof CATALOG_FEED_ID | typeof CATALOG_SKILLS_FEED_ID = CATALOG_FEED_ID,
feedId:
| typeof CATALOG_FEED_ID
| typeof CATALOG_SKILLS_FEED_ID
| typeof PROMOTIONS_FEED_ID = CATALOG_FEED_ID,
) {
const publication = await ctx.runQuery(internal.catalogFeed.getLatestPublication, { feedId });
if (!publication) {
@@ -71,3 +74,7 @@ export async function catalogFeedV1Handler(
export async function catalogSkillsFeedV1Handler(ctx: ActionCtx, request: Request) {
return await catalogFeedV1Handler(ctx, request, CATALOG_SKILLS_FEED_ID);
}
export async function promotionsFeedV1Handler(ctx: ActionCtx, request: Request) {
return await catalogFeedV1Handler(ctx, request, PROMOTIONS_FEED_ID);
}
+300
View File
@@ -0,0 +1,300 @@
import { ConvexError } from "convex/values";
import { internal } from "../_generated/api";
import type { ActionCtx } from "../_generated/server";
import { mergeHeaders } from "../lib/httpHeaders";
import { applyRateLimit } from "../lib/httpRateLimit";
import {
formatUserFacingErrorMessage,
getPathSegments,
json,
parseJsonPayload,
requireAdminOrResponse,
requireApiTokenUserOrResponse,
text,
toOptionalNumber,
} from "./shared";
const PROMOTIONS_CACHE_SECONDS = 300;
function promotionsCacheHeaders(
promotions: ReadonlyArray<{ active: boolean; endsAt: number }>,
now: number,
nextStartsAt?: number | null,
) {
const nearestExpiry = promotions.reduce(
(nearest, promotion) =>
promotion.active && promotion.endsAt >= now ? Math.min(nearest, promotion.endsAt) : nearest,
Number.POSITIVE_INFINITY,
);
const nearestBoundary =
nextStartsAt !== undefined && nextStartsAt !== null && nextStartsAt >= now
? Math.min(nearestExpiry, nextStartsAt)
: nearestExpiry;
const secondsUntilBoundary = Number.isFinite(nearestBoundary)
? Math.max(0, Math.floor((nearestBoundary - now) / 1000))
: PROMOTIONS_CACHE_SECONDS;
const maxAge = Math.min(PROMOTIONS_CACHE_SECONDS, secondsUntilBoundary);
return {
"Cache-Control": `public, max-age=${maxAge}, s-maxage=${maxAge}, must-revalidate`,
};
}
type PromotionModelPayload = {
modelRef: string;
alias?: string;
suggestedDefault?: boolean;
};
type PromotionInputPayload = {
slug: string;
title: string;
blurb: string;
sponsor?: string;
startsAt: number;
endsAt: number;
provider?: string;
authChoiceId?: string;
pluginNames?: string[];
models: PromotionModelPayload[];
signupUrl?: string;
docsUrl?: string;
launchPageUrl?: string;
};
function optionalString(value: unknown, label: string) {
if (value === undefined || value === null) return undefined;
if (typeof value !== "string") throw new Error(`${label} must be a string`);
const trimmed = value.trim();
return trimmed || undefined;
}
function requiredString(value: unknown, label: string) {
const parsed = optionalString(value, label);
if (!parsed) throw new Error(`Missing ${label}`);
return parsed;
}
function requiredNumber(value: unknown, label: string) {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`${label} must be a timestamp (ms since epoch)`);
}
return value;
}
function parsePromotionInputPayload(payload: Record<string, unknown>): PromotionInputPayload {
const modelsRaw = payload.models;
if (!Array.isArray(modelsRaw) || modelsRaw.length === 0) {
throw new Error("models must be a non-empty array");
}
const models = modelsRaw.map((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
throw new Error(`models[${index}] must be an object`);
}
const record = entry as Record<string, unknown>;
const suggestedDefault = record.suggestedDefault;
if (suggestedDefault !== undefined && typeof suggestedDefault !== "boolean") {
throw new Error(`models[${index}].suggestedDefault must be a boolean`);
}
const alias = optionalString(record.alias, `models[${index}].alias`);
return {
modelRef: requiredString(record.modelRef, `models[${index}].modelRef`),
...(alias ? { alias } : {}),
...(suggestedDefault ? { suggestedDefault } : {}),
};
});
let pluginNames: string[] | undefined;
if (payload.pluginNames !== undefined && payload.pluginNames !== null) {
if (!Array.isArray(payload.pluginNames)) {
throw new Error("pluginNames must be an array of strings");
}
pluginNames = payload.pluginNames.map((name, index) =>
requiredString(name, `pluginNames[${index}]`),
);
}
const sponsor = optionalString(payload.sponsor, "sponsor");
const provider = optionalString(payload.provider, "provider");
const authChoiceId = optionalString(payload.authChoiceId, "authChoiceId");
const signupUrl = optionalString(payload.signupUrl, "signupUrl");
const docsUrl = optionalString(payload.docsUrl, "docsUrl");
const launchPageUrl = optionalString(payload.launchPageUrl, "launchPageUrl");
return {
slug: requiredString(payload.slug, "slug"),
title: requiredString(payload.title, "title"),
blurb: requiredString(payload.blurb, "blurb"),
...(sponsor ? { sponsor } : {}),
startsAt: requiredNumber(payload.startsAt, "startsAt"),
endsAt: requiredNumber(payload.endsAt, "endsAt"),
...(provider ? { provider } : {}),
...(authChoiceId ? { authChoiceId } : {}),
...(pluginNames && pluginNames.length > 0 ? { pluginNames } : {}),
models,
...(signupUrl ? { signupUrl } : {}),
...(docsUrl ? { docsUrl } : {}),
...(launchPageUrl ? { launchPageUrl } : {}),
};
}
function promotionInputErrorToResponse(error: unknown, headers: HeadersInit) {
return text(formatUserFacingErrorMessage(error, "Invalid promotion input"), 400, headers);
}
function promotionMutationErrorToResponse(error: unknown, headers: HeadersInit) {
const rawMessage = error instanceof Error ? error.message : "";
const isUserFacing =
error instanceof ConvexError || /(?:Uncaught\s+)?ConvexError:/i.test(rawMessage);
if (!isUserFacing) return text("Internal Server Error", 500, headers);
const message = formatUserFacingErrorMessage(error, "Promotion request failed");
const lower = message.toLowerCase();
if (lower.includes("unauthorized")) return text("Unauthorized", 401, headers);
if (lower.includes("forbidden")) return text("Forbidden", 403, headers);
if (lower.includes("not found")) return text(message, 404, headers);
if (lower.includes("already exists")) return text(message, 409, headers);
return text(message, 400, headers);
}
export async function listPromotionsV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
const url = new URL(request.url);
if (url.searchParams.get("status") === "all") {
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const admin = requireAdminOrResponse(auth.user, rate.headers);
if (!admin.ok) return admin.response;
const limit = Math.max(
1,
Math.min(toOptionalNumber(url.searchParams.get("limit")) ?? 100, 100),
);
const result = await ctx.runQuery(internal.promotions.listAllInternal, {
paginationOpts: {
cursor: url.searchParams.get("cursor")?.trim() || null,
numItems: limit,
},
});
return json(
{
promotions: result.page,
nextCursor: result.isDone ? null : result.continueCursor,
},
200,
rate.headers,
);
}
const now = Date.now();
const { promotions, nextStartsAt } = await ctx.runQuery(internal.promotions.listActiveInternal, {
now,
});
return json(
{ promotions },
200,
mergeHeaders(rate.headers, promotionsCacheHeaders(promotions, now, nextStartsAt)),
);
}
export async function promotionsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
const segments = getPathSegments(request, "/api/v1/promotions/");
if (segments.length !== 1) return text("Not found", 404, rate.headers);
const slug = segments[0]?.trim().toLowerCase() ?? "";
if (!slug) return text("Not found", 404, rate.headers);
const now = Date.now();
const promotion = await ctx.runQuery(internal.promotions.getBySlugPublicInternal, { slug, now });
if (!promotion) return text("Promotion not found", 404, rate.headers);
return json(promotion, 200, mergeHeaders(rate.headers, promotionsCacheHeaders([promotion], now)));
}
export async function createPromotionV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const admin = requireAdminOrResponse(auth.user, rate.headers);
if (!admin.ok) return admin.response;
const payloadResult = await parseJsonPayload(request, rate.headers);
if (!payloadResult.ok) return payloadResult.response;
let input: PromotionInputPayload;
try {
input = parsePromotionInputPayload(payloadResult.payload);
} catch (error) {
return promotionInputErrorToResponse(error, rate.headers);
}
try {
const result = await ctx.runMutation(internal.promotions.createInternal, {
actorUserId: auth.userId,
input,
});
return json(result, 200, rate.headers);
} catch (error) {
return promotionMutationErrorToResponse(error, rate.headers);
}
}
export async function promotionsPostRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
const segments = getPathSegments(request, "/api/v1/promotions/");
if (segments.length !== 2) return text("Not found", 404, rate.headers);
const slug = segments[0]?.trim().toLowerCase() ?? "";
const action = segments[1];
if (!slug || (action !== "update" && action !== "status")) {
return text("Not found", 404, rate.headers);
}
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const admin = requireAdminOrResponse(auth.user, rate.headers);
if (!admin.ok) return admin.response;
const payloadResult = await parseJsonPayload(request, rate.headers);
if (!payloadResult.ok) return payloadResult.response;
const payload = payloadResult.payload;
if (action === "status") {
const status = typeof payload.status === "string" ? payload.status.trim() : "";
if (status !== "draft" && status !== "active" && status !== "ended") {
return text("status must be one of draft|active|ended", 400, rate.headers);
}
try {
const result = await ctx.runMutation(internal.promotions.setStatusInternal, {
actorUserId: auth.userId,
slug,
status,
});
return json(result, 200, rate.headers);
} catch (error) {
return promotionMutationErrorToResponse(error, rate.headers);
}
}
let input: PromotionInputPayload;
try {
input = parsePromotionInputPayload(payload);
} catch (error) {
return promotionInputErrorToResponse(error, rate.headers);
}
try {
const result = await ctx.runMutation(internal.promotions.updateInternal, {
actorUserId: auth.userId,
targetSlug: slug,
input,
});
return json(result, 200, rate.headers);
} catch (error) {
return promotionMutationErrorToResponse(error, rate.headers);
}
}
+1
View File
@@ -197,6 +197,7 @@ export const RETENTION_POLICIES = {
officialPluginMigrations: permanent("Official plugin migration state."),
catalogFeedPublications: permanent("Current published hosted catalog feed snapshot."),
stars: permanent("User star records."),
promotions: permanent("Curated promotional offers; ended records stay for launch-page history."),
auditLogs: permanent("Audit logs are durable compliance/security history."),
systemSettings: permanent("Durable operator-controlled system settings."),
publisherAbuseScoreRuns: permanent("Abuse scoring run history."),
+827
View File
@@ -0,0 +1,827 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@convex-dev/auth/server", () => ({
getAuthUserId: vi.fn(),
authTables: {},
}));
vi.mock("./lib/access", async () => {
const actual = await vi.importActual<typeof import("./lib/access")>("./lib/access");
return {
...actual,
requireUser: vi.fn(),
};
});
const { requireUser } = await import("./lib/access");
const {
create,
update,
setStatus,
listActiveInternal,
listForStaff,
getBySlugPublicInternal,
normalizePromotionInput,
} = await import("./promotions");
type WrappedHandler<TArgs, TResult = unknown> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
};
const createHandler = (create as unknown as WrappedHandler<Record<string, unknown>>)._handler;
const setStatusHandler = (setStatus as unknown as WrappedHandler<{ slug: string; status: string }>)
._handler;
const listActiveHandler = (listActiveInternal as unknown as WrappedHandler<{ now: number }>)
._handler;
const getBySlugHandler = (
getBySlugPublicInternal as unknown as WrappedHandler<{ slug: string; now: number }>
)._handler;
const adminUser = { _id: "users:admin", role: "admin" };
const regularUser = { _id: "users:regular", role: "user" };
const validInput = {
slug: "example-models-launch",
title: "Free Example models",
blurb: "A limited-time free model offer from Example.",
sponsor: "Example",
startsAt: 1_000,
endsAt: 2_000,
provider: "example-provider",
authChoiceId: "example-provider-api-key",
models: [{ modelRef: "example-provider/example/model-alpha", alias: "model-alpha" }],
signupUrl: "https://signup.example.com",
};
function makeScheduler() {
return {
runAfter: vi.fn(async (..._args: unknown[]) => "job:1"),
runAt: vi.fn(async (..._args: unknown[]) => "job:2"),
};
}
function makeMutationCtx({
existing = null,
activePromotions = [],
}: {
existing?: unknown;
activePromotions?: unknown[];
} = {}) {
const inserts: Array<{ table: string; doc: Record<string, unknown> }> = [];
const patches: Array<{ id: string; patch: Record<string, unknown> }> = [];
const db = {
normalizeId: vi.fn(),
system: {},
get: vi.fn(),
query: vi.fn(() => ({
withIndex: vi.fn(() => ({
unique: vi.fn(async () => existing),
take: vi.fn(async (limit: number) => activePromotions.slice(0, limit)),
})),
})),
insert: vi.fn(async (table: string, doc: Record<string, unknown>) => {
inserts.push({ table, doc });
return `${table}:${inserts.length}`;
}),
patch: vi.fn(async (id: string, patch: Record<string, unknown>) => {
patches.push({ id, patch });
}),
replace: vi.fn(),
delete: vi.fn(),
};
const scheduler = makeScheduler();
return { ctx: { db, scheduler } as never, db, scheduler, inserts, patches };
}
afterEach(() => {
vi.mocked(requireUser).mockReset();
});
describe("normalizePromotionInput", () => {
it("normalizes slug case and trims fields", () => {
const normalized = normalizePromotionInput({
...validInput,
slug: " Example-Models-Launch ",
title: " Free Example models ",
});
expect(normalized.slug).toBe("example-models-launch");
expect(normalized.title).toBe("Free Example models");
});
it("rejects invalid slugs", () => {
expect(() => normalizePromotionInput({ ...validInput, slug: "Bad Slug!" })).toThrow(/Slug/);
expect(() => normalizePromotionInput({ ...validInput, slug: "-leading" })).toThrow(/Slug/);
});
it("rejects non-https URLs", () => {
expect(() =>
normalizePromotionInput({ ...validInput, signupUrl: "http://insecure.example.com" }),
).toThrow(/https/);
expect(() => normalizePromotionInput({ ...validInput, docsUrl: "not a url" })).toThrow(
/valid URL/,
);
});
it("rejects inverted windows and empty model lists", () => {
expect(() =>
normalizePromotionInput({ ...validInput, startsAt: 2_000, endsAt: 1_000 }),
).toThrow(/endsAt/);
expect(() => normalizePromotionInput({ ...validInput, models: [] })).toThrow(/model/);
});
it("rejects timestamps outside the JavaScript Date range", () => {
expect(() => normalizePromotionInput({ ...validInput, startsAt: -Number.MAX_VALUE })).toThrow(
/valid timestamp/,
);
expect(() => normalizePromotionInput({ ...validInput, endsAt: Number.MAX_VALUE })).toThrow(
/valid timestamp/,
);
});
it.each([
["modelRef", { modelRef: "provider/model\ninjected" }],
["Model alias", { modelRef: "provider/model", alias: "Alias\r\ninjected" }],
])("rejects line breaks in %s", (label, model) => {
expect(() => normalizePromotionInput({ ...validInput, models: [model] })).toThrow(label);
});
// CLI authoring contracts: the OpenClaw consumer rejects promotions whose
// identifiers violate these grammars and skips aliases it cannot register.
it("rejects aliases that are not typed identifiers", () => {
expect(() =>
normalizePromotionInput({
...validInput,
models: [{ modelRef: "example-provider/example/model-alpha", alias: "Model Alpha" }],
}),
).toThrow(/alias/);
expect(() =>
normalizePromotionInput({
...validInput,
models: [{ modelRef: "example-provider/example/model-alpha", alias: "alias$(rm)" }],
}),
).toThrow(/alias/);
});
it("accepts typed-identifier aliases", () => {
const normalized = normalizePromotionInput({
...validInput,
models: [{ modelRef: "example-provider/example/model-alpha", alias: "model-alpha_v1.2:x" }],
});
expect(normalized.models[0]?.alias).toBe("model-alpha_v1.2:x");
});
it("rejects modelRefs with shell-unsafe characters", () => {
for (const modelRef of [
"example-provider/model alpha",
"example-provider/model;rm",
"example-provider/`model`",
"-leading-dash/model",
]) {
expect(() => normalizePromotionInput({ ...validInput, models: [{ modelRef }] })).toThrow(
/modelRef/,
);
}
});
it("rejects modelRefs outside the declared provider prefix", () => {
expect(() =>
normalizePromotionInput({
...validInput,
models: [{ modelRef: "other-provider/example/model-alpha" }],
}),
).toThrow(/provider prefix/);
expect(() =>
normalizePromotionInput({
...validInput,
models: [{ modelRef: "example-provider/" }],
}),
).toThrow(/provider prefix/);
});
it("skips the provider-prefix requirement when no provider is declared", () => {
const { provider: _provider, authChoiceId: _authChoiceId, ...rest } = validInput;
const normalized = normalizePromotionInput({
...rest,
models: [{ modelRef: "any-provider/example/model-alpha" }],
});
expect(normalized.models[0]?.modelRef).toBe("any-provider/example/model-alpha");
});
it("rejects provider, authChoiceId, and plugin names outside the identifier grammar", () => {
expect(() => normalizePromotionInput({ ...validInput, provider: "bad provider" })).toThrow(
/Provider/,
);
expect(() => normalizePromotionInput({ ...validInput, authChoiceId: "choice id!" })).toThrow(
/authChoiceId/,
);
expect(() =>
normalizePromotionInput({ ...validInput, pluginNames: ["good-plugin", "bad name"] }),
).toThrow(/Plugin name/);
});
it("accepts scoped npm plugin names and lowercases them", () => {
const normalized = normalizePromotionInput({
...validInput,
pluginNames: ["@openclaw/Example-Plugin", "plain-plugin"],
});
expect(normalized.pluginNames).toEqual(["@openclaw/example-plugin", "plain-plugin"]);
});
});
describe("promotions.create", () => {
it("creates a draft promotion and writes an audit log for admins", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: adminUser._id,
user: adminUser,
} as never);
const { ctx, inserts } = makeMutationCtx();
const result = (await createHandler(ctx, validInput)) as {
ok: boolean;
slug: string;
status: string;
};
expect(result).toEqual({ ok: true, slug: validInput.slug, status: "draft" });
const promotionInsert = inserts.find((entry) => entry.table === "promotions");
expect(promotionInsert?.doc.status).toBe("draft");
expect(promotionInsert?.doc.createdByUserId).toBe(adminUser._id);
const auditInsert = inserts.find((entry) => entry.table === "auditLogs");
expect(auditInsert?.doc.action).toBe("promotion.create");
expect(auditInsert?.doc.targetType).toBe("promotion");
expect(auditInsert?.doc.targetId).toBe(validInput.slug);
expect(auditInsert?.doc.actorUserId).toBe(adminUser._id);
});
it("rejects non-admin users", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: regularUser._id,
user: regularUser,
} as never);
const { ctx, inserts } = makeMutationCtx();
await expect(createHandler(ctx, validInput)).rejects.toThrow("Forbidden");
expect(inserts).toHaveLength(0);
});
it("rejects duplicate slugs", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: adminUser._id,
user: adminUser,
} as never);
const { ctx, inserts } = makeMutationCtx({
existing: { _id: "promotions:1", slug: validInput.slug },
});
await expect(createHandler(ctx, validInput)).rejects.toThrow(/already exists/);
expect(inserts).toHaveLength(0);
});
});
describe("promotions.update", () => {
const updateHandler = (update as unknown as WrappedHandler<Record<string, unknown>>)._handler;
function makeUpdateCtx(lookupResults: unknown[]) {
const uniqueMock = vi.fn();
for (const result of lookupResults) uniqueMock.mockResolvedValueOnce(result);
const replace = vi.fn();
const insert = vi.fn(async () => "auditLogs:1");
const db = {
normalizeId: vi.fn(),
system: {},
get: vi.fn(),
query: vi.fn(() => ({
withIndex: vi.fn(() => ({ unique: uniqueMock })),
})),
insert,
patch: vi.fn(),
replace,
delete: vi.fn(),
};
const scheduler = makeScheduler();
return { ctx: { db, scheduler } as never, replace, insert, scheduler };
}
it("rejects slug changes once the promotion is no longer a draft", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: adminUser._id,
user: adminUser,
} as never);
const { ctx, replace } = makeUpdateCtx([
{ _id: "promotions:1", slug: validInput.slug, status: "active", createdAt: 1 },
]);
await expect(
updateHandler(ctx, { targetSlug: validInput.slug, ...validInput, slug: "renamed-launch" }),
).rejects.toThrow(/draft/);
expect(replace).not.toHaveBeenCalled();
});
it("allows slug changes while still a draft", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: adminUser._id,
user: adminUser,
} as never);
const { ctx, replace } = makeUpdateCtx([
{ _id: "promotions:1", slug: validInput.slug, status: "draft", createdAt: 1 },
null,
]);
const result = (await updateHandler(ctx, {
targetSlug: validInput.slug,
...validInput,
slug: "renamed-launch",
})) as { ok: boolean; slug: string };
expect(result.slug).toBe("renamed-launch");
expect(replace).toHaveBeenCalledTimes(1);
});
it("preserves launch history when editing an ended promotion", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: adminUser._id,
user: adminUser,
} as never);
const { ctx, replace } = makeUpdateCtx([
{
_id: "promotions:1",
slug: validInput.slug,
status: "ended",
launchedAt: validInput.startsAt,
createdByUserId: adminUser._id,
createdAt: 1,
},
]);
await updateHandler(ctx, { targetSlug: validInput.slug, ...validInput });
expect(replace).toHaveBeenCalledWith(
"promotions:1",
expect.objectContaining({ launchedAt: validInput.startsAt }),
);
});
it("does not mark a canceled promotion as launched when editing it later", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: adminUser._id,
user: adminUser,
} as never);
const { ctx, replace } = makeUpdateCtx([
{
_id: "promotions:1",
slug: validInput.slug,
status: "ended",
createdByUserId: adminUser._id,
createdAt: 1,
},
]);
await updateHandler(ctx, { targetSlug: validInput.slug, ...validInput });
expect(replace.mock.calls[0]?.[1]).not.toHaveProperty("launchedAt");
});
it("rejects moving an unlaunched active promotion into an expired window", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: adminUser._id,
user: adminUser,
} as never);
const { ctx, replace } = makeUpdateCtx([
{
_id: "promotions:1",
slug: validInput.slug,
status: "active",
startsAt: Date.now() + 60_000,
createdByUserId: adminUser._id,
createdAt: 1,
},
]);
await expect(
updateHandler(ctx, {
targetSlug: validInput.slug,
...validInput,
startsAt: 1,
endsAt: 2,
}),
).rejects.toThrow(/expired/);
expect(replace).not.toHaveBeenCalled();
});
});
describe("promotions.setStatus", () => {
const futureStartsAt = Date.now() + 60_000;
const futureEndsAt = Date.now() + 120_000;
const storedPromotion = {
_id: "promotions:1",
slug: validInput.slug,
status: "draft",
startsAt: futureStartsAt,
endsAt: futureEndsAt,
};
it("updates status, writes an audit log, and schedules feed republication", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: adminUser._id,
user: adminUser,
} as never);
const { ctx, inserts, patches, scheduler } = makeMutationCtx({ existing: storedPromotion });
const result = (await setStatusHandler(ctx, {
slug: validInput.slug,
status: "active",
})) as { ok: boolean; status: string };
expect(result.status).toBe("active");
expect(patches).toHaveLength(1);
expect(patches[0]?.patch.status).toBe("active");
const auditInsert = inserts.find((entry) => entry.table === "auditLogs");
expect(auditInsert?.doc.action).toBe("promotion.set_status");
expect(auditInsert?.doc.metadata).toMatchObject({ from: "draft", to: "active" });
// Immediate republish plus one scheduled republish per future window edge.
expect(scheduler.runAfter).toHaveBeenCalledTimes(1);
expect(scheduler.runAt).toHaveBeenCalledTimes(2);
expect(scheduler.runAt.mock.calls.map((call) => call[0])).toEqual([
futureStartsAt,
futureEndsAt + 1,
]);
});
it("records launch when activating a promotion after its window starts", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: adminUser._id,
user: adminUser,
} as never);
const { ctx, patches } = makeMutationCtx({
existing: { ...storedPromotion, startsAt: 1 },
});
await setStatusHandler(ctx, { slug: validInput.slug, status: "active" });
expect(patches[0]?.patch.launchedAt).toEqual(expect.any(Number));
});
it("rejects activating a promotion after its window expired", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: adminUser._id,
user: adminUser,
} as never);
const { ctx, patches } = makeMutationCtx({
existing: { ...storedPromotion, startsAt: 1, endsAt: 2 },
});
await expect(
setStatusHandler(ctx, { slug: validInput.slug, status: "active" }),
).rejects.toThrow(/expired/i);
expect(patches).toHaveLength(0);
});
it("republishes the feed immediately when ending a promotion, without edge jobs", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: adminUser._id,
user: adminUser,
} as never);
const { ctx, patches, scheduler } = makeMutationCtx({
existing: { ...storedPromotion, status: "active" },
});
await setStatusHandler(ctx, { slug: validInput.slug, status: "ended" });
expect(patches[0]?.patch).not.toHaveProperty("launchedAt");
expect(scheduler.runAfter).toHaveBeenCalledTimes(1);
expect(scheduler.runAt).not.toHaveBeenCalled();
});
it("records that a promotion launched when ending it after its start", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: adminUser._id,
user: adminUser,
} as never);
const { ctx, patches } = makeMutationCtx({
existing: { ...storedPromotion, status: "active", startsAt: 1 },
});
await setStatusHandler(ctx, { slug: validInput.slug, status: "ended" });
expect(patches[0]?.patch.launchedAt).toBe(1);
});
it("is a no-op when the status is unchanged", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: adminUser._id,
user: adminUser,
} as never);
const { ctx, inserts, patches, scheduler } = makeMutationCtx({ existing: storedPromotion });
const result = (await setStatusHandler(ctx, {
slug: validInput.slug,
status: "draft",
})) as { ok: boolean; status: string };
expect(result.status).toBe("draft");
expect(patches).toHaveLength(0);
expect(inserts).toHaveLength(0);
expect(scheduler.runAfter).not.toHaveBeenCalled();
});
it.each(["active", "ended"])("rejects returning a %s promotion to draft", async (status) => {
vi.mocked(requireUser).mockResolvedValue({
userId: adminUser._id,
user: adminUser,
} as never);
const { ctx, inserts, patches } = makeMutationCtx({
existing: { ...storedPromotion, status },
});
await expect(setStatusHandler(ctx, { slug: validInput.slug, status: "draft" })).rejects.toThrow(
/draft/,
);
expect(patches).toHaveLength(0);
expect(inserts).toHaveLength(0);
});
it("rejects ending a promotion before it has been activated", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: adminUser._id,
user: adminUser,
} as never);
const { ctx, inserts, patches } = makeMutationCtx({ existing: storedPromotion });
await expect(setStatusHandler(ctx, { slug: validInput.slug, status: "ended" })).rejects.toThrow(
/activated/,
);
expect(patches).toHaveLength(0);
expect(inserts).toHaveLength(0);
});
it("rejects reactivating an ended promotion", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: adminUser._id,
user: adminUser,
} as never);
const { ctx, inserts, patches } = makeMutationCtx({
existing: { ...storedPromotion, status: "ended" },
});
await expect(
setStatusHandler(ctx, { slug: validInput.slug, status: "active" }),
).rejects.toThrow(/reactivated/);
expect(patches).toHaveLength(0);
expect(inserts).toHaveLength(0);
});
it("rejects activation when the curated active set is full", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: adminUser._id,
user: adminUser,
} as never);
const activePromotions = Array.from({ length: 50 }, (_, index) => ({
_id: `promotions:${index}`,
status: "active",
}));
const { ctx, inserts, patches } = makeMutationCtx({
existing: storedPromotion,
activePromotions,
});
await expect(
setStatusHandler(ctx, { slug: validInput.slug, status: "active" }),
).rejects.toThrow(/At most 50/);
expect(patches).toHaveLength(0);
expect(inserts).toHaveLength(0);
});
});
describe("promotions.listForStaff", () => {
it("rejects moderators — drafts are admin-only", async () => {
const listForStaffHandler = (
listForStaff as unknown as WrappedHandler<{
paginationOpts: { cursor: string | null; numItems: number };
}>
)._handler;
vi.mocked(requireUser).mockResolvedValue({
userId: "users:moderator",
user: { _id: "users:moderator", role: "moderator" },
} as never);
await expect(
listForStaffHandler({ db: {} } as never, {
paginationOpts: { cursor: null, numItems: 25 },
}),
).rejects.toThrow("Forbidden");
});
it("paginates the permanent promotion history for admins", async () => {
const listForStaffHandler = (
listForStaff as unknown as WrappedHandler<{
paginationOpts: { cursor: string | null; numItems: number };
}>
)._handler;
vi.mocked(requireUser).mockResolvedValue({
userId: adminUser._id,
user: adminUser,
} as never);
const paginate = vi.fn().mockResolvedValue({
page: [{ _id: "promotions:1", slug: "newest" }],
isDone: false,
continueCursor: "next-page",
});
const order = vi.fn(() => ({ paginate }));
const query = vi.fn(() => ({ order }));
const result = await listForStaffHandler({ db: { query } } as never, {
paginationOpts: { cursor: null, numItems: 25 },
});
expect(query).toHaveBeenCalledWith("promotions");
expect(order).toHaveBeenCalledWith("desc");
expect(paginate).toHaveBeenCalledWith({ cursor: null, numItems: 25 });
expect(result).toEqual({
page: [{ _id: "promotions:1", slug: "newest" }],
isDone: false,
continueCursor: "next-page",
});
});
});
describe("promotions.listActiveInternal", () => {
const base = {
title: "Promo",
blurb: "Blurb",
models: [{ modelRef: "example-provider/example/model-alpha" }],
createdByUserId: "users:admin",
createdAt: 1,
updatedAt: 1,
};
function makeListCtx(rows: Array<Record<string, unknown>>) {
const take = vi.fn(async (limit: number) => rows.slice(0, limit));
return {
ctx: {
db: {
query: vi.fn(() => ({
withIndex: vi.fn(() => ({ take })),
})),
},
} as never,
take,
};
}
it("returns only in-window active promotions as public payloads", async () => {
const rows = [
{ ...base, _id: "promotions:1", slug: "live", status: "active", startsAt: 100, endsAt: 300 },
{
...base,
_id: "promotions:2",
slug: "not-started",
status: "active",
startsAt: 250,
endsAt: 400,
},
{
...base,
_id: "promotions:3",
slug: "expired",
status: "active",
startsAt: 10,
endsAt: 100,
},
];
const { ctx } = makeListCtx(rows);
const result = (await listActiveHandler(ctx, { now: 200 })) as {
promotions: Array<Record<string, unknown>>;
nextStartsAt: number | null;
};
expect(result.promotions.map((promotion) => promotion.slug)).toEqual(["live"]);
expect(result.promotions[0]?.active).toBe(true);
expect(result.promotions[0]).not.toHaveProperty("createdByUserId");
expect(result.promotions[0]).not.toHaveProperty("_id");
expect(result.nextStartsAt).toBe(250);
});
it("does not let many scheduled future promotions crowd out a live one", async () => {
const futureRows = Array.from({ length: 49 }, (_, index) => ({
...base,
_id: `promotions:future-${index}`,
slug: `future-${index}`,
status: "active",
startsAt: 500 + index,
endsAt: 1_000 + index,
}));
const liveRow = {
...base,
_id: "promotions:live",
slug: "live",
status: "active",
startsAt: 100,
endsAt: 2_000,
};
const { ctx } = makeListCtx([...futureRows, liveRow]);
const result = (await listActiveHandler(ctx, {
now: 200,
})) as {
promotions: Array<Record<string, unknown>>;
nextStartsAt: number | null;
};
expect(result.promotions.map((promotion) => promotion.slug)).toEqual(["live"]);
expect(result.nextStartsAt).toBe(500);
});
it("reports the next start after the active result limit is full", async () => {
const liveRows = Array.from({ length: 49 }, (_, index) => ({
...base,
_id: `promotions:live-${index}`,
slug: `live-${index}`,
status: "active",
startsAt: 100,
endsAt: 1_000 + index,
}));
const scheduledRow = {
...base,
_id: "promotions:scheduled",
slug: "scheduled",
status: "active",
startsAt: 500,
endsAt: 2_000,
};
const { ctx, take } = makeListCtx([...liveRows, scheduledRow]);
const result = (await listActiveHandler(ctx, {
now: 200,
})) as {
promotions: Array<Record<string, unknown>>;
nextStartsAt: number | null;
};
expect(result.promotions).toHaveLength(49);
expect(result.nextStartsAt).toBe(500);
expect(take).toHaveBeenCalledWith(50);
});
});
describe("promotions.getBySlugPublicInternal", () => {
const base = {
_id: "promotions:1",
slug: "example-models-launch",
title: "Promo",
blurb: "Blurb",
startsAt: 100,
endsAt: 200,
models: [{ modelRef: "example-provider/example/model-alpha" }],
createdByUserId: "users:admin",
createdAt: 1,
updatedAt: 1,
};
function makeQueryCtx(row: unknown) {
return {
db: {
query: vi.fn(() => ({
withIndex: vi.fn(() => ({
unique: vi.fn(async () => row),
})),
})),
},
} as never;
}
it("hides drafts", async () => {
const ctx = makeQueryCtx({ ...base, status: "draft" });
expect(await getBySlugHandler(ctx, { slug: base.slug, now: 150 })).toBeNull();
});
it("hides active promotions before their launch window", async () => {
const ctx = makeQueryCtx({ ...base, status: "active" });
expect(await getBySlugHandler(ctx, { slug: base.slug, now: 50 })).toBeNull();
});
it("hides promotions killed before their launch window", async () => {
const ctx = makeQueryCtx({ ...base, status: "ended" });
expect(await getBySlugHandler(ctx, { slug: base.slug, now: 50 })).toBeNull();
});
it("returns ended promotions with active=false", async () => {
const ctx = makeQueryCtx({ ...base, status: "ended", launchedAt: 100 });
const result = (await getBySlugHandler(ctx, { slug: base.slug, now: 150 })) as {
active: boolean;
status: string;
};
expect(result.status).toBe("ended");
expect(result.active).toBe(false);
});
it("hides promotions ended before launch after their scheduled start", async () => {
const ctx = makeQueryCtx({ ...base, status: "ended" });
expect(await getBySlugHandler(ctx, { slug: base.slug, now: 150 })).toBeNull();
});
it("hides ended promotions before their launch window", async () => {
const ctx = makeQueryCtx({ ...base, status: "ended" });
expect(await getBySlugHandler(ctx, { slug: base.slug, now: 50 })).toBeNull();
});
});
+587
View File
@@ -0,0 +1,587 @@
import { paginationOptsValidator } from "convex/server";
import { ConvexError, v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { internalMutation, internalQuery, mutation, query } from "./functions";
import { assertAdmin, requireUser } from "./lib/access";
import { tryNormalizePackageName } from "./lib/packageRegistry";
export type PromotionStatus = "draft" | "active" | "ended";
const PROMOTION_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
// Cross-repo authoring contracts with the OpenClaw promotions consumer.
// The CLI rejects the entire promotion when modelRef / provider /
// authChoiceId violate these grammars (they are echoed into copy-paste
// commands, so anything else is a shell-injection path), and it skips
// aliases that are not typed identifiers — so reject at authoring time
// instead of publishing a promotion clients cannot claim. Plugin names use
// the registry's canonical npm-safe grammar (scoped names allowed).
const PROMOTION_MODEL_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
const PROMOTION_ALIAS_PATTERN = /^[A-Za-z0-9_.:-]+$/;
const PROMOTION_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._@/-]*$/;
const MAX_SLUG_LENGTH = 64;
const MAX_TITLE_LENGTH = 120;
const MAX_BLURB_LENGTH = 500;
const MAX_SHORT_FIELD_LENGTH = 128;
const MAX_URL_LENGTH = 500;
const MAX_MODELS = 20;
const MAX_PLUGIN_NAMES = 10;
const STAFF_LIST_PAGE_SIZE = 100;
const ACTIVE_SET_LIMIT = 50;
const promotionModelArgValidator = v.object({
modelRef: v.string(),
alias: v.optional(v.string()),
suggestedDefault: v.optional(v.boolean()),
});
const promotionInputArgs = {
slug: v.string(),
title: v.string(),
blurb: v.string(),
sponsor: v.optional(v.string()),
startsAt: v.number(),
endsAt: v.number(),
provider: v.optional(v.string()),
authChoiceId: v.optional(v.string()),
pluginNames: v.optional(v.array(v.string())),
models: v.array(promotionModelArgValidator),
signupUrl: v.optional(v.string()),
docsUrl: v.optional(v.string()),
launchPageUrl: v.optional(v.string()),
} as const;
const promotionStatusArgValidator = v.union(
v.literal("draft"),
v.literal("active"),
v.literal("ended"),
);
type PromotionModelInput = {
modelRef: string;
alias?: string;
suggestedDefault?: boolean;
};
export type PromotionInput = {
slug: string;
title: string;
blurb: string;
sponsor?: string;
startsAt: number;
endsAt: number;
provider?: string;
authChoiceId?: string;
pluginNames?: string[];
models: PromotionModelInput[];
signupUrl?: string;
docsUrl?: string;
launchPageUrl?: string;
};
export function normalizePromotionSlug(raw: string) {
return raw.trim().toLowerCase();
}
function requireShortField(label: string, value: string | undefined, maxLength: number) {
if (value === undefined) return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (trimmed.length > maxLength) {
throw new ConvexError(`${label} too long (max ${maxLength} chars)`);
}
return trimmed;
}
function requireSingleLineField(label: string, value: string | undefined, maxLength: number) {
const parsed = requireShortField(label, value, maxLength);
if (parsed && /[\r\n]/.test(parsed)) {
throw new ConvexError(`${label} must be a single line`);
}
return parsed;
}
function requireHttpsUrl(label: string, value: string | undefined) {
if (value === undefined) return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (trimmed.length > MAX_URL_LENGTH) {
throw new ConvexError(`${label} too long (max ${MAX_URL_LENGTH} chars)`);
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw new ConvexError(`${label} must be a valid URL`);
}
if (parsed.protocol !== "https:") {
throw new ConvexError(`${label} must use https`);
}
return trimmed;
}
export function normalizePromotionInput(input: PromotionInput): PromotionInput {
const slug = normalizePromotionSlug(input.slug);
if (!slug) throw new ConvexError("Slug required");
if (slug.length > MAX_SLUG_LENGTH || !PROMOTION_SLUG_PATTERN.test(slug)) {
throw new ConvexError(
"Slug must be lowercase letters, digits, and hyphens (no leading/trailing hyphen)",
);
}
const title = input.title.trim();
if (!title) throw new ConvexError("Title required");
if (title.length > MAX_TITLE_LENGTH) {
throw new ConvexError(`Title too long (max ${MAX_TITLE_LENGTH} chars)`);
}
const blurb = input.blurb.trim();
if (!blurb) throw new ConvexError("Blurb required");
if (blurb.length > MAX_BLURB_LENGTH) {
throw new ConvexError(`Blurb too long (max ${MAX_BLURB_LENGTH} chars)`);
}
if (
!Number.isFinite(input.startsAt) ||
!Number.isFinite(input.endsAt) ||
Number.isNaN(new Date(input.startsAt).getTime()) ||
Number.isNaN(new Date(input.endsAt).getTime())
) {
throw new ConvexError("startsAt and endsAt must be valid timestamps (ms)");
}
if (input.endsAt <= input.startsAt) {
throw new ConvexError("endsAt must be after startsAt");
}
if (input.models.length === 0) throw new ConvexError("At least one model required");
if (input.models.length > MAX_MODELS) {
throw new ConvexError(`Too many models (max ${MAX_MODELS})`);
}
const models = input.models.map((model) => {
const modelRef = requireSingleLineField("modelRef", model.modelRef, MAX_SHORT_FIELD_LENGTH * 2);
if (!modelRef) throw new ConvexError("modelRef required for every model");
if (!PROMOTION_MODEL_REF_PATTERN.test(modelRef)) {
throw new ConvexError(
`modelRef "${modelRef}" may only use letters, digits, and . _ : / - characters`,
);
}
const alias = requireSingleLineField("Model alias", model.alias, MAX_SHORT_FIELD_LENGTH);
if (alias && !PROMOTION_ALIAS_PATTERN.test(alias)) {
throw new ConvexError(
`Model alias "${alias}" must use only letters, digits, dots, underscores, colons, or dashes (no spaces) — the CLI skips aliases it cannot register`,
);
}
return {
modelRef,
...(alias ? { alias } : {}),
...(model.suggestedDefault ? { suggestedDefault: true } : {}),
};
});
const rawPluginNames = (input.pluginNames ?? []).map((name) => name.trim()).filter(Boolean);
if (rawPluginNames.length > MAX_PLUGIN_NAMES) {
throw new ConvexError(`Too many plugin names (max ${MAX_PLUGIN_NAMES})`);
}
const pluginNames = rawPluginNames.map((name) => {
if (name.length > 214) throw new ConvexError(`Plugin name too long: ${name}`);
const normalized = tryNormalizePackageName(name);
if (!normalized) {
throw new ConvexError(
"Plugin names must be lowercase and npm-safe (example: @scope/name or plugin-name)",
);
}
return normalized;
});
const sponsor = requireShortField("Sponsor", input.sponsor, MAX_SHORT_FIELD_LENGTH);
const provider = requireShortField("Provider", input.provider, MAX_SHORT_FIELD_LENGTH);
if (provider && !PROMOTION_IDENTIFIER_PATTERN.test(provider)) {
throw new ConvexError("Provider may only use letters, digits, and . _ @ / - characters");
}
const authChoiceId = requireShortField(
"authChoiceId",
input.authChoiceId,
MAX_SHORT_FIELD_LENGTH,
);
if (authChoiceId && !PROMOTION_IDENTIFIER_PATTERN.test(authChoiceId)) {
throw new ConvexError("authChoiceId may only use letters, digits, and . _ @ / - characters");
}
// The CLI refuses to configure models outside the promotion's declared
// provider (`<provider>/<model>`), so a mismatched ref is unclaimable.
if (provider) {
for (const model of models) {
const prefix = `${provider}/`;
if (!model.modelRef.startsWith(prefix) || model.modelRef.length <= prefix.length) {
throw new ConvexError(
`modelRef "${model.modelRef}" must start with the promotion provider prefix "${prefix}"`,
);
}
}
}
const signupUrl = requireHttpsUrl("signupUrl", input.signupUrl);
const docsUrl = requireHttpsUrl("docsUrl", input.docsUrl);
const launchPageUrl = requireHttpsUrl("launchPageUrl", input.launchPageUrl);
return {
slug,
title,
blurb,
...(sponsor ? { sponsor } : {}),
startsAt: input.startsAt,
endsAt: input.endsAt,
...(provider ? { provider } : {}),
...(authChoiceId ? { authChoiceId } : {}),
...(pluginNames.length > 0 ? { pluginNames } : {}),
models,
...(signupUrl ? { signupUrl } : {}),
...(docsUrl ? { docsUrl } : {}),
...(launchPageUrl ? { launchPageUrl } : {}),
};
}
export function isPromotionActive(promotion: Doc<"promotions">, now: number) {
return promotion.status === "active" && promotion.startsAt <= now && now <= promotion.endsAt;
}
function resolvePromotionLaunchedAt(
promotion: Pick<Doc<"promotions">, "status" | "startsAt" | "launchedAt">,
nextStatus: PromotionStatus,
nextStartsAt: number,
now: number,
) {
if (promotion.launchedAt !== undefined) return promotion.launchedAt;
if (promotion.status === "active" && promotion.startsAt <= now) return promotion.startsAt;
if (nextStatus === "active" && nextStartsAt <= now) return now;
return undefined;
}
export function toPublicPromotion(promotion: Doc<"promotions">, now: number) {
return {
slug: promotion.slug,
title: promotion.title,
blurb: promotion.blurb,
...(promotion.sponsor ? { sponsor: promotion.sponsor } : {}),
status: promotion.status,
active: isPromotionActive(promotion, now),
startsAt: promotion.startsAt,
endsAt: promotion.endsAt,
...(promotion.provider ? { provider: promotion.provider } : {}),
...(promotion.authChoiceId ? { authChoiceId: promotion.authChoiceId } : {}),
...(promotion.pluginNames && promotion.pluginNames.length > 0
? { pluginNames: promotion.pluginNames }
: {}),
models: promotion.models,
...(promotion.signupUrl ? { signupUrl: promotion.signupUrl } : {}),
...(promotion.docsUrl ? { docsUrl: promotion.docsUrl } : {}),
...(promotion.launchPageUrl ? { launchPageUrl: promotion.launchPageUrl } : {}),
};
}
async function getPromotionBySlug(ctx: QueryCtx | MutationCtx, slug: string) {
return await ctx.db
.query("promotions")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.unique();
}
// Feed snapshots are immutable, so visibility changes must republish the
// promotions feed: immediately for the mutation itself, and at future window
// edges so launches and expiries land on time without waiting for the
// periodic backstop publish.
async function schedulePromotionsFeedRepublication(
ctx: MutationCtx,
promotion: Pick<Doc<"promotions">, "status" | "startsAt" | "endsAt">,
) {
await ctx.scheduler.runAfter(0, internal.promotionsFeed.publishInternal, {});
if (promotion.status !== "active") return;
const now = Date.now();
for (const edge of [promotion.startsAt, promotion.endsAt + 1]) {
if (edge > now) {
await ctx.scheduler.runAt(edge, internal.promotionsFeed.publishInternal, {});
}
}
}
async function requireActorFromId(ctx: MutationCtx, actorUserId: Id<"users">) {
const actor = await ctx.db.get(actorUserId);
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error("User not found");
return actor;
}
async function createPromotionForActor(
ctx: MutationCtx,
actor: Doc<"users">,
input: PromotionInput,
) {
assertAdmin(actor);
const normalized = normalizePromotionInput(input);
const existing = await getPromotionBySlug(ctx, normalized.slug);
if (existing) throw new ConvexError(`Promotion already exists: ${normalized.slug}`);
const now = Date.now();
const promotionId = await ctx.db.insert("promotions", {
...normalized,
status: "draft",
createdByUserId: actor._id,
createdAt: now,
updatedAt: now,
});
await ctx.db.insert("auditLogs", {
actorUserId: actor._id,
action: "promotion.create",
targetType: "promotion",
targetId: normalized.slug,
metadata: { promotionId, title: normalized.title },
createdAt: now,
});
return { ok: true as const, slug: normalized.slug, status: "draft" as const };
}
async function updatePromotionForActor(
ctx: MutationCtx,
actor: Doc<"users">,
slug: string,
input: PromotionInput,
) {
assertAdmin(actor);
const existing = await getPromotionBySlug(ctx, normalizePromotionSlug(slug));
if (!existing) throw new ConvexError("Promotion not found");
const normalized = normalizePromotionInput(input);
if (normalized.slug !== existing.slug) {
// Slugs are referenced by external links and CLI claim provenance once a
// promotion has been activated; only drafts may be renamed.
if (existing.status !== "draft") {
throw new ConvexError("Slug can only be changed while the promotion is a draft");
}
const collision = await getPromotionBySlug(ctx, normalized.slug);
if (collision) throw new ConvexError(`Promotion already exists: ${normalized.slug}`);
}
const now = Date.now();
const hasLaunched =
existing.launchedAt !== undefined || (existing.status === "active" && existing.startsAt <= now);
if (existing.status === "active" && !hasLaunched && normalized.endsAt < now) {
throw new ConvexError("Unlaunched promotions cannot be moved to an expired window");
}
const launchedAt = resolvePromotionLaunchedAt(
existing,
existing.status,
normalized.startsAt,
now,
);
await ctx.db.replace(existing._id, {
...normalized,
status: existing.status,
...(launchedAt !== undefined ? { launchedAt } : {}),
createdByUserId: existing.createdByUserId,
createdAt: existing.createdAt,
updatedByUserId: actor._id,
updatedAt: now,
});
await ctx.db.insert("auditLogs", {
actorUserId: actor._id,
action: "promotion.update",
targetType: "promotion",
targetId: normalized.slug,
metadata: { promotionId: existing._id, previousSlug: existing.slug },
createdAt: now,
});
await schedulePromotionsFeedRepublication(ctx, {
status: existing.status,
startsAt: normalized.startsAt,
endsAt: normalized.endsAt,
});
return { ok: true as const, slug: normalized.slug, status: existing.status };
}
async function setPromotionStatusForActor(
ctx: MutationCtx,
actor: Doc<"users">,
slug: string,
status: PromotionStatus,
) {
assertAdmin(actor);
const existing = await getPromotionBySlug(ctx, normalizePromotionSlug(slug));
if (!existing) throw new ConvexError("Promotion not found");
if (status === "draft" && existing.status !== "draft") {
throw new ConvexError("Published promotions cannot return to draft");
}
if (existing.status === "draft" && status === "ended") {
throw new ConvexError("Draft promotions must be activated before they can end");
}
if (existing.status === "ended" && status === "active") {
throw new ConvexError("Ended promotions cannot be reactivated");
}
if (existing.status === status) {
return { ok: true as const, slug: existing.slug, status };
}
const now = Date.now();
if (status === "active") {
if (existing.endsAt < now) {
throw new ConvexError("Expired promotions cannot be activated");
}
const activePromotions = await ctx.db
.query("promotions")
.withIndex("by_status_endsAt", (q) => q.eq("status", "active"))
.take(ACTIVE_SET_LIMIT);
if (activePromotions.length >= ACTIVE_SET_LIMIT) {
throw new ConvexError(
`At most ${ACTIVE_SET_LIMIT} promotions can be active; end an existing promotion first`,
);
}
}
const launchedAt = resolvePromotionLaunchedAt(existing, status, existing.startsAt, now);
await ctx.db.patch(existing._id, {
status,
...(launchedAt !== undefined ? { launchedAt } : {}),
updatedByUserId: actor._id,
updatedAt: now,
});
await ctx.db.insert("auditLogs", {
actorUserId: actor._id,
action: "promotion.set_status",
targetType: "promotion",
targetId: existing.slug,
metadata: { promotionId: existing._id, from: existing.status, to: status },
createdAt: now,
});
await schedulePromotionsFeedRepublication(ctx, {
status,
startsAt: existing.startsAt,
endsAt: existing.endsAt,
});
return { ok: true as const, slug: existing.slug, status };
}
// Dashboard entry points (Convex auth session).
export const create = mutation({
args: promotionInputArgs,
handler: async (ctx, args) => {
const { user } = await requireUser(ctx);
return createPromotionForActor(ctx, user, args);
},
});
export const update = mutation({
args: { targetSlug: v.string(), ...promotionInputArgs },
handler: async (ctx, args) => {
const { user } = await requireUser(ctx);
const { targetSlug, ...input } = args;
return updatePromotionForActor(ctx, user, targetSlug, input);
},
});
export const setStatus = mutation({
args: { slug: v.string(), status: promotionStatusArgValidator },
handler: async (ctx, args) => {
const { user } = await requireUser(ctx);
return setPromotionStatusForActor(ctx, user, args.slug, args.status);
},
});
// HTTP API entry points (API-token auth resolved in the handler).
export const createInternal = internalMutation({
args: { actorUserId: v.id("users"), input: v.object(promotionInputArgs) },
handler: async (ctx, args) => {
const actor = await requireActorFromId(ctx, args.actorUserId);
return createPromotionForActor(ctx, actor, args.input);
},
});
export const updateInternal = internalMutation({
args: {
actorUserId: v.id("users"),
targetSlug: v.string(),
input: v.object(promotionInputArgs),
},
handler: async (ctx, args) => {
const actor = await requireActorFromId(ctx, args.actorUserId);
return updatePromotionForActor(ctx, actor, args.targetSlug, args.input);
},
});
export const setStatusInternal = internalMutation({
args: {
actorUserId: v.id("users"),
slug: v.string(),
status: promotionStatusArgValidator,
},
handler: async (ctx, args) => {
const actor = await requireActorFromId(ctx, args.actorUserId);
return setPromotionStatusForActor(ctx, actor, args.slug, args.status);
},
});
// Activation enforces ACTIVE_SET_LIMIT, so this read remains bounded while
// still considering every curated active or scheduled promotion.
async function collectActivePromotions(ctx: QueryCtx, now: number) {
const promotions: Array<ReturnType<typeof toPublicPromotion>> = [];
let nextStartsAt: number | null = null;
const active = await ctx.db
.query("promotions")
.withIndex("by_status_endsAt", (q) => q.eq("status", "active").gte("endsAt", now))
.take(ACTIVE_SET_LIMIT);
for (const promotion of active) {
if (promotion.startsAt > now) {
nextStartsAt =
nextStartsAt === null ? promotion.startsAt : Math.min(nextStartsAt, promotion.startsAt);
continue;
}
if (!isPromotionActive(promotion, now)) continue;
promotions.push(toPublicPromotion(promotion, now));
}
return { promotions, nextStartsAt };
}
export const listActiveInternal = internalQuery({
args: { now: v.number() },
handler: async (ctx, args) => collectActivePromotions(ctx, args.now),
});
export const getBySlugPublicInternal = internalQuery({
args: { slug: v.string(), now: v.number() },
handler: async (ctx, args) => {
const promotion = await getPromotionBySlug(ctx, normalizePromotionSlug(args.slug));
// Drafts and every pre-launch state stay hidden so unreleased launch
// details cannot be read by guessing the slug. Ended promotions remain
// visible after launch so stale links can render an "ended" state.
if (!promotion || promotion.status === "draft") return null;
if (promotion.status === "ended") {
return promotion.launchedAt === undefined ? null : toPublicPromotion(promotion, args.now);
}
if (args.now < promotion.startsAt) return null;
return toPublicPromotion(promotion, args.now);
},
});
export const listAllInternal = internalQuery({
args: { paginationOpts: paginationOptsValidator },
handler: async (ctx, args) => {
const result = await ctx.db.query("promotions").order("desc").paginate(args.paginationOpts);
const now = Date.now();
return {
...result,
page: result.page.map((promotion) => toPublicPromotion(promotion, now)),
};
},
});
export const listForStaff = query({
args: { paginationOpts: paginationOptsValidator },
handler: async (ctx, args) => {
const { user } = await requireUser(ctx);
// Admin-only like every other promotions surface: drafts carry unreleased
// launch windows and sponsor details.
assertAdmin(user);
return await ctx.db
.query("promotions")
.order("desc")
.paginate({
...args.paginationOpts,
numItems: Math.min(args.paginationOpts.numItems, STAFF_LIST_PAGE_SIZE),
});
},
});
+141
View File
@@ -0,0 +1,141 @@
import { parsePromotionsFeed, PROMOTIONS_FEED_ID } from "clawhub-schema";
import { describe, expect, it, vi } from "vitest";
vi.mock("@convex-dev/auth/server", () => ({
getAuthUserId: vi.fn(),
authTables: {},
}));
const { publishInternal } = await import("./promotionsFeed");
type WrappedHandler<TArgs, TResult = unknown> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
};
const publishHandler = (publishInternal as unknown as WrappedHandler<Record<string, never>>)
._handler;
const basePromotion = {
title: "Free Example models",
blurb: "A limited-time free model offer from Example.",
models: [{ modelRef: "example-provider/example/model-alpha", alias: "model-alpha" }],
createdByUserId: "users:admin",
createdAt: 1,
updatedAt: 1,
};
function makeCtx({
promotions,
latestPublication = null,
}: {
promotions: Array<Record<string, unknown>>;
latestPublication?: Record<string, unknown> | null;
}) {
const inserts: Array<{ table: string; doc: Record<string, unknown> }> = [];
const patches: Array<{ id: unknown; patch: Record<string, unknown> }> = [];
const db = {
normalizeId: vi.fn(),
system: {},
get: vi.fn(),
query: vi.fn((table: string) => {
if (table === "promotions") {
return {
withIndex: vi.fn(() => ({
async *[Symbol.asyncIterator]() {
yield* promotions;
},
})),
};
}
if (table === "catalogFeedPublications") {
return {
withIndex: vi.fn(() => ({
unique: vi.fn(async () => latestPublication),
})),
};
}
throw new Error(`Unexpected query table: ${table}`);
}),
insert: vi.fn(async (table: string, doc: Record<string, unknown>) => {
inserts.push({ table, doc });
return `${table}:${inserts.length}`;
}),
patch: vi.fn(async (id: unknown, patch: Record<string, unknown>) => {
patches.push({ id, patch });
}),
replace: vi.fn(),
delete: vi.fn(),
};
return { ctx: { db } as never, inserts, patches };
}
describe("promotionsFeed.publishInternal", () => {
it("publishes launched active promotions as a valid feed snapshot", async () => {
const now = Date.now();
const { ctx, inserts } = makeCtx({
promotions: [
{
...basePromotion,
_id: "promotions:1",
slug: "live",
status: "active",
startsAt: now - 1_000,
endsAt: now + 1_000,
},
{
...basePromotion,
_id: "promotions:2",
slug: "not-started",
status: "active",
startsAt: now + 500,
endsAt: now + 2_000,
},
],
});
const result = (await publishHandler(ctx, {})) as { sequence: number; entryCount: number };
expect(result.sequence).toBe(1);
expect(result.entryCount).toBe(1);
const publicationInsert = inserts.find((entry) => entry.table === "catalogFeedPublications");
expect(publicationInsert?.doc.feedId).toBe(PROMOTIONS_FEED_ID);
const feed = parsePromotionsFeed(JSON.parse(publicationInsert?.doc.payload as string));
expect(feed.entries.map((entry) => entry.slug)).toEqual(["live"]);
expect(feed.entries[0]).not.toHaveProperty("status");
});
it("increments the sequence and patches the existing publication row", async () => {
const now = Date.now();
const { ctx, inserts, patches } = makeCtx({
promotions: [
{
...basePromotion,
_id: "promotions:1",
slug: "live",
status: "active",
startsAt: now - 1_000,
endsAt: now + 1_000,
},
],
latestPublication: { _id: "catalogFeedPublications:1", sequence: 7 },
});
const result = (await publishHandler(ctx, {})) as { sequence: number };
expect(result.sequence).toBe(8);
expect(inserts).toHaveLength(0);
expect(patches).toHaveLength(1);
expect(patches[0]?.patch.sequence).toBe(8);
});
it("publishes an empty feed when nothing is live", async () => {
const { ctx, inserts } = makeCtx({ promotions: [] });
const result = (await publishHandler(ctx, {})) as { entryCount: number };
expect(result.entryCount).toBe(0);
const publicationInsert = inserts.find((entry) => entry.table === "catalogFeedPublications");
const feed = parsePromotionsFeed(JSON.parse(publicationInsert?.doc.payload as string));
expect(feed.entries).toEqual([]);
});
});
+95
View File
@@ -0,0 +1,95 @@
import {
PROMOTIONS_FEED_DESCRIPTION,
PROMOTIONS_FEED_ID,
PROMOTIONS_FEED_SCHEMA_VERSION,
serializePromotionsFeed,
type PromotionsFeedEntry,
} from "clawhub-schema";
import type { Doc } from "./_generated/dataModel";
import { internalMutation } from "./functions";
import { sha256Hex } from "./lib/clawpack";
import { isPromotionActive } from "./promotions";
// Feed snapshots are immutable bytes (see specs/hosted-catalog-feed.md), so a
// snapshot is republished whenever promotion state changes and at each
// promotion's window edges. The expiry horizon only signals staleness to
// clients; edge-triggered republication keeps content correct well within it.
const PROMOTIONS_FEED_EXPIRY_MS = 24 * 60 * 60 * 1000;
const PROMOTIONS_FEED_MAX_ENTRIES = 50;
export function toPromotionsFeedEntry(promotion: Doc<"promotions">): PromotionsFeedEntry {
return {
type: "promotion",
slug: promotion.slug,
title: promotion.title,
blurb: promotion.blurb,
...(promotion.sponsor ? { sponsor: promotion.sponsor } : {}),
startsAt: promotion.startsAt,
endsAt: promotion.endsAt,
...(promotion.provider ? { provider: promotion.provider } : {}),
...(promotion.authChoiceId ? { authChoiceId: promotion.authChoiceId } : {}),
...(promotion.pluginNames && promotion.pluginNames.length > 0
? { pluginNames: promotion.pluginNames }
: {}),
models: promotion.models,
...(promotion.signupUrl ? { signupUrl: promotion.signupUrl } : {}),
...(promotion.docsUrl ? { docsUrl: promotion.docsUrl } : {}),
...(promotion.launchPageUrl ? { launchPageUrl: promotion.launchPageUrl } : {}),
};
}
export const publishInternal = internalMutation({
args: {},
handler: async (ctx) => {
const now = Date.now();
// Same visibility rule as the public API: active and launched only, so
// pre-activated future promotions never leak launch details into the feed.
const entries: PromotionsFeedEntry[] = [];
const active = ctx.db
.query("promotions")
.withIndex("by_status_endsAt", (q) => q.eq("status", "active").gte("endsAt", now));
for await (const promotion of active) {
if (!isPromotionActive(promotion, now)) continue;
entries.push(toPromotionsFeedEntry(promotion));
if (entries.length >= PROMOTIONS_FEED_MAX_ENTRIES) break;
}
const latest = await ctx.db
.query("catalogFeedPublications")
.withIndex("by_feed", (q) => q.eq("feedId", PROMOTIONS_FEED_ID))
.unique();
const sequence = (latest?.sequence ?? 0) + 1;
const generatedAt = new Date(now).toISOString();
const expiresAt = new Date(now + PROMOTIONS_FEED_EXPIRY_MS).toISOString();
const payload = serializePromotionsFeed({
schemaVersion: PROMOTIONS_FEED_SCHEMA_VERSION,
id: PROMOTIONS_FEED_ID,
generatedAt,
sequence,
expiresAt,
description: PROMOTIONS_FEED_DESCRIPTION,
entries,
});
const payloadSha256 = await sha256Hex(new TextEncoder().encode(payload));
const publication = {
feedId: PROMOTIONS_FEED_ID,
sequence,
generatedAt,
expiresAt,
payload,
payloadSha256,
publishedAt: now,
};
const publicationId = latest
? (await ctx.db.patch(latest._id, publication), latest._id)
: await ctx.db.insert("catalogFeedPublications", publication);
return {
publicationId,
feedId: PROMOTIONS_FEED_ID,
sequence,
payloadSha256,
publishedAt: now,
entryCount: entries.length,
};
},
});
+40
View File
@@ -2546,6 +2546,45 @@ const stars = defineTable({
.index("by_user", ["userId"])
.index("by_skill_user", ["skillId", "userId"]);
const promotionStatusValidator = v.union(
v.literal("draft"),
v.literal("active"),
v.literal("ended"),
);
// Declarative activation payload consumed by the OpenClaw CLI. The CLI
// validates authChoiceId/pluginNames against its local provider catalog and
// never executes anything from these records.
const promotionModelValidator = v.object({
modelRef: v.string(),
alias: v.optional(v.string()),
suggestedDefault: v.optional(v.boolean()),
});
const promotions = defineTable({
slug: v.string(),
title: v.string(),
blurb: v.string(),
sponsor: v.optional(v.string()),
status: promotionStatusValidator,
startsAt: v.number(),
endsAt: v.number(),
provider: v.optional(v.string()),
authChoiceId: v.optional(v.string()),
pluginNames: v.optional(v.array(v.string())),
models: v.array(promotionModelValidator),
signupUrl: v.optional(v.string()),
docsUrl: v.optional(v.string()),
launchPageUrl: v.optional(v.string()),
launchedAt: v.optional(v.number()),
createdByUserId: v.id("users"),
updatedByUserId: v.optional(v.id("users")),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_slug", ["slug"])
.index("by_status_endsAt", ["status", "endsAt"]);
const auditLogs = defineTable({
actorUserId: v.optional(v.id("users")),
action: v.string(),
@@ -3116,6 +3155,7 @@ export default defineSchema({
officialPluginMigrations,
catalogFeedPublications,
stars,
promotions,
auditLogs,
systemSettings,
publisherAbuseScoreRuns,
+58
View File
@@ -60,6 +60,12 @@ import {
cmdTransferPackageOwner,
cmdUpsertPackageMigration,
} from "./commands/packages.js";
import {
cmdCreatePromotion,
cmdListPromotions,
cmdSetPromotionStatus,
cmdUpdatePromotion,
} from "./commands/promotions.js";
const program = new Command()
.name("clawhub-admin")
@@ -331,6 +337,13 @@ const skills = program
.showHelpAfterError()
.showSuggestionAfterError();
const promotions = program
.command("promotions")
.alias("promotion")
.description("Platform promotion records (admin only)")
.showHelpAfterError()
.showSuggestionAfterError();
registerPluginOperations(plugins);
registerPluginModerationCommands(plugins);
registerPluginGovernanceCommands(plugins);
@@ -342,6 +355,51 @@ registerOrgCommands(org);
registerEmailCommands(email);
registerContentRightsCommands(contentRights);
registerSkillModerationCommands(skills);
registerPromotionCommands(promotions);
function registerPromotionCommands(command: Command) {
command
.command("list")
.description("List promotions (active by default)")
.option("--all", "Include drafts and ended promotions (admin token required)")
.option("--json", "Output JSON")
.action(async (options) => {
const opts = await resolveGlobalOpts();
await cmdListPromotions(opts, options);
});
command
.command("create")
.description("Create a promotion (starts as draft) from a JSON file")
.argument("<file>", "JSON file with the promotion payload")
.option("--json", "Output JSON")
.action(async (file, options) => {
const opts = await resolveGlobalOpts();
await cmdCreatePromotion(opts, file, options);
});
command
.command("update")
.description("Replace a promotion's fields from a JSON file (status unchanged)")
.argument("<slug>", "Promotion slug")
.argument("<file>", "JSON file with the promotion payload")
.option("--json", "Output JSON")
.action(async (slug, file, options) => {
const opts = await resolveGlobalOpts();
await cmdUpdatePromotion(opts, slug, file, options);
});
command
.command("set-status")
.description("Set a promotion's status (draft|active|ended)")
.argument("<slug>", "Promotion slug")
.argument("<status>", "draft, active, or ended")
.option("--json", "Output JSON")
.action(async (slug, status, options) => {
const opts = await resolveGlobalOpts();
await cmdSetPromotionStatus(opts, slug, status, options);
});
}
function collectOption(value: string, previous: string[]) {
return [...previous, value];
@@ -0,0 +1,79 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createAuthTokenModuleMocks,
createHttpModuleMocks,
createRegistryModuleMocks,
createUiModuleMocks,
makeGlobalOpts,
} from "../../../clawhub/test/cliCommandTestKit.js";
const authTokenMocks = createAuthTokenModuleMocks();
const registryMocks = createRegistryModuleMocks();
const httpMocks = createHttpModuleMocks();
const uiMocks = createUiModuleMocks();
vi.mock("../../../clawhub/src/cli/authToken.js", () => authTokenMocks.moduleFactory());
vi.mock("../../../clawhub/src/cli/registry.js", () => registryMocks.moduleFactory());
vi.mock("../../../clawhub/src/http.js", () => httpMocks.moduleFactory());
vi.mock("../../../clawhub/src/cli/ui.js", () => uiMocks.moduleFactory());
const { cmdListPromotions } = await import("./promotions");
const promotion = {
slug: "example-models-launch",
title: "Free Example models",
blurb: "A limited-time free model offer from Example.",
status: "ended",
active: false,
startsAt: 100,
endsAt: 200,
models: [{ modelRef: "example-provider/example/model-alpha" }],
};
afterEach(() => {
vi.clearAllMocks();
});
describe("cmdListPromotions", () => {
it("loads every page for --all", async () => {
vi.spyOn(console, "log").mockImplementation(() => undefined);
httpMocks.apiRequest
.mockResolvedValueOnce({
promotions: [{ ...promotion, slug: "newer-promotion" }],
nextCursor: "next page",
})
.mockResolvedValueOnce({
promotions: [{ ...promotion, slug: "older-promotion" }],
nextCursor: null,
});
const result = await cmdListPromotions(makeGlobalOpts(), { all: true });
expect(httpMocks.apiRequest).toHaveBeenNthCalledWith(
1,
"https://clawhub.ai",
expect.objectContaining({
method: "GET",
path: "/api/v1/promotions?status=all&limit=100",
token: "tkn",
}),
expect.anything(),
);
expect(httpMocks.apiRequest).toHaveBeenNthCalledWith(
2,
"https://clawhub.ai",
expect.objectContaining({
method: "GET",
path: "/api/v1/promotions?status=all&limit=100&cursor=next%20page",
token: "tkn",
}),
expect.anything(),
);
expect(result.promotions.map((entry) => entry.slug)).toEqual([
"newer-promotion",
"older-promotion",
]);
});
});
@@ -0,0 +1,177 @@
import { readFile } from "node:fs/promises";
import { requireAuthToken } from "../../../clawhub/src/cli/authToken.js";
import { getRegistry } from "../../../clawhub/src/cli/registry.js";
import type { GlobalOpts } from "../../../clawhub/src/cli/types.js";
import { createCrabLoader, fail, formatError } from "../../../clawhub/src/cli/ui.js";
import { apiRequest } from "../../../clawhub/src/http.js";
import {
ApiRoutes,
ApiV1PromotionsListResponseSchema,
ApiV1PromotionWriteResponseSchema,
parseArk,
type ApiV1Promotion,
} from "../../../clawhub/src/schema/index.js";
const PROMOTION_STATUSES = ["draft", "active", "ended"] as const;
type PromotionStatus = (typeof PROMOTION_STATUSES)[number];
function normalizeSlugOrFail(raw: string) {
const slug = raw.trim().toLowerCase();
if (!slug) fail("Promotion slug required");
return slug;
}
async function readPromotionInputFile(file: string) {
let raw: string;
try {
raw = await readFile(file, "utf8");
} catch (error) {
return fail(`Unable to read ${file}: ${formatError(error)}`);
}
try {
return JSON.parse(raw) as Record<string, unknown>;
} catch {
return fail(`${file} is not valid JSON`);
}
}
function formatPromotionLine(promotion: ApiV1Promotion) {
const window = `${new Date(promotion.startsAt).toISOString().slice(0, 10)}${new Date(
promotion.endsAt,
)
.toISOString()
.slice(0, 10)}`;
const state = promotion.active ? "active" : promotion.status;
const models = promotion.models.map((model) => model.modelRef).join(", ");
return `${promotion.slug} [${state}] ${window} ${promotion.title} (${models})`;
}
export async function cmdListPromotions(
opts: GlobalOpts,
options: { all?: boolean; json?: boolean },
) {
const registry = await getRegistry(opts, { cache: true });
const token = options.all ? await requireAuthToken() : undefined;
const promotions: ApiV1Promotion[] = [];
const seenCursors = new Set<string>();
let cursor: string | null = null;
do {
const requestPath: string = options.all
? `${ApiRoutes.promotions}?status=all&limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`
: ApiRoutes.promotions;
const result: unknown = await apiRequest(
registry,
{ method: "GET", path: requestPath, ...(token ? { token } : {}) },
ApiV1PromotionsListResponseSchema,
);
const page: {
promotions: ApiV1Promotion[];
nextCursor?: string | null;
} = parseArk(ApiV1PromotionsListResponseSchema, result, "Promotions response");
promotions.push(...page.promotions);
cursor = options.all ? (page.nextCursor ?? null) : null;
if (cursor && seenCursors.has(cursor)) {
fail("Promotions response repeated a pagination cursor");
}
if (cursor) seenCursors.add(cursor);
} while (cursor);
const parsed = { promotions };
if (options.json) {
process.stdout.write(`${JSON.stringify(parsed, null, 2)}\n`);
return parsed;
}
if (parsed.promotions.length === 0) {
console.log(options.all ? "No promotions." : "No active promotions.");
return parsed;
}
for (const promotion of parsed.promotions) {
console.log(formatPromotionLine(promotion));
}
return parsed;
}
export async function cmdCreatePromotion(
opts: GlobalOpts,
file: string,
options: { json?: boolean },
) {
const body = await readPromotionInputFile(file);
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const spinner = options.json ? null : createCrabLoader("Creating promotion");
try {
const result = await apiRequest(
registry,
{ method: "POST", path: ApiRoutes.promotions, token, body },
ApiV1PromotionWriteResponseSchema,
);
spinner?.succeed(
`Created promotion "${result.slug}" (status: ${result.status}). Activate with: clawhub-admin promotions set-status ${result.slug} active`,
);
if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return result;
} catch (error) {
spinner?.fail(formatError(error));
throw error;
}
}
export async function cmdUpdatePromotion(
opts: GlobalOpts,
slugArg: string,
file: string,
options: { json?: boolean },
) {
const slug = normalizeSlugOrFail(slugArg);
const body = await readPromotionInputFile(file);
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const spinner = options.json ? null : createCrabLoader(`Updating promotion ${slug}`);
try {
const result = await apiRequest(
registry,
{ method: "POST", path: `${ApiRoutes.promotions}/${slug}/update`, token, body },
ApiV1PromotionWriteResponseSchema,
);
spinner?.succeed(`Updated promotion "${result.slug}" (status: ${result.status})`);
if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return result;
} catch (error) {
spinner?.fail(formatError(error));
throw error;
}
}
export async function cmdSetPromotionStatus(
opts: GlobalOpts,
slugArg: string,
statusArg: string,
options: { json?: boolean },
) {
const slug = normalizeSlugOrFail(slugArg);
const status = statusArg.trim().toLowerCase() as PromotionStatus;
if (!PROMOTION_STATUSES.includes(status)) {
fail(`Status must be one of: ${PROMOTION_STATUSES.join(", ")}`);
}
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const spinner = options.json ? null : createCrabLoader(`Setting ${slug} to ${status}`);
try {
const result = await apiRequest(
registry,
{ method: "POST", path: `${ApiRoutes.promotions}/${slug}/status`, token, body: { status } },
ApiV1PromotionWriteResponseSchema,
);
spinner?.succeed(`Promotion "${result.slug}" is now ${result.status}`);
if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return result;
} catch (error) {
spinner?.fail(formatError(error));
throw error;
}
}
+1
View File
@@ -21,6 +21,7 @@ export const ApiRoutes = {
packages: "/api/v1/packages",
codePlugins: "/api/v1/code-plugins",
bundlePlugins: "/api/v1/bundle-plugins",
promotions: "/api/v1/promotions",
stars: "/api/v1/stars",
transfers: "/api/v1/transfers",
publishers: "/api/v1/publishers",
+36
View File
@@ -949,6 +949,42 @@ export const ApiV1SetRoleResponseSchema = type({
role: '"admin"|"moderator"|"user"',
});
export const ApiV1PromotionModelSchema = type({
modelRef: "string",
alias: "string?",
suggestedDefault: "boolean?",
});
export const ApiV1PromotionSchema = type({
slug: "string",
title: "string",
blurb: "string",
sponsor: "string?",
status: '"draft"|"active"|"ended"',
active: "boolean",
startsAt: "number",
endsAt: "number",
provider: "string?",
authChoiceId: "string?",
pluginNames: "string[]?",
models: ApiV1PromotionModelSchema.array(),
signupUrl: "string?",
docsUrl: "string?",
launchPageUrl: "string?",
});
export type ApiV1Promotion = (typeof ApiV1PromotionSchema)[inferred];
export const ApiV1PromotionsListResponseSchema = type({
promotions: ApiV1PromotionSchema.array(),
"nextCursor?": "string|null",
});
export const ApiV1PromotionWriteResponseSchema = type({
ok: "true",
slug: "string",
status: '"draft"|"active"|"ended"',
});
export const ApiV1StarResponseSchema = type({
ok: "true",
starred: "boolean",
+1
View File
@@ -8,6 +8,7 @@ export * from "./openclawContract.js";
export * from "./openClawExtensionSlugs.js";
export * from "./packages.js";
export * from "./pluginCategories.js";
export * from "./promotionsFeed.js";
export { ApiRoutes, LegacyApiRoutes } from "./routes.js";
export * from "./schemas.js";
export * from "./textFiles.js";
+1
View File
@@ -7,6 +7,7 @@ export * from "./openclawContract.js";
export * from "./openClawExtensionSlugs.js";
export * from "./packages.js";
export * from "./pluginCategories.js";
export * from "./promotionsFeed.js";
export { ApiRoutes, LegacyApiRoutes } from "./routes.js";
export * from "./schemas.js";
export * from "./textFiles.js";
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"}
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"}
+67
View File
@@ -0,0 +1,67 @@
import { type inferred } from "arktype";
export declare const PromotionsFeedModelSchema: import("arktype/internal/variants/object.ts").ObjectType<{
modelRef: string;
alias?: string | undefined;
suggestedDefault?: boolean | undefined;
}, {}>;
export type PromotionsFeedModel = (typeof PromotionsFeedModelSchema)[inferred];
export declare const PromotionsFeedEntrySchema: import("arktype/internal/variants/object.ts").ObjectType<{
type: "promotion";
slug: string;
title: string;
blurb: string;
startsAt: number;
endsAt: number;
models: {
modelRef: string;
alias?: string | undefined;
suggestedDefault?: boolean | undefined;
}[];
sponsor?: string | undefined;
provider?: string | undefined;
authChoiceId?: string | undefined;
pluginNames?: string[] | undefined;
signupUrl?: string | undefined;
docsUrl?: string | undefined;
launchPageUrl?: string | undefined;
}, {}>;
export type PromotionsFeedEntry = (typeof PromotionsFeedEntrySchema)[inferred];
export declare const PromotionsFeedSchema: import("arktype/internal/variants/object.ts").ObjectType<{
schemaVersion: number;
id: string;
generatedAt: string;
sequence: number;
expiresAt: string;
entries: {
type: "promotion";
slug: string;
title: string;
blurb: string;
startsAt: number;
endsAt: number;
models: {
modelRef: string;
alias?: string | undefined;
suggestedDefault?: boolean | undefined;
}[];
sponsor?: string | undefined;
provider?: string | undefined;
authChoiceId?: string | undefined;
pluginNames?: string[] | undefined;
signupUrl?: string | undefined;
docsUrl?: string | undefined;
launchPageUrl?: string | undefined;
}[];
description?: string | undefined;
}, {}>;
export type PromotionsFeed = (typeof PromotionsFeedSchema)[inferred];
/**
* Cross-repo wire contract with the OpenClaw promotions consumer. Bump this
* only after matching OpenClaw parser/validation support has shipped,
* otherwise clients reject the hosted feed.
*/
export declare const PROMOTIONS_FEED_SCHEMA_VERSION = 1;
export declare const PROMOTIONS_FEED_ID = "clawhub-promotions";
export declare const PROMOTIONS_FEED_DESCRIPTION = "Active promotional offers surfaced to OpenClaw clients at runtime.";
export declare function parsePromotionsFeed(value: unknown): PromotionsFeed;
export declare function serializePromotionsFeed(feed: PromotionsFeed): string;
+107
View File
@@ -0,0 +1,107 @@
import { type } from "arktype";
const HttpsUrlSchema = type("string.url").narrow((value, ctx) => new URL(value).protocol === "https:" || ctx.mustBe("an HTTPS URL"));
export const PromotionsFeedModelSchema = type({
"+": "reject",
modelRef: "string",
"alias?": "string",
"suggestedDefault?": "boolean",
});
export const PromotionsFeedEntrySchema = type({
"+": "reject",
type: '"promotion"',
slug: "string",
title: "string",
blurb: "string",
"sponsor?": "string",
startsAt: "number",
endsAt: "number",
"provider?": "string",
"authChoiceId?": "string",
"pluginNames?": "string[]",
models: PromotionsFeedModelSchema.array(),
"signupUrl?": HttpsUrlSchema,
"docsUrl?": HttpsUrlSchema,
"launchPageUrl?": HttpsUrlSchema,
});
export const PromotionsFeedSchema = type({
"+": "reject",
schemaVersion: "number",
id: "string",
generatedAt: "string",
sequence: "number",
expiresAt: "string",
"description?": "string",
entries: PromotionsFeedEntrySchema.array(),
});
/**
* Cross-repo wire contract with the OpenClaw promotions consumer. Bump this
* only after matching OpenClaw parser/validation support has shipped,
* otherwise clients reject the hosted feed.
*/
export const PROMOTIONS_FEED_SCHEMA_VERSION = 1;
export const PROMOTIONS_FEED_ID = "clawhub-promotions";
export const PROMOTIONS_FEED_DESCRIPTION = "Active promotional offers surfaced to OpenClaw clients at runtime.";
export function parsePromotionsFeed(value) {
const feed = PromotionsFeedSchema.assert(value);
if (feed.id !== PROMOTIONS_FEED_ID) {
throw new Error(`Unsupported promotions feed id: ${feed.id}`);
}
if (feed.schemaVersion !== PROMOTIONS_FEED_SCHEMA_VERSION) {
throw new Error(`Unsupported promotions feed schema version: ${feed.schemaVersion}`);
}
if (feed.sequence < 0 || !Number.isSafeInteger(feed.sequence)) {
throw new Error("Promotions feed sequence must be a non-negative integer");
}
if (!Number.isFinite(Date.parse(feed.generatedAt)) ||
!Number.isFinite(Date.parse(feed.expiresAt))) {
throw new Error("Promotions feed timestamps must be valid ISO dates");
}
if (Date.parse(feed.expiresAt) <= Date.parse(feed.generatedAt)) {
throw new Error("Promotions feed expiresAt must be after generatedAt");
}
for (const entry of feed.entries) {
if (entry.endsAt <= entry.startsAt) {
throw new Error(`Promotions feed entry "${entry.slug}" has an inverted window`);
}
}
return feed;
}
export function serializePromotionsFeed(feed) {
const parsed = parsePromotionsFeed(feed);
const entries = [...parsed.entries]
.sort((left, right) => left.slug.localeCompare(right.slug))
.map((entry) => ({
type: entry.type,
slug: entry.slug,
title: entry.title,
blurb: entry.blurb,
...(entry.sponsor === undefined ? {} : { sponsor: entry.sponsor }),
startsAt: entry.startsAt,
endsAt: entry.endsAt,
...(entry.provider === undefined ? {} : { provider: entry.provider }),
...(entry.authChoiceId === undefined ? {} : { authChoiceId: entry.authChoiceId }),
...(entry.pluginNames === undefined ? {} : { pluginNames: [...entry.pluginNames] }),
models: [...entry.models]
.sort((left, right) => left.modelRef.localeCompare(right.modelRef))
.map((model) => ({
modelRef: model.modelRef,
...(model.alias === undefined ? {} : { alias: model.alias }),
...(model.suggestedDefault === undefined
? {}
: { suggestedDefault: model.suggestedDefault }),
})),
...(entry.signupUrl === undefined ? {} : { signupUrl: entry.signupUrl }),
...(entry.docsUrl === undefined ? {} : { docsUrl: entry.docsUrl }),
...(entry.launchPageUrl === undefined ? {} : { launchPageUrl: entry.launchPageUrl }),
}));
return JSON.stringify({
schemaVersion: parsed.schemaVersion,
id: parsed.id,
generatedAt: parsed.generatedAt,
sequence: parsed.sequence,
expiresAt: parsed.expiresAt,
...(parsed.description === undefined ? {} : { description: parsed.description }),
entries,
});
}
//# sourceMappingURL=promotionsFeed.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"promotionsFeed.js","sourceRoot":"","sources":["../src/promotionsFeed.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,IAAI,EAAE,MAAM,SAAS,CAAC;AAE9C,MAAM,cAAc,GAAG,IAAI,CAAC,YAAY,CAAC,CAAC,MAAM,CAC9C,CAAC,KAAK,EAAE,GAAG,EAAE,EAAE,CAAC,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,QAAQ,KAAK,QAAQ,IAAI,GAAG,CAAC,MAAM,CAAC,cAAc,CAAC,CACnF,CAAC;AAEF,MAAM,CAAC,MAAM,yBAAyB,GAAG,IAAI,CAAC;IAC5C,GAAG,EAAE,QAAQ;IACb,QAAQ,EAAE,QAAQ;IAClB,QAAQ,EAAE,QAAQ;IAClB,mBAAmB,EAAE,SAAS;CAC/B,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,yBAAyB,GAAG,IAAI,CAAC;IAC5C,GAAG,EAAE,QAAQ;IACb,IAAI,EAAE,aAAa;IACnB,IAAI,EAAE,QAAQ;IACd,KAAK,EAAE,QAAQ;IACf,KAAK,EAAE,QAAQ;IACf,UAAU,EAAE,QAAQ;IACpB,QAAQ,EAAE,QAAQ;IAClB,MAAM,EAAE,QAAQ;IAChB,WAAW,EAAE,QAAQ;IACrB,eAAe,EAAE,QAAQ;IACzB,cAAc,EAAE,UAAU;IAC1B,MAAM,EAAE,yBAAyB,CAAC,KAAK,EAAE;IACzC,YAAY,EAAE,cAAc;IAC5B,UAAU,EAAE,cAAc;IAC1B,gBAAgB,EAAE,cAAc;CACjC,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,oBAAoB,GAAG,IAAI,CAAC;IACvC,GAAG,EAAE,QAAQ;IACb,aAAa,EAAE,QAAQ;IACvB,EAAE,EAAE,QAAQ;IACZ,WAAW,EAAE,QAAQ;IACrB,QAAQ,EAAE,QAAQ;IAClB,SAAS,EAAE,QAAQ;IACnB,cAAc,EAAE,QAAQ;IACxB,OAAO,EAAE,yBAAyB,CAAC,KAAK,EAAE;CAC3C,CAAC,CAAC;AAGH;;;;GAIG;AACH,MAAM,CAAC,MAAM,8BAA8B,GAAG,CAAC,CAAC;AAChD,MAAM,CAAC,MAAM,kBAAkB,GAAG,oBAAoB,CAAC;AACvD,MAAM,CAAC,MAAM,2BAA2B,GACtC,oEAAoE,CAAC;AAEvE,MAAM,UAAU,mBAAmB,CAAC,KAAc;IAChD,MAAM,IAAI,GAAG,oBAAoB,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAChD,IAAI,IAAI,CAAC,EAAE,KAAK,kBAAkB,EAAE,CAAC;QACnC,MAAM,IAAI,KAAK,CAAC,mCAAmC,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC;IAChE,CAAC;IACD,IAAI,IAAI,CAAC,aAAa,KAAK,8BAA8B,EAAE,CAAC;QAC1D,MAAM,IAAI,KAAK,CAAC,+CAA+C,IAAI,CAAC,aAAa,EAAE,CAAC,CAAC;IACvF,CAAC;IACD,IAAI,IAAI,CAAC,QAAQ,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC9D,MAAM,IAAI,KAAK,CAAC,yDAAyD,CAAC,CAAC;IAC7E,CAAC;IACD,IACE,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;QAC9C,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,EAC5C,CAAC;QACD,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAC;IACxE,CAAC;IACD,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC;QAC/D,MAAM,IAAI,KAAK,CAAC,qDAAqD,CAAC,CAAC;IACzE,CAAC;IACD,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;QACjC,IAAI,KAAK,CAAC,MAAM,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC;YACnC,MAAM,IAAI,KAAK,CAAC,0BAA0B,KAAK,CAAC,IAAI,0BAA0B,CAAC,CAAC;QAClF,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,uBAAuB,CAAC,IAAoB;IAC1D,MAAM,MAAM,GAAG,mBAAmB,CAAC,IAAI,CAAC,CAAC;IACzC,MAAM,OAAO,GAAG,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC;SAChC,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;SAC1D,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;QACf,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,GAAG,CAAC,KAAK,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,CAAC;QAClE,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,MAAM,EAAE,KAAK,CAAC,MAAM;QACpB,GAAG,CAAC,KAAK,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC;QACrE,GAAG,CAAC,KAAK,CAAC,YAAY,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,KAAK,CAAC,YAAY,EAAE,CAAC;QACjF,GAAG,CAAC,KAAK,CAAC,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,CAAC,GAAG,KAAK,CAAC,WAAW,CAAC,EAAE,CAAC;QACnF,MAAM,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,CAAC;aACtB,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;aAClE,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;YACf,QAAQ,EAAE,KAAK,CAAC,QAAQ;YACxB,GAAG,CAAC,KAAK,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,CAAC;YAC5D,GAAG,CAAC,KAAK,CAAC,gBAAgB,KAAK,SAAS;gBACtC,CAAC,CAAC,EAAE;gBACJ,CAAC,CAAC,EAAE,gBAAgB,EAAE,KAAK,CAAC,gBAAgB,EAAE,CAAC;SAClD,CAAC,CAAC;QACL,GAAG,CAAC,KAAK,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,EAAE,CAAC;QACxE,GAAG,CAAC,KAAK,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,CAAC;QAClE,GAAG,CAAC,KAAK,CAAC,aAAa,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,KAAK,CAAC,aAAa,EAAE,CAAC;KACrF,CAAC,CAAC,CAAC;IACN,OAAO,IAAI,CAAC,SAAS,CAAC;QACpB,aAAa,EAAE,MAAM,CAAC,aAAa;QACnC,EAAE,EAAE,MAAM,CAAC,EAAE;QACb,WAAW,EAAE,MAAM,CAAC,WAAW;QAC/B,QAAQ,EAAE,MAAM,CAAC,QAAQ;QACzB,SAAS,EAAE,MAAM,CAAC,SAAS;QAC3B,GAAG,CAAC,MAAM,CAAC,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,MAAM,CAAC,WAAW,EAAE,CAAC;QAChF,OAAO;KACR,CAAC,CAAC;AACL,CAAC"}
+2
View File
@@ -22,8 +22,10 @@ export declare const ApiRoutes: {
readonly packages: "/api/v1/packages";
readonly codePlugins: "/api/v1/code-plugins";
readonly bundlePlugins: "/api/v1/bundle-plugins";
readonly promotions: "/api/v1/promotions";
readonly catalogFeed: "/api/v1/feeds/plugins";
readonly catalogSkillsFeed: "/api/v1/feeds/skills";
readonly promotionsFeed: "/api/v1/feeds/promotions";
readonly stars: "/api/v1/stars";
readonly transfers: "/api/v1/transfers";
readonly publishers: "/api/v1/publishers";
+2
View File
@@ -22,8 +22,10 @@ export const ApiRoutes = {
packages: "/api/v1/packages",
codePlugins: "/api/v1/code-plugins",
bundlePlugins: "/api/v1/bundle-plugins",
promotions: "/api/v1/promotions",
catalogFeed: "/api/v1/feeds/plugins",
catalogSkillsFeed: "/api/v1/feeds/skills",
promotionsFeed: "/api/v1/feeds/promotions",
stars: "/api/v1/stars",
transfers: "/api/v1/transfers",
publishers: "/api/v1/publishers",
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,mBAAmB,EAAE,4BAA4B;IACjD,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,UAAU,EAAE,uBAAuB;IACnC,OAAO,EAAE,iBAAiB;IAC1B,aAAa,EAAE,wBAAwB;IACvC,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,WAAW,EAAE,uBAAuB;IACpC,iBAAiB,EAAE,sBAAsB;IACzC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,UAAU,EAAE,oBAAoB;IAChC,KAAK,EAAE,eAAe;IACtB,aAAa,EAAE,wBAAwB;IACvC,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"}
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,mBAAmB,EAAE,4BAA4B;IACjD,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,UAAU,EAAE,uBAAuB;IACnC,OAAO,EAAE,iBAAiB;IAC1B,aAAa,EAAE,wBAAwB;IACvC,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,UAAU,EAAE,oBAAoB;IAChC,WAAW,EAAE,uBAAuB;IACpC,iBAAiB,EAAE,sBAAsB;IACzC,cAAc,EAAE,0BAA0B;IAC1C,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,UAAU,EAAE,oBAAoB;IAChC,KAAK,EAAE,eAAe;IACtB,aAAa,EAAE,wBAAwB;IACvC,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"}
+1
View File
@@ -8,6 +8,7 @@ export * from "./openclawContract.js";
export * from "./openClawExtensionSlugs.js";
export * from "./packages.js";
export * from "./pluginCategories.js";
export * from "./promotionsFeed.js";
export { ApiRoutes, LegacyApiRoutes } from "./routes.js";
export * from "./schemas.js";
export * from "./textFiles.js";
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import {
PROMOTIONS_FEED_ID,
PROMOTIONS_FEED_SCHEMA_VERSION,
parsePromotionsFeed,
serializePromotionsFeed,
type PromotionsFeed,
} from "./promotionsFeed.js";
function makeFeed(overrides: Partial<PromotionsFeed> = {}): PromotionsFeed {
return {
schemaVersion: PROMOTIONS_FEED_SCHEMA_VERSION,
id: PROMOTIONS_FEED_ID,
generatedAt: "2026-07-01T00:00:00.000Z",
sequence: 1,
expiresAt: "2026-07-02T00:00:00.000Z",
entries: [
{
type: "promotion",
slug: "zeta-launch",
title: "Zeta launch",
blurb: "Free models from Zeta.",
startsAt: 100,
endsAt: 200,
models: [
{ modelRef: "example-provider/zeta/model-beta" },
{ modelRef: "example-provider/zeta/model-alpha", alias: "Alpha", suggestedDefault: true },
],
signupUrl: "https://signup.example.com",
},
{
type: "promotion",
slug: "alpha-launch",
title: "Alpha launch",
blurb: "Free models from Alpha.",
sponsor: "Alpha",
startsAt: 100,
endsAt: 300,
models: [{ modelRef: "example-provider/alpha/model-one" }],
},
],
...overrides,
};
}
describe("promotions feed schema", () => {
it("round-trips a valid feed", () => {
const parsed = parsePromotionsFeed(JSON.parse(serializePromotionsFeed(makeFeed())));
expect(parsed.id).toBe(PROMOTIONS_FEED_ID);
expect(parsed.entries).toHaveLength(2);
});
it("serializes deterministically: entries by slug, models by modelRef", () => {
const payload = JSON.parse(serializePromotionsFeed(makeFeed())) as PromotionsFeed;
expect(payload.entries.map((entry) => entry.slug)).toEqual(["alpha-launch", "zeta-launch"]);
expect(payload.entries[1]?.models.map((model) => model.modelRef)).toEqual([
"example-provider/zeta/model-alpha",
"example-provider/zeta/model-beta",
]);
});
it("rejects unsupported schema versions", () => {
expect(() => parsePromotionsFeed(makeFeed({ schemaVersion: 2 }))).toThrow(/schema version/);
});
it("rejects a payload for a different feed id", () => {
expect(() => parsePromotionsFeed(makeFeed({ id: "wrong-feed" }))).toThrow(/feed id/);
});
it("rejects inverted feed and entry windows", () => {
expect(() => parsePromotionsFeed(makeFeed({ expiresAt: "2026-06-30T00:00:00.000Z" }))).toThrow(
/expiresAt/,
);
const feed = makeFeed();
const entry = feed.entries[0];
if (!entry) throw new Error("fixture missing entry");
entry.endsAt = entry.startsAt;
expect(() => parsePromotionsFeed(feed)).toThrow(/inverted window/);
});
it("rejects unknown fields", () => {
const feed = makeFeed() as PromotionsFeed & { extra?: string };
feed.extra = "nope";
expect(() => parsePromotionsFeed(feed)).toThrow();
});
it.each(["signupUrl", "docsUrl", "launchPageUrl"] as const)(
"rejects non-HTTPS %s values",
(field) => {
const feed = makeFeed();
const entry = feed.entries[0];
if (!entry) throw new Error("fixture missing entry");
entry[field] = "http://insecure.example.com";
expect(() => parsePromotionsFeed(feed)).toThrow(/HTTPS URL/);
},
);
});
+121
View File
@@ -0,0 +1,121 @@
import { type inferred, type } from "arktype";
const HttpsUrlSchema = type("string.url").narrow(
(value, ctx) => new URL(value).protocol === "https:" || ctx.mustBe("an HTTPS URL"),
);
export const PromotionsFeedModelSchema = type({
"+": "reject",
modelRef: "string",
"alias?": "string",
"suggestedDefault?": "boolean",
});
export type PromotionsFeedModel = (typeof PromotionsFeedModelSchema)[inferred];
export const PromotionsFeedEntrySchema = type({
"+": "reject",
type: '"promotion"',
slug: "string",
title: "string",
blurb: "string",
"sponsor?": "string",
startsAt: "number",
endsAt: "number",
"provider?": "string",
"authChoiceId?": "string",
"pluginNames?": "string[]",
models: PromotionsFeedModelSchema.array(),
"signupUrl?": HttpsUrlSchema,
"docsUrl?": HttpsUrlSchema,
"launchPageUrl?": HttpsUrlSchema,
});
export type PromotionsFeedEntry = (typeof PromotionsFeedEntrySchema)[inferred];
export const PromotionsFeedSchema = type({
"+": "reject",
schemaVersion: "number",
id: "string",
generatedAt: "string",
sequence: "number",
expiresAt: "string",
"description?": "string",
entries: PromotionsFeedEntrySchema.array(),
});
export type PromotionsFeed = (typeof PromotionsFeedSchema)[inferred];
/**
* Cross-repo wire contract with the OpenClaw promotions consumer. Bump this
* only after matching OpenClaw parser/validation support has shipped,
* otherwise clients reject the hosted feed.
*/
export const PROMOTIONS_FEED_SCHEMA_VERSION = 1;
export const PROMOTIONS_FEED_ID = "clawhub-promotions";
export const PROMOTIONS_FEED_DESCRIPTION =
"Active promotional offers surfaced to OpenClaw clients at runtime.";
export function parsePromotionsFeed(value: unknown): PromotionsFeed {
const feed = PromotionsFeedSchema.assert(value);
if (feed.id !== PROMOTIONS_FEED_ID) {
throw new Error(`Unsupported promotions feed id: ${feed.id}`);
}
if (feed.schemaVersion !== PROMOTIONS_FEED_SCHEMA_VERSION) {
throw new Error(`Unsupported promotions feed schema version: ${feed.schemaVersion}`);
}
if (feed.sequence < 0 || !Number.isSafeInteger(feed.sequence)) {
throw new Error("Promotions feed sequence must be a non-negative integer");
}
if (
!Number.isFinite(Date.parse(feed.generatedAt)) ||
!Number.isFinite(Date.parse(feed.expiresAt))
) {
throw new Error("Promotions feed timestamps must be valid ISO dates");
}
if (Date.parse(feed.expiresAt) <= Date.parse(feed.generatedAt)) {
throw new Error("Promotions feed expiresAt must be after generatedAt");
}
for (const entry of feed.entries) {
if (entry.endsAt <= entry.startsAt) {
throw new Error(`Promotions feed entry "${entry.slug}" has an inverted window`);
}
}
return feed;
}
export function serializePromotionsFeed(feed: PromotionsFeed): string {
const parsed = parsePromotionsFeed(feed);
const entries = [...parsed.entries]
.sort((left, right) => left.slug.localeCompare(right.slug))
.map((entry) => ({
type: entry.type,
slug: entry.slug,
title: entry.title,
blurb: entry.blurb,
...(entry.sponsor === undefined ? {} : { sponsor: entry.sponsor }),
startsAt: entry.startsAt,
endsAt: entry.endsAt,
...(entry.provider === undefined ? {} : { provider: entry.provider }),
...(entry.authChoiceId === undefined ? {} : { authChoiceId: entry.authChoiceId }),
...(entry.pluginNames === undefined ? {} : { pluginNames: [...entry.pluginNames] }),
models: [...entry.models]
.sort((left, right) => left.modelRef.localeCompare(right.modelRef))
.map((model) => ({
modelRef: model.modelRef,
...(model.alias === undefined ? {} : { alias: model.alias }),
...(model.suggestedDefault === undefined
? {}
: { suggestedDefault: model.suggestedDefault }),
})),
...(entry.signupUrl === undefined ? {} : { signupUrl: entry.signupUrl }),
...(entry.docsUrl === undefined ? {} : { docsUrl: entry.docsUrl }),
...(entry.launchPageUrl === undefined ? {} : { launchPageUrl: entry.launchPageUrl }),
}));
return JSON.stringify({
schemaVersion: parsed.schemaVersion,
id: parsed.id,
generatedAt: parsed.generatedAt,
sequence: parsed.sequence,
expiresAt: parsed.expiresAt,
...(parsed.description === undefined ? {} : { description: parsed.description }),
entries,
});
}
+2
View File
@@ -23,8 +23,10 @@ export const ApiRoutes = {
packages: "/api/v1/packages",
codePlugins: "/api/v1/code-plugins",
bundlePlugins: "/api/v1/bundle-plugins",
promotions: "/api/v1/promotions",
catalogFeed: "/api/v1/feeds/plugins",
catalogSkillsFeed: "/api/v1/feeds/skills",
promotionsFeed: "/api/v1/feeds/promotions",
stars: "/api/v1/stars",
transfers: "/api/v1/transfers",
publishers: "/api/v1/publishers",
+7 -1
View File
@@ -174,7 +174,7 @@ The CLI can discover the API base from:
Keep production rewrites and discovery metadata aligned before release.
### Hosted catalog feed
### Hosted feeds
Refresh the OpenClaw hosted plugin and skill feeds after the production Convex
deployment has the catalog projections:
@@ -189,6 +189,12 @@ validators. The unversioned `/feeds/plugins` and `/feeds/skills` paths redirect
to their versioned routes. Attach `registry.openclaw.ai` to the same Vercel
project before configuring OpenClaw's default feed URLs.
Production backend deploys publish an initial promotions snapshot after Convex
deploys. Active promotion changes then refresh the stored snapshot immediately,
schedule refreshes at launch and expiry boundaries, and use a six-hour cron as
an expiry backstop. The feed is served through `/v1/feeds/promotions`, with
`/feeds/promotions` redirecting to the versioned route.
## 5) Post-deploy checks
Run the contract verifier and smoke tests against production after deploy:
+46 -11
View File
@@ -1,16 +1,17 @@
---
summary: "ClawHub publication contract for the OpenClaw hosted plugin catalog feed."
summary: "ClawHub publication contract for the OpenClaw hosted plugin, skill, and promotions feeds."
read_when:
- Publishing the OpenClaw hosted plugin catalog feed
- Publishing an OpenClaw hosted feed
- Changing feed entries, cache headers, or publication workflow
- Wiring registry.openclaw.ai to ClawHub
---
# Hosted Catalog Feed
# Hosted Feeds
ClawHub is the canonical producer for the initial OpenClaw plugin and skill
feeds. The feeds are projections of the existing public package, release, and
skill records; they are not second catalogs.
feeds and the runtime promotions feed. The feeds are projections of existing
public package, release, skill, and promotion records; they are not second
catalogs.
## Contract
@@ -65,6 +66,30 @@ Until the skills feed has pagination or sharding, it publishes at most 1000
eligible entries per snapshot so a large skills corpus does not block the plugin
feed publication path.
The promotions feed uses id `clawhub-promotions`, schema version `1`, and the
`/v1/feeds/promotions` route. Entries are declarative promotion records, not
commands or executable content. They may identify providers, auth choices,
plugins, models, and HTTPS signup/docs/launch URLs. Only promotions with
`status: "active"` whose launch window has started are published. The active
set is capped at 50 records by the promotions write path, which also bounds each
snapshot. Public slug lookups keep ended promotions readable only when they
actually crossed their launch boundary; promotions canceled before launch stay
private permanently, even if their scheduled window or other fields are edited.
Expired drafts cannot be activated, and unlaunched active promotions cannot be
rescheduled wholly into an expired window. Model references and aliases are
single-line fields so management form serialization remains lossless.
The write path also enforces the OpenClaw consumer's authoring grammars so a
promotion can never publish in a shape clients reject or silently degrade on:
model refs, provider, and auth choice id are restricted to shell-safe
identifier characters because the CLI echoes them into copy-paste commands
and refuses anything else; aliases must be typed identifiers (letters,
digits, `._:-`, no spaces) because the CLI skips aliases it cannot register;
plugin names use the package registry's canonical npm-safe grammar (scoped
`@scope/name` allowed); and when a provider is declared, every model ref must
start with `<provider>/`, matching the CLI's refusal to configure models
outside the promotion's declared provider.
## Publication
`convex/catalogFeed.ts` builds both feeds from indexed package/skill queries and
@@ -78,10 +103,20 @@ hours and can be run manually. It requires the existing `Production` environment
envelopes require a separate production key-management decision and must not be
advertised to OpenClaw clients until the signing key and trust root are deployed.
`convex/promotionsFeed.ts` builds the promotions snapshot from the bounded active
set and stores it in the same publication table. Production backend deploys
publish an initial snapshot before contract verification. Promotion updates and
status changes schedule an immediate refresh. Active promotions also schedule
refreshes at `startsAt` and at `endsAt + 1ms`: both window endpoints are
inclusive, so the expiry refresh must run after the final active millisecond. A
six-hour cron is the backstop for long-running or empty feeds, keeping every
snapshot inside its 24-hour `expiresAt` horizon.
## Edge delivery
The HTTP endpoints are `/api/v1/feeds/plugins` and `/api/v1/feeds/skills`. Each
returns its stored bytes unchanged and provides:
The HTTP endpoints are `/api/v1/feeds/plugins`, `/api/v1/feeds/skills`, and
`/api/v1/feeds/promotions`. Each returns its stored bytes unchanged and
provides:
- `ETag: "sha256:<payload hash>"`
- `Last-Modified`
@@ -89,9 +124,9 @@ returns its stored bytes unchanged and provides:
- `Surrogate-Control: max-age=300, stale-while-revalidate=86400`
- `304 Not Modified` for matching `If-None-Match` or `If-Modified-Since`
`vercel.json` exposes both `/v1/feeds/plugins` and `/v1/feeds/skills` as
edge-friendly rewrites to the Convex endpoints. The unversioned `/feeds/*`
paths permanently redirect to their versioned paths. The
`vercel.json` exposes `/v1/feeds/plugins`, `/v1/feeds/skills`, and
`/v1/feeds/promotions` as edge-friendly rewrites to the Convex endpoints. The
unversioned `/feeds/*` paths permanently redirect to their versioned paths. The
`registry.openclaw.ai` custom domain must point at the same Vercel project
before the public RFC URLs are enabled.
@@ -100,7 +135,7 @@ and install-candidate ordering. Additive fields may be introduced within a
major version; incompatible wire changes require a new versioned route and
schema version.
`/.well-known/openclaw-registry.json` advertises both versioned feeds.
`/.well-known/openclaw-registry.json` advertises the plugin and skill feeds.
`/.well-known/clawhub.json` remains the ClawHub API discovery document.
Do not make the feed request-time dynamic. Refresh the stored publication first,
+22
View File
@@ -6,6 +6,8 @@ describe("production deploy workflow", () => {
type WorkflowStep = {
name?: string;
env?: Record<string, string>;
if?: string;
run?: string;
};
type WorkflowJob = {
@@ -52,10 +54,30 @@ describe("production deploy workflow", () => {
"Stamp Convex build SHA",
"Stamp Convex deploy time",
"Deploy Convex",
"Publish promotions feed snapshot",
"Verify Convex contract",
]);
expect(authSecretSteps).toEqual(["Write authenticated storage state"]);
expect(tagJob?.permissions).toEqual({ contents: "write" });
expect(tagJob?.needs).toEqual(["validate-deploy-request", "deploy-production"]);
});
it("publishes the initial promotions snapshot after backend deploy", async () => {
const workflow = parseYaml(await readFile(".github/workflows/deploy.yml", "utf8")) as {
jobs?: Record<string, WorkflowJob>;
};
const steps = workflow.jobs?.["deploy-production"]?.steps ?? [];
const deployIndex = steps.findIndex((step) => step.name === "Deploy Convex");
const publishIndex = steps.findIndex(
(step) => step.name === "Publish promotions feed snapshot",
);
const verifyIndex = steps.findIndex((step) => step.name === "Verify Convex contract");
const publishStep = steps[publishIndex];
expect(deployIndex).toBeGreaterThanOrEqual(0);
expect(publishIndex).toBeGreaterThan(deployIndex);
expect(verifyIndex).toBeGreaterThan(publishIndex);
expect(publishStep?.if).toBe("needs.validate-deploy-request.outputs.deploy_backend == 'true'");
expect(publishStep?.run).toBe("bunx convex run promotionsFeed:publishInternal --prod");
});
});
+6
View File
@@ -289,6 +289,12 @@ describe("restored UI design contract", () => {
);
});
it("keeps promotion cards within narrow mobile viewports", () => {
expect(cssRule(styles(), ".home-v2-promotions-track")).toContain(
"grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr))",
);
});
it("keeps typeahead creator avatars round for users and square for orgs", () => {
const css = styles();
@@ -0,0 +1,92 @@
/* @vitest-environment jsdom */
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HomePromotionsSection } from "./HomePromotionsSection";
const { fetchMock, publicApiUrlMock } = vi.hoisted(() => ({
fetchMock: vi.fn(),
publicApiUrlMock: vi.fn(),
}));
vi.mock("../lib/publicApiUrl", () => ({
publicApiUrl: publicApiUrlMock,
}));
const promotion = {
slug: "example-models-launch",
title: "Free Example models",
blurb: "A limited-time free model offer from Example.",
status: "active",
active: true,
startsAt: 0,
endsAt: 0,
models: [{ modelRef: "example-provider/example/model-alpha" }],
};
async function flushPromises() {
await act(async () => {
await Promise.resolve();
});
}
function promotionsResponse(promotions: Array<typeof promotion>) {
return new Response(JSON.stringify({ promotions }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
describe("HomePromotionsSection", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(100_000);
fetchMock.mockReset();
publicApiUrlMock.mockReset();
publicApiUrlMock.mockReturnValue(new URL("https://clawhub.test/api/v1/promotions"));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("polls for promotions that become active while the page is open", async () => {
fetchMock
.mockResolvedValueOnce(promotionsResponse([]))
.mockResolvedValueOnce(
promotionsResponse([{ ...promotion, startsAt: 120_000, endsAt: 200_000 }]),
);
render(<HomePromotionsSection />);
await flushPromises();
expect(screen.queryByText(promotion.title)).toBeNull();
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://clawhub.test/api/v1/promotions");
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1]?.[0]).toBe("https://clawhub.test/api/v1/promotions");
expect(screen.getByText(promotion.title)).toBeTruthy();
});
it("removes a promotion at its expiry boundary and refreshes the query", async () => {
fetchMock
.mockResolvedValueOnce(promotionsResponse([{ ...promotion, endsAt: 100_500 }]))
.mockResolvedValueOnce(promotionsResponse([]));
render(<HomePromotionsSection />);
await flushPromises();
expect(screen.getByText(promotion.title)).toBeTruthy();
expect(screen.getByText("Ends today")).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(501);
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(screen.queryByText(promotion.title)).toBeNull();
});
});
+129
View File
@@ -0,0 +1,129 @@
import { ApiRoutes } from "clawhub-schema/routes";
import { ArrowRight, Gift } from "lucide-react";
import { useEffect, useState } from "react";
import { publicApiUrl } from "../lib/publicApiUrl";
type PublicPromotion = {
slug: string;
title: string;
blurb: string;
sponsor?: string;
endsAt: number;
signupUrl?: string;
docsUrl?: string;
launchPageUrl?: string;
};
const PROMOTIONS_POLL_INTERVAL_MS = 60_000;
function nextPromotionsRefreshDelay(promotions: PublicPromotion[], now: number) {
return promotions.reduce(
(delay, promotion) =>
promotion.endsAt >= now ? Math.min(delay, promotion.endsAt - now + 1) : delay,
PROMOTIONS_POLL_INTERVAL_MS,
);
}
function formatEndsAt(endsAt: number) {
const now = new Date();
const end = new Date(endsAt);
const today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
const endDay = Date.UTC(end.getFullYear(), end.getMonth(), end.getDate());
const days = Math.max(0, Math.round((endDay - today) / (24 * 60 * 60 * 1000)));
if (days === 0) return "Ends today";
if (days === 1) return "Ends tomorrow";
return `${days} days left`;
}
function promotionCtaUrl(promotion: PublicPromotion) {
return promotion.launchPageUrl ?? promotion.signupUrl ?? promotion.docsUrl ?? null;
}
function PromotionCard({ promotion }: { promotion: PublicPromotion }) {
const ctaUrl = promotionCtaUrl(promotion);
return (
<article className="home-v2-promotion-card">
<div className="home-v2-promotion-head">
<span className="home-v2-promotion-flag">
<Gift size={13} aria-hidden="true" />
{promotion.sponsor ? `${promotion.sponsor} promotion` : "Promotion"}
</span>
<span className="home-v2-promotion-ends">{formatEndsAt(promotion.endsAt)}</span>
</div>
<h3 className="home-v2-promotion-title">{promotion.title}</h3>
<p className="home-v2-promotion-blurb">{promotion.blurb}</p>
{/* No CLI claim snippet yet: the openclaw `promos claim` command ships
separately; advertise it here once that CLI flow exists. */}
{ctaUrl ? (
<div className="home-v2-promotion-foot">
<a
className="home-v2-promotion-link"
href={ctaUrl}
target="_blank"
rel="noopener noreferrer"
>
Learn more <ArrowRight size={13} aria-hidden="true" />
</a>
</div>
) : null}
</article>
);
}
export function HomePromotionsSection() {
const [promotions, setPromotions] = useState<PublicPromotion[]>([]);
useEffect(() => {
let cancelled = false;
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
function scheduleRefresh(active: PublicPromotion[]) {
if (refreshTimer) clearTimeout(refreshTimer);
const delay = nextPromotionsRefreshDelay(active, Date.now());
refreshTimer = setTimeout(() => {
if (cancelled) return;
setPromotions((current) => current.filter((promotion) => promotion.endsAt >= Date.now()));
void loadPromotions();
}, delay);
}
async function loadPromotions() {
try {
const response = await fetch(publicApiUrl(ApiRoutes.promotions).toString(), {
headers: { Accept: "application/json" },
});
if (!response.ok) throw new Error(`Promotions request failed: ${response.status}`);
const payload = (await response.json()) as { promotions?: PublicPromotion[] };
if (!Array.isArray(payload.promotions)) throw new Error("Invalid promotions response");
const active = payload.promotions;
if (cancelled) return;
setPromotions(active);
scheduleRefresh(active);
} catch {
// Promotions are decorative on the homepage; render nothing on failure.
if (!cancelled) scheduleRefresh([]);
}
}
void loadPromotions();
return () => {
cancelled = true;
if (refreshTimer) clearTimeout(refreshTimer);
};
}, []);
if (promotions.length === 0) return null;
return (
<section className="home-v2-promotions" aria-labelledby="home-promotions-title">
<h2 id="home-promotions-title" className="sr-only">
Active promotions
</h2>
<div className="home-v2-promotions-track">
{promotions.map((promotion) => (
<PromotionCard key={promotion.slug} promotion={promotion} />
))}
</div>
</section>
);
}
+2 -43
View File
@@ -7,7 +7,7 @@ import type {
} from "clawhub-schema";
import { ApiRoutes } from "clawhub-schema/routes";
import { hasOwnProperty } from "./hasOwnProperty";
import { getRequiredRuntimeEnv, getRuntimeEnv } from "./runtimeEnv";
import { publicApiUrl } from "./publicApiUrl";
export type PackageListItem = {
name: string;
@@ -211,49 +211,8 @@ function normalizeApiPath(path: string) {
return path.startsWith("/") ? path : `/${path}`;
}
function resolveAbsoluteBaseUrl(...candidates: Array<string | undefined>) {
for (const candidate of candidates) {
const value = candidate?.trim();
if (!value) continue;
try {
return new URL(value).toString();
} catch {
continue;
}
}
return null;
}
async function packageApiUrl(path: string) {
const normalizedPath = normalizeApiPath(path);
if (typeof window !== "undefined") {
// In production, Vercel rewrites /api/* to the Convex site, so relative
// paths work. In local dev, Nitro intercepts the request before Vite's
// proxy, so we must use the Convex site URL directly.
const convexClientBaseUrl = resolveAbsoluteBaseUrl(
getRuntimeEnv("VITE_CONVEX_SITE_URL"),
getRuntimeEnv("VITE_CONVEX_URL"),
);
if (
convexClientBaseUrl &&
(window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1" ||
window.location.hostname === "0.0.0.0")
) {
return new URL(normalizedPath, convexClientBaseUrl);
}
return new URL(normalizedPath, window.location.origin);
}
// On the server (SSR / loader), always use the Convex site URL directly.
// In production, Vercel rewrites /api/* but SSR loaders run server-side
// where the rewrite doesn't apply. Using getRequestUrl() would loop back
// into TanStack Start / Nitro, which rejects non-HTML requests.
const base =
resolveAbsoluteBaseUrl(
getRuntimeEnv("VITE_CONVEX_SITE_URL"),
getRuntimeEnv("VITE_CONVEX_URL"),
) ?? getRequiredRuntimeEnv("VITE_CONVEX_URL");
return new URL(normalizedPath, base);
return publicApiUrl(path);
}
export function getPackageDownloadPath(name: string, version?: string | null) {
+46
View File
@@ -0,0 +1,46 @@
import { getRequiredRuntimeEnv, getRuntimeEnv } from "./runtimeEnv";
function normalizeApiPath(path: string) {
return path.startsWith("/") ? path : `/${path}`;
}
function resolveAbsoluteBaseUrl(...candidates: Array<string | undefined>) {
for (const candidate of candidates) {
const value = candidate?.trim();
if (!value) continue;
try {
return new URL(value).toString();
} catch {
continue;
}
}
return null;
}
export function publicApiUrl(path: string) {
const normalizedPath = normalizeApiPath(path);
if (typeof window !== "undefined") {
// In production, Vercel rewrites /api/* to the Convex site. Local Nitro
// intercepts those paths, so local browsers use the Convex site directly.
const convexClientBaseUrl = resolveAbsoluteBaseUrl(
getRuntimeEnv("VITE_CONVEX_SITE_URL"),
getRuntimeEnv("VITE_CONVEX_URL"),
);
if (
convexClientBaseUrl &&
(window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1" ||
window.location.hostname === "0.0.0.0")
) {
return new URL(normalizedPath, convexClientBaseUrl);
}
return new URL(normalizedPath, window.location.origin);
}
const base =
resolveAbsoluteBaseUrl(
getRuntimeEnv("VITE_CONVEX_SITE_URL"),
getRuntimeEnv("VITE_CONVEX_URL"),
) ?? getRequiredRuntimeEnv("VITE_CONVEX_URL");
return new URL(normalizedPath, base);
}
@@ -0,0 +1,114 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { PromotionsPage } from "./PromotionsPage";
function makePromotion(overrides: Record<string, unknown> = {}) {
return {
_id: "promotions:1",
_creationTime: 1,
slug: "example-models-launch",
title: "Free Example models",
blurb: "A limited-time free model offer from Example.",
status: "draft",
startsAt: new Date(2026, 6, 4, 12, 34, 56, 789).getTime(),
endsAt: new Date(2026, 6, 5, 13, 45, 12, 345).getTime(),
models: [{ modelRef: "example-provider/example/model-alpha" }],
createdByUserId: "users:admin",
createdAt: 1,
updatedAt: 1,
...overrides,
} as never;
}
describe("PromotionsPage", () => {
it("preserves timestamp precision when editing a promotion", () => {
render(
<PromotionsPage
promotions={[makePromotion()]}
onCreate={vi.fn()}
onUpdate={vi.fn()}
onSetStatus={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
const startsInput = screen.getByLabelText("Starts *") as HTMLInputElement;
const endsInput = screen.getByLabelText("Ends *") as HTMLInputElement;
expect(startsInput.value).toBe("2026-07-04T12:34:56.789");
expect(endsInput.value).toBe("2026-07-05T13:45:12.345");
expect(startsInput.step).toBe("0.001");
expect(endsInput.step).toBe("0.001");
});
it("round-trips escaped model delimiters without corrupting aliases", async () => {
const onUpdate = vi.fn().mockResolvedValue(true);
render(
<PromotionsPage
promotions={[
makePromotion({
models: [
{
modelRef: String.raw`example-provider/example\model|alpha`,
alias: "Turbo | Pro",
},
],
}),
]}
onCreate={vi.fn()}
onUpdate={onUpdate}
onSetStatus={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
expect((screen.getByLabelText("Models *") as HTMLTextAreaElement).value).toBe(
String.raw`example-provider/example\\model\|alpha | Turbo \| Pro`,
);
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() =>
expect(onUpdate).toHaveBeenCalledWith(
"example-models-launch",
expect.objectContaining({
models: [
{
modelRef: String.raw`example-provider/example\model|alpha`,
alias: "Turbo | Pro",
},
],
}),
),
);
});
it("loads additional permanent history pages", () => {
const onLoadMore = vi.fn();
render(
<PromotionsPage
promotions={[makePromotion()]}
pageStatus="CanLoadMore"
onCreate={vi.fn()}
onLoadMore={onLoadMore}
onUpdate={vi.fn()}
onSetStatus={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
expect(onLoadMore).toHaveBeenCalledOnce();
});
it("does not offer to reactivate ended promotions", () => {
render(
<PromotionsPage
promotions={[makePromotion({ status: "ended" })]}
onCreate={vi.fn()}
onUpdate={vi.fn()}
onSetStatus={vi.fn()}
/>,
);
expect(screen.queryByRole("button", { name: "Activate" })).toBeNull();
});
});
+463
View File
@@ -0,0 +1,463 @@
import { useState, type ReactNode } from "react";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import { Textarea } from "../../components/ui/textarea";
import {
formatTimestamp,
type PromotionEntry,
type PromotionInput,
type PromotionStatus,
} from "./managementShared";
type PromotionFormState = {
slug: string;
title: string;
blurb: string;
sponsor: string;
provider: string;
authChoiceId: string;
startsAt: string;
endsAt: string;
pluginNames: string;
models: string;
signupUrl: string;
docsUrl: string;
launchPageUrl: string;
};
type PromotionPageStatus = "LoadingFirstPage" | "CanLoadMore" | "LoadingMore" | "Exhausted";
const EMPTY_FORM: PromotionFormState = {
slug: "",
title: "",
blurb: "",
sponsor: "",
provider: "",
authChoiceId: "",
startsAt: "",
endsAt: "",
pluginNames: "",
models: "",
signupUrl: "",
docsUrl: "",
launchPageUrl: "",
};
function toDatetimeLocal(value: number) {
const date = new Date(value);
const pad = (part: number) => String(part).padStart(2, "0");
const milliseconds = String(date.getMilliseconds()).padStart(3, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
date.getHours(),
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${milliseconds}`;
}
function escapeModelField(value: string) {
return value.replaceAll("\\", "\\\\").replaceAll("|", "\\|");
}
function parseModelLine(line: string) {
const fields = [""];
let escaped = false;
for (const character of line) {
const fieldIndex = fields.length - 1;
if (escaped) {
fields[fieldIndex] += character === "\\" || character === "|" ? character : `\\${character}`;
escaped = false;
} else if (character === "\\") {
escaped = true;
} else if (character === "|") {
fields.push("");
} else {
fields[fieldIndex] += character;
}
}
if (escaped) fields[fields.length - 1] += "\\";
return fields.map((field) => field.trim());
}
function promotionToForm(promotion: PromotionEntry): PromotionFormState {
return {
slug: promotion.slug,
title: promotion.title,
blurb: promotion.blurb,
sponsor: promotion.sponsor ?? "",
provider: promotion.provider ?? "",
authChoiceId: promotion.authChoiceId ?? "",
startsAt: toDatetimeLocal(promotion.startsAt),
endsAt: toDatetimeLocal(promotion.endsAt),
pluginNames: (promotion.pluginNames ?? []).join(", "),
models: promotion.models
.map((model) => {
const modelRef = escapeModelField(model.modelRef);
const alias = model.alias ? escapeModelField(model.alias) : "";
if (model.suggestedDefault) {
return `${modelRef} | ${alias} | default`;
}
return alias ? `${modelRef} | ${alias}` : modelRef;
})
.join("\n"),
signupUrl: promotion.signupUrl ?? "",
docsUrl: promotion.docsUrl ?? "",
launchPageUrl: promotion.launchPageUrl ?? "",
};
}
function parseForm(form: PromotionFormState): { input: PromotionInput } | { error: string } {
const startsAt = form.startsAt ? new Date(form.startsAt).getTime() : Number.NaN;
const endsAt = form.endsAt ? new Date(form.endsAt).getTime() : Number.NaN;
if (!Number.isFinite(startsAt) || !Number.isFinite(endsAt)) {
return { error: "Start and end times are required." };
}
const models: PromotionInput["models"] = [];
for (const line of form.models.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const fields = parseModelLine(trimmed);
if (fields.length > 3) {
return { error: "Model lines may contain at most three fields." };
}
const [modelRef = "", alias = "", flag = ""] = fields;
if (!modelRef) continue;
if (flag && flag.toLowerCase() !== "default") {
return { error: 'The third model field must be "default" when provided.' };
}
models.push({
modelRef,
...(alias ? { alias } : {}),
...(flag.toLowerCase() === "default" ? { suggestedDefault: true } : {}),
});
}
if (models.length === 0) {
return { error: "At least one model line is required (modelRef | alias | default)." };
}
const pluginNames = form.pluginNames
.split(",")
.map((name) => name.trim())
.filter(Boolean);
return {
input: {
slug: form.slug.trim().toLowerCase(),
title: form.title.trim(),
blurb: form.blurb.trim(),
...(form.sponsor.trim() ? { sponsor: form.sponsor.trim() } : {}),
startsAt,
endsAt,
...(form.provider.trim() ? { provider: form.provider.trim() } : {}),
...(form.authChoiceId.trim() ? { authChoiceId: form.authChoiceId.trim() } : {}),
...(pluginNames.length > 0 ? { pluginNames } : {}),
models,
...(form.signupUrl.trim() ? { signupUrl: form.signupUrl.trim() } : {}),
...(form.docsUrl.trim() ? { docsUrl: form.docsUrl.trim() } : {}),
...(form.launchPageUrl.trim() ? { launchPageUrl: form.launchPageUrl.trim() } : {}),
},
};
}
function statusBadgeLabel(promotion: PromotionEntry, now: number) {
if (promotion.status !== "active") return promotion.status;
if (now < promotion.startsAt) return "active (starts soon)";
if (now > promotion.endsAt) return "active (window over)";
return "active";
}
export function PromotionsPage({
promotions,
pageStatus = promotions === undefined ? "LoadingFirstPage" : "Exhausted",
onCreate,
onLoadMore,
onUpdate,
onSetStatus,
}: {
promotions: PromotionEntry[] | undefined;
pageStatus?: PromotionPageStatus;
onCreate: (input: PromotionInput) => Promise<boolean>;
onLoadMore?: () => void;
onUpdate: (targetSlug: string, input: PromotionInput) => Promise<boolean>;
onSetStatus: (slug: string, status: PromotionStatus) => void;
}) {
const [form, setForm] = useState<PromotionFormState>(EMPTY_FORM);
const [editingSlug, setEditingSlug] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const now = Date.now();
const setField = (field: keyof PromotionFormState) => (value: string) => {
setForm((previous) => ({ ...previous, [field]: value }));
};
const resetForm = () => {
setForm(EMPTY_FORM);
setEditingSlug(null);
setFormError(null);
};
const submit = () => {
const parsed = parseForm(form);
if ("error" in parsed) {
setFormError(parsed.error);
return;
}
setFormError(null);
setSubmitting(true);
const request = editingSlug ? onUpdate(editingSlug, parsed.input) : onCreate(parsed.input);
void request
.then((ok) => {
if (ok) resetForm();
})
.finally(() => setSubmitting(false));
};
return (
<div className="management-view">
<h2 className="section-title text-[1.2rem] m-0">Promotions</h2>
<p className="section-subtitle m-0 mt-1">
Promotional offers surfaced to the OpenClaw CLI at runtime. New promotions start as drafts;
only active promotions inside their window are visible publicly.
</p>
<div className="management-list">
{promotions === undefined ? (
<div className="management-empty">Loading promotions</div>
) : promotions.length === 0 ? (
<div className="management-empty">No promotions yet. Create the first one below.</div>
) : (
promotions.map((promotion) => (
<div key={promotion._id} className="management-item">
<div className="management-item-main">
<strong>{promotion.title}</strong>
<div className="section-subtitle m-0">
{promotion.slug}
{promotion.sponsor ? ` · ${promotion.sponsor}` : ""}
{promotion.provider ? ` · via ${promotion.provider}` : ""} ·{" "}
{formatTimestamp(promotion.startsAt)} {formatTimestamp(promotion.endsAt)}
</div>
<div className="management-tags">
<Badge>{statusBadgeLabel(promotion, now)}</Badge>
{promotion.models.map((model) => (
<Badge key={model.modelRef}>
{model.modelRef}
{model.suggestedDefault ? " ★" : ""}
</Badge>
))}
</div>
</div>
<div className="management-actions management-action-grid">
<Button
className="management-action-btn"
type="button"
onClick={() => {
setForm(promotionToForm(promotion));
setEditingSlug(promotion.slug);
setFormError(null);
}}
>
Edit
</Button>
{promotion.status === "draft" ? (
<Button
className="management-action-btn"
type="button"
onClick={() => onSetStatus(promotion.slug, "active")}
>
Activate
</Button>
) : null}
{promotion.status === "active" ? (
<Button
className="management-action-btn"
type="button"
onClick={() => onSetStatus(promotion.slug, "ended")}
>
End
</Button>
) : null}
</div>
</div>
))
)}
</div>
{onLoadMore && (pageStatus === "CanLoadMore" || pageStatus === "LoadingMore") ? (
<div className="mt-3 flex justify-end">
<Button
type="button"
variant="outline"
disabled={pageStatus === "LoadingMore"}
onClick={onLoadMore}
>
{pageStatus === "LoadingMore" ? "Loading..." : "Load more"}
</Button>
</div>
) : null}
<h3 className="section-title text-[1.05rem] m-0 mt-6">
{editingSlug ? `Edit "${editingSlug}"` : "Create promotion"}
</h3>
<div className="promotion-form-grid">
<PromotionField id="promotion-slug" label="Slug *">
<Input
id="promotion-slug"
value={form.slug}
placeholder="spring-models-launch"
onChange={(event) => setField("slug")(event.target.value)}
/>
</PromotionField>
<PromotionField id="promotion-title" label="Title *">
<Input
id="promotion-title"
value={form.title}
placeholder="Free models from Acme"
onChange={(event) => setField("title")(event.target.value)}
/>
</PromotionField>
<PromotionField id="promotion-sponsor" label="Sponsor">
<Input
id="promotion-sponsor"
value={form.sponsor}
placeholder="Acme"
onChange={(event) => setField("sponsor")(event.target.value)}
/>
</PromotionField>
<PromotionField id="promotion-blurb" label="Blurb *" className="promotion-form-field-wide">
<Textarea
id="promotion-blurb"
rows={2}
className="min-h-[64px]"
value={form.blurb}
placeholder="A limited-time free model offer."
onChange={(event) => setField("blurb")(event.target.value)}
/>
</PromotionField>
<PromotionField id="promotion-starts" label="Starts *">
<Input
id="promotion-starts"
type="datetime-local"
step="0.001"
value={form.startsAt}
onChange={(event) => setField("startsAt")(event.target.value)}
/>
</PromotionField>
<PromotionField id="promotion-ends" label="Ends *">
<Input
id="promotion-ends"
type="datetime-local"
step="0.001"
value={form.endsAt}
onChange={(event) => setField("endsAt")(event.target.value)}
/>
</PromotionField>
<PromotionField
id="promotion-models"
label="Models *"
hint="One per line: modelRef | alias | default — alias is a typed identifier (letters, digits, . _ : - only, no spaces)"
className="promotion-form-field-wide"
>
<Textarea
id="promotion-models"
rows={3}
className="mono min-h-[84px]"
value={form.models}
placeholder="provider/org/model-name | model-alias | default"
onChange={(event) => setField("models")(event.target.value)}
/>
</PromotionField>
<PromotionField
id="promotion-provider"
label="Provider"
hint="Provider id the CLI resolves locally"
>
<Input
id="promotion-provider"
value={form.provider}
placeholder="provider-id"
onChange={(event) => setField("provider")(event.target.value)}
/>
</PromotionField>
<PromotionField
id="promotion-auth-choice"
label="Auth choice"
hint="Onboarding auth choice id"
>
<Input
id="promotion-auth-choice"
value={form.authChoiceId}
placeholder="provider-api-key"
onChange={(event) => setField("authChoiceId")(event.target.value)}
/>
</PromotionField>
<PromotionField id="promotion-plugins" label="Plugins" hint="Comma-separated package names">
<Input
id="promotion-plugins"
value={form.pluginNames}
placeholder="plugin-name, another-plugin"
onChange={(event) => setField("pluginNames")(event.target.value)}
/>
</PromotionField>
<PromotionField id="promotion-signup-url" label="Signup URL">
<Input
id="promotion-signup-url"
value={form.signupUrl}
placeholder="https://…"
onChange={(event) => setField("signupUrl")(event.target.value)}
/>
</PromotionField>
<PromotionField id="promotion-docs-url" label="Docs URL">
<Input
id="promotion-docs-url"
value={form.docsUrl}
placeholder="https://…"
onChange={(event) => setField("docsUrl")(event.target.value)}
/>
</PromotionField>
<PromotionField id="promotion-launch-url" label="Launch page URL">
<Input
id="promotion-launch-url"
value={form.launchPageUrl}
placeholder="https://…"
onChange={(event) => setField("launchPageUrl")(event.target.value)}
/>
</PromotionField>
</div>
{formError ? <p className="section-subtitle m-0 mt-3 text-red-500">{formError}</p> : null}
<div className="mt-4 flex items-center gap-2">
<Button type="button" onClick={submit} disabled={submitting}>
{editingSlug ? "Save changes" : "Create draft"}
</Button>
{editingSlug ? (
<Button type="button" variant="outline" onClick={resetForm} disabled={submitting}>
Cancel edit
</Button>
) : null}
</div>
</div>
);
}
function PromotionField({
id,
label,
hint,
className,
children,
}: {
id: string;
label: string;
hint?: string;
className?: string;
children: ReactNode;
}) {
return (
<div className={className ? `promotion-form-field ${className}` : "promotion-form-field"}>
<Label htmlFor={id}>{label}</Label>
{children}
{hint ? <p className="promotion-form-hint">{hint}</p> : null}
</div>
);
}
@@ -43,12 +43,26 @@ export type ManagementView =
| "publishers"
| "skills"
| "plugins"
| "promotions"
| "duplicates"
| "recent"
| "audit"
| "system"
| "settings";
export type PromotionEntry = FunctionReturnType<typeof api.promotions.listForStaff>["page"][number];
export type PromotionStatus = PromotionEntry["status"];
export type PromotionInput = Omit<
PromotionEntry,
| "_id"
| "_creationTime"
| "status"
| "createdByUserId"
| "updatedByUserId"
| "createdAt"
| "updatedAt"
>;
export type ManagementOwnerOption = {
userId: Id<"users">;
label: string;
+2
View File
@@ -4,6 +4,7 @@ import { HomeAppsSection } from "../components/HomeAppsSection";
import { HomeBringSkillsSection } from "../components/HomeBringSkillsSection";
import { HomeListingSection } from "../components/HomeListingSection";
import { HomePopularPublishersSection } from "../components/HomePopularPublishersSection";
import { HomePromotionsSection } from "../components/HomePromotionsSection";
import { HomeV2FoldBottomFade } from "../components/HomeV2FoldBottomFade";
import { HOME_APP_ICON_PRELOAD_HREFS } from "../lib/homeApps";
import { fetchInitialHomeListing, type HomeListingInitialData } from "../lib/homeListingData";
@@ -413,6 +414,7 @@ function SkillsHome() {
<p className="home-v2-sub">Discover skills and plugins from top creators</p>
</section>
<HomePromotionsSection />
<HomeListingSection initialListing={initialListing} />
<HomePopularPublishersSection />
<HomeAppsSection />
+85
View File
@@ -5,6 +5,7 @@ import {
ChevronRight,
ClipboardList,
GitBranch,
Megaphone,
PackageSearch,
Plug,
UserRound,
@@ -52,12 +53,16 @@ import {
type PublisherAbuseReviewItem,
type PublisherAbuseSignalStatus,
type PublisherAbuseTab,
type PromotionEntry,
type PromotionInput,
type PromotionStatus,
type RecentVersionEntry,
type ReportedSkillEntry,
type SkillBySlugResult,
USER_BAN_REASON_MAX_LENGTH,
} from "./-management/managementShared";
import { PluginsPage } from "./-management/PluginsPage";
import { PromotionsPage } from "./-management/PromotionsPage";
import { RecentPushesPage } from "./-management/RecentPushesPage";
import { ReportsPage } from "./-management/ReportsPage";
import { SkillsPage } from "./-management/SkillsPage";
@@ -71,6 +76,7 @@ const MANAGEMENT_VIEWS = new Set<string>([
"publishers",
"skills",
"plugins",
"promotions",
"duplicates",
"recent",
"audit",
@@ -243,6 +249,20 @@ export function Management() {
staff && abuseViewActive ? {} : "skip",
);
const {
results: promotionResults,
status: promotionPageStatus,
loadMore: loadMorePromotions,
} = usePaginatedQuery(
api.promotions.listForStaff,
admin && activeView === "promotions" ? {} : "skip",
{ initialNumItems: 25 },
);
const promotions =
promotionPageStatus === "LoadingFirstPage" ? undefined : (promotionResults as PromotionEntry[]);
const createPromotion = useMutation(api.promotions.create);
const updatePromotion = useMutation(api.promotions.update);
const setPromotionStatus = useMutation(api.promotions.setStatus);
const setRole = useMutation(api.users.setRole);
const banUser = useMutation(api.users.banUser);
const unbanUser = useMutation(api.users.unbanUser);
@@ -718,6 +738,46 @@ export function Management() {
});
};
const handleCreatePromotion = (input: PromotionInput) =>
createPromotion(input)
.then(() => {
toast.success("Promotion created as draft.");
return true;
})
.catch((error) => {
toast.error(formatMutationError(error));
return false;
});
const handleUpdatePromotion = (targetSlug: string, input: PromotionInput) =>
updatePromotion({ targetSlug, ...input })
.then(() => {
toast.success("Promotion updated.");
return true;
})
.catch((error) => {
toast.error(formatMutationError(error));
return false;
});
const handleSetPromotionStatus = (slug: string, status: PromotionStatus) => {
const apply = () => {
void setPromotionStatus({ slug, status })
.then(() => toast.success(`Promotion "${slug}" is now ${status}.`))
.catch((error) => toast.error(formatMutationError(error)));
};
if (status === "active") {
setConfirmRequest({
title: `Activate "${slug}"?`,
body: "Active promotions inside their window are served publicly to every OpenClaw CLI.",
confirmLabel: "Activate promotion",
onConfirm: apply,
});
return;
}
apply();
};
const requestTogglePublisherAbuseAutoban = () => {
if (!publisherAbuseAutobanSetting) return;
const nextEnabled = !publisherAbuseAutobanSetting.enabled;
@@ -937,6 +997,22 @@ export function Management() {
description="User administration is available to admins."
/>
) : null}
{admin && activeView === "promotions" ? (
<PromotionsPage
promotions={promotions}
pageStatus={promotionPageStatus}
onCreate={handleCreatePromotion}
onLoadMore={() => loadMorePromotions(25)}
onUpdate={handleUpdatePromotion}
onSetStatus={handleSetPromotionStatus}
/>
) : null}
{!admin && activeView === "promotions" ? (
<ManagementPlaceholder
title="Promotions"
description="Promotion administration is available to admins."
/>
) : null}
{activeView === "overview" ? (
<ManagementPlaceholder
title="Overview"
@@ -1062,6 +1138,14 @@ function ManagementSidebar({
label="Plugins"
view="plugins"
/>
{admin ? (
<ManagementSidebarLink
active={activeView === "promotions"}
icon={<Megaphone size={15} />}
label="Promotions"
view="promotions"
/>
) : null}
</div>
</nav>
</aside>
@@ -1112,6 +1196,7 @@ const MANAGEMENT_VIEW_LABELS: Record<ManagementView, string> = {
publishers: "Publishers",
skills: "Skills",
plugins: "Plugins",
promotions: "Promotions",
duplicates: "Duplicate candidates",
recent: "Recent pushes",
audit: "Audit log",
+123
View File
@@ -15023,6 +15023,38 @@ code {
margin-top: 12px;
}
/* Management promotions form */
.promotion-form-grid {
display: grid;
gap: 16px;
margin-top: 12px;
grid-template-columns: 1fr;
}
@media (min-width: 900px) {
.promotion-form-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.promotion-form-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
align-self: start;
}
.promotion-form-field-wide {
grid-column: 1 / -1;
}
.promotion-form-hint {
margin: 0;
font-size: 12px;
color: var(--ink-soft);
}
.management-control {
display: flex;
flex-wrap: wrap;
@@ -25470,6 +25502,97 @@ a.search-empty-action {
0 1px 2px rgba(15, 23, 42, 0.12);
}
/* ═══ HOME PROMOTIONS ═══ */
.home-v2-promotions {
width: 100%;
max-width: 1296px;
margin: 0 auto;
padding: 0 48px 8px;
}
.home-v2-promotions-track {
display: grid;
gap: 14px;
grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
}
.home-v2-promotion-card {
display: grid;
gap: 8px;
padding: 18px 20px;
border: 1px solid var(--hv2-accent-border);
border-radius: var(--hv2-radius-lg);
background: linear-gradient(135deg, var(--hv2-accent-fill), var(--hv2-surface));
}
.home-v2-promotion-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.home-v2-promotion-flag {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--hv2-accent);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.home-v2-promotion-ends {
color: var(--hv2-text-tertiary);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.home-v2-promotion-title {
margin: 0;
color: var(--hv2-text);
font-size: 16px;
font-weight: 600;
letter-spacing: -0.01em;
}
.home-v2-promotion-blurb {
margin: 0;
color: var(--hv2-text-secondary);
font-size: 13px;
line-height: 1.5;
}
.home-v2-promotion-foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 4px;
}
.home-v2-promotion-link {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--hv2-accent);
font-size: 13px;
font-weight: 500;
text-decoration: none;
}
.home-v2-promotion-link:hover {
text-decoration: underline;
}
@media (max-width: 720px) {
.home-v2-promotions {
padding: 0 20px 8px;
}
}
/* ═══ HOME POPULAR PUBLISHERS ═══ */
.home-v2-popular-publishers {
display: grid;
+9
View File
@@ -27,6 +27,11 @@
"destination": "/v1/feeds/skills",
"statusCode": 308
},
{
"source": "/feeds/promotions",
"destination": "/v1/feeds/promotions",
"statusCode": 308
},
{
"source": "/",
"has": [
@@ -64,6 +69,10 @@
{
"source": "/v1/feeds/skills",
"destination": "https://wry-manatee-359.convex.site/api/v1/feeds/skills"
},
{
"source": "/v1/feeds/promotions",
"destination": "https://wry-manatee-359.convex.site/api/v1/feeds/promotions"
}
],
"images": {