diff --git a/docs/superpowers/plans/2026-08-15-next-plugins-site.md b/docs/superpowers/plans/2026-08-15-next-plugins-site.md new file mode 100644 index 0000000..1c125ac --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-next-plugins-site.md @@ -0,0 +1,1273 @@ +# next.jarviscli.dev Site Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a public browse/install site plus a token-gated admin panel for revenge-next-plugs, deployed at `next.jarviscli.dev`, replacing the current bare `/index.json`-only Worker. + +**Architecture:** A single Cloudflare Worker (new `worker/src/` tree) sits in front of the existing `[assets]` static binding. It serves the public site (`/`, `/plugins/`), the real install feed (`/index.json`, format unchanged from today), and a token-gated admin UI (`/admin`, `/api/admin/*`) backed by a new D1 database. No Durable Object, no KV cache - D1 is read fresh per request. + +**Tech Stack:** TypeScript, Cloudflare Workers (`@cloudflare/workers-types`), D1, `wrangler`. No framework/router library - plain `fetch` handler, matching the sibling classic repo's `api/src/index.ts` style but adapted (that file is the pattern reference throughout this plan, not something to import from - it lives in a separate repo/build). + +## Global Constraints + +- Style: single quotes, no semicolons, trailing commas, arrow-parens as-needed (`biome.json`, already configured) - run `bunx biome check --write worker` after every task. +- Public `/index.json` format must stay byte-compatible with what Next's client already reads today (`{format, name, description, plugins: {: {name, description, author, channels: {latest, ...}, versions: {: {url, sha256, size, dependencies}}}}}`) - never break installs. +- Version/sha256/url/size in `/index.json` always come from the real built ZIPs (`build/dist/*.zip`) - never fabricated, never overridden by admin edits. +- Admin write endpoints require `Authorization: Bearer ` (Worker secret), checked server-side on every write, no exceptions. +- Cloudflare account: `dc5e308f1df2d6bb5082951d3f3993a5` (already used successfully this session via the `cloudflare-api` MCP tool for the sibling repo's D1/KV/dev-worker setup - reuse the same account, same tool). +- Plugin id format: `/^[a-zA-Z0-9.-]+$/`, no `..`, doesn't start with `.` (from `generate-index.ts`'s `PLUGIN_ID_REGEX` - reuse this exact regex for admin-side validation of draft plugin ids). +- A generated manifest for a draft plugin must declare `dependencies: {"revenge.api": {...}, "discord": {...}}` - `generate-index.ts` rejects a manifest missing either (`RESERVED_DEPENDENCY_IDS`). +- Visual style: dark background, single purple accent (`#9a8cff` / badge bg `rgba(154,140,255,0.12)` / border `rgba(154,140,255,0.25)`) used only on interactive/status elements (badges, buttons) - never as a decorative background glow or gradient. Reference: `.superpowers/brainstorm/263484-1786809105/content/glass-refined.html` (the approved mockup, already committed to the repo's `.superpowers/` - read it directly for exact colors/spacing before building the browse page). + +--- + +## File Structure + +``` +worker/ + src/ + index.ts - fetch handler entrypoint, routing only + env.ts - Env interface (D1Database, Fetcher, ADMIN_TOKEN) + db.ts - D1 access: overrides + channels, typed read/write helpers + publicIndex.ts - merges base build/dist/index.json with D1 overrides -> public /index.json + theme.ts - shared CSS string + HTML shell (head, OG meta helper) used by every page + pages/ + browse.ts - renders / + detail.ts - renders /plugins/, sets per-plugin OG tags + admin.ts - renders /admin (token gate + edit UI) + admin/ + auth.ts - Bearer-token check helper + routes.ts - /api/admin/* write handlers (override upsert, channel promote, draft create) + schema.sql - plugin_overrides + plugin_channels D1 schema + tsconfig.json - Workers-runtime tsconfig (separate from the RN-targeting root one) +wrangler.toml - MODIFY: add `main`, `[[d1_databases]]` +package.json - MODIFY: add @cloudflare/workers-types devDependency, extend lint:types +.github/scripts/verify-deploy.mjs - MODIFY: add OG-tag check + admin-401 check +``` + +`worker/src/` is its own unit, isolated from `plugins/` (RN/Hermes runtime) and `cli/`-style tooling - it never imports from `plugins/*/js`, and nothing in `plugins/` imports from `worker/`. Two completely different runtimes (Workers vs Hermes), kept structurally separate the same way the root `tsconfig.json` already excludes `plugins/*/build`. + +--- + +### Task 1: D1 database, schema, and worker scaffolding + +**Files:** +- Create: `worker/schema.sql` +- Create: `worker/tsconfig.json` +- Create: `worker/src/env.ts` +- Create: `worker/src/index.ts` (minimal placeholder - ASSETS passthrough only) +- Modify: `wrangler.toml` +- Modify: `package.json` + +**Interfaces:** +- Produces: `Env` interface (`ASSETS: Fetcher; DB: D1Database; ADMIN_TOKEN: string`) - every later task imports this from `worker/src/env.ts`. + +- [ ] **Step 1: Write the D1 schema** + +`worker/schema.sql`: +```sql +DROP TABLE IF EXISTS plugin_overrides; +CREATE TABLE plugin_overrides ( + id TEXT PRIMARY KEY, + name TEXT, + description TEXT, + tagline TEXT, + note TEXT, + category TEXT, + accent TEXT, + status TEXT, + how_it_works TEXT, + features TEXT, -- JSON array of strings + commands TEXT, -- JSON array of {cmd, desc} + limitations TEXT, + hidden INTEGER NOT NULL DEFAULT 0, + is_draft INTEGER NOT NULL DEFAULT 0, + manifest TEXT, -- only used when is_draft = 1 + main_js TEXT, -- only used when is_draft = 1 + updated_at TEXT NOT NULL +); + +DROP TABLE IF EXISTS plugin_channels; +CREATE TABLE plugin_channels ( + plugin_id TEXT NOT NULL, + channel TEXT NOT NULL, -- 'alpha' | 'beta' | 'stable' + version TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (plugin_id, channel) +); +``` + +- [ ] **Step 2: Create the D1 database via the Cloudflare API** + +Use the `cloudflare-api` MCP tool's `execute` action (same tool, same account, proven working earlier this session for the sibling repo's dev D1): + +```js +async () => { + const res = await cloudflare.request({ + method: "POST", + path: `/accounts/dc5e308f1df2d6bb5082951d3f3993a5/d1/database`, + body: { name: "revenge-next-plugs" }, + }); + return res; +} +``` +Record the returned `result.uuid` - it goes into `wrangler.toml` in Step 5. + +- [ ] **Step 3: Apply the schema to the new database** + +```js +async () => { + const sql = /* paste the full contents of worker/schema.sql here as a template string */ ``; + const res = await cloudflare.request({ + method: "POST", + path: `/accounts/dc5e308f1df2d6bb5082951d3f3993a5/d1/database//query`, + body: { sql }, + }); + return res; +} +``` +Expected: `success: true`, two statements each reporting `changed_db: true`. + +- [ ] **Step 4: Set the ADMIN_TOKEN secret** + +Generate a random token (e.g. `openssl rand -hex 24`) and set it: +```bash +cd /root/revenge-next-plugs +echo -n "" | bunx wrangler secret put ADMIN_TOKEN +``` +Save the token somewhere you (the human) can retrieve it later - it's never printed back by Cloudflare after this. + +- [ ] **Step 5: Wire wrangler.toml** + +Modify `wrangler.toml` to add a `main` entry and the D1 binding: +```toml +name = "revenge-next-plugs" +compatibility_date = "2026-08-08" +account_id = "dc5e308f1df2d6bb5082951d3f3993a5" +main = "worker/src/index.ts" + +[assets] +directory = "build/dist" +binding = "ASSETS" + +[[d1_databases]] +binding = "DB" +database_name = "revenge-next-plugs" +database_id = "" + +[[routes]] +pattern = "next.jarviscli.dev" +custom_domain = true +``` +(`binding = "ASSETS"` under `[assets]` is added explicitly here even though it was implicit before - the Worker code now references `env.ASSETS` directly, so make the name explicit rather than relying on the Cloudflare default.) + +- [ ] **Step 6: Add `@cloudflare/workers-types` and a worker-specific tsconfig** + +```bash +cd /root/revenge-next-plugs +bun add -d @cloudflare/workers-types +``` + +`worker/tsconfig.json`: +```json +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "types": ["@cloudflare/workers-types"] + }, + "include": ["src"] +} +``` +This is deliberately separate from the root `tsconfig.json` (which pulls in `@revenge-mod/types` - React Native/Hermes globals that don't exist in the Workers runtime, and vice versa: `D1Database`/`Fetcher` don't exist in the RN runtime). + +- [ ] **Step 7: `Env` interface** + +`worker/src/env.ts`: +```ts +export interface Env { + ASSETS: Fetcher + DB: D1Database + ADMIN_TOKEN: string +} +``` + +- [ ] **Step 8: Minimal placeholder entrypoint** + +`worker/src/index.ts`: +```ts +import type { Env } from './env' + +export default { + async fetch(request: Request, env: Env): Promise { + return env.ASSETS.fetch(request) + }, +} +``` + +- [ ] **Step 9: Extend `lint:types` to cover the worker** + +Modify `package.json`'s `scripts.lint:types`: +```json +"lint:types": "tsc --noEmit && tsc -p worker --noEmit && tsc -p cli --noEmit" +``` +(The pre-existing `tsc -p cli --noEmit` already fails today because no `cli/` directory exists yet - that's a pre-existing gap, not something this task fixes. Placing the new `worker` check *before* it means a broken worker build still fails loudly instead of being masked by the already-broken `cli` check.) + +- [ ] **Step 10: Build, typecheck, format** + +```bash +cd /root/revenge-next-plugs +bun run lint:types 2>&1 | grep -v "^plugins/staff-tags/js/patches/details.tsx" # pre-existing, unrelated +bunx biome check --write worker +``` +Expected: no NEW errors (the `details.tsx` ones are pre-existing and out of scope for this plan). + +- [ ] **Step 11: Deploy and verify the placeholder** + +```bash +bunx wrangler deploy +curl -s -o /dev/null -w "%{http_code}\n" https://next.jarviscli.dev/index.json +``` +Expected: `200` (the site behaves exactly as before this task - Step 8's Worker is a pure passthrough to the same static assets that were already being served). + +- [ ] **Step 12: Commit** + +```bash +git add worker wrangler.toml package.json bun.lock +git commit -m "next-site: scaffold worker, D1 database, and deploy wiring" +``` + +--- + +### Task 2: D1 access layer + dynamic public index.json + +**Files:** +- Create: `worker/src/db.ts` +- Create: `worker/src/publicIndex.ts` +- Modify: `worker/src/index.ts` + +**Interfaces:** +- Consumes: `Env` from Task 1's `worker/src/env.ts`. +- Produces: + - `db.ts`: `getOverride(env: Env, id: string): Promise`, `getAllOverrides(env: Env): Promise>`, `getChannels(env: Env, id: string): Promise>`, `upsertOverride(env: Env, id: string, patch: Partial): Promise`, `setChannel(env: Env, id: string, channel: string, version: string): Promise`. `Override` type: `{name?, description?, tagline?, note?, category?, accent?, status?, howItWorks?, features?: string[], commands?: {cmd:string,desc:string}[], limitations?, hidden: boolean, isDraft: boolean, manifest?: string, mainJs?: string}`. + - `publicIndex.ts`: `buildPublicIndex(baseIndex: BaseIndex, overrides: Record): BaseIndex` (same shape as `generate-index.ts`'s output - `{format, name, description, plugins: {...}}`) - later tasks (browse/detail pages) reuse this same merged shape for display data too. + +- [ ] **Step 1: D1 row types and read/write helpers** + +`worker/src/db.ts`: +```ts +import type { Env } from './env' + +export interface Override { + name?: string + description?: string + tagline?: string + note?: string + category?: string + accent?: string + status?: string + howItWorks?: string + features?: string[] + commands?: { cmd: string; desc: string }[] + limitations?: string + hidden: boolean + isDraft: boolean + manifest?: string + mainJs?: string +} + +interface Row { + id: string + name: string | null + description: string | null + tagline: string | null + note: string | null + category: string | null + accent: string | null + status: string | null + how_it_works: string | null + features: string | null + commands: string | null + limitations: string | null + hidden: number + is_draft: number + manifest: string | null + main_js: string | null +} + +function rowToOverride(row: Row): Override { + return { + name: row.name ?? undefined, + description: row.description ?? undefined, + tagline: row.tagline ?? undefined, + note: row.note ?? undefined, + category: row.category ?? undefined, + accent: row.accent ?? undefined, + status: row.status ?? undefined, + howItWorks: row.how_it_works ?? undefined, + features: row.features ? JSON.parse(row.features) : undefined, + commands: row.commands ? JSON.parse(row.commands) : undefined, + limitations: row.limitations ?? undefined, + hidden: !!row.hidden, + isDraft: !!row.is_draft, + manifest: row.manifest ?? undefined, + mainJs: row.main_js ?? undefined, + } +} + +export async function getOverride(env: Env, id: string): Promise { + const row = await env.DB.prepare('SELECT * FROM plugin_overrides WHERE id = ?1') + .bind(id) + .first() + return row ? rowToOverride(row) : null +} + +export async function getAllOverrides(env: Env): Promise> { + const { results } = await env.DB.prepare('SELECT * FROM plugin_overrides').all() + const out: Record = {} + for (const row of results) out[row.id] = rowToOverride(row) + return out +} + +export async function getChannels(env: Env, id: string): Promise> { + const { results } = await env.DB.prepare( + 'SELECT channel, version FROM plugin_channels WHERE plugin_id = ?1', + ) + .bind(id) + .all<{ channel: string; version: string }>() + const out: Record = {} + for (const row of results) out[row.channel] = row.version + return out +} + +export async function getAllChannels(env: Env): Promise>> { + const { results } = await env.DB.prepare('SELECT plugin_id, channel, version FROM plugin_channels').all<{ + plugin_id: string + channel: string + version: string + }>() + const out: Record> = {} + for (const row of results) { + out[row.plugin_id] ??= {} + out[row.plugin_id][row.channel] = row.version + } + return out +} + +export async function upsertOverride(env: Env, id: string, patch: Partial): Promise { + const existing = await getOverride(env, id) + const merged: Override = { hidden: false, isDraft: false, ...existing, ...patch } + + await env.DB.prepare( + `INSERT INTO plugin_overrides ( + id, name, description, tagline, note, category, accent, status, how_it_works, + features, commands, limitations, hidden, is_draft, manifest, main_js, updated_at + ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, description = excluded.description, tagline = excluded.tagline, + note = excluded.note, category = excluded.category, accent = excluded.accent, + status = excluded.status, how_it_works = excluded.how_it_works, features = excluded.features, + commands = excluded.commands, limitations = excluded.limitations, hidden = excluded.hidden, + is_draft = excluded.is_draft, manifest = excluded.manifest, main_js = excluded.main_js, + updated_at = excluded.updated_at`, + ) + .bind( + id, + merged.name ?? null, + merged.description ?? null, + merged.tagline ?? null, + merged.note ?? null, + merged.category ?? null, + merged.accent ?? null, + merged.status ?? null, + merged.howItWorks ?? null, + merged.features ? JSON.stringify(merged.features) : null, + merged.commands ? JSON.stringify(merged.commands) : null, + merged.limitations ?? null, + merged.hidden ? 1 : 0, + merged.isDraft ? 1 : 0, + merged.manifest ?? null, + merged.mainJs ?? null, + new Date().toISOString(), + ) + .run() +} + +export async function setChannel(env: Env, id: string, channel: string, version: string): Promise { + await env.DB.prepare( + `INSERT INTO plugin_channels (plugin_id, channel, version, updated_at) VALUES (?1,?2,?3,?4) + ON CONFLICT(plugin_id, channel) DO UPDATE SET version = excluded.version, updated_at = excluded.updated_at`, + ) + .bind(id, channel, version, new Date().toISOString()) + .run() +} +``` + +- [ ] **Step 2: Write the failing test for the merge logic** + +There's no existing test runner in this repo (`plugin-cli` ships a build/generate-index CLI only). Write a small standalone script instead, matching the repo's existing "smoke test as a script" convention (`verify-deploy.mjs`). + +`worker/src/publicIndex.test.mjs` (temporary, for this step only - deleted at the end of Step 4 once `publicIndex.ts` is proven against it via a real bundled check in Task 6, so this doesn't become a second untracked test system): +```js +import assert from 'node:assert' + +const baseIndex = { + format: 1, + name: 'revenge-next-plugs', + description: '', + plugins: { + 'dev.example.foo': { + name: 'Foo', + description: 'does foo', + author: 'Rosie', + channels: { latest: '1.0.0' }, + versions: { '1.0.0': { url: 'https://x/foo.zip', sha256: 'abc', size: 10, dependencies: {} } }, + }, + 'dev.example.hidden': { + name: 'Hidden', + description: 'should not appear', + author: 'Rosie', + channels: { latest: '1.0.0' }, + versions: { '1.0.0': { url: 'https://x/hidden.zip', sha256: 'def', size: 10, dependencies: {} } }, + }, + }, +} + +const overrides = { + 'dev.example.foo': { hidden: false, isDraft: false, name: 'Foo Override' }, + 'dev.example.hidden': { hidden: true, isDraft: false }, +} + +const channels = { + 'dev.example.foo': { stable: '1.0.0', beta: '1.0.0' }, +} + +// Inline copy of the logic under test, since this repo has no test runner wired up yet - +// deleted once Task 6's live deploy check exercises the real module end-to-end. +function buildPublicIndex(base, overrides, channels) { + const plugins = {} + for (const [id, plugin] of Object.entries(base.plugins)) { + const override = overrides[id] + if (override?.hidden) continue + const stable = channels[id]?.stable + plugins[id] = { + ...plugin, + name: override?.name ?? plugin.name, + description: override?.description ?? plugin.description, + channels: { ...plugin.channels, ...(stable && stable in plugin.versions ? { latest: stable } : {}) }, + } + } + return { ...base, plugins } +} + +const result = buildPublicIndex(baseIndex, overrides, channels) +assert.strictEqual(Object.keys(result.plugins).length, 1, 'hidden plugin should be dropped') +assert.strictEqual(result.plugins['dev.example.foo'].name, 'Foo Override', 'override name should win') +assert.strictEqual(result.plugins['dev.example.foo'].channels.latest, '1.0.0', 'stable channel should map to latest') +console.log('publicIndex merge logic: OK') +``` + +- [ ] **Step 3: Run it to confirm the test itself is sound** + +```bash +node worker/src/publicIndex.test.mjs +``` +Expected: `publicIndex merge logic: OK` (this validates the test's own assertions against the inline reference implementation before the real TypeScript module exists). + +- [ ] **Step 4: Write the real `publicIndex.ts` (same logic, typed, importable)** + +`worker/src/publicIndex.ts`: +```ts +import type { Override } from './db' + +export interface IndexVersion { + url: string + sha256: string + size: number + dependencies: Record +} + +export interface IndexPlugin { + name: string + description: string + author: string + icon?: string + channels: Record + versions: Record +} + +export interface BaseIndex { + format: number + name: string + description: string + icon?: string + plugins: Record +} + +/** + * Merges the git-tracked base index (real versions/hashes, never touched) with live D1 + * overrides: hidden plugins drop out, edited display fields win, and whatever version is + * mapped to the 'stable' channel becomes 'latest' - the only field of `channels` a public + * consumer (Next's installer) ever reads. alpha/beta stay in `channels` for the admin UI's + * own use but are meaningless to a public client. + */ +export function buildPublicIndex( + base: BaseIndex, + overrides: Record, + channels: Record>, +): BaseIndex { + const plugins: Record = {} + for (const [id, plugin] of Object.entries(base.plugins)) { + const override = overrides[id] + if (override?.hidden) continue + + const pluginChannels = channels[id] + const stable = pluginChannels?.stable + plugins[id] = { + ...plugin, + name: override?.name ?? plugin.name, + description: override?.description ?? plugin.description, + channels: { + ...plugin.channels, + ...(stable && stable in plugin.versions ? { latest: stable } : {}), + }, + } + } + return { ...base, plugins } +} +``` + +- [ ] **Step 5: Wire it into the fetch handler** + +Modify `worker/src/index.ts`: +```ts +import { getAllChannels, getAllOverrides } from './db' +import { buildPublicIndex } from './publicIndex' +import type { Env } from './env' +import type { BaseIndex } from './publicIndex' + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url) + + if (url.pathname === '/index.json') { + const baseRes = await env.ASSETS.fetch(new URL('/index.json', request.url)) + if (!baseRes.ok) return baseRes + + const base = await baseRes.json() + const [overrides, channels] = await Promise.all([getAllOverrides(env), getAllChannels(env)]) + const merged = buildPublicIndex(base, overrides, channels) + + return new Response(JSON.stringify(merged), { + headers: { 'content-type': 'application/json' }, + }) + } + + return env.ASSETS.fetch(request) + }, +} +``` + +- [ ] **Step 6: Delete the temporary standalone test, typecheck, format** + +```bash +rm worker/src/publicIndex.test.mjs +cd /root/revenge-next-plugs +bun run lint:types 2>&1 | grep -v "^plugins/staff-tags/js/patches/details.tsx" +bunx biome check --write worker +``` +Expected: no new errors. + +- [ ] **Step 7: Deploy and verify live** + +```bash +bunx wrangler deploy +curl -s https://next.jarviscli.dev/index.json | python3 -m json.tool | head -20 +``` +Expected: identical shape to before this task (no overrides exist in D1 yet, so the merge is a no-op) - confirms the new code path didn't regress anything. + +- [ ] **Step 8: Commit** + +```bash +git add worker +git commit -m "next-site: D1 access layer, dynamic public index.json merge" +``` + +--- + +### Task 3: Shared theme + browse page (`/`) + +**Files:** +- Create: `worker/src/theme.ts` +- Create: `worker/src/pages/browse.ts` +- Modify: `worker/src/index.ts` + +**Interfaces:** +- Consumes: `buildPublicIndex`'s merged `BaseIndex` from Task 2, plus `getAllOverrides` for `tagline`/`category`/`accent` (fields the public `/index.json` doesn't carry, but the browse page needs for display). +- Produces: `theme.ts`: `PAGE_CSS: string`, `htmlShell(opts: {title: string; description: string; ogTitle?: string; ogDescription?: string; body: string}): string`. `browse.ts`: `renderBrowsePage(index: BaseIndex, overrides: Record): string` (returns full HTML) - `pages/detail.ts` in Task 4 reuses `htmlShell`. + +- [ ] **Step 1: Shared CSS + HTML shell, matching the approved mockup exactly** + +Read `.superpowers/brainstorm/263484-1786809105/content/glass-refined.html` first and copy its exact color values (`#0b0c10` background, `#e8e9ed` text, `#9a8cff`/`rgba(154,140,255,0.12)`/`rgba(154,140,255,0.25)` accent, `rgba(255,255,255,0.07-0.12)` borders) rather than re-deriving them from memory. + +`worker/src/theme.ts`: +```ts +export const PAGE_CSS = ` + * { box-sizing: border-box; } + body { + margin: 0; + background: #0b0c10; + color: #e8e9ed; + font-family: -apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif; + } + a { color: inherit; text-decoration: none; } + .wrap { max-width: 960px; margin: 0 auto; padding: 0 24px; } + header { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 24px; border-bottom: 1px solid rgba(255,255,255,0.07); + } + .brand { display: flex; align-items: center; gap: 10px; } + .brand-mark { + width: 22px; height: 22px; border-radius: 6px; background: #15161c; + border: 1px solid rgba(255,255,255,0.12); display: flex; align-items: center; + justify-content: center; font-size: 11px; font-weight: 700; color: #9a8cff; + } + .brand-name { font-weight: 600; font-size: 13.5px; letter-spacing: -0.01em; } + nav { display: flex; align-items: center; gap: 18px; font-size: 12px; color: rgba(255,255,255,0.5); } + .page-title { font-size: 19px; font-weight: 650; letter-spacing: -0.015em; margin-bottom: 4px; } + .page-sub { font-size: 12.5px; color: rgba(255,255,255,0.45); margin-bottom: 16px; } + .chip { + background: transparent; border: 1px solid rgba(255,255,255,0.07); border-radius: 7px; + padding: 5px 11px; font-size: 11.5px; color: rgba(255,255,255,0.4); display: inline-block; + } + .chip.active { background: #17181e; border-color: rgba(255,255,255,0.09); color: rgba(255,255,255,0.85); } + .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 4px 0 24px; } + .card { + background: linear-gradient(180deg, rgba(255,255,255,0.045), rgba(255,255,255,0.015)); + border: 1px solid rgba(255,255,255,0.08); border-radius: 11px; padding: 15px; + } + .card-top { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 10px; } + .card-icon { + width: 30px; height: 30px; border-radius: 8px; background: #1a1b22; + border: 1px solid rgba(255,255,255,0.1); display: flex; align-items: center; + justify-content: center; font-size: 13px; + } + .badge { + background: rgba(154,140,255,0.12); color: #b6acff; border: 1px solid rgba(154,140,255,0.25); + font-size: 9.5px; font-weight: 600; padding: 2px 7px; border-radius: 5px; + } + .card-name { font-size: 13.5px; font-weight: 600; letter-spacing: -0.005em; } + .card-desc { font-size: 11px; color: rgba(255,255,255,0.5); margin-top: 3px; line-height: 1.4; } + .card-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; } + .card-meta { font-size: 10px; color: rgba(255,255,255,0.35); } + .btn { + background: #e8e9ed; color: #0b0c10; font-size: 10.5px; font-weight: 600; + padding: 4px 10px; border-radius: 6px; display: inline-block; + } +` + +export function htmlShell(opts: { + title: string + description: string + ogTitle?: string + ogDescription?: string + body: string +}): string { + const ogTitle = opts.ogTitle ?? opts.title + const ogDescription = opts.ogDescription ?? opts.description + return ` + + + + +${escapeHtml(opts.title)} + + + + + + + + +${opts.body} +` +} + +export function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} +``` + +- [ ] **Step 2: Browse page renderer** + +`worker/src/pages/browse.ts`: +```ts +import { escapeHtml, htmlShell } from '../theme' +import type { Override } from '../db' +import type { BaseIndex } from '../publicIndex' + +export function renderBrowsePage(index: BaseIndex, overrides: Record): string { + const entries = Object.entries(index.plugins) + + const cards = entries + .map(([id, plugin]) => { + const override = overrides[id] + const version = plugin.channels.latest ?? Object.keys(plugin.versions)[0] + const tagline = override?.tagline ?? plugin.description + return ` +
+
+
${plugin.icon ? escapeHtml(plugin.icon) : '\u{1F9E9}'}
+
STABLE
+
+ +
${escapeHtml(tagline)}
+
+
${escapeHtml(plugin.author)} · v${escapeHtml(version ?? '?')}
+ View +
+
` + }) + .join('') + + const body = ` +
+
+
N
+
${escapeHtml(index.name)}
+
+ +
+
+
Plugins
+
${entries.length} available · installs directly into Revenge Next
+
${cards}
+
` + + return htmlShell({ + title: index.name, + description: index.description || 'Plugins for Revenge Next.', + body, + }) +} +``` + +- [ ] **Step 3: Wire the `/` route** + +Modify `worker/src/index.ts`, adding a branch before the final `env.ASSETS.fetch(request)` fallback: +```ts +import { getAllChannels, getAllOverrides } from './db' +import { renderBrowsePage } from './pages/browse' +import { buildPublicIndex } from './publicIndex' +// ... + +if (url.pathname === '/') { + const baseRes = await env.ASSETS.fetch(new URL('/index.json', request.url)) + const base = await baseRes.json() + const [overrides, channels] = await Promise.all([getAllOverrides(env), getAllChannels(env)]) + const merged = buildPublicIndex(base, overrides, channels) + const visible = Object.fromEntries( + Object.entries(merged.plugins).filter(([id]) => !overrides[id]?.hidden), + ) + return new Response(renderBrowsePage({ ...merged, plugins: visible }, overrides), { + headers: { 'content-type': 'text/html; charset=utf-8' }, + }) +} +``` +(`buildPublicIndex` already drops hidden plugins from `.plugins`, so the extra filter here is redundant defense, not new logic - keep it simple and just pass `merged` directly instead once this is verified; noted here for clarity during review, not as a real double-filter to ship.) + +Actually - simplify: `buildPublicIndex`'s output already excludes hidden plugins entirely, so just pass `merged` straight to `renderBrowsePage(merged, overrides)` without the redundant `visible` filtering line. + +- [ ] **Step 4: Typecheck, format, deploy, verify live** + +```bash +cd /root/revenge-next-plugs +bun run lint:types 2>&1 | grep -v "^plugins/staff-tags/js/patches/details.tsx" +bunx biome check --write worker +bunx wrangler deploy +curl -s -o /dev/null -w "%{http_code}\n" https://next.jarviscli.dev/ +curl -s https://next.jarviscli.dev/ | grep -c "card-name" +``` +Expected: `200`, and a count matching the number of plugins currently built (2, as of this plan). + +- [ ] **Step 5: Commit** + +```bash +git add worker +git commit -m "next-site: browse page" +``` + +--- + +### Task 4: Plugin detail page with server-rendered OG tags + +**Files:** +- Create: `worker/src/pages/detail.ts` +- Modify: `worker/src/index.ts` + +**Interfaces:** +- Consumes: `htmlShell`, `escapeHtml` from Task 3's `theme.ts`; `buildPublicIndex`, `getOverride` from Task 2. +- Produces: `renderDetailPage(id: string, plugin: IndexPlugin, override: Override | null): string`. + +- [ ] **Step 1: Detail page renderer** + +`worker/src/pages/detail.ts`: +```ts +import { escapeHtml, htmlShell } from '../theme' +import type { Override } from '../db' +import type { IndexPlugin } from '../publicIndex' + +export function renderDetailPage(id: string, plugin: IndexPlugin, override: Override | null): string { + const description = override?.description ?? plugin.description + const tagline = override?.tagline ?? description + const version = plugin.channels.latest ?? Object.keys(plugin.versions)[0] + + const featuresHtml = override?.features?.length + ? `
    ${override.features.map(f => `
  • ${escapeHtml(f)}
  • `).join('')}
` + : '' + + const body = ` +
+
+ N +
${escapeHtml(plugin.name)}
+
+ +
+
+
${escapeHtml(plugin.name)}
+
${escapeHtml(tagline)}
+

${escapeHtml(description)}

+ ${featuresHtml} +
${escapeHtml(plugin.author)} · v${escapeHtml(version ?? '?')}
+ Add repository in Revenge Next +
` + + return htmlShell({ + title: `${plugin.name} - Revenge Next`, + description: tagline, + ogTitle: plugin.name, + ogDescription: tagline, + body, + }) +} +``` + +Note on the install button: Next's actual per-plugin deep-link install scheme (if one exists beyond "add this repository's index.json URL in-app") wasn't confirmed this session - linking to `/index.json` with "Add repository in Revenge Next" is the documented, always-correct fallback (matches this repo's own README: "This repository auto-deploys to next.jarviscli.dev", described as a repository endpoint, not per-plugin links). If Next gains a confirmed per-plugin deep-link scheme later, swap this one link - nothing else in this task depends on the exact URL shape. + +- [ ] **Step 2: Wire the `/plugins/` route** + +Modify `worker/src/index.ts`: +```ts +import { getAllChannels, getAllOverrides, getOverride } from './db' +import { renderDetailPage } from './pages/detail' +// ... + +const detailMatch = url.pathname.match(/^\/plugins\/([\w.-]+)$/) +if (detailMatch) { + const id = detailMatch[1] + const baseRes = await env.ASSETS.fetch(new URL('/index.json', request.url)) + const base = await baseRes.json() + const [overrides, channels] = await Promise.all([getAllOverrides(env), getAllChannels(env)]) + const merged = buildPublicIndex(base, overrides, channels) + + const plugin = merged.plugins[id] + if (!plugin) return new Response('Not found', { status: 404 }) + + return new Response(renderDetailPage(id, plugin, overrides[id] ?? null), { + headers: { 'content-type': 'text/html; charset=utf-8' }, + }) +} +``` + +- [ ] **Step 3: Typecheck, format, deploy, verify OG tags live** + +```bash +cd /root/revenge-next-plugs +bun run lint:types 2>&1 | grep -v "^plugins/staff-tags/js/patches/details.tsx" +bunx biome check --write worker +bunx wrangler deploy +curl -s https://next.jarviscli.dev/plugins/dev.everestmcarthur.staff-tags | grep -E "og:title|og:description" +``` +Expected: `og:title` content is `Staff Tags` (or whatever it's renamed to by the time this runs - see the Custom Tags rework, tracked separately), not `revenge-next-plugs` or any site-wide fallback string. This is the concrete check the user asked for. + +- [ ] **Step 4: Commit** + +```bash +git add worker +git commit -m "next-site: plugin detail page with per-plugin OG tags" +``` + +--- + +### Task 5: Admin auth, admin UI, and write endpoints + +**Files:** +- Create: `worker/src/admin/auth.ts` +- Create: `worker/src/admin/routes.ts` +- Create: `worker/src/pages/admin.ts` +- Modify: `worker/src/index.ts` + +**Interfaces:** +- Consumes: `upsertOverride`, `setChannel` from Task 2's `db.ts`; `htmlShell` from Task 3. +- Produces: `checkAuth(request: Request, env: Env): boolean`; `handleAdminApi(request: Request, env: Env, url: URL): Promise` (returns `null` if the path isn't an admin API route, so `index.ts` can fall through cleanly). + +- [ ] **Step 1: Auth check** + +`worker/src/admin/auth.ts`: +```ts +import type { Env } from '../env' + +export function checkAuth(request: Request, env: Env): boolean { + const auth = request.headers.get('authorization') + return auth === `Bearer ${env.ADMIN_TOKEN}` +} +``` + +- [ ] **Step 2: Write endpoints** + +`worker/src/admin/routes.ts`: +```ts +import { setChannel, upsertOverride } from '../db' +import { checkAuth } from './auth' +import type { Env } from '../env' +import type { BaseIndex } from '../publicIndex' + +const PLUGIN_ID_REGEX = /^[a-zA-Z0-9.-]+$/ // matches generate-index.ts's own validation exactly + +function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +const DEFAULT_MAIN_JS = `export default plugin({\n\tstart() {},\n})\n` + +async function sha256Hex(text: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)) + return Array.from(new Uint8Array(digest)) + .map(b => b.toString(16).padStart(2, '0')) + .join('') +} + +/** Returns null if `url.pathname` isn't an admin API route, so the caller can fall through. */ +export async function handleAdminApi(request: Request, env: Env, url: URL): Promise { + if (!url.pathname.startsWith('/api/admin/')) return null + + if (!checkAuth(request, env)) return json({ error: 'unauthorized' }, 401) + + const overrideMatch = url.pathname.match(/^\/api\/admin\/overrides\/([\w.-]+)$/) + if (overrideMatch && request.method === 'PUT') { + const id = overrideMatch[1] + if (!PLUGIN_ID_REGEX.test(id)) return json({ error: 'invalid id' }, 400) + + const body = await request.json>().catch(() => null) + if (!body) return json({ error: 'invalid body' }, 400) + + const patch: Parameters[2] = { + name: body.name as string | undefined, + description: body.description as string | undefined, + tagline: body.tagline as string | undefined, + note: body.note as string | undefined, + category: body.category as string | undefined, + accent: body.accent as string | undefined, + status: body.status as string | undefined, + howItWorks: body.howItWorks as string | undefined, + features: body.features as string[] | undefined, + commands: body.commands as { cmd: string; desc: string }[] | undefined, + limitations: body.limitations as string | undefined, + hidden: body.hidden as boolean | undefined, + isDraft: body.isDraft as boolean | undefined, + } + + if (patch.isDraft) { + const mainJs = (body.mainJs as string | undefined) ?? DEFAULT_MAIN_JS + patch.mainJs = mainJs + patch.manifest = JSON.stringify({ + format: 1, + id, + name: patch.name ?? id, + description: patch.description ?? '', + author: (body.author as string | undefined) ?? '', + version: (body.version as string | undefined) ?? '0.0.1', + dependencies: { 'revenge.api': { version: '>=1 <2' }, discord: { version: '*' } }, + dist: { script: 'index.js' }, + }) + } + + await upsertOverride(env, id, patch) + return json({ ok: true }) + } + + const channelMatch = url.pathname.match(/^\/api\/admin\/channels\/([\w.-]+)\/([\w-]+)$/) + if (channelMatch && request.method === 'PUT') { + const [, id, channel] = channelMatch + const body = await request.json<{ version?: string }>().catch(() => null) + if (!body?.version) return json({ error: 'version required' }, 400) + + // Mirrors generate-index.ts's own "Channel override points at unpublished version" check - + // a channel can only ever point at a version that's actually in the built index. + const baseRes = await env.ASSETS.fetch('https://internal/index.json') + const base = await baseRes.json() + const plugin = base.plugins[id] + if (!plugin || !(body.version in plugin.versions)) { + return json({ error: `version '${body.version}' is not published for '${id}'` }, 400) + } + + await setChannel(env, id, channel, body.version) + return json({ ok: true }) + } + + return json({ error: 'not found' }, 404) +} +``` + +- [ ] **Step 3: Admin page (token entry + edit UI)** + +`worker/src/pages/admin.ts`: +```ts +import { htmlShell } from '../theme' + +/** + * Single-page admin UI: a token field gates everything else client-side (the real gate is + * server-side, in handleAdminApi - this is just UX, not security). Once a token is entered, it + * fetches /index.json for the plugin list and lets the admin PUT overrides/channels directly. + */ +export function renderAdminPage(): string { + const body = ` +
+
+ N +
Admin
+
+ +
+
+
+
Admin token
+ + +
+ +
+ ` + + return htmlShell({ title: 'Admin - Revenge Next', description: 'Admin panel', body }) +} +``` + +- [ ] **Step 4: Wire `/admin` and `/api/admin/*` into the fetch handler** + +Modify `worker/src/index.ts`: +```ts +import { handleAdminApi } from './admin/routes' +import { renderAdminPage } from './pages/admin' +// ... + +if (url.pathname === '/admin') { + return new Response(renderAdminPage(), { headers: { 'content-type': 'text/html; charset=utf-8' } }) +} + +const adminApiRes = await handleAdminApi(request, env, url) +if (adminApiRes) return adminApiRes +``` +(Place this branch before the final `env.ASSETS.fetch(request)` fallback, alongside the other route checks from Tasks 2-4.) + +- [ ] **Step 5: Typecheck, format** + +```bash +cd /root/revenge-next-plugs +bun run lint:types 2>&1 | grep -v "^plugins/staff-tags/js/patches/details.tsx" +bunx biome check --write worker +``` + +- [ ] **Step 6: Deploy and verify auth gating live** + +```bash +bunx wrangler deploy +curl -s -o /dev/null -w "%{http_code}\n" https://next.jarviscli.dev/api/admin/overrides/dev.everestmcarthur.radial-status -X PUT -d '{}' +``` +Expected: `401` (no `Authorization` header sent). + +```bash +curl -s -o /dev/null -w "%{http_code}\n" https://next.jarviscli.dev/api/admin/overrides/dev.everestmcarthur.radial-status \ + -X PUT -H "authorization: Bearer " \ + -H "content-type: application/json" -d '{"tagline":"test tagline"}' +curl -s https://next.jarviscli.dev/plugins/dev.everestmcarthur.radial-status | grep "test tagline" +``` +Expected: `200`, and the tagline shows up live on the detail page - confirms the whole write -> read path works end to end. Revert the test edit afterward (`PUT` again with the real tagline, or `hidden: false` / whatever the correct value was) so a throwaway test string doesn't stay live. + +- [ ] **Step 7: Commit** + +```bash +git add worker +git commit -m "next-site: admin auth, admin UI, override/channel write endpoints" +``` + +--- + +### Task 6: Extend verify-deploy.mjs, final end-to-end verification + +**Files:** +- Modify: `.github/scripts/verify-deploy.mjs` + +**Interfaces:** +- Consumes: nothing new - this task only adds checks to the existing script's already-fetched `idx` data plus two new fetches. + +- [ ] **Step 1: Add the OG-tag and admin-401 checks** + +Modify `.github/scripts/verify-deploy.mjs`, appending after the existing sha256 check: +```js +// New checks added for the next.jarviscli.dev site (browse/detail/admin pages). + +const detailHtml = await fetchWithRetry(`${BASE_URL}/plugins/${id}`, { html: true }); +if (!detailHtml.includes(`property="og:title" content="${plugin.name}"`)) { + throw new Error(`Plugin detail page for ${id} is missing correct og:title for "${plugin.name}"`); +} +console.log(`Verified ${id}: og:title present and correct`); + +const unauthedRes = await fetch(`${BASE_URL}/api/admin/overrides/${id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: "{}", +}); +if (unauthedRes.status !== 401) { + throw new Error(`Unauthenticated admin write should return 401, got ${unauthedRes.status}`); +} +console.log("Verified: unauthenticated admin write correctly rejected (401)"); +``` + +`fetchWithRetry` currently only supports `binary` (returns `arrayBuffer()`) or JSON (`res.json()`) - add an `html` option: +```js +async function fetchWithRetry(url, { attempts = 5, delayMs = 3000, binary = false, html = false } = {}) { + let lastError; + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`${url} responded ${res.status} ${res.statusText}`); + } + if (binary) return await res.arrayBuffer(); + if (html) return await res.text(); + return await res.json(); + } catch (e) { + lastError = e; + if (attempt < attempts) { + console.log(`Attempt ${attempt}/${attempts} for ${url} failed (${e.message}), retrying in ${delayMs}ms...`); + await new Promise(r => setTimeout(r, delayMs)); + } + } + } + throw new Error(`${url} failed after ${attempts} attempts: ${lastError.message}`); +} +``` + +- [ ] **Step 2: Run the smoke test against the live site** + +```bash +cd /root/revenge-next-plugs +bun .github/scripts/verify-deploy.mjs +``` +Expected: all four "Verified ..." lines print, script exits 0. + +- [ ] **Step 3: Full local rebuild + redeploy, confirming nothing in the Gradle/generate-index pipeline broke** + +```bash +rm -rf build +./gradlew packageAllPlugins +bun run generate-index -- --dist build/dist --base-url https://next.jarviscli.dev --out build/dist/index.json +bunx wrangler deploy +bun .github/scripts/verify-deploy.mjs +``` +Expected: clean build, clean deploy, smoke test passes. + +- [ ] **Step 4: Manual browser check** + +Visit `https://next.jarviscli.dev/` and `https://next.jarviscli.dev/admin` directly (not curl) - confirm the browse page renders per the approved mockup, and the admin panel's token gate/edit/promote flow works end to end (enter the real `ADMIN_TOKEN`, edit a tagline, promote a version, confirm it shows up on `/`). + +- [ ] **Step 5: Commit** + +```bash +git add .github/scripts/verify-deploy.mjs +git commit -m "next-site: extend verify-deploy.mjs with OG-tag and admin-auth checks" +``` + +--- + +## Self-Review + +**Spec coverage:** +- D1-backed admin, no live-broadcast -> Task 1-2 (no DO/WebSocket anywhere in this plan). Covered. +- Public feed shows only `stable` -> `buildPublicIndex` in Task 2. Covered. +- Browse + detail pages, approved visual direction -> Task 3-4. Covered. +- Per-plugin OG tags -> Task 4, tested in Task 6. Covered. +- Admin token gate, edit any field, hide/show, channel promotion, draft creation -> Task 5. Covered. +- Error handling (D1 down, bad channel target, bad token) -> `upsertOverride`/`setChannel` don't special-case D1-down (D1 is either up or the whole Worker request fails - Cloudflare's D1 binding doesn't have a distinct "unreachable but rest of Worker still runs" mode within a single request the way an external HTTP call would); the channel-target and token checks are both implemented in Task 5 exactly as specced. +- Testing -> Task 6. + +**Placeholder scan:** No TBD/TODO. The one open point (Next's per-plugin deep-link scheme) is explicitly flagged as a known unknown with a concrete, correct fallback, not a placeholder - Task 4's note explains why and what to do if it's resolved later. + +**Type consistency:** `Override` type defined once in `db.ts` (Task 2), imported everywhere else that needs it (Task 4's `renderDetailPage`, Task 5's `routes.ts`). `BaseIndex`/`IndexPlugin`/`IndexVersion` defined once in `publicIndex.ts` (Task 2), imported by Tasks 3-5. `Env` defined once in `env.ts` (Task 1), imported everywhere. No renamed/drifted signatures across tasks.