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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
25 changes: 20 additions & 5 deletions apps/web/app/lib/collections/collection-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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<Response>,
now: () => Date = () => new Date(),
): Promise<Response> {
Expand All @@ -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) {
Expand Down
66 changes: 66 additions & 0 deletions apps/web/app/lib/collections/collection-moderation.ts
Original file line number Diff line number Diff line change
@@ -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<CollectionRow> {
const row = await c.env.DB.prepare("SELECT * FROM collections WHERE id = ?").bind(id).first<CollectionRow>();
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<Response> {
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<AuditRow>();
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<Response> {
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) });
}
20 changes: 16 additions & 4 deletions apps/web/app/lib/collections/collection-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}$/;
Expand Down Expand Up @@ -61,6 +61,8 @@ export function editMembers(base: CollectionMember[], add: unknown, remove: unkn
type Handler = (c: CollectionCaller, id: string, args: string[]) => Promise<Response>;
interface RouteDef {
pattern: RegExp;
/** Every method of the route needs the admin secret. */
admin?: true;
get?: Handler;
post?: Handler;
patch?: Handler;
Expand All @@ -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 });
},
},
Expand Down Expand Up @@ -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));
Expand All @@ -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) => {
Expand Down Expand Up @@ -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();
Expand Down
76 changes: 21 additions & 55 deletions apps/web/app/lib/collections/collection-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CollectionRow> {
const row = await db(c).prepare("SELECT * FROM collections WHERE id = ?").bind(id).first<CollectionRow>();
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;
}

Expand Down Expand Up @@ -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"),
Expand All @@ -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 };
Expand Down
65 changes: 65 additions & 0 deletions apps/web/app/lib/collections/collection-views.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}

Loading
Loading