diff --git a/src/__tests__/feature-signal.test.ts b/src/__tests__/feature-signal.test.ts new file mode 100644 index 00000000..3cddde1f --- /dev/null +++ b/src/__tests__/feature-signal.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from "vitest"; +import { SELF } from "cloudflare:test"; + +/** + * Tests for PATCH /api/signals/:id/feature — publisher homepage curation. + * Exercises Worker-level validation (happens before BIP-322 auth check). + * + * Tests that require a valid BIP-322 signature + approved signal state are + * covered indirectly through the broader signal integration tests. + */ +describe("PATCH /api/signals/:id/feature — validation", () => { + it("returns 400 when body is not valid JSON", async () => { + const res = await SELF.fetch( + "http://example.com/api/signals/test-id/feature", + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: "not-json", + } + ); + expect(res.status).toBe(400); + }); + + it("returns 400 when btc_address is missing", async () => { + const res = await SELF.fetch( + "http://example.com/api/signals/test-id/feature", + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ featured: true }), + } + ); + expect(res.status).toBe(400); + const body = await res.json<{ error: string }>(); + expect(body.error).toContain("btc_address"); + }); + + it("returns 400 when featured is missing", async () => { + const res = await SELF.fetch( + "http://example.com/api/signals/test-id/feature", + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + btc_address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + }), + } + ); + expect(res.status).toBe(400); + const body = await res.json<{ error: string }>(); + expect(body.error).toContain("boolean"); + }); + + it("returns 400 when featured is an integer (not boolean)", async () => { + const res = await SELF.fetch( + "http://example.com/api/signals/test-id/feature", + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + btc_address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + featured: 1, + }), + } + ); + expect(res.status).toBe(400); + const body = await res.json<{ error: string }>(); + expect(body.error).toContain("boolean"); + }); + + it("returns 400 when featured is a string (not boolean)", async () => { + const res = await SELF.fetch( + "http://example.com/api/signals/test-id/feature", + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + btc_address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + featured: "true", + }), + } + ); + expect(res.status).toBe(400); + const body = await res.json<{ error: string }>(); + expect(body.error).toContain("boolean"); + }); + + it("returns 400 when btc_address format is invalid", async () => { + const res = await SELF.fetch( + "http://example.com/api/signals/test-id/feature", + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + btc_address: "not-valid-btc", + featured: true, + }), + } + ); + expect(res.status).toBe(400); + const body = await res.json<{ error: string }>(); + expect(body.error).toContain("BTC address"); + }); + + it("returns 401 when auth headers are missing (valid body format)", async () => { + // Once validation passes, missing auth headers return 401 + const res = await SELF.fetch( + "http://example.com/api/signals/test-id/feature", + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + btc_address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + featured: true, + }), + } + ); + expect(res.status).toBe(401); + const body = await res.json<{ error: string }>(); + expect(body.error).toBeTruthy(); + }); +}); + +describe("GET /api/front-page — featured ordering", () => { + it("returns a curated signals response with signals array and curated flag", async () => { + const res = await SELF.fetch("http://example.com/api/front-page"); + expect(res.status).toBe(200); + const body = await res.json<{ signals: unknown[]; total: number; curated: boolean }>(); + expect(body.curated).toBe(true); + expect(Array.isArray(body.signals)).toBe(true); + }); +}); diff --git a/src/lib/do-client.ts b/src/lib/do-client.ts index 03589855..4df5297e 100644 --- a/src/lib/do-client.ts +++ b/src/lib/do-client.ts @@ -803,6 +803,24 @@ export async function reviewSignal( }); } +export interface FeatureSignalInput { + btc_address: string; + featured: boolean; +} + +export async function featureSignal( + env: Env, + signalId: string, + input: FeatureSignalInput +): Promise> { + const stub = getStub(env); + return doFetch(stub, `/signals/${encodeURIComponent(signalId)}/feature`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); +} + // --------------------------------------------------------------------------- // Brief Signals (inclusion tracking) // --------------------------------------------------------------------------- diff --git a/src/lib/types.ts b/src/lib/types.ts index c2b314b3..d1f2d14b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -199,6 +199,8 @@ export interface Signal { readonly reviewed_at: string | null; /** Models, tools, and skills used to produce this signal */ readonly disclosure: string; + /** Publisher-curated homepage placement flag */ + readonly featured: boolean; } /** diff --git a/src/objects/news-do.ts b/src/objects/news-do.ts index 7568fff9..21113e0f 100644 --- a/src/objects/news-do.ts +++ b/src/objects/news-do.ts @@ -5,7 +5,7 @@ import type { Env, Beat, Signal, SignalStatus, Streak, Brief, Classified, Classi import { validateSlug, validateHexColor, sanitizeString, validateDateFormat } from "../lib/validators"; import { generateId, getPacificDate, getPacificYesterday, getPacificDayStartUTC, getPacificDayEndUTC, getNextDate } from "../lib/helpers"; import { CLASSIFIED_DURATION_DAYS, CLASSIFIED_BRIEF_SLOTS, CLASSIFIED_BRIEF_MAX_CHARS, CLASSIFIED_STATUSES, SIGNAL_COOLDOWN_HOURS, BEAT_EXPIRY_DAYS, MAX_SIGNALS_PER_DAY, MAX_INCLUDED_SIGNALS_PER_BRIEF, MAX_APPROVED_SIGNALS_PER_DAY, SIGNAL_STATUSES, REVIEWABLE_SIGNAL_STATUSES, CONFIG_PUBLISHER_ADDRESS, BRIEF_INCLUSION_PAYOUT_SATS, WEEKLY_PRIZE_1ST_SATS, WEEKLY_PRIZE_2ND_SATS, WEEKLY_PRIZE_3RD_SATS, SCORING_WEIGHTS, PAYMENT_STAGE_TTL_MS } from "../lib/constants"; -import { SCHEMA_SQL, MIGRATION_PHASE0_SQL, MIGRATION_PAYMENTS_SQL, MIGRATION_BEAT_RESTRUCTURE_SQL, MIGRATION_SBTC_TRACKING_SQL, MIGRATION_CLASSIFIEDS_CLEANUP_SQL, MIGRATION_CLASSIFIEDS_REVIEW_SQL, MIGRATION_SNAPSHOTS_SQL, MIGRATION_BEAT_CLAIMS_SQL, MIGRATION_RETRACTION_SQL, MIGRATION_BEAT_NETWORK_FOCUS_SQL, MIGRATION_BITCOIN_MACRO_SQL, MIGRATION_QUANTUM_BEAT_SQL, MIGRATION_PAYMENT_STAGING_SQL, MIGRATION_APPROVAL_CAP_INDEX_SQL } from "./schema"; +import { SCHEMA_SQL, MIGRATION_PHASE0_SQL, MIGRATION_PAYMENTS_SQL, MIGRATION_BEAT_RESTRUCTURE_SQL, MIGRATION_SBTC_TRACKING_SQL, MIGRATION_CLASSIFIEDS_CLEANUP_SQL, MIGRATION_CLASSIFIEDS_REVIEW_SQL, MIGRATION_SNAPSHOTS_SQL, MIGRATION_BEAT_CLAIMS_SQL, MIGRATION_RETRACTION_SQL, MIGRATION_BEAT_NETWORK_FOCUS_SQL, MIGRATION_BITCOIN_MACRO_SQL, MIGRATION_QUANTUM_BEAT_SQL, MIGRATION_PAYMENT_STAGING_SQL, MIGRATION_APPROVAL_CAP_INDEX_SQL, MIGRATION_FEATURED_SIGNAL_SQL } from "./schema"; // ── State machine transition maps ── // Hoisted to module level so they are created once and are testable. @@ -48,6 +48,7 @@ interface RawSignalRow { publisher_feedback: string | null; reviewed_at: string | null; disclosure: string; + featured: number; } interface RawCompiledSignalRow extends CompiledSignalRow { @@ -78,6 +79,7 @@ function rowToSignal(row: Record): Signal { publisher_feedback: raw.publisher_feedback ?? null, reviewed_at: raw.reviewed_at ?? null, disclosure: raw.disclosure ?? "", + featured: (raw.featured ?? 0) === 1, }; } @@ -230,7 +232,10 @@ export class NewsDO extends DurableObject { // 14 = Re-run beat inserts (idempotent fix — v12/v13 may have failed silently on staging) // 15 = Payment staging (confirmed-only x402 finalization keyed by paymentId) // 16 = Approval cap index — compound index on (status, reviewed_at) for daily count queries (#362) - const CURRENT_MIGRATION_VERSION = 16; + // 17 = (reserved — see PR #343) + // 18 = (reserved — see PR #333) + // 19 = Featured signal column (publisher homepage curation — signals.featured INTEGER 0/1, closes #347) + const CURRENT_MIGRATION_VERSION = 19; const versionRows = this.ctx.storage.sql .exec("SELECT value FROM config WHERE key = 'migration_version'") .toArray(); @@ -448,6 +453,20 @@ export class NewsDO extends DurableObject { } } + // Featured signal column — publisher homepage curation (closes #347). + if (appliedVersion < 19) { + for (const stmt of MIGRATION_FEATURED_SIGNAL_SQL) { + try { + this.ctx.storage.sql.exec(stmt); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (!msg.includes("duplicate column") && !msg.includes("already exists")) { + console.error("Featured signal migration statement failed:", e); + } + } + } + } + // Record current migration version so future cold starts skip all of the above. this.ctx.storage.sql.exec( "INSERT INTO config (key, value) VALUES ('migration_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')", @@ -861,6 +880,74 @@ export class NewsDO extends DurableObject { return c.json({ ok: true, data: signal, approval_cap: approvalCap } as DOResult); }); + // PATCH /signals/:id/feature — Publisher toggles homepage featured flag + // Body: { btc_address, featured: boolean } + // Only approved/brief_included signals can be featured. + this.router.patch("/signals/:id/feature", async (c) => { + const id = c.req.param("id"); + const body = await parseRequiredJson(c); + if (!body) { + return c.json({ ok: false, error: "Invalid JSON body" } satisfies DOResult, 400); + } + + const { btc_address, featured } = body; + if (typeof featured !== "boolean") { + return c.json({ ok: false, error: '"featured" must be a boolean' } satisfies DOResult, 400); + } + + // Verify publisher designation + const publisherRows = this.ctx.storage.sql + .exec("SELECT value FROM config WHERE key = ?", CONFIG_PUBLISHER_ADDRESS) + .toArray(); + if (publisherRows.length === 0) { + return c.json({ ok: false, error: "Publisher not yet designated" } satisfies DOResult, 403); + } + const publisherAddress = (publisherRows[0] as { value: string }).value; + if (btc_address !== publisherAddress) { + return c.json({ ok: false, error: "Only the designated Publisher can perform this action" } satisfies DOResult, 403); + } + + // Verify signal exists + const signalRows = this.ctx.storage.sql + .exec("SELECT id, status FROM signals WHERE id = ?", id) + .toArray(); + if (signalRows.length === 0) { + return c.json({ ok: false, error: `Signal "${id}" not found` } satisfies DOResult, 404); + } + + // Only approved or brief_included signals can be featured + const currentStatus = (signalRows[0] as { id: string; status: SignalStatus }).status; + if (currentStatus !== "approved" && currentStatus !== "brief_included") { + return c.json({ + ok: false, + error: `Only approved or brief_included signals can be featured (current: "${currentStatus}")`, + } satisfies DOResult, 400); + } + + const now = new Date().toISOString(); + this.ctx.storage.sql.exec( + "UPDATE signals SET featured = ?, updated_at = ? WHERE id = ?", + featured ? 1 : 0, + now, + id + ); + + const updated = this.ctx.storage.sql + .exec( + `SELECT s.*, b.name as beat_name, GROUP_CONCAT(st.tag) as tags_csv + FROM signals s + LEFT JOIN beats b ON s.beat_slug = b.slug + LEFT JOIN signal_tags st ON s.id = st.signal_id + WHERE s.id = ?1 + GROUP BY s.id`, + id + ) + .toArray(); + + const signal = rowToSignal(updated[0] as Record); + return c.json({ ok: true, data: signal } satisfies DOResult); + }); + // ------------------------------------------------------------------------- // Beats CRUD // ------------------------------------------------------------------------- @@ -1424,6 +1511,8 @@ export class NewsDO extends DurableObject { // GET /signals/front-page — all approved + brief_included signals in a single query // Eliminates the need for two separate /signals calls from the Worker route. // LIMIT 500 preserves the old behavior (200 approved + 200 brief_included = up to 400). + // Featured signals (featured=1) float to the top; within featured and non-featured, + // signals are ordered by created_at DESC. this.router.get("/signals/front-page", (c) => { const rows = this.ctx.storage.sql .exec( @@ -1433,7 +1522,7 @@ export class NewsDO extends DurableObject { LEFT JOIN signal_tags st ON s.id = st.signal_id WHERE s.status IN ('approved', 'brief_included') GROUP BY s.id - ORDER BY s.created_at DESC + ORDER BY s.featured DESC, s.created_at DESC LIMIT 500` ) .toArray(); diff --git a/src/objects/schema.ts b/src/objects/schema.ts index bca894ac..fd07fa9e 100644 --- a/src/objects/schema.ts +++ b/src/objects/schema.ts @@ -515,3 +515,15 @@ ON CONFLICT(slug) DO UPDATE SET export const MIGRATION_APPROVAL_CAP_INDEX_SQL = [ "CREATE INDEX IF NOT EXISTS idx_signals_status_reviewed ON signals(status, reviewed_at)", ] as const; + +/** + * MIGRATION_FEATURED_SIGNAL_SQL — publisher homepage curation (#347). + * Adds a `featured` INTEGER column (0/1 flag) to signals so the Publisher can + * pin top stories to the front of the homepage feed. + * + * Closes #347. + */ +export const MIGRATION_FEATURED_SIGNAL_SQL = [ + "ALTER TABLE signals ADD COLUMN featured INTEGER NOT NULL DEFAULT 0", + "CREATE INDEX IF NOT EXISTS idx_signals_featured ON signals(featured) WHERE featured = 1", +] as const; diff --git a/src/routes/signal-review.ts b/src/routes/signal-review.ts index bdebe7ae..a586cc5f 100644 --- a/src/routes/signal-review.ts +++ b/src/routes/signal-review.ts @@ -8,7 +8,7 @@ import { Hono } from "hono"; import type { Env, AppVariables, SignalStatus } from "../lib/types"; import { createRateLimitMiddleware } from "../middleware/rate-limit"; -import { reviewSignal, listFrontPagePage, listFrontPage } from "../lib/do-client"; +import { reviewSignal, featureSignal, listFrontPagePage, listFrontPage } from "../lib/do-client"; import { validateDateFormat } from "../lib/validators"; import { validateBtcAddress } from "../lib/validators"; import { verifyAuth } from "../services/auth"; @@ -111,6 +111,76 @@ signalReviewRouter.patch("/api/signals/:id/review", reviewRateLimit, async (c) = }); }); +// PATCH /api/signals/:id/feature — Publisher pins/unpins a signal as a top story (BIP-322 auth required) +signalReviewRouter.patch("/api/signals/:id/feature", reviewRateLimit, async (c) => { + const signalId = c.req.param("id"); + if (!signalId) return c.json({ error: "Missing signal ID" }, 400); + + let body: Record; + try { + body = await c.req.json>(); + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + const { btc_address, featured } = body; + + if (!btc_address) { + return c.json({ error: "Missing required field: btc_address" }, 400); + } + if (typeof featured !== "boolean") { + return c.json({ error: '"featured" must be a boolean' }, 400); + } + + if (!validateBtcAddress(btc_address)) { + return c.json({ error: "Invalid BTC address format" }, 400); + } + + // BIP-322 auth + const authResult = verifyAuth( + c.req.raw.headers, + btc_address as string, + "PATCH", + `/api/signals/${signalId}/feature` + ); + if (!authResult.valid) { + return c.json({ error: authResult.error, code: authResult.code }, 401); + } + + const result = await featureSignal(c.env, signalId, { + btc_address: btc_address as string, + featured, + }); + + if (!result.ok) { + return c.json({ error: result.error }, result.status ?? 400); + } + + const logger = c.get("logger"); + logger.info("signal featured", { + signal_id: signalId, + featured, + publisher: btc_address, + }); + + const s = result.data as NonNullable; + return c.json({ + id: s.id, + btcAddress: s.btc_address, + beat: s.beat_name ?? s.beat_slug, + beatSlug: s.beat_slug, + headline: s.headline, + content: s.body, + sources: s.sources, + tags: s.tags, + timestamp: s.created_at, + status: s.status, + featured: s.featured, + disclosure: s.disclosure, + correction_of: s.correction_of, + }); +}); + // GET /api/front-page — curated signals (approved + brief_included only) // Without ?before: returns all approved + brief_included signals (today's feed) // With ?before=YYYY-MM-DD: returns one day of signals strictly before that date (infinite scroll)