Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,15 @@ pnpm db:migrate:remote # Apply migrations to remote D1
| `/api/auth/*` | auth-catchall.tsx | Better Auth |
| `/api/search` | api.search.ts | None |
| `/api/skills/:slug` | api.skill-detail.ts | None |
| `/api/skills/:slug/references` | api.skill-references.ts | None (GET), Session/Key (POST) |
| `/api/skills/:slug/rate` | api.skill-rate.ts | Session/Key |
| `/api/skills/:slug/review` | api.skill-review.ts | Session/Key |
| `/api/skills/:slug/favorite` | api.skill-favorite.ts | Session/Key |
| `/api/skills/:slug/rate` | api.skill-rate.ts | Session, same-origin only |
| `/api/skills/:slug/review` | api.skill-review.ts | None (GET), Session same-origin only (POST) |
| `/api/skills/:slug/favorite` | api.skill-favorite.ts | Session, same-origin only |
| `/api/skills/:slug/install` | api.skill-install.ts | Optional (API key or X-Device-Id) |
| `/api/skills/:slug/vote` | api.skill-vote.ts | Session |
| `/api/report` | api.usage-report.ts | Session/Key |
| `/api/skills/:slug/vote` | api.skill-vote.ts | Session, same-origin only |
| `/api/report` | api.usage-report.ts | API key |
| `/api/user/api-keys` | api.user-api-keys.ts | Session |
| `/api/user/interactions` | api.user-interactions.ts | Session (graceful fallback) |
| `/api/skills/register` | api.skill-register.ts | Session/Key |
| `/api/skills/register` | api.skill-register.ts | Session (same-origin only)/Key |
| `/api/admin/seed` | api.admin.seed.ts | Admin secret |
| `/api/v1/packages/:scope/:slug/releases` | api.v1.package-releases.ts (GET list); action re-exported from api.v1.release-publish.ts (POST publish) | GET: optional Key/Session; POST: scoped key `releases:publish:@handle` (404 unless `SKILLX_RELEASES_API` on) |
| `/api/v1/packages/:scope/:slug/releases/:version`, `/api/v1/releases/:digest/manifest` | api.v1.release-manifest.ts | Optional Key/Session (404 unless `SKILLX_RELEASES_API` on) |
Expand Down Expand Up @@ -140,7 +139,7 @@ Search algorithm: `./docs/search-algorithm.md`

**References & Scripts**: Stored separately — `skill_references` table for external docs/links/examples (title, filename, url, type enum, content). Scripts stored as JSON in `skills.scripts` (name, description, language, url). CLI flags: `skillx use --include-refs --include-scripts` to display.

**Vote API**: POST `/api/skills/:slug/vote` with `{ direction: 'up'|'down' }`. Rate limited 10 votes/min per user. Atomic count update via SQL subquery. Bidirectional: same vote direction toggles off.
**Vote API**: POST `/api/skills/:slug/vote` with `{ type: 'up'|'down'|'none' }` (`none` clears the vote). Rate limited 10 votes/min per user. Atomic count update via SQL subquery. Returns `{ vote_type, net_votes }`.

**CLI `skillx use` resolution**: `author/skill` (two-part → DB slug `author-skill`) | `org/repo/skill` (three-part → DB slug `org-skill`, fallback register from GitHub) | `slug` (direct lookup, fallback search) | `"keywords"` (search mode). Flags: `--include-refs`, `--include-scripts` for extended content. Display logic split into `use-display.ts` module.

Expand All @@ -158,6 +157,7 @@ Search algorithm: `./docs/search-algorithm.md`
- **Seed data** in `scripts/seed-data.json` (30 real skills from skills.sh). Seed via `ADMIN_SECRET=... pnpm seed`.
- **Scoped API keys** — `api_keys.scopes` NULL = legacy key; scoped keys (`releases:publish:@handle`) work only on the release publish API. They are stored under `hashScopedApiKey` (domain-separated), so legacy routes (`authenticateApiKey`, by plain key hash) never accept them. Apply migration 0012 to remote D1 before deploying (`docs/deployment-guide.md`).
- **Releases are byte-exact** — the release publish API (`app/lib/releases/`) never sanitizes; the scan only picks `published` vs `quarantined`. R2 writes are create-only, the D1 row is written last. See `docs/api-reference.md#publishing-releases`.
- **Same-origin on cookie mutations** — `SameSite=Lax` alone lets a sibling subdomain ride the session cookie, so a mutation a session can authenticate must refuse a foreign origin with `crossOriginRefusal` (`app/lib/auth/same-origin.ts`) before it writes. Session-only routes (rate, review, favorite, vote, user API keys) check first; routes that also take a Bearer key check only on the session path (register after `authenticateRequest`; every `resolveLimitedActor` caller — release state, promote, review, collections, personal kits —, 403 `cross_origin`). Add the check to any new cookie-authenticated mutation.
- **Content security scanning** — all SKILL.md content scanned for prompt injection, invisible chars, ANSI escapes, shell injection. `risk_label` column stores result ("safe"/"caution"/"danger"/"unknown"). Sanitization strips zero-width Unicode + ANSI escapes before storage.

## Deployment
Expand All @@ -179,3 +179,12 @@ Read here: `./docs/deployment-guide.md`
## Documentation

All docs in `./docs/` — see design-guidelines.md for full UI/UX spec, code-standards.md for patterns, system-architecture.md for diagrams.

## Issue Workflow

Claim an issue before working on it so parallel contributors and agents do not
collide: assign yourself and move it through the labels `in progress` → `in review`
(code review running) → `done` (PR open) → `shipped` (merged to `main`). Search open
and closed issues first; comment on a related or duplicate issue with a reference
instead of starting parallel work. File an issue for an unrelated bug or gap you
find instead of widening the current PR silently.
22 changes: 22 additions & 0 deletions apps/web/app/lib/docs/api-endpoints-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,29 @@ export const SKILL_ENDPOINTS: EndpointProps[] = [
errors: [
{ status: 400, body: "Score must be a number between 0 and 10", cause: "Invalid score value" },
{ status: 401, body: "Authentication required", cause: "Not logged in" },
{ status: 403, body: "Cross-origin request refused", cause: "Origin is not this site" },
{ status: 404, body: "Skill not found", cause: "Invalid slug" },
],
},
{
id: "skill-vote",
method: "POST",
path: "/api/skills/:slug/vote",
auth: "Session required",
description: "Upvote, downvote, or clear your vote. Rate limited to 10 votes per minute.",
params: [
{ name: "type", type: "string", required: true, description: '"up", "down", or "none" (clears the vote)' },
],
requestExample: `{ "type": "up" }`,
responseExample: `{ "vote_type": "up", "net_votes": 39 }`,
errors: [
{ status: 400, body: "type must be 'up', 'down', or 'none'", cause: "Invalid type" },
{ status: 401, body: "Authentication required", cause: "Not logged in" },
{ status: 403, body: "Cross-origin request refused", cause: "Origin is not this site" },
{ status: 404, body: "Skill not found", cause: "Invalid slug" },
{ status: 429, body: "Rate limit exceeded. Max 10 votes per minute.", cause: "Too many votes" },
],
},
{
id: "skill-review-get",
method: "GET",
Expand Down Expand Up @@ -134,6 +154,7 @@ export const SKILL_ENDPOINTS: EndpointProps[] = [
{ status: 400, body: "Review content cannot be empty", cause: "Empty content" },
{ status: 400, body: "Cannot exceed 2000 characters", cause: "Too long" },
{ status: 401, body: "Authentication required", cause: "Not logged in" },
{ status: 403, body: "Cross-origin request refused", cause: "Origin is not this site" },
{ status: 404, body: "Skill not found", cause: "Invalid slug" },
],
},
Expand All @@ -147,6 +168,7 @@ export const SKILL_ENDPOINTS: EndpointProps[] = [
notes: ["favorited: true = added, false = removed. No request body needed."],
errors: [
{ status: 401, body: "Authentication required", cause: "Not logged in" },
{ status: 403, body: "Cross-origin request refused", cause: "Origin is not this site" },
{ status: 404, body: "Skill not found", cause: "Invalid slug" },
],
},
Expand Down
61 changes: 61 additions & 0 deletions apps/web/app/lib/releases/release-json-body.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Capped JSON object bodies of the release write APIs (state, provisioning,
* personal kits). A body must be `application/json`: a cross-origin page can
* send that only after a CORS preflight, which these routes never grant.
*/
import { ReleaseHttpError, releaseError } from "./release-http";

/** Largest accepted request body; a reason is at most 512 UTF-8 bytes. */
export const MAX_STATE_BODY_BYTES = 4096;

export const invalidRequest = (message: string) => releaseError(400, "invalid_request", message);

async function readCappedText(request: Request, maxBytes: number): Promise<string> {
const type = (request.headers.get("Content-Type") ?? "").split(";")[0]!.trim().toLowerCase();
if (type !== "application/json") throw releaseError(415, "unsupported_media_type", "body must be application/json");
const tooLarge = () => releaseError(413, "request_too_large", `body exceeds ${maxBytes} bytes`);
if (Number(request.headers.get("Content-Length") ?? "0") > maxBytes) throw tooLarge();
if (request.body === null) throw invalidRequest("body is required");
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
for (let next = await reader.read(); !next.done; next = await reader.read()) {
total += next.value.length;
if (total > maxBytes) {
await reader.cancel().catch(() => undefined);
throw tooLarge();
}
chunks.push(next.value);
}
const bytes = new Uint8Array(total);
let at = 0;
for (const chunk of chunks) {
bytes.set(chunk, at);
at += chunk.length;
}
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
throw invalidRequest("body must be UTF-8 JSON");
}
}

/**
* Reads a capped `application/json` body that must be a JSON object holding
* only `fields` (an unknown field is 400). Values are not checked here.
* `maxBytes` defaults to the state API cap; other write APIs pass their own.
*/
export async function readJsonObject(request: Request, fields: ReadonlySet<string>, maxBytes = MAX_STATE_BODY_BYTES): Promise<Record<string, unknown>> {
let body: unknown;
try {
body = JSON.parse(await readCappedText(request, maxBytes));
} catch (error) {
if (error instanceof ReleaseHttpError) throw error;
throw invalidRequest("body must be a JSON object");
}
if (typeof body !== "object" || body === null || Array.isArray(body)) throw invalidRequest("body must be a JSON object");
const record = body as Record<string, unknown>;
const unknown = Object.keys(record).filter((key) => !fields.has(key));
if (unknown.length > 0) throw invalidRequest(`unknown field: ${unknown.join(", ")}`);
return record;
}
64 changes: 9 additions & 55 deletions apps/web/app/lib/releases/release-state-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ import { ReleaseHttpError, errorResponse, notFoundResponse, releaseError } from
import { releasesApiEnabled } from "./release-limits";
import { RELEASE_WRITE_RATE_WINDOW_SECONDS, checkReleaseRateLimit, rateLimitCaller } from "./release-rate-limit";
import { resolveViewer } from "./release-read";
import { invalidRequest, readJsonObject } from "./release-json-body";
import { crossOriginRefusal } from "~/lib/auth/same-origin";
import { PUBLISHER_STATES, transitionPublisher, transitionRelease } from "./release-transitions";
import type { PublisherState, TransitionActor } from "./release-transitions";

/** `ADMIN_SECRET` is typed as set (workers/app.ts) but may be missing at runtime; then no admin exists. */
export type ReleaseStateEnv = Env;

/** Largest accepted request body; a reason is at most 512 UTF-8 bytes. */
export const MAX_STATE_BODY_BYTES = 4096;
const BODY_FIELDS: ReadonlySet<string> = new Set(["from", "to", "reason"]);
/** A publisher handle as stored in `publishers.handle`: lowercase, without the `@`. */
const HANDLE_SOURCE = "[a-z0-9][a-z0-9._-]{0,63}";
Expand All @@ -42,8 +42,7 @@ export const BARE_PUBLISHER_HANDLE = new RegExp(`^${HANDLE_SOURCE}$`);
/** Control, format (bidi included), line/paragraph separator and lone surrogate characters. */
const FORBIDDEN_REASON = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Cs}]/u;
export const NO_STORE = { "Cache-Control": "no-store" };

export const invalidRequest = (message: string) => releaseError(400, "invalid_request", message);
export { MAX_STATE_BODY_BYTES, invalidRequest, readJsonObject } from "./release-json-body";

async function sha256(text: string): Promise<Uint8Array> {
return new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
Expand All @@ -69,36 +68,6 @@ export async function resolveActor(request: Request, env: ReleaseStateEnv): Prom
return { kind: "user", id: userId };
}

async function readCappedText(request: Request, maxBytes: number): Promise<string> {
const type = (request.headers.get("Content-Type") ?? "").split(";")[0]!.trim().toLowerCase();
if (type !== "application/json") throw releaseError(415, "unsupported_media_type", "body must be application/json");
const tooLarge = () => releaseError(413, "request_too_large", `body exceeds ${maxBytes} bytes`);
if (Number(request.headers.get("Content-Length") ?? "0") > maxBytes) throw tooLarge();
if (request.body === null) throw invalidRequest("body is required");
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
for (let next = await reader.read(); !next.done; next = await reader.read()) {
total += next.value.length;
if (total > maxBytes) {
await reader.cancel().catch(() => undefined);
throw tooLarge();
}
chunks.push(next.value);
}
const bytes = new Uint8Array(total);
let at = 0;
for (const chunk of chunks) {
bytes.set(chunk, at);
at += chunk.length;
}
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
throw invalidRequest("body must be UTF-8 JSON");
}
}

/** An optional audit reason: 1 to 512 UTF-8 bytes without control, format or separator characters. */
export function parseReason(raw: unknown): string | null {
if (raw === undefined || raw === null) return null;
Expand All @@ -109,26 +78,6 @@ export function parseReason(raw: unknown): string | null {
return raw;
}

/**
* Reads a capped `application/json` body that must be a JSON object holding
* only `fields` (an unknown field is 400). Values are not checked here.
* `maxBytes` defaults to the state API cap; other write APIs pass their own.
*/
export async function readJsonObject(request: Request, fields: ReadonlySet<string>, maxBytes = MAX_STATE_BODY_BYTES): Promise<Record<string, unknown>> {
let body: unknown;
try {
body = JSON.parse(await readCappedText(request, maxBytes));
} catch (error) {
if (error instanceof ReleaseHttpError) throw error;
throw invalidRequest("body must be a JSON object");
}
if (typeof body !== "object" || body === null || Array.isArray(body)) throw invalidRequest("body must be a JSON object");
const record = body as Record<string, unknown>;
const unknown = Object.keys(record).filter((key) => !fields.has(key));
if (unknown.length > 0) throw invalidRequest(`unknown field: ${unknown.join(", ")}`);
return record;
}

/** Reads `{from, to, reason?}`; `from` and `to` must be states of the subject. */
export async function readStateChange<S extends string>(
request: Request,
Expand Down Expand Up @@ -184,7 +133,12 @@ export async function enforceWriteLimit(env: ReleaseStateEnv, caller: string, fa
* than the limit allows.
*/
export async function resolveLimitedActor(request: Request, env: ReleaseStateEnv): Promise<Exclude<TransitionActor, { kind: "publisher_key" }>> {
if (request.headers.get("X-Admin-Secret") !== null) await enforceWriteLimit(env, rateLimitCaller(request, null), false);
const admin = request.headers.get("X-Admin-Secret") !== null;
// Without a key or secret the session cookie decides, and it rides along ambiently.
if (!admin && request.headers.get("Authorization") === null && crossOriginRefusal(request) !== null) {
throw releaseError(403, "cross_origin", "cross-origin request refused");
}
if (admin) await enforceWriteLimit(env, rateLimitCaller(request, null), false);
const actor = await resolveActor(request, env);
await enforceWriteLimit(env, rateLimitCaller(request, actor.id), actor.kind !== "admin");
return actor;
Expand Down
Loading
Loading