diff --git a/CLAUDE.md b/CLAUDE.md index 42c751e..d6761b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | @@ -110,6 +111,7 @@ 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) | @@ -117,12 +119,13 @@ pnpm db:migrate:remote # Apply migrations to remote D1 | `/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 @@ -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:@`. 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-` to `@agentkit/`), 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@` 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. diff --git a/apps/web/app/components/dashboard/confirm-dialog.tsx b/apps/web/app/components/dashboard/confirm-dialog.tsx new file mode 100644 index 0000000..bb11d20 --- /dev/null +++ b/apps/web/app/components/dashboard/confirm-dialog.tsx @@ -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 ( +
+
{ + e.preventDefault(); + if (ready) onConfirm(); + }} + > +

+ {title} +

+
{children}
+ + {requireText !== undefined && ( +
+ + 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" + /> +
+ )} + + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+
+ ); +} diff --git a/apps/web/app/components/dashboard/kits-section.tsx b/apps/web/app/components/dashboard/kits-section.tsx new file mode 100644 index 0000000..23d94d1 --- /dev/null +++ b/apps/web/app/components/dashboard/kits-section.tsx @@ -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 ( +
  • +
    + + {!active && } + {/* Kit visibility lives in its revision document; it changes by activating a revision, not here. */} + + kitActionUrl(kit.identity, action)} + disabled={!active} + /> +
    + {open && ( +
    + {kit.revisions.length === 0 ? ( +

    No revisions.

    + ) : ( +
      + {kit.revisions.map((r) => ( +
    1. + #{r.number} + + {kit.effectiveRevision?.id === r.id && } + {formatDate(r.createdAt)} +
    2. + ))} +
    + )} + {kit.latestRevisionNumber > kit.revisions.length &&

    Only the newest {kit.revisions.length} revisions are shown.

    } +
    + )} +
  • + ); +} + +/** "My personal kits": the user's kits with their revision history, deleted ones included. */ +export function KitsSection({ data }: { data: SectionData }) { + return ( +
    +

    + My personal kits +

    + {data.status === "unavailable" ? ( + + ) : data.items.length === 0 ? ( +

    You have no personal kits.

    + ) : ( + <> +
      + {data.items.map((kit) => ( + + ))} +
    + {data.truncated &&

    Showing the first {data.limit} kits by name.

    } + + )} +
    + ); +} diff --git a/apps/web/app/components/dashboard/lifecycle-actions.tsx b/apps/web/app/components/dashboard/lifecycle-actions.tsx new file mode 100644 index 0000000..891c559 --- /dev/null +++ b/apps/web/app/components/dashboard/lifecycle-actions.tsx @@ -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 ( +
    + + {expired ? "Restore window expired" : `Restorable until ${formatDate(restoreDeadline)}`} + {error && ( + + {error} + + )} +
    + ); + } + + const close = () => { + setConfirming(false); + clearError(); + }; + return ( + <> + + {confirming && ( + { + if (await run(urlFor("delete"), {})) close(); + }} + > +

    + 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. +

    +

    + 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. +

    +
    + )} + + ); +} diff --git a/apps/web/app/components/dashboard/packages-section.tsx b/apps/web/app/components/dashboard/packages-section.tsx new file mode 100644 index 0000000..6282bcd --- /dev/null +++ b/apps/web/app/components/dashboard/packages-section.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import { ChevronDown, ChevronRight, Package } from "lucide-react"; +import type { DashboardPackage } from "~/lib/creator/creator-dashboard"; +import { LifecycleActions } from "./lifecycle-actions"; +import { ReleaseHistory } from "./release-history"; +import { StateBadge } from "./state-badge"; +import { packageActionUrl } from "./use-owner-action"; +import { VisibilityControl } from "./visibility-control"; + +export type SectionData = { status: "available"; items: T[]; truncated: boolean; limit: number } | { status: "unavailable"; reason: string }; + +export function Unavailable({ what, reason }: { what: string; reason: string }) { + return ( +

    + {what} unavailable: {reason} +

    + ); +} + +function PackageRow({ pkg }: { pkg: DashboardPackage }) { + const [open, setOpen] = useState(false); + const deleted = pkg.deletedAt !== null; + const active = pkg.publisherState === "active"; + return ( +
  • +
    + + {!active && } + {deleted ? : } + packageActionUrl(pkg.identity, action)} + disabled={!active} + /> +
    + {open && ( +
    + +
    + )} +
  • + ); +} + +/** "My packages": every package of the publishers the user owns, deleted ones included. */ +export function PackagesSection({ data }: { data: SectionData }) { + return ( +
    +

    + My packages +

    + {data.status === "unavailable" ? ( + + ) : data.items.length === 0 ? ( +

    You have no packages yet. Publish one with the CLI below.

    + ) : ( + <> +
      + {data.items.map((pkg) => ( + + ))} +
    + {data.truncated &&

    Showing the first {data.limit} packages by name.

    } + + )} +
    + ); +} diff --git a/apps/web/app/components/dashboard/publish-panel.tsx b/apps/web/app/components/dashboard/publish-panel.tsx new file mode 100644 index 0000000..e7582c9 --- /dev/null +++ b/apps/web/app/components/dashboard/publish-panel.tsx @@ -0,0 +1,45 @@ +import { Link } from "react-router"; +import { Terminal } from "lucide-react"; + +interface PublishPanelProps { + /** Active publisher handles the user owns (without `@`). */ + handles: string[]; +} + +/** + * "Publish a new version": releases are published only with the CLI and a + * publish-scoped key (or GitHub OIDC), never from the browser, so this panel + * shows the exact command. Every release is free. + */ +export function PublishPanel({ handles }: PublishPanelProps) { + const handle = handles[0] ?? ""; + const command = `SKILLX_API_KEY=sx_... skillx publish ./my-skill --name @${handle}/my-skill --release-version 1.0.0 --visibility private`; + return ( +
    +

    + Publish a new version +

    +

    Releases are published from your machine or CI with the SkillX CLI:

    +
    +        {command}
    +      
    +
      +
    • + Publishing needs a publish-scoped key (releases:publish:@{handle}) created in{" "} + + Settings + + , or GitHub Actions OIDC with --oidc. skillx release publish is the same + command. +
    • +
    • + --visibility is public, unlisted or{" "} + private and applies only to a new package (default private); change it later here. A CLI older than the + unlisted release refuses unlisted: publish as private, then switch it here. +
    • +
    • A version is immutable: publish a new version instead of replacing one, and yank a bad one here.
    • + {handles.length === 0 &&
    • You do not own an active publisher yet; an operator provisions publisher handles.
    • } +
    +
    + ); +} diff --git a/apps/web/app/components/dashboard/release-history.tsx b/apps/web/app/components/dashboard/release-history.tsx new file mode 100644 index 0000000..2feecad --- /dev/null +++ b/apps/web/app/components/dashboard/release-history.tsx @@ -0,0 +1,92 @@ +import { useState } from "react"; +import type { DashboardRelease } from "~/lib/creator/creator-dashboard"; +import { ConfirmDialog } from "./confirm-dialog"; +import { StateBadge } from "./state-badge"; +import { formatDate, releaseStateUrl, useOwnerAction } from "./use-owner-action"; + +interface ReleaseHistoryProps { + identity: string; + releases: DashboardRelease[]; + truncated: boolean; + /** Yanking is offered only for a live package of an active publisher. */ + canYank: boolean; +} + +/** + * Every release of one package, newest first, with its state. A published + * release can be yanked (the owner edge `published -> yanked` of the state + * API); undoing a yank is an operator action, so it is not offered here. + */ +export function ReleaseHistory({ identity, releases, truncated, canYank }: ReleaseHistoryProps) { + const { run, busy, error, clearError } = useOwnerAction(); + const [yanking, setYanking] = useState(null); + + if (releases.length === 0) return

    No releases yet.

    ; + + const close = () => { + setYanking(null); + clearError(); + }; + return ( +
    + + + + + + + + + + + + + {releases.map((r) => ( + + + + + + + + + + ))} + +
    VersionChannelStatePublishedYankedTree digest +
    {r.version}{r.channel} + + {formatDate(r.publishedAt)}{formatDate(r.yankedAt)} + {r.releaseDigest === null ? "—" : `${r.releaseDigest.slice(0, 19)}…`} + + {canYank && r.state === "published" && ( + + )} +
    + {truncated &&

    Only the newest {releases.length} releases are shown.

    } + + {yanking !== null && ( + { + if (await run(releaseStateUrl(identity, yanking), { from: "published", to: "yanked" })) close(); + }} + > +

    New installs by version stop resolving it (410). Lockfiles pinned to its digest keep working.

    +

    Only an operator can publish a yanked release again.

    +
    + )} +
    + ); +} diff --git a/apps/web/app/components/dashboard/state-badge.tsx b/apps/web/app/components/dashboard/state-badge.tsx new file mode 100644 index 0000000..ef8b1c9 --- /dev/null +++ b/apps/web/app/components/dashboard/state-badge.tsx @@ -0,0 +1,21 @@ +const TONES: Record = { + published: "border-sx-accent/40 bg-sx-accent/10 text-sx-accent", + active: "border-sx-accent/40 bg-sx-accent/10 text-sx-accent", + reviewed: "border-sx-accent/40 bg-sx-accent/10 text-sx-accent", + public: "border-sx-accent/40 bg-sx-accent/10 text-sx-accent", + yanked: "border-amber-500/40 bg-amber-500/10 text-amber-400", + unlisted: "border-sky-500/40 bg-sky-500/10 text-sky-400", + proposed: "border-sky-500/40 bg-sky-500/10 text-sky-400", + quarantined: "border-red-500/40 bg-red-500/10 text-red-400", + rejected: "border-red-500/40 bg-red-500/10 text-red-400", + deleted: "border-red-500/40 bg-red-500/10 text-red-400", + suspended: "border-red-500/40 bg-red-500/10 text-red-400", + revoked: "border-red-500/40 bg-red-500/10 text-red-400", +}; + +const NEUTRAL = "border-sx-border bg-sx-bg-elevated text-sx-fg-muted"; + +/** A small state label (release, revision, visibility, publisher). */ +export function StateBadge({ state }: { state: string }) { + return {state}; +} diff --git a/apps/web/app/components/dashboard/use-owner-action.ts b/apps/web/app/components/dashboard/use-owner-action.ts new file mode 100644 index 0000000..22a89eb --- /dev/null +++ b/apps/web/app/components/dashboard/use-owner-action.ts @@ -0,0 +1,66 @@ +import { useCallback, useState } from "react"; +import { useRevalidator } from "react-router"; + +export type ActionResult = { ok: true; data: Record } | { ok: false; message: string }; + +/** + * POSTs one JSON owner action with the session cookie (same origin) and + * reloads the dashboard data only after a 2xx JSON answer, so an API error is + * never shown as success. Failures carry the server's `error.message` (and + * code), or a network message. + */ +export async function postOwnerAction(url: string, body: Record): Promise { + let res: Response; + try { + res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify(body), + }); + } catch { + return { ok: false, message: "Network error: the change was not applied." }; + } + const parsed = (await res.json().catch(() => null)) as Record | null; + if (!res.ok || parsed === null) { + const error = (parsed?.error ?? null) as { code?: string; message?: string } | null; + const detail = error?.message ?? `HTTP ${res.status}`; + return { ok: false, message: error?.code ? `${detail} (${error.code})` : detail }; + } + return { ok: true, data: parsed }; +} + +/** One in-flight owner action at a time per component, with its error. */ +export function useOwnerAction() { + const revalidator = useRevalidator(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const run = useCallback( + async (url: string, body: Record): Promise => { + setBusy(true); + setError(null); + try { + const result = await postOwnerAction(url, body); + if (!result.ok) { + setError(result.message); + return false; + } + await revalidator.revalidate(); + return true; + } finally { + setBusy(false); + } + }, + [revalidator], + ); + + return { run, busy, error, clearError: () => setError(null) }; +} + +/** `/api/v1/packages/@h/slug/` or `/api/v1/kits/@h/name/`; identities are `@scope/name` in lowercase. */ +export const packageActionUrl = (identity: string, action: string) => `/api/v1/packages/${identity}/${action}`; +export const releaseStateUrl = (identity: string, version: string) => `/api/v1/packages/${identity}/releases/${encodeURIComponent(version)}/state`; +export const kitActionUrl = (identity: string, action: string) => `/api/v1/kits/${identity}/${action}`; + +export const formatDate = (ms: number | null) => (ms === null ? "—" : new Date(ms).toISOString().slice(0, 16).replace("T", " ") + " UTC"); diff --git a/apps/web/app/components/dashboard/visibility-control.tsx b/apps/web/app/components/dashboard/visibility-control.tsx new file mode 100644 index 0000000..4aac6e0 --- /dev/null +++ b/apps/web/app/components/dashboard/visibility-control.tsx @@ -0,0 +1,90 @@ +import { useState } from "react"; +import { PACKAGE_VISIBILITIES } from "@skillx/contracts"; +import type { PackageVisibility } from "@skillx/contracts"; +import { ConfirmDialog } from "./confirm-dialog"; +import { packageActionUrl, useOwnerAction } from "./use-owner-action"; + +interface VisibilityControlProps { + identity: string; + visibility: string; + disabled?: boolean; +} + +const MEANING: Record = { + public: "Readable by anyone, and it may be listed in public discovery.", + unlisted: "Readable by anyone who knows its name or a release digest, but never listed in public discovery.", + private: "Readable only by you and accounts holding an entitlement.", +}; + +const WIDENED_READERS: Record = { + public: "Every release that is not yanked or quarantined becomes downloadable by anyone, without signing in.", + unlisted: "Anyone with its name or a link to a release can read and download every release that is not yanked or quarantined, without signing in.", +}; + +/** + * Visibility selector. Every change is confirmed; widening it (out of private, + * or to public) takes a second explicit approval (the API accepts it only from + * this signed-in session with `approve: true`). Narrowing visibility never + * recalls copies already downloaded or installed. + */ +export function VisibilityControl({ identity, visibility, disabled = false }: VisibilityControlProps) { + const { run, busy, error, clearError } = useOwnerAction(); + const [target, setTarget] = useState(null); + const [step, setStep] = useState<1 | 2>(1); + const widening = target !== null && (visibility === "private" ? target !== "private" : target === "public"); + + const close = () => { + setTarget(null); + setStep(1); + clearError(); + }; + const apply = async () => { + if (target === null) return; + const ok = await run(packageActionUrl(identity, "visibility"), { from: visibility, to: target, ...(widening ? { approve: true } : {}) }); + if (ok) close(); + }; + + return ( + <> + + + + {widening && step === 1 && ( + setStep(2)} onCancel={close}> +

    {MEANING[target]}

    +

    {WIDENED_READERS[target]}

    +
    + )} + {widening && step === 2 && ( + +

    + This is a separate approval: {identity} becomes readable by{" "} + {target === "public" ? "anyone" : "anyone with its name or a link"}. Making it private again later does not recall copies people downloaded in + the meantime. +

    +
    + )} + {target !== null && !widening && ( + +

    {MEANING[target]}

    + {(target === "private" || visibility === "public") &&

    Copies already downloaded or installed are not recalled; only new reads are refused.

    } +
    + )} + + ); +} diff --git a/apps/web/app/components/layout/navbar.tsx b/apps/web/app/components/layout/navbar.tsx index 1cde887..ee2f0b0 100644 --- a/apps/web/app/components/layout/navbar.tsx +++ b/apps/web/app/components/layout/navbar.tsx @@ -50,6 +50,12 @@ export function Navbar() { > Docs + + Dashboard + Profile + setMobileMenuOpen(false)} + className="rounded-lg px-3 py-2 text-sm text-sx-fg-muted transition-colors hover:bg-sx-bg-hover hover:text-sx-fg" + > + Dashboard + setMobileMenuOpen(false)} diff --git a/apps/web/app/lib/creator/creator-dashboard.ts b/apps/web/app/lib/creator/creator-dashboard.ts new file mode 100644 index 0000000..2e3189f --- /dev/null +++ b/apps/web/app/lib/creator/creator-dashboard.ts @@ -0,0 +1,199 @@ +/** + * Owner dashboard data: every package of every publisher the user owns and + * every personal kit the user owns, deleted ones included (with their restore + * deadline). Owner-only by construction: every query is keyed by the user id, + * so quarantined and draft releases, which no read endpoint offers, are shown + * here as states to their owner and to nobody else. + * + * Bounded: at most `DASHBOARD_PACKAGE_LIMIT` packages and `DASHBOARD_KIT_LIMIT` + * kits (by name; `truncated` says more exist), the newest + * `DASHBOARD_RELEASES_PER_PACKAGE` releases of each package and the newest + * `DASHBOARD_REVISIONS_PER_KIT` revisions of each kit. + */ +import { restoreDeadline } from "./owner-audit"; + +export const DASHBOARD_PACKAGE_LIMIT = 200; +export const DASHBOARD_KIT_LIMIT = 200; +export const DASHBOARD_RELEASES_PER_PACKAGE = 100; +export const DASHBOARD_REVISIONS_PER_KIT = 20; + +export interface DashboardRelease { + version: string; + channel: string; + state: string; + publishedAt: number | null; + yankedAt: number | null; + createdAt: number; + releaseDigest: string | null; +} + +export interface DashboardPackage { + identity: string; + kind: string; + visibility: string; + publisherHandle: string; + publisherState: string; + deletedAt: number | null; + restoreDeadline: number | null; + releases: DashboardRelease[]; + /** More releases exist than are listed. */ + releasesTruncated: boolean; +} + +export interface DashboardRevision { + id: string; + number: number; + state: string; + createdAt: number; +} + +export interface DashboardKit { + identity: string; + visibility: string; + publisherState: string; + generation: number; + latestRevisionNumber: number; + effectiveRevision: { id: string; number: number } | null; + deletedAt: number | null; + restoreDeadline: number | null; + revisions: DashboardRevision[]; +} + +const num = (v: unknown) => (v === null || v === undefined ? null : Number(v)); + +async function rowsPerParent(db: D1Database, table: string, parent: string, columns: string, order: string, ids: string[], perParent: number): Promise { + if (ids.length === 0) return []; + const { results } = await db + .prepare( + `SELECT * FROM (SELECT ${columns}, ROW_NUMBER() OVER (PARTITION BY ${parent} ORDER BY ${order}) AS rn + FROM ${table} WHERE ${parent} IN (SELECT value FROM json_each(?))) + WHERE rn <= ? ORDER BY ${parent}, rn`, + ) + .bind(JSON.stringify(ids), perParent + 1) + .all(); + return results; +} + +function groupBy(rows: T[], key: (row: T) => string): Map { + const out = new Map(); + for (const row of rows) out.set(key(row), [...(out.get(key(row)) ?? []), row]); + return out; +} +interface PackageRow { + id: string; + name: string; + kind: string; + visibility: string; + deleted_at: number | null; + handle: string; + publisher_state: string; +} + +interface ReleaseRow { + package_id: string; + version: string; + channel: string; + release_state: string; + published_at: number | null; + yanked_at: number | null; + created_at: number; + release_digest: string | null; +} + +export async function listOwnedPackages(db: D1Database, userId: string): Promise<{ items: DashboardPackage[]; truncated: boolean }> { + if (userId === "") return { items: [], truncated: false }; + const { results } = await db + .prepare( + `SELECT p.id, p.name, p.kind, p.visibility, p.deleted_at, pub.handle, pub.state AS publisher_state + FROM publishers pub JOIN packages p ON p.publisher_id = pub.id + WHERE pub.owner_user_id = ? AND pub.owner_user_id <> '' + ORDER BY p.name LIMIT ?`, + ) + .bind(userId, DASHBOARD_PACKAGE_LIMIT + 1) + .all(); + const page = results.slice(0, DASHBOARD_PACKAGE_LIMIT); + const columns = "package_id, version, channel, release_state, published_at, yanked_at, created_at, release_digest"; + const releases = await rowsPerParent(db, "package_releases", "package_id", columns, "created_at DESC, id", page.map((p) => p.id), DASHBOARD_RELEASES_PER_PACKAGE); + const byPackage = groupBy(releases, (r) => r.package_id); + const items = page.map((p): DashboardPackage => { + const rows = byPackage.get(p.id) ?? []; + const deletedAt = num(p.deleted_at); + return { + identity: p.name, + kind: p.kind, + visibility: p.visibility, + publisherHandle: p.handle, + publisherState: p.publisher_state, + deletedAt, + restoreDeadline: deletedAt === null ? null : restoreDeadline(deletedAt), + releases: rows.slice(0, DASHBOARD_RELEASES_PER_PACKAGE).map((r) => ({ + version: r.version, + channel: r.channel, + state: r.release_state, + publishedAt: num(r.published_at), + yankedAt: num(r.yanked_at), + createdAt: Number(r.created_at), + releaseDigest: r.release_digest, + })), + releasesTruncated: rows.length > DASHBOARD_RELEASES_PER_PACKAGE, + }; + }); + return { items, truncated: results.length > DASHBOARD_PACKAGE_LIMIT }; +} + +interface KitRowRaw { + id: string; + name: string; + visibility: string; + generation: number; + latest_revision_number: number; + effective_revision_id: string | null; + effective_number: number | null; + deleted_at: number | null; + publisher_state: string; +} + +interface RevisionRowRaw { + kit_id: string; + id: string; + revision_number: number; + state: string; + created_at: number; +} + +export async function listOwnedKits(db: D1Database, userId: string): Promise<{ items: DashboardKit[]; truncated: boolean }> { + if (userId === "") return { items: [], truncated: false }; + const { results } = await db + .prepare( + `SELECT k.id, k.name, k.visibility, k.generation, k.latest_revision_number, k.effective_revision_id, + v.revision_number AS effective_number, k.deleted_at, pub.state AS publisher_state + FROM personal_kits k + JOIN publishers pub ON pub.id = k.publisher_id + LEFT JOIN personal_kit_revisions v ON v.id = k.effective_revision_id + WHERE k.owner_user_id = ? + ORDER BY k.name LIMIT ?`, + ) + .bind(userId, DASHBOARD_KIT_LIMIT + 1) + .all(); + const page = results.slice(0, DASHBOARD_KIT_LIMIT); + const revisionColumns = "kit_id, id, revision_number, state, created_at"; + const revisions = await rowsPerParent(db, "personal_kit_revisions", "kit_id", revisionColumns, "revision_number DESC", page.map((k) => k.id), DASHBOARD_REVISIONS_PER_KIT); + const byKit = groupBy(revisions, (r) => r.kit_id); + const items = page.map((k): DashboardKit => { + const deletedAt = num(k.deleted_at); + return { + identity: k.name, + visibility: k.visibility, + publisherState: k.publisher_state, + generation: Number(k.generation), + latestRevisionNumber: Number(k.latest_revision_number), + effectiveRevision: k.effective_revision_id === null ? null : { id: k.effective_revision_id, number: Number(k.effective_number) }, + deletedAt, + restoreDeadline: deletedAt === null ? null : restoreDeadline(deletedAt), + revisions: (byKit.get(k.id) ?? []) + .slice(0, DASHBOARD_REVISIONS_PER_KIT) + .map((r) => ({ id: r.id, number: Number(r.revision_number), state: r.state, createdAt: Number(r.created_at) })), + }; + }); + return { items, truncated: results.length > DASHBOARD_KIT_LIMIT }; +} diff --git a/apps/web/app/lib/creator/kit-management.ts b/apps/web/app/lib/creator/kit-management.ts new file mode 100644 index 0000000..bdfb879 --- /dev/null +++ b/apps/web/app/lib/creator/kit-management.ts @@ -0,0 +1,44 @@ +/** + * Soft delete and restore of a personal kit. Only its owner (session or + * unscoped key) or the admin may; anyone else gets the kit API's unknown-kit + * `404 not_found`. The owner's publisher must still be active and owned + * (`403 kit.publisher_not_owned`, as on every kit write). A kit the admin + * deleted can only be restored by the admin (`403 restore.admin_deleted`). Both changes bump + * the kit generation, so a kit write racing them fails its compare-and-set. + */ +import { kitNotFound } from "~/lib/personal-kits/kit-http"; +import { findKitRecord, requireActivePublisher } from "~/lib/personal-kits/kit-store"; +import type { KitRow } from "~/lib/personal-kits/kit-store"; +import type { RequestActor } from "~/lib/releases/release-state-http"; +import { restoreDeleted, softDelete } from "./owner-audit"; +import type { LifecycleSubject } from "./owner-audit"; + +async function managedKit(db: D1Database, name: string, actor: RequestActor): Promise { + const kit = await findKitRecord(db, name); + if (kit === null) throw kitNotFound(); + if (actor.kind === "admin") return kit; + if (kit.owner_user_id !== actor.id) throw kitNotFound(); + await requireActivePublisher(db, kit, actor.id); + return kit; +} + +const lifecycleSubject = (kit: KitRow): LifecycleSubject => ({ + kind: "kit", + table: "personal_kits", + id: kit.id, + name: kit.name, + deletedAt: kit.deleted_at === null ? null : Number(kit.deleted_at), + extraSet: ", generation = generation + 1", +}); + +export async function deleteKit(db: D1Database, name: string, actor: RequestActor, now: Date) { + const kit = await managedKit(db, name, actor); + const { deletedAt, restoreDeadline } = await softDelete(db, lifecycleSubject(kit), actor, now); + return { kit: name, deletedAt: new Date(deletedAt).toISOString(), restoreDeadline: new Date(restoreDeadline).toISOString() }; +} + +export async function restoreKit(db: D1Database, name: string, actor: RequestActor, now: Date) { + const kit = await managedKit(db, name, actor); + const { restoredAt } = await restoreDeleted(db, lifecycleSubject(kit), actor, now); + return { kit: name, restoredAt: new Date(restoredAt).toISOString(), generation: Number(kit.generation) + 1 }; +} diff --git a/apps/web/app/lib/creator/owner-actions-http.ts b/apps/web/app/lib/creator/owner-actions-http.ts new file mode 100644 index 0000000..a4e1c1f --- /dev/null +++ b/apps/web/app/lib/creator/owner-actions-http.ts @@ -0,0 +1,86 @@ +/** + * HTTP boundary of the owner management API: + * + * - `POST /api/v1/packages/@handle/slug/visibility` with `{"from", "to", "approve"?}` + * - `POST /api/v1/packages/@handle/slug/delete` and `.../restore` with `{}` + * - `POST /api/v1/kits/@handle/name/delete` and `.../restore` with `{}` + * + * 404 `not_found` while `SKILLX_RELEASES_API` is off; the kit routes also + * need `SKILLX_PERSONAL_KITS_API` (else 404 `kit.api_disabled`). The actor is + * resolved exactly as by the release state API (`resolveLimitedActor`): a + * cookie-session request from another origin is `403 cross_origin` first, + * then the admin secret, an unscoped key or the session; every scoped key is + * refused (401), so a leaked CI or kit key cannot delete or publish widely. + * Writes count against `RELEASE_WRITE_LIMITER` before the body is read. + * Bodies are `application/json` (a cross-origin page cannot send that without + * a preflight these routes never grant). Every answer is `no-store`. + */ +import { isPackageVisibility, PACKAGE_VISIBILITIES } from "@skillx/contracts"; +import { kitApiDisabledResponse, parseKitName, personalKitsApiEnabled } from "~/lib/personal-kits/kit-http"; +import { parsePackageIdentity } from "~/lib/releases/release-access"; +import { ReleaseHttpError, errorResponse, notFoundResponse, releaseError } from "~/lib/releases/release-http"; +import { releasesApiEnabled } from "~/lib/releases/release-limits"; +import { NO_STORE, failureHeaders, invalidRequest, methodNotAllowed, readJsonObject, resolveLimitedActor } from "~/lib/releases/release-state-http"; +import { deleteKit, restoreKit } from "./kit-management"; +import { changePackageVisibility, deletePackage, restorePackage } from "./package-management"; + +export type PackageAction = "visibility" | "delete" | "restore"; +export type KitAction = "delete" | "restore"; + +const PACKAGE_ACTIONS: ReadonlySet = new Set(["visibility", "delete", "restore"]); +export const isPackageAction = (value: string): value is PackageAction => PACKAGE_ACTIONS.has(value); +export const isKitAction = (value: string): value is KitAction => value === "delete" || value === "restore"; + +const VISIBILITY_FIELDS: ReadonlySet = new Set(["from", "to", "approve"]); +const NO_FIELDS: ReadonlySet = new Set(); + +type Params = Record; + +/** A signed-in web session: no API key and no admin secret on the request. */ +const isInteractive = (request: Request) => !request.headers.has("Authorization") && !request.headers.has("X-Admin-Secret"); + +async function readVisibilityBody(request: Request) { + const body = await readJsonObject(request, VISIBILITY_FIELDS); + if (!isPackageVisibility(body.from) || !isPackageVisibility(body.to)) throw invalidRequest(`from and to must be one of: ${PACKAGE_VISIBILITIES.join(", ")}`); + if (body.approve !== undefined && typeof body.approve !== "boolean") throw invalidRequest("approve must be a boolean"); + return { from: body.from, to: body.to, approve: body.approve === true }; +} + +async function run(work: () => Promise): Promise { + try { + return Response.json(await work(), { headers: NO_STORE }); + } catch (error) { + if (error instanceof ReleaseHttpError) return errorResponse(error, failureHeaders(error)); + console.error("owner management request failed:", error instanceof Error ? error.message : String(error)); + return errorResponse(releaseError(500, "owner.internal_error", "owner management request failed")); + } +} + +/** Serves one package management request. */ +export async function servePackageAction(request: Request, env: Env, params: Params, action: PackageAction, now: () => Date = () => new Date()): Promise { + if (!releasesApiEnabled(env)) return notFoundResponse(); + if (request.method !== "POST") return methodNotAllowed(); + return run(async () => { + const identity = parsePackageIdentity(params.scope, params.slug); + const actor = await resolveLimitedActor(request, env); + if (action === "visibility") { + const body = await readVisibilityBody(request); + return changePackageVisibility(env.DB, identity, { ...body, interactive: isInteractive(request), actor, now: now() }); + } + await readJsonObject(request, NO_FIELDS); + return action === "delete" ? deletePackage(env.DB, identity, actor, now()) : restorePackage(env.DB, identity, actor, now()); + }); +} + +/** Serves one kit delete or restore request (dispatched by the kit router). */ +export async function serveKitAction(request: Request, env: Env, params: Params, action: KitAction, now: () => Date = () => new Date()): Promise { + if (!releasesApiEnabled(env)) return notFoundResponse(); + if (!personalKitsApiEnabled(env as { SKILLX_PERSONAL_KITS_API?: string })) return kitApiDisabledResponse(); + if (request.method !== "POST") return methodNotAllowed(); + return run(async () => { + const kit = parseKitName(params.scope, params.name); + const actor = await resolveLimitedActor(request, env); + await readJsonObject(request, NO_FIELDS); + return action === "delete" ? deleteKit(env.DB, kit, actor, now()) : restoreKit(env.DB, kit, actor, now()); + }); +} diff --git a/apps/web/app/lib/creator/owner-audit.ts b/apps/web/app/lib/creator/owner-audit.ts new file mode 100644 index 0000000..e84bae6 --- /dev/null +++ b/apps/web/app/lib/creator/owner-audit.ts @@ -0,0 +1,130 @@ +/** + * Audited owner management writes (visibility, delete, restore) of packages + * and personal kits, and the shared soft-delete lifecycle. + * + * Every applied change writes one append-only `package_audit` row in the same + * D1 batch as the change. Both statements carry the same compare-and-set + * guard and run in one transaction, so the audit row exists exactly when the + * change applied: a lost race writes neither. + * + * Soft delete sets `deleted_at`; the subject then does not exist for any read + * path. Restore clears it within `RESTORE_WINDOW_MS`; after that the answer is + * `410 restore_window_expired` and the tombstone stays for good (no hard purge, + * the name is never reused). + */ +import { releaseError } from "~/lib/releases/release-http"; +import type { RequestActor } from "~/lib/releases/release-state-http"; + +/** How long a deleted package or kit may be restored. */ +export const RESTORE_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; + +export const restoreDeadline = (deletedAt: number) => deletedAt + RESTORE_WINDOW_MS; + +export type AuditSubjectKind = "package" | "kit"; +export type AuditAction = "visibility" | "delete" | "restore"; + +export interface AuditEntry { + subjectKind: AuditSubjectKind; + subjectId: string; + subjectName: string; + actor: RequestActor; + action: AuditAction; + from: string | null; + to: string | null; + at: number; +} + +/** A SQL condition with its binds. */ +export interface Guarded { + sql: string; + binds: unknown[]; +} + +/** + * Applies `update` and its audit row atomically under `guard`. Returns false + * (nothing written) when the guard no longer holds. + */ +export async function applyAudited(db: D1Database, entry: AuditEntry, guard: Guarded, update: Guarded): Promise { + const audit = db + .prepare( + `INSERT INTO package_audit (id, subject_kind, subject_id, subject_name, actor_kind, actor_id, action, from_value, to_value, created_at) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE ${guard.sql}`, + ) + .bind( + `pau_${crypto.randomUUID()}`, + entry.subjectKind, + entry.subjectId, + entry.subjectName, + entry.actor.kind, + entry.actor.id, + entry.action, + entry.from, + entry.to, + entry.at, + ...guard.binds, + ); + const results = await db.batch([audit, db.prepare(update.sql).bind(...update.binds)]); + return Number(results[1]?.meta.changes ?? 0) === 1; +} + +/** A package or kit row as the lifecycle sees it. */ +export interface LifecycleSubject { + kind: AuditSubjectKind; + table: "packages" | "personal_kits"; + id: string; + name: string; + deletedAt: number | null; + /** Extra `, column = ...` assignments written with every lifecycle change (a kit bumps its generation). */ + extraSet?: string; +} + +const lifecycleConflict = (s: LifecycleSubject) => + releaseError(409, `${s.kind}.state_conflict`, `the ${s.kind} changed while the request ran; reload it and retry`); + +/** Soft-deletes the subject; 409 `.deleted` when it already is. */ +export async function softDelete(db: D1Database, s: LifecycleSubject, actor: RequestActor, now: Date): Promise<{ deletedAt: number; restoreDeadline: number }> { + if (s.deletedAt !== null) throw releaseError(409, `${s.kind}.deleted`, `${s.name} is already deleted`); + const at = now.getTime(); + const ok = await applyAudited( + db, + { subjectKind: s.kind, subjectId: s.id, subjectName: s.name, actor, action: "delete", from: "live", to: "deleted", at }, + { sql: `EXISTS (SELECT 1 FROM ${s.table} WHERE id = ? AND deleted_at IS NULL)`, binds: [s.id] }, + { sql: `UPDATE ${s.table} SET deleted_at = ?, updated_at = ?${s.extraSet ?? ""} WHERE id = ? AND deleted_at IS NULL`, binds: [at, at, s.id] }, + ); + if (!ok) throw lifecycleConflict(s); + return { deletedAt: at, restoreDeadline: restoreDeadline(at) }; +} + +/** The actor kind of the subject's latest delete. Binds: subject kind, subject id. */ +const LATEST_DELETE_ACTOR = `(SELECT actor_kind FROM package_audit WHERE subject_kind = ? AND subject_id = ? AND action = 'delete' + ORDER BY created_at DESC, rowid DESC LIMIT 1)`; + +/** + * Restores a deleted subject within the window; 409 when not deleted, 410 + * after the window. An admin delete is containment: only the admin may undo it + * (`403 restore.admin_deleted`), re-checked inside the batch. + */ +export async function restoreDeleted(db: D1Database, s: LifecycleSubject, actor: RequestActor, now: Date): Promise<{ restoredAt: number }> { + if (s.deletedAt === null) throw releaseError(409, `${s.kind}.not_deleted`, `${s.name} is not deleted`); + const ownerRestore = actor.kind !== "admin"; + if (ownerRestore) { + const latest = await db.prepare(`SELECT ${LATEST_DELETE_ACTOR} AS actor_kind`).bind(s.kind, s.id).first<{ actor_kind: string | null }>(); + if (latest?.actor_kind === "admin") throw releaseError(403, "restore.admin_deleted", `${s.name} was deleted by an admin; only an admin can restore it`); + } + const at = now.getTime(); + if (at > restoreDeadline(s.deletedAt)) { + throw releaseError(410, "restore_window_expired", `${s.name} was deleted more than 30 days ago and can no longer be restored; the name stays reserved`); + } + const notAdminDeleted = ownerRestore ? { sql: ` AND COALESCE(${LATEST_DELETE_ACTOR}, '') <> 'admin'`, binds: [s.kind, s.id] } : { sql: "", binds: [] }; + const ok = await applyAudited( + db, + { subjectKind: s.kind, subjectId: s.id, subjectName: s.name, actor, action: "restore", from: "deleted", to: "live", at }, + { sql: `EXISTS (SELECT 1 FROM ${s.table} WHERE id = ? AND deleted_at = ?)${notAdminDeleted.sql}`, binds: [s.id, s.deletedAt, ...notAdminDeleted.binds] }, + { + sql: `UPDATE ${s.table} SET deleted_at = NULL, updated_at = ?${s.extraSet ?? ""} WHERE id = ? AND deleted_at = ?${notAdminDeleted.sql}`, + binds: [at, s.id, s.deletedAt, ...notAdminDeleted.binds], + }, + ); + if (!ok) throw lifecycleConflict(s); + return { restoredAt: at }; +} diff --git a/apps/web/app/lib/creator/package-management.ts b/apps/web/app/lib/creator/package-management.ts new file mode 100644 index 0000000..8ba64fb --- /dev/null +++ b/apps/web/app/lib/creator/package-management.ts @@ -0,0 +1,115 @@ +/** + * Owner management of a package: change its visibility, soft-delete it and + * restore it. Authorization mirrors the release state API: + * + * - An account (session or unscoped key) must own the package's publisher; + * anyone else gets the unknown-package `404 not_found`, so the endpoints + * cannot test whether a package exists. The owner of a suspended or revoked + * publisher gets `403 publisher_revoked`. + * - The admin (`X-Admin-Secret`) may delete and restore, never change + * visibility (`403 visibility.owner_only`). A package the admin deleted + * can only be restored by the admin (`403 restore.admin_deleted`). + * - A visibility change is a compare-and-set on `from` (`409 + * visibility_conflict`). Widening it, so anonymous callers can read more + * (private to unlisted or public, unlisted to public), needs an interactive + * web session and `"approve": true`, never an API key (`403 + * visibility.approval_required`): unlisted is anonymously readable by name. + * Narrowing (public to unlisted, anything to private) is allowed with a + * session or an unscoped key. A deleted package cannot change visibility + * (`404`). + */ +import type { PackageVisibility } from "@skillx/contracts"; +import { notFound } from "~/lib/releases/release-access"; +import { releaseError } from "~/lib/releases/release-http"; +import type { RequestActor } from "~/lib/releases/release-state-http"; +import { applyAudited, restoreDeleted, softDelete } from "./owner-audit"; +import type { LifecycleSubject } from "./owner-audit"; + +interface PackageSubject { + id: string; + name: string; + visibility: string; + deleted_at: number | null; + owner_user_id: string | null; + publisher_state: string; +} + +/** The package by name, deleted or not, with its publisher's owner and standing. */ +async function loadPackage(db: D1Database, identity: string): Promise { + return db + .prepare( + `SELECT p.id, p.name, p.visibility, p.deleted_at, pub.owner_user_id, pub.state AS publisher_state + FROM packages p JOIN publishers pub ON pub.id = p.publisher_id + WHERE p.name = ?`, + ) + .bind(identity) + .first(); +} + +/** The package the actor may manage: 404 for anyone but its owner (and the admin), 403 for the owner of an inactive publisher. */ +async function managedPackage(db: D1Database, identity: string, actor: RequestActor): Promise { + const pkg = await loadPackage(db, identity); + if (pkg === null) throw notFound(); + if (actor.kind === "admin") return pkg; + if (pkg.owner_user_id === null || pkg.owner_user_id === "" || pkg.owner_user_id !== actor.id) throw notFound(); + if (pkg.publisher_state !== "active") throw releaseError(403, "publisher_revoked", "the publisher of this package is suspended or revoked"); + return pkg; +} + +const lifecycleSubject = (pkg: PackageSubject): LifecycleSubject => ({ + kind: "package", + table: "packages", + id: pkg.id, + name: pkg.name, + deletedAt: pkg.deleted_at === null ? null : Number(pkg.deleted_at), +}); + +export interface VisibilityChange { + from: PackageVisibility; + to: PackageVisibility; + /** The explicit approval a widening change (out of private, or to public) needs. */ + approve: boolean; + /** A signed-in web session: no API key and no admin secret. */ + interactive: boolean; + actor: RequestActor; + now: Date; +} + +export async function changePackageVisibility(db: D1Database, identity: string, change: VisibilityChange) { + const { from, to, actor } = change; + if (actor.kind === "admin") throw releaseError(403, "visibility.owner_only", "only the package owner may change its visibility"); + const pkg = await managedPackage(db, identity, actor); + if (pkg.deleted_at !== null) throw notFound(); + if (from === to) throw releaseError(400, "invalid_request", "from and to must differ"); + const widening = from === "private" ? to !== "private" : to === "public"; + if (widening && !(change.interactive && change.approve)) { + throw releaseError( + 403, + "visibility.approval_required", + `making a ${from} package ${to} needs explicit approval in a signed-in web session ("approve": true), never an API key`, + ); + } + if (pkg.visibility !== from) throw releaseError(409, "visibility_conflict", `${identity} is no longer ${from}; reload it and retry`); + const at = change.now.getTime(); + const guard = "EXISTS (SELECT 1 FROM packages WHERE id = ? AND visibility = ? AND deleted_at IS NULL)"; + const ok = await applyAudited( + db, + { subjectKind: "package", subjectId: pkg.id, subjectName: pkg.name, actor, action: "visibility", from, to, at }, + { sql: guard, binds: [pkg.id, from] }, + { sql: "UPDATE packages SET visibility = ?, updated_at = ? WHERE id = ? AND visibility = ? AND deleted_at IS NULL", binds: [to, at, pkg.id, from] }, + ); + if (!ok) throw releaseError(409, "visibility_conflict", `${identity} is no longer ${from}; reload it and retry`); + return { identity, from, to, visibility: to, changedAt: change.now.toISOString() }; +} + +export async function deletePackage(db: D1Database, identity: string, actor: RequestActor, now: Date) { + const pkg = await managedPackage(db, identity, actor); + const { deletedAt, restoreDeadline } = await softDelete(db, lifecycleSubject(pkg), actor, now); + return { identity, deletedAt: new Date(deletedAt).toISOString(), restoreDeadline: new Date(restoreDeadline).toISOString() }; +} + +export async function restorePackage(db: D1Database, identity: string, actor: RequestActor, now: Date) { + const pkg = await managedPackage(db, identity, actor); + const { restoredAt } = await restoreDeleted(db, lifecycleSubject(pkg), actor, now); + return { identity, visibility: pkg.visibility, restoredAt: new Date(restoredAt).toISOString() }; +} diff --git a/apps/web/app/lib/db/personal-kits-schema.ts b/apps/web/app/lib/db/personal-kits-schema.ts index bead501..c04dca8 100644 --- a/apps/web/app/lib/db/personal-kits-schema.ts +++ b/apps/web/app/lib/db/personal-kits-schema.ts @@ -20,6 +20,8 @@ export const personalKits = sqliteTable( latest_revision_number: integer("latest_revision_number").notNull().default(0), created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + // Soft delete: invisible to the kit API, library and MCP; restorable for 30 days. + deleted_at: integer("deleted_at", { mode: "timestamp_ms" }), }, (table) => [uniqueIndex("idx_personal_kits_name").on(table.name), index("idx_personal_kits_owner").on(table.owner_user_id)] ); diff --git a/apps/web/app/lib/db/registry-schema.ts b/apps/web/app/lib/db/registry-schema.ts index c06a29a..dc201a1 100644 --- a/apps/web/app/lib/db/registry-schema.ts +++ b/apps/web/app/lib/db/registry-schema.ts @@ -25,9 +25,12 @@ export const packages = sqliteTable( name: text("name").notNull(), // fully-qualified '@handle/slug' slug: text("slug").notNull(), kind: text("kind").notNull(), // skill | hook-pack | bundle - visibility: text("visibility").notNull().default("public"), // public | private + visibility: text("visibility").notNull().default("public"), // public | unlisted | private created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + // Soft delete (owner or admin). Set: invisible to every read path; restorable for 30 days, + // then a permanent tombstone that keeps the name reserved. + deleted_at: integer("deleted_at", { mode: "timestamp_ms" }), }, (table) => [ uniqueIndex("idx_packages_name").on(table.name), @@ -94,6 +97,26 @@ export const releaseStateAudit = sqliteTable( (table) => [index("idx_release_state_audit_subject").on(table.subject_kind, table.subject_id, table.created_at)] ); +// Owner management of packages and personal kits: one row per applied visibility change, +// delete or restore. Append-only (migration 0022 triggers abort UPDATE and DELETE); no foreign +// keys, so the history outlives its subject. +export const packageAudit = sqliteTable( + "package_audit", + { + id: text("id").primaryKey(), + subject_kind: text("subject_kind").notNull(), // package | kit + subject_id: text("subject_id").notNull(), // packages.id or personal_kits.id + subject_name: text("subject_name").notNull(), // '@handle/name' at the time of the change + actor_kind: text("actor_kind").notNull(), // admin | user + actor_id: text("actor_id").notNull(), // 'admin' (shared ADMIN_SECRET) or the user id + action: text("action").notNull(), // visibility | delete | restore + from_value: text("from_value"), + to_value: text("to_value"), + created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), + }, + (table) => [index("idx_package_audit_subject").on(table.subject_id, table.created_at)] +); + // Release verification evidence — binds a verified badge to an exact release digest. export const releaseVerificationEvidence = sqliteTable( "release_verification_evidence", diff --git a/apps/web/app/lib/library/library-store.ts b/apps/web/app/lib/library/library-store.ts index 3e3614a..a006b0c 100644 --- a/apps/web/app/lib/library/library-store.ts +++ b/apps/web/app/lib/library/library-store.ts @@ -10,12 +10,14 @@ * * The latest release of a skill follows the read gate: published only, not in * a tree quarantined anywhere, and readable by the caller. Every release of a - * public package is readable; for a private package the owner reads all of + * public or unlisted package is readable; for a private package the owner reads all of * them and an entitled caller reads those the entitlement covers (a * release-scoped entitlement never shows other releases). Among the readable * releases the newest is chosen by the CLI install rule (highest semver, stable * before prerelease), so a library `installRef` is what a bare install resolves. * + * Deleted packages and kits (`deleted_at` set) are never listed. + * * Order is `(name, kind)`: stable while assets are added or removed, because a * cursor names a position in that order rather than an offset. */ @@ -71,7 +73,8 @@ const SKILLS = ` SELECT p2.id FROM publishers o JOIN packages p2 ON p2.publisher_id = o.id WHERE o.owner_user_id = me.uid AND o.owner_user_id <> '' UNION - SELECT e.package_id FROM entitlements e WHERE e.subject_id = me.uid AND e.revoked_at IS NULL)`; + SELECT e.package_id FROM entitlements e WHERE e.subject_id = me.uid AND e.revoked_at IS NULL) + AND p.deleted_at IS NULL`; const KITS = ` SELECT k.name AS name, 'kit' AS kind, 'owner' AS relation, k.visibility AS visibility, @@ -81,7 +84,7 @@ const KITS = ` FROM me, personal_kits k JOIN publishers pub ON pub.id = k.publisher_id LEFT JOIN personal_kit_revisions v ON v.id = k.effective_revision_id - WHERE k.owner_user_id = me.uid`; + WHERE k.owner_user_id = me.uid AND k.deleted_at IS NULL`; /** * The releases of the listed packages the caller can read and install. `+x.release_state` @@ -99,7 +102,7 @@ const READABLE_RELEASES = ` AND +x.release_state = 'published' AND x.release_digest IS NOT NULL AND x.signed_manifest_key IS NOT NULL AND NOT EXISTS (SELECT 1 FROM package_releases q WHERE q.release_digest = x.release_digest AND q.release_state = 'quarantined') - AND (p.visibility = 'public' OR ${OWNED} + AND (p.visibility IN ('public', 'unlisted') OR ${OWNED} OR EXISTS (SELECT 1 FROM entitlements e WHERE e.subject_id = me.uid AND e.package_id = p.id AND e.revoked_at IS NULL AND (e.release_id IS NULL OR e.release_id = x.id)))`; diff --git a/apps/web/app/lib/personal-kits/kit-revisions.ts b/apps/web/app/lib/personal-kits/kit-revisions.ts index 272930f..25cec8e 100644 --- a/apps/web/app/lib/personal-kits/kit-revisions.ts +++ b/apps/web/app/lib/personal-kits/kit-revisions.ts @@ -12,7 +12,7 @@ import { conflict, handleOfKit, json } from "./kit-http"; import type { KitCaller } from "./kit-http"; import { commitKitMutation, - findKit, + findKitRecord, getRevision, listRevisions, newId, @@ -91,8 +91,10 @@ export async function readRevision(caller: KitCaller, kitName: string, id: strin */ export async function storeRevision(caller: KitCaller, req: StoreRequest): Promise<{ revision: RevisionRow; generation: number; warnings: string[]; replayed: boolean }> { const db = caller.env.DB; - const existing = await findKit(db, req.kitName); + const existing = await findKitRecord(db, req.kitName); if (existing !== null && existing.owner_user_id !== caller.userId) throw releaseError(404, "not_found", "Not found"); + // The tombstone keeps the name: the owner restores the kit (within the window) instead of recreating it. + if (existing !== null && existing.deleted_at !== null) throw conflict("kit.deleted", `${req.kitName} is deleted; restore it instead of creating it again`); const doc = validateKitDocument(req.document); const digest = await revisionDigest(doc); if (existing !== null) { diff --git a/apps/web/app/lib/personal-kits/kit-router.ts b/apps/web/app/lib/personal-kits/kit-router.ts index bff9ac3..0e81116 100644 --- a/apps/web/app/lib/personal-kits/kit-router.ts +++ b/apps/web/app/lib/personal-kits/kit-router.ts @@ -13,6 +13,7 @@ import { previewUpdate, rebaseKit } from "./kit-rebase"; import { quarantineRevision, readProposalReview, reviewProposal } from "./kit-review"; import { readDiff, readKit, readRevision, readRevisions, storeRevision, storedResponse } from "./kit-revisions"; import { notFoundResponse } from "~/lib/releases/release-http"; +import { isKitAction, serveKitAction } from "~/lib/creator/owner-actions-http"; type Handler = (caller: KitCaller, kit: string, args: string[]) => Promise; @@ -110,6 +111,8 @@ const methodNotAllowed = (allow: string) => json({ error: { code: "method_not_al /** Serves one request of the kit API. */ export async function serveKitRequest(request: Request, env: KitEnv, params: Record, now?: () => Date): Promise { const sub = params["*"] ?? ""; + // Soft delete and restore take the owner management path (owner or admin, no scoped key, audited). + if (isKitAction(sub)) return serveKitAction(request, env, params, sub, now); const route = ROUTES.find((r) => r.pattern.test(sub)); const method = request.method === "HEAD" ? "GET" : request.method; const handler = method === "GET" ? route?.get : method === "POST" ? route?.post : undefined; diff --git a/apps/web/app/lib/personal-kits/kit-store.ts b/apps/web/app/lib/personal-kits/kit-store.ts index 5c62ba7..de4402e 100644 --- a/apps/web/app/lib/personal-kits/kit-store.ts +++ b/apps/web/app/lib/personal-kits/kit-store.ts @@ -21,6 +21,7 @@ export interface KitRow { latest_revision_number: number; created_at: number; updated_at: number; + deleted_at: number | null; } export interface RevisionRow { @@ -44,9 +45,9 @@ export interface RevisionRow { export const newId = (prefix: string) => `${prefix}_${crypto.randomUUID()}`; -export async function findKit(db: D1Database, name: string): Promise { - return db.prepare("SELECT * FROM personal_kits WHERE name = ?").bind(name).first(); -} +/** A kit by name: `findKitRecord` also finds a deleted one (delete, restore, create-name check); `findKit`, for every other read and write, does not. */ +export const findKitRecord = (db: D1Database, name: string) => db.prepare("SELECT * FROM personal_kits WHERE name = ?").bind(name).first(); +export const findKit = (db: D1Database, name: string) => db.prepare("SELECT * FROM personal_kits WHERE name = ? AND deleted_at IS NULL").bind(name).first(); /** The kit of its owner; anyone else (and an unknown kit) gets the same 404. */ export async function ownedKit(db: D1Database, name: string, userId: string): Promise { diff --git a/apps/web/app/lib/releases/ingest-release.ts b/apps/web/app/lib/releases/ingest-release.ts index 2df2fb7..dd84a27 100644 --- a/apps/web/app/lib/releases/ingest-release.ts +++ b/apps/web/app/lib/releases/ingest-release.ts @@ -3,7 +3,7 @@ * * Order (each step rejects before any later one runs): * name and reserved namespaces (for the requested provider) -> scoped key and publisher ownership -> - * signing key -> metadata -> package owner, kind and visibility -> capped upload -> tar read with limits and tree + * signing key -> metadata -> package owner, kind, deletion and visibility -> capped upload -> tar read with limits and tree * digest -> version conflict -> subrequest budget -> exec manifest -> scan -> signed manifest -> R2 artifact, blobs * and manifest (create-only) -> D1 row (last). Release bytes are stored * exactly as uploaded; nothing is sanitized. Kit releases (`@agentkit/*`, @@ -18,7 +18,7 @@ import { BLOB_WRITE_CONCURRENCY, MAX_RELEASE_UPLOAD_BYTES, assertSubrequestBudge import { RELEASE_METADATA_HEADER, parseReleaseMetadata, parseReleaseName, publishVisibility, readUploadBody, requestedProvider } from "./release-request"; import { scanRelease } from "./release-scan"; import { readTree, sha256Hex } from "./release-tree-read"; -import { findPackage, findRelease, insertRelease } from "./release-store"; +import { assertPackageLive, findPackage, findRelease, insertRelease } from "./release-store"; import type { ExistingRelease } from "./release-store"; import { artifactKey, blobKey, forEachLimited, manifestKey, putCreateOnly } from "./r2-create-only"; import { assertSameKitProvenance } from "./release-kit"; @@ -92,6 +92,7 @@ async function ingest(request: Request, params: Params, deps: IngestDeps, prepar if (pkg !== null && pkg.kind !== metadata.kind) { throw releaseError(409, "release.kind_conflict", `${name.identity} is a ${pkg.kind} package, not ${metadata.kind}`); } + assertPackageLive(name.identity, pkg); if (pkg !== null) assertVisibility(name.identity, metadata.visibility, pkg.visibility); const visibility = publishVisibility(metadata, pkg?.visibility ?? null); @@ -176,6 +177,7 @@ async function ingest(request: Request, params: Params, deps: IngestDeps, prepar if (outcome === "conflict") { // A package created concurrently with another visibility is refused like an existing one, also when ours was the default. const current = await findPackage(deps.db, name.identity); + assertPackageLive(name.identity, current); if (current !== null && current.publisherId === auth.publisherId && current.kind === metadata.kind) { assertVisibility(name.identity, visibility, current.visibility); } diff --git a/apps/web/app/lib/releases/release-access.ts b/apps/web/app/lib/releases/release-access.ts index fd286e3..c479fa5 100644 --- a/apps/web/app/lib/releases/release-access.ts +++ b/apps/web/app/lib/releases/release-access.ts @@ -5,6 +5,9 @@ * then publisher revoked (403), then yanked (410 by version, served with its * state by digest). * + * - Only `private` is protected: `public` and `unlisted` are readable by + * anyone. A deleted package (`packages.deleted_at` set) does not exist + * here, for every viewer including its owner. * - A private package is readable by the owner of its publisher * (`publishers.owner_user_id`, read at request time, so a change of owner * takes effect at once) and by a viewer holding an unrevoked entitlement @@ -21,7 +24,7 @@ * new resolution by version. * - Draft rows, and rows without a signed manifest, do not exist here. */ -import { PACKAGE_NAME_PATTERN, RELEASE_DIGEST_PATTERN, RELEASE_VERSION_PATTERN, decidePayloadAccess } from "@skillx/contracts"; +import { PACKAGE_NAME_PATTERN, RELEASE_DIGEST_PATTERN, RELEASE_VERSION_PATTERN, decidePayloadAccess, isOpenPackageVisibility } from "@skillx/contracts"; import { ERROR_CODES } from "@skillx/skill-tree"; import { releaseError } from "./release-http"; @@ -39,7 +42,7 @@ export interface ReadableRelease { artifactSize: number | null; manifestKey: string; createdAt: number; - /** `public` or `private`; anything else is treated as private. */ + /** `public`, `unlisted` or `private`; anything else is treated as private. */ visibility: string; } @@ -104,7 +107,7 @@ const RELEASE_SELECT = ` FROM package_releases r JOIN packages p ON p.id = r.package_id JOIN publishers pub ON pub.id = p.publisher_id - WHERE r.release_state IN ('published', 'yanked', 'quarantined') + WHERE r.release_state IN ('published', 'yanked', 'quarantined') AND p.deleted_at IS NULL AND r.release_digest IS NOT NULL AND r.signed_manifest_key IS NOT NULL`; interface ReleaseRowRaw { @@ -156,14 +159,16 @@ async function queryReleases(db: D1Database, where: string, userId: string | nul } /** - * The shared payload-access gate: public packages for everyone, private ones - * only for the owner of their publisher or with an entitlement. Ownership + * The shared payload-access gate: only `private` is protected. Public and + * unlisted packages are for everyone (unlisted is merely kept out of public + * discovery); private ones only for the owner of their publisher or with an + * entitlement. An unknown stored value is protected (fails closed). Ownership * counts only for a signed-in viewer, never for anonymous. */ function viewerMayRead(target: { identity: string; visibility: string; entitled: boolean; owned: boolean }, userId: string | null): boolean { const owner = target.owned && userId !== null && userId !== ""; return decidePayloadAccess( - { slug: target.identity, isProtected: target.visibility !== "public", state: "published" }, + { slug: target.identity, isProtected: !isOpenPackageVisibility(target.visibility), state: "published" }, { userId, hasEntitlement: target.entitled || owner }, () => true, ).granted; @@ -246,14 +251,14 @@ export const MAX_DIGEST_ROWS = 50; /** * Bounds the digest lookup deterministically, in exactly the order that - * decides the winner: rows the viewer may read (public, owned, or entitled + * decides the winner: rows the viewer may read (public or unlisted, owned, or entitled * to the package or the release, mirroring `viewerMayRead`; `entitled` and * `owned` are always 0 for an anonymous viewer), then rows of active * publishers, then published before yanked, then the earliest, then `id`. So * the bound only drops rows that could not have won, and the in-memory sort * below agrees with it. */ -const DIGEST_ORDER = `ORDER BY (p.visibility = 'public' OR entitled OR owned) DESC, pub.state = 'active' DESC, +const DIGEST_ORDER = `ORDER BY (p.visibility IN ('public', 'unlisted') OR entitled OR owned) DESC, pub.state = 'active' DESC, r.release_state = 'published' DESC, r.created_at, r.id`; const publishedFirst = (row: ReleaseRowAccess) => (row.state === "published" ? 0 : 1); @@ -294,7 +299,7 @@ export async function readablePackageNames(db: D1Database, names: readonly strin WHERE e.subject_id = ? AND e.package_id = p.id AND e.revoked_at IS NULL) AS entitled, ${OWNED} AS owned FROM packages p JOIN publishers pub ON pub.id = p.publisher_id - WHERE p.name IN (${chunk.map(() => "?").join(", ")})`, + WHERE p.name IN (${chunk.map(() => "?").join(", ")}) AND p.deleted_at IS NULL`, ) .bind(userId ?? "", userId ?? "", ...chunk), ); @@ -318,7 +323,7 @@ export async function listPackageReleases(db: D1Database, identity: string, user WHERE e.subject_id = ? AND e.package_id = p.id AND e.revoked_at IS NULL) AS entitled, ${OWNED} AS owned FROM packages p JOIN publishers pub ON pub.id = p.publisher_id - WHERE p.name = ?`, + WHERE p.name = ? AND p.deleted_at IS NULL`, ) .bind(userId ?? "", userId ?? "", identity) .first<{ id: string; visibility: string; publisher_state: string; entitled: number; owned: number }>(); diff --git a/apps/web/app/lib/releases/release-promote.ts b/apps/web/app/lib/releases/release-promote.ts index 9683dc3..1769210 100644 --- a/apps/web/app/lib/releases/release-promote.ts +++ b/apps/web/app/lib/releases/release-promote.ts @@ -24,7 +24,7 @@ * `RELEASE_WRITE_LIMITER` before the body is read. 404 while * `SKILLX_RELEASES_API` is off. */ -import { RELEASE_CHANNELS } from "@skillx/contracts"; +import { RELEASE_CHANNELS, isOpenPackageVisibility } from "@skillx/contracts"; import type { ReleaseChannel } from "@skillx/contracts"; import { hashScopedApiKey } from "~/lib/auth/api-key-utils"; import { notFound, parsePackageIdentity, parseReleaseVersion } from "./release-access"; @@ -44,7 +44,7 @@ const isChannel = (value: unknown): value is ReleaseChannel => typeof value === /** Re-checked inside the batch: the release is published and its publisher active. */ const PROMOTABLE_GUARD = `EXISTS (SELECT 1 FROM package_releases gr JOIN packages gp ON gp.id = gr.package_id - JOIN publishers gpub ON gpub.id = gp.publisher_id WHERE gr.id = ? AND gr.release_state = 'published' AND gpub.state = 'active')`; + JOIN publishers gpub ON gpub.id = gp.publisher_id WHERE gr.id = ? AND gr.release_state = 'published' AND gpub.state = 'active' AND gp.deleted_at IS NULL)`; interface Subject { id: string; @@ -95,10 +95,11 @@ export async function promoteRelease( .prepare( `SELECT r.id, r.channel, r.release_state, r.release_digest, p.visibility, pub.owner_user_id, pub.state AS publisher_state FROM package_releases r JOIN packages p ON p.id = r.package_id JOIN publishers pub ON pub.id = p.publisher_id - WHERE p.name = ? AND r.version = ?`, + WHERE p.name = ? AND r.version = ? AND p.deleted_at IS NULL`, ) .bind(identity, version) .first(); + // A deleted package's releases do not exist here, for every actor. if (subject === null) throw notFound(); const from = input.from ?? (isChannel(subject.channel) ? subject.channel : "stable"); const { to, actor } = input; @@ -110,7 +111,7 @@ export async function promoteRelease( if (actor.kind === "user" && subject.owner_user_id !== actor.id) { // A private package stays indistinguishable from an unknown one. - await refuse("denied", subject.visibility === "public" ? releaseError(403, "transition.not_owner", `only the owner of ${identity} or an admin may promote it`) : notFound()); + await refuse("denied", isOpenPackageVisibility(subject.visibility) ? releaseError(403, "transition.not_owner", `only the owner of ${identity} or an admin may promote it`) : notFound()); } if (from === to) await refuse("invalid", releaseError(422, "promote.same_channel", `the release is already in ${to}`)); if (actor.kind === "publisher_key" && RANK[to] < RANK[from]) { diff --git a/apps/web/app/lib/releases/release-read.ts b/apps/web/app/lib/releases/release-read.ts index c5dd121..f19605b 100644 --- a/apps/web/app/lib/releases/release-read.ts +++ b/apps/web/app/lib/releases/release-read.ts @@ -90,12 +90,13 @@ export async function serveReleaseRead( /** * Public answers are cacheable (`immutable` for bytes). Every answer about a - * private package is `private, no-store`, so revoking access, a quarantine or - * a key revocation takes effect on the next request instead of after a - * browser-held copy expires. + * private or unlisted package is `private, no-store`, so revoking access, a + * quarantine, a visibility change, a delete or a key revocation takes effect + * on the next request instead of after a shared or browser-held copy expires + * (and an unlisted package never sits in a shared cache). */ export function cacheControl(release: { visibility: string }, maxAge: number, immutable: boolean): string { - if (release.visibility !== "public") return "private, no-store"; + if (release.visibility !== "public") return "private, no-store"; // private, unlisted, or unknown (fails closed) return `public, max-age=${maxAge}${immutable ? ", immutable" : ""}, no-transform`; } diff --git a/apps/web/app/lib/releases/release-request.ts b/apps/web/app/lib/releases/release-request.ts index ca90e15..f8e6955 100644 --- a/apps/web/app/lib/releases/release-request.ts +++ b/apps/web/app/lib/releases/release-request.ts @@ -3,7 +3,7 @@ * the `X-SkillX-Release` metadata header and the capped tar body. Nothing * here touches storage. */ -import { PACKAGE_KINDS, PACKAGE_NAME_PATTERN, PACKAGE_VISIBILITIES, RELEASE_CHANNELS, RELEASE_VERSION_PATTERN } from "@skillx/contracts"; +import { PACKAGE_KINDS, PACKAGE_NAME_PATTERN, PACKAGE_VISIBILITIES, RELEASE_CHANNELS, RELEASE_VERSION_PATTERN, isPackageVisibility } from "@skillx/contracts"; import type { PackageKind, PackageVisibility, ReleaseChannel, ReleaseProvenance, ReleaseProvider, ReleaseSourceProvenance } from "@skillx/contracts"; import { ERROR_CODES, SkillTreeError, checkDirectoryAlias, checkPublisher, validateIdentity } from "@skillx/skill-tree"; import { releaseError } from "./release-http"; @@ -150,7 +150,7 @@ export function parseReleaseMetadata(header: string | null): ReleaseMetadata { * kits read foundation content as a public base. */ export function publishVisibility(metadata: ReleaseMetadata, stored: string | null): PackageVisibility { - if (stored !== null) return stored === "public" ? "public" : "private"; + if (stored !== null) return isPackageVisibility(stored) ? stored : "private"; if (metadata.visibility !== null) return metadata.visibility; return metadata.provenance?.provider === "kit" ? "public" : "private"; } diff --git a/apps/web/app/lib/releases/release-review-queue.ts b/apps/web/app/lib/releases/release-review-queue.ts index 403428c..4911729 100644 --- a/apps/web/app/lib/releases/release-review-queue.ts +++ b/apps/web/app/lib/releases/release-review-queue.ts @@ -24,7 +24,7 @@ export async function listQuarantine(db: D1Database) { JOIN packages p ON p.id = r.package_id LEFT JOIN release_reviews rv ON rv.id = ( SELECT id FROM release_reviews WHERE release_id = r.id ORDER BY created_at DESC, rowid DESC LIMIT 1) - WHERE r.release_state = 'quarantined' + WHERE r.release_state = 'quarantined' AND p.deleted_at IS NULL ORDER BY r.quarantined_at, r.id LIMIT ?`, ) .bind(MAX_QUEUE_ENTRIES) diff --git a/apps/web/app/lib/releases/release-review.ts b/apps/web/app/lib/releases/release-review.ts index 6fe5e2c..f7dd610 100644 --- a/apps/web/app/lib/releases/release-review.ts +++ b/apps/web/app/lib/releases/release-review.ts @@ -83,7 +83,7 @@ export async function reviewRelease( const subject = await db .prepare( `SELECT r.id, r.release_state, r.release_digest, r.signed_manifest_key, r.quarantined_at - FROM package_releases r JOIN packages p ON p.id = r.package_id WHERE p.name = ? AND r.version = ?`, + FROM package_releases r JOIN packages p ON p.id = r.package_id WHERE p.name = ? AND r.version = ? AND p.deleted_at IS NULL`, ) .bind(identity, version) .first(); diff --git a/apps/web/app/lib/releases/release-store.ts b/apps/web/app/lib/releases/release-store.ts index a41e0cb..6983ede 100644 --- a/apps/web/app/lib/releases/release-store.ts +++ b/apps/web/app/lib/releases/release-store.ts @@ -5,12 +5,15 @@ * makes exactly one of two concurrent uploads of a version win. */ import type { ExecSummary, PackageKind, PackageVisibility, ReleaseChannel, ReleaseSourceProvenance } from "@skillx/contracts"; +import { releaseError } from "./release-http"; export interface ExistingPackage { id: string; publisherId: string; kind: string; visibility: string; + /** Set when the package is soft-deleted: no new release may land in it. */ + deletedAt: number | null; } export interface ExistingRelease { @@ -47,10 +50,23 @@ export interface NewRelease { export async function findPackage(db: D1Database, identity: string): Promise { const row = await db - .prepare("SELECT id, publisher_id, kind, visibility FROM packages WHERE name = ?") + .prepare("SELECT id, publisher_id, kind, visibility, deleted_at FROM packages WHERE name = ?") .bind(identity) - .first<{ id: string; publisher_id: string; kind: string; visibility: string }>(); - return row === null ? null : { id: row.id, publisherId: row.publisher_id, kind: row.kind, visibility: row.visibility }; + .first<{ id: string; publisher_id: string; kind: string; visibility: string; deleted_at: number | null }>(); + if (row === null) return null; + const deletedAt = row.deleted_at === null ? null : Number(row.deleted_at); + return { id: row.id, publisherId: row.publisher_id, kind: row.kind, visibility: row.visibility, deletedAt }; +} + +/** + * A deleted package takes no new release, and its name is not reusable while + * the tombstone exists (always: there is no hard purge): `409 package.deleted` + * before anything is written. The owner restores it within the window instead. + */ +export function assertPackageLive(identity: string, pkg: ExistingPackage | null): void { + if (pkg !== null && pkg.deletedAt !== null) { + throw releaseError(409, "package.deleted", `${identity} is deleted; restore it within 30 days of deletion to publish again (the name is never reused)`); + } } export async function findRelease(db: D1Database, identity: string, version: string): Promise { @@ -77,8 +93,9 @@ const isUniqueViolation = (error: unknown) => error instanceof Error && /UNIQUE /** * Creates the package when needed and inserts the release, atomically. * Returns "conflict" when the version already exists (a concurrent upload - * won) or the package exists with another kind, publisher or visibility, so - * a release never lands in a package more visible than the caller decided. + * won) or the package exists with another kind, publisher or visibility, or + * is deleted, so a release never lands in a package more visible than the + * caller decided, nor in a deleted one. */ export async function insertRelease(db: D1Database, release: NewRelease): Promise<"inserted" | "conflict"> { const packageId = `pkg_${crypto.randomUUID()}`; @@ -100,7 +117,7 @@ export async function insertRelease(db: D1Database, release: NewRelease): Promis artifact_key, artifact_digest, artifact_size, release_digest, file_count, tree_size, signed_manifest_key, source_provenance, exec_summary_json, created_at, published_at, quarantined_at) SELECT ?, id, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? - FROM packages WHERE name = ? AND publisher_id = ? AND kind = ? AND visibility = ?`, + FROM packages WHERE name = ? AND publisher_id = ? AND kind = ? AND visibility = ? AND deleted_at IS NULL`, ) .bind( releaseId, diff --git a/apps/web/app/lib/releases/release-transitions.ts b/apps/web/app/lib/releases/release-transitions.ts index 3405d9b..0c05084 100644 --- a/apps/web/app/lib/releases/release-transitions.ts +++ b/apps/web/app/lib/releases/release-transitions.ts @@ -24,7 +24,7 @@ * every other edge, the containment composite and every publisher * transition are admin-only. */ -import { RELEASE_STATE_TRANSITIONS } from "@skillx/contracts"; +import { RELEASE_STATE_TRANSITIONS, isOpenPackageVisibility } from "@skillx/contracts"; import type { ReleaseState } from "@skillx/contracts"; import { notFound } from "./release-access"; import { releaseError } from "./release-http"; @@ -87,9 +87,9 @@ const RELEASE_ENTRY_TIMESTAMP: Partial> = { quarantined: ", quarantined_at = ?", }; -/** Re-checked inside the batch for owners, so a suspension landing after the pre-check still refuses the change. */ +/** Re-checked inside the batch for owners, so a suspension or package delete landing after the pre-check still refuses the change. */ const ACTIVE_PUBLISHER_GUARD = `EXISTS (SELECT 1 FROM package_releases gr JOIN packages gp ON gp.id = gr.package_id - JOIN publishers gpub ON gpub.id = gp.publisher_id WHERE gr.id = ? AND gpub.state = 'active')`; + JOIN publishers gpub ON gpub.id = gp.publisher_id WHERE gr.id = ? AND gpub.state = 'active' AND gp.deleted_at IS NULL)`; const publisherInactive = () => releaseError(403, "publisher_revoked", "the publisher of this package is suspended or revoked"); @@ -130,10 +130,11 @@ export async function transitionRelease( FROM package_releases r JOIN packages p ON p.id = r.package_id JOIN publishers pub ON pub.id = p.publisher_id - WHERE p.name = ? AND r.version = ?`, + WHERE p.name = ? AND r.version = ? AND p.deleted_at IS NULL`, ) .bind(identity, version) .first(); + // A deleted package's releases do not exist here, for the admin too. if (subject === null) throw notFound(); const attempt: Attempt = { kind: "release", subjectId: subject.id, input }; const { from, to, actor } = input; @@ -143,8 +144,8 @@ export async function transitionRelease( // Authorization first: ownership, then the publisher's standing, then the graph. if (actor.kind !== "admin") { if (subject.owner_user_id === null || subject.owner_user_id !== actor.id) { - // A private package stays indistinguishable from an unknown one. - await denied(db, attempt, subject.visibility === "public" ? releaseError(403, "transition.not_owner", `only the owner of ${identity} or an admin may change its state`) : notFound()); + // A private package stays indistinguishable from an unknown one; public and unlisted ones are readable anyway. + await denied(db, attempt, isOpenPackageVisibility(subject.visibility) ? releaseError(403, "transition.not_owner", `only the owner of ${identity} or an admin may change its state`) : notFound()); } if (subject.publisher_state !== "active") await denied(db, attempt, publisherInactive()); } diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts index 7005710..7c47852 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -8,6 +8,7 @@ export default [ route("llms-full.txt", "routes/llms-full-txt.ts"), route("profile", "routes/profile.tsx"), route("settings", "routes/settings.tsx"), + route("dashboard", "routes/dashboard.tsx"), route("docs", "routes/docs.tsx"), route("docs/agents", "routes/docs-agents.tsx"), route("docs/api", "routes/docs-api.tsx"), @@ -43,6 +44,10 @@ export default [ route("api/v1/packages/:scope/:slug/releases/:version/status", "routes/api.v1.release-status.ts"), route("api/v1/packages/:scope/:slug/releases/:version/promote", "routes/api.v1.release-promote.ts"), route("api/v1/packages/:scope/:slug/releases/:version/review", "routes/api.v1.release-review.ts"), + // Owner management: the last path segment names the action. + route("api/v1/packages/:scope/:slug/visibility", "routes/api.v1.package-manage.ts", { id: "package-visibility" }), + route("api/v1/packages/:scope/:slug/delete", "routes/api.v1.package-manage.ts", { id: "package-delete" }), + route("api/v1/packages/:scope/:slug/restore", "routes/api.v1.package-manage.ts", { id: "package-restore" }), route("api/v1/admin/quarantine", "routes/api.v1.admin-quarantine.ts"), route("api/v1/publishers", "routes/api.v1.publishers.ts"), route("api/v1/publishers/:handle/state", "routes/api.v1.release-state.ts", { id: "publisher-state" }), diff --git a/apps/web/app/routes/api.v1.package-manage.ts b/apps/web/app/routes/api.v1.package-manage.ts new file mode 100644 index 0000000..d70af7b --- /dev/null +++ b/apps/web/app/routes/api.v1.package-manage.ts @@ -0,0 +1,22 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { isPackageAction, servePackageAction } from "~/lib/creator/owner-actions-http"; +import { notFoundResponse } from "~/lib/releases/release-http"; +import { releasesApiEnabled } from "~/lib/releases/release-limits"; +import { methodNotAllowed } from "~/lib/releases/release-state-http"; + +/** + * Owner management of a package, mounted on three paths (the last segment + * names the action): `POST /api/v1/packages/@handle/slug/visibility`, + * `.../delete` and `.../restore` (see `lib/creator/owner-actions-http.ts`). + * Answers 404 to everything while `SKILLX_RELEASES_API` is off, 405 to other + * methods. + */ +export async function loader({ context }: LoaderFunctionArgs) { + return releasesApiEnabled(context.cloudflare.env) ? methodNotAllowed() : notFoundResponse(); +} + +export async function action({ request, params, context }: ActionFunctionArgs) { + const segment = new URL(request.url).pathname.replace(/\/+$/, "").split("/").pop() ?? ""; + if (!isPackageAction(segment)) return notFoundResponse(); + return servePackageAction(request, context.cloudflare.env, params, segment); +} diff --git a/apps/web/app/routes/dashboard.tsx b/apps/web/app/routes/dashboard.tsx new file mode 100644 index 0000000..879c02e --- /dev/null +++ b/apps/web/app/routes/dashboard.tsx @@ -0,0 +1,57 @@ +import { useLoaderData } from "react-router"; +import type { LoaderFunctionArgs, MetaFunction } from "react-router"; +import { PageContainer } from "../components/layout/page-container"; +import { KitsSection } from "../components/dashboard/kits-section"; +import { PackagesSection } from "../components/dashboard/packages-section"; +import type { SectionData } from "../components/dashboard/packages-section"; +import { PublishPanel } from "../components/dashboard/publish-panel"; +import { requireAuth } from "~/lib/auth/session-helpers"; +import { listOwnedActivePublishers } from "~/lib/auth/user-api-keys"; +import { getDb } from "~/lib/db"; +import { DASHBOARD_KIT_LIMIT, DASHBOARD_PACKAGE_LIMIT, listOwnedKits, listOwnedPackages } from "~/lib/creator/creator-dashboard"; +import type { DashboardKit, DashboardPackage } from "~/lib/creator/creator-dashboard"; +import { personalKitsApiEnabled } from "~/lib/personal-kits/kit-http"; +import { releasesApiEnabled } from "~/lib/releases/release-limits"; + +export const meta: MetaFunction = () => [{ title: "Creator dashboard | SkillX" }, { name: "robots", content: "noindex" }]; + +const RELEASES_OFF = "the releases API is not enabled on this deployment"; +const KITS_OFF = "personal kits are not enabled on this deployment"; + +/** + * Owner dashboard (session required). Packages need `SKILLX_RELEASES_API`; + * kits also need `SKILLX_PERSONAL_KITS_API` (their management endpoints sit + * behind both). A section whose flag is off says "unavailable", never empty. + */ +export async function loader({ request, context }: LoaderFunctionArgs) { + const env = context.cloudflare.env as Env; + const session = await requireAuth(request, env); + const userId = session.user.id; + const releasesOn = releasesApiEnabled(env); + const kitsOn = releasesOn && personalKitsApiEnabled(env as { SKILLX_PERSONAL_KITS_API?: string }); + const [packages, kits, publishers] = await Promise.all([ + releasesOn ? listOwnedPackages(env.DB, userId) : null, + kitsOn ? listOwnedKits(env.DB, userId) : null, + listOwnedActivePublishers(getDb(env.DB), userId), + ]); + const packageData: SectionData = + packages === null ? { status: "unavailable", reason: RELEASES_OFF } : { status: "available", ...packages, limit: DASHBOARD_PACKAGE_LIMIT }; + const kitData: SectionData = + kits === null ? { status: "unavailable", reason: releasesOn ? KITS_OFF : RELEASES_OFF } : { status: "available", ...kits, limit: DASHBOARD_KIT_LIMIT }; + return { packages: packageData, kits: kitData, handles: publishers.map((p) => p.handle), releasesOn }; +} + +export default function Dashboard() { + const data = useLoaderData(); + return ( + +
    +

    Creator dashboard

    +

    Manage the visibility, releases and lifecycle of your packages and personal kits. All releases are free.

    +
    + + + {data.releasesOn && } +
    + ); +} diff --git a/apps/web/drizzle/migrations/0022_creator_dashboard.sql b/apps/web/drizzle/migrations/0022_creator_dashboard.sql new file mode 100644 index 0000000..594da00 --- /dev/null +++ b/apps/web/drizzle/migrations/0022_creator_dashboard.sql @@ -0,0 +1,48 @@ +-- Creator dashboard: soft delete of packages and personal kits, and the owner management +-- audit. +-- +-- Strictly additive: one new table with its index and two triggers, and one nullable column on +-- `packages` and on `personal_kits`. Existing rows keep NULL `deleted_at` (not deleted), so +-- every read stays as before. The triggers make `package_audit` append-only (any UPDATE or +-- DELETE aborts). There are no foreign keys, so the history outlives its package or kit. +-- Code that predates this migration never reads the new column or table; the new code needs +-- them, so apply this migration before deploying it. +-- +-- Rollback un-deletes content: code before 0022 ignores `deleted_at`, so a deleted package or +-- kit becomes readable again. Take a D1 time-travel bookmark first. To keep deleted subjects +-- hidden, make them private first: +-- UPDATE packages SET visibility = 'private' WHERE deleted_at IS NOT NULL; +-- UPDATE personal_kits SET visibility = 'private' WHERE deleted_at IS NOT NULL; +-- Code before 0022 treats `unlisted` as private (fails closed). SQLite >= 3.35 drops columns +-- in place; the last statement removes the wrangler bookkeeping row so a later apply re-runs +-- 0022: +-- DROP TRIGGER IF EXISTS package_audit_no_delete; +-- DROP TRIGGER IF EXISTS package_audit_no_update; +-- DROP INDEX IF EXISTS idx_package_audit_subject; +-- DROP TABLE IF EXISTS package_audit; +-- ALTER TABLE `personal_kits` DROP COLUMN `deleted_at`; +-- ALTER TABLE `packages` DROP COLUMN `deleted_at`; +-- DELETE FROM d1_migrations WHERE name = '0022_creator_dashboard.sql'; + +CREATE TABLE `package_audit` ( + `id` text PRIMARY KEY NOT NULL, + `subject_kind` text NOT NULL, + `subject_id` text NOT NULL, + `subject_name` text NOT NULL, + `actor_kind` text NOT NULL, + `actor_id` text NOT NULL, + `action` text NOT NULL, + `from_value` text, + `to_value` text, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `idx_package_audit_subject` ON `package_audit` (`subject_id`,`created_at`);--> statement-breakpoint +ALTER TABLE `packages` ADD `deleted_at` integer;--> statement-breakpoint +ALTER TABLE `personal_kits` ADD `deleted_at` integer;--> statement-breakpoint +CREATE TRIGGER IF NOT EXISTS package_audit_no_update BEFORE UPDATE ON package_audit BEGIN + SELECT RAISE(ABORT, 'package_audit is append-only'); +END;--> statement-breakpoint +CREATE TRIGGER IF NOT EXISTS package_audit_no_delete BEFORE DELETE ON package_audit BEGIN + SELECT RAISE(ABORT, 'package_audit is append-only'); +END; diff --git a/apps/web/drizzle/migrations/meta/0022_snapshot.json b/apps/web/drizzle/migrations/meta/0022_snapshot.json new file mode 100644 index 0000000..703fdd3 --- /dev/null +++ b/apps/web/drizzle/migrations/meta/0022_snapshot.json @@ -0,0 +1,3256 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "dfce19ab-14ac-4c20-8834-ef64630bc4fe", + "prevId": "75ccd28d-2427-492a-8c5b-8c9f9f6755c4", + "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_audit": { + "name": "package_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 + }, + "subject_name": { + "name": "subject_name", + "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 + }, + "from_value": { + "name": "from_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "to_value": { + "name": "to_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_package_audit_subject": { + "name": "idx_package_audit_subject", + "columns": [ + "subject_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "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 + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "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 + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "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": {} + }, + "agentkit_links": { + "name": "agentkit_links", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agentkit_sub": { + "name": "agentkit_sub", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_enc": { + "name": "refresh_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_at": { + "name": "linked_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validated_at": { + "name": "validated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refreshing_until": { + "name": "refreshing_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_agentkit_links_subject": { + "name": "idx_agentkit_links_subject", + "columns": [ + "issuer", + "agentkit_sub" + ], + "isUnique": true + } + }, + "foreignKeys": { + "agentkit_links_user_id_user_id_fk": { + "name": "agentkit_links_user_id_user_id_fk", + "tableFrom": "agentkit_links", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 d910a9d..c539ea6 100644 --- a/apps/web/drizzle/migrations/meta/_journal.json +++ b/apps/web/drizzle/migrations/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1793294428991, "tag": "0021_agentkit_links", "breakpoints": true + }, + { + "idx": 22, + "version": "6", + "when": 1793297813420, + "tag": "0022_creator_dashboard", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/web/tests/creator/creator-dashboard.test.ts b/apps/web/tests/creator/creator-dashboard.test.ts new file mode 100644 index 0000000..cbc22a6 --- /dev/null +++ b/apps/web/tests/creator/creator-dashboard.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { DASHBOARD_REVISIONS_PER_KIT, listOwnedKits, listOwnedPackages } from "~/lib/creator/creator-dashboard"; +import { RESTORE_WINDOW_MS } from "~/lib/creator/owner-audit"; +import { BODY, createKitHarness, skillText } from "../personal-kits/kit-harness"; +import type { KitHarness } from "../personal-kits/kit-harness"; + +/** + * Dashboard data: the owner's packages with every release state and the + * owner's kits with their newest revisions, deleted ones included with their + * restore deadline, and nothing of anyone else. + * Fixtures: `user-a` owns `@alpha` and the suspended `@gone`, `user-b` owns `@beta`. + */ +let k: KitHarness; + +const tree = (name: string) => ({ "SKILL.md": skillText(BODY, `name: ${name}\ndescription: ${name}`) }); +const setState = (name: string, version: string, state: string) => + k.sql("UPDATE package_releases SET release_state = ? WHERE version = ? AND package_id = (SELECT id FROM packages WHERE name = ?)", state, version, name); + +beforeEach(async () => { + k = await createKitHarness(); + await k.publish("@alpha/demo", "1.0.0", tree("demo")); + await k.publish("@alpha/demo", "1.1.0", tree("demo")); + await k.publish("@alpha/old", "1.0.0", tree("old")); + await k.publish("@beta/hidden", "1.0.0", tree("hidden")); +}); + +describe("dashboard packages", () => { + it("lists the owner's packages with all releases, quarantined ones included", async () => { + setState("@alpha/demo", "1.1.0", "quarantined"); + const { items, truncated } = await listOwnedPackages(k.h.deps.db, "user-a"); + expect(truncated).toBe(false); + expect(items.map((p) => p.identity)).toEqual(["@alpha/demo", "@alpha/old"]); + const demo = items[0]; + expect(demo).toMatchObject({ kind: "skill", visibility: "public", publisherHandle: "alpha", publisherState: "active", deletedAt: null, restoreDeadline: null }); + expect(demo.releases.map((r) => [r.version, r.state]).sort()).toEqual([ + ["1.0.0", "published"], + ["1.1.0", "quarantined"], + ]); + expect(demo.releasesTruncated).toBe(false); + }); + + it("shows nothing of another account's packages", async () => { + setState("@beta/hidden", "1.0.0", "quarantined"); + expect((await listOwnedPackages(k.h.deps.db, "user-b")).items.map((p) => p.identity)).toEqual(["@beta/hidden"]); + expect((await listOwnedPackages(k.h.deps.db, "user-c")).items).toEqual([]); + expect((await listOwnedPackages(k.h.deps.db, "")).items).toEqual([]); + }); + + it("includes a deleted package with its restore deadline", async () => { + k.sql("UPDATE packages SET deleted_at = 1000 WHERE name = '@alpha/old'"); + const old = (await listOwnedPackages(k.h.deps.db, "user-a")).items.find((p) => p.identity === "@alpha/old"); + expect(old).toMatchObject({ deletedAt: 1000, restoreDeadline: 1000 + RESTORE_WINDOW_MS }); + expect(old?.releases).toHaveLength(1); + }); +}); + +describe("dashboard kits", () => { + async function revise(times: number) { + for (let generation = 0; generation < times; generation++) { + const document = k.doc([await k.entry("@alpha/demo", "1.0.0", { "SKILL.md": skillText(`${BODY}revision ${generation}\n`, "name: demo\ndescription: demo") })]); + const res = await k.call("revisions", { expectedGeneration: generation, idempotencyKey: k.idem(), document }); + expect(res.status, JSON.stringify(res.body)).toBe(201); + } + } + + it("lists the owner's kits with their newest revisions first", async () => { + await revise(2); + const { items } = await listOwnedKits(k.h.deps.db, "user-a"); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ identity: "@alpha/my-kit", visibility: "private", latestRevisionNumber: 2, effectiveRevision: null, deletedAt: null }); + expect(items[0].revisions.map((r) => r.number)).toEqual([2, 1]); + expect((await listOwnedKits(k.h.deps.db, "user-b")).items).toEqual([]); + }); + + it("caps the revisions per kit and includes a deleted kit with its deadline", async () => { + await revise(DASHBOARD_REVISIONS_PER_KIT + 2); + k.sql("UPDATE personal_kits SET deleted_at = 5000"); + const [kit] = (await listOwnedKits(k.h.deps.db, "user-a")).items; + expect(kit.revisions).toHaveLength(DASHBOARD_REVISIONS_PER_KIT); + expect(kit.revisions[0].number).toBe(DASHBOARD_REVISIONS_PER_KIT + 2); + expect(kit).toMatchObject({ deletedAt: 5000, restoreDeadline: 5000 + RESTORE_WINDOW_MS }); + }); +}); diff --git a/apps/web/tests/creator/creator-helpers.ts b/apps/web/tests/creator/creator-helpers.ts new file mode 100644 index 0000000..e738678 --- /dev/null +++ b/apps/web/tests/creator/creator-helpers.ts @@ -0,0 +1,71 @@ +/** + * Shared helpers of the owner management tests, over the release state + * harness (`useStateHarness` in `beforeEach`). Each test file keeps its own + * session mock because `vi.mock` is per file. + */ +import { servePackageAction } from "~/lib/creator/owner-actions-http"; +import type { PackageAction } from "~/lib/creator/owner-actions-http"; +import { serveDownloadRedeem } from "~/lib/releases/release-download-url"; +import { loader as listLoader } from "~/routes/api.v1.package-releases"; +import { loader as manifestLoader } from "~/routes/api.v1.release-manifest"; +import { loader as artifactLoader } from "~/routes/api.v1.release-artifact"; +import { action as mintAction } from "~/routes/api.v1.release-download-url"; +import { TEST_KEY_ID } from "../releases/release-harness"; +import { envOf, sql } from "../releases/release-state-helpers"; +import type { ChangeOptions, Harness } from "../releases/release-state-helpers"; + +export const ORIGIN = "https://skillx.test"; +const readEnv = () => envOf({ SKILLX_REGISTRY_SIGNING_KEY_ID: TEST_KEY_ID, SKILLX_TOKEN_SECRET: "test-token-secret-0123456789abcdef" }); + +export interface ManageOptions extends ChangeOptions { + now?: Date; +} + +/** One owner management request against a package (`POST .../visibility|delete|restore`). */ +export async function manage(name: string, action: PackageAction, body: unknown = {}, o: ManageOptions = {}) { + const [scope, slug] = name.split("/") as [string, string]; + const headers = new Headers({ "Content-Type": "application/json", ...o.headers }); + if (o.key !== undefined) headers.set("Authorization", `Bearer ${o.key}`); + if (o.admin !== undefined) headers.set("X-Admin-Secret", o.admin); + const request = new Request(`${ORIGIN}/api/v1/packages/${name}/${action}`, { method: o.method ?? "POST", headers, body: o.rawBody ?? JSON.stringify(body) }); + const now = o.now; + const res = await servePackageAction(request, envOf(o.env) as never, { scope, slug }, action, now === undefined ? undefined : () => now); + return { status: res.status, headers: res.headers, body: (await res.json()) as Record }; +} + +const readRequest = (path: string, key?: string, init: RequestInit = {}) => { + const headers = new Headers(init.headers); + if (key !== undefined) headers.set("Authorization", `Bearer ${key}`); + return new Request(`${ORIGIN}${path}`, { ...init, headers }); +}; + +const read = (loader: (args: never) => Promise, path: string, params: Record, key?: string) => + loader({ request: readRequest(path, key), params, context: { cloudflare: { env: readEnv() } } } as never); + +/** Statuses of the list, version manifest, digest manifest and artifact reads of `name@1.0.0`. */ +export async function readStatuses(name: string, digest: string, key?: string): Promise { + const [scope, slug] = name.split("/") as [string, string]; + const responses = await Promise.all([ + read(listLoader, `/api/v1/packages/${name}/releases`, { scope, slug }, key), + read(manifestLoader, `/api/v1/packages/${name}/releases/1.0.0`, { scope, slug, version: "1.0.0" }, key), + read(manifestLoader, `/api/v1/releases/${digest}/manifest`, { digest }, key), + read(artifactLoader, `/api/v1/releases/${digest}/artifact`, { digest }, key), + ]); + return responses.map((r) => r.status); +} + +/** Mints a signed artifact URL for `digest` with `key`; returns its redeem function. */ +export async function mintDownload(digest: string, key: string) { + const minted = await mintAction({ request: readRequest(`/api/v1/releases/${digest}/artifact/url`, key, { method: "POST" }), params: { digest }, context: { cloudflare: { env: readEnv() } } } as never); + if (minted.status !== 200) throw new Error(`mint failed with ${minted.status}`); + const { url } = (await minted.json()) as { url: string }; + return async () => (await serveDownloadRedeem(new Request(url), readEnv() as never, { token: url.slice(url.lastIndexOf("/") + 1) })).status; +} + +export const packageAudit = (h: Harness) => + h.d1.raw.prepare("SELECT subject_kind, subject_name, actor_kind, actor_id, action, from_value, to_value FROM package_audit ORDER BY created_at, rowid").all() as Array>; + +export const visibilityOf = (h: Harness, name: string) => + (h.d1.raw.prepare("SELECT visibility, deleted_at FROM packages WHERE name = ?").get(name) as { visibility: string; deleted_at: number | null }) ?? null; + +export const setVisibility = (name: string, visibility: string) => sql("UPDATE packages SET visibility = ? WHERE name = ?", visibility, name); diff --git a/apps/web/tests/creator/kit-deletion.test.ts b/apps/web/tests/creator/kit-deletion.test.ts new file mode 100644 index 0000000..bdd1998 --- /dev/null +++ b/apps/web/tests/creator/kit-deletion.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { kitsWriteScope } from "~/lib/auth/api-key-scopes"; +import { serveKitAction } from "~/lib/creator/owner-actions-http"; +import { callTool } from "~/lib/mcp/tools"; +import { loader as libraryLoader } from "~/routes/api.v1.library"; +import { FakeRateLimiter } from "../releases/fake-rate-limiter"; +import { BODY, NOW, createKitHarness, skillText } from "../personal-kits/kit-harness"; +import type { KitHarness } from "../personal-kits/kit-harness"; + +/** + * Kit soft delete: a deleted kit is invisible to the kit API, the library and + * MCP `get_kit`, its name cannot be reused, and the owner (or the admin) can + * restore it for 30 days. + */ +const session = vi.hoisted(() => ({ user: null as { id: string } | null })); +vi.mock("~/lib/auth/session-helpers", () => ({ + getSession: async () => (session.user === null ? null : { user: session.user }), +})); + +const ADMIN = "test-admin-secret-0123456789"; +const DAY = 24 * 60 * 60 * 1000; +let k: KitHarness; + +const env = () => ({ + SKILLX_RELEASES_API: "1", + SKILLX_PERSONAL_KITS_API: "1", + DB: k.h.deps.db, + R2: k.h.deps.bucket, + ADMIN_SECRET: ADMIN, + RELEASE_READ_LIMITER: new FakeRateLimiter(10_000), + RELEASE_WRITE_LIMITER: new FakeRateLimiter(10_000), +}); + +beforeEach(async () => { + k = await createKitHarness(); + session.user = null; + await k.publish("@alpha/demo", "1.0.0", { "SKILL.md": skillText(BODY) }); + const document = k.doc([await k.entry("@alpha/demo", "1.0.0")]); + expect((await k.call("revisions", { expectedGeneration: 0, idempotencyKey: k.idem(), document })).status).toBe(201); +}); + +const kitAudit = () => k.h.d1.raw.prepare("SELECT subject_kind, subject_name, actor_kind, action FROM package_audit ORDER BY created_at, rowid").all(); + +async function libraryKits(key: string): Promise { + const request = new Request("https://skillx.test/api/v1/library", { headers: { Authorization: `Bearer ${key}` } }); + const res = await libraryLoader({ request, params: {}, context: { cloudflare: { env: env() } } } as never); + const body = (await res.json()) as { items: Array<{ kind: string; identity: string }> }; + return body.items.filter((i) => i.kind === "kit").map((i) => i.identity); +} + +const getKit = async (userId: string | null) => { + const result = await callTool("get_kit", { kit: "@alpha/my-kit" }, { env: env() as unknown as Env, userId }); + return JSON.parse(result.content[0].text) as { found: boolean }; +}; + +async function kitAction(action: "delete" | "restore", now: Date) { + const request = new Request(`https://skillx.test/api/v1/kits/@alpha/my-kit/${action}`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${k.ownerKey}` }, + body: "{}", + }); + const res = await serveKitAction(request, env() as never, { scope: "@alpha", name: "my-kit" }, action, () => now); + return { status: res.status, body: (await res.json()) as Record }; +} + +describe("kit deletion", () => { + it("hides the kit from the kit API, the library and MCP get_kit", async () => { + expect(await libraryKits(k.ownerKey)).toEqual(["@alpha/my-kit"]); + expect((await getKit("user-a")).found).toBe(true); + const res = await k.call("delete", {}); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe("no-store"); + expect(res.body).toMatchObject({ kit: "@alpha/my-kit", deletedAt: expect.any(String), restoreDeadline: expect.any(String) }); + expect((await k.call("")).status).toBe(404); + expect((await k.call("revisions")).status).toBe(404); + expect(await libraryKits(k.ownerKey)).toEqual([]); + expect((await getKit("user-a")).found).toBe(false); + expect(kitAudit()).toEqual([{ subject_kind: "kit", subject_name: "@alpha/my-kit", actor_kind: "user", action: "delete" }]); + }); + + it("answers 409 kit.deleted to a new revision under the deleted name", async () => { + await k.call("delete", {}); + const document = k.doc([await k.entry("@alpha/demo", "1.0.0")]); + for (const expectedGeneration of [0, 1, 2]) { + const res = await k.call("revisions", { expectedGeneration, idempotencyKey: k.idem(), document }); + expect(res.status).toBe(409); + expect(res.body.error.code).toBe("kit.deleted"); + } + expect(k.count("personal_kit_revisions")).toBe(1); + }); + + it("refuses scoped keys and other accounts, and lets the admin delete and restore", async () => { + const scoped = await k.h.createKey("user-a", [kitsWriteScope("alpha")]); + expect((await k.call("delete", {}, { key: scoped })).status).toBe(401); + expect((await k.call("delete", {}, { key: k.otherKey })).status).toBe(404); + expect((await k.call("delete", {}, { key: null, headers: { "X-Admin-Secret": ADMIN } })).status).toBe(200); + expect((await k.call("restore", {}, { key: k.otherKey })).status).toBe(404); + expect((await k.call("restore", {}, { key: null, headers: { "X-Admin-Secret": ADMIN } })).status).toBe(200); + expect(kitAudit().map((a: any) => [a.actor_kind, a.action])).toEqual([ + ["admin", "delete"], + ["admin", "restore"], + ]); + }); + + it("refuses a cross-origin web session and accepts a same-origin one", async () => { + session.user = { id: "user-a" }; + const res = await k.call("delete", {}, { key: null, headers: { Origin: "https://evil.example" } }); + expect(res).toMatchObject({ status: 403, body: { error: { code: "cross_origin" } } }); + expect(kitAudit()).toEqual([]); + expect((await k.call("delete", {}, { key: null, headers: { Origin: "https://skillx.test" } })).status).toBe(200); + }); + + it("is unavailable while either flag is off", async () => { + expect((await k.call("delete", {}, { env: { SKILLX_PERSONAL_KITS_API: undefined } })).status).toBe(404); + expect((await k.call("delete", {}, { env: { SKILLX_RELEASES_API: undefined } })).status).toBe(404); + expect(kitAudit()).toEqual([]); + }); +}); + +describe("kit restore", () => { + it("restores within 30 days, bumps the generation and serves the kit again", async () => { + const before = (await k.call("")).body; + expect((await kitAction("delete", NOW)).status).toBe(200); + const res = await kitAction("restore", new Date(NOW.getTime() + 30 * DAY)); + expect(res).toMatchObject({ status: 200, body: { kit: "@alpha/my-kit", generation: Number(before.generation) + 2 } }); + expect((await k.call("")).status).toBe(200); + expect(await libraryKits(k.ownerKey)).toEqual(["@alpha/my-kit"]); + }); + + it("answers 410 after the window and keeps the tombstone", async () => { + await kitAction("delete", NOW); + const res = await kitAction("restore", new Date(NOW.getTime() + 30 * DAY + 1)); + expect(res).toMatchObject({ status: 410, body: { error: { code: "restore_window_expired" } } }); + expect((await k.call("")).status).toBe(404); + }); + + it("refuses the owner restoring an admin delete with 403 restore.admin_deleted", async () => { + const admin = { key: null, headers: { "X-Admin-Secret": ADMIN } }; + expect((await k.call("delete", {}, admin)).status).toBe(200); + expect(await kitAction("restore", NOW)).toMatchObject({ status: 403, body: { error: { code: "restore.admin_deleted" } } }); + expect((await k.call("")).status).toBe(404); + expect(kitAudit().map((a: any) => [a.actor_kind, a.action])).toEqual([["admin", "delete"]]); + expect((await k.call("restore", {}, admin)).status).toBe(200); + expect((await k.call("")).status).toBe(200); + }); + + it("answers 409 kit.not_deleted to a live kit and kit.deleted to a second delete", async () => { + expect((await k.call("restore", {})).body.error.code).toBe("kit.not_deleted"); + await k.call("delete", {}); + expect((await k.call("delete", {})).body.error.code).toBe("kit.deleted"); + }); +}); diff --git a/apps/web/tests/creator/owner-action-client.test.ts b/apps/web/tests/creator/owner-action-client.test.ts new file mode 100644 index 0000000..ba4197f --- /dev/null +++ b/apps/web/tests/creator/owner-action-client.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { kitActionUrl, packageActionUrl, postOwnerAction, releaseStateUrl } from "~/components/dashboard/use-owner-action"; + +/** The dashboard's request helper reports success only for a 2xx JSON answer. */ +afterEach(() => vi.unstubAllGlobals()); + +const respond = (response: Response | Error) => { + const fetchMock = vi.fn(async () => { + if (response instanceof Error) throw response; + return response; + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +}; + +describe("postOwnerAction", () => { + it("returns the data of a 2xx JSON answer and sends a same-origin JSON POST", async () => { + const fetchMock = respond(Response.json({ visibility: "private" })); + expect(await postOwnerAction("/api/v1/packages/@alpha/demo/visibility", { from: "public", to: "private" })).toEqual({ ok: true, data: { visibility: "private" } }); + expect(fetchMock).toHaveBeenCalledWith("/api/v1/packages/@alpha/demo/visibility", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: '{"from":"public","to":"private"}', + }); + }); + + it("reports an API error with its message and code", async () => { + respond(Response.json({ error: { code: "visibility_conflict", message: "@alpha/demo is no longer public" } }, { status: 409 })); + expect(await postOwnerAction("/x", {})).toEqual({ ok: false, message: "@alpha/demo is no longer public (visibility_conflict)" }); + }); + + it("never reports success for a non-JSON answer, a non-2xx answer or a network failure", async () => { + respond(new Response("", { status: 200 })); + expect((await postOwnerAction("/x", {})).ok).toBe(false); + respond(new Response("bad gateway", { status: 502 })); + expect(await postOwnerAction("/x", {})).toEqual({ ok: false, message: "HTTP 502" }); + respond(new TypeError("offline")); + expect(await postOwnerAction("/x", {})).toEqual({ ok: false, message: "Network error: the change was not applied." }); + }); +}); + +describe("dashboard action URLs", () => { + it("builds the management, state and kit URLs", () => { + expect(packageActionUrl("@alpha/demo", "delete")).toBe("/api/v1/packages/@alpha/demo/delete"); + expect(releaseStateUrl("@alpha/demo", "1.0.0-beta.1")).toBe("/api/v1/packages/@alpha/demo/releases/1.0.0-beta.1/state"); + expect(kitActionUrl("@alpha/my-kit", "restore")).toBe("/api/v1/kits/@alpha/my-kit/restore"); + }); +}); diff --git a/apps/web/tests/creator/package-deletion.test.ts b/apps/web/tests/creator/package-deletion.test.ts new file mode 100644 index 0000000..fb4c888 --- /dev/null +++ b/apps/web/tests/creator/package-deletion.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { readablePackageNames } from "~/lib/releases/release-access"; +import { listQuarantine } from "~/lib/releases/release-review-queue"; +import { action as promoteAction } from "~/routes/api.v1.release-promote"; +import { action as reviewAction } from "~/routes/api.v1.release-review"; +import { NOW, tarOf } from "../releases/release-harness"; +import { ADMIN, change, envOf, expectError, publish, skillFiles, useStateHarness } from "../releases/release-state-helpers"; +import type { Harness } from "../releases/release-state-helpers"; +import { manage, mintDownload, packageAudit, readStatuses, setVisibility, visibilityOf } from "./creator-helpers"; + +/** + * Soft delete: a deleted package disappears from every read path for every + * viewer, refuses new versions and transitions, and can be restored for 30 + * days; afterwards its name stays reserved. + */ +const session = vi.hoisted(() => ({ user: null as { id: string } | null })); +vi.mock("~/lib/auth/session-helpers", () => ({ + getSession: async () => (session.user === null ? null : { user: session.user }), +})); + +const DAY = 24 * 60 * 60 * 1000; +let h: Harness; +let ownerKey: string; +let digest: string; + +beforeEach(async () => { + h = await useStateHarness(); + session.user = null; + ownerKey = await h.createKey("user-a", null); + digest = await publish(); +}); + +const at = (days: number) => new Date(NOW.getTime() + days * DAY); + +describe("package deletion", () => { + it("hides the package from every read, for anonymous callers and the owner", async () => { + expect(await readStatuses("@alpha/demo", digest)).toEqual([200, 200, 200, 200]); + const res = await manage("@alpha/demo", "delete", {}, { key: ownerKey, now: NOW }); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe("no-store"); + expect(res.body).toEqual({ identity: "@alpha/demo", deletedAt: NOW.toISOString(), restoreDeadline: at(30).toISOString() }); + expect(await readStatuses("@alpha/demo", digest)).toEqual([404, 404, 404, 404]); + expect(await readStatuses("@alpha/demo", digest, ownerKey)).toEqual([404, 404, 404, 404]); + expect(await readablePackageNames(h.deps.db, ["@alpha/demo"], "user-a")).toEqual(new Set()); + expect(await readablePackageNames(h.deps.db, ["@alpha/demo"], null)).toEqual(new Set()); + expect(packageAudit(h)).toEqual([expect.objectContaining({ actor_id: "user-a", action: "delete", from_value: "live", to_value: "deleted" })]); + }); + + it("invalidates a signed download URL minted before the delete", async () => { + setVisibility("@alpha/demo", "private"); + const redeem = await mintDownload(digest, ownerKey); + expect(await redeem()).toBe(200); + expect((await manage("@alpha/demo", "delete", {}, { key: ownerKey })).status).toBe(200); + expect(await redeem()).toBe(404); + }); + + it("refuses a new version with 409 package.deleted and writes nothing", async () => { + await manage("@alpha/demo", "delete", {}, { key: ownerKey }); + const before = h.releaseRows(); + const objects = h.r2.objects.size; + const res = await h.upload("@alpha/demo", tarOf(skillFiles()), { version: "2.0.0" }); + expect(res.status).toBe(409); + expect(res.body.error.code).toBe("package.deleted"); + expect(h.releaseRows()).toEqual(before); + expect(h.r2.objects.size).toBe(objects); + }); + + it("answers 404 to state transitions and promotions of its releases", async () => { + await manage("@alpha/demo", "delete", {}, { key: ownerKey }); + expectError(await change("@alpha/demo", "1.0.0", { from: "published", to: "yanked" }, { key: ownerKey }), 404, "not_found"); + expectError(await change("@alpha/demo", "1.0.0", { from: "published", to: "yanked" }, { admin: ADMIN }), 404, "not_found"); + const request = new Request("https://skillx.test/api/v1/packages/@alpha/demo/releases/1.0.0/promote", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${ownerKey}` }, + body: JSON.stringify({ to: "beta" }), + }); + const res = await promoteAction({ request, params: { scope: "@alpha", slug: "demo", version: "1.0.0" }, context: { cloudflare: { env: envOf() } } } as never); + expect(res.status).toBe(404); + }); + + it("drops its quarantined releases from the review queue and answers 404 to a review", async () => { + h.d1.raw.prepare("UPDATE package_releases SET release_state = 'quarantined', quarantined_at = 1").run(); + expect(await listQuarantine(h.deps.db)).toHaveLength(1); + await manage("@alpha/demo", "delete", {}, { key: ownerKey }); + expect(await listQuarantine(h.deps.db)).toEqual([]); + const request = new Request("https://skillx.test/api/v1/packages/@alpha/demo/releases/1.0.0/review", { + method: "POST", + headers: { "Content-Type": "application/json", "X-Admin-Secret": ADMIN }, + body: JSON.stringify({ decision: "approve", reason: "false positive" }), + }); + const res = await reviewAction({ request, params: { scope: "@alpha", slug: "demo", version: "1.0.0" }, context: { cloudflare: { env: envOf() } } } as never); + expect(res.status).toBe(404); + }); + + it("answers 409 package.deleted to a second delete and refuses visibility changes", async () => { + await manage("@alpha/demo", "delete", {}, { key: ownerKey }); + expectError(await manage("@alpha/demo", "delete", {}, { key: ownerKey }), 409, "package.deleted"); + expectError(await manage("@alpha/demo", "visibility", { from: "public", to: "private" }, { key: ownerKey }), 404, "not_found"); + }); + + it("requires an empty JSON body", async () => { + expect((await manage("@alpha/demo", "delete", { force: true }, { key: ownerKey })).status).toBe(400); + expect((await manage("@alpha/demo", "delete", undefined, { key: ownerKey, rawBody: "" })).status).toBe(400); + expect(visibilityOf(h, "@alpha/demo").deleted_at).toBeNull(); + }); + + it("answers 404 to another account and lets the admin delete and restore", async () => { + const otherKey = await h.createKey("user-b", null); + expectError(await manage("@alpha/demo", "delete", {}, { key: otherKey }), 404, "not_found"); + expect((await manage("@alpha/demo", "delete", {}, { admin: ADMIN })).status).toBe(200); + expectError(await manage("@alpha/demo", "restore", {}, { key: otherKey }), 404, "not_found"); + expect((await manage("@alpha/demo", "restore", {}, { admin: ADMIN })).status).toBe(200); + expect(packageAudit(h).map((a) => [a.actor_kind, a.action])).toEqual([ + ["admin", "delete"], + ["admin", "restore"], + ]); + }); +}); + +describe("package restore", () => { + it("restores within 30 days with the same visibility", async () => { + setVisibility("@alpha/demo", "unlisted"); + await manage("@alpha/demo", "delete", {}, { key: ownerKey, now: NOW }); + const res = await manage("@alpha/demo", "restore", {}, { key: ownerKey, now: at(30) }); + expect(res).toMatchObject({ status: 200, body: { identity: "@alpha/demo", visibility: "unlisted", restoredAt: at(30).toISOString() } }); + expect(await readStatuses("@alpha/demo", digest)).toEqual([200, 200, 200, 200]); + expect(packageAudit(h).map((a) => a.action)).toEqual(["delete", "restore"]); + }); + + it("answers 410 after the window and keeps the tombstone", async () => { + await manage("@alpha/demo", "delete", {}, { key: ownerKey, now: NOW }); + expectError(await manage("@alpha/demo", "restore", {}, { key: ownerKey, now: new Date(at(30).getTime() + 1) }), 410, "restore_window_expired"); + expect(visibilityOf(h, "@alpha/demo").deleted_at).toBe(NOW.getTime()); + const res = await h.upload("@alpha/demo", tarOf(skillFiles()), { version: "2.0.0" }); + expect(res.body.error.code).toBe("package.deleted"); + }); + + it("refuses the owner restoring an admin delete with 403 restore.admin_deleted", async () => { + expect((await manage("@alpha/demo", "delete", {}, { admin: ADMIN, now: NOW })).status).toBe(200); + session.user = { id: "user-a" }; + expectError(await manage("@alpha/demo", "restore", {}, { key: ownerKey, now: at(1) }), 403, "restore.admin_deleted"); + expectError(await manage("@alpha/demo", "restore", {}, { headers: { Origin: "https://skillx.test" }, now: at(1) }), 403, "restore.admin_deleted"); + expect(visibilityOf(h, "@alpha/demo").deleted_at).toBe(NOW.getTime()); + expect(packageAudit(h).map((a) => [a.actor_kind, a.action])).toEqual([["admin", "delete"]]); + expect((await manage("@alpha/demo", "restore", {}, { admin: ADMIN, now: at(1) })).status).toBe(200); + }); + + it("lets the owner restore their own delete after an earlier admin delete was restored", async () => { + await manage("@alpha/demo", "delete", {}, { admin: ADMIN, now: NOW }); + await manage("@alpha/demo", "restore", {}, { admin: ADMIN, now: at(1) }); + expect((await manage("@alpha/demo", "delete", {}, { key: ownerKey, now: at(2) })).status).toBe(200); + expect((await manage("@alpha/demo", "restore", {}, { key: ownerKey, now: at(3) })).status).toBe(200); + }); + + it("answers 409 package.not_deleted to a live package", async () => { + expectError(await manage("@alpha/demo", "restore", {}, { key: ownerKey }), 409, "package.not_deleted"); + }); +}); diff --git a/apps/web/tests/creator/package-visibility.test.ts b/apps/web/tests/creator/package-visibility.test.ts new file mode 100644 index 0000000..fb55005 --- /dev/null +++ b/apps/web/tests/creator/package-visibility.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { releasePublishScope } from "~/lib/auth/api-key-scopes"; +import { action as routeAction, loader as routeLoader } from "~/routes/api.v1.package-manage"; +import { ADMIN, envOf, expectError, publish, useStateHarness } from "../releases/release-state-helpers"; +import type { Harness } from "../releases/release-state-helpers"; +import { ORIGIN, manage, packageAudit, readStatuses, setVisibility, visibilityOf } from "./creator-helpers"; + +/** + * Package visibility changes: a compare-and-set on `from`, owner-only, audited, + * and a change to public only with a same-origin web session and approval. + * Fixtures: `user-a` owns `@alpha`, `user-b` owns `@beta`. + */ +const session = vi.hoisted(() => ({ user: null as { id: string } | null })); +vi.mock("~/lib/auth/session-helpers", () => ({ + getSession: async () => (session.user === null ? null : { user: session.user }), +})); + +let h: Harness; +let ownerKey: string; +let digest: string; +const SAME_ORIGIN = { Origin: ORIGIN }; + +beforeEach(async () => { + h = await useStateHarness(); + session.user = null; + ownerKey = await h.createKey("user-a", null); + digest = await publish(); +}); + +describe("package visibility: narrowing", () => { + it("lets the owner key make a public package private, audited and no-store", async () => { + const res = await manage("@alpha/demo", "visibility", { from: "public", to: "private" }, { key: ownerKey }); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe("no-store"); + expect(res.body).toMatchObject({ identity: "@alpha/demo", from: "public", to: "private", visibility: "private" }); + expect(visibilityOf(h, "@alpha/demo").visibility).toBe("private"); + expect(packageAudit(h)).toEqual([ + { subject_kind: "package", subject_name: "@alpha/demo", actor_kind: "user", actor_id: "user-a", action: "visibility", from_value: "public", to_value: "private" }, + ]); + expect(await readStatuses("@alpha/demo", digest)).toEqual([404, 404, 404, 404]); + }); + + it("makes an unlisted package readable anonymously by name", async () => { + session.user = { id: "user-a" }; + expect((await manage("@alpha/demo", "visibility", { from: "public", to: "unlisted" }, { headers: SAME_ORIGIN })).status).toBe(200); + expect(await readStatuses("@alpha/demo", digest)).toEqual([200, 200, 200, 200]); + }); + + it("answers 409 visibility_conflict when from is stale and writes nothing", async () => { + expectError(await manage("@alpha/demo", "visibility", { from: "unlisted", to: "private" }, { key: ownerKey }), 409, "visibility_conflict"); + expect(packageAudit(h)).toEqual([]); + expect(visibilityOf(h, "@alpha/demo").visibility).toBe("public"); + }); + + it("validates the body", async () => { + for (const body of [{ from: "public", to: "internal" }, { from: "public" }, { from: "public", to: "private", approve: "yes" }, { from: "public", to: "private", extra: 1 }]) { + expect((await manage("@alpha/demo", "visibility", body, { key: ownerKey })).status).toBe(400); + } + expect((await manage("@alpha/demo", "visibility", { from: "public", to: "public" }, { key: ownerKey })).status).toBe(400); + }); +}); + +describe("package visibility: making a package public", () => { + beforeEach(() => setVisibility("@alpha/demo", "private")); + + it("refuses an API key even with approval", async () => { + const res = await manage("@alpha/demo", "visibility", { from: "private", to: "public", approve: true }, { key: ownerKey }); + expectError(res, 403, "visibility.approval_required"); + expect(packageAudit(h)).toEqual([]); + }); + + it("refuses a web session without approve: true", async () => { + session.user = { id: "user-a" }; + expectError(await manage("@alpha/demo", "visibility", { from: "private", to: "public" }, { headers: SAME_ORIGIN }), 403, "visibility.approval_required"); + expect(visibilityOf(h, "@alpha/demo").visibility).toBe("private"); + }); + + it("accepts a same-origin web session with approve: true", async () => { + session.user = { id: "user-a" }; + const res = await manage("@alpha/demo", "visibility", { from: "private", to: "public", approve: true }, { headers: SAME_ORIGIN }); + expect(res).toMatchObject({ status: 200, body: { visibility: "public" } }); + expect(packageAudit(h)).toEqual([expect.objectContaining({ actor_kind: "user", from_value: "private", to_value: "public" })]); + }); + + it("refuses a cross-origin web session before anything else", async () => { + session.user = { id: "user-a" }; + for (const headers of [{ Origin: "https://evil.example" }, { "Sec-Fetch-Site": "cross-site" }]) { + expectError(await manage("@alpha/demo", "visibility", { from: "private", to: "public", approve: true }, { headers }), 403, "cross_origin"); + expectError(await manage("@alpha/demo", "delete", {}, { headers }), 403, "cross_origin"); + } + expect(packageAudit(h)).toEqual([]); + }); +}); + +describe("package visibility: making a private package unlisted", () => { + beforeEach(() => setVisibility("@alpha/demo", "private")); + + it("refuses an owner API key, because unlisted is readable anonymously", async () => { + for (const approve of [false, true]) { + expectError(await manage("@alpha/demo", "visibility", { from: "private", to: "unlisted", approve }, { key: ownerKey }), 403, "visibility.approval_required"); + } + expect(visibilityOf(h, "@alpha/demo").visibility).toBe("private"); + expect(packageAudit(h)).toEqual([]); + expect(await readStatuses("@alpha/demo", digest)).toEqual([404, 404, 404, 404]); + }); + + it("refuses a web session without approve: true and accepts one with it", async () => { + session.user = { id: "user-a" }; + expectError(await manage("@alpha/demo", "visibility", { from: "private", to: "unlisted" }, { headers: SAME_ORIGIN }), 403, "visibility.approval_required"); + const res = await manage("@alpha/demo", "visibility", { from: "private", to: "unlisted", approve: true }, { headers: SAME_ORIGIN }); + expect(res).toMatchObject({ status: 200, body: { visibility: "unlisted" } }); + expect(packageAudit(h)).toEqual([expect.objectContaining({ actor_kind: "user", from_value: "private", to_value: "unlisted" })]); + expect(await readStatuses("@alpha/demo", digest)).toEqual([200, 200, 200, 200]); + }); + + it("lets an owner API key make an unlisted package private again", async () => { + setVisibility("@alpha/demo", "unlisted"); + expect((await manage("@alpha/demo", "visibility", { from: "unlisted", to: "private" }, { key: ownerKey })).status).toBe(200); + expect(visibilityOf(h, "@alpha/demo").visibility).toBe("private"); + }); +}); + +describe("package visibility: who may change it", () => { + it("answers 404 to another account and to an unknown package", async () => { + const otherKey = await h.createKey("user-b", null); + expectError(await manage("@alpha/demo", "visibility", { from: "public", to: "private" }, { key: otherKey }), 404, "not_found"); + expectError(await manage("@alpha/none", "visibility", { from: "public", to: "private" }, { key: ownerKey }), 404, "not_found"); + }); + + it("refuses scoped keys, anonymous callers and the admin", async () => { + const scoped = await h.createKey("user-a", [releasePublishScope("alpha")]); + expect((await manage("@alpha/demo", "visibility", { from: "public", to: "private" }, { key: scoped })).status).toBe(401); + expect((await manage("@alpha/demo", "visibility", { from: "public", to: "private" })).status).toBe(401); + expectError(await manage("@alpha/demo", "visibility", { from: "public", to: "private" }, { admin: ADMIN }), 403, "visibility.owner_only"); + expect(packageAudit(h)).toEqual([]); + }); + + it("answers 403 publisher_revoked to the owner of a suspended publisher", async () => { + h.d1.raw.prepare("UPDATE publishers SET state = 'suspended' WHERE handle = 'alpha'").run(); + expectError(await manage("@alpha/demo", "visibility", { from: "public", to: "private" }, { key: ownerKey }), 403, "publisher_revoked"); + }); + + it("is 404 while the releases API is off, 405 for other methods", async () => { + expect((await manage("@alpha/demo", "visibility", {}, { key: ownerKey, env: { SKILLX_RELEASES_API: undefined } })).status).toBe(404); + expect((await manage("@alpha/demo", "visibility", {}, { key: ownerKey, method: "PUT" })).status).toBe(405); + }); +}); + +describe("package management route", () => { + const call = async (path: string, body: unknown) => { + const request = new Request(`${ORIGIN}${path}`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${ownerKey}` }, body: JSON.stringify(body) }); + return routeAction({ request, params: { scope: "@alpha", slug: "demo" }, context: { cloudflare: { env: envOf() } } } as never); + }; + + it("dispatches on the last path segment", async () => { + expect((await call("/api/v1/packages/@alpha/demo/visibility", { from: "public", to: "unlisted" })).status).toBe(200); + expect((await call("/api/v1/packages/@alpha/demo/delete", {})).status).toBe(200); + expect((await call("/api/v1/packages/@alpha/demo/restore", {})).status).toBe(200); + expect((await call("/api/v1/packages/@alpha/demo/purge", {})).status).toBe(404); + }); + + it("answers GET with 405, or 404 while the releases API is off", async () => { + const get = (env: Record) => routeLoader({ request: new Request(`${ORIGIN}/api/v1/packages/@alpha/demo/delete`), params: {}, context: { cloudflare: { env } } } as never); + expect((await get(envOf())).status).toBe(405); + expect((await get(envOf({ SKILLX_RELEASES_API: undefined }))).status).toBe(404); + }); +}); diff --git a/apps/web/tests/releases/release-harness.ts b/apps/web/tests/releases/release-harness.ts index c519f3d..f94c2e0 100644 --- a/apps/web/tests/releases/release-harness.ts +++ b/apps/web/tests/releases/release-harness.ts @@ -35,7 +35,7 @@ export interface UploadOptions { * packages are public unless a test says otherwise; `null` omits the field * so the registry default applies. */ - visibility?: "public" | "private" | null; + visibility?: "public" | "unlisted" | "private" | null; } export async function createHarness() { diff --git a/apps/web/tests/releases/release-private-publish.test.ts b/apps/web/tests/releases/release-private-publish.test.ts index 45c5158..562c27f 100644 --- a/apps/web/tests/releases/release-private-publish.test.ts +++ b/apps/web/tests/releases/release-private-publish.test.ts @@ -159,7 +159,7 @@ describe("publish visibility: never changed by a publish", () => { expect(h.releaseRows()).toEqual([expect.objectContaining({ name: "@alpha/notes", version: winner.body.version })]); }); - it.each([["unlisted"], [true], [null]])("rejects visibility %j with 400 before any write", async (value) => { + it.each([["internal"], ["Public"], [true], [null]])("rejects visibility %j with 400 before any write", async (value) => { const res = await publish("@alpha/notes", { metadata: { version: "1.0.0", visibility: value } }); expect(res).toMatchObject({ status: 400, body: { error: { code: "release.invalid_metadata" } } }); expect(h.r2.putLog).toEqual([]); diff --git a/apps/web/tests/releases/sign-manifest.test.ts b/apps/web/tests/releases/sign-manifest.test.ts index 0a514c6..dcc8e8d 100644 --- a/apps/web/tests/releases/sign-manifest.test.ts +++ b/apps/web/tests/releases/sign-manifest.test.ts @@ -78,7 +78,8 @@ describe("request boundary", () => { visibility: null, }); expect(parseReleaseMetadata(header({ version: "1.0.0", visibility: "private" })).visibility).toBe("private"); - expect(() => parseReleaseMetadata(header({ version: "1.0.0", visibility: "unlisted" }))).toThrow(/visibility/); + expect(parseReleaseMetadata(header({ version: "1.0.0", visibility: "unlisted" })).visibility).toBe("unlisted"); + expect(() => parseReleaseMetadata(header({ version: "1.0.0", visibility: "internal" }))).toThrow(/visibility/); expect(() => parseReleaseMetadata(null)).toThrow(/required/); expect(() => parseReleaseMetadata("%%%")).toThrow(/base64/); expect(() => parseReleaseMetadata(header({ version: "1.0.0+build" }))).toThrow(/SemVer/); diff --git a/docs/api-reference.md b/docs/api-reference.md index 39d0f73..40e47e3 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1038,8 +1038,8 @@ or `null` reasons and reason rules). Same public test key as the manifest vector `status_sequence` and `status_issued_at` (sequence and issue time of the last signed status; the state itself stays in `release_state`), and the non-unique index `idx_package_releases_digest`. `packages.visibility` -(`public` column default, `private`; the publish route always writes it -explicitly, see Visibility below). `publishers.state` (`active` default, +(`public` column default, `unlisted`, `private`; the publish route always +writes it explicitly, see Visibility below). `publishers.state` (`active` default, `suspended`, `revoked`). All additive; rollback SQL is in the migration header and ends by deleting the `0011_release_tree_digest.sql` row from `d1_migrations`, so the migration can be applied again. @@ -1073,8 +1073,8 @@ Unscoped keys are rejected (`auth.scope_missing`). `bundle`), `sourceProvenance` (`{repo, commit, path}` or `null`) and `declaredScripts` (packaged paths) and `provenance` (`null`, `{"provider": "skillx"}`, or `{"provider": "kit", "origin": - "remote-signed" | "local-dev"}`) and `visibility` (`public` or `private`; - any other value is `400 release.invalid_metadata`). Unknown members are + "remote-signed" | "local-dev"}`) and `visibility` (`public`, `unlisted` + or `private`; any other value is `400 release.invalid_metadata`). Unknown members are rejected. `sourceProvenance` is asserted by the publisher and not verified (GitHub OIDC is a follow-up); the registry signature covers it but does not vouch @@ -1098,7 +1098,11 @@ never changes the visibility of an existing package: omitting `visibility` keeps it, and naming a different one is `409 release.visibility_mismatch` before anything is written (also when a concurrent first publish created the package with the other visibility). Visibility is registry policy, not part -of the signed manifest. The response carries the stored `visibility`. +of the signed manifest. The response carries the stored `visibility`. After +creation only the owner changes it, with the visibility endpoint (see +[Managing packages and kits](#managing-packages-and-kits-owner)). Publishing +to a deleted package is `409 package.deleted` before anything is written, +also after its restore window: a deleted name is never reused. **Processing**, each step rejecting before the next runs: name and reserved namespaces, key and ownership, signing key, metadata, package owner, kind and @@ -1208,7 +1212,7 @@ index: one row wins and the loser gets the same idempotent `200` or `409`. | 401 | `auth.invalid_key` (also an expired, forged or revoked `sxo_` OIDC publish token) | | 403 | `identity.reserved_publisher` (`@agentkit` without kit provenance), `identity.reserved_alias` (`ak-`/`ak:` slugs), `auth.scope_missing`, `publisher.not_owner`, `publisher.inactive` | | 404 | `not_found` (API disabled) | -| 409 | `release.version_exists`, `release.kind_conflict`, `release.visibility_mismatch`, `idempotency.in_progress` | +| 409 | `release.version_exists`, `release.kind_conflict`, `release.visibility_mismatch`, `package.deleted`, `idempotency.in_progress` | | 413 | `tree.limit_exceeded` | | 415 | `release.unsupported_media_type` | | 422 | other `tree.*` codes from the skill-tree reader, `exec.manifest_invalid`, `exec.entrypoint_undeclared`, `idempotency.key_reused` | @@ -1320,6 +1324,16 @@ never accepted in the query string. order: unknown or not readable by the caller `404`, then quarantined `403`, then publisher revoked `403`, then yanked): +- Deleted package (`packages.deleted_at` set): `404 not_found` to everyone, + its owner and the admin included, on every endpoint, including signed + download URLs minted before the delete. Restoring it within 30 days makes + it readable again under its visibility. +- Unlisted package (`packages.visibility = 'unlisted'`): readable by anyone + who knows its name or digest, exactly like a public package, but never + listed: public discovery (search, leaderboard, `llms.txt`, MCP + `search_packages`) reads the skill catalog, never registry packages, and + the private library lists it only to its owner and entitled callers. Its + answers are `private, no-store` like a private package's. - Private package (`packages.visibility = 'private'`): readable by the owner of its publisher (`publishers.owner_user_id`, the signed-in account of the session or unscoped API key; no entitlement needed) and by a caller with an @@ -1726,7 +1740,7 @@ The publisher route answers `{"handle", "from", "to", "state", "changedAt"}`. | 400 | `invalid_request` (body, states, reason), `identity.invalid`, `invalid_version` | | 401 | `auth.required`, `invalid_api_key`, `invalid_admin_secret` (with `WWW-Authenticate: Bearer`) | | 403 | `cross_origin` (web session from another origin), `transition.not_owner` (public package), `transition.admin_only`, `publisher_revoked` | -| 404 | `not_found` (unknown release or publisher; a private package of another owner; API disabled) | +| 404 | `not_found` (unknown release or publisher; a private package of another owner; a deleted package; API disabled) | | 409 | `state_conflict` | | 413 | `request_too_large` | | 415 | `unsupported_media_type` | @@ -1734,6 +1748,78 @@ The publisher route answers `{"handle", "from", "to", "state", "changedAt"}`. | 429 | `rate_limited` (with `Retry-After`) | | 500 | `release.internal_error` | +### Managing packages and kits (owner) + +The owner manages a package or personal kit from the web dashboard +(`/dashboard`) or with these endpoints. All are `POST` with an +`application/json` body, behind `SKILLX_RELEASES_API` (`404 not_found` while +it is off; kits also need `SKILLX_PERSONAL_KITS_API`, else `404 +kit.api_disabled`), other methods `405`. Every answer is `Cache-Control: +no-store`; errors use the usual `{"error": {"code", "message"}}` envelope. + +| Path | Body | Answer | +|------|------|--------| +| `/api/v1/packages/@{publisher}/{slug}/visibility` | `{"from", "to", "approve"?}` | `{identity, from, to, visibility, changedAt}` | +| `/api/v1/packages/@{publisher}/{slug}/delete` | `{}` | `{identity, deletedAt, restoreDeadline}` | +| `/api/v1/packages/@{publisher}/{slug}/restore` | `{}` | `{identity, visibility, restoredAt}` | +| `/api/v1/kits/@{handle}/{name}/delete` | `{}` | `{kit, deletedAt, restoreDeadline}` | +| `/api/v1/kits/@{handle}/{name}/restore` | `{}` | `{kit, restoredAt, generation}` | + +**Callers.** Resolved as for state changes: a web session from another origin +is `403 cross_origin` before anything else; then the admin secret, an +unscoped API key or the session. Scoped keys (publish, `library:read`, +`kits:write`) are refused (`401`). Anyone but the owner gets `404 not_found`, +so the endpoints do not disclose existence; +the owner of a suspended or revoked publisher gets `403 publisher_revoked`. +The admin may delete and restore, never change visibility (`403 +visibility.owner_only`). An admin delete is containment: only the admin can +restore it, and the owner gets `403 restore.admin_deleted` (decided by the +latest `delete` row in `package_audit`, re-checked inside the restore batch). + +**Visibility** (`public`, `unlisted`, `private`) is a compare-and-set: +`from` must be the stored visibility, else `409 visibility_conflict` and +nothing is written. Widening, so that anonymous callers can read more +(`private` to `unlisted` or `public`, `unlisted` to `public`), needs a +signed-in web session and `"approve": true`, because `unlisted` is readable by +anyone who knows the name; an API key, or a session without approval, gets +`403 visibility.approval_required`. Narrowing (`public` to `unlisted`, +anything to `private`) is allowed with a session or an unscoped key and takes +effect on the next read; +copies already downloaded are not recalled. A deleted package cannot change +visibility (`404`). Kit visibility is not changed here: it follows the +activated revision (`private` or `public`, see +[personal-kits-api.md](./personal-kits-api.md)). + +**Delete and restore** are soft. A deleted package disappears from every +read path for every viewer (see Access and state rules), refuses new +versions (`409 package.deleted`) and answers `404` to state changes, +promotion and quarantine review of its releases; it leaves the review queue. +A deleted kit disappears from the kit API (`404 not_found`), the library +and MCP `get_kit`, and a new revision under its name is `409 kit.deleted`. +Restore within 30 days of the delete (`restoreDeadline`) clears the +tombstone and keeps visibility and releases as they were; a kit's +`generation` advances on delete and on restore. After the deadline restore is +`410 restore_window_expired`, the tombstone stays and the name is never +reused. Deleting a deleted subject is `409 package.deleted` / `kit.deleted`; +restoring a live one is `409 package.not_deleted` / `kit.not_deleted`. + +Every change writes one row to the append-only `package_audit` table +(migration `0022_creator_dashboard`, triggers refuse `UPDATE` and `DELETE`): +`subject_kind` (`package` or `kit`), `subject_id`, `subject_name`, +`actor_kind`, `actor_id`, `action` (`visibility`, `delete`, `restore`), +`from_value`, `to_value`, `created_at`. The write and its audit row commit in +one batch under the same guard, so a lost race writes neither (`409`). + +| Status | Codes | +|--------|-------| +| 400 | `invalid_request` (body, unknown member, `from` equal to `to`), `identity.invalid` | +| 401 | `auth.required`, `invalid_api_key`, `invalid_admin_secret`, scoped keys | +| 403 | `cross_origin`, `visibility.approval_required`, `visibility.owner_only`, `publisher_revoked`, `restore.admin_deleted` | +| 404 | `not_found`, `kit.api_disabled` | +| 409 | `visibility_conflict`, `package.deleted`, `package.not_deleted`, `package.state_conflict`, `kit.deleted`, `kit.not_deleted`, `kit.state_conflict` | +| 410 | `restore_window_expired` | +| 429 | `rate_limited` (with `Retry-After`; `RELEASE_WRITE_LIMITER`) | + ### Provisioning a publisher `POST /api/v1/publishers` (admin only) creates an `active` publisher, the diff --git a/docs/cli-reference.md b/docs/cli-reference.md index ca82708..e209e65 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -280,7 +280,7 @@ not a local path (or no source at all) runs the deprecated import (see | `--kind ` | `skill`, `hook-pack` or `bundle` | `skill` | | `--repo ` | Provenance repository URL | `origin` remote (https form) | | `--script ` | Declare a packaged script (repeatable) | — | -| `--visibility ` | `public` or `private`. Omitted: a new package is `private` (a note goes to stderr, never stdout) and an existing package keeps its visibility. A different visibility for an existing package is refused: `api_error`, exit `1`, `error.details.code` `release.visibility_mismatch` (like `release.version_exists`) | — | +| `--visibility ` | `public`, `unlisted` (readable by anyone who knows the name, never listed) or `private`. Omitted: a new package is `private` (a note goes to stderr, never stdout) and an existing package keeps its visibility. A different visibility for an existing package is refused: `api_error`, exit `1`, `error.details.code` `release.visibility_mismatch` (like `release.version_exists`); change it in the web dashboard (`/dashboard`) instead. Publishing to a deleted package is refused with `package.deleted` | — | | `--kit-origin ` | Publish an `@agentkit/*` kit release (`remote-signed` or `local-dev`), signed as `skillx.release/v2`; needs a `releases:publish:@agentkit` key; the slug omits `ak-` (`@agentkit/plan`). Installing v2 releases needs skillx-sh 0.7.0 or later | — | | `--registry ` | Registry base URL; `https` only (`http` only for localhost) | configured base URL | | `--idempotency-key ` | Sends `Idempotency-Key`: a retry with the same key and bytes replays the first answer; other bytes are refused | — | diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index d940fc7..ca926d0 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -549,6 +549,26 @@ again. Rollback: set the flag to `"0"` and redeploy (bearer tokens are ignored, link routes answer 404, unlink still works); to remove the table, run the header SQL of `0021_agentkit_links.sql`. +**Migration 0022 and the creator dashboard.** `0022_creator_dashboard` adds +the nullable `packages.deleted_at` and `personal_kits.deleted_at` (NULL: not +deleted, so every existing row reads as before) and the new append-only +`package_audit` table with `idx_package_audit_subject` and two triggers that +abort any `UPDATE` or `DELETE` on it. It is additive, but the order matters: +the new code reads `deleted_at` on every release, library and kit query, so +apply it with `pnpm run db:migrate:remote` after a backup and before +deploying the code. Code before 0022 runs fine on the migrated schema. No flag +changes: `/dashboard` needs a session, its package section and the +`/api/v1/packages/.../visibility|delete|restore` endpoints follow +`SKILLX_RELEASES_API`, and its kit section and the kit `delete|restore` +endpoints also follow `SKILLX_PERSONAL_KITS_API`. Publishers need +skillx-sh with `unlisted` support to publish `--visibility unlisted`; older +CLIs refuse the value locally, and an owner can switch a package to unlisted +in the dashboard instead. Rollback: deploy the previous code, then run the +header SQL of `0022_creator_dashboard.sql`. Code before 0022 ignores +`deleted_at`, so a rollback un-deletes content: first run the two `UPDATE ... +SET visibility = 'private' WHERE deleted_at IS NOT NULL` statements in that +header. It treats `unlisted` as private, which fails closed. + 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)). diff --git a/docs/personal-kits-api.md b/docs/personal-kits-api.md index b0b6403..9e1c159 100644 --- a/docs/personal-kits-api.md +++ b/docs/personal-kits-api.md @@ -237,6 +237,7 @@ Every kit has a `generation`. `GET` returns it, with an `ETag` of `"g"`. | `POST /conflicts/:id/resolve` `{expectedGeneration, idempotencyKey, approvedBy:"user", decisions, agentSuggestion?}` | The user's decision becomes a `draft` revision with origin `resolution`. | | `POST /activate`, `POST /rollback` `{expectedGeneration, revisionId, approval:{revisionDigest}}` | Make a revision effective. `rollback` re-activates an earlier effective revision. | | `GET /lock[?revision=]` | The lock and lineage documents of the effective (or named) revision. | +| `POST /delete`, `POST /restore` `{}` | Soft-delete the kit, or restore it within 30 days (owner session or unscoped key, or the admin; scoped keys 401). A deleted kit answers 404 here, in the library and in MCP `get_kit`, and a new revision under its name is 409 `kit.deleted`. Both advance `generation`. An admin delete is containment: only the admin can restore it (the owner gets 403 `restore.admin_deleted`). See [api-reference.md](./api-reference.md#managing-packages-and-kits-owner). | The default sources are the effective revision for `fromRevisionId` and for the lock. @@ -575,8 +576,9 @@ other deployment and to re-enabling after a rollback. the owner's kit unchanged. It never follows a redirect (a 3xx fails the check) and prints a JSON receipt with no keys. Exit 2 means the deployment still answers `kit.api_disabled`. -3. Clean up: the API cannot delete a kit (revisions and audit rows are - immutable, and kits are `ON DELETE RESTRICT`), so the smoke reuses one +3. Clean up: the API only soft-deletes a kit, and a deleted name is never + reused (revisions and audit rows are immutable, and kits are `ON DELETE + RESTRICT`), so the smoke reuses one private kit, `@handle/agentkit-smoke` (`--kit-name` to choose another), and each run only appends and activates one revision there; the receipt names the kit and the run. Keep that kit on the dedicated smoke account; do not @@ -600,7 +602,7 @@ the smoke and the flag-off answer, not a deployment. | `kit.generation_conflict`, `kit.idempotency_conflict`, `kit.revision_exists`, `kit.composition_digest_mismatch`, `overlay.conflict`, `kit.approval_required`, `kit.review_required`, `kit.revision_unusable`, `kit.revision_effective`, `kit.discard_unconfirmed`, `kit.conflict_closed`, `kit.locked_incompatible`, `kit.not_a_proposal`, `proposal.private_kit_required` | 409 | | `kit.generation_conflict` when the stale generation came with `If-Match` | 412 | | `proposal.invalid`, `proposal.forbidden_field`, `proposal.private_only`, `proposal.evidence_too_large`, `proposal.unknown_skill`, `proposal.base_mismatch`, `proposal.content_refused` (`findings`), `proposal.undeclared_permission` (`required`, `missing`), `proposal.excess_permission` (`required`, `extra`) | 422 | -| `kit.admin_only`, `kit.interactive_approval_required`, `cross_origin` | 403 | +| `kit.admin_only`, `kit.interactive_approval_required`, `restore.admin_deleted`, `cross_origin` | 403 | | `kit.invalid`, `overlay.invalid`, `schema.unsupported_version`, `kit.duplicate_identity`, `kit.mode_violation`, `kit.alias_collision`, `overlay.shared_alias`, `identity.reserved_alias`, `kit.request_too_large`, `kit.cycle`, `kit.multi_parent`, `kit.public_derivative_forbidden`, `kit.base_unavailable` (409 when resolving a conflict) | 422 | | `kit.publisher_not_owned` | 403 | | `invalid_request` (malformed body, ids, updates or `If-Match`) | 400 | diff --git a/docs/system-architecture.md b/docs/system-architecture.md index 757c8ed..53f1903 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -706,13 +706,32 @@ exceeds what the owner's session reads. revocable on its own; unscoped keys keep their legacy behavior. **Visibility is chosen at publish and is not a signed field.** Release -metadata takes `visibility` (`public` or `private`; anything else is `400`). -An existing package keeps its visibility: omitting the field keeps it, naming -the other one is `409 release.visibility_mismatch` and writes nothing. A new -package published without `visibility` is `private` (`skillx publish` prints a -note on stderr); a new `@agentkit` kit release defaults to `public`, as before. -Visibility is registry policy and not part of the signed manifest. Changing it -later is out of scope here. +metadata takes `visibility` (`public`, `unlisted` or `private`; anything else +is `400`). An existing package keeps its visibility: omitting the field keeps +it, naming another one is `409 release.visibility_mismatch` and writes +nothing. A new package published without `visibility` is `private` +(`skillx publish` prints a note on stderr); a new `@agentkit` kit release +defaults to `public`, as before. Visibility is registry policy and not part of +the signed manifest. Only `private` is protected: `unlisted` reads like +`public` by name or digest but is never listed (discovery reads the skill +catalog, not registry packages) and is never shared-cached. + +**Changing visibility, deleting and restoring** happen only through the owner +management API (`lib/creator/`, used by the `/dashboard` page; see +[api-reference.md](./api-reference.md#managing-packages-and-kits-owner)). +A visibility change is a compare-and-set on `from`; widening (out of +`private`, or to `public`) needs an interactive web session and explicit +approval, never an API key, while narrowing is allowed with an unscoped key +and applies to the next read only (copies already downloaded are not +recalled). A package or kit the admin deleted can only be restored by the +admin. Delete is a soft tombstone +(`packages.deleted_at`, `personal_kits.deleted_at`): every read query of the +release gate (`release-access.ts`), the library, the kit store, publish, +transitions, promotion and review filter it, so a deleted package is `404` +to everyone, signed download URLs included. Restore clears it within 30 days; +after that it is `410` and the name stays reserved. Each change and its row +in the append-only `package_audit` table commit in one guarded batch. +Publishing stays CLI-only, and un-yanking stays an admin transition. **Responses.** Release reads of a private package and `/library` answers are `Cache-Control: private, no-store` with `Vary: Authorization, Cookie`; kit API diff --git a/packages/cli/src/built-cli.test.ts b/packages/cli/src/built-cli.test.ts index 4dedc59..f4551e9 100644 --- a/packages/cli/src/built-cli.test.ts +++ b/packages/cli/src/built-cli.test.ts @@ -163,7 +163,7 @@ describe("built cli", { timeout: 30_000 }, () => { expect(received.visibility).toBe("public"); expect(explicit.stderr).not.toContain("note:"); - const bad = await publish("--visibility", "unlisted").catch((e) => e); + const bad = await publish("--visibility", "internal").catch((e) => e); expect(bad.code).toBe(2); expect(JSON.parse(bad.stdout)).toMatchObject({ ok: false, error: { code: "usage_error", message: expect.stringContaining("--visibility") } }); } finally { diff --git a/packages/cli/src/commands/release-publish-visibility.test.ts b/packages/cli/src/commands/release-publish-visibility.test.ts index 8f1f03c..732dc7f 100644 --- a/packages/cli/src/commands/release-publish-visibility.test.ts +++ b/packages/cli/src/commands/release-publish-visibility.test.ts @@ -8,10 +8,11 @@ describe('requestedVisibility', () => { expect(requestedVisibility(undefined)).toBeUndefined(); }); - it('accepts exactly public and private', () => { + it('accepts exactly public, unlisted and private', () => { expect(requestedVisibility('public')).toBe('public'); + expect(requestedVisibility('unlisted')).toBe('unlisted'); expect(requestedVisibility('private')).toBe('private'); - for (const bad of ['unlisted', 'Public', '']) expect(() => requestedVisibility(bad)).toThrow(/--visibility must be one of: public, private/); + for (const bad of ['internal', 'Public', '']) expect(() => requestedVisibility(bad)).toThrow(/--visibility must be one of: public, unlisted, private/); }); }); diff --git a/packages/cli/src/commands/release-publish.ts b/packages/cli/src/commands/release-publish.ts index 0c9e170..e21444e 100644 --- a/packages/cli/src/commands/release-publish.ts +++ b/packages/cli/src/commands/release-publish.ts @@ -68,7 +68,7 @@ export function kitProvenance(name: { handle: string; slug: string }, kitOrigin: return { provider: 'kit', origin: kitOrigin }; } -/** `--visibility`: undefined when omitted (the registry decides), else exactly public or private. */ +/** `--visibility`: undefined when omitted (the registry decides), else exactly one of PACKAGE_VISIBILITIES. */ export function requestedVisibility(raw: string | undefined): PackageVisibility | undefined { if (raw === undefined) return undefined; if (!(PACKAGE_VISIBILITIES as readonly string[]).includes(raw)) { @@ -193,7 +193,7 @@ export function withReleasePublishOptions(command: Command): Command { .option('--kind ', 'skill | hook-pack | bundle', 'skill') .option('--repo ', 'source repository URL for provenance (default: git remote origin)') .option('--kit-origin ', 'publish an @agentkit/* kit release with this origin: remote-signed | local-dev (needs a releases:publish:@agentkit key)') - .option('--visibility ', 'public | private; omitted: a new package is private, an existing one keeps its visibility (a different one is refused)') + .option('--visibility ', `${PACKAGE_VISIBILITIES.join(' | ')} (unlisted: readable by anyone who knows the name, never listed); omitted: a new package is private, an existing one keeps its visibility (a different one is refused)`) .option('--script ', 'declare a packaged file as a script (repeatable)', collect, []) .option('--registry ', 'registry base URL (default: configured base URL)') .option('--idempotency-key ', 'Idempotency-Key header: a retry with the same key replays the first answer') diff --git a/packages/contracts/src/package-manifest.test.ts b/packages/contracts/src/package-manifest.test.ts index 90ca93e..b33cc05 100644 --- a/packages/contracts/src/package-manifest.test.ts +++ b/packages/contracts/src/package-manifest.test.ts @@ -19,7 +19,9 @@ import { PACKAGE_VISIBILITIES, RELEASE_STATES, SEMVER_PATTERN, + isOpenPackageVisibility, isPackageKind, + isPackageVisibility, isReleaseState, isResolvableReleaseState, validatePackageManifest, @@ -94,8 +96,18 @@ describe("package kinds and release states", () => { expect(isPackageKind("plugin")).toBe(false); }); - it("defines exactly the public and private package visibilities", () => { - expect([...PACKAGE_VISIBILITIES]).toEqual(["public", "private"]); + it("defines exactly the public, unlisted and private package visibilities", () => { + expect([...PACKAGE_VISIBILITIES]).toEqual(["public", "unlisted", "private"]); + for (const v of PACKAGE_VISIBILITIES) expect(isPackageVisibility(v)).toBe(true); + for (const bad of ["Public", "internal", "", null, 1]) expect(isPackageVisibility(bad)).toBe(false); + }); + + it("protects only private packages and fails closed on unknown stored values", () => { + expect(isOpenPackageVisibility("public")).toBe(true); + expect(isOpenPackageVisibility("unlisted")).toBe(true); + expect(isOpenPackageVisibility("private")).toBe(false); + expect(isOpenPackageVisibility("PUBLIC")).toBe(false); + expect(isOpenPackageVisibility("")).toBe(false); }); it("defines exactly the four release states", () => { diff --git a/packages/contracts/src/package-manifest.ts b/packages/contracts/src/package-manifest.ts index 671d9dc..41691aa 100644 --- a/packages/contracts/src/package-manifest.ts +++ b/packages/contracts/src/package-manifest.ts @@ -17,10 +17,25 @@ export const SKILLX_PACKAGE_SCHEMA_VERSION = "skillx.package/v1"; export const PACKAGE_KINDS = ["skill", "hook-pack", "bundle"] as const; export type PackageKind = (typeof PACKAGE_KINDS)[number]; -/** Who may read a package's releases: anyone, or only viewers the registry read gate admits. */ -export const PACKAGE_VISIBILITIES = ["public", "private"] as const; +/** + * Who may read a package's releases. `public`: anyone, and it may be listed. + * `unlisted`: anyone who knows the identity (or a digest), but it is never + * listed in public discovery. `private`: only viewers the registry read gate + * admits (owner, entitlement). + */ +export const PACKAGE_VISIBILITIES = ["public", "unlisted", "private"] as const; export type PackageVisibility = (typeof PACKAGE_VISIBILITIES)[number]; +export const isPackageVisibility = (value: unknown): value is PackageVisibility => + typeof value === "string" && (PACKAGE_VISIBILITIES as readonly string[]).includes(value); + +/** + * True when anyone may read a package of this stored visibility (`public` or + * `unlisted`). Only `private` is protected, and any unknown stored value is + * treated as protected, so the gate fails closed. + */ +export const isOpenPackageVisibility = (visibility: string): boolean => visibility === "public" || visibility === "unlisted"; + export const RELEASE_STATES = ["draft", "quarantined", "published", "yanked"] as const; export type ReleaseState = (typeof RELEASE_STATES)[number];