diff --git a/CLAUDE.md b/CLAUDE.md index b165b28..6a12529 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,7 @@ pnpm db:migrate:remote # Apply migrations to remote D1 | `/api/v1/oidc/github/token` | api.v1.oidc-github-token.ts (GitHub Actions JWT → 15 min `sxo_` token scoped `releases:publish:@handle`; jti replay-checked) | GitHub OIDC JWT (503 without `SKILLX_TOKEN_SECRET`; 404 unless `SKILLX_RELEASES_API` on) | | `/api/v1/releases/:digest/artifact/url`, `/api/v1/downloads/:token` | api.v1.release-download-url.ts, api.v1.release-download.ts (5 min signed artifact URL for private/entitled releases) | URL: entitled Key/Session; download: the token (404 unless `SKILLX_RELEASES_API` on) | | `/api/v1/kits/:scope/:name`, `/api/v1/kits/:scope/:name/*` | api.v1.kits.ts (personal kit revisions, rebase, conflicts, session-derived proposals and review, activate/rollback, lock; see docs/personal-kits-api.md) | Owner session or unscoped API key; scoped keys 401, non-owners 404; `revisions/:id/quarantine` admin secret only (404 unless `SKILLX_PERSONAL_KITS_API` on) | -| `/api/v1/collections`, `/api/v1/collections/:id`, `/api/v1/collections/:id/*` | api.v1.collections.ts (collections: create/update/delete, immutable revisions, members, clone; see docs/api-reference.md#collections) | Reads: owner, or anyone for unlisted/public; writes: owner session or unscoped key (404 unless `SKILLX_COLLECTIONS_API` on) | +| `/api/v1/collections`, `/api/v1/collections/:id`, `/api/v1/collections/:id/*` | api.v1.collections.ts (collections: create/update/delete, immutable revisions, members, clone; per-account cap, default 100; `:id/moderation` hides/unhides, audited; see docs/api-reference.md#collections) | Reads: owner, or anyone for unlisted/public and not hidden; writes: owner session or unscoped key; `moderation`: admin `X-Admin-Secret` only (404 unless `SKILLX_COLLECTIONS_API` on) | ## Database Tables (Drizzle schema) @@ -184,7 +184,8 @@ All docs in `./docs/` — see design-guidelines.md for full UI/UX spec, code-sta Claim an issue before working on it so parallel contributors and agents do not collide: assign yourself and move it through the labels `in progress` → `in review` -(code review running) → `done` (PR open) → `shipped` (merged to `main`). Search open +(code review running) → `done` (PR open) → `shipped` (merged to `main`); use `blocked` +with a comment naming the blocker while work cannot proceed. Search open and closed issues first; comment on a related or duplicate issue with a reference instead of starting parallel work. File an issue for an unrelated bug or gap you find instead of widening the current PR silently. diff --git a/apps/web/app/lib/collections/collection-http.ts b/apps/web/app/lib/collections/collection-http.ts index fff9d1f..d296f89 100644 --- a/apps/web/app/lib/collections/collection-http.ts +++ b/apps/web/app/lib/collections/collection-http.ts @@ -14,7 +14,17 @@ import { checkReleaseRateLimit, rateLimitCaller } from "~/lib/releases/release-r 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 }; +export type CollectionEnv = Env & { SKILLX_COLLECTIONS_API?: string; SKILLX_COLLECTIONS_MAX_PER_USER?: string }; + +/** Collections one account may keep when `SKILLX_COLLECTIONS_MAX_PER_USER` is unset; below the 200-row listing page. */ +export const DEFAULT_COLLECTIONS_PER_USER = 100; + +/** The per-account cap: `SKILLX_COLLECTIONS_MAX_PER_USER` when it is a positive integer, the default otherwise. */ +export function collectionLimit(env: { SKILLX_COLLECTIONS_MAX_PER_USER?: string }): number { + const raw = (env.SKILLX_COLLECTIONS_MAX_PER_USER ?? "").trim(); + const value = /^\d{1,6}$/.test(raw) ? Number(raw) : 0; + return value > 0 ? value : DEFAULT_COLLECTIONS_PER_USER; +} /** A full revision (256 members) fits easily; larger bodies are refused before parsing. */ export const MAX_COLLECTION_BODY_BYTES = 256 * 1024; @@ -40,7 +50,7 @@ export class CollectionValidationError extends ReleaseHttpError { } export interface CollectionCaller { - /** The signed-in user, or null for an anonymous read. */ + /** The signed-in user, or null for an anonymous read or an operator (admin secret) call. */ userId: string | null; env: CollectionEnv; request: Request; @@ -58,7 +68,7 @@ export function requireRevisionNumber(raw: unknown, field = "expectedRevision"): export async function serveCollections( request: Request, env: CollectionEnv, - access: "read" | "write", + access: "read" | "write" | "admin", handler: (caller: CollectionCaller) => Promise, now: () => Date = () => new Date(), ): Promise { @@ -73,8 +83,13 @@ export async function serveCollections( } } 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; + if (access === "admin") { + if (actor.kind !== "admin") throw releaseError(403, "collection.admin_only", "this action is reserved to operators"); + userId = null; + } else { + 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) { diff --git a/apps/web/app/lib/collections/collection-moderation.ts b/apps/web/app/lib/collections/collection-moderation.ts new file mode 100644 index 0000000..dc5b62f --- /dev/null +++ b/apps/web/app/lib/collections/collection-moderation.ts @@ -0,0 +1,66 @@ +/** + * Operator moderation of collections (`/api/v1/collections/:id/moderation`, + * admin secret only). `POST {from, to, reason?}` moves `moderation_state` + * between `active` and `hidden` through the audited compare-and-set of + * release states (`release_state_audit`, subject kind `collection`). A + * `hidden` collection reads as 404 to everyone but its owner, whatever its + * visibility, and its owner can neither clone nor delete it. `GET` answers the + * state, the latest revision (operators read no other route) and the history. + */ +import { releaseError } from "~/lib/releases/release-http"; +import { compareAndSet } from "~/lib/releases/release-state-cas"; +import { readStateChange } from "~/lib/releases/release-state-http"; +import { collectionNotFound, json } from "./collection-http"; +import type { CollectionCaller } from "./collection-http"; +import { findRevision } from "./collection-store"; +import { collectionView, revisionView } from "./collection-views"; +import type { CollectionRow } from "./collection-views"; + +export const MODERATION_STATES = ["active", "hidden"] as const; +export type ModerationState = (typeof MODERATION_STATES)[number]; + +const isModerationState = (value: unknown): value is ModerationState => (MODERATION_STATES as readonly unknown[]).includes(value); + +interface AuditRow { + from_state: string; + to_state: string; + outcome: string; + reason: string | null; + created_at: number; +} + +async function anyCollection(c: CollectionCaller, id: string): Promise { + const row = await c.env.DB.prepare("SELECT * FROM collections WHERE id = ?").bind(id).first(); + if (row === null) throw collectionNotFound(); + return row; +} + +/** `GET .../moderation`: the collection as its owner sees it, its latest revision, and the newest 100 moderation attempts. */ +export async function readModeration(c: CollectionCaller, id: string): Promise { + const row = await anyCollection(c, id); + const history = await c.env.DB.prepare( + "SELECT from_state, to_state, outcome, reason, created_at FROM release_state_audit WHERE subject_kind = 'collection' AND subject_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 100", + ) + .bind(id) + .all(); + return json({ + collection: collectionView(row, row.owner_id), + revision: revisionView(await findRevision(c, id, row.latest_revision_number)), + history: history.results.map((h) => ({ from: h.from_state, to: h.to_state, outcome: h.outcome, reason: h.reason, at: new Date(h.created_at).toISOString() })), + }); +} + +/** `POST .../moderation` with `{from, to, reason?}`; a stale `from` is audited and answers 409 `collection.moderation_conflict`. */ +export async function moderateCollection(c: CollectionCaller, id: string): Promise { + await anyCollection(c, id); + const change = await readStateChange(c.request, isModerationState, MODERATION_STATES); + if (change.from === change.to) throw releaseError(400, "invalid_request", "from and to must differ"); + const outcome = await compareAndSet( + c.env.DB, + { kind: "collection", subjectId: id, input: { ...change, actor: { kind: "admin", id: "admin" }, now: c.now } }, + { extraSet: ", moderation_reason = ?, moderated_at = ?", extraBinds: [change.reason, c.now.getTime()] }, + ); + const row = await anyCollection(c, id); + if (outcome !== "applied") throw releaseError(409, "collection.moderation_conflict", `the collection is ${row.moderation_state}, not ${change.from}`); + return json({ collection: collectionView(row, row.owner_id) }); +} diff --git a/apps/web/app/lib/collections/collection-router.ts b/apps/web/app/lib/collections/collection-router.ts index e8dae54..f516152 100644 --- a/apps/web/app/lib/collections/collection-router.ts +++ b/apps/web/app/lib/collections/collection-router.ts @@ -9,9 +9,9 @@ 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 { moderateCollection, readModeration } from "./collection-moderation"; import { appendRevision, - collectionView, deleteCollection, findRevision, insertCollection, @@ -20,9 +20,9 @@ import { ownedCollection, prepareContent, readableCollection, - revisionView, updateMetadata, } from "./collection-store"; +import { collectionView, isHidden, revisionView } from "./collection-views"; const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/; const ID = /^col_[0-9a-f-]{36}$/; @@ -61,6 +61,8 @@ export function editMembers(base: CollectionMember[], add: unknown, remove: unkn type Handler = (c: CollectionCaller, id: string, args: string[]) => Promise; interface RouteDef { pattern: RegExp; + /** Every method of the route needs the admin secret. */ + admin?: true; get?: Handler; post?: Handler; patch?: Handler; @@ -78,7 +80,10 @@ const ROUTES: RouteDef[] = [ return json({ collection: collectionView(updated, c.userId) }); }, delete: async (c, id) => { - await deleteCollection(c, await ownedCollection(c, id)); + const row = await ownedCollection(c, id); + // Deleting would let the owner re-post the content unmoderated; a hidden one stays and keeps its slot. + if (isHidden(row)) throw releaseError(409, "collection.hidden", "a collection hidden by moderation cannot be deleted"); + await deleteCollection(c, row); return json({ deleted: true, id }); }, }, @@ -117,6 +122,7 @@ const ROUTES: RouteDef[] = [ pattern: /^clone$/, post: async (c, id) => { const source = await readableCollection(c, id); + if (isHidden(source)) throw releaseError(403, "collection.hidden", "a collection hidden by moderation cannot be cloned"); 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)); @@ -128,6 +134,12 @@ const ROUTES: RouteDef[] = [ return json({ collection: collectionView(created.collection, c.userId), revision: revisionView(created.revision) }, 201); }, }, + { + pattern: /^moderation$/, + admin: true, + get: (c, id) => readModeration(c, id), + post: (c, id) => moderateCollection(c, id), + }, ]; const methodNotAllowed = (route: RouteDef) => { @@ -168,7 +180,7 @@ export function serveCollectionRequest(request: Request, env: CollectionEnv, par return serveCollections( request, env, - method === "get" ? "read" : "write", + route?.admin ? "admin" : method === "get" ? "read" : "write", async (c) => { const id = params.id ?? ""; if (route === undefined || !ID.test(id)) return notFoundResponse(); diff --git a/apps/web/app/lib/collections/collection-store.ts b/apps/web/app/lib/collections/collection-store.ts index 4f04d07..1536c3a 100644 --- a/apps/web/app/lib/collections/collection-store.ts +++ b/apps/web/app/lib/collections/collection-store.ts @@ -8,66 +8,19 @@ 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 { CollectionValidationError, collectionLimit, 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; -} +import { isHidden } from "./collection-views"; +import type { CollectionRevisionRow, CollectionRow } from "./collection-views"; 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. */ +/** The collection when the caller may read it (owner, or not private and not hidden); 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(); + if (row === null || (row.owner_id !== c.userId && (row.visibility === "private" || isHidden(row)))) throw collectionNotFound(); return row; } @@ -119,13 +72,23 @@ function insertRevision(c: CollectionCaller, collectionId: string, number: numbe } const isUniqueViolation = (error: unknown) => error instanceof Error && /UNIQUE constraint failed/i.test(error.message); +const isForeignKeyViolation = (error: unknown) => error instanceof Error && /FOREIGN KEY constraint failed/i.test(error.message); +const limitReached = (limit: number) => + releaseError(409, "collection.limit_reached", `you already have ${limit} collections, the most one account may keep; delete one first`); -/** Creates a collection with revision 1. A slug the owner already uses is 409 `collection.slug_exists`. */ +/** + * Creates a collection with revision 1. A slug the owner already uses is 409 + * `collection.slug_exists`; an owner at the cap is 409 `collection.limit_reached`. + * The cap is checked by the insert itself, so concurrent creates cannot pass it: + * a refused collection insert leaves the revision without its parent, the + * foreign key fails the batch, and nothing is written. + */ export async function insertCollection( c: CollectionCaller, fields: { slug: string; name: string; visibility: string; clonedFrom: string | null }, content: Content, ): Promise<{ collection: CollectionRow; revision: CollectionRevisionRow }> { + const limit = collectionLimit(c.env); const now = c.now.getTime(); const collection: CollectionRow = { id: newId("col"), @@ -142,12 +105,15 @@ export async function insertCollection( 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), + .prepare( + "INSERT INTO collections (id, owner_id, slug, name, visibility, latest_revision_number, cloned_from_revision_id, created_at, updated_at) SELECT ?, ?, ?, ?, ?, 1, ?, ?, ? WHERE (SELECT count(*) FROM collections WHERE owner_id = ?) < ?", + ) + .bind(collection.id, collection.owner_id, collection.slug, collection.name, collection.visibility, collection.cloned_from_revision_id, now, now, collection.owner_id, limit), revision.statement, ]); } catch (error) { if (isUniqueViolation(error)) throw releaseError(409, "collection.slug_exists", `you already have a collection named ${fields.slug}`); + if (isForeignKeyViolation(error)) throw limitReached(limit); throw error; } return { collection, revision: revision.row }; diff --git a/apps/web/app/lib/collections/collection-views.ts b/apps/web/app/lib/collections/collection-views.ts new file mode 100644 index 0000000..da2e3ae --- /dev/null +++ b/apps/web/app/lib/collections/collection-views.ts @@ -0,0 +1,65 @@ +/** + * Row shapes of collections and their revisions, and the JSON views the API + * answers with. `moderation` is shown only to the owner (and operators): a + * hidden collection is 404 to everyone else, so they never see the field set. + */ +import type { CollectionConfig, CollectionMember } from "@skillx/contracts"; + +export interface CollectionRow { + id: string; + owner_id: string; + slug: string; + name: string; + visibility: string; + latest_revision_number: number; + cloned_from_revision_id: string | null; + /** Absent on a database before migration 0020; read through `isHidden`. */ + moderation_state?: string; + moderation_reason?: string | null; + moderated_at?: number | 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; +} + +const iso = (ms: number) => new Date(ms).toISOString(); + +/** Hidden by an operator: 404 to everyone but the owner, whatever the visibility. */ +export const isHidden = (row: CollectionRow) => row.moderation_state === "hidden"; + +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, + ...(isHidden(row) ? { moderation: { state: "hidden", reason: row.moderation_reason ?? null, at: row.moderated_at == null ? null : iso(row.moderated_at) } } : {}), + 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), + }; +} + diff --git a/apps/web/app/lib/db/collections-schema.ts b/apps/web/app/lib/db/collections-schema.ts index 347ff88..a62e969 100644 --- a/apps/web/app/lib/db/collections-schema.ts +++ b/apps/web/app/lib/db/collections-schema.ts @@ -13,6 +13,10 @@ export const collections = sqliteTable( 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"), + // Operator moderation: a `hidden` collection reads as 404 to everyone but its owner, whatever its visibility. + moderation_state: text("moderation_state").notNull().default("active"), // active | hidden + moderation_reason: text("moderation_reason"), + moderated_at: integer("moderated_at", { mode: "timestamp_ms" }), created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(), }, diff --git a/apps/web/app/lib/db/registry-schema.ts b/apps/web/app/lib/db/registry-schema.ts index f8488ff..c06a29a 100644 --- a/apps/web/app/lib/db/registry-schema.ts +++ b/apps/web/app/lib/db/registry-schema.ts @@ -81,8 +81,8 @@ export const releaseStateAudit = sqliteTable( "release_state_audit", { id: text("id").primaryKey(), - subject_kind: text("subject_kind").notNull(), // release | publisher | release_channel | trusted_publisher - subject_id: text("subject_id").notNull(), // package_releases.id, publishers.id or trusted_publishers.id + subject_kind: text("subject_kind").notNull(), // release | publisher | release_channel | trusted_publisher | collection + subject_id: text("subject_id").notNull(), // package_releases.id, publishers.id, trusted_publishers.id or collections.id actor_kind: text("actor_kind").notNull(), // admin | user | publisher_key (scoped key or OIDC publish token) actor_id: text("actor_id").notNull(), // 'admin' (shared ADMIN_SECRET), the user id, the API key id, or oidc: from_state: text("from_state").notNull(), // the state the actor expected (compare-and-set); "none" for a publisher creation diff --git a/apps/web/app/lib/releases/release-state-cas.ts b/apps/web/app/lib/releases/release-state-cas.ts index ea41553..bffaa01 100644 --- a/apps/web/app/lib/releases/release-state-cas.ts +++ b/apps/web/app/lib/releases/release-state-cas.ts @@ -7,7 +7,7 @@ */ import type { TransitionInput } from "./release-transitions"; -export type SubjectKind = "release" | "publisher" | "release_channel"; +export type SubjectKind = "release" | "publisher" | "release_channel" | "collection"; export type AttemptOutcome = "denied" | "invalid"; export type CasOutcome = "applied" | "stale" | "denied"; @@ -43,6 +43,7 @@ const TARGETS = { release: { table: "package_releases", column: "release_state" }, publisher: { table: "publishers", column: "state" }, release_channel: { table: "package_releases", column: "channel" }, + collection: { table: "collections", column: "moderation_state" }, } as const; export const AUDIT_COLUMNS = "id, subject_kind, subject_id, actor_kind, actor_id, from_state, to_state, outcome, reason, created_at"; diff --git a/apps/web/drizzle/migrations/0020_collection_moderation.sql b/apps/web/drizzle/migrations/0020_collection_moderation.sql new file mode 100644 index 0000000..494fbed --- /dev/null +++ b/apps/web/drizzle/migrations/0020_collection_moderation.sql @@ -0,0 +1,21 @@ +-- Collections moderation (issue #114): operators can hide a public or unlisted collection. +-- +-- Strictly additive: one NOT NULL column with a default ('active', so every existing row stays +-- readable as before) and two nullable columns. Code that predates this migration never reads +-- them, and the collection code treats a missing `moderation_state` as 'active', so either +-- deploy order works; only the admin moderation route needs the columns. +-- +-- Rollback un-hides content: code before 0020 ignores `moderation_state`, so first make every +-- hidden collection private (UPDATE collections SET visibility = 'private' WHERE +-- moderation_state = 'hidden'), then deploy the previous code. +-- +-- 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 0020: +-- ALTER TABLE `collections` DROP COLUMN `moderated_at`; +-- ALTER TABLE `collections` DROP COLUMN `moderation_reason`; +-- ALTER TABLE `collections` DROP COLUMN `moderation_state`; +-- DELETE FROM d1_migrations WHERE name = '0020_collection_moderation.sql'; + +ALTER TABLE `collections` ADD `moderation_state` text DEFAULT 'active' NOT NULL;--> statement-breakpoint +ALTER TABLE `collections` ADD `moderation_reason` text;--> statement-breakpoint +ALTER TABLE `collections` ADD `moderated_at` integer; diff --git a/apps/web/drizzle/migrations/meta/0020_snapshot.json b/apps/web/drizzle/migrations/meta/0020_snapshot.json new file mode 100644 index 0000000..f34def2 --- /dev/null +++ b/apps/web/drizzle/migrations/meta/0020_snapshot.json @@ -0,0 +1,3064 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "26896f4e-7661-4436-aa54-78c5287a172e", + "prevId": "597309b0-cc14-4448-96ff-71366d81d623", + "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_trusted": { + "name": "idx_oidc_token_exchanges_trusted", + "columns": [ + "trusted_publisher_id", + "created_at" + ], + "isUnique": false + }, + "idx_oidc_token_exchanges_created": { + "name": "idx_oidc_token_exchanges_created", + "columns": [ + "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 + }, + "revision_digest": { + "name": "revision_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "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 + }, + "cloned_from_revision_id": { + "name": "cloned_from_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderation_state": { + "name": "moderation_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "moderation_reason": { + "name": "moderation_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderated_at": { + "name": "moderated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "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_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": {} + } +} \ No newline at end of file diff --git a/apps/web/drizzle/migrations/meta/_journal.json b/apps/web/drizzle/migrations/meta/_journal.json index f194f14..c3070e3 100644 --- a/apps/web/drizzle/migrations/meta/_journal.json +++ b/apps/web/drizzle/migrations/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1792412951303, "tag": "0019_collection_revision_lineage", "breakpoints": true + }, + { + "idx": 20, + "version": "6", + "when": 1792464094499, + "tag": "0020_collection_moderation", + "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 index 71b3bd8..727f79e 100644 --- a/apps/web/tests/collections/collections-api.test.ts +++ b/apps/web/tests/collections/collections-api.test.ts @@ -1,54 +1,5 @@ -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 }); +import { describe, expect, it } from "vitest"; +import { ADMIN_SECRET, DIGEST, call, create, h, otherKey, scopedKey } from "./collections-harness"; describe("collections API", () => { it("is 404 while the flag is off", async () => { @@ -126,7 +77,7 @@ describe("collections API", () => { 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" } }); + const admin = await call("/api/v1/collections", { slug: "x" }, { key: null, headers: { "X-Admin-Secret": ADMIN_SECRET } }); expect(admin).toMatchObject({ status: 403, body: { error: { code: "collection.owner_only" } } }); }); diff --git a/apps/web/tests/collections/collections-harness.ts b/apps/web/tests/collections/collections-harness.ts new file mode 100644 index 0000000..b2c2dbc --- /dev/null +++ b/apps/web/tests/collections/collections-harness.ts @@ -0,0 +1,57 @@ +/** + * Shared harness of the collections API tests: a fake D1 with every + * migration, three keys (two owners and a scoped publish key) and a `call` + * that drives the route module the way React Router does. + */ +import { beforeEach } 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>; +export let h: Harness; +export let ownerKey: string; +export let otherKey: string; +export let scopedKey: string; + +export const DIGEST = `sha256:${"a".repeat(64)}`; +export const ADMIN_SECRET = "test-admin-secret-0123456789"; + +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")]); +}); + +export interface CallOptions { + key?: string | null; + env?: Record; + method?: string; + headers?: Record; +} + +export 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, + ...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 }; +} + +export const create = (extra: Record = {}, o: CallOptions = {}) => + call("/api/v1/collections", { slug: "starter", name: "Starter", members: [{ packageName: "@alpha/demo", version: "1.0.0" }], config: { scope: "user" }, ...extra }, o); diff --git a/apps/web/tests/collections/collections-moderation.test.ts b/apps/web/tests/collections/collections-moderation.test.ts new file mode 100644 index 0000000..dacb0ca --- /dev/null +++ b/apps/web/tests/collections/collections-moderation.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { collectionLimit } from "~/lib/collections/collection-http"; +import { collectionView, isHidden } from "~/lib/collections/collection-views"; +import { ADMIN_SECRET, call, create, h, otherKey } from "./collections-harness"; + +const admin = { key: null, headers: { "X-Admin-Secret": ADMIN_SECRET } }; +const moderate = (id: string, body: Record) => call(`/api/v1/collections/${id}/moderation`, body, admin); + +describe("collections per-account cap", () => { + it("defaults to 100 and takes a positive integer override", () => { + expect(collectionLimit({})).toBe(100); + expect(collectionLimit({ SKILLX_COLLECTIONS_MAX_PER_USER: " 3 " })).toBe(3); + for (const bad of ["0", "-1", "abc", "1.5", "9999999"]) expect(collectionLimit({ SKILLX_COLLECTIONS_MAX_PER_USER: bad })).toBe(100); + }); + + it("refuses a create or clone over the cap with 409 and writes nothing", async () => { + const env = { SKILLX_COLLECTIONS_MAX_PER_USER: "2" }; + expect((await create({ slug: "one" }, { env })).status).toBe(201); + const pub = (await create({ slug: "two", visibility: "public" }, { env })).body.collection; + const over = await create({ slug: "three" }, { env }); + expect(over).toMatchObject({ status: 409, body: { error: { code: "collection.limit_reached" } } }); + const clone = await call(`/api/v1/collections/${pub.id}/clone`, { slug: "copy" }, { env }); + expect(clone.body.error.code).toBe("collection.limit_reached"); + expect(h.d1.raw.prepare("SELECT count(*) AS n FROM collections").get()).toEqual({ n: 2 }); + expect(h.d1.raw.prepare("SELECT count(*) AS n FROM collection_revisions").get()).toEqual({ n: 2 }); + // The cap is per account: another user still clones, and deleting frees a slot. + expect((await call(`/api/v1/collections/${pub.id}/clone`, { slug: "copy" }, { env, key: otherKey })).status).toBe(201); + expect((await call(`/api/v1/collections/${pub.id}`, undefined, { method: "DELETE" })).status).toBe(200); + expect((await create({ slug: "three" }, { env })).status).toBe(201); + }); +}); + +describe("collections moderation", () => { + it("hides a public collection from everyone but its owner, and blocks cloning", async () => { + const { id } = (await create({ visibility: "public" })).body.collection; + const hidden = await moderate(id, { from: "active", to: "hidden", reason: "spam" }); + expect(hidden.status).toBe(200); + expect(hidden.body.collection).toMatchObject({ id, visibility: "public", moderation: { state: "hidden", reason: "spam" } }); + expect((await call(`/api/v1/collections/${id}`, undefined, { key: null })).status).toBe(404); + expect((await call(`/api/v1/collections/${id}/revisions/latest`, undefined, { key: otherKey })).status).toBe(404); + expect((await call(`/api/v1/collections/${id}/clone`, { slug: "copy" }, { key: otherKey })).status).toBe(404); + const own = await call(`/api/v1/collections/${id}`); + expect(own.body.collection.moderation).toMatchObject({ state: "hidden", reason: "spam" }); + expect((await call(`/api/v1/collections/${id}/clone`, { slug: "copy" })).body.error.code).toBe("collection.hidden"); + // Re-publishing does not lift the hide; only an operator does. + expect((await call(`/api/v1/collections/${id}`, { visibility: "unlisted" }, { method: "PATCH" })).status).toBe(200); + expect((await call(`/api/v1/collections/${id}`, undefined, { key: null })).status).toBe(404); + // The owner keeps editing; new revisions stay hidden, and deleting is refused so the slot stays used. + expect((await call(`/api/v1/collections/${id}/members`, { expectedRevision: 1, remove: ["@alpha/demo"] })).status).toBe(201); + expect((await call(`/api/v1/collections/${id}/revisions/latest`, undefined, { key: null })).status).toBe(404); + const del = await call(`/api/v1/collections/${id}`, undefined, { method: "DELETE" }); + expect(del).toMatchObject({ status: 409, body: { error: { code: "collection.hidden" } } }); + expect((await moderate(id, { from: "hidden", to: "active" })).body.collection.moderation).toBeUndefined(); + expect((await call(`/api/v1/collections/${id}`, undefined, { key: null })).status).toBe(200); + }); + + it("is compare-and-set and audits every attempt", async () => { + const { id } = (await create({ visibility: "public" })).body.collection; + expect((await moderate(id, { from: "active", to: "hidden", reason: "spam" })).status).toBe(200); + const stale = await moderate(id, { from: "active", to: "hidden", reason: "other" }); + expect(stale).toMatchObject({ status: 409, body: { error: { code: "collection.moderation_conflict" } } }); + expect((await moderate(id, { from: "hidden", to: "hidden" })).status).toBe(400); + expect((await moderate(id, { from: "active", to: "gone" })).status).toBe(400); + const read = await call(`/api/v1/collections/${id}/moderation`, undefined, admin); + expect(read.body.collection.moderation).toMatchObject({ state: "hidden", reason: "spam" }); + expect(read.body.revision).toMatchObject({ revisionNumber: 1, members: [{ packageName: "@alpha/demo" }] }); + expect(read.body.history.map((e: { outcome: string }) => e.outcome).sort()).toEqual(["applied", "stale"]); + const rows = h.d1.raw.prepare("SELECT actor_kind, subject_kind FROM release_state_audit WHERE subject_id = ?").all(id); + expect(rows).toEqual([ + { actor_kind: "admin", subject_kind: "collection" }, + { actor_kind: "admin", subject_kind: "collection" }, + ]); + }); + + it("reads a row written before migration 0020 as active", () => { + const legacy = { id: "col_x", owner_id: "u", slug: "s", name: "n", visibility: "public", latest_revision_number: 1, cloned_from_revision_id: null, created_at: 0, updated_at: 0 }; + expect(isHidden(legacy)).toBe(false); + expect(collectionView(legacy, null)).not.toHaveProperty("moderation"); + }); + + it("is reserved to the admin secret and 404s unknown collections", async () => { + const { id } = (await create({ visibility: "public" })).body.collection; + expect((await call(`/api/v1/collections/${id}/moderation`, { from: "active", to: "hidden" })).body.error.code).toBe("collection.admin_only"); + expect((await call(`/api/v1/collections/${id}/moderation`, undefined, { key: null })).status).toBe(401); + const wrong = await call(`/api/v1/collections/${id}/moderation`, undefined, { key: null, headers: { "X-Admin-Secret": "nope" } }); + expect(wrong.body.error.code).toBe("invalid_admin_secret"); + expect((await moderate("col_00000000-0000-0000-0000-000000000000", { from: "active", to: "hidden" })).status).toBe(404); + expect((await call(`/api/v1/collections/${id}/moderation`, undefined, { method: "DELETE", ...admin })).status).toBe(405); + }); +}); diff --git a/docs/api-reference.md b/docs/api-reference.md index 1e47890..5d38965 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1395,6 +1395,8 @@ numbered revisions; per-user overlays and activation stay with personal kits. | GET | `/:id/revisions/:n` or `/latest` | Reader | `{revision}` | | POST | `/:id/members` | Owner | `{expectedRevision, add?: Member[], remove?: string[]}` → 201 `{revision}` | | POST | `/:id/clone` | Reader | `{slug?, name?, visibility?, revisionNumber?}` → 201 new private collection of the caller | +| GET | `/:id/moderation` | Admin | `{collection, revision, history}` (state, latest revision, newest 100 audited attempts) | +| POST | `/:id/moderation` | Admin | `{from, to, reason?}` (`active` ⇄ `hidden`) → `{collection}` | - **Auth**: owner writes need a session or an unscoped account API key; scoped keys are refused. A session write from another origin is `403 cross_origin` @@ -1402,6 +1404,23 @@ numbered revisions; per-user overlays and activation stay with personal kits. `RELEASE_READ_LIMITER`. - **Visibility**: `private` (default; non-owners get 404), `unlisted` and `public` (anyone may read and clone). +- **Per-account cap**: one account keeps at most 100 collections + (`SKILLX_COLLECTIONS_MAX_PER_USER` overrides it with a positive integer). A + create or clone over the cap answers `409 collection.limit_reached` and + writes nothing; deleting a collection frees a slot. The cap is enforced in + the insert itself, so concurrent creates cannot pass it. +- **Moderation**: an operator (`X-Admin-Secret` only; accounts get + `403 collection.admin_only`) hides a collection with + `POST /:id/moderation {from: "active", to: "hidden", reason?}` and lifts it + with the reverse edge. A hidden collection answers 404 to everyone but its + owner, whatever its visibility, and changing the visibility does not lift + it. The owner still reads and edits it and sees + `collection.moderation = {state: "hidden", reason, at}`, but cannot clone it + (`403 collection.hidden`) or delete it (`409 collection.hidden`; it keeps its + slot under the cap). The change is compare-and-set: a stale `from` + answers `409 collection.moderation_conflict`. Every compare-and-set attempt + (`applied` or `stale`) is appended to + `release_state_audit` with `subject_kind = 'collection'`. - **Members**: `{packageName, version?, digest?, optional?}`. A member without `version` follows the newest stable release at install time; a pinned one is exact, and a `digest` must equal that release's `releaseDigest`. @@ -1415,6 +1434,8 @@ numbered revisions; per-user overlays and activation stay with personal kits. Database: migration `0019_collection_revision_lineage` adds `collection_revisions.revision_digest`, `collections.cloned_from_revision_id` and the `collection_revisions_immutable` trigger (rollback SQL in its header). +Migration `0020_collection_moderation` adds `collections.moderation_state` +(default `active`), `moderation_reason` and `moderated_at`. ### Changing release and publisher state diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index 91715f9..7029bb3 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -488,6 +488,26 @@ to `"0"`, redeploy, then run the header SQL of `0019_collection_revision_lineage.sql` (drops the trigger and both columns, clears the `d1_migrations` row). +**Migration 0020 and collection moderation.** `0020_collection_moderation` +adds `collections.moderation_state` (`NOT NULL DEFAULT 'active'`) and the +nullable `moderation_reason` and `moderated_at`. It is additive and either +deploy order works: the collection code reads a missing `moderation_state` as +`active`, and only the admin route `/api/v1/collections/:id/moderation` needs +the columns (it answers 500 until the migration is applied). Apply it with +`pnpm run db:migrate:remote` after a backup. Hide a collection with: + +```bash +curl -X POST "https://skillx.sh/api/v1/collections/$ID/moderation" \ + -H "X-Admin-Secret: $ADMIN_SECRET" -H 'Content-Type: application/json' \ + -d '{"from":"active","to":"hidden","reason":"spam"}' +``` + +The per-account collection cap defaults to 100; set the var +`SKILLX_COLLECTIONS_MAX_PER_USER` to change it. Rollback: deploy the previous +code, then run the header SQL of `0020_collection_moderation.sql`. Code before +0020 ignores `moderation_state`, so a rollback un-hides content: first run +`UPDATE collections SET visibility = 'private' WHERE moderation_state = 'hidden'`. + The CLI installer (`skillx install`, `sync --locked`, `mcp`) needs no server change beyond the release read API, but it verifies manifest signatures against keys the user trusts (see [CLI reference](./cli-reference.md#trusted-signing-keys)).