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
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ pnpm db:migrate:remote # Apply migrations to remote D1
| `/api/mcp` | api.mcp.ts (read-only MCP over JSON-RPC: `search_packages`, `get_package`, `get_release`, `check_compatibility`, `get_kit`; 64 KiB body cap, `RELEASE_READ_LIMITER` under `mcp:`) | None; only `get_kit` reads an unscoped Key/Session (owner view) |
| `/profile` | profile.tsx | Session |
| `/settings` | settings.tsx | Session |
| `/dashboard` | dashboard.tsx (creator dashboard: packages with visibility, version history, yank, delete/restore; kits with revisions, delete/restore; publish-CLI panel; a flag-off section says "unavailable") | Session |
| `/api/auth/*` | auth-catchall.tsx | Better Auth |
| `/api/search` | api.search.ts | None |
| `/api/skills/:slug` | api.skill-detail.ts | None |
Expand All @@ -110,19 +111,21 @@ pnpm db:migrate:remote # Apply migrations to remote D1
| `/api/v1/releases/:digest/files/*`, `/api/v1/releases/:digest/artifact` | api.v1.release-file.ts, api.v1.release-artifact.ts (digest reads take `?version=` and `?package=` selectors) | Optional Key/Session (404 unless `SKILLX_RELEASES_API` on) |
| `/api/v1/packages/:scope/:slug/releases/:version/status` | api.v1.release-status.ts (GET signed `skillx.release-status/v1`, lazily issued) | Optional Key/Session (404 unless `SKILLX_RELEASES_API` on) |
| `/api/v1/packages/:scope/:slug/releases/:version/state`, `/api/v1/publishers/:handle/state` | api.v1.release-state.ts (POST compare-and-set state change, audited) | Admin `X-Admin-Secret` or owner session/full API key; scoped keys refused (404 unless `SKILLX_RELEASES_API` on) |
| `/api/v1/packages/:scope/:slug/visibility`, `.../delete`, `.../restore` | api.v1.package-manage.ts → lib/creator/ (POST; visibility is a compare-and-set `{from,to,approve?}`, widening (out of `private`, or to `public`) only from a web session with `approve: true`; an admin delete is restorable only by the admin; soft delete with a 30-day restore, then 410; audited in `package_audit`; see docs/api-reference.md#managing-packages-and-kits-owner) | Owner session or unscoped key; admin may delete/restore; scoped keys 401, non-owners 404 (404 unless `SKILLX_RELEASES_API` on) |
| `/api/v1/publishers` | api.v1.publishers.ts (POST provision an active publisher; idempotent, audited) | Admin `X-Admin-Secret` only; accounts 403 (404 unless `SKILLX_RELEASES_API` on) |
| `/api/v1/keys` | api.v1.signing-keys.ts | None (404 unless `SKILLX_RELEASES_API` on) |
| `/api/v1/packages/:scope/:slug/releases/:version/promote` | api.v1.release-promote.ts (POST channel move, audited; publish credentials may only promote) | Scoped key / OIDC publish token, owner session/key, or admin (404 unless `SKILLX_RELEASES_API` on) |
| `/api/v1/packages/:scope/:slug/releases/:version/review`, `/api/v1/admin/quarantine` | api.v1.release-review.ts, api.v1.admin-quarantine.ts (approve/reject quarantined releases; see docs/release-resolution-policy.md) | Admin `X-Admin-Secret` only (404 unless `SKILLX_RELEASES_API` on) |
| `/api/v1/publishers/:handle/trusted-publishers`, `.../:id/revoke` | api.v1.trusted-publishers.ts (GitHub OIDC trust config, audited) | Admin `X-Admin-Secret` only (404 unless `SKILLX_RELEASES_API` on) |
| `/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/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, soft `delete`/`restore` (admin too); 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; 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)

`skills`, `skill_references`, `ratings`, `reviews`, `favorites`, `votes`, `usageStats`, `apiKeys`, `installs`
Registry: `packages` (visibility `public|unlisted|private`, soft-delete `deleted_at`), `package_releases`, `publishers`, append-only `package_audit` (owner visibility/delete/restore), and more in `lib/db/registry-schema.ts`
Plus Better Auth tables: `user`, `session`, `account`, `verification`

## Key Patterns
Expand Down Expand Up @@ -163,7 +166,7 @@ Search algorithm: `./docs/search-algorithm.md`
- **Scoped API keys** — `api_keys.scopes` NULL = legacy key; scoped keys work only where their scope is checked: `releases:publish:@handle` on release publish/promote, `library:read` on release reads and kit reads, `kits:write:@handle` on that handle's kits (`lib/auth/scoped-viewer.ts`). They are stored under `hashScopedApiKey` (domain-separated), so legacy routes (`authenticateApiKey`, by plain key hash) never accept them. Apply migration 0012 to remote D1 before deploying (`docs/deployment-guide.md`).
- **AgentKit identity** (`app/lib/agentkit/`, flag `SKILLX_AGENTKIT_IDENTITY`, migration 0021) — an agentkit.best `at+jwt` for `https://skillx.sh/api` is accepted as Bearer wherever scoped keys are (`resolveRequestCredential`), verified locally via JWKS and mapped `skillx-kits:read` → `library:read`, `skillx-kits:write` → `kits:write:@<active handle>`. No token-exchange endpoint. Liveness: a link older than 60 s is re-validated by refreshing SkillX's own grant (one-time refresh claimed in D1); `invalid_grant` deletes the link. GitHub login is unchanged; unlinked accounts behave as before. See `docs/api-reference.md#agentkit-access-token-flag-skillx_agentkit_identity`.
- **Releases are byte-exact** — the release publish API (`app/lib/releases/`) never sanitizes; the scan only picks `published` vs `quarantined`. R2 writes are create-only, the D1 row is written last. Only `@agentkit/*` kit releases (metadata `provenance.provider = "kit"`) are signed as `skillx.release/v2`; everything else stays v1, and a registry signature never yields `signed-kit` trust. Kit release slugs omit `ak-` (`@agentkit/ak-*` is 400; AgentKit maps `ak-<x>` to `@agentkit/<x>`), and clients need skillx-sh 0.7.0+ to verify v2. See `docs/api-reference.md#publishing-releases`.
- **Same-origin on cookie mutations** — `SameSite=Lax` alone lets a sibling subdomain ride the session cookie, so a mutation a session can authenticate must refuse a foreign origin with `crossOriginRefusal` (`app/lib/auth/same-origin.ts`) before it writes. Session-only routes (rate, review, favorite, vote, user API keys) check first; routes that also take a Bearer key check only on the session path (register after `authenticateRequest`; every `resolveLimitedActor` caller — release state, promote, review, collections, personal kits —, 403 `cross_origin`). Add the check to any new cookie-authenticated mutation.
- **Same-origin on cookie mutations** — `SameSite=Lax` alone lets a sibling subdomain ride the session cookie, so a mutation a session can authenticate must refuse a foreign origin with `crossOriginRefusal` (`app/lib/auth/same-origin.ts`) before it writes. Session-only routes (rate, review, favorite, vote, user API keys) check first; routes that also take a Bearer key check only on the session path (register after `authenticateRequest`; every `resolveLimitedActor` caller — release state, promote, review, collections, personal kits, owner package/kit management —, 403 `cross_origin`). Add the check to any new cookie-authenticated mutation.
- **Claude plugin** — `plugins/skillx/` is the only folder submitted to the Claude directory; keep everything it loads inside it. Run any CLI as `skillx` or an exact `npx --yes skillx-sh@<version>` pin, never `npx skillx` (a different npm package). Its skill must not trigger on unrelated tasks or load/install a catalog skill without the user's explicit yes. Before changing it, run `claude plugin validate plugins/skillx --profile directory --strict` and its eval suite (`docs/claude-plugin.md`).
- **Content security scanning** — all SKILL.md content scanned for prompt injection, invisible chars, ANSI escapes, shell injection. `risk_label` column stores result ("safe"/"caution"/"danger"/"unknown"). Sanitization strips zero-width Unicode + ANSI escapes before storage.

Expand Down
81 changes: 81 additions & 0 deletions apps/web/app/components/dashboard/confirm-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useId, useState } from "react";
import type { ReactNode } from "react";

interface ConfirmDialogProps {
title: string;
children: ReactNode;
confirmLabel: string;
/** The exact text the user must type before confirming (a package or kit name). */
requireText?: string;
/** Red confirm button for destructive actions. */
danger?: boolean;
busy?: boolean;
error?: string | null;
onConfirm: () => void;
onCancel: () => void;
}

/** Modal confirmation; stays open with the server error until the action succeeds or is cancelled. */
export function ConfirmDialog({ title, children, confirmLabel, requireText, danger = false, busy = false, error = null, onConfirm, onCancel }: ConfirmDialogProps) {
const [typed, setTyped] = useState("");
const titleId = useId();
const inputId = useId();
const ready = !busy && (requireText === undefined || typed === requireText);

return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<form
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="w-full max-w-lg rounded-lg border border-sx-border bg-sx-bg p-6"
onSubmit={(e) => {
e.preventDefault();
if (ready) onConfirm();
}}
>
<h3 id={titleId} className="mb-3 font-mono text-lg font-semibold">
{title}
</h3>
<div className="space-y-2 text-sm text-sx-fg-muted">{children}</div>

{requireText !== undefined && (
<div className="mt-4">
<label htmlFor={inputId} className="mb-1 block text-sm">
Type <code className="font-mono text-sx-fg">{requireText}</code> to confirm
</label>
<input
id={inputId}
type="text"
autoComplete="off"
value={typed}
onChange={(e) => setTyped(e.target.value)}
className="w-full rounded-lg border border-sx-border bg-sx-bg-elevated px-3 py-2 font-mono text-sm text-sx-fg"
/>
</div>
)}

{error && (
<p role="alert" className="mt-4 rounded-lg border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-400">
{error}
</p>
)}

<div className="mt-6 flex justify-end gap-3">
<button type="button" onClick={onCancel} className="rounded-lg border border-sx-border px-4 py-2 text-sm text-sx-fg-muted hover:text-sx-fg">
Cancel
</button>
<button
type="submit"
disabled={!ready}
className={`rounded-lg px-4 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50 ${
danger ? "bg-red-500 text-white hover:bg-red-600" : "bg-sx-accent text-sx-bg hover:bg-sx-accent-hover"
}`}
>
{busy ? "Working…" : confirmLabel}
</button>
</div>
</form>
</div>
);
}
86 changes: 86 additions & 0 deletions apps/web/app/components/dashboard/kits-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useState } from "react";
import { ChevronDown, ChevronRight, Layers } from "lucide-react";
import type { DashboardKit } from "~/lib/creator/creator-dashboard";
import { LifecycleActions } from "./lifecycle-actions";
import { Unavailable } from "./packages-section";
import type { SectionData } from "./packages-section";
import { StateBadge } from "./state-badge";
import { formatDate, kitActionUrl } from "./use-owner-action";

function KitRow({ kit }: { kit: DashboardKit }) {
const [open, setOpen] = useState(false);
const active = kit.publisherState === "active";
return (
<li className="rounded-lg border border-sx-border bg-sx-bg-elevated">
<div className="flex flex-wrap items-center gap-3 px-4 py-3">
<button
type="button"
onClick={() => setOpen(!open)}
aria-expanded={open}
className="flex min-w-0 flex-1 items-center gap-2 text-left font-mono text-sm text-sx-fg hover:text-sx-accent"
>
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="truncate">{kit.identity}</span>
<span className="text-xs text-sx-fg-subtle">
effective {kit.effectiveRevision === null ? "none" : `#${kit.effectiveRevision.number}`} · latest #{kit.latestRevisionNumber} · generation {kit.generation}
</span>
</button>
{!active && <StateBadge state={kit.publisherState} />}
{/* Kit visibility lives in its revision document; it changes by activating a revision, not here. */}
<StateBadge state={kit.deletedAt === null ? kit.visibility : "deleted"} />
<LifecycleActions
noun="kit"
identity={kit.identity}
deletedAt={kit.deletedAt}
restoreDeadline={kit.restoreDeadline}
urlFor={(action) => kitActionUrl(kit.identity, action)}
disabled={!active}
/>
</div>
{open && (
<div className="border-t border-sx-border px-4 py-3">
{kit.revisions.length === 0 ? (
<p className="text-sm text-sx-fg-muted">No revisions.</p>
) : (
<ol className="space-y-1 text-xs">
{kit.revisions.map((r) => (
<li key={r.id} className="flex items-center gap-3">
<span className="w-10 font-mono text-sx-fg">#{r.number}</span>
<StateBadge state={r.state} />
{kit.effectiveRevision?.id === r.id && <StateBadge state="active" />}
<span className="text-sx-fg-subtle">{formatDate(r.createdAt)}</span>
</li>
))}
</ol>
)}
{kit.latestRevisionNumber > kit.revisions.length && <p className="mt-2 text-[11px] text-sx-fg-subtle">Only the newest {kit.revisions.length} revisions are shown.</p>}
</div>
)}
</li>
);
}

/** "My personal kits": the user's kits with their revision history, deleted ones included. */
export function KitsSection({ data }: { data: SectionData<DashboardKit> }) {
return (
<section className="mb-10" aria-labelledby="my-kits">
<h2 id="my-kits" className="mb-4 flex items-center gap-2 font-mono text-lg font-semibold">
<Layers size={18} className="text-sx-accent" /> My personal kits
</h2>
{data.status === "unavailable" ? (
<Unavailable what="Personal kits" reason={data.reason} />
) : data.items.length === 0 ? (
<p className="text-sm text-sx-fg-muted">You have no personal kits.</p>
) : (
<>
<ul className="space-y-2">
{data.items.map((kit) => (
<KitRow key={kit.identity} kit={kit} />
))}
</ul>
{data.truncated && <p className="mt-2 text-xs text-sx-fg-subtle">Showing the first {data.limit} kits by name.</p>}
</>
)}
</section>
);
}
84 changes: 84 additions & 0 deletions apps/web/app/components/dashboard/lifecycle-actions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { useState } from "react";
import { RotateCcw, Trash2 } from "lucide-react";
import { ConfirmDialog } from "./confirm-dialog";
import { formatDate, useOwnerAction } from "./use-owner-action";

interface LifecycleActionsProps {
/** `package` or `kit`, for the wording. */
noun: string;
identity: string;
deletedAt: number | null;
restoreDeadline: number | null;
/** The delete or restore endpoint of this item. */
urlFor: (action: "delete" | "restore") => string;
disabled?: boolean;
}

const BUTTON = "inline-flex items-center gap-1 rounded border px-2 py-1 text-xs disabled:cursor-not-allowed disabled:opacity-50";

/**
* Delete (confirmed by typing the exact name) or, for a deleted item, Restore
* with its deadline. After the 30-day window the item stays a tombstone: it
* cannot be restored and its name is never reused.
*/
export function LifecycleActions({ noun, identity, deletedAt, restoreDeadline, urlFor, disabled = false }: LifecycleActionsProps) {
const { run, busy, error, clearError } = useOwnerAction();
const [confirming, setConfirming] = useState(false);

if (deletedAt !== null) {
const expired = restoreDeadline !== null && Date.now() > restoreDeadline;
return (
<div className="flex flex-col items-end gap-1">
<button
type="button"
disabled={disabled || busy || expired}
onClick={() => void run(urlFor("restore"), {})}
className={`${BUTTON} border-sx-accent/40 text-sx-accent hover:bg-sx-accent/10`}
>
<RotateCcw size={12} /> {busy ? "Restoring…" : "Restore"}
</button>
<span className="text-[11px] text-sx-fg-subtle">{expired ? "Restore window expired" : `Restorable until ${formatDate(restoreDeadline)}`}</span>
{error && (
<span role="alert" className="max-w-xs text-right text-[11px] text-red-400">
{error}
</span>
)}
</div>
);
}

const close = () => {
setConfirming(false);
clearError();
};
return (
<>
<button type="button" disabled={disabled} onClick={() => setConfirming(true)} className={`${BUTTON} border-red-500/40 text-red-400 hover:bg-red-500/10`}>
<Trash2 size={12} /> Delete
</button>
{confirming && (
<ConfirmDialog
title={`Delete ${identity}?`}
confirmLabel={`Delete ${noun}`}
requireText={identity}
danger
busy={busy}
error={error}
onCancel={close}
onConfirm={async () => {
if (await run(urlFor("delete"), {})) close();
}}
>
<p>
New requests for the {noun} stop being served at once: reads, listings, installs and download links refuse it, for everyone including you. You
can restore it from this dashboard for 30 days.
</p>
<p>
After 30 days it cannot be restored, and its name stays reserved: it can never be published again. Copies already installed, downloaded or cached
elsewhere are not deleted or recalled.
</p>
</ConfirmDialog>
)}
</>
);
}
Loading
Loading