* 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>
Remove the static Vercel CSP in favor of per-request nonce-based CSP headers from the TanStack Start server entry. Preserve theme SSR without an inline bootstrap script and keep local development CSP allowances scoped to localhost.
* fix: couple docs auth localhost returns to a local app origin
The /auth/docs broker POSTs the signed-in user's auth token to the
return_to origin, but the allowlist trusted http://localhost:4173 /
127.0.0.1:4173 unconditionally, so production could hand the token to a
local listener.
Allow loopback return origins only when the app itself is served from a
loopback origin, so a public deployment (incl. staging/preview) can never
post the token to localhost regardless of runtime env. Keep the fixed
production docs origins (clawhub.ai, documentation.openclaw.ai,
docs.openclaw.ai), drop loopback from the production CSP form-action, and
record the token-destination invariant in specs/auth-identity.md.
* fix: align docs auth form destinations
* fix: retry public GitHub package fetches
* Revert "fix: retry public GitHub package fetches"
This reverts commit 1529aa34f3.
vercel.json currently allow-lists SVG-only hosts (img.shields.io,
shields.io, badgen.net, flat.badgen.net) while dangerouslyAllowSVG:
false rejects every SVG source. Those two settings are incompatible,
and every badge in every README on production is returning 400
INVALID_IMAGE_OPTIMIZE_REQUEST (e.g. the license badge on
/plugins/@opik/opik-openclaw).
Switch to the pattern Vercel documents for safely serving SVGs in
their NEXTJS_SAFE_SVG_IMAGES conformance rule:
- dangerouslyAllowSVG: true — lets the optimizer accept SVG inputs
- contentDispositionType: attachment — forces download instead of
inline document rendering if someone navigates directly to the
/_vercel/image URL (the only context where SVG scripts would run)
- contentSecurityPolicy: script-src 'none'; sandbox; — blocks script
execution in the response
Defense in depth: browsers already sandbox SVGs loaded through <img>
so scripts don't run there anyway; the CSP + attachment header cover
the edge case of someone opening the optimizer URL directly. Net
security is equivalent to rejecting SVGs, but badges actually render.
Docs: https://vercel.com/docs/conformance/rules/NEXTJS_SAFE_SVG_IMAGES
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the XSS / IP-leak surface from rendering third-party README
images directly on clawhub.ai. Routes external http(s) <img> sources
through Vercel's /_vercel/image endpoint, which enforces a host
allow-list, rejects SVG by default, and re-encodes rasters to webp.
Docs: https://vercel.com/docs/image-optimization
- vercel.json: add `images` config — host allow-list (raw.githubusercontent,
shields.io, etc., based on NuGet's published README allow-list),
dangerouslyAllowSVG=false, formats=[webp], 1d minimum cache TTL.
- src/components/ProxiedImg.tsx: small wrapper that rewrites external
http(s) src URLs to /_vercel/image?url=...&w=...&q=75. Local paths,
relative paths, and data: URIs pass through unchanged.
- MarkdownPreview + SkillDetailTabs: pass ProxiedImg as the `img`
component override to react-markdown — covers both raw HTML <img>
and markdown  syntax.
- package.json: drop unused `next` dep (vestigial from staging merge,
zero imports anywhere; doesn't affect next-themes).
Tests: 1028/1028 (was 1017, added 11 — ProxiedImg unit tests +
markdown integration tests covering proxied vs passthrough paths).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>