diff --git a/CLAUDE.md b/CLAUDE.md index d970a30..42c751e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) | @@ -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:@`. 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. - **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`). diff --git a/apps/web/app/components/settings/agentkit-link-section.tsx b/apps/web/app/components/settings/agentkit-link-section.tsx new file mode 100644 index 0000000..f8541c9 --- /dev/null +++ b/apps/web/app/components/settings/agentkit-link-section.tsx @@ -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 = { + 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 = { + "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 ( +
+

+ AgentKit account +

+ {outcome && ( +

+ {outcome.text} +

+ )} + {status.linked ? ( +
+

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

+ +
+ ) : ( +
+

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

+
+ +
+
+ )} +
+ ); +} diff --git a/apps/web/app/lib/agentkit/access-token.ts b/apps/web/app/lib/agentkit/access-token.ts new file mode 100644 index 0000000..2d3e109 --- /dev/null +++ b/apps/web/app/lib/agentkit/access-token.ts @@ -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 { + return Uint8Array.from(atob(text.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)); +} + +function decodeJson(part: string): Record { + 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; + } catch { + // fall through + } + throw new InvalidAccessTokenError("malformed token"); +} + +interface JwksCache { + issuer: string; + keys: Map; + 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 | 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 { + 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(); + 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 { + 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 { + 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; + 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, + }; +} diff --git a/apps/web/app/lib/agentkit/agentkit-config.ts b/apps/web/app/lib/agentkit/agentkit-config.ts new file mode 100644 index 0000000..2659dc2 --- /dev/null +++ b/apps/web/app/lib/agentkit/agentkit-config.ts @@ -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 `/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}`; diff --git a/apps/web/app/lib/agentkit/as-client.ts b/apps/web/app/lib/agentkit/as-client.ts new file mode 100644 index 0000000..fba0813 --- /dev/null +++ b/apps/web/app/lib/agentkit/as-client.ts @@ -0,0 +1,68 @@ +/** + * Calls to the agentkit.best AS token and revocation endpoints as the public + * client SkillX (no secret: CIMD client_id plus PKCE). Every response is + * reduced to what SkillX needs; tokens are never logged. + */ +import { asEndpoint, skillxClientId, skillxRedirectUri, skillxResource } from "./agentkit-config"; +import type { AgentKitEnv } from "./agentkit-config"; + +export interface TokenSet { + accessToken: string; + refreshToken: string | null; + scope: string | null; +} + +/** The AS refused the grant (`invalid_grant`): the link is gone. */ +export class GrantRevokedError extends Error {} +/** The AS could not be reached or answered something unexpected: retry later. */ +export class AuthServerUnavailableError extends Error {} + +const TIMEOUT_MS = 8_000; + +async function tokenRequest(env: AgentKitEnv, fields: Record, fetcher: typeof fetch): Promise { + let res: Response; + try { + res = await fetcher(asEndpoint(env, "token"), { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + body: new URLSearchParams({ ...fields, client_id: skillxClientId(env), resource: skillxResource(env) }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + } catch (error) { + throw new AuthServerUnavailableError(error instanceof Error ? error.message : String(error)); + } + const body = (await res.json().catch(() => ({}))) as Record; + if (res.ok && typeof body.access_token === "string") { + return { + accessToken: body.access_token, + refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : null, + scope: typeof body.scope === "string" ? body.scope : null, + }; + } + if (res.status >= 400 && res.status < 500 && body.error === "invalid_grant") throw new GrantRevokedError("invalid_grant"); + throw new AuthServerUnavailableError(`token endpoint answered HTTP ${res.status}${typeof body.error === "string" ? ` ${body.error}` : ""}`); +} + +export function exchangeCode(env: AgentKitEnv, code: string, codeVerifier: string, fetcher: typeof fetch = fetch): Promise { + return tokenRequest(env, { grant_type: "authorization_code", code, code_verifier: codeVerifier, redirect_uri: skillxRedirectUri(env) }, fetcher); +} + +export function refreshGrant(env: AgentKitEnv, refreshToken: string, fetcher: typeof fetch = fetch): Promise { + return tokenRequest(env, { grant_type: "refresh_token", refresh_token: refreshToken }, fetcher); +} + +/** RFC 7009 revocation; best effort, so a failure is reported, never thrown. */ +export async function revokeRefreshToken(env: AgentKitEnv, refreshToken: string, fetcher: typeof fetch = fetch): Promise { + try { + const res = await fetcher(asEndpoint(env, "revoke"), { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token: refreshToken, token_type_hint: "refresh_token", client_id: skillxClientId(env) }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + return res.ok; + } catch (error) { + console.warn("agentkit link: revocation failed:", error instanceof Error ? error.message : String(error)); + return false; + } +} diff --git a/apps/web/app/lib/agentkit/bearer-credential.ts b/apps/web/app/lib/agentkit/bearer-credential.ts new file mode 100644 index 0000000..3914e4f --- /dev/null +++ b/apps/web/app/lib/agentkit/bearer-credential.ts @@ -0,0 +1,60 @@ +/** + * An agentkit.best access token as a SkillX API credential (identity + * contract §4.2). The token's `sub` must have a live link to a SkillX account; + * the request then runs as that account with SkillX scopes translated from + * the AS scopes, so every route keeps its scoped-key rules: + * + * - `skillx-kits:read` → `library:read` + * - `skillx-kits:write` → `kits:write:@` for each active publisher the account owns + */ +import { LIBRARY_READ_SCOPE, kitsWriteScope } from "~/lib/auth/api-key-scopes"; +import { releaseError } from "~/lib/releases/release-http"; +import { InvalidAccessTokenError, verifyAccessToken } from "./access-token"; +import { SKILLX_KITS_READ, SKILLX_KITS_WRITE, agentKitIssuer, linkSettingsUrl } from "./agentkit-config"; +import { ensureLinkLive, LinkUnavailableError } from "./link-liveness"; +import type { LinkEnv } from "./link-liveness"; +import { linkOfSubject } from "./link-store"; + +export interface AgentKitCredential { + userId: string; + scopes: string[]; +} + +async function ownedHandles(db: D1Database, userId: string): Promise { + const { results } = await db + .prepare("SELECT handle FROM publishers WHERE owner_user_id = ? AND state = 'active' ORDER BY handle") + .bind(userId) + .all<{ handle: string }>(); + return results.map((row) => row.handle); +} + +export async function skillxScopesFor(db: D1Database, userId: string, granted: readonly string[]): Promise { + const scopes: string[] = []; + if (granted.includes(SKILLX_KITS_READ)) scopes.push(LIBRARY_READ_SCOPE); + if (granted.includes(SKILLX_KITS_WRITE)) scopes.push(...(await ownedHandles(db, userId)).map(kitsWriteScope)); + return scopes; +} + +const unavailable = () => releaseError(503, "agentkit_unavailable", "agentkit.best could not be reached to verify the token; retry shortly"); + +export async function resolveAgentKitBearer(env: LinkEnv, token: string, fetcher: typeof fetch = fetch): Promise { + let claims; + try { + claims = await verifyAccessToken(env, token, fetcher); + } catch (error) { + if (error instanceof InvalidAccessTokenError) throw releaseError(401, "invalid_token", "the AgentKit access token is not valid for SkillX"); + console.warn("agentkit bearer: JWKS unavailable:", error instanceof Error ? error.message : String(error)); + throw unavailable(); + } + const notLinked = () => + releaseError(403, "account_not_linked", `this AgentKit account is not linked to a SkillX account; link it at ${linkSettingsUrl(env)}`); + const link = await linkOfSubject(env.DB, agentKitIssuer(env), claims.sub); + if (link === null) throw notLinked(); + try { + if ((await ensureLinkLive(env, link, fetcher)) === "revoked") throw notLinked(); + } catch (error) { + if (error instanceof LinkUnavailableError) throw unavailable(); + throw error; + } + return { userId: link.userId, scopes: await skillxScopesFor(env.DB, link.userId, claims.scopes) }; +} diff --git a/apps/web/app/lib/agentkit/link-crypto.ts b/apps/web/app/lib/agentkit/link-crypto.ts new file mode 100644 index 0000000..b7a8a61 --- /dev/null +++ b/apps/web/app/lib/agentkit/link-crypto.ts @@ -0,0 +1,44 @@ +/** + * AES-GCM sealing of the stored AgentKit refresh token. The key is derived + * from `BETTER_AUTH_SECRET` with HKDF under its own label, so no new secret + * is needed. Rotating that secret makes stored tokens unreadable, which reads + * as a revoked link: the user links again. + */ +import { base64UrlDecode, base64UrlEncode } from "./access-token"; + +const INFO = new TextEncoder().encode("skillx agentkit-link refresh-token v1"); + +async function linkKey(secret: string): Promise { + const material = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), "HKDF", false, ["deriveKey"]); + return crypto.subtle.deriveKey( + { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(32), info: INFO }, + material, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +/** `v1..`, base64url; the user id is bound as associated data. */ +export async function sealRefreshToken(secret: string, userId: string, token: string): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const data = new TextEncoder().encode(token); + const sealed = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(userId) }, await linkKey(secret), data); + return `v1.${base64UrlEncode(iv)}.${base64UrlEncode(new Uint8Array(sealed))}`; +} + +/** The token, or null when it cannot be opened (other secret, other user, tampered). */ +export async function openRefreshToken(secret: string, userId: string, sealed: string): Promise { + const [version, iv, body] = sealed.split("."); + if (version !== "v1" || !iv || !body) return null; + try { + const plain = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: base64UrlDecode(iv), additionalData: new TextEncoder().encode(userId) }, + await linkKey(secret), + base64UrlDecode(body), + ); + return new TextDecoder().decode(plain); + } catch { + return null; + } +} diff --git a/apps/web/app/lib/agentkit/link-flow.ts b/apps/web/app/lib/agentkit/link-flow.ts new file mode 100644 index 0000000..76eee1d --- /dev/null +++ b/apps/web/app/lib/agentkit/link-flow.ts @@ -0,0 +1,105 @@ +/** + * "Link with AgentKit" (identity contract §4.1): a signed-in SkillX user + * authorizes SkillX at agentkit.best (auth code + S256 PKCE, resource + * `/api`); the callback stores the token's `sub` on the account with + * the encrypted refresh token. GitHub login is untouched. + * + * The PKCE verifier lives in KV for 10 minutes under the random `state`, + * bound to the account that started the flow, and is consumed once: a + * callback in another account's session (login CSRF) is refused. + */ +import { base64UrlEncode, verifyAccessToken } from "./access-token"; +import { SKILLX_RESOURCE_SCOPES, agentKitIssuer, asEndpoint, skillxClientId, skillxRedirectUri, skillxResource } from "./agentkit-config"; +import { exchangeCode, revokeRefreshToken } from "./as-client"; +import { openRefreshToken, sealRefreshToken } from "./link-crypto"; +import type { LinkEnv } from "./link-liveness"; +import { deleteLink, insertLink, linkOfSubject, linkOfUser } from "./link-store"; + +export interface LinkFlowEnv extends LinkEnv { + KV: KVNamespace; +} + +const STATE_TTL_SECONDS = 600; +const stateKey = (state: string) => `agentkit-link:state:${state}`; + +/** The outcome shown on /settings as `?agentkit=`. */ +export type LinkOutcome = "linked" | "denied" | "expired" | "already_linked" | "taken" | "failed"; + +const randomToken = () => base64UrlEncode(crypto.getRandomValues(new Uint8Array(32))); + +async function challengeOf(verifier: string): Promise { + return base64UrlEncode(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))); +} + +/** The authorize URL to send the user to, or null when the account is already linked. */ +export async function startLink(env: LinkFlowEnv, userId: string): Promise { + if ((await linkOfUser(env.DB, userId)) !== null) return null; + const state = randomToken(); + const verifier = randomToken(); + await env.KV.put(stateKey(state), JSON.stringify({ userId, verifier }), { expirationTtl: STATE_TTL_SECONDS }); + const url = new URL(asEndpoint(env, "authorize")); + url.search = new URLSearchParams({ + response_type: "code", + client_id: skillxClientId(env), + redirect_uri: skillxRedirectUri(env), + resource: skillxResource(env), + scope: SKILLX_RESOURCE_SCOPES.join(" "), + code_challenge: await challengeOf(verifier), + code_challenge_method: "S256", + state, + }).toString(); + return url.toString(); +} + +/** Handles the callback query for the signed-in `userId`. */ +export async function completeLink(env: LinkFlowEnv, userId: string, query: URLSearchParams, fetcher: typeof fetch = fetch): Promise { + const state = query.get("state") ?? ""; + const stored = state === "" ? null : await env.KV.get<{ userId: string; verifier: string }>(stateKey(state), "json"); + if (stored === null || stored.userId !== userId) return "expired"; + await env.KV.delete(stateKey(state)); + if (query.get("error") !== null) return "denied"; + const code = query.get("code"); + if (code === null || code === "") return "failed"; + + let tokens; + let sub: string; + try { + tokens = await exchangeCode(env, code, stored.verifier, fetcher); + sub = (await verifyAccessToken(env, tokens.accessToken, fetcher)).sub; + } catch (error) { + console.warn("agentkit link: code exchange failed:", error instanceof Error ? error.message : String(error)); + return "failed"; + } + const issuer = agentKitIssuer(env); + // A refused link must not leave a live grant behind at the AS. + const refuse = async (outcome: LinkOutcome) => { + if (tokens.refreshToken !== null) await revokeRefreshToken(env, tokens.refreshToken, fetcher); + return outcome; + }; + // Without a refresh token SkillX could never re-validate the link, which would vanish after a minute. + if (tokens.refreshToken === null) return "failed"; + if ((await linkOfUser(env.DB, userId)) !== null) return refuse("already_linked"); + if ((await linkOfSubject(env.DB, issuer, sub)) !== null) return refuse("taken"); + + const now = Date.now(); + const inserted = await insertLink(env.DB, { + userId, + issuer, + sub, + scopes: tokens.scope ?? SKILLX_RESOURCE_SCOPES.join(" "), + refreshTokenEnc: await sealRefreshToken(env.BETTER_AUTH_SECRET, userId, tokens.refreshToken), + linkedAt: now, + validatedAt: now, + }); + return inserted ? "linked" : refuse("taken"); +} + +/** Revokes SkillX's grant at the AS (best effort) and removes the link. */ +export async function unlink(env: LinkEnv, userId: string, fetcher: typeof fetch = fetch): Promise { + const link = await linkOfUser(env.DB, userId); + if (link === null) return false; + const refreshToken = link.refreshTokenEnc === null ? null : await openRefreshToken(env.BETTER_AUTH_SECRET, userId, link.refreshTokenEnc); + if (refreshToken !== null) await revokeRefreshToken(env, refreshToken, fetcher); + await deleteLink(env.DB, userId); + return true; +} diff --git a/apps/web/app/lib/agentkit/link-liveness.ts b/apps/web/app/lib/agentkit/link-liveness.ts new file mode 100644 index 0000000..6c6cf17 --- /dev/null +++ b/apps/web/app/lib/agentkit/link-liveness.ts @@ -0,0 +1,73 @@ +/** + * Re-validation of a stored link (identity contract §2): before SkillX acts on + * a link whose last successful refresh is older than 60 s, it refreshes its own + * grant. `invalid_grant` means the user revoked SkillX in Connected apps (or + * the account is gone): the link row is deleted and the account falls back to + * SkillX's own auth. + * + * Refresh tokens rotate and are one-time (reuse revokes the grant), so one + * request claims the refresh in D1, only if no other refreshed since it read + * the row, and stores the rotated token before verifying anything; concurrent + * requests ride on that refresh. An unreachable AS never deletes a + * link; it is tolerated for `OUTAGE_GRACE_MS`, then the caller gets 503. + */ +import { verifyAccessToken } from "./access-token"; +import { AuthServerUnavailableError, GrantRevokedError, refreshGrant, revokeRefreshToken } from "./as-client"; +import type { AgentKitEnv } from "./agentkit-config"; +import { openRefreshToken, sealRefreshToken } from "./link-crypto"; +import { claimRefresh, deleteLink, linkOfUser, recordRefresh, releaseRefresh, storeRefreshToken } from "./link-store"; +import type { AgentKitLink } from "./link-store"; + +export interface LinkEnv extends AgentKitEnv { + DB: D1Database; + BETTER_AUTH_SECRET: string; +} + +export const LINK_FRESH_MS = 60_000; +const CLAIM_MS = 15_000; +const OUTAGE_GRACE_MS = 15 * 60_000; + +export class LinkUnavailableError extends Error {} + +export type Liveness = "live" | "revoked"; + +function withinGrace(link: AgentKitLink, now: number): Liveness { + if (now - link.validatedAt <= OUTAGE_GRACE_MS) return "live"; + throw new LinkUnavailableError("agentkit.best could not confirm the link"); +} + +async function revoked(env: LinkEnv, link: AgentKitLink): Promise { + await deleteLink(env.DB, link.userId); + return "revoked"; +} + +export async function ensureLinkLive(env: LinkEnv, link: AgentKitLink, fetcher: typeof fetch = fetch, now = Date.now()): Promise { + if (now - link.validatedAt <= LINK_FRESH_MS) return "live"; + if (!(await claimRefresh(env.DB, link.userId, link.validatedAt, now, now + CLAIM_MS))) { + // Another request is refreshing (for at most CLAIM_MS) or just did; only a deleted row means revoked. + return (await linkOfUser(env.DB, link.userId)) === null ? "revoked" : "live"; + } + + const refreshToken = link.refreshTokenEnc === null ? null : await openRefreshToken(env.BETTER_AUTH_SECRET, link.userId, link.refreshTokenEnc); + if (refreshToken === null) return revoked(env, link); + try { + const tokens = await refreshGrant(env, refreshToken, fetcher); + // The AS has rotated the token: keep the new one before anything else can fail. + const sealed = tokens.refreshToken === null ? null : await sealRefreshToken(env.BETTER_AUTH_SECRET, link.userId, tokens.refreshToken); + if (sealed !== null) await storeRefreshToken(env.DB, link.userId, sealed); + const claims = await verifyAccessToken(env, tokens.accessToken, fetcher, Math.floor(now / 1000)); + if (claims.sub !== link.sub) { + if (tokens.refreshToken !== null) await revokeRefreshToken(env, tokens.refreshToken, fetcher); + return revoked(env, link); + } + await recordRefresh(env.DB, link.userId, null, tokens.scope, now); + return "live"; + } catch (error) { + if (error instanceof GrantRevokedError) return revoked(env, link); + await releaseRefresh(env.DB, link.userId); + if (error instanceof AuthServerUnavailableError) return withinGrace(link, now); + // A token the AS just issued that SkillX cannot verify (JWKS unreachable, wrong audience): no deletion. + console.warn("agentkit link: refresh not verifiable:", error instanceof Error ? error.message : String(error)); + return withinGrace(link, now); + } +} diff --git a/apps/web/app/lib/agentkit/link-status.ts b/apps/web/app/lib/agentkit/link-status.ts new file mode 100644 index 0000000..ddfa5ac --- /dev/null +++ b/apps/web/app/lib/agentkit/link-status.ts @@ -0,0 +1,24 @@ +/** What /settings shows about a user's AgentKit link; a revoked link is detected and removed here too. */ +import { agentKitIdentityEnabled } from "./agentkit-config"; +import { ensureLinkLive, LinkUnavailableError } from "./link-liveness"; +import type { LinkEnv } from "./link-liveness"; +import { linkOfUser } from "./link-store"; + +export type AgentKitLinkStatus = + | { enabled: false } + | { enabled: true; linked: false } + | { enabled: true; linked: true; linkedAt: number; scopes: string[]; unverified: boolean }; + +export async function agentKitLinkStatus(env: LinkEnv, userId: string, fetcher: typeof fetch = fetch): Promise { + if (!agentKitIdentityEnabled(env)) return { enabled: false }; + const link = await linkOfUser(env.DB, userId); + if (link === null) return { enabled: true, linked: false }; + let unverified = false; + try { + if ((await ensureLinkLive(env, link, fetcher)) === "revoked") return { enabled: true, linked: false }; + } catch (error) { + if (!(error instanceof LinkUnavailableError)) throw error; + unverified = true; + } + return { enabled: true, linked: true, linkedAt: link.linkedAt, scopes: link.scopes.split(" ").filter(Boolean), unverified }; +} diff --git a/apps/web/app/lib/agentkit/link-store.ts b/apps/web/app/lib/agentkit/link-store.ts new file mode 100644 index 0000000..d4ea9ac --- /dev/null +++ b/apps/web/app/lib/agentkit/link-store.ts @@ -0,0 +1,86 @@ +/** D1 access for `agentkit_links` (see db/agentkit-link-schema.ts). */ + +export interface AgentKitLink { + userId: string; + issuer: string; + sub: string; + scopes: string; + refreshTokenEnc: string | null; + linkedAt: number; + validatedAt: number; +} + +interface LinkRow { + user_id: string; + issuer: string; + agentkit_sub: string; + scopes: string; + refresh_token_enc: string | null; + linked_at: number; + validated_at: number; +} + +const COLUMNS = "user_id, issuer, agentkit_sub, scopes, refresh_token_enc, linked_at, validated_at"; + +const toLink = (row: LinkRow | null): AgentKitLink | null => + row === null + ? null + : { + userId: row.user_id, + issuer: row.issuer, + sub: row.agentkit_sub, + scopes: row.scopes, + refreshTokenEnc: row.refresh_token_enc, + linkedAt: row.linked_at, + validatedAt: row.validated_at, + }; + +export async function linkOfUser(db: D1Database, userId: string): Promise { + return toLink(await db.prepare(`SELECT ${COLUMNS} FROM agentkit_links WHERE user_id = ?`).bind(userId).first()); +} + +export async function linkOfSubject(db: D1Database, issuer: string, sub: string): Promise { + return toLink(await db.prepare(`SELECT ${COLUMNS} FROM agentkit_links WHERE issuer = ? AND agentkit_sub = ?`).bind(issuer, sub).first()); +} + +/** Inserts a link; false when either side is already linked (the unique keys decide, so races are safe). */ +export async function insertLink(db: D1Database, link: AgentKitLink): Promise { + const result = await db + .prepare(`INSERT INTO agentkit_links (${COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`) + .bind(link.userId, link.issuer, link.sub, link.scopes, link.refreshTokenEnc, link.linkedAt, link.validatedAt) + .run(); + return (result.meta?.changes ?? 0) === 1; +} + +export async function deleteLink(db: D1Database, userId: string): Promise { + await db.prepare("DELETE FROM agentkit_links WHERE user_id = ?").bind(userId).run(); +} + +/** + * Claims the one refresh of this link until `until`. False when another + * request holds it, or already refreshed since `validatedAt` was read: the + * refresh token that read saw may be spent. + */ +export async function claimRefresh(db: D1Database, userId: string, validatedAt: number, now: number, until: number): Promise { + const result = await db + .prepare("UPDATE agentkit_links SET refreshing_until = ? WHERE user_id = ? AND validated_at = ? AND (refreshing_until IS NULL OR refreshing_until < ?)") + .bind(until, userId, validatedAt, now) + .run(); + return (result.meta?.changes ?? 0) === 1; +} + +/** Stores a rotated refresh token as soon as the AS issues it, keeping the claim. */ +export async function storeRefreshToken(db: D1Database, userId: string, refreshTokenEnc: string): Promise { + await db.prepare("UPDATE agentkit_links SET refresh_token_enc = ? WHERE user_id = ?").bind(refreshTokenEnc, userId).run(); +} + +export async function recordRefresh(db: D1Database, userId: string, refreshTokenEnc: string | null, scopes: string | null, now: number): Promise { + await db + .prepare("UPDATE agentkit_links SET refresh_token_enc = COALESCE(?, refresh_token_enc), scopes = COALESCE(?, scopes), validated_at = ?, refreshing_until = NULL WHERE user_id = ?") + .bind(refreshTokenEnc, scopes, now, userId) + .run(); +} + +export async function releaseRefresh(db: D1Database, userId: string): Promise { + await db.prepare("UPDATE agentkit_links SET refreshing_until = NULL WHERE user_id = ?").bind(userId).run(); +} diff --git a/apps/web/app/lib/auth/scoped-viewer.ts b/apps/web/app/lib/auth/scoped-viewer.ts index ec673bd..01f82c6 100644 --- a/apps/web/app/lib/auth/scoped-viewer.ts +++ b/apps/web/app/lib/auth/scoped-viewer.ts @@ -1,5 +1,7 @@ /** * The credential of an API request: an unscoped account key, a scoped key, + * an agentkit.best access token of a linked account (while + * `SKILLX_AGENTKIT_IDENTITY` is on; see lib/agentkit/bearer-credential.ts), * or (without an `Authorization` header) the web session. * * A present `Authorization` header must carry a valid, unrevoked key as @@ -15,6 +17,9 @@ import { authenticateRequest } from "./authenticate-request"; import { hashScopedApiKey } from "./api-key-utils"; import { parseStoredScopes } from "./api-key-scopes"; import { getDb } from "~/lib/db"; +import { looksLikeJwt } from "~/lib/agentkit/access-token"; +import { agentKitIdentityEnabled } from "~/lib/agentkit/agentkit-config"; +import { resolveAgentKitBearer } from "~/lib/agentkit/bearer-credential"; import { releaseError } from "~/lib/releases/release-http"; import type { ReleaseHttpError } from "~/lib/releases/release-http"; @@ -71,6 +76,12 @@ export async function resolveRequestCredential( const owner = await authenticateApiKey(getDb(env.DB), key); if (owner !== null) return { userId: owner.userId, keyId: owner.keyId, scopes: null }; const scoped = await authenticateScopedKey(env.DB, key); + if (scoped === null && agentKitIdentityEnabled(env) && looksLikeJwt(key)) { + // An agentkit.best access token runs as the linked account with translated scopes. + const agentKit = await resolveAgentKitBearer(env, key); + if (!accepts(agentKit.scopes)) throw invalidApiKey(); + return { userId: agentKit.userId, keyId: null, scopes: agentKit.scopes }; + } if (scoped === null || !accepts(scoped.scopes)) throw invalidApiKey(); await recordKeyUse(env.DB, scoped.keyId); return scoped; diff --git a/apps/web/app/lib/db/agentkit-link-schema.ts b/apps/web/app/lib/db/agentkit-link-schema.ts new file mode 100644 index 0000000..cb815c0 --- /dev/null +++ b/apps/web/app/lib/db/agentkit-link-schema.ts @@ -0,0 +1,25 @@ +import { sqliteTable, text, integer, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { user } from "./schema"; + +// AgentKit account links: one agentkit.best account (`iss`, `sub`) per SkillX +// account and the reverse. The refresh token of SkillX's own grant is stored +// AES-GCM encrypted; the link is re-validated by refreshing it (see +// lib/agentkit/link-liveness.ts) and the row is deleted on unlink or revoke. +export const agentkitLinks = sqliteTable( + "agentkit_links", + { + user_id: text("user_id") + .primaryKey() + .references(() => user.id, { onDelete: "cascade" }), + issuer: text("issuer").notNull(), + agentkit_sub: text("agentkit_sub").notNull(), + scopes: text("scopes").notNull(), // space-separated, as granted + refresh_token_enc: text("refresh_token_enc"), + linked_at: integer("linked_at", { mode: "timestamp_ms" }).notNull(), + // Last successful refresh (or the link itself): the link is trusted for 60 s after it. + validated_at: integer("validated_at", { mode: "timestamp_ms" }).notNull(), + // A refresh in flight until this time; refresh tokens are one-time, so only one request refreshes. + refreshing_until: integer("refreshing_until", { mode: "timestamp_ms" }), + }, + (table) => [uniqueIndex("idx_agentkit_links_subject").on(table.issuer, table.agentkit_sub)] +); diff --git a/apps/web/app/lib/db/schemas.ts b/apps/web/app/lib/db/schemas.ts index 0ab9ee1..e908d6c 100644 --- a/apps/web/app/lib/db/schemas.ts +++ b/apps/web/app/lib/db/schemas.ts @@ -12,3 +12,4 @@ export * from "./registry-publishing-schema"; export * from "./entitlements-schema"; export * from "./collections-schema"; export * from "./personal-kits-schema"; +export * from "./agentkit-link-schema"; diff --git a/apps/web/app/lib/personal-kits/kit-http.ts b/apps/web/app/lib/personal-kits/kit-http.ts index 551e90e..2313dee 100644 --- a/apps/web/app/lib/personal-kits/kit-http.ts +++ b/apps/web/app/lib/personal-kits/kit-http.ts @@ -233,7 +233,7 @@ export async function serveKit( } if (error instanceof ReleaseHttpError) { const retry = (error as { retryAfter?: number }).retryAfter; - return errorResponse(error, retry === undefined ? failureHeaders(error) : { "Retry-After": String(retry) }); + return errorResponse(error, retry === undefined ? failureHeaders(error, env) : { "Retry-After": String(retry) }); } console.error("personal kit request failed:", error instanceof Error ? error.message : String(error)); return errorResponse(releaseError(500, "kit.internal_error", "personal kit request failed")); diff --git a/apps/web/app/lib/releases/release-read.ts b/apps/web/app/lib/releases/release-read.ts index 57f7794..c5dd121 100644 --- a/apps/web/app/lib/releases/release-read.ts +++ b/apps/web/app/lib/releases/release-read.ts @@ -13,6 +13,7 @@ * later quarantine cannot recall copies already held by browsers or proxies; * it only stops new fetches from this API. Private answers are never stored. */ +import { bearerChallenge } from "~/lib/agentkit/agentkit-config"; import { RELEASE_KEY_ID_PATTERN, RELEASE_SIGNATURE_ALGORITHM, validateReleaseManifest } from "@skillx/contracts"; import type { SkillxReleaseManifest } from "@skillx/contracts"; import { validatePath } from "@skillx/skill-tree"; @@ -80,7 +81,7 @@ export async function serveReleaseRead( return await handler({ db: env.DB, bucket: env.R2, userId, keyId, request }); } catch (error) { if (error instanceof ReleaseHttpError) { - return errorResponse(error, error.status === 401 ? { "WWW-Authenticate": "Bearer" } : {}); + return errorResponse(error, error.status === 401 ? { "WWW-Authenticate": bearerChallenge(env) } : {}); } console.error("release read failed:", error instanceof Error ? error.message : String(error)); return errorResponse(releaseError(500, "release.internal_error", "release read failed")); diff --git a/apps/web/app/lib/releases/release-state-http.ts b/apps/web/app/lib/releases/release-state-http.ts index ea2de92..5d6d75c 100644 --- a/apps/web/app/lib/releases/release-state-http.ts +++ b/apps/web/app/lib/releases/release-state-http.ts @@ -19,6 +19,8 @@ * containment never depends on it. * Every answer is `no-store`. */ +import { bearerChallenge } from "~/lib/agentkit/agentkit-config"; +import type { AgentKitEnv } from "~/lib/agentkit/agentkit-config"; import { RELEASE_STATES, RELEASE_STATUS_REASON_MAX_BYTES, isReleaseState } from "@skillx/contracts"; import { parsePackageIdentity, parseReleaseVersion } from "./release-access"; import { ReleaseHttpError, errorResponse, notFoundResponse, releaseError } from "./release-http"; @@ -167,10 +169,10 @@ export async function resolveLimitedActor(request: Request, env: ReleaseStateEnv return (await resolveLimitedScopedActor(request, env)).actor; } -/** `Retry-After` for a 429, `WWW-Authenticate` for a 401. */ -export function failureHeaders(error: ReleaseHttpError): Record { +/** `Retry-After` for a 429, `WWW-Authenticate` for a 401 (with RFC 9728 metadata where `env` accepts AgentKit tokens). */ +export function failureHeaders(error: ReleaseHttpError, env?: AgentKitEnv): Record { if (error instanceof RateLimitedError) return { "Retry-After": String(error.retryAfterSeconds) }; - return error.status === 401 ? { "WWW-Authenticate": "Bearer" } : {}; + return error.status === 401 ? { "WWW-Authenticate": bearerChallenge(env) } : {}; } /** Serves one transition request; `params.handle` selects the publisher route. */ diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts index bf387f9..7005710 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -31,6 +31,11 @@ export default [ route("api/report", "routes/api.usage-report.ts"), route("api/user/api-keys", "routes/api.user-api-keys.ts"), route("api/user/interactions", "routes/api.user-interactions.ts"), + route("api/user/agentkit-link", "routes/api.user-agentkit-link.ts"), + route("auth/agentkit/:step", "routes/auth.agentkit.ts"), + route("oauth/client.json", "routes/oauth.client-json.ts"), + route(".well-known/oauth-protected-resource", "routes/well-known.oauth-protected-resource.ts", { id: "protected-resource-metadata" }), + route(".well-known/oauth-protected-resource/api", "routes/well-known.oauth-protected-resource.ts", { id: "protected-resource-metadata-api" }), // GET lists releases; POST (the action re-exported from api.v1.release-publish.ts) publishes one. route("api/v1/packages/:scope/:slug/releases", "routes/api.v1.package-releases.ts"), route("api/v1/packages/:scope/:slug/releases/:version", "routes/api.v1.release-manifest.ts", { id: "release-manifest-by-version" }), diff --git a/apps/web/app/routes/api.user-agentkit-link.ts b/apps/web/app/routes/api.user-agentkit-link.ts new file mode 100644 index 0000000..6afc087 --- /dev/null +++ b/apps/web/app/routes/api.user-agentkit-link.ts @@ -0,0 +1,29 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { agentKitIdentityEnabled } from "~/lib/agentkit/agentkit-config"; +import { unlink } from "~/lib/agentkit/link-flow"; +import { agentKitLinkStatus } from "~/lib/agentkit/link-status"; +import { crossOriginRefusal } from "~/lib/auth/same-origin"; +import { getSession } from "~/lib/auth/session-helpers"; + +const NO_STORE = { "Cache-Control": "private, no-store" }; + +/** `GET /api/user/agentkit-link`: the signed-in user's AgentKit link. */ +export async function loader({ request, context }: LoaderFunctionArgs) { + const env = context.cloudflare.env as Env; + const session = await getSession(request, env); + if (!session?.user?.id) return Response.json({ error: "Authentication required" }, { status: 401 }); + return Response.json(await agentKitLinkStatus(env, session.user.id), { headers: NO_STORE }); +} + +/** `DELETE /api/user/agentkit-link`: unlink; SkillX's grant is revoked at agentkit.best. */ +export async function action({ request, context }: ActionFunctionArgs) { + const env = context.cloudflare.env as Env; + if (request.method !== "DELETE") return Response.json({ error: "Method not allowed" }, { status: 405 }); + const crossOrigin = crossOriginRefusal(request); + if (crossOrigin) return crossOrigin; + const session = await getSession(request, env); + if (!session?.user?.id) return Response.json({ error: "Authentication required" }, { status: 401 }); + // Unlinking stays possible with the flag off, so turning the feature off never strands a link. + const removed = await unlink(env, session.user.id); + return Response.json({ enabled: agentKitIdentityEnabled(env), linked: false, removed }, { headers: NO_STORE }); +} diff --git a/apps/web/app/routes/auth.agentkit.ts b/apps/web/app/routes/auth.agentkit.ts new file mode 100644 index 0000000..382d874 --- /dev/null +++ b/apps/web/app/routes/auth.agentkit.ts @@ -0,0 +1,34 @@ +import { redirect } from "react-router"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { agentKitIdentityEnabled } from "~/lib/agentkit/agentkit-config"; +import { completeLink, startLink } from "~/lib/agentkit/link-flow"; +import { crossOriginRefusal } from "~/lib/auth/same-origin"; +import { getSession } from "~/lib/auth/session-helpers"; + +/** + * `POST /auth/agentkit/start` sends the signed-in user to agentkit.best to + * authorize SkillX; `GET /auth/agentkit/callback` finishes the link. Both end + * on /settings with `?agentkit=`. 404 while `SKILLX_AGENTKIT_IDENTITY` + * is off. + */ +const settings = (outcome: string) => redirect(`/settings?agentkit=${outcome}#agentkit`); + +export async function action({ request, context, params }: ActionFunctionArgs) { + const env = context.cloudflare.env as Env; + if (!agentKitIdentityEnabled(env) || params.step !== "start") return new Response("Not found", { status: 404 }); + const crossOrigin = crossOriginRefusal(request); + if (crossOrigin) return crossOrigin; + const session = await getSession(request, env); + if (!session?.user?.id) return redirect("/settings"); + const url = await startLink(env, session.user.id); + return url === null ? settings("already_linked") : redirect(url); +} + +export async function loader({ request, context, params }: LoaderFunctionArgs) { + const env = context.cloudflare.env as Env; + if (!agentKitIdentityEnabled(env)) return new Response("Not found", { status: 404 }); + if (params.step !== "callback") return redirect("/settings"); + const session = await getSession(request, env); + if (!session?.user?.id) return settings("expired"); + return settings(await completeLink(env, session.user.id, new URL(request.url).searchParams)); +} diff --git a/apps/web/app/routes/oauth.client-json.ts b/apps/web/app/routes/oauth.client-json.ts new file mode 100644 index 0000000..86dca8d --- /dev/null +++ b/apps/web/app/routes/oauth.client-json.ts @@ -0,0 +1,26 @@ +import type { LoaderFunctionArgs } from "react-router"; +import { agentKitIdentityEnabled, siteOrigin, skillxClientId, skillxRedirectUri, SKILLX_RESOURCE_SCOPES } from "~/lib/agentkit/agentkit-config"; + +/** + * `GET /oauth/client.json`: SkillX's OAuth Client ID Metadata Document for + * agentkit.best. Its URL is the client_id; a public client, so no secret. + * 404 while `SKILLX_AGENTKIT_IDENTITY` is off. + */ +export async function loader({ context }: LoaderFunctionArgs) { + const env = context.cloudflare.env as Env; + if (!agentKitIdentityEnabled(env)) return new Response("Not found", { status: 404 }); + return Response.json( + { + client_id: skillxClientId(env), + client_name: "SkillX", + client_uri: siteOrigin(env), + logo_uri: `${siteOrigin(env)}/favicon.ico`, + redirect_uris: [skillxRedirectUri(env)], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + scope: SKILLX_RESOURCE_SCOPES.join(" "), + }, + { headers: { "Cache-Control": "public, max-age=300" } }, + ); +} diff --git a/apps/web/app/routes/settings.tsx b/apps/web/app/routes/settings.tsx index 2dc1e2c..b6b16cc 100644 --- a/apps/web/app/routes/settings.tsx +++ b/apps/web/app/routes/settings.tsx @@ -9,21 +9,25 @@ import { getDb } from "~/lib/db"; import { listOwnedActivePublishers, listUserApiKeys } from "~/lib/auth/user-api-keys"; import type { CreateKeyRequest } from "~/lib/auth/api-key-form"; import { useState } from "react"; +import { AgentKitLinkSection } from "../components/settings/agentkit-link-section"; +import { agentKitLinkStatus } from "~/lib/agentkit/link-status"; +import type { AgentKitLinkStatus } from "~/lib/agentkit/link-status"; export async function loader({ request, context }: LoaderFunctionArgs) { const env = context.cloudflare.env as Env; const session = await getSession(request, env); if (!session?.user?.id) { - return { isAuthenticated: false, keys: [], publishers: [] }; + return { isAuthenticated: false, keys: [], publishers: [], agentkit: { enabled: false } as AgentKitLinkStatus }; } const db = getDb(env.DB); - const [keys, publishers] = await Promise.all([ + const [keys, publishers, agentkit] = await Promise.all([ listUserApiKeys(db, session.user.id), listOwnedActivePublishers(db, session.user.id), + agentKitLinkStatus(env, session.user.id), ]); - return { isAuthenticated: true, keys, publishers }; + return { isAuthenticated: true, keys, publishers, agentkit }; } interface CreateKeyResponse { @@ -114,6 +118,8 @@ export default function Settings() { /> )} + +

API Keys

diff --git a/apps/web/app/routes/well-known.oauth-protected-resource.ts b/apps/web/app/routes/well-known.oauth-protected-resource.ts new file mode 100644 index 0000000..ae8f92c --- /dev/null +++ b/apps/web/app/routes/well-known.oauth-protected-resource.ts @@ -0,0 +1,23 @@ +import type { LoaderFunctionArgs } from "react-router"; +import { agentKitIdentityEnabled, agentKitIssuer, skillxResource, SKILLX_RESOURCE_SCOPES } from "~/lib/agentkit/agentkit-config"; + +/** + * RFC 9728 protected resource metadata for `/api`, served at + * `/.well-known/oauth-protected-resource` and its path-suffixed form + * `/.well-known/oauth-protected-resource/api`. 404 while + * `SKILLX_AGENTKIT_IDENTITY` is off. + */ +export async function loader({ context }: LoaderFunctionArgs) { + const env = context.cloudflare.env as Env; + if (!agentKitIdentityEnabled(env)) return new Response("Not found", { status: 404 }); + return Response.json( + { + resource: skillxResource(env), + resource_name: "SkillX", + authorization_servers: [agentKitIssuer(env)], + scopes_supported: [...SKILLX_RESOURCE_SCOPES], + bearer_methods_supported: ["header"], + }, + { headers: { "Cache-Control": "public, max-age=300" } }, + ); +} diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts index d4f0a11..972f0d1 100644 --- a/apps/web/drizzle.config.ts +++ b/apps/web/drizzle.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ "./app/lib/db/entitlements-schema.ts", "./app/lib/db/collections-schema.ts", "./app/lib/db/personal-kits-schema.ts", + "./app/lib/db/agentkit-link-schema.ts", ], dialect: "sqlite", }); diff --git a/apps/web/drizzle/migrations/0021_agentkit_links.sql b/apps/web/drizzle/migrations/0021_agentkit_links.sql new file mode 100644 index 0000000..4a2557e --- /dev/null +++ b/apps/web/drizzle/migrations/0021_agentkit_links.sql @@ -0,0 +1,22 @@ +-- AgentKit account links (Link with AgentKit, issue #81). +-- +-- Strictly additive: one new table, no existing row is read or changed. Code that predates +-- this migration never touches it, and the identity code is off unless +-- SKILLX_AGENTKIT_IDENTITY is "1", so either deploy order works. +-- +-- Rollback (take a D1 time-travel bookmark first; links are lost and users link again): +-- DROP TABLE `agentkit_links`; +-- DELETE FROM d1_migrations WHERE name = '0021_agentkit_links.sql'; +CREATE TABLE `agentkit_links` ( + `user_id` text PRIMARY KEY NOT NULL, + `issuer` text NOT NULL, + `agentkit_sub` text NOT NULL, + `scopes` text NOT NULL, + `refresh_token_enc` text, + `linked_at` integer NOT NULL, + `validated_at` integer NOT NULL, + `refreshing_until` integer, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_agentkit_links_subject` ON `agentkit_links` (`issuer`,`agentkit_sub`); \ No newline at end of file diff --git a/apps/web/drizzle/migrations/meta/0021_snapshot.json b/apps/web/drizzle/migrations/meta/0021_snapshot.json new file mode 100644 index 0000000..69659ee --- /dev/null +++ b/apps/web/drizzle/migrations/meta/0021_snapshot.json @@ -0,0 +1,3153 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "75ccd28d-2427-492a-8c5b-8c9f9f6755c4", + "prevId": "26896f4e-7661-4436-aa54-78c5287a172e", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Default'" + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + }, + "idx_api_keys_user": { + "name": "idx_api_keys_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_api_keys_hash": { + "name": "idx_api_keys_hash", + "columns": [ + "key_hash" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "favorites": { + "name": "favorites", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_favorites_pk": { + "name": "idx_favorites_pk", + "columns": [ + "user_id", + "skill_id" + ], + "isUnique": true + }, + "idx_favorites_skill": { + "name": "idx_favorites_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "favorites_skill_id_skills_id_fk": { + "name": "favorites_skill_id_skills_id_fk", + "tableFrom": "favorites", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "installs": { + "name": "installs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_installs_skill": { + "name": "idx_installs_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + }, + "idx_installs_user": { + "name": "idx_installs_user", + "columns": [ + "skill_id", + "user_id" + ], + "isUnique": true, + "where": "user_id IS NOT NULL" + }, + "idx_installs_device": { + "name": "idx_installs_device", + "columns": [ + "skill_id", + "device_id" + ], + "isUnique": true, + "where": "device_id IS NOT NULL" + } + }, + "foreignKeys": { + "installs_skill_id_skills_id_fk": { + "name": "installs_skill_id_skills_id_fk", + "tableFrom": "installs", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ratings": { + "name": "ratings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_agent": { + "name": "is_agent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_ratings_user_skill": { + "name": "idx_ratings_user_skill", + "columns": [ + "user_id", + "skill_id" + ], + "isUnique": true + }, + "idx_ratings_skill": { + "name": "idx_ratings_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ratings_skill_id_skills_id_fk": { + "name": "ratings_skill_id_skills_id_fk", + "tableFrom": "ratings", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_agent": { + "name": "is_agent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_reviews_skill": { + "name": "idx_reviews_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + }, + "idx_reviews_user": { + "name": "idx_reviews_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "reviews_skill_id_skills_id_fk": { + "name": "reviews_skill_id_skills_id_fk", + "tableFrom": "reviews", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skill_aliases": { + "name": "skill_aliases", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'legacy'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_skill_aliases_skill": { + "name": "idx_skill_aliases_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "skill_aliases_skill_id_skills_id_fk": { + "name": "skill_aliases_skill_id_skills_id_fk", + "tableFrom": "skill_aliases", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skills": { + "name": "skills", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_repo": { + "name": "source_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "install_command": { + "name": "install_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'1.0.0'" + }, + "is_paid": { + "name": "is_paid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "avg_rating": { + "name": "avg_rating", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "rating_count": { + "name": "rating_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "github_stars": { + "name": "github_stars", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "install_count": { + "name": "install_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "composite_score": { + "name": "composite_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "bayesian_rating": { + "name": "bayesian_rating", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "trending_score": { + "name": "trending_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "favorite_count": { + "name": "favorite_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "upvote_count": { + "name": "upvote_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "downvote_count": { + "name": "downvote_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "net_votes": { + "name": "net_votes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "scripts": { + "name": "scripts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compatibility_json": { + "name": "compatibility_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fts_content": { + "name": "fts_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "risk_label": { + "name": "risk_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "skills_slug_unique": { + "name": "skills_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "idx_skills_category": { + "name": "idx_skills_category", + "columns": [ + "category" + ], + "isUnique": false + }, + "idx_skills_author": { + "name": "idx_skills_author", + "columns": [ + "author" + ], + "isUnique": false + }, + "idx_skills_avg_rating": { + "name": "idx_skills_avg_rating", + "columns": [ + "avg_rating" + ], + "isUnique": false + }, + "idx_skills_composite_score": { + "name": "idx_skills_composite_score", + "columns": [ + "composite_score" + ], + "isUnique": false + }, + "idx_skills_trending_score": { + "name": "idx_skills_trending_score", + "columns": [ + "trending_score" + ], + "isUnique": false + }, + "idx_skills_net_votes": { + "name": "idx_skills_net_votes", + "columns": [ + "net_votes" + ], + "isUnique": false + }, + "idx_skills_source_identity": { + "name": "idx_skills_source_identity", + "columns": [ + "source_repo", + "source_path" + ], + "isUnique": true, + "where": "source_repo IS NOT NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_stats": { + "name": "usage_stats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_usage_skill": { + "name": "idx_usage_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + }, + "idx_usage_created": { + "name": "idx_usage_created", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "usage_stats_skill_id_skills_id_fk": { + "name": "usage_stats_skill_id_skills_id_fk", + "tableFrom": "usage_stats", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "votes": { + "name": "votes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vote_type": { + "name": "vote_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_votes_user_skill": { + "name": "idx_votes_user_skill", + "columns": [ + "user_id", + "skill_id" + ], + "isUnique": true + }, + "idx_votes_skill": { + "name": "idx_votes_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "votes_skill_id_skills_id_fk": { + "name": "votes_skill_id_skills_id_fk", + "tableFrom": "votes", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "package_releases": { + "name": "package_releases", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "package_id": { + "name": "package_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stable'" + }, + "release_state": { + "name": "release_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "manifest_schema_version": { + "name": "manifest_schema_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "artifact_key": { + "name": "artifact_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "artifact_digest": { + "name": "artifact_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "artifact_size": { + "name": "artifact_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "release_digest": { + "name": "release_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tree_size": { + "name": "tree_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_manifest_key": { + "name": "signed_manifest_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_provenance": { + "name": "source_provenance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compatibility_json": { + "name": "compatibility_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "yanked_at": { + "name": "yanked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quarantined_at": { + "name": "quarantined_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_sequence": { + "name": "status_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_issued_at": { + "name": "status_issued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_key": { + "name": "status_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exec_summary_json": { + "name": "exec_summary_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_package_releases_version": { + "name": "idx_package_releases_version", + "columns": [ + "package_id", + "version" + ], + "isUnique": true + }, + "idx_package_releases_state": { + "name": "idx_package_releases_state", + "columns": [ + "release_state" + ], + "isUnique": false + }, + "idx_package_releases_digest": { + "name": "idx_package_releases_digest", + "columns": [ + "release_digest" + ], + "isUnique": false + } + }, + "foreignKeys": { + "package_releases_package_id_packages_id_fk": { + "name": "package_releases_package_id_packages_id_fk", + "tableFrom": "package_releases", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "packages": { + "name": "packages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_packages_name": { + "name": "idx_packages_name", + "columns": [ + "name" + ], + "isUnique": true + }, + "idx_packages_publisher": { + "name": "idx_packages_publisher", + "columns": [ + "publisher_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "packages_publisher_id_publishers_id_fk": { + "name": "packages_publisher_id_publishers_id_fk", + "tableFrom": "packages", + "tableTo": "publishers", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "publishers": { + "name": "publishers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "publishers_handle_unique": { + "name": "publishers_handle_unique", + "columns": [ + "handle" + ], + "isUnique": true + }, + "idx_publishers_owner": { + "name": "idx_publishers_owner", + "columns": [ + "owner_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_state_audit": { + "name": "release_state_audit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subject_kind": { + "name": "subject_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_state": { + "name": "from_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_state": { + "name": "to_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_release_state_audit_subject": { + "name": "idx_release_state_audit_subject", + "columns": [ + "subject_kind", + "subject_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_verification_evidence": { + "name": "release_verification_evidence", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_digest": { + "name": "release_digest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "harness_version": { + "name": "harness_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "os": { + "name": "os", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "probe_id": { + "name": "probe_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verifier": { + "name": "verifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_release_evidence_release": { + "name": "idx_release_evidence_release", + "columns": [ + "release_id" + ], + "isUnique": false + }, + "idx_release_evidence_probe": { + "name": "idx_release_evidence_probe", + "columns": [ + "release_id", + "harness", + "probe_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "release_verification_evidence_release_id_package_releases_id_fk": { + "name": "release_verification_evidence_release_id_package_releases_id_fk", + "tableFrom": "release_verification_evidence", + "tableTo": "package_releases", + "columnsFrom": [ + "release_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oidc_token_exchanges": { + "name": "oidc_token_exchanges", + "columns": { + "jti": { + "name": "jti", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "trusted_publisher_id": { + "name": "trusted_publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workflow_ref": { + "name": "workflow_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_oidc_token_exchanges_trusted": { + "name": "idx_oidc_token_exchanges_trusted", + "columns": [ + "trusted_publisher_id", + "created_at" + ], + "isUnique": false + }, + "idx_oidc_token_exchanges_created": { + "name": "idx_oidc_token_exchanges_created", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_publish_idempotency": { + "name": "release_publish_idempotency", + "columns": { + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_fingerprint": { + "name": "request_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_release_publish_idempotency_expires": { + "name": "idx_release_publish_idempotency_expires", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "release_publish_idempotency_publisher_id_idempotency_key_pk": { + "columns": [ + "publisher_id", + "idempotency_key" + ], + "name": "release_publish_idempotency_publisher_id_idempotency_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_reviews": { + "name": "release_reviews", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_digest": { + "name": "release_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_release_reviews_release": { + "name": "idx_release_reviews_release", + "columns": [ + "release_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "trusted_publishers": { + "name": "trusted_publishers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workflow_path": { + "name": "workflow_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_trusted_publishers_publisher": { + "name": "idx_trusted_publishers_publisher", + "columns": [ + "publisher_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "entitlements": { + "name": "entitlements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "package_id": { + "name": "package_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_entitlements_subject": { + "name": "idx_entitlements_subject", + "columns": [ + "subject_id" + ], + "isUnique": false + }, + "idx_entitlements_package": { + "name": "idx_entitlements_package", + "columns": [ + "package_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "entitlements_package_id_packages_id_fk": { + "name": "entitlements_package_id_packages_id_fk", + "tableFrom": "entitlements", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlements_release_id_package_releases_id_fk": { + "name": "entitlements_release_id_package_releases_id_fk", + "tableFrom": "entitlements", + "tableTo": "package_releases", + "columnsFrom": [ + "release_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_revisions": { + "name": "collection_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "members_json": { + "name": "members_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_json": { + "name": "config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revision_digest": { + "name": "revision_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_collection_revisions_number": { + "name": "idx_collection_revisions_number", + "columns": [ + "collection_id", + "revision_number" + ], + "isUnique": true + }, + "idx_collection_revisions_collection": { + "name": "idx_collection_revisions_collection", + "columns": [ + "collection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "collection_revisions_collection_id_collections_id_fk": { + "name": "collection_revisions_collection_id_collections_id_fk", + "tableFrom": "collection_revisions", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cloned_from_revision_id": { + "name": "cloned_from_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderation_state": { + "name": "moderation_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "moderation_reason": { + "name": "moderation_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderated_at": { + "name": "moderated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_collections_owner_slug": { + "name": "idx_collections_owner_slug", + "columns": [ + "owner_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kit_audit": { + "name": "personal_kit_audit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kit_id": { + "name": "kit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_id": { + "name": "revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kit_audit_kit": { + "name": "idx_personal_kit_audit_kit", + "columns": [ + "kit_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kit_conflicts": { + "name": "personal_kit_conflicts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kit_id": { + "name": "kit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_revision_id": { + "name": "from_revision_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "report_json": { + "name": "report_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolution_json": { + "name": "resolution_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_revision_id": { + "name": "resolved_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reeval_required": { + "name": "reeval_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kit_conflicts_kit": { + "name": "idx_personal_kit_conflicts_kit", + "columns": [ + "kit_id", + "status" + ], + "isUnique": false + }, + "idx_personal_kit_conflicts_idempotency": { + "name": "idx_personal_kit_conflicts_idempotency", + "columns": [ + "kit_id", + "idempotency_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "personal_kit_conflicts_kit_id_personal_kits_id_fk": { + "name": "personal_kit_conflicts_kit_id_personal_kits_id_fk", + "tableFrom": "personal_kit_conflicts", + "tableTo": "personal_kits", + "columnsFrom": [ + "kit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kit_proposals": { + "name": "personal_kit_proposals", + "columns": { + "revision_id": { + "name": "revision_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kit_id": { + "name": "kit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "declared_permissions_json": { + "name": "declared_permissions_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "required_permissions_json": { + "name": "required_permissions_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "evidence_json": { + "name": "evidence_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scan_json": { + "name": "scan_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kit_proposals_kit": { + "name": "idx_personal_kit_proposals_kit", + "columns": [ + "kit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "personal_kit_proposals_revision_id_personal_kit_revisions_id_fk": { + "name": "personal_kit_proposals_revision_id_personal_kit_revisions_id_fk", + "tableFrom": "personal_kit_proposals", + "tableTo": "personal_kit_revisions", + "columnsFrom": [ + "revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kit_revisions": { + "name": "personal_kit_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kit_id": { + "name": "kit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_revision_id": { + "name": "parent_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revision_digest": { + "name": "revision_digest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_json": { + "name": "document_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approval_json": { + "name": "approval_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "activated_at": { + "name": "activated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quarantined_at": { + "name": "quarantined_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kit_revisions_number": { + "name": "idx_personal_kit_revisions_number", + "columns": [ + "kit_id", + "revision_number" + ], + "isUnique": true + }, + "idx_personal_kit_revisions_idempotency": { + "name": "idx_personal_kit_revisions_idempotency", + "columns": [ + "kit_id", + "idempotency_key" + ], + "isUnique": true + }, + "idx_personal_kit_revisions_digest": { + "name": "idx_personal_kit_revisions_digest", + "columns": [ + "kit_id", + "revision_digest" + ], + "isUnique": false + } + }, + "foreignKeys": { + "personal_kit_revisions_kit_id_personal_kits_id_fk": { + "name": "personal_kit_revisions_kit_id_personal_kits_id_fk", + "tableFrom": "personal_kit_revisions", + "tableTo": "personal_kits", + "columnsFrom": [ + "kit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kits": { + "name": "personal_kits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_id": { + "name": "local_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "effective_revision_id": { + "name": "effective_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kits_name": { + "name": "idx_personal_kits_name", + "columns": [ + "name" + ], + "isUnique": true + }, + "idx_personal_kits_owner": { + "name": "idx_personal_kits_owner", + "columns": [ + "owner_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "personal_kits_publisher_id_publishers_id_fk": { + "name": "personal_kits_publisher_id_publishers_id_fk", + "tableFrom": "personal_kits", + "tableTo": "publishers", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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 c3070e3..d910a9d 100644 --- a/apps/web/drizzle/migrations/meta/_journal.json +++ b/apps/web/drizzle/migrations/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1792464094499, "tag": "0020_collection_moderation", "breakpoints": true + }, + { + "idx": 21, + "version": "6", + "when": 1793294428991, + "tag": "0021_agentkit_links", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/web/tests/auth/agentkit-bearer.test.ts b/apps/web/tests/auth/agentkit-bearer.test.ts new file mode 100644 index 0000000..5922d74 --- /dev/null +++ b/apps/web/tests/auth/agentkit-bearer.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { InvalidAccessTokenError, resetJwksCache, verifyAccessToken } from "~/lib/agentkit/access-token"; +import { bearerChallenge } from "~/lib/agentkit/agentkit-config"; +import { insertLink } from "~/lib/agentkit/link-store"; +import { grantsAnyKitRead, grantsLibraryRead } from "~/lib/auth/api-key-scopes"; +import { resolveRequestCredential } from "~/lib/auth/scoped-viewer"; +import { ReleaseHttpError } from "~/lib/releases/release-http"; +import { createFakeAs, createIdentityEnv, ISS } from "./agentkit-fake-as"; + +let as: Awaited>; +let ctx: ReturnType; + +beforeEach(async () => { + resetJwksCache(); + as = await createFakeAs(); + ctx = createIdentityEnv(); + vi.stubGlobal("fetch", as.fetcher); +}); +afterEach(() => vi.unstubAllGlobals()); + +const bearer = (token: string) => new Request("https://skillx.sh/api/v1/library", { headers: { Authorization: `Bearer ${token}` } }); +const env = () => ctx.env as unknown as Env; +const link = (userId: string, sub = "ak-user-1", validatedAt = Date.now()) => + insertLink(ctx.env.DB, { userId, issuer: ISS, sub, scopes: "skillx-kits:read skillx-kits:write", refreshTokenEnc: null, linkedAt: validatedAt, validatedAt }); + +async function refusal(promise: Promise): Promise<{ status: number; code: string }> { + const error = await promise.then(() => null, (e: unknown) => e); + expect(error).toBeInstanceOf(ReleaseHttpError); + return { status: (error as ReleaseHttpError).status, code: (error as ReleaseHttpError).code }; +} + +describe("AgentKit access token verification", () => { + it("accepts a token signed by the AS for the SkillX resource", async () => { + const claims = await verifyAccessToken(ctx.env, await as.sign(), as.fetcher); + expect(claims).toMatchObject({ sub: "ak-user-1", scopes: ["skillx-kits:read", "skillx-kits:write"], clientId: "ak-cli", grantId: "g1" }); + }); + + it.each([ + ["another audience", { aud: "https://mcp.agentkit.best/mcp" }, {}], + ["another issuer", { iss: "https://evil.test" }, {}], + ["an expired token", { exp: Math.floor(Date.now() / 1000) - 30 }, {}], + ["a future iat", { iat: Math.floor(Date.now() / 1000) + 600 }, {}], + ["an id token typ", {}, { typ: "JWT" }], + ["alg none", {}, { alg: "none" }], + ["an unknown kid", {}, { kid: "k9" }], + ["no subject", { sub: "" }, {}], + ])("refuses %s", async (_label, claims, header) => { + await expect(verifyAccessToken(ctx.env, await as.sign(claims, header), as.fetcher)).rejects.toBeInstanceOf(InvalidAccessTokenError); + }); + + it("refuses a token whose payload was altered after signing", async () => { + const [h, , s] = (await as.sign()).split("."); + const forged = (await as.sign({ sub: "someone-else" })).split(".")[1]; + await expect(verifyAccessToken(ctx.env, `${h}.${forged}.${s}`, as.fetcher)).rejects.toBeInstanceOf(InvalidAccessTokenError); + }); + + it("caches the JWKS between tokens", async () => { + await verifyAccessToken(ctx.env, await as.sign(), as.fetcher); + await verifyAccessToken(ctx.env, await as.sign(), as.fetcher); + expect(as.state.jwksCalls).toBe(1); + }); + + it("refuses an undecodable signature as an invalid token, not an outage", async () => { + const [h, p] = (await as.sign()).split("."); + await expect(verifyAccessToken(ctx.env, `${h}.${p}.abcde`, as.fetcher)).rejects.toBeInstanceOf(InvalidAccessTokenError); + }); + + it("keeps verifying with cached keys while a JWKS refresh fails, refetching at most every 30 s", async () => { + vi.useFakeTimers({ now: Date.now(), toFake: ["Date"] }); + try { + await verifyAccessToken(ctx.env, await as.sign(), as.fetcher); + as.state.jwksDown = true; + vi.setSystemTime(Date.now() + 11 * 60_000); + await verifyAccessToken(ctx.env, await as.sign(), as.fetcher); + await verifyAccessToken(ctx.env, await as.sign(), as.fetcher); + expect(as.state.jwksCalls).toBe(2); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("401 challenge", () => { + it("advertises the RFC 9728 metadata only while the feature is on", () => { + expect(bearerChallenge(createIdentityEnv("0").env)).toBe("Bearer"); + expect(bearerChallenge(createIdentityEnv("1").env)).toBe('Bearer resource_metadata="https://skillx.sh/.well-known/oauth-protected-resource"'); + expect(bearerChallenge({ SKILLX_AGENTKIT_IDENTITY: "1", ENVIRONMENT: "development" })).toBe( + 'Bearer resource_metadata="http://localhost:5173/.well-known/oauth-protected-resource"', + ); + }); +}); + +describe("AgentKit access token as a SkillX credential", () => { + it("runs as the linked account with translated scopes", async () => { + await link("user-a"); + const credential = await resolveRequestCredential(bearer(await as.sign()), env(), grantsAnyKitRead); + // `gone` is suspended, so it grants nothing. + expect(credential).toEqual({ userId: "user-a", keyId: null, scopes: ["library:read", "kits:write:@alpha"] }); + }); + + it("maps a read-only grant to library:read only", async () => { + await link("user-a"); + const credential = await resolveRequestCredential(bearer(await as.sign({ scope: "skillx-kits:read" })), env(), grantsLibraryRead); + expect(credential.scopes).toEqual(["library:read"]); + }); + + it("answers 403 account_not_linked with the link URL for an unlinked AgentKit account", async () => { + const error = await resolveRequestCredential(bearer(await as.sign()), env(), grantsLibraryRead).catch((e: ReleaseHttpError) => e); + expect(error).toMatchObject({ status: 403, code: "account_not_linked" }); + expect((error as Error).message).toContain("https://skillx.sh/settings#agentkit"); + }); + + it("answers 401 invalid_token for a token SkillX must not accept", async () => { + await link("user-a"); + expect(await refusal(resolveRequestCredential(bearer(await as.sign({ aud: "https://other.test/api" })), env(), grantsLibraryRead))).toEqual({ status: 401, code: "invalid_token" }); + }); + + it("refuses the token where the route accepts none of its scopes, like a scoped key", async () => { + await link("user-a"); + expect(await refusal(resolveRequestCredential(bearer(await as.sign()), env()))).toEqual({ status: 401, code: "invalid_api_key" }); + }); + + it("is ignored entirely while SKILLX_AGENTKIT_IDENTITY is off", async () => { + ctx = createIdentityEnv("0"); + await link("user-a"); + expect(await refusal(resolveRequestCredential(bearer(await as.sign()), env(), grantsLibraryRead))).toEqual({ status: 401, code: "invalid_api_key" }); + expect(as.state.jwksCalls).toBe(0); + }); + + it("answers 503 when the JWKS cannot be fetched", async () => { + as.state.down = true; + expect(await refusal(resolveRequestCredential(bearer(await as.sign()), env(), grantsLibraryRead))).toEqual({ status: 503, code: "agentkit_unavailable" }); + }); + + it("drops a link revoked at agentkit.best once it is older than 60 seconds", async () => { + await link("user-a", "ak-user-1", Date.now() - 61_000); + // No stored refresh token reads as a revoked grant. + expect(await refusal(resolveRequestCredential(bearer(await as.sign()), env(), grantsLibraryRead))).toEqual({ status: 403, code: "account_not_linked" }); + expect(ctx.d1.raw.prepare("SELECT COUNT(*) AS n FROM agentkit_links").get()).toEqual({ n: 0 }); + }); +}); diff --git a/apps/web/tests/auth/agentkit-fake-as.ts b/apps/web/tests/auth/agentkit-fake-as.ts new file mode 100644 index 0000000..7642423 --- /dev/null +++ b/apps/web/tests/auth/agentkit-fake-as.ts @@ -0,0 +1,121 @@ +/** + * A fake agentkit.best AS for the identity tests: a real ES256 key and JWKS, + * the token endpoint (authorization_code with S256 PKCE, rotating one-time + * refresh tokens) and RFC 7009 revocation, plus a FakeD1 with every migration. + */ +import { base64UrlEncode } from "~/lib/agentkit/access-token"; +import { FakeD1 } from "../releases/fake-d1"; + +export const ISS = "https://as.test"; +export const RESOURCE = "https://skillx.sh/api"; + +const enc = (value: unknown) => base64UrlEncode(new TextEncoder().encode(JSON.stringify(value))); + +export async function createFakeAs() { + const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"])) as CryptoKeyPair; + const jwk = await crypto.subtle.exportKey("jwk", pair.publicKey); + const state = { + sub: "ak-user-1", + scope: "skillx-kits:read skillx-kits:write", + revoked: false, + down: false, + /** Only the JWKS endpoint fails (the token endpoint still answers). */ + jwksDown: false, + noRefreshToken: false, + refreshTokens: new Set(), + revokedTokens: [] as string[], + codes: new Map(), // code → code_challenge + tokenCalls: 0, + jwksCalls: 0, + }; + let counter = 0; + + async function sign(claims: Record = {}, header: Record = {}): Promise { + const now = Math.floor(Date.now() / 1000); + const head = enc({ alg: "ES256", typ: "at+jwt", kid: "k1", ...header }); + const body = enc({ iss: ISS, aud: RESOURCE, sub: state.sub, client_id: "ak-cli", grant_id: "g1", scope: state.scope, jti: `j${++counter}`, iat: now, exp: now + 60, ...claims }); + const sig = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, pair.privateKey, new TextEncoder().encode(`${head}.${body}`)); + return `${head}.${body}.${base64UrlEncode(new Uint8Array(sig))}`; + } + + async function issue(): Promise { + const access = { access_token: await sign(), token_type: "Bearer", expires_in: 60, scope: state.scope }; + if (state.noRefreshToken) return Response.json(access); + const refresh = `rt-${++counter}`; + state.refreshTokens.add(refresh); + return Response.json({ ...access, refresh_token: refresh }); + } + + const fetcher = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (state.down) throw new TypeError("network down"); + if (url === `${ISS}/.well-known/jwks.json`) { + state.jwksCalls++; + if (state.jwksDown) return new Response("unavailable", { status: 503 }); + return Response.json({ keys: [{ ...jwk, kid: "k1", alg: "ES256", use: "sig" }] }); + } + const form = new URLSearchParams(String(init?.body ?? "")); + if (url === `${ISS}/oauth/revoke`) { + state.revokedTokens.push(form.get("token") ?? ""); + state.refreshTokens.delete(form.get("token") ?? ""); + return new Response(null, { status: 200 }); + } + if (url === `${ISS}/oauth/token`) { + state.tokenCalls++; + const invalid = () => Response.json({ error: "invalid_grant" }, { status: 400 }); + if (form.get("resource") !== RESOURCE || form.get("client_id") !== "https://skillx.sh/oauth/client.json") return Response.json({ error: "invalid_request" }, { status: 400 }); + if (form.get("grant_type") === "authorization_code") { + const challenge = state.codes.get(form.get("code") ?? ""); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(form.get("code_verifier") ?? ""))); + if (challenge === undefined || challenge !== base64UrlEncode(digest)) return invalid(); + state.codes.delete(form.get("code") ?? ""); + return issue(); + } + const presented = form.get("refresh_token") ?? ""; + if (state.revoked || !state.refreshTokens.delete(presented)) return invalid(); + return issue(); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + return { state, sign, fetcher }; +} + +export function fakeKv() { + const store = new Map(); + return { + store, + kv: { + async get(key: string, type?: string) { + const value = store.get(key); + if (value === undefined) return null; + return type === "json" ? JSON.parse(value) : value; + }, + async put(key: string, value: string) { + store.set(key, value); + }, + async delete(key: string) { + store.delete(key); + }, + } as unknown as KVNamespace, + }; +} + +export function createIdentityEnv(flag = "1") { + const d1 = new FakeD1(); + for (const id of ["user-a", "user-b"]) { + d1.raw.exec(`INSERT INTO user (id, name, email, createdAt, updatedAt) VALUES ('${id}', '${id}', '${id}@x.test', 1, 1)`); + } + d1.raw.exec("INSERT INTO publishers (id, handle, display_name, owner_user_id, created_at, state) VALUES ('p1', 'alpha', 'Alpha', 'user-a', 1, 'active')"); + d1.raw.exec("INSERT INTO publishers (id, handle, display_name, owner_user_id, created_at, state) VALUES ('p2', 'gone', 'Gone', 'user-a', 1, 'suspended')"); + const { kv, store } = fakeKv(); + const env = { + DB: d1.asD1(), + KV: kv, + BETTER_AUTH_SECRET: "test-secret-at-least-32-characters-long", + ENVIRONMENT: "production", + SKILLX_AGENTKIT_IDENTITY: flag, + AGENTKIT_ISSUER: ISS, + }; + return { d1, env, kvStore: store }; +} diff --git a/apps/web/tests/auth/agentkit-link.test.ts b/apps/web/tests/auth/agentkit-link.test.ts new file mode 100644 index 0000000..1197bfd --- /dev/null +++ b/apps/web/tests/auth/agentkit-link.test.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { resetJwksCache } from "~/lib/agentkit/access-token"; +import { completeLink, startLink, unlink } from "~/lib/agentkit/link-flow"; +import { ensureLinkLive, LinkUnavailableError } from "~/lib/agentkit/link-liveness"; +import { claimRefresh, linkOfUser } from "~/lib/agentkit/link-store"; +import { loader as clientJson } from "~/routes/oauth.client-json"; +import { loader as resourceMetadata } from "~/routes/well-known.oauth-protected-resource"; +import { createFakeAs, createIdentityEnv } from "./agentkit-fake-as"; + +let as: Awaited>; +let ctx: ReturnType; + +beforeEach(async () => { + resetJwksCache(); + as = await createFakeAs(); + ctx = createIdentityEnv(); +}); + +/** Runs the flow as the AS would: start, approve (a code bound to the PKCE challenge), callback. */ +async function linkAs(userId: string, callbackUser = userId) { + const authorize = new URL((await startLink(ctx.env, userId))!); + const code = `code-${userId}-${Math.random()}`; + as.state.codes.set(code, authorize.searchParams.get("code_challenge")!); + const query = new URLSearchParams({ code, state: authorize.searchParams.get("state")! }); + return { authorize, outcome: await completeLink(ctx.env, callbackUser, query, as.fetcher) }; +} + +const rows = () => ctx.d1.raw.prepare("SELECT user_id, agentkit_sub, refresh_token_enc FROM agentkit_links").all() as Array>; + +describe("Link with AgentKit", () => { + it("sends the user to the AS with PKCE S256, the SkillX resource and its CIMD client id", async () => { + const authorize = new URL((await startLink(ctx.env, "user-a"))!); + expect(`${authorize.origin}${authorize.pathname}`).toBe("https://as.test/oauth/authorize"); + expect(Object.fromEntries(authorize.searchParams)).toMatchObject({ + response_type: "code", + client_id: "https://skillx.sh/oauth/client.json", + redirect_uri: "https://skillx.sh/auth/agentkit/callback", + resource: "https://skillx.sh/api", + scope: "skillx-kits:read skillx-kits:write", + code_challenge_method: "S256", + }); + }); + + it("stores the AgentKit subject with the refresh token encrypted", async () => { + expect((await linkAs("user-a")).outcome).toBe("linked"); + const [row] = rows(); + expect(row).toMatchObject({ user_id: "user-a", agentkit_sub: "ak-user-1" }); + expect(row.refresh_token_enc).toMatch(/^v1\./); + expect(row.refresh_token_enc).not.toContain("rt-"); + expect(ctx.kvStore.size).toBe(0); + }); + + it("refuses a callback in another account's session (login CSRF)", async () => { + expect((await linkAs("user-a", "user-b")).outcome).toBe("expired"); + expect(rows()).toEqual([]); + }); + + it("refuses a second link of the same AgentKit account and revokes the new grant", async () => { + await linkAs("user-a"); + expect((await linkAs("user-b")).outcome).toBe("taken"); + expect(rows()).toHaveLength(1); + expect(as.state.revokedTokens).toHaveLength(1); + }); + + it("does not start a second link for a linked account", async () => { + await linkAs("user-a"); + expect(await startLink(ctx.env, "user-a")).toBeNull(); + }); + + it("reports a denied consent without linking", async () => { + const authorize = new URL((await startLink(ctx.env, "user-a"))!); + const query = new URLSearchParams({ error: "access_denied", state: authorize.searchParams.get("state")! }); + expect(await completeLink(ctx.env, "user-a", query, as.fetcher)).toBe("denied"); + expect(rows()).toEqual([]); + }); + + it("unlinks: revokes SkillX's refresh token at the AS and deletes the row", async () => { + await linkAs("user-a"); + expect(await unlink(ctx.env, "user-a", as.fetcher)).toBe(true); + expect(rows()).toEqual([]); + expect(as.state.revokedTokens).toHaveLength(1); + expect(as.state.revokedTokens[0]).toMatch(/^rt-/); + // The only live refresh token at the AS was SkillX's, and it is gone. + expect(as.state.refreshTokens.size).toBe(0); + }); +}); + +describe("link liveness", () => { + /** Ages the stored validation and returns the row as a request would read it. */ + const stale = async (ageMs = 61_000) => { + ctx.d1.raw.prepare("UPDATE agentkit_links SET validated_at = ?").run(Date.now() - ageMs); + return (await linkOfUser(ctx.env.DB, "user-a"))!; + }; + + it("trusts a link validated within 60 seconds without calling the AS", async () => { + await linkAs("user-a"); + const calls = as.state.tokenCalls; + expect(await ensureLinkLive(ctx.env, (await linkOfUser(ctx.env.DB, "user-a"))!, as.fetcher)).toBe("live"); + expect(as.state.tokenCalls).toBe(calls); + }); + + it("refreshes a stale link and rotates the stored refresh token", async () => { + await linkAs("user-a"); + const before = rows()[0].refresh_token_enc; + expect(await ensureLinkLive(ctx.env, await stale(), as.fetcher)).toBe("live"); + expect(rows()[0].refresh_token_enc).not.toBe(before); + }); + + it("deletes the link when the AS answers invalid_grant (revoked in Connected apps)", async () => { + await linkAs("user-a"); + as.state.revoked = true; + expect(await ensureLinkLive(ctx.env, await stale(), as.fetcher)).toBe("revoked"); + expect(rows()).toEqual([]); + }); + + it("keeps the link through a short AS outage, then answers unavailable", async () => { + await linkAs("user-a"); + as.state.down = true; + expect(await ensureLinkLive(ctx.env, await stale(), as.fetcher)).toBe("live"); + await expect(ensureLinkLive(ctx.env, await stale(16 * 60_000), as.fetcher)).rejects.toBeInstanceOf(LinkUnavailableError); + expect(rows()).toHaveLength(1); + }); + + it("lets only one request spend the one-time refresh token", async () => { + await linkAs("user-a"); + const now = Date.now(); + const { validatedAt } = (await linkOfUser(ctx.env.DB, "user-a"))!; + expect(await claimRefresh(ctx.env.DB, "user-a", validatedAt, now, now + 15_000)).toBe(true); + expect(await claimRefresh(ctx.env.DB, "user-a", validatedAt, now, now + 15_000)).toBe(false); + // The second request rides on the refresh in flight, even past the outage grace. + const calls = as.state.tokenCalls; + const old = { ...(await linkOfUser(ctx.env.DB, "user-a"))!, validatedAt: now - 16 * 60_000 }; + expect(await ensureLinkLive(ctx.env, old, as.fetcher, now)).toBe("live"); + expect(as.state.tokenCalls).toBe(calls); + }); + + it("never replays a refresh token another request already rotated", async () => { + await linkAs("user-a"); + const seenByB = await stale(); + expect(await ensureLinkLive(ctx.env, seenByB, as.fetcher)).toBe("live"); // request A refreshes + const calls = as.state.tokenCalls; + expect(await ensureLinkLive(ctx.env, seenByB, as.fetcher)).toBe("live"); // B, with A's stale read + expect(as.state.tokenCalls).toBe(calls); + expect(rows()).toHaveLength(1); + }); + + it("keeps the rotated refresh token when the new access token cannot be verified", async () => { + await linkAs("user-a"); + resetJwksCache(); + as.state.jwksDown = true; + const before = rows()[0].refresh_token_enc; + expect(await ensureLinkLive(ctx.env, await stale(), as.fetcher)).toBe("live"); + expect(rows()[0].refresh_token_enc).not.toBe(before); + as.state.jwksDown = false; + resetJwksCache(); + // The stored token is the rotated one, so the next refresh still works. + const calls = as.state.tokenCalls; + expect(await ensureLinkLive(ctx.env, await stale(), as.fetcher)).toBe("live"); + expect(as.state.tokenCalls).toBe(calls + 1); + expect(rows()).toHaveLength(1); + }); + + it("refuses a link the AS issued no refresh token for", async () => { + as.state.noRefreshToken = true; + expect((await linkAs("user-a")).outcome).toBe("failed"); + expect(rows()).toEqual([]); + }); +}); + +describe("discovery documents", () => { + const call = (loader: typeof clientJson, flag: string) => + loader({ request: new Request("https://skillx.sh/x"), params: {}, context: { cloudflare: { env: createIdentityEnv(flag).env } } } as never) as Promise; + + it("serve the CIMD and RFC 9728 metadata only while the feature is on", async () => { + expect((await call(clientJson, "0")).status).toBe(404); + expect((await call(resourceMetadata, "0")).status).toBe(404); + expect(await (await call(clientJson, "1")).json()).toMatchObject({ + client_id: "https://skillx.sh/oauth/client.json", + redirect_uris: ["https://skillx.sh/auth/agentkit/callback"], + token_endpoint_auth_method: "none", + }); + expect(await (await call(resourceMetadata, "1")).json()).toMatchObject({ + resource: "https://skillx.sh/api", + authorization_servers: ["https://as.test"], + scopes_supported: ["skillx-kits:read", "skillx-kits:write"], + }); + }); +}); diff --git a/apps/web/workers/app.ts b/apps/web/workers/app.ts index 027bdab..dcc59d9 100644 --- a/apps/web/workers/app.ts +++ b/apps/web/workers/app.ts @@ -28,6 +28,8 @@ declare global { SKILLX_TOKEN_SECRET?: string; /** Audience a GitHub Actions OIDC token must carry; defaults to `skillx`. */ SKILLX_OIDC_AUDIENCE?: string; + /** agentkit.best OAuth issuer override (development); defaults to https://agentkit.best. */ + AGENTKIT_ISSUER?: string; } } diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc index 5cba10a..854a6f2 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -73,6 +73,11 @@ // Rollback = set "0" and redeploy. Needs migrations 0015 and 0016. "SKILLX_PERSONAL_KITS_API": "1", // Collections API (/api/v1/collections/...). "0" = off (404 everywhere); rollback = set "0" and redeploy. Needs migrations 0009 and 0019. - "SKILLX_COLLECTIONS_API": "1" + "SKILLX_COLLECTIONS_API": "1", + // AgentKit identity: "Link with AgentKit" on /settings, AgentKit access tokens as API credentials, + // /oauth/client.json and /.well-known/oauth-protected-resource. On only for "1" or "true"; off, + // those paths answer 404 and SkillX works exactly as before. Needs migration 0021 and the + // agentkit.best AS serving the https://skillx.sh/api resource. Rollback = set "0" and redeploy. + "SKILLX_AGENTKIT_IDENTITY": "0" } } diff --git a/docs/api-reference.md b/docs/api-reference.md index f1551bc..39d0f73 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -22,6 +22,44 @@ Key format: `sx_` prefix + 64 hex characters (32 random bytes). First 8 chars us Generate keys via `POST /api/user/api-keys` (requires session auth). +### AgentKit access token (flag `SKILLX_AGENTKIT_IDENTITY`) + +A SkillX account linked to an agentkit.best account (Settings → "Link with AgentKit") can call the scoped-credential routes (`/api/v1/library`, release reads, `/api/v1/kits/...`) with an agentkit.best access token instead of a SkillX key. There is no SkillX token-exchange endpoint: the client (ak CLI, AgentKit Desktop) asks the agentkit.best AS for a token with `resource=https://skillx.sh/api` and sends it as is: + +``` +Authorization: Bearer +``` + +SkillX verifies it locally against `https://agentkit.best/.well-known/jwks.json`: `alg` ES256, `typ` `at+jwt`, `iss` `https://agentkit.best`, `aud` containing `https://skillx.sh/api`, `exp`/`iat` (5 s skew), non-empty `sub`. The token then acts as the linked SkillX account with these scopes: + +| AgentKit scope | SkillX scope | +|----------------|--------------| +| `skillx-kits:read` | `library:read` | +| `skillx-kits:write` | `kits:write:@` for each active publisher the account owns | + +A route that accepts none of the translated scopes answers `401 invalid_api_key`, like a scoped key. Other failures: + +| Status | Code | Meaning | +|--------|------|---------| +| `401` | `invalid_token` | Signature, issuer, audience, type or expiry is wrong | +| `403` | `account_not_linked` | No SkillX account is linked to this AgentKit `sub` (or the link was revoked); the message carries the link URL `https://skillx.sh/settings#agentkit` | +| `503` | `agentkit_unavailable` | The JWKS, or the AS while re-validating a link, cannot be reached | + +Revocation: AS tokens live 60 s, and SkillX re-validates the link by refreshing its own grant whenever the last check is older than 60 s; an `invalid_grant` deletes the link. During an AS outage a link checked within the last 15 minutes keeps working, then requests answer `503`. A signed artifact download URL already minted with the token stays valid for its 5 minutes, as with a session. While the flag is on, every `401` from these routes carries `WWW-Authenticate: Bearer resource_metadata="https://skillx.sh/.well-known/oauth-protected-resource"`; with it off the challenge stays plain `Bearer`. + +Discovery and linking (all `404` while the flag is off, except unlink): + +| Route | Purpose | +|-------|---------| +| `GET /.well-known/oauth-protected-resource` (also `/.well-known/oauth-protected-resource/api`) | RFC 9728 metadata: `resource`, `authorization_servers`, `scopes_supported` | +| `GET /oauth/client.json` | SkillX's client ID metadata document (public client, `token_endpoint_auth_method: none`) | +| `POST /auth/agentkit/start` | Session required; redirects to the AS with auth code + S256 PKCE, `resource=https://skillx.sh/api` | +| `GET /auth/agentkit/callback` | Completes the link, redirects to `/settings?agentkit=#agentkit` (`linked`, `denied`, `expired`, `already_linked`, `taken`, `failed`) | +| `GET /api/user/agentkit-link` | Session required; `{enabled, linked, linkedAt?, scopes?, unverified?}` | +| `DELETE /api/user/agentkit-link` | Session, same-origin; revokes SkillX's refresh token at the AS and deletes the link | + +One AgentKit account links to at most one SkillX account. Data: table `agentkit_links` (migration `0021_agentkit_links`), refresh token stored AES-GCM encrypted. + --- ## Endpoints diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index 44fb483..d940fc7 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -535,6 +535,20 @@ code, then run the header SQL of `0020_collection_moderation.sql`. Code before 0020 ignores `moderation_state`, so a rollback un-hides content: first run `UPDATE collections SET visibility = 'private' WHERE moderation_state = 'hidden'`. +**Migration 0021 and AgentKit identity.** `0021_agentkit_links` creates the +new table `agentkit_links` (one row per linked account) and touches no existing +data. Apply it with `pnpm run db:migrate:remote` after a backup, before +setting `SKILLX_AGENTKIT_IDENTITY` to `"1"` in `apps/web/wrangler.jsonc`. +Production keeps the flag `"0"` until the agentkit.best authorization server +serves `/.well-known/jwks.json`, `/oauth/token` and `/oauth/revoke` and accepts +the client ID `https://skillx.sh/oauth/client.json`. `AGENTKIT_ISSUER` +(default `https://agentkit.best`) points at another AS for staging. The link +refresh token is encrypted with a key derived from `BETTER_AUTH_SECRET`; +rotating that secret makes stored links unreadable, so each user must link +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`. + 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)).