From e4f68d56c79d0ef0d56b3c537e549dfdcbb57f0d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 09:57:02 +0000 Subject: [PATCH 1/2] feat(marketplace): publish skills/profiles/MCPs from your PC via API token Adds the missing "push to marketplace" path so a user can mint an API token on cuecards.cc and publish from their own machine. Hosted API (web/): - web/lib/db.ts: shared pg pool (singleton, serverless-safe). - web/lib/market.ts: getMarket()/publishMarket(), Postgres-backed, authenticated via auth.api.getSession (session cookie OR Bearer api-key). Install commands are derived server-side (never trusted from the client), every field is clamped/slugged, ids carry a per-user suffix and (user_id, type, name) is unique so re-publishing updates your own item and can't overwrite another user's. Schema is created lazily. - web/api/v1/community.ts: Vercel function (GET list / GET ?mine / POST). - dev-server + check-auth-flow now cover publish + list (the e2e gate). - vite proxy routes /api/v1/community to the auth server in dev. CLI (src/): - cue marketplace login | whoami | publish . - src/lib/cue-credentials.ts: token store (~/.config/cue/credentials.json, 0600), resolution order flag > CUE_API_TOKEN > file; CUE_API_URL / --api override the endpoint. Unit-tested. Studio (web/src/): - Market view: Publish modal now pushes to the hosted API when signed in (falls back to a local draft otherwise), and the browse list merges the community catalog with "yours" flagging. Docs: README "## API" section + updated web/AUTH.md. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0153P8DbBQg1yPQYhBv8JiWg --- README.md | 41 ++++- src/commands/marketplace.ts | 172 ++++++++++++++++++ src/lib/cue-credentials.test.ts | 74 ++++++++ src/lib/cue-credentials.ts | 64 +++++++ web/AUTH.md | 41 ++++- web/api/v1/community.ts | 58 ++++++ web/lib/db.ts | 33 ++++ web/lib/market.ts | 303 ++++++++++++++++++++++++++++++++ web/scripts/check-auth-flow.ts | 29 ++- web/scripts/dev-server.ts | 23 ++- web/src/lib/market-client.ts | 67 +++++++ web/src/studio/views/Market.tsx | 72 ++++++-- web/vite.config.ts | 8 + 13 files changed, 966 insertions(+), 19 deletions(-) create mode 100644 src/lib/cue-credentials.test.ts create mode 100644 src/lib/cue-credentials.ts create mode 100644 web/api/v1/community.ts create mode 100644 web/lib/db.ts create mode 100644 web/lib/market.ts create mode 100644 web/src/lib/market-client.ts diff --git a/README.md b/README.md index a46f425b..f386d185 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,10 @@ cue discover search # find skills on GitHub cue discover install # install one cue lint-skill --fix # validate a SKILL.md +# Marketplace (push your own to cuecards.cc) +cue marketplace login --token # save the API token from the studio → API view +cue marketplace publish profile ship-fast # push a profile / skill / mcp for everyone + # Health cue doctor --fix # diff declared vs actual state, auto-repair cue optimizer # dashboard: skills, MCPs, CLIs, usage per profile @@ -196,6 +200,41 @@ cue failures --propose # let Claude draft profile improvements from failur --- +## API + +cuecards.cc gives every account a free, per-user **API token** and a small HTTP +API. Mint a token in the studio (`cue dashboard` → **API** view, or +[cuecards.cc](https://cuecards.cc)), then use it from your own machine to push +profiles, skills, and MCPs to the community marketplace. + +```bash +# 1. Save the token (verifies it against the server before writing ~/.config/cue/credentials.json) +cue marketplace login --token cue_sk_… # or: export CUE_API_TOKEN=cue_sk_… +cue marketplace whoami # confirm which account you're authenticated as + +# 2. Push something to the marketplace +cue marketplace publish profile ship-fast --tags build,review +cue marketplace publish skill seo-audit --source-url https://github.com/me/skills +cue marketplace publish mcp my-server --desc "internal tooling MCP" +``` + +Authenticate HTTP calls with a Bearer header (the token also works as +`x-api-key`): + +```bash +curl https://cuecards.cc/api/v1/me -H "Authorization: Bearer $CUE_API_TOKEN" +curl https://cuecards.cc/api/v1/community # public community catalog (no auth) +curl https://cuecards.cc/api/v1/community -H "Authorization: Bearer $CUE_API_TOKEN" \ + -X POST -H 'content-type: application/json' \ + -d '{"type":"profile","name":"ship-fast","tags":["build"]}' +``` + +Install commands are **derived server-side** — a submission can never inject an +arbitrary `add` string. See [web/AUTH.md](web/AUTH.md) for the auth model, +self-hosting, and `CUE_API_URL` (point the CLI at a different deployment). + +--- + ## Install options | Path | Command | @@ -239,7 +278,7 @@ No. Everything cue computes — including the per-skill usage bars in `cue optim What does cue NOT do? - It doesn't modify or repackage the Claude Code / Codex binaries. -- It doesn't host a remote marketplace — skills live in your repo or come from open source. +- It doesn't lock you in — skills live in your repo or come from open source; the optional [cuecards.cc marketplace](#api) is just a sharing layer you push to with your own token, never a requirement. - It doesn't coordinate multi-agent runs (that's [colony](https://github.com/recodeee/colony) + [gitguardex](https://github.com/recodeee/gitguardex), layered on top via the parallel-agents tier). diff --git a/src/commands/marketplace.ts b/src/commands/marketplace.ts index 8c9abfd8..f8bbda03 100644 --- a/src/commands/marketplace.ts +++ b/src/commands/marketplace.ts @@ -1030,6 +1030,162 @@ async function cmdDiscover( return 0; } +// --------------------------------------------------------------------------- +// Hosted marketplace: login / whoami / publish +// +// "Get an API token on cuecards.cc, then push your skills / profiles / mcps +// from your own machine." These talk to the hosted API (not Smithery), using +// the user's bearer token. Install commands are derived server-side, so a +// submission can never inject an arbitrary install string. +// --------------------------------------------------------------------------- + +const PUBLISH_TYPES = ["profile", "workflow", "skill", "cli", "mcp", "plugin"] as const; +type PublishType = (typeof PUBLISH_TYPES)[number]; + +/** Pull the value of `--flag value` out of an arg list (returns null if absent). */ +function flagValue(args: string[], flag: string): string | null { + const i = args.indexOf(flag); + return i >= 0 && args[i + 1] && !args[i + 1]!.startsWith("--") ? args[i + 1]! : null; +} + +async function apiFetch( + url: string, + token: string | null, + init: { method?: string; body?: unknown } = {}, +): Promise<{ status: number; json: any }> { + const headers: Record = { "content-type": "application/json" }; + if (token) headers.authorization = `Bearer ${token}`; + const res = await fetch(url, { + method: init.method ?? "GET", + headers, + body: init.body !== undefined ? JSON.stringify(init.body) : undefined, + }); + let json: any = null; + try { json = await res.json(); } catch { /* non-JSON */ } + return { status: res.status, json }; +} + +/** `cue marketplace login --token ` — save the token to ~/.config/cue. */ +async function cmdLogin(args: string[]): Promise { + const { resolveApiUrl, saveCredentials, loadCredentials } = await import("../lib/cue-credentials"); + const token = flagValue(args, "--token") ?? process.env.CUE_API_TOKEN ?? null; + if (!token) { + process.stderr.write( + "Usage: cue marketplace login --token \n\n" + + "Create a token in the cuecards.cc studio → API view, then paste it here.\n" + + "(or set CUE_API_TOKEN in your environment.)\n", + ); + return 1; + } + const apiUrl = resolveApiUrl(loadCredentials()); + + // Verify the token works before persisting it. + const me = await apiFetch(`${apiUrl}/api/v1/me`, token); + if (me.status !== 200 || !me.json?.ok) { + process.stderr.write(`${red("✗")} token rejected by ${apiUrl} (HTTP ${me.status}). Not saved.\n`); + return 1; + } + const path = saveCredentials({ apiUrl, token }); + process.stdout.write(`${green("✓")} logged in as ${bold(me.json.data.email)} — token saved to ${dim(path)}\n`); + return 0; +} + +/** `cue marketplace whoami` — print the authenticated account. */ +async function cmdWhoami(args: string[]): Promise { + const { resolveApiUrl, resolveToken, loadCredentials } = await import("../lib/cue-credentials"); + const token = resolveToken(flagValue(args, "--token")); + const apiUrl = resolveApiUrl(loadCredentials()); + if (!token) { + process.stderr.write(`${yellow("not logged in")} — run ${bold("cue marketplace login --token ")}\n`); + return 1; + } + const me = await apiFetch(`${apiUrl}/api/v1/me`, token); + if (me.status !== 200 || !me.json?.ok) { + process.stderr.write(`${red("✗")} token invalid or expired (HTTP ${me.status}).\n`); + return 1; + } + process.stdout.write(`${green("✓")} ${bold(me.json.data.email)} ${dim(`(${me.json.data.id})`)} @ ${apiUrl}\n`); + return 0; +} + +/** Best-effort description for a local profile (used when --desc is omitted). */ +async function deriveProfileDescription(name: string): Promise { + try { + const { loadProfile } = await import("../lib/profile-loader"); + const p = await loadProfile(name); + return p.description ?? ""; + } catch { return ""; } +} + +/** + * `cue marketplace publish ` — push one item to the marketplace. + * Flags: --desc, --tags a,b,c, --source-url, --token, --api. + */ +async function cmdPublish(args: string[], json: boolean): Promise { + const { resolveApiUrl, resolveToken, loadCredentials } = await import("../lib/cue-credentials"); + + // type + name are the first two non-flag args that aren't the value of a + // preceding --flag. + const flagsWithValues = new Set(["--desc", "--tags", "--source-url", "--token", "--api"]); + const pos: string[] = []; + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + if (a.startsWith("--")) { if (flagsWithValues.has(a)) i++; continue; } + pos.push(a); + } + + const type = pos[0] as PublishType | undefined; + const name = pos[1]; + if (!type || !PUBLISH_TYPES.includes(type) || !name) { + process.stderr.write( + "Usage: cue marketplace publish [--desc ] [--tags a,b,c] [--source-url ]\n\n" + + ` one of: ${PUBLISH_TYPES.join(", ")}\n` + + " the profile / skill / mcp name to publish\n\n" + + "Examples:\n" + + " cue marketplace publish profile ship-fast --tags build,review\n" + + " cue marketplace publish skill seo-audit --source-url https://github.com/me/skills\n", + ); + return 1; + } + + const token = resolveToken(flagValue(args, "--token")); + if (!token) { + process.stderr.write( + `${yellow("not logged in")} — run ${bold("cue marketplace login --token ")} first\n` + + `(create the token in the cuecards.cc studio → API view, or set CUE_API_TOKEN).\n`, + ); + return 1; + } + + const apiUrl = (flagValue(args, "--api") ?? resolveApiUrl(loadCredentials())).replace(/\/+$/, ""); + let description = flagValue(args, "--desc") ?? ""; + if (!description && type === "profile") description = await deriveProfileDescription(name); + const tags = (flagValue(args, "--tags") ?? "").split(",").map((t) => t.trim()).filter(Boolean); + const sourceUrl = flagValue(args, "--source-url") ?? undefined; + + if (!json) process.stdout.write(`Publishing ${bold(type)} "${bold(name)}" to ${apiUrl}…\n`); + + const res = await apiFetch(`${apiUrl}/api/v1/community`, token, { + method: "POST", + body: { type, name, description, tags, sourceUrl }, + }); + + if (res.status !== 200 || !res.json?.ok) { + const err = res.json?.error ?? `HTTP ${res.status}`; + if (json) process.stdout.write(JSON.stringify({ ok: false, error: err }) + "\n"); + else process.stderr.write(`${red("✗ publish failed:")} ${err}\n`); + return 1; + } + + if (json) { process.stdout.write(JSON.stringify(res.json.data, null, 2) + "\n"); return 0; } + + const item = res.json.data; + process.stdout.write(`\n${green("✓ published")} as ${bold(item.handle + "/" + item.name)}\n`); + process.stdout.write(` install: ${dim(item.add)}\n`); + process.stdout.write(` it's now in the community marketplace — anyone running cue can find and install it.\n\n`); + return 0; +} + export async function run(args: string[]): Promise { if (args.includes("-h") || args.includes("--help")) { process.stdout.write(`cue marketplace — search and install MCPs + skills @@ -1058,10 +1214,20 @@ Subcommands: list-tools [conn] List tools from connected MCPs find-tools Search tools by intent +Publish (push to the hosted marketplace at cuecards.cc): + login --token Save your API token (create it in the studio → API view, + or set CUE_API_TOKEN). Verifies before saving. + whoami Show the account your token authenticates as. + publish Push a profile / skill / mcp to the marketplace. + Flags: --desc , --tags a,b,c, --source-url , + --token , --api . Types: ${PUBLISH_TYPES.join(", ")}. + Examples: cue marketplace search "github" cue marketplace install-mcp exa cue marketplace search-skills "kubernetes" + cue marketplace login --token cue_sk_… + cue marketplace publish profile ship-fast --tags build,review `); return 0; } @@ -1087,6 +1253,12 @@ Examples: return cmdListTools(rest[1] ?? "", json); case "find-tools": return cmdFindTools(rest.slice(1).join(" ") || "", json); + case "login": + return cmdLogin(rest.slice(1)); + case "whoami": + return cmdWhoami(rest.slice(1)); + case "publish": + return cmdPublish(rest.slice(1), json); case "discover": { const previewIdx = rest.indexOf("--pr-preview"); if (previewIdx >= 0) { diff --git a/src/lib/cue-credentials.test.ts b/src/lib/cue-credentials.test.ts new file mode 100644 index 00000000..6b20a145 --- /dev/null +++ b/src/lib/cue-credentials.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + DEFAULT_API_URL, + loadCredentials, + resolveApiUrl, + resolveToken, + saveCredentials, +} from "./cue-credentials"; + +let dir: string; +let prevXdg: string | undefined; +let prevToken: string | undefined; +let prevUrl: string | undefined; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "cue-creds-")); + prevXdg = process.env.XDG_CONFIG_HOME; + prevToken = process.env.CUE_API_TOKEN; + prevUrl = process.env.CUE_API_URL; + process.env.XDG_CONFIG_HOME = dir; // configDir() → /cue + delete process.env.CUE_API_TOKEN; + delete process.env.CUE_API_URL; +}); + +afterEach(() => { + if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = prevXdg; + if (prevToken === undefined) delete process.env.CUE_API_TOKEN; else process.env.CUE_API_TOKEN = prevToken; + if (prevUrl === undefined) delete process.env.CUE_API_URL; else process.env.CUE_API_URL = prevUrl; + rmSync(dir, { recursive: true, force: true }); +}); + +describe("cue-credentials", () => { + test("returns null when nothing is saved", () => { + expect(loadCredentials()).toBeNull(); + expect(resolveToken()).toBeNull(); + }); + + test("saves and reloads a token", () => { + const path = saveCredentials({ apiUrl: "https://cuecards.cc", token: "cue_sk_abc" }); + expect(path).toContain("credentials.json"); + const creds = loadCredentials(); + expect(creds?.token).toBe("cue_sk_abc"); + expect(resolveToken()).toBe("cue_sk_abc"); + }); + + test("writes the credentials file with 0600 perms", () => { + const path = saveCredentials({ apiUrl: DEFAULT_API_URL, token: "secret" }); + const mode = statSync(path).mode & 0o777; + expect(mode).toBe(0o600); + // sanity: the secret is actually persisted + expect(readFileSync(path, "utf8")).toContain("secret"); + }); + + test("resolution order: flag > env > file", () => { + saveCredentials({ apiUrl: DEFAULT_API_URL, token: "from-file" }); + expect(resolveToken("from-flag")).toBe("from-flag"); + process.env.CUE_API_TOKEN = "from-env"; + expect(resolveToken()).toBe("from-env"); + expect(resolveToken("from-flag")).toBe("from-flag"); + delete process.env.CUE_API_TOKEN; + expect(resolveToken()).toBe("from-file"); + }); + + test("resolveApiUrl honors env, then saved, then default; trims trailing slash", () => { + expect(resolveApiUrl(null)).toBe(DEFAULT_API_URL); + expect(resolveApiUrl({ apiUrl: "https://my.host/", token: "t" })).toBe("https://my.host"); + process.env.CUE_API_URL = "http://localhost:3000/"; + expect(resolveApiUrl({ apiUrl: "https://my.host", token: "t" })).toBe("http://localhost:3000"); + }); +}); diff --git a/src/lib/cue-credentials.ts b/src/lib/cue-credentials.ts new file mode 100644 index 00000000..697b9268 --- /dev/null +++ b/src/lib/cue-credentials.ts @@ -0,0 +1,64 @@ +/** + * Credentials for the hosted cuecards.cc API — the token a user mints in the + * studio's "API" view (BetterAuth apiKey) and uses from their own machine to + * publish skills / profiles / mcps to the marketplace. + * + * Resolution order for the token (first hit wins): + * 1. an explicit `--token ` flag (handled by the caller) + * 2. the `CUE_API_TOKEN` environment variable + * 3. `~/.config/cue/credentials.json` (written by `cue marketplace login`) + * + * The file is written with 0600 perms — it holds a bearer secret. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { configDir } from "./config-paths"; + +/** Public site, overridable for self-hosting / local dev via CUE_API_URL. */ +export const DEFAULT_API_URL = "https://cuecards.cc"; + +export interface Credentials { + apiUrl: string; + token: string; +} + +function credentialsPath(): string { + return join(configDir(), "credentials.json"); +} + +/** The API base URL: `CUE_API_URL` env > saved file > default. */ +export function resolveApiUrl(saved?: Credentials | null): string { + const env = process.env.CUE_API_URL; + if (env && env.length > 0) return env.replace(/\/+$/, ""); + if (saved?.apiUrl) return saved.apiUrl.replace(/\/+$/, ""); + return DEFAULT_API_URL; +} + +export function loadCredentials(): Credentials | null { + const path = credentialsPath(); + if (!existsSync(path)) return null; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; + if (typeof parsed.token === "string" && parsed.token.length > 0) { + return { apiUrl: parsed.apiUrl ?? DEFAULT_API_URL, token: parsed.token }; + } + } catch { /* corrupt file → treat as unauthenticated */ } + return null; +} + +export function saveCredentials(creds: Credentials): string { + const dir = configDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const path = credentialsPath(); + writeFileSync(path, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 }); + return path; +} + +/** Resolve the active token: flag > env > saved file. */ +export function resolveToken(flagToken?: string | null): string | null { + if (flagToken && flagToken.length > 0) return flagToken; + const env = process.env.CUE_API_TOKEN; + if (env && env.length > 0) return env; + return loadCredentials()?.token ?? null; +} diff --git a/web/AUTH.md b/web/AUTH.md index f8325c59..12a24494 100644 --- a/web/AUTH.md +++ b/web/AUTH.md @@ -9,7 +9,8 @@ functions, same-origin with the SPA. cuecards.cc ├─ / Vite SPA (cue studio) — the "API tokens" rail view ├─ /api/auth/* BetterAuth: sign-up, sign-in, sign-out, session, api-key/* -└─ /api/v1/me returns the caller (session cookie OR Bearer token) +├─ /api/v1/me returns the caller (session cookie OR Bearer token) +└─ /api/v1/community community marketplace: GET (public list) + POST (publish) ``` ## Environment @@ -81,6 +82,44 @@ curl https://cuecards.cc/api/v1/me \ Tokens also accept the `x-api-key` header. Each token is rate limited to 120 requests/minute by default (configurable in `lib/auth.ts`). +## Publishing to the marketplace + +`/api/v1/community` is the "push from your PC" surface. Users mint a token in the +API view, then publish profiles / skills / MCPs — from the studio's **Publish** +modal (session cookie) or `cue marketplace publish` (Bearer token). + +```bash +# list the public community catalog (no auth) +curl https://cuecards.cc/api/v1/community + +# your own items, any status (auth) +curl https://cuecards.cc/api/v1/community?mine=1 -H "Authorization: Bearer " + +# publish / re-publish one item (auth) +curl -X POST https://cuecards.cc/api/v1/community \ + -H "Authorization: Bearer " -H 'content-type: application/json' \ + -d '{"type":"profile","name":"ship-fast","description":"…","tags":["build","review"]}' +``` + +Server behavior (`web/lib/market.ts`): + +- **Auth** via `auth.api.getSession` — same path as `/api/v1/me`, so a session + cookie or a Bearer api-key both work. +- **The install command is derived server-side** (`cue add /`, + `cue marketplace install-mcp `, …) and never read from the request, so a + submission can't smuggle a `curl … | bash` into someone's dashboard. +- Every field is length-clamped; `name`/tags are slugged; `sourceUrl` must be + `https://`. The `id` embeds a per-user suffix and `(user_id, type, name)` is + unique, so re-publishing updates your own item and no one can overwrite + another user's. +- Items default to `status = 'approved'` (frictionless, matching free signup). + The `status` column is the hook to add moderation later without a schema + change. + +The table (`market_item`) is created lazily on first use, so no extra migration +step is required beyond BetterAuth's. The CLI defaults to `https://cuecards.cc`; +point it elsewhere with `CUE_API_URL` (env) or `--api `. + ## Deploy (Vercel + Neon) 1. Create a Neon project; copy the **pooled** connection string. diff --git a/web/api/v1/community.ts b/web/api/v1/community.ts new file mode 100644 index 00000000..5f58f147 --- /dev/null +++ b/web/api/v1/community.ts @@ -0,0 +1,58 @@ +/** + * Vercel function: the community marketplace API. + * + * GET /api/v1/community -> approved community submissions + * GET /api/v1/community?mine=1 -> the caller's own items (any status) + * POST /api/v1/community -> publish/re-publish one item + * + * Auth: session cookie OR `Authorization: Bearer ` (GET?mine + POST). + * The real work lives in `lib/market.ts`; this wrapper only adapts Node + * req/res to web `Headers` and reads the JSON body. + */ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { fromNodeHeaders } from "better-auth/node"; + +import { getMarket, publishMarket, type PublishInput } from "../../lib/market"; + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let data = ""; + req.on("data", (c) => { data += c; if (data.length > 1_000_000) req.destroy(); }); + req.on("end", () => resolve(data)); + req.on("error", reject); + }); +} + +export default async function handler( + req: IncomingMessage & { url?: string }, + res: ServerResponse, +): Promise { + const headers = fromNodeHeaders(req.headers); + res.setHeader("content-type", "application/json"); + + if (req.method === "GET") { + const mine = /[?&]mine=1\b/.test(req.url ?? ""); + const { status, body } = await getMarket(headers, { mine }); + res.statusCode = status; + res.end(JSON.stringify(body)); + return; + } + + if (req.method === "POST") { + let input: PublishInput; + try { + input = JSON.parse((await readBody(req)) || "{}") as PublishInput; + } catch { + res.statusCode = 400; + res.end(JSON.stringify({ ok: false, error: "invalid-json" })); + return; + } + const { status, body } = await publishMarket(headers, input); + res.statusCode = status; + res.end(JSON.stringify(body)); + return; + } + + res.statusCode = 405; + res.end(JSON.stringify({ ok: false, error: "method-not-allowed" })); +} diff --git a/web/lib/db.ts b/web/lib/db.ts new file mode 100644 index 00000000..26b6de7a --- /dev/null +++ b/web/lib/db.ts @@ -0,0 +1,33 @@ +/** + * Shared Postgres pool for server-side code that isn't BetterAuth itself + * (BetterAuth owns its own pool inside `lib/auth.ts`). Everything that needs + * raw SQL — the marketplace submission store — goes through this singleton so + * a single function instance never opens more connections than it has to. + * + * Server-only. Never import from `web/src/*` (the browser bundle). + */ +import { Pool } from "pg"; + +function required(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`missing required env var: ${name}`); + return value; +} + +let pool: Pool | null = null; + +/** + * Lazily create (and reuse) the process-wide pool. Defaults to a single + * connection per instance — Neon's pooler endpoint in `DATABASE_URL` provides + * real concurrency — matching the discipline in `lib/auth.ts`. Raise via + * `PG_POOL_MAX` for the long-lived local dev server. + */ +export function getPool(): Pool { + if (!pool) { + pool = new Pool({ + connectionString: required("DATABASE_URL"), + max: Number(process.env.PG_POOL_MAX ?? 1), + }); + } + return pool; +} diff --git a/web/lib/market.ts b/web/lib/market.ts new file mode 100644 index 00000000..44f83dac --- /dev/null +++ b/web/lib/market.ts @@ -0,0 +1,303 @@ +/** + * Marketplace publish + list logic, shared between transports (the Bun dev + * server and the Vercel Node function), exactly like `lib/me.ts`. + * + * The "push from your PC" goal: + * - A user mints an API token in the studio (BetterAuth apiKey plugin). + * - `cue marketplace publish …` (or the studio Publish modal) POSTs a + * profile / skill / mcp here with `Authorization: Bearer `. + * - We authenticate via `auth.api.getSession` (works for both session + * cookies and Bearer api-keys, thanks to `enableSessionForAPIKeys`), + * validate + clamp the payload, and upsert it into Postgres. + * + * Security stance (deliberate): + * - The install command is DERIVED server-side, never taken from the client, + * so a submission can't smuggle `curl … | bash` into someone else's + * dashboard. + * - Every text field is length-clamped; tags are capped and slugged. + * - `id` embeds a per-user suffix so two users can't overwrite each other, + * and a unique (user_id, type, name) index makes re-publishing an update. + */ +import { createHash } from "node:crypto"; + +import { auth } from "./auth"; +import { getPool } from "./db"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export const MARKET_TYPES = ["profile", "workflow", "skill", "cli", "mcp", "plugin"] as const; +export type MarketType = (typeof MARKET_TYPES)[number]; + +/** The fields a client may submit. Everything else is derived or server-set. */ +export interface PublishInput { + type: string; + name: string; + description?: string; + tags?: string[]; + sourceUrl?: string; +} + +/** One marketplace item, byte-compatible with the studio's `MarketItem`. */ +export interface MarketItem { + id: string; + type: MarketType; + name: string; + author: string; + handle: string; + stars: number; + installs: string; + when: string; + featured: boolean; + desc: string; + tags: string[]; + source: "registry" | "local"; + add: string; + addKind: MarketType; + sourceUrl?: string; + status?: string; +} + +export type Result = + | { status: number; body: { ok: true; data: T } } + | { status: number; body: { ok: false; error: string } }; + +// --------------------------------------------------------------------------- +// Schema (idempotent — runs lazily on first use so a fresh DB just works) +// --------------------------------------------------------------------------- + +let schemaReady: Promise | null = null; + +function ensureSchema(): Promise { + if (!schemaReady) { + schemaReady = (async () => { + const pool = getPool(); + await pool.query(` + CREATE TABLE IF NOT EXISTS market_item ( + id text PRIMARY KEY, + user_id text NOT NULL, + handle text NOT NULL, + type text NOT NULL, + name text NOT NULL, + description text NOT NULL DEFAULT '', + tags text[] NOT NULL DEFAULT '{}', + source_url text, + status text NOT NULL DEFAULT 'approved', + stars integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ); + `); + await pool.query( + `CREATE UNIQUE INDEX IF NOT EXISTS market_item_owner_uidx + ON market_item (user_id, type, name);`, + ); + })().catch((err) => { + // Reset so a transient failure (cold DB) can be retried next call. + schemaReady = null; + throw err; + }); + } + return schemaReady; +} + +// --------------------------------------------------------------------------- +// Validation + helpers +// --------------------------------------------------------------------------- + +const MAX = { name: 64, desc: 500, tag: 24, tags: 8, url: 300, handle: 48 } as const; + +/** lower-kebab slug, used for ids and to normalize handles/names. */ +function slug(s: string): string { + return s + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, MAX.name); +} + +function clampStr(s: unknown, max: number): string { + return (typeof s === "string" ? s : "").trim().slice(0, max); +} + +/** Short stable per-user suffix so ids never collide across accounts. */ +function userSuffix(userId: string): string { + return createHash("sha256").update(userId).digest("hex").slice(0, 6); +} + +/** Compact relative time like the studio expects: "now", "5h", "3d", "2w". */ +function relWhen(d: Date): string { + const secs = Math.max(0, (Date.now() - d.getTime()) / 1000); + if (secs < 90 * 60) return "now"; + const hours = secs / 3600; + if (hours < 36) return `${Math.round(hours)}h`; + const days = hours / 24; + if (days < 14) return `${Math.round(days)}d`; + return `${Math.round(days / 7)}w`; +} + +/** + * Derive the safe install / "add" command for an item. NEVER trust a + * client-supplied command — that's the whole point. The shapes mirror the + * studio's existing copy-install affordance and the `cue` CLI surface. + */ +function deriveAdd(type: MarketType, handle: string, name: string): string { + const ref = `${handle}/${name}`; + switch (type) { + case "mcp": return `cue marketplace install-mcp ${name}`; + case "skill": return `cue marketplace install-skill ${ref}`; + default: return `cue add ${ref}`; + } +} + +interface ValidInput { + type: MarketType; + name: string; + description: string; + tags: string[]; + sourceUrl: string | null; +} + +function validate(input: PublishInput): { ok: true; value: ValidInput } | { ok: false; error: string } { + const type = clampStr(input.type, 16) as MarketType; + if (!MARKET_TYPES.includes(type)) { + return { ok: false, error: `invalid type (expected one of: ${MARKET_TYPES.join(", ")})` }; + } + const name = slug(clampStr(input.name, MAX.name)); + if (!name) return { ok: false, error: "name is required (lowercase letters, digits, dashes)" }; + + const description = clampStr(input.description, MAX.desc); + + const rawTags = Array.isArray(input.tags) ? input.tags : []; + const tags = [...new Set(rawTags.map((t) => slug(clampStr(t, MAX.tag))).filter(Boolean))].slice(0, MAX.tags); + + let sourceUrl: string | null = null; + const url = clampStr(input.sourceUrl, MAX.url); + if (url) { + if (!/^https:\/\/[^\s]+$/i.test(url)) { + return { ok: false, error: "sourceUrl must be an https:// URL" }; + } + sourceUrl = url; + } + + return { ok: true, value: { type, name, description, tags, sourceUrl } }; +} + +// --------------------------------------------------------------------------- +// Row → MarketItem +// --------------------------------------------------------------------------- + +interface Row { + id: string; + handle: string; + type: MarketType; + name: string; + description: string; + tags: string[]; + source_url: string | null; + status: string; + stars: number; + created_at: Date; +} + +function rowToItem(r: Row): MarketItem { + return { + id: r.id, + type: r.type, + name: r.name, + author: r.handle, + handle: r.handle, + stars: r.stars, + installs: "0", + when: relWhen(new Date(r.created_at)), + featured: false, + desc: r.description, + tags: r.tags ?? [], + source: "registry", + add: deriveAdd(r.type, r.handle, r.name), + addKind: r.type, + ...(r.source_url ? { sourceUrl: r.source_url } : {}), + status: r.status, + }; +} + +// --------------------------------------------------------------------------- +// Public operations +// --------------------------------------------------------------------------- + +const SELECT = ` + SELECT id, handle, type, name, description, tags, source_url, status, stars, created_at + FROM market_item`; + +/** + * GET /api/v1/market — the community-published catalog. Returns approved items + * for everyone; when authenticated and `mine` is set, returns the caller's own + * items regardless of status so they can see pending/just-published entries. + */ +export async function getMarket(headers: Headers, opts: { mine?: boolean } = {}): Promise> { + try { + await ensureSchema(); + const pool = getPool(); + + if (opts.mine) { + const session = await auth.api.getSession({ headers }); + if (!session) return { status: 401, body: { ok: false, error: "unauthorized" } }; + const res = await pool.query( + `${SELECT} WHERE user_id = $1 ORDER BY created_at DESC LIMIT 200`, + [session.user.id], + ); + return { status: 200, body: { ok: true, data: { items: res.rows.map(rowToItem) } } }; + } + + const res = await pool.query( + `${SELECT} WHERE status = 'approved' ORDER BY stars DESC, created_at DESC LIMIT 500`, + ); + return { status: 200, body: { ok: true, data: { items: res.rows.map(rowToItem) } } }; + } catch (err) { + return { status: 500, body: { ok: false, error: (err as Error).message } }; + } +} + +/** + * POST /api/v1/market — publish (or re-publish) one item. Authenticated via + * session cookie OR `Authorization: Bearer `. + */ +export async function publishMarket(headers: Headers, input: PublishInput): Promise> { + let session: Awaited>; + try { + session = await auth.api.getSession({ headers }); + } catch (err) { + return { status: 500, body: { ok: false, error: (err as Error).message } }; + } + if (!session) return { status: 401, body: { ok: false, error: "unauthorized" } }; + + const checked = validate(input ?? {}); + if (!checked.ok) return { status: 400, body: { ok: false, error: checked.error } }; + const { type, name, description, tags, sourceUrl } = checked.value; + + const userId = session.user.id; + const handle = slug(session.user.name || session.user.email.split("@")[0] || "anon").slice(0, MAX.handle) || "anon"; + const id = `${type}:${name}-${userSuffix(userId)}`; + + try { + await ensureSchema(); + const pool = getPool(); + const res = await pool.query( + `INSERT INTO market_item (id, user_id, handle, type, name, description, tags, source_url) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (user_id, type, name) DO UPDATE + SET description = EXCLUDED.description, + tags = EXCLUDED.tags, + source_url = EXCLUDED.source_url, + handle = EXCLUDED.handle, + updated_at = now() + RETURNING id, handle, type, name, description, tags, source_url, status, stars, created_at`, + [id, userId, handle, type, name, description, tags, sourceUrl], + ); + return { status: 200, body: { ok: true, data: rowToItem(res.rows[0]!) } }; + } catch (err) { + return { status: 500, body: { ok: false, error: (err as Error).message } }; + } +} diff --git a/web/scripts/check-auth-flow.ts b/web/scripts/check-auth-flow.ts index 6dc9dea6..8239d973 100644 --- a/web/scripts/check-auth-flow.ts +++ b/web/scripts/check-auth-flow.ts @@ -7,6 +7,8 @@ * 2. POST /api/auth/sign-in/email -> 200, session cookie set * 3. POST /api/auth/api-key/create -> 200, returns key (shown once) * 4. GET /api/v1/me (Bearer ) -> 200, email matches the new user + * 5. POST /api/v1/community (Bearer) -> 200, publishes a profile + * 6. GET /api/v1/community -> 200, the new item is listed * * Env: BASE (default http://localhost:3000). * Run: bun scripts/check-auth-flow.ts @@ -91,7 +93,32 @@ async function main(): Promise { } console.log(`ok ${BURST}× Bearer -> all 200 (per-key rate limit is generous, not 10/day)`); - console.log("\nPASS full auth flow: register -> login -> token -> Bearer /me (+burst)"); + // 6. Publish a marketplace item with the Bearer token — the "push from your + // PC" path the CLI uses. Then confirm it shows up in the public catalog. + const profileName = `check-profile-${Date.now()}`; + res = await fetch(`${BASE}/api/v1/community`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}`, origin }, + body: JSON.stringify({ type: "profile", name: profileName, description: "e2e check profile", tags: ["check", "e2e"] }), + }); + if (res.status !== 200) fail("market/publish", `status ${res.status}: ${await res.text()}`); + const pub = (await res.json()) as { ok: boolean; data?: { id?: string; name?: string; add?: string } }; + if (!pub.ok || !pub.data?.id) fail("market/publish", `unexpected body: ${JSON.stringify(pub)}`); + if (pub.data.add?.includes("curl") || pub.data.add?.includes("|")) { + fail("market/publish", `install command should be server-derived + safe, got: ${pub.data.add}`); + } + console.log(`ok market/publish -> 200 (${pub.data.name}, add: ${pub.data.add})`); + + // 7. The item is now in the public list. + res = await fetch(`${BASE}/api/v1/community`, { headers: { origin } }); + if (res.status !== 200) fail("market/list", `status ${res.status}: ${await res.text()}`); + const list = (await res.json()) as { ok: boolean; data?: { items?: Array<{ id: string }> } }; + if (!list.ok || !list.data?.items?.some((i) => i.id === pub.data!.id)) { + fail("market/list", `published item ${pub.data.id} not found in catalog`); + } + console.log(`ok market/list -> 200 (catalog includes the new item)`); + + console.log("\nPASS full flow: register -> login -> token -> /me -> publish -> list"); } main().catch((err) => fail("uncaught", String(err))); diff --git a/web/scripts/dev-server.ts b/web/scripts/dev-server.ts index 5d6168ab..c2035673 100644 --- a/web/scripts/dev-server.ts +++ b/web/scripts/dev-server.ts @@ -4,21 +4,24 @@ * BetterAuth instance the Vercel functions use, so a green check here means the * real code paths work — only the transport (Bun vs Vercel Node) differs. * - * /api/auth/* -> BetterAuth (sign-up, sign-in, session, api-key/*) - * /api/v1/me -> shared getMe() + * /api/auth/* -> BetterAuth (sign-up, sign-in, session, api-key/*) + * /api/v1/me -> shared getMe() + * /api/v1/community -> shared getMarket() / publishMarket() * * Env: DATABASE_URL, BETTER_AUTH_SECRET, optional PORT (default 3000). * Run: bun scripts/dev-server.ts */ import { auth } from "../lib/auth"; import { getMe } from "../lib/me"; +import { getMarket, publishMarket, type PublishInput } from "../lib/market"; const port = Number(process.env.PORT ?? 3000); const server = Bun.serve({ port, async fetch(req) { - const { pathname } = new URL(req.url); + const url = new URL(req.url); + const { pathname } = url; if (pathname.startsWith("/api/auth")) { return auth.handler(req); } @@ -26,6 +29,20 @@ const server = Bun.serve({ const { status, body } = await getMe(req.headers); return Response.json(body, { status }); } + if (pathname === "/api/v1/community") { + if (req.method === "GET") { + const { status, body } = await getMarket(req.headers, { mine: url.searchParams.get("mine") === "1" }); + return Response.json(body, { status }); + } + if (req.method === "POST") { + let input: PublishInput; + try { input = (await req.json()) as PublishInput; } + catch { return Response.json({ ok: false, error: "invalid-json" }, { status: 400 }); } + const { status, body } = await publishMarket(req.headers, input); + return Response.json(body, { status }); + } + return Response.json({ ok: false, error: "method-not-allowed" }, { status: 405 }); + } return new Response("not found", { status: 404 }); }, }); diff --git a/web/src/lib/market-client.ts b/web/src/lib/market-client.ts new file mode 100644 index 00000000..183cd151 --- /dev/null +++ b/web/src/lib/market-client.ts @@ -0,0 +1,67 @@ +/** + * Client for the HOSTED community marketplace (`/api/v1/community`) — the + * publish/push surface, distinct from the local dashboard's read-only browse + * catalog (`/api/v1/market`, via `studio/api.ts`). + * + * Always talks to the same origin (Vercel function in prod, the Vite auth + * proxy in dev). Auth is the BetterAuth session cookie, so `credentials: + * "include"` is enough — the same token a `cue marketplace publish` call sends + * as a Bearer header. Reads fail SOFT (empty list) so a local `cue dashboard` + * that doesn't serve this route never breaks the Market view. + */ +import { useQuery } from "@tanstack/react-query"; + +import type { MarketItem } from "../studio/api"; + +export interface PublishInput { + type: MarketItem["type"]; + name: string; + description?: string; + tags?: string[]; + sourceUrl?: string; +} + +type Envelope = { ok: true; data: T } | { ok: false; error: string }; + +/** Fetch the approved community catalog. Returns [] on any error. */ +export async function fetchCommunity(): Promise { + try { + const res = await fetch("/api/v1/community", { credentials: "include" }); + const env = (await res.json()) as Envelope<{ items: MarketItem[] }>; + return env.ok ? env.data.items : []; + } catch { + return []; + } +} + +/** Publish one item. Throws with the server error message on failure. */ +export async function publishCommunity(input: PublishInput): Promise { + let res: Response; + try { + res = await fetch("/api/v1/community", { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }); + } catch (err) { + throw new Error(`couldn't reach the marketplace: ${(err as Error).message}`); + } + let env: Envelope; + try { + env = (await res.json()) as Envelope; + } catch { + throw new Error(`marketplace returned non-JSON (HTTP ${res.status})`); + } + if (!env.ok) throw new Error(env.error); + return env.data; +} + +/** React Query hook for the community catalog (caches a minute). */ +export function useCommunityMarket() { + return useQuery({ + queryKey: ["community-market"], + queryFn: fetchCommunity, + staleTime: 60_000, + }); +} diff --git a/web/src/studio/views/Market.tsx b/web/src/studio/views/Market.tsx index b11ea3ea..29aea373 100644 --- a/web/src/studio/views/Market.tsx +++ b/web/src/studio/views/Market.tsx @@ -12,8 +12,11 @@ */ import { useEffect, useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { useMarket, useProfilesFull, type MarketItem } from "../api"; +import { useCommunityMarket, publishCommunity } from "../../lib/market-client"; +import { useSession } from "../../lib/auth-client"; // A locally-published draft is a MarketItem with the extra "yours" marker. Kept // in localStorage and prepended to the browse list before the live catalog. @@ -61,6 +64,9 @@ function daysAgo(when: string): number { export function MarketView() { const { data } = useMarket(); + const community = useCommunityMarket(); + const { data: session } = useSession(); + const queryClient = useQueryClient(); const profilesQ = useProfilesFull(); const [q, setQ] = useState(""); @@ -92,12 +98,18 @@ export function MarketView() { const toggleStar = (id: string) => setStars((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id])); const flash = (m: string) => { setToast(m); setTimeout(() => setToast(""), 1800); }; - // Browse list: the user's local drafts on top, then the live catalog. Never - // the prototype SEED — a fresh checkout shows exactly what /market returns. - const items: LocalMarketItem[] = useMemo( - () => [...published, ...(data?.items ?? [])], - [published, data], - ); + // Browse list: the user's local drafts on top, then the hosted community + // submissions (what everyone pushed), then this checkout's live catalog. + // Never the prototype SEED — a fresh checkout shows exactly what the APIs + // return. Community items the signed-in user owns are flagged "yours". + const myHandle = session?.user?.name || session?.user?.email?.split("@")[0] || null; + const items: LocalMarketItem[] = useMemo(() => { + const communityItems: LocalMarketItem[] = (community.data ?? []).map((i) => ({ + ...i, + mine: myHandle != null && i.handle === myHandle, + })); + return [...published, ...communityItems, ...(data?.items ?? [])]; + }, [published, community.data, data, myHandle]); const counts = useMemo(() => { const c: Record = { all: items.length }; @@ -250,8 +262,30 @@ export function MarketView() { {pubOpen && ( setPubOpen(false)} - onPublish={(draft) => { + onPublish={async (draft) => { + // Signed in → push to the hosted marketplace so everyone sees it. + if (session) { + try { + await publishCommunity({ + type: draft.type, + name: draft.name, + description: draft.desc, + tags: draft.tags, + }); + await queryClient.invalidateQueries({ queryKey: ["community-market"] }); + setPubOpen(false); + setType("all"); + setQ(""); + setSort("new"); + flash(draft.name + " published to the marketplace ✓"); + } catch (err) { + flash("Publish failed: " + (err as Error).message); + } + return; + } + // Signed out → keep a local-only draft and point them at the API view. const item: LocalMarketItem = { ...draft, id: "u" + Date.now(), @@ -271,7 +305,7 @@ export function MarketView() { setType("all"); setQ(""); setSort("new"); - flash("Published locally ✓ — will open a registry PR"); + flash("Saved locally — sign in (API view) to publish to everyone"); }} /> )} @@ -283,17 +317,29 @@ export function MarketView() { // The publish form yields just the editable fields; the view fills the rest. type PublishDraft = { type: MarketType; name: string; desc: string; tags: string[] }; -function PublishModal({ onClose, onPublish }: { onClose: () => void; onPublish: (draft: PublishDraft) => void }) { +function PublishModal({ signedIn, onClose, onPublish }: { signedIn: boolean; onClose: () => void; onPublish: (draft: PublishDraft) => void | Promise }) { const [type, setType] = useState("profile"); const [name, setName] = useState(""); const [desc, setDesc] = useState(""); const [tags, setTags] = useState(""); + const [busy, setBusy] = useState(false); const valid = name.trim() && desc.trim(); + const submit = async () => { + if (!valid || busy) return; + setBusy(true); + try { + await onPublish({ type, name: name.trim(), desc: desc.trim(), tags: tags.split(",").map((t) => t.trim()).filter(Boolean).slice(0, 4) }); + } finally { + setBusy(false); + } + }; return (
e.stopPropagation()}>
Publish to marketplace ×
-
Share a profile, workflow, skill or CLI with everyone running cue.
+
{signedIn + ? "Share a profile, workflow, skill or CLI with everyone running cue." + : "Sign in from the API view to publish to everyone — otherwise this is saved as a local draft."}
diff --git a/web/vite.config.ts b/web/vite.config.ts index b8a48190..0af44f2e 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -22,6 +22,14 @@ export default defineConfig({ target: process.env.AUTH_TARGET ?? "http://127.0.0.1:3000", changeOrigin: true, }, + // The community marketplace (publish + community catalog) lives on the + // auth server too — it shares BetterAuth's session/token + Postgres. + // Kept distinct from /api/v1/market (the local dashboard's browse + // catalog), so both coexist. + "/api/v1/community": { + target: process.env.AUTH_TARGET ?? "http://127.0.0.1:3000", + changeOrigin: true, + }, "/api": { target: "http://127.0.0.1:7891", changeOrigin: true, From 09aba369d5cba79b9d479902d1ce39b4521a0c3d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 10:27:18 +0000 Subject: [PATCH 2/2] fix(ci): bump resources/skills submodule to current soul-main head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pinned gitlink (4274beae) was force-pushed away on opencue/skills, so actions/checkout failed fetching the submodule on every job — repo-wide, before tests/lint ran. Bump to a452e5d (current soul-main head) so CI can fetch submodules and actually run. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0153P8DbBQg1yPQYhBv8JiWg --- resources/skills | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/skills b/resources/skills index 4274beae..a452e5d2 160000 --- a/resources/skills +++ b/resources/skills @@ -1 +1 @@ -Subproject commit 4274beaece3a38a4a7a85142b2f09f2b23e3ce89 +Subproject commit a452e5d2892d59d766b1927c9d35a6f806bce79e