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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ pnpm db:migrate:remote # Apply migrations to remote D1
| `/api/v1/oidc/github/token` | api.v1.oidc-github-token.ts (GitHub Actions JWT → 15 min `sxo_` token scoped `releases:publish:@handle`; jti replay-checked) | GitHub OIDC JWT (503 without `SKILLX_TOKEN_SECRET`; 404 unless `SKILLX_RELEASES_API` on) |
| `/api/v1/releases/:digest/artifact/url`, `/api/v1/downloads/:token` | api.v1.release-download-url.ts, api.v1.release-download.ts (5 min signed artifact URL for private/entitled releases) | URL: entitled Key/Session; download: the token (404 unless `SKILLX_RELEASES_API` on) |
| `/api/v1/kits/:scope/:name`, `/api/v1/kits/:scope/:name/*` | api.v1.kits.ts (personal kit revisions, rebase, conflicts, session-derived proposals and review, activate/rollback, lock; see docs/personal-kits-api.md) | Owner session or unscoped API key; scoped keys 401, non-owners 404; `revisions/:id/quarantine` admin secret only (404 unless `SKILLX_PERSONAL_KITS_API` on) |
| `/api/v1/collections`, `/api/v1/collections/:id`, `/api/v1/collections/:id/*` | api.v1.collections.ts (collections: create/update/delete, immutable revisions, members, clone; see docs/api-reference.md#collections) | Reads: owner, or anyone for unlisted/public; writes: owner session or unscoped key (404 unless `SKILLX_COLLECTIONS_API` on) |

## Database Tables (Drizzle schema)

Expand Down
91 changes: 91 additions & 0 deletions apps/web/app/lib/collections/collection-http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* HTTP boundary of the collections API (`/api/v1/collections[/...]`, see
* docs/api-reference.md#collections). 404 everywhere while
* `SKILLX_COLLECTIONS_API` is off. Reads accept an unscoped API key, the web
* session or no credentials (public and unlisted collections only) and count
* against `RELEASE_READ_LIMITER`; writes need an owner (session or unscoped
* key, never the admin secret or a scoped key) and count against
* `RELEASE_WRITE_LIMITER` (fail closed) before the body is read. A private
* collection of someone else answers the same 404 as an unknown one. Every
* answer is `no-store`; failures are `{error:{code,message}}`.
*/
import { ReleaseHttpError, errorResponse, notFoundResponse, releaseError } from "~/lib/releases/release-http";
import { checkReleaseRateLimit, rateLimitCaller } from "~/lib/releases/release-rate-limit";
import { resolveViewer } from "~/lib/releases/release-read";
import { NO_STORE, failureHeaders, readJsonObject, resolveLimitedActor } from "~/lib/releases/release-state-http";

export type CollectionEnv = Env & { SKILLX_COLLECTIONS_API?: string };

/** A full revision (256 members) fits easily; larger bodies are refused before parsing. */
export const MAX_COLLECTION_BODY_BYTES = 256 * 1024;

export function collectionsApiEnabled(env: { SKILLX_COLLECTIONS_API?: string }): boolean {
const value = (env.SKILLX_COLLECTIONS_API ?? "").trim().toLowerCase();
return value === "1" || value === "true";
}

export const collectionNotFound = () => releaseError(404, "not_found", "Not found");
export const badRequest = (message: string) => releaseError(400, "invalid_request", message);

export const json = (body: unknown, status = 200) => Response.json(body, { status, headers: NO_STORE });

/** A refusal that carries validation issues next to `code` and `message`. */
export class CollectionValidationError extends ReleaseHttpError {
readonly issues: Array<{ path: string; code: string }>;

constructor(issues: Array<{ path: string; code: string }>) {
super(422, "collection.invalid", `invalid collection content: ${issues.map((i) => `${i.path} ${i.code}`).join(", ")}`);
this.issues = issues;
}
}

export interface CollectionCaller {
/** The signed-in user, or null for an anonymous read. */
userId: string | null;
env: CollectionEnv;
request: Request;
now: Date;
}

export const readBody = (request: Request, fields: readonly string[]) => readJsonObject(request, new Set(fields), MAX_COLLECTION_BODY_BYTES);

export function requireRevisionNumber(raw: unknown, field = "expectedRevision"): number {
if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < 0) throw badRequest(`${field} must be a non-negative integer`);
return raw;
}

/** Runs one endpoint: flag, caller and limit, then the handler; maps every failure to the error shape. */
export async function serveCollections(
request: Request,
env: CollectionEnv,
access: "read" | "write",
handler: (caller: CollectionCaller) => Promise<Response>,
now: () => Date = () => new Date(),
): Promise<Response> {
if (!collectionsApiEnabled(env)) return notFoundResponse();
try {
let userId: string | null;
if (access === "read") {
userId = await resolveViewer(request, env);
const decision = await checkReleaseRateLimit(env.RELEASE_READ_LIMITER, rateLimitCaller(request, userId));
if (!decision.allowed) {
throw Object.assign(releaseError(429, "rate_limited", `too many requests; retry after ${decision.retryAfterSeconds} seconds`), { retryAfter: decision.retryAfterSeconds });
}
} else {
const actor = await resolveLimitedActor(request, env);
if (actor.kind !== "user") throw releaseError(403, "collection.owner_only", "collections are written by their owner, not by operators");
userId = actor.id;
}
return await handler({ userId, env, request, now: now() });
} catch (error) {
if (error instanceof CollectionValidationError) {
return Response.json({ error: { code: error.code, message: error.message, issues: error.issues } }, { status: 422, headers: NO_STORE });
}
if (error instanceof ReleaseHttpError) {
const retry = (error as { retryAfter?: number }).retryAfter;
return errorResponse(error, retry === undefined ? failureHeaders(error) : { "Retry-After": String(retry) });
}
console.error("collection request failed:", error instanceof Error ? error.message : String(error));
return errorResponse(releaseError(500, "collection.internal_error", "collection request failed"));
}
}
181 changes: 181 additions & 0 deletions apps/web/app/lib/collections/collection-router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* Dispatch of `/api/v1/collections[/:id[/<sub>]]` by method and sub-path.
* GETs are reads (anonymous allowed for non-private collections); POST,
* PATCH and DELETE are owner writes. A collection is a recipe of package
* references; per-user overlays and activation belong to personal kits.
*/
import { COLLECTION_VISIBILITIES } from "@skillx/contracts";
import type { CollectionMember } from "@skillx/contracts";
import { notFoundResponse, releaseError } from "~/lib/releases/release-http";
import { badRequest, json, readBody, requireRevisionNumber, serveCollections } from "./collection-http";
import type { CollectionCaller, CollectionEnv } from "./collection-http";
import {
appendRevision,
collectionView,
deleteCollection,
findRevision,
insertCollection,
listOwned,
listRevisions,
ownedCollection,
prepareContent,
readableCollection,
revisionView,
updateMetadata,
} from "./collection-store";

const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
const ID = /^col_[0-9a-f-]{36}$/;

function requireSlug(raw: unknown): string {
if (typeof raw !== "string" || !SLUG.test(raw)) throw badRequest("slug must be 1 to 64 lowercase letters, digits or dashes");
return raw;
}
function requireName(raw: unknown, fallback?: string): string {
if (raw === undefined && fallback !== undefined) return fallback;
if (typeof raw !== "string" || raw.trim() === "" || raw.length > 120) throw badRequest("name must be 1 to 120 characters");
return raw.trim();
}
function requireVisibility(raw: unknown, fallback = "private"): string {
if (raw === undefined) return fallback;
if (!(COLLECTION_VISIBILITIES as readonly unknown[]).includes(raw)) throw badRequest(`visibility must be one of ${COLLECTION_VISIBILITIES.join(", ")}`);
return raw as string;
}

/** The member list of `base` with `add` upserted by package and `remove` dropped (each must exist). */
export function editMembers(base: CollectionMember[], add: unknown, remove: unknown): unknown[] {
if (add !== undefined && !Array.isArray(add)) throw badRequest("add must be an array of members");
if (remove !== undefined && (!Array.isArray(remove) || remove.some((r) => typeof r !== "string"))) throw badRequest("remove must be an array of package names");
const byName = new Map<string, unknown>(base.map((m) => [m.packageName, m]));
for (const name of (remove ?? []) as string[]) {
if (!byName.delete(name)) throw releaseError(422, "collection.member_missing", `${name} is not a member`);
}
for (const member of (add ?? []) as unknown[]) {
const name = typeof member === "object" && member !== null ? (member as { packageName?: unknown }).packageName : undefined;
if (typeof name !== "string") throw badRequest("every added member needs a packageName");
byName.set(name, member);
}
return [...byName.values()];
}

type Handler = (c: CollectionCaller, id: string, args: string[]) => Promise<Response>;
interface RouteDef {
pattern: RegExp;
get?: Handler;
post?: Handler;
patch?: Handler;
delete?: Handler;
}

const ROUTES: RouteDef[] = [
{
pattern: /^$/,
get: async (c, id) => json({ collection: collectionView(await readableCollection(c, id), c.userId) }),
patch: async (c, id) => {
const row = await ownedCollection(c, id);
const b = await readBody(c.request, ["name", "visibility"]);
const updated = await updateMetadata(c, row, requireName(b.name, row.name), requireVisibility(b.visibility, row.visibility));
return json({ collection: collectionView(updated, c.userId) });
},
delete: async (c, id) => {
await deleteCollection(c, await ownedCollection(c, id));
return json({ deleted: true, id });
},
},
{
pattern: /^revisions$/,
get: async (c, id) => {
await readableCollection(c, id);
return json({ revisions: (await listRevisions(c, id)).map(revisionView) });
},
post: async (c, id) => {
const row = await ownedCollection(c, id);
const b = await readBody(c.request, ["expectedRevision", "members", "config"]);
const revision = await appendRevision(c, row, requireRevisionNumber(b.expectedRevision), await prepareContent(b.members, b.config));
return json({ revision: revisionView(revision) }, 201);
},
},
{
pattern: /^revisions\/(\d{1,9}|latest)$/,
get: async (c, id, [n]) => {
const row = await readableCollection(c, id);
return json({ revision: revisionView(await findRevision(c, id, n === "latest" ? row.latest_revision_number : Number(n))) });
},
},
{
pattern: /^members$/,
post: async (c, id) => {
const row = await ownedCollection(c, id);
const b = await readBody(c.request, ["expectedRevision", "add", "remove"]);
const expected = requireRevisionNumber(b.expectedRevision);
const latest = revisionView(await findRevision(c, id, row.latest_revision_number));
const content = await prepareContent(editMembers(latest.members, b.add, b.remove), latest.config);
return json({ revision: revisionView(await appendRevision(c, row, expected, content)) }, 201);
},
},
{
pattern: /^clone$/,
post: async (c, id) => {
const source = await readableCollection(c, id);
const b = await readBody(c.request, ["slug", "name", "visibility", "revisionNumber"]);
const number = b.revisionNumber === undefined ? source.latest_revision_number : requireRevisionNumber(b.revisionNumber, "revisionNumber");
const from = revisionView(await findRevision(c, id, number));
const created = await insertCollection(
c,
{ slug: requireSlug(b.slug ?? source.slug), name: requireName(b.name, source.name), visibility: requireVisibility(b.visibility), clonedFrom: from.id },
await prepareContent(from.members, from.config),
);
return json({ collection: collectionView(created.collection, c.userId), revision: revisionView(created.revision) }, 201);
},
},
];

const methodNotAllowed = (route: RouteDef) => {
const allow = (["get", "post", "patch", "delete"] as const).filter((m) => route[m] !== undefined).map((m) => m.toUpperCase()).join(", ");
return json({ error: { code: "method_not_allowed", message: `use ${allow}` } }, 405);
};

/** Serves `/api/v1/collections` (list own, create). */
export function serveCollectionIndex(request: Request, env: CollectionEnv, now?: () => Date): Promise<Response> {
const write = request.method === "POST";
return serveCollections(
request,
env,
write ? "write" : "read",
async (c) => {
if (!write) {
if (request.method !== "GET" && request.method !== "HEAD") return json({ error: { code: "method_not_allowed", message: "use GET, POST" } }, 405);
if (c.userId === null) throw releaseError(401, "auth.required", "sign in, or send Authorization: Bearer <account API key>");
return json({ collections: (await listOwned(c)).map((row) => collectionView(row, c.userId)) });
}
const b = await readBody(request, ["slug", "name", "visibility", "members", "config"]);
const created = await insertCollection(
c,
{ slug: requireSlug(b.slug), name: requireName(b.name, typeof b.slug === "string" ? b.slug : undefined), visibility: requireVisibility(b.visibility), clonedFrom: null },
await prepareContent(b.members, b.config),
);
return json({ collection: collectionView(created.collection, c.userId), revision: revisionView(created.revision) }, 201);
},
now,
);
}

/** Serves `/api/v1/collections/:id[/<sub>]`. */
export function serveCollectionRequest(request: Request, env: CollectionEnv, params: Record<string, string | undefined>, now?: () => Date): Promise<Response> {
const sub = params["*"] ?? "";
const route = ROUTES.find((r) => r.pattern.test(sub));
const method = (request.method === "HEAD" ? "GET" : request.method).toLowerCase() as "get" | "post" | "patch" | "delete";
return serveCollections(
request,
env,
method === "get" ? "read" : "write",
async (c) => {
const id = params.id ?? "";
if (route === undefined || !ID.test(id)) return notFoundResponse();
const handler = route[method];
if (handler === undefined) return methodNotAllowed(route);
return handler(c, id, route.pattern.exec(sub)!.slice(1));
},
now,
);
}
Loading
Loading