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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ pnpm db:migrate:remote # Apply migrations to remote D1
| `/api/report` | api.usage-report.ts | API key |
| `/api/user/api-keys` | api.user-api-keys.ts | Session |
| `/api/user/interactions` | api.user-interactions.ts | Session (graceful fallback) |
| `/api/user/agentkit-link` | api.user-agentkit-link.ts (GET link status, DELETE unlink + revoke at the AS) | Session; DELETE same-origin only |
| `/auth/agentkit/start`, `/auth/agentkit/callback` | auth.agentkit.ts ("Link with AgentKit": auth code + S256 PKCE, `resource=https://skillx.sh/api`) | Session (404 unless `SKILLX_AGENTKIT_IDENTITY` on) |
| `/oauth/client.json`, `/.well-known/oauth-protected-resource[/api]` | oauth.client-json.ts, well-known.oauth-protected-resource.ts (CIMD, RFC 9728 metadata) | None (404 unless `SKILLX_AGENTKIT_IDENTITY` on) |
| `/api/skills/register` | api.skill-register.ts | Session (same-origin only)/Key |
| `/api/admin/seed` | api.admin.seed.ts | Admin secret |
| `/api/v1/packages/:scope/:slug/releases` | api.v1.package-releases.ts (GET list); action re-exported from api.v1.release-publish.ts (POST publish) | GET: optional Key/Session; POST: scoped key `releases:publish:@handle` (404 unless `SKILLX_RELEASES_API` on) |
Expand Down Expand Up @@ -158,6 +161,7 @@ Search algorithm: `./docs/search-algorithm.md`
- **Max 200 LOC per file** — split into focused modules if exceeded.
- **Seed data** in `scripts/seed-data.json` (30 real skills from skills.sh). Seed via `ADMIN_SECRET=... pnpm seed`.
- **Scoped API keys** — `api_keys.scopes` NULL = legacy key; scoped keys work only where their scope is checked: `releases:publish:@handle` on release publish/promote, `library:read` on release reads and kit reads, `kits:write:@handle` on that handle's kits (`lib/auth/scoped-viewer.ts`). They are stored under `hashScopedApiKey` (domain-separated), so legacy routes (`authenticateApiKey`, by plain key hash) never accept them. Apply migration 0012 to remote D1 before deploying (`docs/deployment-guide.md`).
- **AgentKit identity** (`app/lib/agentkit/`, flag `SKILLX_AGENTKIT_IDENTITY`, migration 0021) — an agentkit.best `at+jwt` for `https://skillx.sh/api` is accepted as Bearer wherever scoped keys are (`resolveRequestCredential`), verified locally via JWKS and mapped `skillx-kits:read` → `library:read`, `skillx-kits:write` → `kits:write:@<active handle>`. No token-exchange endpoint. Liveness: a link older than 60 s is re-validated by refreshing SkillX's own grant (one-time refresh claimed in D1); `invalid_grant` deletes the link. GitHub login is unchanged; unlinked accounts behave as before. See `docs/api-reference.md#agentkit-access-token-flag-skillx_agentkit_identity`.
- **Releases are byte-exact** — the release publish API (`app/lib/releases/`) never sanitizes; the scan only picks `published` vs `quarantined`. R2 writes are create-only, the D1 row is written last. Only `@agentkit/*` kit releases (metadata `provenance.provider = "kit"`) are signed as `skillx.release/v2`; everything else stays v1, and a registry signature never yields `signed-kit` trust. Kit release slugs omit `ak-` (`@agentkit/ak-*` is 400; AgentKit maps `ak-<x>` to `@agentkit/<x>`), and clients need skillx-sh 0.7.0+ to verify v2. See `docs/api-reference.md#publishing-releases`.
- **Same-origin on cookie mutations** — `SameSite=Lax` alone lets a sibling subdomain ride the session cookie, so a mutation a session can authenticate must refuse a foreign origin with `crossOriginRefusal` (`app/lib/auth/same-origin.ts`) before it writes. Session-only routes (rate, review, favorite, vote, user API keys) check first; routes that also take a Bearer key check only on the session path (register after `authenticateRequest`; every `resolveLimitedActor` caller — release state, promote, review, collections, personal kits —, 403 `cross_origin`). Add the check to any new cookie-authenticated mutation.
- **Claude plugin** — `plugins/skillx/` is the only folder submitted to the Claude directory; keep everything it loads inside it. Run any CLI as `skillx` or an exact `npx --yes skillx-sh@<version>` pin, never `npx skillx` (a different npm package). Its skill must not trigger on unrelated tasks or load/install a catalog skill without the user's explicit yes. Before changing it, run `claude plugin validate plugins/skillx --profile directory --strict` and its eval suite (`docs/claude-plugin.md`).
Expand Down
72 changes: 72 additions & 0 deletions apps/web/app/components/settings/agentkit-link-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useFetcher, useSearchParams } from "react-router";
import { Link2 } from "lucide-react";
import type { AgentKitLinkStatus } from "~/lib/agentkit/link-status";

const OUTCOME_TEXT: Record<string, { text: string; error: boolean }> = {
linked: { text: "Your AgentKit account is linked.", error: false },
denied: { text: "Linking was cancelled on agentkit.best.", error: true },
expired: { text: "The link request expired or belongs to another session. Start again.", error: true },
already_linked: { text: "This SkillX account is already linked. Unlink it first.", error: true },
taken: { text: "That AgentKit account is already linked to another SkillX account.", error: true },
failed: { text: "agentkit.best did not complete the link. Try again later.", error: true },
};

const SCOPE_TEXT: Record<string, string> = {
"skillx-kits:read": "read your library and personal kits",
"skillx-kits:write": "publish and manage your personal kits",
};

/** "Link with AgentKit" on /settings; renders nothing while the feature is off. */
export function AgentKitLinkSection({ status }: { status: AgentKitLinkStatus }) {
const [params] = useSearchParams();
const unlinkFetcher = useFetcher<{ error?: string }>();
if (!status.enabled) return null;

const outcome = OUTCOME_TEXT[params.get("agentkit") ?? ""];
const unlinking = unlinkFetcher.state !== "idle";
const handleUnlink = () => {
if (!confirm("Unlink your AgentKit account? AgentKit apps will lose access to SkillX until you link again.")) return;
unlinkFetcher.submit({}, { method: "delete", action: "/api/user/agentkit-link", encType: "application/json" });
};

return (
<section id="agentkit" className="mb-8 rounded-lg border border-sx-border bg-sx-bg-elevated p-6">
<h2 className="mb-2 flex items-center gap-2 font-mono text-lg font-semibold">
<Link2 size={18} /> AgentKit account
</h2>
{outcome && (
<p role="status" className={`mb-4 rounded-lg px-4 py-2 text-sm ${outcome.error ? "border border-red-500/40 bg-red-500/10 text-red-500" : "border border-sx-accent/30 bg-sx-accent/10 text-sx-accent"}`}>
{outcome.text}
</p>
)}
{status.linked ? (
<div className="space-y-3 text-sm">
<p className="text-sx-fg-muted">
Linked on {new Date(status.linkedAt).toLocaleDateString()}. AgentKit apps (ak, Desktop) signed in to this account can{" "}
{status.scopes.map((s) => SCOPE_TEXT[s] ?? s).join(" and ")}.
{status.unverified && " agentkit.best could not be reached to confirm the link just now."}
</p>
<button
onClick={handleUnlink}
disabled={unlinking}
className="rounded-lg border border-red-500/40 px-4 py-2 text-sm font-medium text-red-500 transition-colors hover:bg-red-500/10 disabled:opacity-50"
>
{unlinking ? "Unlinking…" : "Unlink"}
</button>
</div>
) : (
<div className="space-y-3 text-sm">
<p className="text-sx-fg-muted">
Optional. Link your agentkit.best account so ak and AgentKit Desktop can use your SkillX library and personal kits
without a SkillX API key. GitHub sign-in and API keys keep working either way.
</p>
<form method="post" action="/auth/agentkit/start">
<button type="submit" className="rounded-lg bg-sx-accent px-4 py-2 text-sm font-medium text-sx-bg transition-colors hover:bg-sx-accent-hover">
Link with AgentKit
</button>
</form>
</div>
)}
</section>
);
}
142 changes: 142 additions & 0 deletions apps/web/app/lib/agentkit/access-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* Local verification of agentkit.best access tokens (RFC 9068 `at+jwt`,
* ES256) against the AS JWKS. No introspection: the AS issues 60-second
* tokens for SkillX, so a revoked grant stops working within a minute.
*/
import { agentKitIssuer, skillxResource } from "./agentkit-config";
import type { AgentKitEnv } from "./agentkit-config";

export interface AccessTokenClaims {
sub: string;
scopes: string[];
clientId: string | null;
grantId: string | null;
exp: number;
}

export class InvalidAccessTokenError extends Error {}

const JWT_SHAPE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
/** Clock skew tolerated on `exp` and `iat`, in seconds. */
const SKEW_SECONDS = 5;
const JWKS_TTL_MS = 10 * 60_000;
/** An unknown `kid` refetches the JWKS at most this often (key rotation without a fetch storm). */
const JWKS_REFETCH_MS = 30_000;
const JWKS_TIMEOUT_MS = 5_000;

export const looksLikeJwt = (token: string) => JWT_SHAPE.test(token);

export function base64UrlEncode(bytes: Uint8Array): string {
return btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join("")).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

export function base64UrlDecode(text: string): Uint8Array<ArrayBuffer> {
return Uint8Array.from(atob(text.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
}

function decodeJson(part: string): Record<string, unknown> {
try {
const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(base64UrlDecode(part)));
if (value !== null && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
} catch {
// fall through
}
throw new InvalidAccessTokenError("malformed token");
}

interface JwksCache {
issuer: string;
keys: Map<string, CryptoKey>;
fetchedAt: number;
/** Last fetch attempt, successful or not; throttles refetches. */
attemptedAt: number;
}
let cache: JwksCache | null = null;
/** One JWKS fetch at a time per isolate. */
let inflight: Promise<JwksCache> | null = null;

/** Test hook: forget the cached JWKS. */
export function resetJwksCache(): void {
cache = null;
inflight = null;
}

async function loadJwks(issuer: string, fetcher: typeof fetch): Promise<JwksCache> {
const res = await fetcher(`${issuer}/.well-known/jwks.json`, { signal: AbortSignal.timeout(JWKS_TIMEOUT_MS) });
if (!res.ok) throw new Error(`JWKS fetch failed with HTTP ${res.status}`);
const body = (await res.json()) as { keys?: unknown };
const keys = new Map<string, CryptoKey>();
for (const jwk of Array.isArray(body.keys) ? body.keys : []) {
const k = jwk as JsonWebKey & { kid?: unknown; use?: unknown };
if (k.kty !== "EC" || k.crv !== "P-256" || typeof k.kid !== "string") continue;
if ((k.alg !== undefined && k.alg !== "ES256") || (k.use !== undefined && k.use !== "sig")) continue;
const { kty, crv, x, y } = k;
keys.set(k.kid, await crypto.subtle.importKey("jwk", { kty, crv, x, y }, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]));
}
const now = Date.now();
return { issuer, keys, fetchedAt: now, attemptedAt: now };
}

/**
* Keys of `issuer`, refetched when the cache is older than the TTL or lacks
* `kid`, at most every `JWKS_REFETCH_MS`. A failed refetch keeps serving the
* cached keys; it throws only when there is nothing cached for the issuer.
*/
async function keyFor(issuer: string, kid: string, fetcher: typeof fetch): Promise<CryptoKey | undefined> {
const now = Date.now();
const current = cache !== null && cache.issuer === issuer ? cache : null;
const wanted = current === null || now - current.fetchedAt > JWKS_TTL_MS || !current.keys.has(kid);
if (wanted && (current === null || now - current.attemptedAt > JWKS_REFETCH_MS)) {
inflight ??= loadJwks(issuer, fetcher).finally(() => {
inflight = null;
});
try {
cache = await inflight;
} catch (error) {
if (current === null) throw error;
current.attemptedAt = now;
}
}
return cache !== null && cache.issuer === issuer ? cache.keys.get(kid) : undefined;
}

/**
* Verifies signature, `typ`, `iss`, exact `aud` (the SkillX resource), `exp`
* and `iat`. Throws `InvalidAccessTokenError` for a token SkillX must refuse;
* any other error means the JWKS could not be read.
*/
export async function verifyAccessToken(env: AgentKitEnv, token: string, fetcher: typeof fetch = fetch, nowSeconds = Math.floor(Date.now() / 1000)): Promise<AccessTokenClaims> {
if (!looksLikeJwt(token)) throw new InvalidAccessTokenError("not a JWT");
const [headerPart, payloadPart, signaturePart] = token.split(".");
const header = decodeJson(headerPart);
const typ = typeof header.typ === "string" ? header.typ.toLowerCase() : "";
if (header.alg !== "ES256" || (typ !== "at+jwt" && typ !== "application/at+jwt") || typeof header.kid !== "string") {
throw new InvalidAccessTokenError("unsupported token type");
}
const issuer = agentKitIssuer(env);
const key = await keyFor(issuer, header.kid, fetcher);
if (key === undefined) throw new InvalidAccessTokenError("unknown signing key");
const signed = new TextEncoder().encode(`${headerPart}.${payloadPart}`);
let signature: Uint8Array<ArrayBuffer>;
try {
signature = base64UrlDecode(signaturePart);
} catch {
throw new InvalidAccessTokenError("malformed signature");
}
if (!(await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, signature, signed))) throw new InvalidAccessTokenError("bad signature");

const claims = decodeJson(payloadPart);
const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
if (claims.iss !== issuer || !audiences.includes(skillxResource(env))) throw new InvalidAccessTokenError("wrong issuer or audience");
if (typeof claims.exp !== "number" || claims.exp + SKEW_SECONDS <= nowSeconds) throw new InvalidAccessTokenError("expired");
if (typeof claims.iat === "number" && claims.iat - SKEW_SECONDS > nowSeconds) throw new InvalidAccessTokenError("issued in the future");
if (typeof claims.sub !== "string" || claims.sub === "") throw new InvalidAccessTokenError("no subject");
const scope = typeof claims.scope === "string" ? claims.scope : "";
return {
sub: claims.sub,
scopes: scope.split(" ").filter(Boolean),
clientId: typeof claims.client_id === "string" ? claims.client_id : null,
grantId: typeof claims.grant_id === "string" ? claims.grant_id : null,
exp: claims.exp,
};
}
50 changes: 50 additions & 0 deletions apps/web/app/lib/agentkit/agentkit-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* AgentKit identity (agentkit.best OAuth AS) as seen from SkillX: SkillX is
* a public OAuth client for "Link with AgentKit" and a resource server for
* access tokens minted for `<origin>/api`. Everything is off unless
* `SKILLX_AGENTKIT_IDENTITY` is "1" or "true"; off, SkillX behaves exactly as
* before (GitHub login, SkillX API keys).
*/

export interface AgentKitEnv {
ENVIRONMENT?: string;
SKILLX_AGENTKIT_IDENTITY?: string;
/** Issuer override for development and tests; defaults to https://agentkit.best. */
AGENTKIT_ISSUER?: string;
}

export const DEFAULT_AGENTKIT_ISSUER = "https://agentkit.best";
export const SKILLX_KITS_READ = "skillx-kits:read";
export const SKILLX_KITS_WRITE = "skillx-kits:write";
export const SKILLX_RESOURCE_SCOPES = [SKILLX_KITS_READ, SKILLX_KITS_WRITE] as const;
export function agentKitIdentityEnabled(env: AgentKitEnv): boolean {
const value = (env.SKILLX_AGENTKIT_IDENTITY ?? "").trim().toLowerCase();
return value === "1" || value === "true";
}

export function siteOrigin(env: AgentKitEnv): string {
return env.ENVIRONMENT === "development" ? "http://localhost:5173" : "https://skillx.sh";
}

export function agentKitIssuer(env: AgentKitEnv): string {
return (env.AGENTKIT_ISSUER ?? DEFAULT_AGENTKIT_ISSUER).replace(/\/+$/, "");
}

/** The RFC 8707 resource (and token `aud`) SkillX accepts. */
export const skillxResource = (env: AgentKitEnv) => `${siteOrigin(env)}/api`;
/** The Client ID Metadata Document URL, which is SkillX's client_id at the AS. */
export const skillxClientId = (env: AgentKitEnv) => `${siteOrigin(env)}/oauth/client.json`;
export const skillxRedirectUri = (env: AgentKitEnv) => `${siteOrigin(env)}/auth/agentkit/callback`;
/** Where a caller whose AgentKit account has no SkillX link is sent. */
export const linkSettingsUrl = (env: AgentKitEnv) => `${siteOrigin(env)}/settings#agentkit`;

/** RFC 9728 metadata of the SkillX resource. */
export const resourceMetadataUrl = (env: AgentKitEnv) => `${siteOrigin(env)}/.well-known/oauth-protected-resource`;
/**
* `WWW-Authenticate` for a 401: points RFC 9728 clients at the metadata only
* while it is served, so the flag off keeps the plain challenge.
*/
export const bearerChallenge = (env?: AgentKitEnv) =>
env !== undefined && agentKitIdentityEnabled(env) ? `Bearer resource_metadata="${resourceMetadataUrl(env)}"` : "Bearer";

export const asEndpoint =(env: AgentKitEnv, path: "authorize" | "token" | "revoke") => `${agentKitIssuer(env)}/oauth/${path}`;
Loading
Loading