From b07481f45b53ec997871db7870630fd45e3121ed Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Mon, 19 Oct 2026 19:34:30 +0700 Subject: [PATCH 1/9] feat(collections): collections API with immutable revisions, clone and members Add the flag-gated /api/v1/collections API (SKILLX_COLLECTIONS_API) over the existing collections tables: create, metadata update, delete, clone with lineage, full-replace revisions and member add/remove as compare-and-set revisions. Revisions are digested and immutable (trigger), and content passes a shared sync-leak scanner that refuses secrets and absolute local paths. Contracts gain the skillx.lock/v1 lockfile schema used by the installer. Refs #35 Co-Authored-By: Claude Opus 5.5 --- .../app/lib/collections/collection-http.ts | 91 + .../app/lib/collections/collection-router.ts | 181 + .../app/lib/collections/collection-store.ts | 191 ++ apps/web/app/lib/db/collections-schema.ts | 3 + apps/web/app/routes.ts | 3 + apps/web/app/routes/api.v1.collections.ts | 19 + .../0019_collection_revision_lineage.sql | 20 + .../migrations/meta/0019_snapshot.json | 3042 +++++++++++++++++ .../web/drizzle/migrations/meta/_journal.json | 9 +- .../tests/collections/collections-api.test.ts | 139 + apps/web/wrangler.jsonc | 4 +- packages/contracts/src/collection-revision.ts | 70 + packages/contracts/src/index.ts | 3 + packages/contracts/src/install-lock.test.ts | 49 + packages/contracts/src/install-lock.ts | 138 + packages/contracts/src/sync-leaks.test.ts | 59 + packages/contracts/src/sync-leaks.ts | 95 + .../phase3-design.md | 93 + 18 files changed, 4207 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/lib/collections/collection-http.ts create mode 100644 apps/web/app/lib/collections/collection-router.ts create mode 100644 apps/web/app/lib/collections/collection-store.ts create mode 100644 apps/web/app/routes/api.v1.collections.ts create mode 100644 apps/web/drizzle/migrations/0019_collection_revision_lineage.sql create mode 100644 apps/web/drizzle/migrations/meta/0019_snapshot.json create mode 100644 apps/web/tests/collections/collections-api.test.ts create mode 100644 packages/contracts/src/collection-revision.ts create mode 100644 packages/contracts/src/install-lock.test.ts create mode 100644 packages/contracts/src/install-lock.ts create mode 100644 packages/contracts/src/sync-leaks.test.ts create mode 100644 packages/contracts/src/sync-leaks.ts create mode 100644 plans/261019-1914-issues-31-88-delivery/phase3-design.md diff --git a/apps/web/app/lib/collections/collection-http.ts b/apps/web/app/lib/collections/collection-http.ts new file mode 100644 index 0000000..fff9d1f --- /dev/null +++ b/apps/web/app/lib/collections/collection-http.ts @@ -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, + now: () => Date = () => new Date(), +): Promise { + 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")); + } +} diff --git a/apps/web/app/lib/collections/collection-router.ts b/apps/web/app/lib/collections/collection-router.ts new file mode 100644 index 0000000..e8dae54 --- /dev/null +++ b/apps/web/app/lib/collections/collection-router.ts @@ -0,0 +1,181 @@ +/** + * Dispatch of `/api/v1/collections[/:id[/]]` 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(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; +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 { + 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 "); + 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[/]`. */ +export function serveCollectionRequest(request: Request, env: CollectionEnv, params: Record, now?: () => Date): Promise { + 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, + ); +} diff --git a/apps/web/app/lib/collections/collection-store.ts b/apps/web/app/lib/collections/collection-store.ts new file mode 100644 index 0000000..4f04d07 --- /dev/null +++ b/apps/web/app/lib/collections/collection-store.ts @@ -0,0 +1,191 @@ +/** + * D1 access of collections. A revision write is one batch: insert revision + * `n + 1`, then move `latest_revision_number` from `n` to `n + 1`. The unique + * `(collection_id, revision_number)` index makes two writers of the same `n` + * collide, and the batch is atomic, so the loser writes nothing. Revisions + * are never updated (the `collection_revisions_immutable` trigger aborts it). + */ +import { collectionRevisionDigest, sortCollectionMembers, validateCollectionContent } from "@skillx/contracts"; +import type { CollectionConfig, CollectionMember } from "@skillx/contracts"; +import { releaseError } from "~/lib/releases/release-http"; +import { CollectionValidationError, collectionNotFound } from "./collection-http"; +import type { CollectionCaller } from "./collection-http"; + +export interface CollectionRow { + id: string; + owner_id: string; + slug: string; + name: string; + visibility: string; + latest_revision_number: number; + cloned_from_revision_id: string | null; + created_at: number; + updated_at: number; +} + +export interface CollectionRevisionRow { + id: string; + collection_id: string; + revision_number: number; + members_json: string; + config_json: string | null; + revision_digest: string | null; + created_at: number; +} + +export const newId = (prefix: string) => `${prefix}_${crypto.randomUUID()}`; +const iso = (ms: number) => new Date(ms).toISOString(); + +export function collectionView(row: CollectionRow, userId: string | null) { + return { + id: row.id, + slug: row.slug, + name: row.name, + visibility: row.visibility, + owned: row.owner_id === userId, + latestRevisionNumber: row.latest_revision_number, + clonedFromRevisionId: row.cloned_from_revision_id, + createdAt: iso(row.created_at), + updatedAt: iso(row.updated_at), + }; +} + +export function revisionView(row: CollectionRevisionRow) { + return { + id: row.id, + collectionId: row.collection_id, + revisionNumber: row.revision_number, + revisionDigest: row.revision_digest, + members: JSON.parse(row.members_json) as CollectionMember[], + config: (row.config_json === null ? {} : JSON.parse(row.config_json)) as CollectionConfig, + createdAt: iso(row.created_at), + }; +} + +const db = (c: CollectionCaller) => c.env.DB; + +/** The collection when the caller may read it (owner, or not private); otherwise the 404 of an unknown one. */ +export async function readableCollection(c: CollectionCaller, id: string): Promise { + const row = await db(c).prepare("SELECT * FROM collections WHERE id = ?").bind(id).first(); + if (row === null || (row.visibility === "private" && row.owner_id !== c.userId)) throw collectionNotFound(); + return row; +} + +/** The collection when the caller owns it; anyone else gets 404, even for a public one. */ +export async function ownedCollection(c: CollectionCaller, id: string): Promise { + const row = await readableCollection(c, id); + if (row.owner_id !== c.userId) throw collectionNotFound(); + return row; +} + +export async function findRevision(c: CollectionCaller, collectionId: string, number: number): Promise { + const row = await db(c) + .prepare("SELECT * FROM collection_revisions WHERE collection_id = ? AND revision_number = ?") + .bind(collectionId, number) + .first(); + if (row === null) throw collectionNotFound(); + return row; +} + +export async function listRevisions(c: CollectionCaller, collectionId: string): Promise { + const res = await db(c).prepare("SELECT * FROM collection_revisions WHERE collection_id = ? ORDER BY revision_number DESC LIMIT 100").bind(collectionId).all(); + return res.results; +} + +/** Validated, sorted content of a new revision; 422 with the issue list otherwise. */ +export async function prepareContent(members: unknown, config: unknown) { + const result = validateCollectionContent({ members, config }); + if (!result.valid) throw new CollectionValidationError(result.issues.map((i) => ({ path: i.path, code: i.code }))); + const content = { members: sortCollectionMembers((members ?? []) as CollectionMember[]), config: (config ?? {}) as CollectionConfig }; + return { ...content, digest: await collectionRevisionDigest(content) }; +} + +type Content = Awaited>; + +function insertRevision(c: CollectionCaller, collectionId: string, number: number, content: Content): { row: CollectionRevisionRow; statement: D1PreparedStatement } { + const row: CollectionRevisionRow = { + id: newId("colrev"), + collection_id: collectionId, + revision_number: number, + members_json: JSON.stringify(content.members), + config_json: JSON.stringify(content.config), + revision_digest: content.digest, + created_at: c.now.getTime(), + }; + const statement = db(c) + .prepare("INSERT INTO collection_revisions (id, collection_id, revision_number, members_json, config_json, revision_digest, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)") + .bind(row.id, row.collection_id, row.revision_number, row.members_json, row.config_json, row.revision_digest, row.created_at); + return { row, statement }; +} + +const isUniqueViolation = (error: unknown) => error instanceof Error && /UNIQUE constraint failed/i.test(error.message); + +/** Creates a collection with revision 1. A slug the owner already uses is 409 `collection.slug_exists`. */ +export async function insertCollection( + c: CollectionCaller, + fields: { slug: string; name: string; visibility: string; clonedFrom: string | null }, + content: Content, +): Promise<{ collection: CollectionRow; revision: CollectionRevisionRow }> { + const now = c.now.getTime(); + const collection: CollectionRow = { + id: newId("col"), + owner_id: c.userId!, + slug: fields.slug, + name: fields.name, + visibility: fields.visibility, + latest_revision_number: 1, + cloned_from_revision_id: fields.clonedFrom, + created_at: now, + updated_at: now, + }; + const revision = insertRevision(c, collection.id, 1, content); + try { + await db(c).batch([ + db(c) + .prepare("INSERT INTO collections (id, owner_id, slug, name, visibility, latest_revision_number, cloned_from_revision_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)") + .bind(collection.id, collection.owner_id, collection.slug, collection.name, collection.visibility, collection.cloned_from_revision_id, now, now), + revision.statement, + ]); + } catch (error) { + if (isUniqueViolation(error)) throw releaseError(409, "collection.slug_exists", `you already have a collection named ${fields.slug}`); + throw error; + } + return { collection, revision: revision.row }; +} + +/** Appends revision `expected + 1`; a stale `expected` is 409 `collection.revision_conflict`. */ +export async function appendRevision(c: CollectionCaller, row: CollectionRow, expected: number, content: Content): Promise { + const conflict = () => releaseError(409, "collection.revision_conflict", `the latest revision is ${row.latest_revision_number}, not ${expected}`); + if (row.latest_revision_number !== expected) throw conflict(); + const next = expected + 1; + const revision = insertRevision(c, row.id, next, content); + try { + await db(c).batch([ + revision.statement, + db(c) + .prepare("UPDATE collections SET latest_revision_number = ?, updated_at = ? WHERE id = ? AND latest_revision_number = ?") + .bind(next, c.now.getTime(), row.id, expected), + ]); + } catch (error) { + if (isUniqueViolation(error)) throw conflict(); + throw error; + } + return revision.row; +} + +export async function updateMetadata(c: CollectionCaller, row: CollectionRow, name: string, visibility: string): Promise { + await db(c).prepare("UPDATE collections SET name = ?, visibility = ?, updated_at = ? WHERE id = ?").bind(name, visibility, c.now.getTime(), row.id).run(); + return { ...row, name, visibility, updated_at: c.now.getTime() }; +} + +export async function deleteCollection(c: CollectionCaller, row: CollectionRow): Promise { + await db(c).batch([ + db(c).prepare("DELETE FROM collection_revisions WHERE collection_id = ?").bind(row.id), + db(c).prepare("DELETE FROM collections WHERE id = ?").bind(row.id), + ]); +} + +export async function listOwned(c: CollectionCaller): Promise { + const res = await db(c).prepare("SELECT * FROM collections WHERE owner_id = ? ORDER BY updated_at DESC LIMIT 200").bind(c.userId).all(); + return res.results; +} diff --git a/apps/web/app/lib/db/collections-schema.ts b/apps/web/app/lib/db/collections-schema.ts index 78d92d6..347ff88 100644 --- a/apps/web/app/lib/db/collections-schema.ts +++ b/apps/web/app/lib/db/collections-schema.ts @@ -11,6 +11,8 @@ export const collections = sqliteTable( name: text("name").notNull(), visibility: text("visibility").notNull().default("private"), // private | unlisted | public latest_revision_number: integer("latest_revision_number").notNull().default(0), + // Lineage of a clone: the source revision it was copied from (no FK: the source may be deleted). + cloned_from_revision_id: text("cloned_from_revision_id"), created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(), }, @@ -29,6 +31,7 @@ export const collectionRevisions = sqliteTable( revision_number: integer("revision_number").notNull(), members_json: text("members_json").notNull(), // JSON: [{ packageName, version?, digest?, optional? }] config_json: text("config_json"), // JSON: non-secret configuration + revision_digest: text("revision_digest"), // sha256 of canonical {config, members} created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), }, (table) => [ diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts index 9d26410..c916c04 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -52,5 +52,8 @@ export default [ route("api/v1/keys", "routes/api.v1.signing-keys.ts"), route("api/v1/kits/:scope/:name", "routes/api.v1.kits.ts", { id: "personal-kit" }), route("api/v1/kits/:scope/:name/*", "routes/api.v1.kits.ts", { id: "personal-kit-sub" }), + route("api/v1/collections", "routes/api.v1.collections.ts", { id: "collections" }), + route("api/v1/collections/:id", "routes/api.v1.collections.ts", { id: "collection" }), + route("api/v1/collections/:id/*", "routes/api.v1.collections.ts", { id: "collection-sub" }), route("*", "routes/$.tsx"), ] satisfies RouteConfig; diff --git a/apps/web/app/routes/api.v1.collections.ts b/apps/web/app/routes/api.v1.collections.ts new file mode 100644 index 0000000..ec29528 --- /dev/null +++ b/apps/web/app/routes/api.v1.collections.ts @@ -0,0 +1,19 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { serveCollectionIndex, serveCollectionRequest } from "~/lib/collections/collection-router"; + +/** + * Collections API, mounted on `/api/v1/collections`, `/api/v1/collections/:id` + * and `/api/v1/collections/:id/*` (see docs/api-reference.md#collections). + * Answers 404 to everything while `SKILLX_COLLECTIONS_API` is off. + */ +function serve(request: Request, env: Env, params: Record) { + return params.id === undefined ? serveCollectionIndex(request, env) : serveCollectionRequest(request, env, params); +} + +export async function loader({ request, params, context }: LoaderFunctionArgs) { + return serve(request, context.cloudflare.env, params); +} + +export async function action({ request, params, context }: ActionFunctionArgs) { + return serve(request, context.cloudflare.env, params); +} diff --git a/apps/web/drizzle/migrations/0019_collection_revision_lineage.sql b/apps/web/drizzle/migrations/0019_collection_revision_lineage.sql new file mode 100644 index 0000000..6c356f4 --- /dev/null +++ b/apps/web/drizzle/migrations/0019_collection_revision_lineage.sql @@ -0,0 +1,20 @@ +-- Collections API (issue #35): revision digests, clone lineage and immutable revisions. +-- +-- Strictly additive: two nullable columns and one trigger. Existing rows keep NULL in both +-- columns (no backfill; revisions created before this migration have no digest). The +-- trigger aborts every UPDATE of a collection revision, so a stored revision can never be +-- rewritten; revisions are still removed by the ON DELETE CASCADE of their collection. +-- +-- Rollback (take a D1 time-travel bookmark first). SQLite >= 3.35 drops columns in place; +-- the last statement removes the wrangler bookkeeping row so a later apply re-runs 0019: +-- DROP TRIGGER IF EXISTS collection_revisions_immutable; +-- ALTER TABLE `collection_revisions` DROP COLUMN `revision_digest`; +-- ALTER TABLE `collections` DROP COLUMN `cloned_from_revision_id`; +-- DELETE FROM d1_migrations WHERE name = '0019_collection_revision_lineage.sql'; + +ALTER TABLE `collection_revisions` ADD `revision_digest` text;--> statement-breakpoint +ALTER TABLE `collections` ADD `cloned_from_revision_id` text;--> statement-breakpoint +CREATE TRIGGER `collection_revisions_immutable` BEFORE UPDATE ON `collection_revisions` +BEGIN + SELECT RAISE(ABORT, 'collection revisions are immutable'); +END; diff --git a/apps/web/drizzle/migrations/meta/0019_snapshot.json b/apps/web/drizzle/migrations/meta/0019_snapshot.json new file mode 100644 index 0000000..c0a8f2c --- /dev/null +++ b/apps/web/drizzle/migrations/meta/0019_snapshot.json @@ -0,0 +1,3042 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "597309b0-cc14-4448-96ff-71366d81d623", + "prevId": "0d0cd266-cecf-468c-8fc4-b07b44ced5ce", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Default'" + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + }, + "idx_api_keys_user": { + "name": "idx_api_keys_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_api_keys_hash": { + "name": "idx_api_keys_hash", + "columns": [ + "key_hash" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "favorites": { + "name": "favorites", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_favorites_pk": { + "name": "idx_favorites_pk", + "columns": [ + "user_id", + "skill_id" + ], + "isUnique": true + }, + "idx_favorites_skill": { + "name": "idx_favorites_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "favorites_skill_id_skills_id_fk": { + "name": "favorites_skill_id_skills_id_fk", + "tableFrom": "favorites", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "installs": { + "name": "installs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_installs_skill": { + "name": "idx_installs_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + }, + "idx_installs_user": { + "name": "idx_installs_user", + "columns": [ + "skill_id", + "user_id" + ], + "isUnique": true, + "where": "user_id IS NOT NULL" + }, + "idx_installs_device": { + "name": "idx_installs_device", + "columns": [ + "skill_id", + "device_id" + ], + "isUnique": true, + "where": "device_id IS NOT NULL" + } + }, + "foreignKeys": { + "installs_skill_id_skills_id_fk": { + "name": "installs_skill_id_skills_id_fk", + "tableFrom": "installs", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ratings": { + "name": "ratings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_agent": { + "name": "is_agent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_ratings_user_skill": { + "name": "idx_ratings_user_skill", + "columns": [ + "user_id", + "skill_id" + ], + "isUnique": true + }, + "idx_ratings_skill": { + "name": "idx_ratings_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ratings_skill_id_skills_id_fk": { + "name": "ratings_skill_id_skills_id_fk", + "tableFrom": "ratings", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_agent": { + "name": "is_agent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_reviews_skill": { + "name": "idx_reviews_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + }, + "idx_reviews_user": { + "name": "idx_reviews_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "reviews_skill_id_skills_id_fk": { + "name": "reviews_skill_id_skills_id_fk", + "tableFrom": "reviews", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skill_aliases": { + "name": "skill_aliases", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'legacy'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_skill_aliases_skill": { + "name": "idx_skill_aliases_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "skill_aliases_skill_id_skills_id_fk": { + "name": "skill_aliases_skill_id_skills_id_fk", + "tableFrom": "skill_aliases", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skills": { + "name": "skills", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_repo": { + "name": "source_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "install_command": { + "name": "install_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'1.0.0'" + }, + "is_paid": { + "name": "is_paid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "avg_rating": { + "name": "avg_rating", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "rating_count": { + "name": "rating_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "github_stars": { + "name": "github_stars", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "install_count": { + "name": "install_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "composite_score": { + "name": "composite_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "bayesian_rating": { + "name": "bayesian_rating", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "trending_score": { + "name": "trending_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "favorite_count": { + "name": "favorite_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "upvote_count": { + "name": "upvote_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "downvote_count": { + "name": "downvote_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "net_votes": { + "name": "net_votes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "scripts": { + "name": "scripts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compatibility_json": { + "name": "compatibility_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fts_content": { + "name": "fts_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "risk_label": { + "name": "risk_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "skills_slug_unique": { + "name": "skills_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "idx_skills_category": { + "name": "idx_skills_category", + "columns": [ + "category" + ], + "isUnique": false + }, + "idx_skills_author": { + "name": "idx_skills_author", + "columns": [ + "author" + ], + "isUnique": false + }, + "idx_skills_avg_rating": { + "name": "idx_skills_avg_rating", + "columns": [ + "avg_rating" + ], + "isUnique": false + }, + "idx_skills_composite_score": { + "name": "idx_skills_composite_score", + "columns": [ + "composite_score" + ], + "isUnique": false + }, + "idx_skills_trending_score": { + "name": "idx_skills_trending_score", + "columns": [ + "trending_score" + ], + "isUnique": false + }, + "idx_skills_net_votes": { + "name": "idx_skills_net_votes", + "columns": [ + "net_votes" + ], + "isUnique": false + }, + "idx_skills_source_identity": { + "name": "idx_skills_source_identity", + "columns": [ + "source_repo", + "source_path" + ], + "isUnique": true, + "where": "source_repo IS NOT NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_stats": { + "name": "usage_stats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_usage_skill": { + "name": "idx_usage_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + }, + "idx_usage_created": { + "name": "idx_usage_created", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "usage_stats_skill_id_skills_id_fk": { + "name": "usage_stats_skill_id_skills_id_fk", + "tableFrom": "usage_stats", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "votes": { + "name": "votes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vote_type": { + "name": "vote_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_votes_user_skill": { + "name": "idx_votes_user_skill", + "columns": [ + "user_id", + "skill_id" + ], + "isUnique": true + }, + "idx_votes_skill": { + "name": "idx_votes_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "votes_skill_id_skills_id_fk": { + "name": "votes_skill_id_skills_id_fk", + "tableFrom": "votes", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "package_releases": { + "name": "package_releases", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "package_id": { + "name": "package_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stable'" + }, + "release_state": { + "name": "release_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "manifest_schema_version": { + "name": "manifest_schema_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "artifact_key": { + "name": "artifact_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "artifact_digest": { + "name": "artifact_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "artifact_size": { + "name": "artifact_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "release_digest": { + "name": "release_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tree_size": { + "name": "tree_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_manifest_key": { + "name": "signed_manifest_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_provenance": { + "name": "source_provenance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compatibility_json": { + "name": "compatibility_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "yanked_at": { + "name": "yanked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quarantined_at": { + "name": "quarantined_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_sequence": { + "name": "status_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_issued_at": { + "name": "status_issued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_key": { + "name": "status_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exec_summary_json": { + "name": "exec_summary_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_package_releases_version": { + "name": "idx_package_releases_version", + "columns": [ + "package_id", + "version" + ], + "isUnique": true + }, + "idx_package_releases_state": { + "name": "idx_package_releases_state", + "columns": [ + "release_state" + ], + "isUnique": false + }, + "idx_package_releases_digest": { + "name": "idx_package_releases_digest", + "columns": [ + "release_digest" + ], + "isUnique": false + } + }, + "foreignKeys": { + "package_releases_package_id_packages_id_fk": { + "name": "package_releases_package_id_packages_id_fk", + "tableFrom": "package_releases", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "packages": { + "name": "packages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_packages_name": { + "name": "idx_packages_name", + "columns": [ + "name" + ], + "isUnique": true + }, + "idx_packages_publisher": { + "name": "idx_packages_publisher", + "columns": [ + "publisher_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "packages_publisher_id_publishers_id_fk": { + "name": "packages_publisher_id_publishers_id_fk", + "tableFrom": "packages", + "tableTo": "publishers", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "publishers": { + "name": "publishers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "publishers_handle_unique": { + "name": "publishers_handle_unique", + "columns": [ + "handle" + ], + "isUnique": true + }, + "idx_publishers_owner": { + "name": "idx_publishers_owner", + "columns": [ + "owner_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_state_audit": { + "name": "release_state_audit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subject_kind": { + "name": "subject_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_state": { + "name": "from_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_state": { + "name": "to_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_release_state_audit_subject": { + "name": "idx_release_state_audit_subject", + "columns": [ + "subject_kind", + "subject_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_verification_evidence": { + "name": "release_verification_evidence", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_digest": { + "name": "release_digest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "harness_version": { + "name": "harness_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "os": { + "name": "os", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "probe_id": { + "name": "probe_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verifier": { + "name": "verifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_release_evidence_release": { + "name": "idx_release_evidence_release", + "columns": [ + "release_id" + ], + "isUnique": false + }, + "idx_release_evidence_probe": { + "name": "idx_release_evidence_probe", + "columns": [ + "release_id", + "harness", + "probe_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "release_verification_evidence_release_id_package_releases_id_fk": { + "name": "release_verification_evidence_release_id_package_releases_id_fk", + "tableFrom": "release_verification_evidence", + "tableTo": "package_releases", + "columnsFrom": [ + "release_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oidc_token_exchanges": { + "name": "oidc_token_exchanges", + "columns": { + "jti": { + "name": "jti", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "trusted_publisher_id": { + "name": "trusted_publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workflow_ref": { + "name": "workflow_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_oidc_token_exchanges_created": { + "name": "idx_oidc_token_exchanges_created", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "idx_oidc_token_exchanges_trusted": { + "name": "idx_oidc_token_exchanges_trusted", + "columns": [ + "trusted_publisher_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_publish_idempotency": { + "name": "release_publish_idempotency", + "columns": { + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_fingerprint": { + "name": "request_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_release_publish_idempotency_expires": { + "name": "idx_release_publish_idempotency_expires", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "release_publish_idempotency_publisher_id_idempotency_key_pk": { + "columns": [ + "publisher_id", + "idempotency_key" + ], + "name": "release_publish_idempotency_publisher_id_idempotency_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_reviews": { + "name": "release_reviews", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_digest": { + "name": "release_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_release_reviews_release": { + "name": "idx_release_reviews_release", + "columns": [ + "release_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "trusted_publishers": { + "name": "trusted_publishers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workflow_path": { + "name": "workflow_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_trusted_publishers_publisher": { + "name": "idx_trusted_publishers_publisher", + "columns": [ + "publisher_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "entitlements": { + "name": "entitlements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "package_id": { + "name": "package_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_entitlements_subject": { + "name": "idx_entitlements_subject", + "columns": [ + "subject_id" + ], + "isUnique": false + }, + "idx_entitlements_package": { + "name": "idx_entitlements_package", + "columns": [ + "package_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "entitlements_package_id_packages_id_fk": { + "name": "entitlements_package_id_packages_id_fk", + "tableFrom": "entitlements", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlements_release_id_package_releases_id_fk": { + "name": "entitlements_release_id_package_releases_id_fk", + "tableFrom": "entitlements", + "tableTo": "package_releases", + "columnsFrom": [ + "release_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_revisions": { + "name": "collection_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "members_json": { + "name": "members_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_json": { + "name": "config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_digest": { + "name": "revision_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_collection_revisions_number": { + "name": "idx_collection_revisions_number", + "columns": [ + "collection_id", + "revision_number" + ], + "isUnique": true + }, + "idx_collection_revisions_collection": { + "name": "idx_collection_revisions_collection", + "columns": [ + "collection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "collection_revisions_collection_id_collections_id_fk": { + "name": "collection_revisions_collection_id_collections_id_fk", + "tableFrom": "collection_revisions", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloned_from_revision_id": { + "name": "cloned_from_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_collections_owner_slug": { + "name": "idx_collections_owner_slug", + "columns": [ + "owner_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kit_audit": { + "name": "personal_kit_audit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kit_id": { + "name": "kit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_id": { + "name": "revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kit_audit_kit": { + "name": "idx_personal_kit_audit_kit", + "columns": [ + "kit_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kit_conflicts": { + "name": "personal_kit_conflicts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kit_id": { + "name": "kit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_revision_id": { + "name": "from_revision_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "report_json": { + "name": "report_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolution_json": { + "name": "resolution_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_revision_id": { + "name": "resolved_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reeval_required": { + "name": "reeval_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kit_conflicts_kit": { + "name": "idx_personal_kit_conflicts_kit", + "columns": [ + "kit_id", + "status" + ], + "isUnique": false + }, + "idx_personal_kit_conflicts_idempotency": { + "name": "idx_personal_kit_conflicts_idempotency", + "columns": [ + "kit_id", + "idempotency_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "personal_kit_conflicts_kit_id_personal_kits_id_fk": { + "name": "personal_kit_conflicts_kit_id_personal_kits_id_fk", + "tableFrom": "personal_kit_conflicts", + "tableTo": "personal_kits", + "columnsFrom": [ + "kit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kit_proposals": { + "name": "personal_kit_proposals", + "columns": { + "revision_id": { + "name": "revision_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kit_id": { + "name": "kit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "declared_permissions_json": { + "name": "declared_permissions_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "required_permissions_json": { + "name": "required_permissions_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "evidence_json": { + "name": "evidence_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scan_json": { + "name": "scan_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kit_proposals_kit": { + "name": "idx_personal_kit_proposals_kit", + "columns": [ + "kit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "personal_kit_proposals_revision_id_personal_kit_revisions_id_fk": { + "name": "personal_kit_proposals_revision_id_personal_kit_revisions_id_fk", + "tableFrom": "personal_kit_proposals", + "tableTo": "personal_kit_revisions", + "columnsFrom": [ + "revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kit_revisions": { + "name": "personal_kit_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kit_id": { + "name": "kit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_revision_id": { + "name": "parent_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revision_digest": { + "name": "revision_digest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_json": { + "name": "document_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approval_json": { + "name": "approval_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "activated_at": { + "name": "activated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quarantined_at": { + "name": "quarantined_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kit_revisions_number": { + "name": "idx_personal_kit_revisions_number", + "columns": [ + "kit_id", + "revision_number" + ], + "isUnique": true + }, + "idx_personal_kit_revisions_idempotency": { + "name": "idx_personal_kit_revisions_idempotency", + "columns": [ + "kit_id", + "idempotency_key" + ], + "isUnique": true + }, + "idx_personal_kit_revisions_digest": { + "name": "idx_personal_kit_revisions_digest", + "columns": [ + "kit_id", + "revision_digest" + ], + "isUnique": false + } + }, + "foreignKeys": { + "personal_kit_revisions_kit_id_personal_kits_id_fk": { + "name": "personal_kit_revisions_kit_id_personal_kits_id_fk", + "tableFrom": "personal_kit_revisions", + "tableTo": "personal_kits", + "columnsFrom": [ + "kit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kits": { + "name": "personal_kits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_id": { + "name": "local_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "effective_revision_id": { + "name": "effective_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kits_name": { + "name": "idx_personal_kits_name", + "columns": [ + "name" + ], + "isUnique": true + }, + "idx_personal_kits_owner": { + "name": "idx_personal_kits_owner", + "columns": [ + "owner_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "personal_kits_publisher_id_publishers_id_fk": { + "name": "personal_kits_publisher_id_publishers_id_fk", + "tableFrom": "personal_kits", + "tableTo": "publishers", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/web/drizzle/migrations/meta/_journal.json b/apps/web/drizzle/migrations/meta/_journal.json index 883ae2e..f194f14 100644 --- a/apps/web/drizzle/migrations/meta/_journal.json +++ b/apps/web/drizzle/migrations/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1792412883490, "tag": "0018_registry_publishing", "breakpoints": true + }, + { + "idx": 19, + "version": "6", + "when": 1792412951303, + "tag": "0019_collection_revision_lineage", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/web/tests/collections/collections-api.test.ts b/apps/web/tests/collections/collections-api.test.ts new file mode 100644 index 0000000..71b3bd8 --- /dev/null +++ b/apps/web/tests/collections/collections-api.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { releasePublishScope } from "~/lib/auth/api-key-scopes"; +import { action, loader } from "~/routes/api.v1.collections"; +import { FakeRateLimiter } from "../releases/fake-rate-limiter"; +import { createHarness } from "../releases/release-harness"; + +type Harness = Awaited>; +let h: Harness; +let ownerKey: string; +let otherKey: string; +let scopedKey: string; + +const DIGEST = `sha256:${"a".repeat(64)}`; + +beforeEach(async () => { + h = await createHarness(); + ownerKey = await h.createKey("user-a", null); + otherKey = await h.createKey("user-b", null); + scopedKey = await h.createKey("user-a", [releasePublishScope("alpha")]); +}); + +interface CallOptions { + key?: string | null; + env?: Record; + method?: string; + headers?: Record; +} + +async function call(path: string, body?: unknown, o: CallOptions = {}) { + const method = o.method ?? (body === undefined ? "GET" : "POST"); + const headers = new Headers({ "Content-Type": "application/json", ...o.headers }); + const key = o.key === undefined ? ownerKey : o.key; + if (key !== null) headers.set("Authorization", `Bearer ${key}`); + const [, , , , id, ...rest] = path.split("/"); + const params = { ...(id === undefined ? {} : { id }), ...(rest.length === 0 ? {} : { "*": rest.join("/") }) }; + const request = new Request(`https://skillx.test${path}`, { method, headers, body: method === "GET" ? undefined : JSON.stringify(body ?? {}) }); + const env = { + SKILLX_COLLECTIONS_API: "1", + DB: h.deps.db, + RELEASE_READ_LIMITER: new FakeRateLimiter(10_000), + RELEASE_WRITE_LIMITER: new FakeRateLimiter(10_000), + ADMIN_SECRET: "test-admin-secret-0123456789", + ...o.env, + }; + const args = { request, params, context: { cloudflare: { env } } } as never; + const res = method === "GET" ? await loader(args) : await action(args); + return { status: res.status, body: (await res.json()) as Record }; +} + +const create = (extra: Record = {}) => + call("/api/v1/collections", { slug: "starter", name: "Starter", members: [{ packageName: "@alpha/demo", version: "1.0.0" }], config: { scope: "user" }, ...extra }); + +describe("collections API", () => { + it("is 404 while the flag is off", async () => { + const res = await call("/api/v1/collections", undefined, { env: { SKILLX_COLLECTIONS_API: "0" } }); + expect(res).toEqual({ status: 404, body: { error: { code: "not_found", message: "Not found" } } }); + }); + + it("creates a private collection with an immutable, digested revision 1", async () => { + const res = await create(); + expect(res.status).toBe(201); + expect(res.body.collection).toMatchObject({ slug: "starter", visibility: "private", owned: true, latestRevisionNumber: 1, clonedFromRevisionId: null }); + expect(res.body.revision).toMatchObject({ revisionNumber: 1, members: [{ packageName: "@alpha/demo", version: "1.0.0" }], config: { scope: "user" } }); + expect(res.body.revision.revisionDigest).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(() => h.d1.raw.prepare("UPDATE collection_revisions SET members_json = '[]'").run()).toThrow(/immutable/); + expect((await create()).body.error.code).toBe("collection.slug_exists"); + }); + + it("hides private collections from others and anonymous callers, shows unlisted ones", async () => { + const { id } = (await create()).body.collection; + expect((await call(`/api/v1/collections/${id}`, undefined, { key: otherKey })).status).toBe(404); + expect((await call(`/api/v1/collections/${id}`, undefined, { key: null })).status).toBe(404); + expect((await call(`/api/v1/collections/${id}`, { visibility: "unlisted" }, { method: "PATCH" })).body.collection.visibility).toBe("unlisted"); + const anon = await call(`/api/v1/collections/${id}/revisions/latest`, undefined, { key: null }); + expect(anon.status).toBe(200); + expect(anon.body.revision.revisionNumber).toBe(1); + expect((await call(`/api/v1/collections/${id}`, { name: "Mine now" }, { method: "PATCH", key: otherKey })).status).toBe(404); + }); + + it("adds and removes members as new revisions with compare-and-set", async () => { + const { id } = (await create()).body.collection; + const added = await call(`/api/v1/collections/${id}/members`, { expectedRevision: 1, add: [{ packageName: "@alpha/zed", version: "2.0.0", digest: DIGEST }] }); + expect(added.status).toBe(201); + expect(added.body.revision.members.map((m: { packageName: string }) => m.packageName)).toEqual(["@alpha/demo", "@alpha/zed"]); + const stale = await call(`/api/v1/collections/${id}/members`, { expectedRevision: 1, remove: ["@alpha/demo"] }); + expect(stale).toMatchObject({ status: 409, body: { error: { code: "collection.revision_conflict" } } }); + const removed = await call(`/api/v1/collections/${id}/members`, { expectedRevision: 2, remove: ["@alpha/demo"] }); + expect(removed.body.revision).toMatchObject({ revisionNumber: 3, members: [{ packageName: "@alpha/zed" }] }); + expect((await call(`/api/v1/collections/${id}/members`, { expectedRevision: 3, remove: ["@alpha/nope"] })).body.error.code).toBe("collection.member_missing"); + const revisions = await call(`/api/v1/collections/${id}/revisions`); + expect(revisions.body.revisions.map((r: { revisionNumber: number }) => r.revisionNumber)).toEqual([3, 2, 1]); + expect((await call(`/api/v1/collections/${id}/revisions/1`)).body.revision.members).toHaveLength(1); + }); + + it("replaces content with a full revision and refuses secrets and absolute paths", async () => { + const { id } = (await create()).body.collection; + const ok = await call(`/api/v1/collections/${id}/revisions`, { expectedRevision: 1, members: [], config: {} }); + expect(ok.body.revision).toMatchObject({ revisionNumber: 2, members: [] }); + const leak = await call(`/api/v1/collections/${id}/revisions`, { expectedRevision: 2, members: [], config: { token: "ghp_0123456789abcdefghijklmnopqrstuvwxyz", dir: "/home/alice/skills" } }); + expect(leak.status).toBe(422); + expect(leak.body.error.issues).toEqual(expect.arrayContaining([{ path: "config.token", code: "sync.secret-key" }, { path: "config.dir", code: "sync.absolute-path" }])); + expect(JSON.stringify(leak.body)).not.toContain("ghp_"); + }); + + it("clones a readable collection into the caller's account with lineage", async () => { + const created = (await create({ visibility: "public" })).body; + const clone = await call(`/api/v1/collections/${created.collection.id}/clone`, { slug: "copy" }, { key: otherKey }); + expect(clone.status).toBe(201); + expect(clone.body.collection).toMatchObject({ slug: "copy", visibility: "private", owned: true, clonedFromRevisionId: created.revision.id }); + expect(clone.body.revision.revisionDigest).toBe(created.revision.revisionDigest); + const privateOne = (await create({ slug: "secret-list" })).body.collection; + expect((await call(`/api/v1/collections/${privateOne.id}/clone`, { slug: "x" }, { key: otherKey })).status).toBe(404); + }); + + it("deletes only for the owner, and lists only the caller's collections", async () => { + const { id } = (await create()).body.collection; + expect((await call(`/api/v1/collections/${id}`, undefined, { method: "DELETE", key: otherKey })).status).toBe(404); + expect((await call("/api/v1/collections", undefined, { key: otherKey })).body.collections).toEqual([]); + expect((await call(`/api/v1/collections/${id}`, undefined, { method: "DELETE" })).body).toEqual({ deleted: true, id }); + expect((await call(`/api/v1/collections/${id}`)).status).toBe(404); + expect(h.d1.raw.prepare("SELECT count(*) AS n FROM collection_revisions").get()).toEqual({ n: 0 }); + }); + + it("refuses scoped publish keys, the admin secret and anonymous writes", async () => { + expect((await create()).status).toBe(201); + expect((await call("/api/v1/collections", { slug: "x" }, { key: scopedKey })).status).toBe(401); + expect((await call("/api/v1/collections", { slug: "x" }, { key: null })).status).toBe(401); + expect((await call("/api/v1/collections", undefined, { key: null })).status).toBe(401); + const admin = await call("/api/v1/collections", { slug: "x" }, { key: null, headers: { "X-Admin-Secret": "test-admin-secret-0123456789" } }); + expect(admin).toMatchObject({ status: 403, body: { error: { code: "collection.owner_only" } } }); + }); + + it("validates bodies: unknown fields, bad slugs and member shapes", async () => { + expect((await call("/api/v1/collections", { slug: "Bad Slug" })).body.error.code).toBe("invalid_request"); + expect((await call("/api/v1/collections", { slug: "ok", extra: 1 })).body.error.code).toBe("invalid_request"); + const bad = await call("/api/v1/collections", { slug: "ok", members: [{ packageName: "nope" }] }); + expect(bad).toMatchObject({ status: 422, body: { error: { code: "collection.invalid" } } }); + }); +}); diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc index 90e0fac..c8c2943 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -69,6 +69,8 @@ // before any R2 write. Workers Paid allows 10,000 per request; on a free account (1,000) set ~600. "SKILLX_RELEASE_SUBREQUEST_BUDGET": "5000", // Personal kit API (/api/v1/kits/@handle/name/...). "0" = off (404 everywhere). - "SKILLX_PERSONAL_KITS_API": "0" + "SKILLX_PERSONAL_KITS_API": "0", + // Collections API (/api/v1/collections/...). "0" = off (404 everywhere). Needs migration 0018. + "SKILLX_COLLECTIONS_API": "0" } } diff --git a/packages/contracts/src/collection-revision.ts b/packages/contracts/src/collection-revision.ts new file mode 100644 index 0000000..e496784 --- /dev/null +++ b/packages/contracts/src/collection-revision.ts @@ -0,0 +1,70 @@ +/** + * Collection revision content: the member list and the non-secret config of + * one immutable revision, validated together, and its digest. The digest is + * the canonical JSON of `{config, members}` with members sorted by package, + * so equal content always gets an equal digest whatever the input order. + */ +import { canonicalJsonBytes } from "./canonical-json"; +import { validateCollectionMember } from "./collections"; +import type { CollectionConfig, CollectionMember } from "./collections"; +import { sha256Digest } from "./release-signature"; +import { findSyncLeaks } from "./sync-leaks"; +import { isPlainObject, issue, validationFail, validationOk } from "./validation"; +import type { ValidationIssue, ValidationResult } from "./validation"; + +export const COLLECTION_LIMITS = { maxMembers: 256, maxConfigKeys: 64, maxConfigValueLength: 1024 } as const; +const CONFIG_KEY = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/; + +export interface CollectionContent { + members: CollectionMember[]; + config: CollectionConfig; +} + +/** Members (unique by package) and config (flat scalars, no secrets, no absolute paths). */ +export function validateCollectionContent(input: { members?: unknown; config?: unknown }): ValidationResult { + const issues: ValidationIssue[] = []; + const members = input.members ?? []; + if (!Array.isArray(members)) { + issues.push(issue("members", "type", "members must be an array")); + } else { + if (members.length > COLLECTION_LIMITS.maxMembers) issues.push(issue("members", "limit", `at most ${COLLECTION_LIMITS.maxMembers} members`)); + const seen = new Set(); + members.forEach((member, i) => { + for (const found of validateCollectionMember(member).issues) issues.push(issue(`members[${i}]${found.path === "" ? "" : `.${found.path}`}`, found.code, found.message)); + if (isPlainObject(member)) { + for (const key of Object.keys(member)) { + if (!["packageName", "version", "digest", "optional"].includes(key)) issues.push(issue(`members[${i}].${key}`, "unknown_field", "is not a member field")); + } + if (member.digest !== undefined && member.version === undefined) issues.push(issue(`members[${i}].digest`, "required", "a digest pin needs its version")); + const name = String(member.packageName); + if (seen.has(name)) issues.push(issue(`members[${i}].packageName`, "duplicate", `${name} is listed twice`)); + seen.add(name); + } + }); + } + const config = input.config ?? {}; + if (!isPlainObject(config)) { + issues.push(issue("config", "type", "config must be an object")); + } else { + const entries = Object.entries(config); + if (entries.length > COLLECTION_LIMITS.maxConfigKeys) issues.push(issue("config", "limit", `at most ${COLLECTION_LIMITS.maxConfigKeys} keys`)); + for (const [key, value] of entries) { + if (!CONFIG_KEY.test(key)) issues.push(issue(`config.${key}`, "pattern", "config keys are short identifiers")); + const scalar = typeof value === "boolean" || (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) || typeof value === "string"; + if (!scalar) issues.push(issue(`config.${key}`, "type", "config values are strings, booleans or non-negative integers")); + if (typeof value === "string" && value.length > COLLECTION_LIMITS.maxConfigValueLength) issues.push(issue(`config.${key}`, "limit", "config value too long")); + } + } + for (const leak of findSyncLeaks({ members, config })) issues.push(issue(leak.path, `sync.${leak.rule}`, "collections must not carry secrets or absolute local paths")); + return issues.length === 0 ? validationOk() : validationFail(issues); +} + +/** Members sorted by package name; the stored and digested order. */ +export function sortCollectionMembers(members: readonly CollectionMember[]): CollectionMember[] { + return [...members].sort((a, b) => (a.packageName < b.packageName ? -1 : a.packageName > b.packageName ? 1 : 0)); +} + +/** `sha256:` of the canonical `{config, members}` of a revision. */ +export function collectionRevisionDigest(content: CollectionContent): Promise { + return sha256Digest(canonicalJsonBytes({ config: content.config, members: sortCollectionMembers(content.members) })); +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f854a73..8fea069 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -23,3 +23,6 @@ export * from "./dto"; export * from "./payload-access"; export * from "./fixtures"; export * from "./personal-kit"; +export * from "./sync-leaks"; +export * from "./install-lock"; +export * from "./collection-revision"; diff --git a/packages/contracts/src/install-lock.test.ts b/packages/contracts/src/install-lock.test.ts new file mode 100644 index 0000000..b733003 --- /dev/null +++ b/packages/contracts/src/install-lock.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { INSTALL_LOCK_SCHEMA, INSTALL_RESOLVER_VERSION, lockDigest, lockOwnerKey, validateInstallLock } from "./install-lock"; +import type { InstallLock } from "./install-lock"; + +const D = (c: string) => `sha256:${c.repeat(64)}`; +const lock = (): InstallLock => ({ + schema: INSTALL_LOCK_SCHEMA, + resolver: INSTALL_RESOLVER_VERSION, + source: { kind: "kit", kit: "@alpha/my-kit", revisionId: "rev_00000000-0000-4000-8000-000000000001", revisionNumber: 2, revisionDigest: D("a") }, + runtimes: { "claude-code": ">=1.0.0" }, + packages: [ + { alias: "demo", package: "@alpha/demo", version: "1.0.0", releaseDigest: D("b"), compositionDigest: D("b"), overlayDigests: [] }, + { alias: "zed", package: "@alpha/zed", version: "2.0.0", releaseDigest: D("c"), compositionDigest: D("d"), overlayDigests: [D("e")] }, + ], +}); + +describe("skillx.lock/v1", () => { + it("validates a well-formed lock and digests it deterministically", async () => { + expect(validateInstallLock(lock())).toEqual({ valid: true, issues: [] }); + const { packages, ...rest } = lock(); + const reordered = JSON.parse(JSON.stringify({ packages, ...rest })); + expect(await lockDigest(reordered)).toBe(await lockDigest(lock())); + expect(lockOwnerKey(lock().source, lock().packages)).toBe("kit:@alpha/my-kit"); + }); + + it("rejects unsorted or duplicate aliases, unknown fields and composed digests without overlay", () => { + const bad = lock(); + bad.packages.reverse(); + (bad as unknown as Record).extra = 1; + bad.packages[1]!.compositionDigest = D("f"); + const codes = validateInstallLock(bad).issues.map((i) => `${i.path}:${i.code}`); + expect(codes).toEqual(expect.arrayContaining(["extra:unknown_field", "packages[1].alias:order", "packages[1].compositionDigest:mismatch"])); + }); + + it("refuses a lock that would sync a secret or an absolute path", () => { + const leaky = lock() as unknown as { runtimes: Record }; + leaky.runtimes = { "claude-code": "/home/alice/bin/claude" }; + const issues = validateInstallLock(leaky).issues; + expect(issues).toContainEqual(expect.objectContaining({ path: "runtimes.claude-code", code: "sync.absolute-path" })); + }); + + it("rejects an unknown source kind and a malformed version", () => { + const bad = lock() as unknown as { source: unknown; packages: Array<{ version: string }> }; + bad.source = { kind: "git" }; + bad.packages[0]!.version = "latest"; + const codes = validateInstallLock(bad).issues.map((i) => i.path); + expect(codes).toEqual(expect.arrayContaining(["source.kind", "packages[0].version"])); + }); +}); diff --git a/packages/contracts/src/install-lock.ts b/packages/contracts/src/install-lock.ts new file mode 100644 index 0000000..e8387a1 --- /dev/null +++ b/packages/contracts/src/install-lock.ts @@ -0,0 +1,138 @@ +/** + * `skillx.lock/v1`: the lockfile (`skillx.lock.json`) of the generic SkillX + * installer. It names what to install, never where: every package is pinned + * by exact version, release tree digest and the digest of the composed tree + * that lands on disk (equal to the release digest when there is no overlay). + * It carries no clock, no machine path and no secret, so the same source + * resolves to the same bytes on every machine and the file is safe to sync. + * + * `lockDigest` hashes the canonical JSON of the whole document; two machines + * agree on a lock exactly when their digests agree. + */ +import { canonicalJsonBytes } from "./canonical-json"; +import { PACKAGE_NAME_PATTERN, SEMVER_PATTERN } from "./package-manifest"; +import { findSyncLeaks } from "./sync-leaks"; +import { isNonEmptyString, isPlainObject, issue, validationFail, validationOk } from "./validation"; +import type { ValidationIssue, ValidationResult } from "./validation"; +import { RELEASE_DIGEST_PATTERN } from "./verification-evidence"; +import { sha256Digest } from "./release-signature"; + +export const INSTALL_LOCK_SCHEMA = "skillx.lock/v1"; +export const INSTALL_RESOLVER_VERSION = "skillx.install-resolver/1"; +export const INSTALL_LOCK_FILENAME = "skillx.lock.json"; +/** Installed directory name: a lowercase skill alias. */ +export const INSTALL_ALIAS_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/; +const KIT_PATTERN = /^@[a-z0-9][a-z0-9-]{0,38}\/[a-z0-9][a-z0-9._-]{0,63}$/; +const ID_PATTERN = /^[a-z]+_[0-9a-f-]{36}$/; +const RUNTIME_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/; +export const INSTALL_LOCK_LIMITS = { maxPackages: 256 } as const; + +export type InstallLockSource = + | { kind: "kit"; kit: string; revisionId: string; revisionNumber: number; revisionDigest: string } + | { kind: "collection"; collectionId: string; revisionNumber: number; revisionDigest: string } + | { kind: "packages" }; + +export interface InstallLockPackage { + alias: string; + package: string; + version: string; + releaseDigest: string; + /** Digest of the tree that is installed: the release tree with every overlay applied. */ + compositionDigest: string; + /** One digest per overlay layer; empty when the release is installed as published. */ + overlayDigests: string[]; +} + +export interface InstallLock { + schema: typeof INSTALL_LOCK_SCHEMA; + resolver: typeof INSTALL_RESOLVER_VERSION; + source: InstallLockSource; + /** Runtime constraints declared by the source (kit `runtimes`); empty means unconstrained. */ + runtimes: Record; + packages: InstallLockPackage[]; +} + +/** The owner key a source installs under, e.g. `kit:@alpha/my-kit`. Stable across revisions. */ +export function lockOwnerKey(source: InstallLockSource, packages: readonly InstallLockPackage[]): string { + if (source.kind === "kit") return `kit:${source.kit}`; + if (source.kind === "collection") return `collection:${source.collectionId}`; + return `packages:${packages.map((p) => p.package).join(",")}`; +} + +/** `sha256:` of the canonical JSON of the lock. */ +export function lockDigest(lock: InstallLock): Promise { + return sha256Digest(canonicalJsonBytes(lock)); +} + +/** Packages sorted by alias (UTF-16 order is fine: aliases are ASCII). */ +export function sortLockPackages(packages: readonly InstallLockPackage[]): InstallLockPackage[] { + return [...packages].sort((a, b) => (a.alias < b.alias ? -1 : a.alias > b.alias ? 1 : 0)); +} + +function checkKeys(value: Record, allowed: readonly string[], path: string, issues: ValidationIssue[]): void { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) issues.push(issue(path === "" ? key : `${path}.${key}`, "unknown_field", "is not a skillx.lock/v1 field")); + } +} + +function checkSource(source: unknown, issues: ValidationIssue[]): void { + if (!isPlainObject(source)) return void issues.push(issue("source", "type", "must be an object")); + const revision = (keys: string[]) => { + checkKeys(source, ["kind", ...keys], "source", issues); + if (!Number.isSafeInteger(source.revisionNumber) || (source.revisionNumber as number) < 1) issues.push(issue("source.revisionNumber", "type", "must be a positive integer")); + if (!RELEASE_DIGEST_PATTERN.test(String(source.revisionDigest))) issues.push(issue("source.revisionDigest", "pattern", "must be sha256:")); + }; + if (source.kind === "kit") { + revision(["kit", "revisionId", "revisionNumber", "revisionDigest"]); + if (!KIT_PATTERN.test(String(source.kit))) issues.push(issue("source.kit", "pattern", "must be @handle/name")); + if (!ID_PATTERN.test(String(source.revisionId))) issues.push(issue("source.revisionId", "pattern", "must be a revision id")); + } else if (source.kind === "collection") { + revision(["collectionId", "revisionNumber", "revisionDigest"]); + if (!ID_PATTERN.test(String(source.collectionId))) issues.push(issue("source.collectionId", "pattern", "must be a collection id")); + } else if (source.kind === "packages") { + checkKeys(source, ["kind"], "source", issues); + } else { + issues.push(issue("source.kind", "unsupported_value", "must be kit, collection or packages")); + } +} + +function checkPackage(pkg: unknown, at: string, issues: ValidationIssue[]): void { + if (!isPlainObject(pkg)) return void issues.push(issue(at, "type", "must be an object")); + checkKeys(pkg, ["alias", "package", "version", "releaseDigest", "compositionDigest", "overlayDigests"], at, issues); + if (!INSTALL_ALIAS_PATTERN.test(String(pkg.alias))) issues.push(issue(`${at}.alias`, "pattern", "must be a lowercase directory alias")); + if (!isNonEmptyString(pkg.package) || !PACKAGE_NAME_PATTERN.test(pkg.package)) issues.push(issue(`${at}.package`, "pattern", "must be @publisher/slug")); + if (!isNonEmptyString(pkg.version) || !SEMVER_PATTERN.test(pkg.version)) issues.push(issue(`${at}.version`, "pattern", "must be an exact semantic version")); + for (const field of ["releaseDigest", "compositionDigest"] as const) { + if (!RELEASE_DIGEST_PATTERN.test(String(pkg[field]))) issues.push(issue(`${at}.${field}`, "pattern", "must be sha256:")); + } + if (!Array.isArray(pkg.overlayDigests) || pkg.overlayDigests.some((d) => !RELEASE_DIGEST_PATTERN.test(String(d)))) { + issues.push(issue(`${at}.overlayDigests`, "type", "must be an array of sha256:")); + } else if (pkg.overlayDigests.length === 0 && pkg.compositionDigest !== pkg.releaseDigest) { + issues.push(issue(`${at}.compositionDigest`, "mismatch", "must equal releaseDigest when there is no overlay")); + } +} + +/** Structural validation, plus the rule that a lock never carries a secret or an absolute path. */ +export function validateInstallLock(input: unknown): ValidationResult { + if (!isPlainObject(input)) return validationFail([issue("", "type", "lock must be an object")]); + const issues: ValidationIssue[] = []; + checkKeys(input, ["schema", "resolver", "source", "runtimes", "packages"], "", issues); + if (input.schema !== INSTALL_LOCK_SCHEMA) issues.push(issue("schema", "unsupported_value", `must equal '${INSTALL_LOCK_SCHEMA}'`)); + if (input.resolver !== INSTALL_RESOLVER_VERSION) issues.push(issue("resolver", "unsupported_value", `must equal '${INSTALL_RESOLVER_VERSION}'`)); + checkSource(input.source, issues); + if (!isPlainObject(input.runtimes) || Object.entries(input.runtimes).some(([k, v]) => !RUNTIME_PATTERN.test(k) || typeof v !== "string")) { + issues.push(issue("runtimes", "type", "must map runtime ids to version ranges")); + } + if (!Array.isArray(input.packages)) { + issues.push(issue("packages", "type", "must be an array")); + } else { + if (input.packages.length > INSTALL_LOCK_LIMITS.maxPackages) issues.push(issue("packages", "limit", `at most ${INSTALL_LOCK_LIMITS.maxPackages} packages`)); + input.packages.forEach((pkg, i) => checkPackage(pkg, `packages[${i}]`, issues)); + const aliases = input.packages.map((p) => (isPlainObject(p) ? String(p.alias) : "")); + aliases.forEach((alias, i) => { + if (i > 0 && aliases[i - 1]! >= alias) issues.push(issue(`packages[${i}].alias`, "order", "packages must be sorted by alias without duplicates")); + }); + } + for (const leak of findSyncLeaks(input)) issues.push(issue(leak.path, `sync.${leak.rule}`, "a lock must not carry secrets or absolute local paths")); + return issues.length === 0 ? validationOk() : validationFail(issues); +} diff --git a/packages/contracts/src/sync-leaks.test.ts b/packages/contracts/src/sync-leaks.test.ts new file mode 100644 index 0000000..4d484d1 --- /dev/null +++ b/packages/contracts/src/sync-leaks.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { findSyncLeaks, hasAbsolutePath, hasSecretToken } from "./sync-leaks"; +import { collectionRevisionDigest, validateCollectionContent } from "./collection-revision"; + +describe("sync-leak scanner", () => { + it.each([ + "-----BEGIN OPENSSH PRIVATE KEY-----", + "Authorization: Bearer abcdefghijklmnop1234", + "https://user:hunter22@example.com/repo.git", + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig", + "ghp_0123456789abcdefghijklmnopqrstuvwxyz", + "sk-ant-api03-abcdefghijklmnopqrstuvwx", + "AKIAABCDEFGHIJKLMNOP", + "sx_abcdefghijklmnopqrstuv", + ])("flags the credential %#", (text) => { + expect(hasSecretToken(text)).toBe(true); + }); + + it.each(["/home/alice/.claude/skills", "/Users/bob/x", "C:\\Users\\carol", "~/notes", "\\\\server\\share", "file:///etc/passwd", "path=/home/x/y"])( + "flags the absolute path %s", + (text) => expect(hasAbsolutePath(text)).toBe(true), + ); + + it.each(["scripts/run.sh", "@alpha/demo", "sha256:" + "a".repeat(64), "https://github.com/a/b", "claude-code", "1.2.3"])("passes %s", (text) => { + expect(hasSecretToken(text) || hasAbsolutePath(text)).toBe(false); + }); + + it("reports paths and rules, never the matched text", () => { + const leaks = findSyncLeaks({ config: { apiKey: "abcdef123456", home: "/home/me", ok: "fine" }, list: ["x", "ghp_0123456789abcdefghijklmnopqrstuvwxyz"] }); + expect(leaks).toEqual([ + { path: "config.apiKey", rule: "secret-key" }, + { path: "config.home", rule: "absolute-path" }, + { path: "list[1]", rule: "secret-token" }, + ]); + expect(JSON.stringify(leaks)).not.toContain("abcdef123456"); + }); +}); + +describe("collection revision content", () => { + const member = { packageName: "@alpha/demo", version: "1.0.0" }; + + it("accepts members and scalar config, and digests independent of member order", async () => { + const a = { members: [member, { packageName: "@alpha/zed" }], config: { scope: "user" } }; + const b = { members: [{ packageName: "@alpha/zed" }, member], config: { scope: "user" } }; + expect(validateCollectionContent(a).valid).toBe(true); + expect(await collectionRevisionDigest(a)).toBe(await collectionRevisionDigest(b)); + }); + + it("refuses duplicates, digest pins without version, nested config, secrets and paths", () => { + const result = validateCollectionContent({ + members: [member, member, { packageName: "@alpha/x", digest: "sha256:" + "b".repeat(64) }], + config: { nested: { a: 1 }, token: "abcdefgh1234", dir: "C:\\Users\\me" } as never, + }); + const codes = result.issues.map((i) => `${i.path}:${i.code}`); + expect(codes).toEqual( + expect.arrayContaining(["members[1].packageName:duplicate", "members[2].digest:required", "config.nested:type", "config.token:sync.secret-key", "config.dir:sync.absolute-path"]), + ); + }); +}); diff --git a/packages/contracts/src/sync-leaks.ts b/packages/contracts/src/sync-leaks.ts new file mode 100644 index 0000000..739442d --- /dev/null +++ b/packages/contracts/src/sync-leaks.ts @@ -0,0 +1,95 @@ +/** + * Sync-leak scanner: secrets and absolute local paths must never leave a + * machine inside anything SkillX uploads or syncs (collection revisions, + * lockfiles). The scan walks a JSON value and reports `{path, rule}` pairs; + * it never returns the matched text, so a finding can be logged safely. + * + * Rules: + * - `secret-key`: an object key that names a password or secret (shared + * policy normalization, `secretKeyKind`) holding a non-empty string; + * - `secret-token`: a value that looks like a credential (private key block, + * `Bearer` token, URL with `user:pass@`, JWT, well-known vendor prefixes, + * SkillX API keys); + * - `absolute-path`: a value that is, or embeds, an absolute machine path + * (`/home/..`, `/Users/..`, `C:\..`, `\\host\..`, `~/..`, `file://`). + * Release-relative paths (`scripts/run.sh`) and URLs pass. + */ +import { secretKeyKind } from "./personal-kit/proposal-policy"; + +export type SyncLeakRule = "secret-key" | "secret-token" | "absolute-path"; + +export interface SyncLeak { + /** JSON path of the offending member, e.g. `config.token` or `packages[2].alias`. */ + path: string; + rule: SyncLeakRule; +} + +const TOKEN_RULES: readonly RegExp[] = [ + /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----/, + /\bbearer\s+[A-Za-z0-9._~+/-]{16,}=*/i, + /\b[a-z][a-z0-9+.-]{0,31}:\/\/[^\s/?#:@]+:[^\s/?#@]+@/i, + /\beyJ[A-Za-z0-9_-]{4,}\.eyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*/, + /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}\b/, + /\bgithub_pat_[A-Za-z0-9_]{40,}\b/, + /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}\b/, + /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/, + /\bxox[abposr]-[A-Za-z0-9-]{10,}\b/, + /\bAIza[0-9A-Za-z_-]{35}\b/, + /\bsx_[A-Za-z0-9_-]{16,}\b/, +]; + +/** Whole-value or embedded absolute machine paths. */ +const PATH_RULES: readonly RegExp[] = [ + /^\//, + /^~[\\/]/, + /^[A-Za-z]:[\\/]/, + /^\\\\/, + /\bfile:\/\//i, + /(?:^|[\s"'=(,;])(?:\/(?:home|Users|root|var|tmp|private|opt|etc|mnt|Volumes)\/)/, + /(?:^|[\s"'=(,;])[A-Za-z]:\\/, +]; + +/** True when `text` holds a credential-looking token. */ +export function hasSecretToken(text: string): boolean { + return TOKEN_RULES.some((re) => re.test(text)); +} + +/** True when `text` is or embeds an absolute local path. */ +export function hasAbsolutePath(text: string): boolean { + return PATH_RULES.some((re) => re.test(text)); +} + +const MAX_DEPTH = 64; + +/** Every leak in `value`, in document order. Deeper than 64 levels is reported as a leak at that path. */ +export function findSyncLeaks(value: unknown, path = ""): SyncLeak[] { + const out: SyncLeak[] = []; + walk(value, path, 0, out); + return out; +} + +function walk(value: unknown, path: string, depth: number, out: SyncLeak[]): void { + if (depth > MAX_DEPTH) { + out.push({ path, rule: "secret-token" }); + return; + } + if (typeof value === "string") { + if (hasSecretToken(value)) out.push({ path, rule: "secret-token" }); + else if (hasAbsolutePath(value)) out.push({ path, rule: "absolute-path" }); + return; + } + if (Array.isArray(value)) { + value.forEach((item, i) => walk(item, `${path}[${i}]`, depth + 1, out)); + return; + } + if (typeof value !== "object" || value === null) return; + for (const [key, child] of Object.entries(value)) { + const childPath = path === "" ? key : `${path}.${key}`; + if (hasAbsolutePath(key)) out.push({ path: childPath, rule: "absolute-path" }); + if (typeof child === "string" && child.length > 0 && secretKeyKind(key) !== null) { + out.push({ path: childPath, rule: "secret-key" }); + continue; + } + walk(child, childPath, depth + 1, out); + } +} diff --git a/plans/261019-1914-issues-31-88-delivery/phase3-design.md b/plans/261019-1914-issues-31-88-delivery/phase3-design.md new file mode 100644 index 0000000..de8a8a5 --- /dev/null +++ b/plans/261019-1914-issues-31-88-delivery/phase3-design.md @@ -0,0 +1,93 @@ +# Phase 3 (#35) design: collections API, resolver, lockfile, installer, sync, local MCP + +Status: accepted for implementation (2026-10-19). Scope: issue #35 checklist; #55 data +model is already on `main` and is reused, not re-implemented. + +## What already exists and is reused + +| Need | Existing piece | Decision | +|------|----------------|----------| +| Kit identity, immutable revisions, CAS, visibility, activation, lock | Personal kit API (`/api/v1/kits/*`, `kit-lock.ts`, `skillx.personal-kit-lock/v1`) | Reused as the **kit** source for the resolver. No new kit endpoints. | +| Pinned bytes | Release read API (`/api/v1/releases/:digest/{manifest,files/*}?version=&package=`) | The only byte source of the installer. | +| Manifest signatures | `verifyReleaseManifest` (contracts) | Client verifies with embedded/configured trusted keys, fail closed. | +| Overlay composition | `applyOverlay`, `memoryTree` (contracts) | Kit entries with an overlay are composed client side and must hash to `compositionDigest`. | +| Collections tables | `collections`, `collection_revisions` (migration 0009), `validateCollectionMember` | Reused; one additive migration. | +| Compatibility engine | `normalizeCompatibility` (contracts) | Used for package declarations found in the tree (`skillx.json`); kit `runtimes` gate the target. | + +## 1. Collections web API (`SKILLX_COLLECTIONS_API`, 404 when off) + +A collection is a recipe of package references (no overlays). Kits stay the +personal, overlay-capable unit; collections are the shareable list. + +- `GET /api/v1/collections` (own list), `POST /api/v1/collections` (create; revision 1). +- `GET|PATCH|DELETE /api/v1/collections/:id` (metadata: `name`, `visibility`). +- `GET|POST /api/v1/collections/:id/revisions`, `GET .../revisions/:n` (full-replace revision). +- `POST /api/v1/collections/:id/members` (`add[]`, `remove[]` → new revision). +- `POST /api/v1/collections/:id/clone` (new private collection of the caller, lineage kept). +- Writes: owner via session or unscoped API key, `RELEASE_WRITE_LIMITER`; CAS by + `expectedRevision` (409 `collection.revision_conflict`). Reads: private → owner only + (others 404), `unlisted`/`public` → anyone. +- Revisions immutable: DB trigger aborts any UPDATE; `revision_digest` = sha256 of + canonical `{members, config}`. +- `config` and members pass the shared **sync-leak scanner** (secrets, absolute paths) → 422. + +Migration `0017_collection_revision_lineage.sql` (additive, rollback SQL in header): +`collections.cloned_from_revision_id`, `collection_revisions.revision_digest`, trigger +`collection_revisions_immutable`. + +## 2. Lockfile `skillx.lock/v1` (contracts `install-lock.ts`) + +Canonical JSON, no timestamps, no machine paths. `source` = kit revision, collection +revision or explicit packages; `target.runtime`; `packages[]` sorted by alias with +`package, version, releaseDigest, compositionDigest, overlayDigests, alias`. Validator + +`lockDigest`. `findSyncLeaks(value)` (also in contracts) is run before a lock is written, +read for sync, or a collection is stored. + +## 3. Installer (CLI `src/lib/install/`) + +- **Adapters**: `claude-code` → `$CLAUDE_CONFIG_DIR/skills` or `~/.claude/skills`; + `codex` → `$CODEX_HOME/skills` or `~/.codex/skills`. Home from `os.homedir()` + (HOME / USERPROFILE). State root `~/.skillx` (`SKILLX_HOME` override). +- **Resolve** → lock. **Plan** per package: `install | update | unchanged | remove | conflict`. +- **Fetch/verify**: signed manifest (trusted key, identity, version, digest), every file + sha256 + size, tree digest; overlay applied and checked against `compositionDigest`. + Content-addressed blob cache `~/.skillx/cache/blobs`. +- **Atomic apply**: stage in `/.skillx-staging/`, journal, rename old → backup, + stage → target, write receipt, drop backup, clear journal. Recovery at start of every + run rolls forward (target present, receipt from journal) or back (restore backup). +- **Receipts** `~/.skillx/receipts//.json` (`skillx.install-receipt/v1`): + package, digests, owned files with sha256, `requiredBy` owner keys (ref-count). +- **Ownership proof**: delete only receipt-listed files whose current sha256 matches. + Modified/unknown files are kept and reported; a dir with unknown content is never replaced + silently (conflict unless `--force` which still preserves unknown files). +- **Uninstall** removes one owner key; files go only when `requiredBy` becomes empty. + +## 4. Commands + +`skillx install ` (`kit:@h/k[#revId]`, `collection:[#n]`, `@p/s[@ver]`), +`skillx update [ref]`, `skillx uninstall `, `skillx list`, `skillx sync --locked +[--lock file] [--from kit:@h/k#rev]`, `skillx mcp`. All accept `--runtime`, `--json`; +install/update/sync accept `--dry-run`. `sync --locked` refuses drift: lock digest +mismatch vs remote revision, local modifications, any digest mismatch while fetching. + +## 5. Local stdio MCP (`skillx mcp`) + +Newline-delimited JSON-RPC 2.0 (MCP stdio): `initialize`, `tools/list`, `tools/call`, +`ping`. Tools: `inspect` (adapters, receipts), `plan` (ref, runtime → plan + `planDigest`), +`apply` (requires `confirm: true`; optional `planDigest` must match the fresh plan). + +## Tests + +Contracts (lock, leak scanner), web collections API over FakeD1, adapters/paths, verify +(tampered file, bad signature, wrong identity), apply/update/uninstall/ref-count/preserve, +interruption at every journal step then rerun converges, E2E machine A → kit revision on the +in-memory server → machine B identical digests, MCP confirm gate. Every test uses temp +HOME/USERPROFILE/CODEX_HOME/SKILLX_HOME. + +## Out of scope / follow-ups + +- Production registry public key is not in the repo: the CLI ships an empty trust store + and reads `SKILLX_TRUSTED_KEYS` / `skillx config set trustedKeys`; embedding the + production key needs the operator to publish it. +- Dependency graphs between packages: releases declare none today; resolver reports + duplicates/alias collisions only. From 2738fe73f60190f48fe01b6fb4d1ed85d4fac7c1 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Mon, 19 Oct 2026 19:56:43 +0700 Subject: [PATCH 2/9] feat(cli): verified installer with lockfile, receipts, sync and local MCP Adds skillx install, update, uninstall, list, sync --locked and mcp. Sources (kit revision, collection revision or packages) resolve to exact versions and digests in a skillx.lock/v1 lock; every manifest signature and file SHA-256 is verified before a staged, journaled rename into the Claude Code or Codex skills directory. Receipts prove ownership, so uninstall keeps edited and unknown files and shared skills are ref-counted. Interrupted runs recover on rerun. Co-Authored-By: Claude Opus 5.5 --- apps/web/tests/install/install-e2e.test.ts | 139 +++++++++++++++ .../tests/install/install-recovery.test.ts | 98 +++++++++++ apps/web/tests/install/install-server.ts | 81 +++++++++ packages/cli/package.json | 1 + packages/cli/src/commands/install-runner.ts | 67 +++++++ packages/cli/src/commands/install.ts | 126 ++++++++++++++ packages/cli/src/commands/mcp.ts | 18 ++ packages/cli/src/index.ts | 3 + packages/cli/src/lib/install/adapters.ts | 63 +++++++ .../cli/src/lib/install/apply-transaction.ts | 151 ++++++++++++++++ packages/cli/src/lib/install/fetch-verify.ts | 128 ++++++++++++++ .../cli/src/lib/install/install-context.ts | 91 ++++++++++ .../cli/src/lib/install/install-engine.ts | 115 ++++++++++++ .../cli/src/lib/install/install-errors.ts | 24 +++ .../cli/src/lib/install/install-operations.ts | 127 ++++++++++++++ packages/cli/src/lib/install/install-state.ts | 164 ++++++++++++++++++ .../cli/src/lib/install/install-units.test.ts | 95 ++++++++++ packages/cli/src/lib/install/local-mcp.ts | 119 +++++++++++++ packages/cli/src/lib/install/lock-file.ts | 30 ++++ packages/cli/src/lib/install/planner.ts | 99 +++++++++++ .../cli/src/lib/install/registry-client.ts | 113 ++++++++++++ packages/cli/src/lib/install/release-pins.ts | 40 +++++ packages/cli/src/lib/install/remove-owned.ts | 86 +++++++++ packages/cli/src/lib/install/resolver.ts | 137 +++++++++++++++ packages/cli/src/lib/install/sync-locked.ts | 63 +++++++ packages/cli/src/lib/install/trusted-keys.ts | 70 ++++++++ packages/cli/src/lib/output.ts | 6 + pnpm-lock.yaml | 3 + 28 files changed, 2257 insertions(+) create mode 100644 apps/web/tests/install/install-e2e.test.ts create mode 100644 apps/web/tests/install/install-recovery.test.ts create mode 100644 apps/web/tests/install/install-server.ts create mode 100644 packages/cli/src/commands/install-runner.ts create mode 100644 packages/cli/src/commands/install.ts create mode 100644 packages/cli/src/commands/mcp.ts create mode 100644 packages/cli/src/lib/install/adapters.ts create mode 100644 packages/cli/src/lib/install/apply-transaction.ts create mode 100644 packages/cli/src/lib/install/fetch-verify.ts create mode 100644 packages/cli/src/lib/install/install-context.ts create mode 100644 packages/cli/src/lib/install/install-engine.ts create mode 100644 packages/cli/src/lib/install/install-errors.ts create mode 100644 packages/cli/src/lib/install/install-operations.ts create mode 100644 packages/cli/src/lib/install/install-state.ts create mode 100644 packages/cli/src/lib/install/install-units.test.ts create mode 100644 packages/cli/src/lib/install/local-mcp.ts create mode 100644 packages/cli/src/lib/install/lock-file.ts create mode 100644 packages/cli/src/lib/install/planner.ts create mode 100644 packages/cli/src/lib/install/registry-client.ts create mode 100644 packages/cli/src/lib/install/release-pins.ts create mode 100644 packages/cli/src/lib/install/remove-owned.ts create mode 100644 packages/cli/src/lib/install/resolver.ts create mode 100644 packages/cli/src/lib/install/sync-locked.ts create mode 100644 packages/cli/src/lib/install/trusted-keys.ts diff --git a/apps/web/tests/install/install-e2e.test.ts b/apps/web/tests/install/install-e2e.test.ts new file mode 100644 index 0000000..bb9f1dc --- /dev/null +++ b/apps/web/tests/install/install-e2e.test.ts @@ -0,0 +1,139 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { lockDigest } from "@skillx/contracts"; +import { installRefs, listInstalled, uninstallRef } from "../../../../packages/cli/src/lib/install/install-operations"; +import { readLockFile, writeLockFile } from "../../../../packages/cli/src/lib/install/lock-file"; +import { readReceipt, scanDir } from "../../../../packages/cli/src/lib/install/install-state"; +import { syncLocked } from "../../../../packages/cli/src/lib/install/sync-locked"; +import { BODY, skillText } from "../personal-kits/kit-harness"; +import { createInstallServer } from "./install-server"; + +type Server = Awaited>; +let s: Server; + +const DEMO = { "SKILL.md": skillText(BODY), "references/guide.md": "guide v1\n" }; +const TOOLS = { "SKILL.md": skillText("# Tools\n", "name: tools\ndescription: Tools"), "scripts/run.sh": "echo run\n" }; + +beforeEach(async () => { + s = await createInstallServer(); + await s.k.publish("@alpha/demo", "1.0.0", DEMO); + await s.k.publish("@alpha/tools", "1.0.0", TOOLS); +}); +afterEach(() => s.cleanup()); + +/** Stores and activates a kit revision: demo with a personal overlay, tools as published. */ +async function activeKit() { + const mine = { ...DEMO, "SKILL.md": skillText(BODY.replace("three", "THREE (mine)")) }; + const document = s.k.doc([await s.k.entry("@alpha/demo", "1.0.0", mine), await s.k.entry("@alpha/tools", "1.0.0")]); + const rev = (await s.k.call("revisions", { expectedGeneration: 0, idempotencyKey: s.k.idem(), document })).body.revision; + const act = await s.k.call("activate", { expectedGeneration: 1, revisionId: rev.id, approval: { revisionDigest: rev.digest } }); + expect(act.status).toBe(200); + return { rev, mine }; +} + +const tree = (dir: string) => scanDir(dir).map((f) => `${f.path} ${f.sha256}`); + +describe("installer end to end", () => { + it("installs a kit revision on machine A and restores identical digests on machine B from the lock", async () => { + const { rev, mine } = await activeKit(); + const a = s.machine("a"); + const ctxA = a.context(); + const outcome = await installRefs(ctxA, ["kit:@alpha/my-kit"]); + expect(outcome.lock.source).toMatchObject({ kind: "kit", kit: "@alpha/my-kit", revisionId: rev.id, revisionDigest: rev.digest }); + expect(outcome.lock.packages.map((p) => [p.alias, p.overlayDigests.length])).toEqual([["demo", 1], ["tools", 0]]); + const demoA = join(ctxA.skillsDir, "demo"); + expect(ctxA.skillsDir).toBe(join(a.root, ".claude", "skills")); + expect(readFileSync(join(demoA, "SKILL.md"), "utf8")).toBe(mine["SKILL.md"]); + + const lockPath = writeLockFile(join(a.root, "skillx.lock.json"), outcome.lock); + expect(readFileSync(lockPath, "utf8")).not.toContain(a.root); + + const b = s.machine("b"); + const ctxB = b.context(); + const restored = await syncLocked(ctxB, readLockFile(lockPath)); + expect(restored.lockDigest).toBe(await lockDigest(outcome.lock)); + for (const alias of ["demo", "tools"]) { + expect(tree(join(ctxB.skillsDir, alias))).toEqual(tree(join(ctxA.skillsDir, alias))); + expect(readReceipt(ctxB.home, "claude-code", alias)!.compositionDigest).toBe(readReceipt(ctxA.home, "claude-code", alias)!.compositionDigest); + } + // Rerunning a sync on a converged machine changes nothing. + const again = await syncLocked(ctxB, readLockFile(lockPath)); + expect(again.items[0]!.plan.actions.map((x) => x.action)).toEqual(["unchanged", "unchanged"]); + + // A third machine restores the kit revision straight from the server, into Codex. + const c = s.machine("c"); + const ctxC = c.context({ runtime: "codex" }); + await installRefs(ctxC, [`kit:@alpha/my-kit#${rev.id}`]); + expect(ctxC.skillsDir).toBe(join(c.root, ".codex", "skills")); + expect(tree(join(ctxC.skillsDir, "demo"))).toEqual(tree(demoA)); + }); + + it("refuses drift: an edited lock, and local edits unless forced (which backs them up)", async () => { + await activeKit(); + const a = s.machine("a"); + const ctx = a.context(); + const { lock } = await installRefs(ctx, ["kit:@alpha/my-kit"]); + const tampered = { ...lock, packages: lock.packages.map((p) => (p.alias === "tools" ? { ...p, alias: "tools2" } : p)) }; + await expect(syncLocked(ctx, tampered)).rejects.toMatchObject({ code: "conflict" }); + + const skill = join(ctx.skillsDir, "tools", "SKILL.md"); + writeFileSync(skill, "my local edit\n"); + await expect(syncLocked(ctx, lock)).rejects.toMatchObject({ code: "conflict", details: { conflicts: [expect.objectContaining({ alias: "tools", reason: "local_modified" })] } }); + expect(readFileSync(skill, "utf8")).toBe("my local edit\n"); + + const forced = await syncLocked(a.context({ force: true }), lock); + const backupDir = forced.applied[0]!.changed.find((c) => c.alias === "tools")!.backupDir!; + expect(readFileSync(join(backupDir, "SKILL.md"), "utf8")).toBe("my local edit\n"); + expect(readFileSync(skill, "utf8")).toBe(TOOLS["SKILL.md"]); + }); + + it("verifies before writing: a tampered file or an untrusted signing key installs nothing", async () => { + const a = s.machine("a"); + s.setRewrite(async (path, res) => (path.endsWith("/files/scripts/run.sh") ? new Response("echo pwned\n", { status: 200 }) : res)); + await expect(installRefs(a.context(), ["@alpha/tools"])).rejects.toMatchObject({ code: "integrity_error" }); + s.setRewrite(null); + await expect(installRefs(a.context({ keys: new Map() }), ["@alpha/tools"])).rejects.toMatchObject({ code: "integrity_error" }); + expect(existsSync(join(a.context().skillsDir, "tools"))).toBe(false); + expect(listInstalled(a.context()).skills).toEqual([]); + }); + + it("installs a collection revision, pinned and latest members, into Codex", async () => { + await s.k.publish("@alpha/tools", "1.1.0", { ...TOOLS, "scripts/run.sh": "echo run 1.1\n" }); + const created = await s.serverFetch("https://skillx.test/api/v1/collections", { + method: "POST", + headers: { Authorization: `Bearer ${s.k.ownerKey}`, "Content-Type": "application/json" }, + body: JSON.stringify({ slug: "starter", members: [{ packageName: "@alpha/demo", version: "1.0.0" }, { packageName: "@alpha/tools" }] }), + }); + const { collection } = (await created.json()) as { collection: { id: string } }; + const c = s.machine("c"); + const ctx = c.context({ runtime: "codex" }); + const outcome = await installRefs(ctx, [`collection:${collection.id}`]); + expect(outcome.lock.source).toMatchObject({ kind: "collection", collectionId: collection.id, revisionNumber: 1 }); + expect(outcome.lock.packages.map((p) => `${p.package}@${p.version}`)).toEqual(["@alpha/demo@1.0.0", "@alpha/tools@1.1.0"]); + expect(readFileSync(join(c.root, ".codex", "skills", "tools", "scripts", "run.sh"), "utf8")).toBe("echo run 1.1\n"); + }); + + it("ref-counts shared skills and uninstalls with ownership proof, keeping edited and unknown files", async () => { + await activeKit(); + const a = s.machine("a"); + const ctx = a.context(); + await installRefs(ctx, ["kit:@alpha/my-kit"]); + await installRefs(ctx, ["@alpha/tools@1.0.0"]); + expect(readReceipt(ctx.home, "claude-code", "tools")!.requiredBy).toEqual(["kit:@alpha/my-kit", "packages:@alpha/tools"]); + + const kitGone = await uninstallRef(ctx, ["kit:@alpha/my-kit"]); + expect(kitGone.removed.map((r) => r.alias)).toEqual(["demo"]); + expect(existsSync(join(ctx.skillsDir, "demo"))).toBe(false); + expect(readReceipt(ctx.home, "claude-code", "tools")!.requiredBy).toEqual(["packages:@alpha/tools"]); + + const tools = join(ctx.skillsDir, "tools"); + writeFileSync(join(tools, "SKILL.md"), "edited\n"); + writeFileSync(join(tools, "notes.txt"), "mine\n"); + const gone = await uninstallRef(ctx, ["@alpha/tools"]); + expect(gone.removed[0]).toMatchObject({ alias: "tools", removed: ["scripts/run.sh"], keptModified: ["SKILL.md"], keptUnknown: ["notes.txt"] }); + expect(scanDir(tools).map((f) => f.path)).toEqual(["SKILL.md", "notes.txt"]); + expect(readReceipt(ctx.home, "claude-code", "tools")).toBeNull(); + await expect(uninstallRef(ctx, ["@alpha/tools"])).rejects.toMatchObject({ code: "not_found" }); + }); +}); diff --git a/apps/web/tests/install/install-recovery.test.ts b/apps/web/tests/install/install-recovery.test.ts new file mode 100644 index 0000000..4f29e2e --- /dev/null +++ b/apps/web/tests/install/install-recovery.test.ts @@ -0,0 +1,98 @@ +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { CrashPoint } from "../../../../packages/cli/src/lib/install/apply-transaction"; +import { installRefs } from "../../../../packages/cli/src/lib/install/install-operations"; +import { readReceipt, scanDir } from "../../../../packages/cli/src/lib/install/install-state"; +import { handleMessage } from "../../../../packages/cli/src/lib/install/local-mcp"; +import { skillText } from "../personal-kits/kit-harness"; +import { createInstallServer } from "./install-server"; + +type Server = Awaited>; +let s: Server; + +const V1 = { "SKILL.md": skillText("# Demo v1\n"), "old-only.md": "v1 file\n" }; +const V2 = { "SKILL.md": skillText("# Demo v2\n"), "new-only.md": "v2 file\n" }; + +beforeEach(async () => { + s = await createInstallServer(); + await s.k.publish("@alpha/demo", "1.0.0", V1); + await s.k.publish("@alpha/demo", "2.0.0", V2); +}); +afterEach(() => s.cleanup()); + +const POINTS: CrashPoint[] = ["staged", "journaled", "moved-old", "swapped", "receipt-written"]; +const paths = (dir: string) => scanDir(dir).map((f) => f.path); + +class Crash extends Error {} + +describe("interrupted installs converge on rerun", () => { + it.each(POINTS)("update killed at %s, then rerun", async (point) => { + const m = s.machine("crash"); + await installRefs(m.context(), ["@alpha/demo@1.0.0"]); + const crashing = m.context({ + hooks: { + at: (p) => { + if (p === point) throw new Crash(p); + }, + }, + }); + await expect(installRefs(crashing, ["@alpha/demo@2.0.0"])).rejects.toBeInstanceOf(Crash); + + // Whatever the crash point, the skill directory never holds a mix of versions; + // between the two renames it is briefly absent until the next run recovers it. + const ctx = m.context(); + const dir = join(ctx.skillsDir, "demo"); + const whole = [["SKILL.md", "old-only.md"], ["SKILL.md", "new-only.md"], ...(point === "moved-old" ? [[]] : [])]; + expect(whole).toContainEqual(paths(dir)); + + const rerun = await installRefs(ctx, ["@alpha/demo@2.0.0"]); + const recovered = [...rerun.recovered.rolledBack, ...rerun.recovered.rolledForward]; + expect(recovered).toEqual(point === "staged" ? [] : ["demo"]); + expect(paths(dir)).toEqual(["SKILL.md", "new-only.md"]); + expect(readReceipt(ctx.home, "claude-code", "demo")).toMatchObject({ version: "2.0.0", requiredBy: ["packages:@alpha/demo"] }); + expect(readdirSync(ctx.stagingDir)).toEqual([]); + expect(existsSync(join(ctx.home, "journal", "claude-code", "demo.json"))).toBe(false); + }); + + it.each(POINTS)("first install killed at %s, then rerun", async (point) => { + const m = s.machine("fresh"); + const crashing = m.context({ hooks: { at: (p) => { if (p === point) throw new Crash(p); } } }); + await expect(installRefs(crashing, ["@alpha/demo@1.0.0"])).rejects.toBeInstanceOf(Crash); + const ctx = m.context(); + await installRefs(ctx, ["@alpha/demo@1.0.0"]); + expect(paths(join(ctx.skillsDir, "demo"))).toEqual(["SKILL.md", "old-only.md"]); + expect(readReceipt(ctx.home, "claude-code", "demo")!.version).toBe("1.0.0"); + expect(readdirSync(ctx.stagingDir)).toEqual([]); + }); +}); + +describe("local MCP", () => { + const call = (m: ReturnType, name: string, args: Record) => + handleMessage({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }, (a) => m.context(a), "test") as Promise<{ result: { isError: boolean; structuredContent: Record } }>; + + it("lists three tools and requires confirm: true to apply", async () => { + const m = s.machine("mcp"); + const list = (await handleMessage({ jsonrpc: "2.0", id: 1, method: "tools/list" }, () => m.context(), "test")) as { result: { tools: Array<{ name: string }> } }; + expect(list.result.tools.map((t) => t.name)).toEqual(["inspect", "plan", "apply"]); + + const refused = await call(m, "apply", { source: "@alpha/demo@1.0.0" }); + expect(refused.result.isError).toBe(true); + expect(refused.result.structuredContent.error.code).toBe("usage_error"); + expect(existsSync(join(m.context().skillsDir, "demo"))).toBe(false); + + const plan = await call(m, "plan", { source: "@alpha/demo@1.0.0", runtime: "codex" }); + expect(plan.result.structuredContent.items[0].plan.actions[0]).toMatchObject({ action: "install", alias: "demo" }); + const digest = plan.result.structuredContent.planDigest as string; + + const stale = await call(m, "apply", { source: "@alpha/demo@2.0.0", runtime: "codex", confirm: true, planDigest: digest }); + expect(stale.result.structuredContent.error.code).toBe("conflict"); + + const applied = await call(m, "apply", { source: "@alpha/demo@1.0.0", runtime: "codex", confirm: true, planDigest: digest }); + expect(applied.result.isError).toBe(false); + expect(existsSync(join(m.root, ".codex", "skills", "demo", "SKILL.md"))).toBe(true); + + const inspect = await call(m, "inspect", { runtime: "codex" }); + expect(inspect.result.structuredContent.installed.skills).toEqual([expect.objectContaining({ alias: "demo", version: "1.0.0" })]); + }); +}); diff --git a/apps/web/tests/install/install-server.ts b/apps/web/tests/install/install-server.ts new file mode 100644 index 0000000..96a6b7e --- /dev/null +++ b/apps/web/tests/install/install-server.ts @@ -0,0 +1,81 @@ +/** + * The installer's end-to-end server: the CLI registry client's `fetch` is + * routed straight into the real route loaders (release read API, personal + * kits, collections) over the in-memory D1/R2 of the kit harness. Machines + * are temp directories that stand in for HOME/USERPROFILE, CODEX_HOME and + * SKILLX_HOME; nothing reaches the real user profile. + */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createInstallContext } from "../../../../packages/cli/src/lib/install/install-context"; +import type { ContextOptions } from "../../../../packages/cli/src/lib/install/install-context"; +import { createRegistry } from "../../../../packages/cli/src/lib/install/registry-client"; +import { action as collectionsAction, loader as collectionsLoader } from "~/routes/api.v1.collections"; +import { loader as kitsLoader } from "~/routes/api.v1.kits"; +import { loader as releaseListLoader } from "~/routes/api.v1.package-releases"; +import { loader as fileLoader } from "~/routes/api.v1.release-file"; +import { loader as manifestLoader } from "~/routes/api.v1.release-manifest"; +import { createKitHarness } from "../personal-kits/kit-harness"; +import { FakeRateLimiter } from "../releases/fake-rate-limiter"; +import { TEST_KEY_ID } from "../releases/release-harness"; + +type Loader = (args: never) => Promise | Response; + +const ROUTES: Array<[RegExp, Loader, (m: RegExpExecArray) => Record]> = [ + [/^\/api\/v1\/releases\/([^/]+)\/manifest$/, manifestLoader as Loader, (m) => ({ digest: m[1]! })], + [/^\/api\/v1\/releases\/([^/]+)\/files\/(.+)$/, fileLoader as Loader, (m) => ({ digest: m[1]!, "*": m[2]! })], + [/^\/api\/v1\/packages\/(@[^/]+)\/([^/]+)\/releases$/, releaseListLoader as Loader, (m) => ({ scope: m[1]!, slug: m[2]! })], + [/^\/api\/v1\/kits\/(@[^/]+)\/([^/]+)(?:\/(.+))?$/, kitsLoader as Loader, (m) => ({ scope: m[1]!, name: m[2]!, ...(m[3] ? { "*": m[3] } : {}) })], + [/^\/api\/v1\/collections(?:\/([^/]+))?(?:\/(.+))?$/, collectionsLoader as Loader, (m) => ({ ...(m[1] ? { id: m[1] } : {}), ...(m[2] ? { "*": m[2] } : {}) })], +]; + +export async function createInstallServer() { + const k = await createKitHarness(); + const requests: string[] = []; + /** Test hook to tamper with responses (e.g. flip a byte of a file). */ + let rewrite: ((path: string, res: Response) => Promise) | null = null; + + const serverFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + const url = new URL(request.url); + requests.push(`${url.pathname}${url.search}`); + for (const [pattern, loader, params] of ROUTES) { + const m = pattern.exec(url.pathname); + if (!m) continue; + const handler = request.method !== "GET" && loader === collectionsLoader ? (collectionsAction as Loader) : loader; + const res = await handler({ request, params: params(m), context: { cloudflare: { env: envOf() } } } as never); + return rewrite ? rewrite(url.pathname, res) : res; + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + const envOf = () => ({ + SKILLX_RELEASES_API: "1", + SKILLX_PERSONAL_KITS_API: "1", + SKILLX_COLLECTIONS_API: "1", + DB: k.h.deps.db, + R2: k.h.deps.bucket, + RELEASE_READ_LIMITER: new FakeRateLimiter(100_000), + RELEASE_WRITE_LIMITER: new FakeRateLimiter(100_000), + }); + + const keys = new Map([[TEST_KEY_ID, k.h.signingKey.publicKey]]); + const roots: string[] = []; + + /** A fresh machine: temp HOME/USERPROFILE with CODEX_HOME and SKILLX_HOME inside it. */ + function machine(name: string) { + const root = mkdtempSync(join(tmpdir(), `skillx-${name}-`)); + roots.push(root); + const env = { HOME: root, USERPROFILE: root, CODEX_HOME: join(root, ".codex"), SKILLX_HOME: join(root, ".skillx") }; + const context = (options: Partial = {}) => + createInstallContext({ registry: createRegistry({ baseUrl: "https://skillx.test", apiKey: k.ownerKey, fetch: serverFetch }), env, keys, ...options }); + return { root, env, context }; + } + + const cleanup = () => roots.forEach((r) => rmSync(r, { recursive: true, force: true })); + const setRewrite = (fn: typeof rewrite) => { + rewrite = fn; + }; + return { k, keys, requests, serverFetch, machine, cleanup, setRewrite }; +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 03e9f49..03b31d6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,6 +25,7 @@ "yaml": "^2.9.1" }, "devDependencies": { + "@skillx/contracts": "workspace:*", "@skillx/skill-tree": "workspace:*", "tsup": "^8.0.0", "typescript": "^5.6.0", diff --git a/packages/cli/src/commands/install-runner.ts b/packages/cli/src/commands/install-runner.ts new file mode 100644 index 0000000..7460108 --- /dev/null +++ b/packages/cli/src/commands/install-runner.ts @@ -0,0 +1,67 @@ +import chalk from 'chalk'; +import { createInstallContext } from '../lib/install/install-context.js'; +import type { InstallContext } from '../lib/install/install-context.js'; +import { InstallError, usageError } from '../lib/install/install-errors.js'; +import type { SourcePlan } from '../lib/install/install-operations.js'; +import { createRegistry } from '../lib/install/registry-client.js'; +import { exitCodeFor, failure, fromError, serialize, success } from '../lib/output.js'; +import type { CliFailure } from '../lib/output.js'; +import { getApiKey, getBaseUrl } from '../utils/config-store.js'; + +/** Options every installer command accepts. */ +export interface InstallerOptions { + runtime?: string; + registry?: string; + json?: boolean; + force?: boolean; +} + +export function contextFrom(options: InstallerOptions): InstallContext { + const baseUrl = options.registry ?? getBaseUrl(); + try { + new URL(baseUrl); + } catch { + throw usageError(`invalid registry URL ${baseUrl}`); + } + return createInstallContext({ + registry: createRegistry({ baseUrl, apiKey: getApiKey() }), + ...(options.runtime ? { runtime: options.runtime } : {}), + force: options.force === true, + }); +} + +function toFailure(error: unknown): CliFailure { + if (error instanceof InstallError) return failure(error.code, error.message, error.details === undefined ? {} : { details: error.details }); + return fromError(error); +} + +/** Runs one command body: JSON envelope or human text, and the exit code of the outcome. */ +export async function runInstaller(options: InstallerOptions, body: () => Promise, human: (data: T) => void): Promise { + try { + const data = await body(); + if (options.json) console.log(serialize(success(data))); + else human(data); + process.exit(0); + } catch (error) { + const envelope = toFailure(error); + if (options.json) console.log(serialize(envelope)); + else { + console.error(chalk.red(`\nāœ— ${envelope.error.message}`)); + const conflicts = (envelope.error.details as { conflicts?: Array<{ alias: string; reason: string; files: string[] }> } | undefined)?.conflicts; + for (const c of conflicts ?? []) console.error(chalk.yellow(` ${c.alias}: ${c.reason}${c.files.length ? ` (${c.files.slice(0, 5).join(', ')})` : ''}`)); + } + process.exit(exitCodeFor(envelope)); + } +} + +export function printPlan(planned: SourcePlan): void { + for (const item of planned.items) { + console.log(chalk.bold(`\n${item.owner}`) + chalk.dim(` → ${item.plan.skillsDir}`)); + for (const a of item.plan.actions) { + const paint = a.action === 'conflict' ? chalk.red : a.action === 'unchanged' ? chalk.dim : chalk.green; + const detail = a.action === 'conflict' ? ` ${a.reason}` : 'version' in a ? ` ${a.package}@${a.version}` : ''; + console.log(` ${paint(a.action.padEnd(9))} ${a.alias}${chalk.dim(detail)}`); + } + } + for (const w of planned.warnings) console.log(chalk.yellow(` ! ${w}`)); +} diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts new file mode 100644 index 0000000..462fbf8 --- /dev/null +++ b/packages/cli/src/commands/install.ts @@ -0,0 +1,126 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { INSTALL_LOCK_FILENAME } from '@skillx/contracts'; +import { installRefs, listInstalled, planRefs, uninstallRef, updateInstalled } from '../lib/install/install-operations.js'; +import type { ApplyOutcome } from '../lib/install/install-operations.js'; +import { usageError } from '../lib/install/install-errors.js'; +import { planLocked, syncLocked } from '../lib/install/sync-locked.js'; +import { readLockFile, writeLockFile } from '../lib/install/lock-file.js'; +import { contextFrom, printPlan, runInstaller } from './install-runner.js'; +import type { InstallerOptions } from './install-runner.js'; + +/** + * `skillx install | update | uninstall | list | sync`: the generic installer. + * Sources are `kit:@handle/kit[#revisionId]`, `collection:[#revision]` + * or `@publisher/package[@version]`; `--runtime` picks the adapter + * (`claude-code`, default, or `codex`). See docs/cli-reference.md. + */ + +interface ApplyOptions extends InstallerOptions { + dryRun?: boolean; + lock?: string | false; +} + +const withCommon = (cmd: Command) => + cmd + .option('--runtime ', 'target runtime: claude-code or codex', 'claude-code') + .option('--registry ', 'registry base URL (default: the configured one)') + .option('--json', 'emit a machine-readable envelope instead of text'); + +function printApplied(outcome: ApplyOutcome, lockPath: string | null): void { + printPlan(outcome); + for (const a of outcome.applied) { + for (const c of a.changed) if (c.backupDir) console.log(chalk.yellow(` ${c.alias}: local changes saved to ${c.backupDir}`)); + for (const r of a.removed) if (r.keptModified.length + r.keptUnknown.length > 0) console.log(chalk.yellow(` ${r.alias}: kept ${[...r.keptModified, ...r.keptUnknown].join(', ')}`)); + } + if (lockPath) console.log(chalk.dim(`\nlock written to ${lockPath}`)); + console.log(chalk.green('\nāœ“ done')); +} + +export const installCommand = withCommon(new Command('install')) + .description('Install a kit revision, a collection or packages into a runtime skills directory') + .argument('', 'kit:@handle/kit[#revisionId] | collection:[#n] | @publisher/package[@version]...') + .option('--dry-run', 'resolve and plan only; change nothing') + .option('--force', 'reinstall over local edits (backed up) and unowned directories (unknown files kept)') + .option('--lock ', 'where to write the lock', INSTALL_LOCK_FILENAME) + .option('--no-lock', 'do not write a lock file') + .action((refs: string[], options: ApplyOptions) => + runInstaller( + options, + async () => { + const ctx = contextFrom(options); + if (options.dryRun) return { dryRun: true as const, ...(await planRefs(ctx, refs)) }; + const outcome = await installRefs(ctx, refs); + const lockPath = options.lock === false ? null : writeLockFile(options.lock ?? INSTALL_LOCK_FILENAME, outcome.lock); + return { ...outcome, lockPath }; + }, + (data) => ('dryRun' in data ? printPlan(data) : printApplied(data, data.lockPath)), + ), + ); + +export const updateCommand = withCommon(new Command('update')) + .description('Re-resolve installed sources (all, or the one named) and apply the changes') + .argument('[refs...]', 'an installed source; all when omitted') + .option('--force', 'reinstall over local edits (backed up)') + .action((refs: string[], options: ApplyOptions) => + runInstaller(options, () => updateInstalled(contextFrom(options), refs), (outcomes) => outcomes.forEach((o) => printApplied(o, null))), + ); + +export const uninstallCommand = withCommon(new Command('uninstall')) + .description('Remove a source; skills another source still needs stay, edited and unknown files are kept') + .argument('', 'the source as installed: kit:@handle/kit, collection: or @publisher/package') + .action((refs: string[], options: InstallerOptions) => + runInstaller( + options, + () => uninstallRef(contextFrom(options), refs), + (result) => { + for (const c of result.changed) console.log(` ${chalk.dim('released')} ${c.alias}`); + for (const r of result.removed) { + console.log(` ${chalk.green('removed')} ${r.alias}`); + const kept = [...r.keptModified, ...r.keptUnknown]; + if (kept.length > 0) console.log(chalk.yellow(` kept ${kept.join(', ')}`)); + } + console.log(chalk.green(`\nāœ“ ${result.owner} uninstalled`)); + }, + ), + ); + +export const listCommand = withCommon(new Command('list')) + .description('List installed skills and the sources that need them') + .action((options: InstallerOptions) => + runInstaller( + options, + async () => listInstalled(contextFrom(options)), + (data) => { + console.log(chalk.bold(`\n${data.runtime}`) + chalk.dim(` ${data.skillsDir}`)); + for (const s of data.skills) console.log(` ${s.alias.padEnd(28)} ${s.package}@${s.version} ${chalk.dim(s.requiredBy.join(', '))}`); + if (data.skills.length === 0) console.log(chalk.dim(' nothing installed')); + }, + ), + ); + +export const syncCommand = withCommon(new Command('sync')) + .description('Restore exactly what a lock pins (from a lock file, or a kit revision with --from)') + .option('--locked', 'required: restore exact digests and refuse any drift') + .option('--lock ', 'lock file to restore', INSTALL_LOCK_FILENAME) + .option('--from ', 'restore a kit revision instead of a lock file: kit:@handle/kit#revisionId') + .option('--dry-run', 'plan only; change nothing') + .option('--force', 'repair local edits (backed up) instead of refusing') + .action((options: ApplyOptions & { locked?: boolean; from?: string }) => + runInstaller( + options, + async () => { + if (!options.locked) throw usageError('sync restores exact digests only; pass --locked'); + const ctx = contextFrom(options); + if (options.from !== undefined) { + if (!/^kit:.+#.+$/.test(options.from)) throw usageError('--from needs an exact kit revision: kit:@handle/kit#revisionId'); + if (options.dryRun) return { dryRun: true as const, ...(await planRefs(ctx, [options.from])) }; + return { ...(await installRefs(ctx, [options.from])), lockPath: null }; + } + const lock = readLockFile(options.lock || INSTALL_LOCK_FILENAME); + if (options.dryRun) return { dryRun: true as const, ...(await planLocked(ctx, lock)) }; + return { ...(await syncLocked(ctx, lock)), lockPath: null }; + }, + (data) => ('dryRun' in data ? printPlan(data) : printApplied(data, null)), + ), + ); diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts new file mode 100644 index 0000000..1e1eec4 --- /dev/null +++ b/packages/cli/src/commands/mcp.ts @@ -0,0 +1,18 @@ +import { Command } from 'commander'; +import { serveStdio } from '../lib/install/local-mcp.js'; +import { contextFrom } from './install-runner.js'; + +/** Injected at build time from packages/cli/package.json by tsup (see tsup.config.ts). */ +declare const __SKILLX_VERSION__: string; + +/** + * `skillx mcp`: the local installer as an MCP server on stdio, with the + * tools `inspect`, `plan` and `apply` (apply requires `confirm: true`). + * This is not the remote registry MCP at `/api/mcp`. + */ +export const mcpCommand = new Command('mcp') + .description('Serve the local installer (inspect, plan, apply) as an MCP server on stdio') + .option('--registry ', 'registry base URL (default: the configured one)') + .action(async (options: { registry?: string }) => { + await serveStdio((args) => contextFrom({ ...options, ...args }), __SKILLX_VERSION__); + }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f01cdc9..5681c2e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -13,6 +13,8 @@ import { validateCommand } from './commands/validate.js'; import { createPackCommand } from './commands/pack.js'; import { promoteCommand } from './commands/promote.js'; import { releaseCommand } from './commands/release-publish.js'; +import { installCommand, listCommand, syncCommand, uninstallCommand, updateCommand } from './commands/install.js'; +import { mcpCommand } from './commands/mcp.js'; /** Injected at build time from packages/cli/package.json by tsup (see tsup.config.ts). */ declare const __SKILLX_VERSION__: string; @@ -37,5 +39,6 @@ program.addCommand(createPackCommand()); program.addCommand(publishCommand); program.addCommand(promoteCommand); program.addCommand(releaseCommand); +for (const command of [installCommand, updateCommand, uninstallCommand, listCommand, syncCommand, mcpCommand]) program.addCommand(command); program.parse(process.argv); diff --git a/packages/cli/src/lib/install/adapters.ts b/packages/cli/src/lib/install/adapters.ts new file mode 100644 index 0000000..8fa345c --- /dev/null +++ b/packages/cli/src/lib/install/adapters.ts @@ -0,0 +1,63 @@ +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { usageError } from './install-errors.js'; + +/** + * Harness adapters: where a runtime loads skills from. An adapter only maps a + * runtime id to a skills directory; the installer does everything else the + * same way for every runtime. Every location is derived from the process + * environment at call time (HOME/USERPROFILE through `os.homedir()`, + * `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `SKILLX_HOME`), so tests point all of + * them at a temporary directory and never touch real user state. + */ + +export type Env = Record; + +export interface HarnessAdapter { + /** Runtime id, the key used by compatibility declarations and kit `runtimes`. */ + id: 'claude-code' | 'codex'; + displayName: string; + /** Directory holding one sub-directory per installed skill. */ + skillsDir(env?: Env): string; +} + +/** HOME on POSIX and USERPROFILE on Windows, as `os.homedir()` reads them, but from `env`. */ +export function homeDir(env: Env = process.env): string { + const fromEnv = process.platform === 'win32' ? env.USERPROFILE : env.HOME; + return fromEnv ? resolve(fromEnv) : homedir(); +} + +/** SkillX local state: receipts, journals, owner records, the blob cache. */ +export function skillxHome(env: Env = process.env): string { + return env.SKILLX_HOME ? resolve(env.SKILLX_HOME) : join(homeDir(env), '.skillx'); +} + +export const claudeCodeAdapter: HarnessAdapter = { + id: 'claude-code', + displayName: 'Claude Code', + skillsDir: (env = process.env) => join(env.CLAUDE_CONFIG_DIR ? resolve(env.CLAUDE_CONFIG_DIR) : join(homeDir(env), '.claude'), 'skills'), +}; + +export const codexAdapter: HarnessAdapter = { + id: 'codex', + displayName: 'Codex', + skillsDir: (env = process.env) => join(env.CODEX_HOME ? resolve(env.CODEX_HOME) : join(homeDir(env), '.codex'), 'skills'), +}; + +export const ADAPTERS: readonly HarnessAdapter[] = [claudeCodeAdapter, codexAdapter]; +export const DEFAULT_RUNTIME = claudeCodeAdapter.id; + +export function getAdapter(id: string): HarnessAdapter { + const adapter = ADAPTERS.find((a) => a.id === id); + if (!adapter) throw usageError(`unknown runtime '${id}'; use one of: ${ADAPTERS.map((a) => a.id).join(', ')}`); + return adapter; +} + +/** + * Staging and backup area of an adapter: a sibling of the skills directory, + * so a rename never crosses a filesystem and the runtime never loads a + * half-written skill from it. + */ +export function stagingDir(adapter: HarnessAdapter, env: Env = process.env): string { + return join(dirname(adapter.skillsDir(env)), '.skillx-staging'); +} diff --git a/packages/cli/src/lib/install/apply-transaction.ts b/packages/cli/src/lib/install/apply-transaction.ts new file mode 100644 index 0000000..5b5d21c --- /dev/null +++ b/packages/cli/src/lib/install/apply-transaction.ts @@ -0,0 +1,151 @@ +import { randomUUID } from 'node:crypto'; +import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import type { VerifiedTree } from './fetch-verify.js'; +import { writeJsonAtomic, writeReceipt } from './install-state.js'; +import type { InstallReceipt } from './install-state.js'; + +/** + * Atomic replacement of one skill directory, recoverable after a crash at + * any point: + * + * stage files → write journal → target → backup → stage → target + * → write receipt → drop backup → drop journal + * + * `recoverJournals` runs before every change. While the stage still exists + * the swap has not happened, so it rolls back (restores the backup, drops + * the stage); once the stage is gone the new tree is in place, so it rolls + * forward (writes the receipt from the journal, drops the backup). Staging + * entries no journal names are leftovers of a crash before the journal was + * written and are removed. + */ + +export type CrashPoint = 'staged' | 'journaled' | 'moved-old' | 'swapped' | 'receipt-written'; + +export interface TxContext { + home: string; + runtime: string; + skillsDir: string; + stagingDir: string; + /** Test hook: throwing here simulates a crash at that point. */ + hooks?: { at?(point: CrashPoint, alias: string): void }; + now: Date; +} + +interface Journal { + schema: 'skillx.install-journal/v1'; + alias: string; + target: string; + stage: string; + backup: string; + receipt: InstallReceipt; +} + +const journalDir = (ctx: Pick) => join(ctx.home, 'journal', ctx.runtime); +const stamp = (now: Date) => now.toISOString().replace(/[:.]/g, '-'); + +/** Copies local files the new tree would overwrite to `/backups///