From ee326d7bfb8b3ee8b773e1812481ae68e3e16108 Mon Sep 17 00:00:00 2001 From: jarvis Date: Fri, 4 Sep 2026 14:34:39 -0400 Subject: [PATCH 1/2] feat(context7): add managed OpenAPI adapter --- .dev.vars.example | 3 + README.md | 9 +- README.zh-CN.md | 1 + docs/architecture.md | 24 +++ migrations/0010_managed_openapi_context7.sql | 16 ++ providers/context7/README.md | 40 ++++ .../agent-skills/context7/SKILL.md | 30 +++ public/.well-known/agent-skills/index.json | 12 ++ specs/context7-adapter.feature | 36 ++++ src/core/dynamic-oauth-client.ts | 160 ++++++++++++++ src/core/managed-openapi-adapter.ts | 199 ++++++++++++++++++ src/providers/context7/adapter.ts | 96 +++++++++ src/providers/context7/config.ts | 26 +++ src/providers/context7/oauth.ts | 188 +++++++++++++++++ src/providers/context7/openapi.ts | 127 +++++++++++ src/worker.ts | 46 ++++ test/core/dynamic-oauth-client.test.ts | 65 ++++++ test/core/managed-openapi-adapter.test.ts | 126 +++++++++++ test/integration/context7-credentials.test.ts | 44 ++++ test/providers/context7-openapi.test.ts | 20 ++ vitest.config.ts | 2 + worker-configuration.d.ts | 7 +- wrangler.jsonc | 7 +- 23 files changed, 1279 insertions(+), 5 deletions(-) create mode 100644 migrations/0010_managed_openapi_context7.sql create mode 100644 providers/context7/README.md create mode 100644 public/.well-known/agent-skills/context7/SKILL.md create mode 100644 public/.well-known/agent-skills/index.json create mode 100644 specs/context7-adapter.feature create mode 100644 src/core/dynamic-oauth-client.ts create mode 100644 src/core/managed-openapi-adapter.ts create mode 100644 src/providers/context7/adapter.ts create mode 100644 src/providers/context7/config.ts create mode 100644 src/providers/context7/oauth.ts create mode 100644 src/providers/context7/openapi.ts create mode 100644 test/core/dynamic-oauth-client.test.ts create mode 100644 test/core/managed-openapi-adapter.test.ts create mode 100644 test/integration/context7-credentials.test.ts create mode 100644 test/providers/context7-openapi.test.ts diff --git a/.dev.vars.example b/.dev.vars.example index b099c51..d8167f2 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -14,6 +14,9 @@ CLOUDFLARE_AUTHORIZATION_ORIGIN=https://dash.cloudflare.com CLOUDFLARE_CLIENT_ID= CLOUDFLARE_CLIENT_SECRET= CLOUDFLARE_CREDENTIAL_ENCRYPTION_KEY= +CONTEXT7_API_ORIGIN=https://context7.com/api +CONTEXT7_OAUTH_ISSUER=https://clerk.context7.com +CONTEXT7_CREDENTIAL_ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key LINEAR_API_ORIGIN=https://api.linear.app LINEAR_AUTHORIZATION_ORIGIN=https://linear.app LINEAR_CLIENT_ID=replace-with-linear-oauth-client-id diff --git a/README.md b/README.md index 704f1ed..03ca31f 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ identity model has already passed a capability review. | GitHub | Provider-delegated application actor | Shared GitHub App actor with trusted Agent attribution | 1 | Alpha | | Linear | Provider-delegated native App actor | Shared App user with trusted per-operation Agent attribution | 1 | Experimental | | Cloudflare | Native service principal | Dedicated account-owned token actor in audit logs | 1 | Design | +| Context7 | Provider-delegated user | Shared OAuth user grant with Agent-attributed adapter audit | 1 | Experimental | | GitLab | Native service principal | Dedicated service account visible in groups, projects, and audit records | 2 | Proposal | | Bitbucket | Native service principal | Repository, project, or workspace access-token actor | 2 | Proposal | | Vercel | Native service principal | Dedicated integration identity with provider-side audit correlation | 2 | Proposal | @@ -170,7 +171,7 @@ specs/ github-adapter.feature linear-adapter.feature src/ - core/ Shared HTTP lifecycle, DPoP, Agent Profile, and errors + core/ Shared HTTP lifecycle, DPoP, managed OpenAPI runtime, Agent Profile, and errors providers/ Isolated provider connections, permission translation, proxy, and transformations storage/ Worker-owned D1 runtime state worker.ts Cloudflare Worker entrypoint @@ -214,6 +215,12 @@ Set `GITHUB_APP_ID`, `GITHUB_PRIVATE_KEY`, `GITHUB_CLIENT_ID`, and `GITHUB_CLIENT_SECRET` in the ignored `.dev.vars` file. Both GitHub-downloaded PKCS#1 keys and unencrypted PKCS#8 PEM keys are accepted. +Context7 uses the reusable managed OpenAPI runtime. It does not need a +provisioned client ID or client secret: the Adapter dynamically registers a +public OAuth client and uses S256 PKCE. Set only a base64-encoded 32-byte +`CONTEXT7_CREDENTIAL_ENCRYPTION_KEY`; the Adapter persists the resulting public +client ID and encrypts controller credentials in D1. + Configure the GitHub App callbacks as: ```text diff --git a/README.zh-CN.md b/README.zh-CN.md index 269359e..6561149 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -63,6 +63,7 @@ Agent 能以自己的稳定身份直接进入各种平台。详见 | GitHub | 代理应用身份 | 共享 GitHub App actor,并注入可信 Agent 角标 | 1 | Alpha | | Linear | 代理的原生 App actor | 共享 App user,以及逐次操作中的可信 Agent 名称/头像 | 1 | 实验性 | | Cloudflare | 原生 service principal | 独立 account-owned token actor 出现在审计日志中 | 1 | 设计中 | +| Context7 | 代理用户身份 | 共享 OAuth 用户授权,并由 Adapter 审计记录具体 Agent | 1 | 实验性 | | GitLab | 原生 service principal | 独立 service account 出现在 group、project 与审计记录中 | 2 | 提案 | | Bitbucket | 原生 service principal | repository、project 或 workspace access-token actor | 2 | 提案 | | Vercel | 原生 service principal | 独立 integration 身份,并可关联平台侧审计 | 2 | 提案 | diff --git a/docs/architecture.md b/docs/architecture.md index 01b5810..e1690b9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,6 +36,30 @@ into Realmroot, adds another authorization model, or introduces a private Realmroot-to-Adapter protocol requires an architecture decision and security review before implementation. +## Managed OpenAPI adapters + +Information-query providers often have a useful OpenAPI contract and ordinary +OAuth, but none of the Agent-facing discovery or proof machinery. They use a +shared managed runtime instead of duplicating that machinery per provider. + +A managed provider definition supplies a fixed upstream origin, a canonical +resource-oriented OpenAPI document, an operation allowlist, scope mapping, and +a credential resolver. The runtime supplies RFC 9728 metadata, service +description discovery, Adapter-issued DPoP authentication, scope enforcement, +credential/header isolation, response streaming, and privacy-preserving audit. +Provider OpenAPI is input to the mapping; it is not permission to expose every +upstream path. + +Upstream OAuth is composed separately. Providers with RFC 7591 dynamic client +registration can use the shared public-client implementation with S256 PKCE, +so deployment needs an encryption key but no manually provisioned client ID or +secret. More complex provider consent, context selection, or lifecycle rules +remain isolated in a provider module while reusing the same Agent boundary. + +Context7 is the first managed provider. Its action-shaped upstream search and +context endpoints are published as the `/libraries` collection and the +`/documentation` derived representation, both under `documentation:read`. + ## Target architecture ```text diff --git a/migrations/0010_managed_openapi_context7.sql b/migrations/0010_managed_openapi_context7.sql new file mode 100644 index 0000000..cc3d2d2 --- /dev/null +++ b/migrations/0010_managed_openapi_context7.sql @@ -0,0 +1,16 @@ +CREATE TABLE managed_oauth_client ( + provider_id TEXT PRIMARY KEY NOT NULL, + client_id TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE context7_external_credential ( + subject TEXT PRIMARY KEY NOT NULL, + display_name TEXT NOT NULL, + access_token_ciphertext TEXT NOT NULL, + refresh_token_ciphertext TEXT NOT NULL, + token_expires_at INTEGER NOT NULL, + provider_scope_json TEXT NOT NULL, + credential_version INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL +); diff --git a/providers/context7/README.md b/providers/context7/README.md new file mode 100644 index 0000000..6cc5351 --- /dev/null +++ b/providers/context7/README.md @@ -0,0 +1,40 @@ +# Context7 capability report + +Status: Experimental + +Context7 exposes a small read-only OpenAPI API and an OAuth 2 authorization +server with dynamic client registration. It does not expose the Realmroot +Resource Server contract directly, so the managed OpenAPI runtime supplies the +Agent-facing boundary. + +## Published resources + +| Agent operation | Upstream operation | Scope | +| --- | --- | --- | +| `GET /context7/libraries` | `GET /api/v2/libs/search` | `documentation:read` | +| `GET /context7/documentation` | `GET /api/v2/context` | `documentation:read` | + +Both operations preserve Context7 query and response semantics. Unlisted +Context7 paths are not reachable through the Adapter. + +## Authorization and identity + +The Adapter dynamically registers a public OAuth client with Context7 and uses +authorization code with S256 PKCE. The registered client ID is public state in +D1. PKCE verifier state and controller access/refresh tokens are encrypted with +`CONTEXT7_CREDENTIAL_ENCRYPTION_KEY`. + +Context7 sees the connected OAuth user. Realmroot and Adapter audit retain the +originating Agent. Context7 does not natively enforce or display that Agent, so +the declared identity level is `provider-delegated` with audit-only +attribution. + +## Native readiness gaps + +- no Realmroot Agent principal at the Context7 API boundary; +- no Agent-visible attribution in Context7; +- no DPoP-bound Context7 access token; +- no Realmroot protected-resource or service-description discovery. + +The Adapter can retire when Context7 accepts Realmroot Agent identity and +proof-bound delegated authority directly. diff --git a/public/.well-known/agent-skills/context7/SKILL.md b/public/.well-known/agent-skills/context7/SKILL.md new file mode 100644 index 0000000..0934154 --- /dev/null +++ b/public/.well-known/agent-skills/context7/SKILL.md @@ -0,0 +1,30 @@ +--- +name: use-context7 +description: Use Context7 through Realmroot Toolbox to resolve a software library and retrieve current, task-relevant documentation without handling Context7 credentials. +--- + +# Use Context7 + +Use the `realmroot` command so every Context7 request runs as the Agent with +controller-approved authority. Never call Context7 with a user token or ask the +user for an API key. + +Before the first operation, run: + +```bash +realmroot toolbox context7 +``` + +If the command reports missing access, request the exact +`documentation:read` scope and continue after controller approval. + +Resolve the library before requesting documentation: + +1. Call `GET /libraries` with `libraryName` and the current task in `query`. +2. Select the best matching `id` from the ranked `results`; do not invent or + infer an identifier when multiple matches exist. +3. Call `GET /documentation` with the selected `libraryId`, a specific `query`, + and `type=json` when structured snippets are useful or `type=txt` for prose. + +Keep each documentation query focused on one concrete question. Repeat the +library lookup when the package or ecosystem changes. diff --git a/public/.well-known/agent-skills/index.json b/public/.well-known/agent-skills/index.json new file mode 100644 index 0000000..3ff538f --- /dev/null +++ b/public/.well-known/agent-skills/index.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + "skills": [ + { + "name": "use-context7", + "type": "skill-md", + "description": "Use Context7 through Realmroot Toolbox to resolve a software library and retrieve current, task-relevant documentation without handling Context7 credentials.", + "url": "/.well-known/agent-skills/context7/SKILL.md", + "digest": "sha256:4393c50b664853900c0d42b26842fb736be05a89e34d9bfb4b00ef98b69d9869" + } + ] +} diff --git a/specs/context7-adapter.feature b/specs/context7-adapter.feature new file mode 100644 index 0000000..2f18b42 --- /dev/null +++ b/specs/context7-adapter.feature @@ -0,0 +1,36 @@ +Feature: Context7 managed OpenAPI adapter + Realmroot Agents query Context7 through a reusable authenticated OpenAPI boundary. + + @journey:context7-contract @entrypoint:http + Scenario: Realmroot discovers the Context7 Resource Server + Given Context7 is configured as a managed OpenAPI adapter + When Realmroot reads its protected-resource metadata and service description + Then both published operations require the documentation:read scope + And no operation outside the configured allowlist is forwarded + + @journey:context7-provider-oauth @entrypoint:http + Scenario: The controller connects Context7 without provisioned client credentials + Given Context7 supports OAuth dynamic client registration + When the controller begins the Adapter authorization flow + Then the Adapter registers a public OAuth client once and uses S256 PKCE + And the PKCE verifier and resulting Context7 tokens are encrypted at rest + + @journey:context7-library-discovery @entrypoint:http + Scenario: An Agent resolves a library before reading documentation + Given the Agent has approved Context7 documentation access + When it lists libraries by library name and task query + Then the managed adapter forwards the request to the configured Context7 search operation + And it returns Context7's ranked library resources unchanged + + @journey:context7-documentation @entrypoint:http + Scenario: An Agent retrieves documentation for a resolved library + Given the Agent selected a Context7 library identifier + When it reads documentation using that identifier and a task query + Then the managed adapter forwards the request to the configured Context7 context operation + And it preserves the upstream content type, status, and body + + @journey:context7-audit-privacy @entrypoint:http + Scenario: Context7 transport audit excludes credentials and query content + When a published Context7 operation completes + Then the audit records the Agent, operation ID, path template, selected scope, status, request ID, and duration + But it does not record tokens, authorization headers, query values, or response content diff --git a/src/core/dynamic-oauth-client.ts b/src/core/dynamic-oauth-client.ts new file mode 100644 index 0000000..3206989 --- /dev/null +++ b/src/core/dynamic-oauth-client.ts @@ -0,0 +1,160 @@ +import { z } from 'zod' +import { sha256Base64Url } from './digest.js' +import { failedDependency } from './problem.js' + +const registrationSchema = z.object({ client_id: z.string().min(1) }) +const tokenSchema = z.object({ + access_token: z.string().min(1), + refresh_token: z.string().min(1).optional(), + expires_in: z.number().int().positive(), + scope: z.union([z.string(), z.array(z.string())]).optional(), +}) + +export type DynamicOAuthRegistrationStore = { + clientId(providerId: string): Promise + saveClientId(providerId: string, clientId: string): Promise +} + +export type DynamicOAuthToken = Readonly<{ + accessToken: string + refreshToken?: string + expiresAt: number + scopes: readonly string[] +}> + +export class D1DynamicOAuthRegistrationStore implements DynamicOAuthRegistrationStore { + constructor(private readonly db: D1Database) {} + + async clientId(providerId: string) { + const row = await this.db + .prepare('SELECT client_id AS clientId FROM managed_oauth_client WHERE provider_id = ?') + .bind(providerId) + .first<{ clientId: string }>() + return row?.clientId ?? null + } + + async saveClientId(providerId: string, clientId: string) { + await this.db + .prepare('INSERT OR IGNORE INTO managed_oauth_client (provider_id, client_id, created_at) VALUES (?, ?, ?)') + .bind(providerId, clientId, Date.now()) + .run() + const stored = await this.clientId(providerId) + if (!stored) throw new Error(`Could not persist the ${providerId} OAuth client registration.`) + return stored + } +} + +export function createDynamicOAuthClient(input: { + providerId: string + clientName: string + issuer: string + redirectUri: string + scopes: readonly string[] + registrationStore: DynamicOAuthRegistrationStore + fetcher?: typeof fetch + now?: () => number +}) { + const fetcher = input.fetcher ?? fetch + const now = input.now ?? Date.now + + return { + async authorizationUrl(state: string) { + const verifier = randomVerifier() + const clientId = await registeredClientId() + const url = new URL('/oauth/authorize', input.issuer) + url.searchParams.set('client_id', clientId) + url.searchParams.set('redirect_uri', input.redirectUri) + url.searchParams.set('response_type', 'code') + url.searchParams.set('scope', input.scopes.join(' ')) + url.searchParams.set('state', state) + url.searchParams.set('code_challenge', await sha256Base64Url(verifier)) + url.searchParams.set('code_challenge_method', 'S256') + return { url: url.toString(), verifier } + }, + exchangeCode(code: string, verifier: string) { + return tokenRequest({ + grant_type: 'authorization_code', + code, + redirect_uri: input.redirectUri, + code_verifier: verifier, + }) + }, + refresh(refreshToken: string) { + return tokenRequest({ grant_type: 'refresh_token', refresh_token: refreshToken }) + }, + async revoke(token: string) { + const response = await fetcher(new URL('/oauth/token/revoke', input.issuer), { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token, token_type_hint: 'refresh_token', client_id: await registeredClientId() }), + signal: AbortSignal.timeout(10_000), + }) + if (!response.ok) throw providerFailure(response, 'OAuth token revocation') + }, + async userInfo(accessToken: string) { + const response = await fetcher(new URL('/oauth/userinfo', input.issuer), { + headers: { authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(10_000), + }) + if (!response.ok) throw providerFailure(response, 'OAuth userinfo request') + return response.json() as Promise + }, + } + + async function registeredClientId() { + const existing = await input.registrationStore.clientId(input.providerId) + if (existing) return existing + const response = await fetcher(new URL('/oauth/register', input.issuer), { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify({ + client_name: input.clientName, + redirect_uris: [input.redirectUri], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + scope: input.scopes.join(' '), + }), + signal: AbortSignal.timeout(10_000), + }) + if (!response.ok) throw providerFailure(response, 'dynamic client registration') + return input.registrationStore.saveClientId( + input.providerId, + registrationSchema.parse(await response.json()).client_id, + ) + } + + async function tokenRequest(parameters: Record): Promise { + const response = await fetcher(new URL('/oauth/token', input.issuer), { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ ...parameters, client_id: await registeredClientId() }), + signal: AbortSignal.timeout(10_000), + }) + if (!response.ok) throw providerFailure(response, 'OAuth token request') + const token = tokenSchema.parse(await response.json()) + return { + accessToken: token.access_token, + ...(token.refresh_token ? { refreshToken: token.refresh_token } : {}), + expiresAt: now() + token.expires_in * 1000, + scopes: normalizeScopes(token.scope ?? input.scopes), + } + } +} + +function randomVerifier() { + const bytes = crypto.getRandomValues(new Uint8Array(48)) + return btoa(String.fromCharCode(...bytes)) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/, '') +} + +function normalizeScopes(value: string | readonly string[]) { + const scopes = typeof value === 'string' ? value.split(/\s+/) : value + return [...new Set(scopes.filter(Boolean))].sort() +} + +function providerFailure(response: Response, operation: string) { + return failedDependency(`The upstream provider rejected ${operation} with ${response.status}.`) +} diff --git a/src/core/managed-openapi-adapter.ts b/src/core/managed-openapi-adapter.ts new file mode 100644 index 0000000..55e6624 --- /dev/null +++ b/src/core/managed-openapi-adapter.ts @@ -0,0 +1,199 @@ +import type { Context } from 'hono' +import type { AdapterEnv, AdapterModule } from './adapter.js' +import { forbidden, HttpProblem, insufficientScope } from './problem.js' +import type { RealmrootAuthenticator } from './realmroot-auth.js' + +export type ManagedOpenApiCredential = Readonly<{ + authorization: string + scopes: readonly string[] + actorType: string +}> + +export type ManagedOpenApiOperation = Readonly<{ + operationId: string + method: string + path: string + upstreamPath: string + scopes: readonly string[] +}> + +export type ManagedOpenApiDefinition = Readonly<{ + id: string + resource: string + issuer: string + upstreamOrigin: string + scopes: Readonly> + operations: readonly ManagedOpenApiOperation[] + openapi: Record + manifest: Record + representation?: Record +}> + +export type ManagedOpenApiDependencies = Readonly<{ + authenticator: RealmrootAuthenticator + credential(subject: string): Promise + audit(record: Record): Promise + fetch?: typeof fetch +}> + +const removedRequestHeaders = new Set([ + 'authorization', + 'dpop', + 'cookie', + 'host', + 'content-length', + 'connection', + 'proxy-connection', + 'keep-alive', + 'transfer-encoding', + 'upgrade', + 'te', + 'trailer', + 'forwarded', + 'cf-connecting-ip', + 'cf-ipcountry', + 'cf-ray', + 'x-forwarded-for', + 'x-forwarded-host', + 'x-forwarded-proto', + 'x-real-ip', +]) +const removedResponseHeaders = new Set([ + 'set-cookie', + 'connection', + 'proxy-connection', + 'keep-alive', + 'transfer-encoding', + 'upgrade', + 'te', + 'trailer', +]) + +export function createManagedOpenApiAdapter( + definition: ManagedOpenApiDefinition, + dependencies: ManagedOpenApiDependencies, +): AdapterModule { + assertDefinition(definition) + const request = dependencies.fetch ?? fetch + const basePath = new URL(definition.resource).pathname.replace(/\/$/, '') + + return { + id: definition.id, + register(app) { + app.get(`/providers/${definition.id}/manifest`, (c) => c.json(definition.manifest)) + app.get(`/.well-known/oauth-protected-resource/${definition.id}`, (c) => + c.json({ + resource: definition.resource, + authorization_servers: [definition.issuer], + scopes_supported: Object.keys(definition.scopes).sort(), + bearer_methods_supported: [], + dpop_bound_access_tokens_required: true, + }), + ) + app.get(`${basePath}/openapi.json`, (c) => + c.json(definition.openapi, 200, { 'Content-Type': 'application/vnd.oai.openapi+json' }), + ) + app.get(basePath, (c) => + c.json( + { + resource: definition.resource, + serviceDescription: `${definition.resource}/openapi.json`, + authorizationModel: 'external', + ...definition.representation, + }, + 200, + { + Link: `<${definition.resource}/openapi.json>; rel="service-desc"; type="application/vnd.oai.openapi+json"`, + }, + ), + ) + for (const operation of definition.operations) { + app.on(operation.method.toUpperCase(), `${basePath}${operation.path}`, (c) => execute(c, operation)) + } + app.all(`${basePath}/*`, () => { + throw new HttpProblem(404, 'about:blank', 'Not Found', `${definition.id} operation is not published.`) + }) + }, + } + + async function execute(c: Context, operation: ManagedOpenApiOperation) { + const principal = await dependencies.authenticator.authenticate(c.req.raw, definition.resource) + const requiredScope = operation.scopes.find((scope) => principal.scopes.has(scope)) + if (!requiredScope) { + throw insufficientScope( + `The Agent token does not authorize this ${definition.id} operation.`, + operation.scopes.map((scope) => [scope]), + ) + } + const credential = await dependencies.credential(principal.subject) + if (!credential.scopes.includes(requiredScope)) { + throw forbidden(`The ${definition.id} OAuth grant does not authorize this operation.`) + } + + const upstream = new URL(definition.upstreamOrigin) + upstream.pathname = `${upstream.pathname.replace(/\/$/, '')}${operation.upstreamPath}` + upstream.search = new URL(c.req.url).search + const headers = sanitizedHeaders(c.req.raw.headers, removedRequestHeaders) + headers.set('authorization', credential.authorization) + const body = bodyFor(c.req.raw) + const startedAt = Date.now() + const response = await request(upstream, { + method: operation.method, + headers, + ...(body ? { body } : {}), + ...(body instanceof ReadableStream ? { duplex: 'half' as const } : {}), + redirect: 'manual', + signal: AbortSignal.timeout(15_000), + } as RequestInit & { duplex?: 'half' }) + await dependencies.audit({ + event: 'provider.operation', + requestId: c.get('requestId'), + provider: definition.id, + operationId: operation.operationId, + method: operation.method, + pathTemplate: operation.path, + scope: requiredScope, + originatingPrincipal: { issuer: principal.actor.issuer, subject: principal.actor.subject }, + providerActor: { type: credential.actorType }, + identityLevel: 'provider-delegated', + result: { status: response.status }, + durationMs: Date.now() - startedAt, + occurredAt: new Date().toISOString(), + }) + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: sanitizedHeaders(response.headers, removedResponseHeaders), + }) + } +} + +function assertDefinition(definition: ManagedOpenApiDefinition) { + if (new URL(definition.resource).origin !== new URL(definition.issuer).origin) { + throw new TypeError('Managed OpenAPI resource and issuer must share an origin.') + } + const signatures = new Set() + for (const operation of definition.operations) { + if (!operation.path.startsWith('/') || !operation.upstreamPath.startsWith('/')) { + throw new TypeError('Managed OpenAPI operation paths must be absolute paths.') + } + if (operation.scopes.length === 0 || operation.scopes.some((scope) => !(scope in definition.scopes))) { + throw new TypeError(`Managed OpenAPI operation ${operation.operationId} has an undeclared scope.`) + } + const signature = `${operation.method.toUpperCase()} ${operation.path}` + if (signatures.has(signature)) throw new TypeError(`Duplicate managed OpenAPI operation: ${signature}`) + signatures.add(signature) + } +} + +function sanitizedHeaders(source: Headers, removed: ReadonlySet) { + const result = new Headers() + for (const [name, value] of source) { + if (!removed.has(name.toLowerCase())) result.append(name, value) + } + return result +} + +function bodyFor(request: Request) { + return request.method === 'GET' || request.method === 'HEAD' ? undefined : request.body +} diff --git a/src/providers/context7/adapter.ts b/src/providers/context7/adapter.ts new file mode 100644 index 0000000..9f890c7 --- /dev/null +++ b/src/providers/context7/adapter.ts @@ -0,0 +1,96 @@ +import type { AdapterModule } from '../../core/adapter.js' +import { createManagedOpenApiAdapter } from '../../core/managed-openapi-adapter.js' +import type { RealmrootAuthenticator } from '../../core/realmroot-auth.js' +import type { Context7AdapterConfig } from './config.js' +import { type Context7OAuthClient, context7AgentScope, type D1Context7Credentials } from './oauth.js' +import { context7OpenApi } from './openapi.js' + +export function createContext7Adapter( + config: Context7AdapterConfig, + dependencies: { + authenticator: RealmrootAuthenticator + provider: Context7OAuthClient + credentials: D1Context7Credentials + audit(record: Record): Promise + fetch?: typeof fetch + }, +): AdapterModule { + const resource = `${config.origin}/context7` + const issuer = `${config.origin}/oauth/context7` + const operations = [ + { + operationId: 'listContext7Libraries', + method: 'GET', + path: '/libraries', + upstreamPath: '/v2/libs/search', + scopes: [context7AgentScope], + }, + { + operationId: 'getContext7Documentation', + method: 'GET', + path: '/documentation', + upstreamPath: '/v2/context', + scopes: [context7AgentScope], + }, + ] as const + + return createManagedOpenApiAdapter( + { + id: 'context7', + resource, + issuer, + upstreamOrigin: config.context7ApiOrigin, + scopes: { [context7AgentScope]: 'Resolve libraries and read current software documentation.' }, + operations, + openapi: context7OpenApi({ resource, issuer }), + representation: { upstream: 'context7', operationMode: 'configured-openapi' }, + manifest: { + schemaVersion: '0.1', + provider: 'context7', + status: 'experimental', + identity: { + level: 'provider-delegated', + visibleInProduct: false, + visibleInAuditLog: false, + attribution: 'audit-only', + }, + actorModes: ['oauth-delegated-user'], + credentialModes: ['adapter-dynamic-public-oauth'], + resourceTypes: ['library', 'documentation'], + scopes: { [context7AgentScope]: { providerPermissions: { oauthScope: 'openid offline_access' } } }, + operations, + revocationSignals: ['adapter-oauth-revocation', 'context7-oauth-revocation'], + nativeReadinessGaps: ['ACTOR-NATIVE', 'AGENT-DISPLAY', 'DPOP'], + retirementCondition: 'Context7 accepts Realmroot Agent identity and proof-bound delegated authority directly.', + }, + }, + { + authenticator: dependencies.authenticator, + audit: dependencies.audit, + ...(dependencies.fetch ? { fetch: dependencies.fetch } : {}), + async credential(subject) { + let credential = await dependencies.credentials.credential(subject) + if (credential.expiresAt <= Date.now() + 30_000) { + const refreshed = await dependencies.provider.refresh(credential.refreshToken) + if (await dependencies.credentials.replace(credential, refreshed)) { + credential = { + ...credential, + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken ?? credential.refreshToken, + expiresAt: refreshed.expiresAt, + providerScopes: refreshed.scopes, + credentialVersion: credential.credentialVersion + 1, + } + } else { + credential = await dependencies.credentials.credential(subject) + } + } + return { + authorization: `Bearer ${credential.accessToken}`, + scopes: [context7AgentScope], + actorType: 'oauth_delegated_user', + } + }, + }, + ) +} diff --git a/src/providers/context7/config.ts b/src/providers/context7/config.ts new file mode 100644 index 0000000..d9af1fb --- /dev/null +++ b/src/providers/context7/config.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' +import type { AppConfig } from '../../config.js' + +const environmentSchema = z.object({ + CONTEXT7_API_ORIGIN: z.url().default('https://context7.com/api'), + CONTEXT7_OAUTH_ISSUER: z.url().default('https://clerk.context7.com'), + CONTEXT7_CREDENTIAL_ENCRYPTION_KEY: z.string().trim().min(1).optional(), +}) + +export type Context7AdapterConfig = AppConfig & { + context7ApiOrigin: string + context7OAuthIssuer: string + context7CredentialEncryptionKey?: string +} + +export function loadContext7Config(environment: unknown, config: AppConfig): Context7AdapterConfig { + const parsed = environmentSchema.parse(environment) + return { + ...config, + context7ApiOrigin: parsed.CONTEXT7_API_ORIGIN.replace(/\/+$/, ''), + context7OAuthIssuer: parsed.CONTEXT7_OAUTH_ISSUER.replace(/\/+$/, ''), + ...(parsed.CONTEXT7_CREDENTIAL_ENCRYPTION_KEY + ? { context7CredentialEncryptionKey: parsed.CONTEXT7_CREDENTIAL_ENCRYPTION_KEY } + : {}), + } +} diff --git a/src/providers/context7/oauth.ts b/src/providers/context7/oauth.ts new file mode 100644 index 0000000..e61cae7 --- /dev/null +++ b/src/providers/context7/oauth.ts @@ -0,0 +1,188 @@ +import { z } from 'zod' +import type { CredentialCipher } from '../../core/credential-cipher.js' +import type { createDynamicOAuthClient, DynamicOAuthToken } from '../../core/dynamic-oauth-client.js' +import type { ExternalProviderAuthorization } from '../../core/external-authorization-server.js' +import { failedDependency, forbidden } from '../../core/problem.js' + +const identitySchema = z + .object({ + sub: z.string().min(1), + name: z.string().min(1).optional(), + preferred_username: z.string().min(1).optional(), + email: z.string().email().optional(), + }) + .passthrough() + +export const context7AgentScope = 'documentation:read' +export const context7ProviderScopes = ['openid', 'profile', 'email', 'offline_access'] as const + +export type Context7OAuthClient = ReturnType +export type Context7Credential = Readonly<{ + subject: string + displayName: string + accessToken: string + refreshToken: string + expiresAt: number + providerScopes: readonly string[] + credentialVersion: number +}> + +export class D1Context7Credentials { + constructor( + private readonly db: D1Database, + private readonly cipher: CredentialCipher, + ) {} + + sealVerifier(verifier: string) { + return this.cipher.seal(verifier, 'context7:oauth-intent:pkce') + } + + openVerifier(verifier: string) { + return this.cipher.open(verifier, 'context7:oauth-intent:pkce') + } + + async upsert(identity: { subject: string; displayName: string }, token: DynamicOAuthToken) { + if (!token.refreshToken) throw failedDependency('Context7 did not issue the required refresh token.') + const context = `context7:${identity.subject}` + const [accessToken, refreshToken] = await Promise.all([ + this.cipher.seal(token.accessToken, `${context}:access`), + this.cipher.seal(token.refreshToken, `${context}:refresh`), + ]) + await this.db + .prepare( + `INSERT INTO context7_external_credential + (subject, display_name, access_token_ciphertext, refresh_token_ciphertext, token_expires_at, + provider_scope_json, credential_version, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?) + ON CONFLICT(subject) DO UPDATE SET display_name = excluded.display_name, + access_token_ciphertext = excluded.access_token_ciphertext, + refresh_token_ciphertext = excluded.refresh_token_ciphertext, + token_expires_at = excluded.token_expires_at, provider_scope_json = excluded.provider_scope_json, + credential_version = context7_external_credential.credential_version + 1, + updated_at = excluded.updated_at`, + ) + .bind( + identity.subject, + identity.displayName, + accessToken, + refreshToken, + token.expiresAt, + JSON.stringify(token.scopes), + Date.now(), + ) + .run() + } + + async credential(subject: string): Promise { + const row = await this.db + .prepare( + `SELECT subject, display_name AS displayName, access_token_ciphertext AS accessToken, + refresh_token_ciphertext AS refreshToken, token_expires_at AS expiresAt, + provider_scope_json AS providerScopesJson, credential_version AS credentialVersion + FROM context7_external_credential WHERE subject = ?`, + ) + .bind(subject) + .first<{ + subject: string + displayName: string + accessToken: string + refreshToken: string + expiresAt: number + providerScopesJson: string + credentialVersion: number + }>() + if (!row) throw forbidden('Active Context7 authorization is required.') + const context = `context7:${row.subject}` + const [accessToken, refreshToken] = await Promise.all([ + this.cipher.open(row.accessToken, `${context}:access`), + this.cipher.open(row.refreshToken, `${context}:refresh`), + ]) + return { + ...row, + accessToken, + refreshToken, + providerScopes: z.array(z.string()).parse(JSON.parse(row.providerScopesJson)), + } + } + + async replace(credential: Context7Credential, token: DynamicOAuthToken) { + const refreshTokenValue = token.refreshToken ?? credential.refreshToken + const context = `context7:${credential.subject}` + const [accessToken, refreshToken] = await Promise.all([ + this.cipher.seal(token.accessToken, `${context}:access`), + this.cipher.seal(refreshTokenValue, `${context}:refresh`), + ]) + const result = await this.db + .prepare( + `UPDATE context7_external_credential SET access_token_ciphertext = ?, refresh_token_ciphertext = ?, + token_expires_at = ?, provider_scope_json = ?, credential_version = credential_version + 1, updated_at = ? + WHERE subject = ? AND credential_version = ?`, + ) + .bind( + accessToken, + refreshToken, + token.expiresAt, + JSON.stringify(token.scopes), + Date.now(), + credential.subject, + credential.credentialVersion, + ) + .run() + return result.meta.changes === 1 + } + + async revoke(subject: string) { + await this.db.prepare('DELETE FROM context7_external_credential WHERE subject = ?').bind(subject).run() + } +} + +export function createContext7ExternalAuthorization(input: { + origin: string + provider: Context7OAuthClient + credentials: D1Context7Credentials +}): ExternalProviderAuthorization { + return { + id: 'context7', + resource: `${input.origin}/context7`, + scopes: ['openid', 'profile', 'email', 'offline_access', context7AgentScope], + async validateGrant({ subject }) { + await input.credentials.credential(subject) + return true + }, + async revoke(subject) { + const credential = await input.credentials.credential(subject) + await input.provider.revoke(credential.refreshToken) + await input.credentials.revoke(subject) + }, + async begin({ providerState }) { + const started = await input.provider.authorizationUrl(providerState) + return { + url: started.url, + stage: 'provider', + data: { verifier: await input.credentials.sealVerifier(started.verifier) }, + } + }, + async complete({ callbackUrl, intent }) { + const code = new URL(callbackUrl).searchParams.get('code') + if (!code) throw failedDependency('Context7 OAuth callback did not include a code.') + const encryptedVerifier = intent.providerData.verifier + if (typeof encryptedVerifier !== 'string') throw failedDependency('Context7 OAuth PKCE state is invalid.') + const token = await input.provider.exchangeCode(code, await input.credentials.openVerifier(encryptedVerifier)) + const identity = identitySchema.parse(await input.provider.userInfo(token.accessToken)) + const resolved = { + subject: identity.sub, + displayName: identity.name ?? identity.preferred_username ?? identity.email ?? identity.sub, + } + await input.credentials.upsert(resolved, token) + return { + type: 'complete', + grant: { + subject: resolved.subject, + displayName: resolved.displayName, + scopes: intent.scopes, + authorizationDetails: [], + }, + } + }, + } +} diff --git a/src/providers/context7/openapi.ts b/src/providers/context7/openapi.ts new file mode 100644 index 0000000..7219a69 --- /dev/null +++ b/src/providers/context7/openapi.ts @@ -0,0 +1,127 @@ +const documentationScope = 'documentation:read' + +export function context7OpenApi(input: { resource: string; issuer: string }) { + const security = [{ context7Documentation: [documentationScope] }] + const query = { + name: 'query', + in: 'query', + required: true, + description: 'The current task or question, used by Context7 for relevance ranking.', + schema: { type: 'string', minLength: 1, maxLength: 500 }, + } + const fast = { + name: 'fast', + in: 'query', + required: false, + schema: { type: 'boolean', default: false }, + } + return { + openapi: '3.1.0', + info: { + title: 'Context7 through Realmroot', + version: '2026-09-04', + description: + 'Resolve software libraries and retrieve current documentation through an Agent-bound OAuth resource.', + }, + servers: [{ url: input.resource }], + paths: { + '/libraries': { + get: { + operationId: 'listContext7Libraries', + summary: 'List libraries matching a name and task', + security, + parameters: [ + { + name: 'libraryName', + in: 'query', + required: true, + description: 'Library name, such as react, nextjs, or hono.', + schema: { type: 'string', minLength: 1, maxLength: 500 }, + }, + query, + fast, + ], + responses: { + 200: { + description: 'Ranked Context7 library resources.', + content: { 'application/json': { schema: { $ref: '#/components/schemas/LibraryCollection' } } }, + }, + default: { description: 'Context7 response or Realmroot authorization failure.' }, + }, + }, + }, + '/documentation': { + get: { + operationId: 'getContext7Documentation', + summary: 'Get documentation for a resolved library', + security, + parameters: [ + { + name: 'libraryId', + in: 'query', + required: true, + description: 'Exact Context7 library identifier returned by the libraries collection.', + schema: { type: 'string', minLength: 1, maxLength: 500 }, + }, + query, + { + name: 'type', + in: 'query', + required: false, + schema: { type: 'string', enum: ['json', 'txt'], default: 'txt' }, + }, + fast, + ], + responses: { + 200: { + description: 'Context7 documentation selected for the task.', + content: { + 'text/plain': { schema: { type: 'string' } }, + 'application/json': { schema: { type: 'object', additionalProperties: true } }, + }, + }, + default: { description: 'Context7 response or Realmroot authorization failure.' }, + }, + }, + }, + }, + components: { + schemas: { + LibraryCollection: { + type: 'object', + required: ['results'], + properties: { + results: { type: 'array', items: { $ref: '#/components/schemas/Library' } }, + searchFilterApplied: { type: 'boolean' }, + }, + additionalProperties: true, + }, + Library: { + type: 'object', + required: ['id', 'title'], + properties: { + id: { type: 'string' }, + title: { type: 'string' }, + description: { type: ['string', 'null'] }, + branch: { type: ['string', 'null'] }, + lastUpdateDate: { type: ['string', 'null'], format: 'date-time' }, + totalTokens: { type: ['integer', 'null'] }, + totalSnippets: { type: ['integer', 'null'] }, + stars: { type: ['integer', 'null'] }, + trustScore: { type: ['number', 'null'] }, + }, + additionalProperties: true, + }, + }, + securitySchemes: { + context7Documentation: { + type: 'openIdConnect', + openIdConnectUrl: `${input.issuer}/.well-known/openid-configuration`, + 'x-dpop-required': true, + description: 'Realmroot Agent credential with approved Context7 documentation access.', + }, + }, + }, + 'x-provider-upstream': 'https://context7.com/api', + } +} diff --git a/src/worker.ts b/src/worker.ts index 97523cd..5afcac2 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -3,6 +3,7 @@ import { createApp } from './app.js' import { loadConfig } from './config.js' import type { AdapterModule } from './core/adapter.js' import { createCredentialCipher } from './core/credential-cipher.js' +import { createDynamicOAuthClient, D1DynamicOAuthRegistrationStore } from './core/dynamic-oauth-client.js' import { createExternalAuthorizationServer } from './core/external-authorization-server.js' import { D1ExternalOAuthStore } from './core/external-oauth-store.js' import { createCloudflareAdapter } from './providers/cloudflare/adapter.js' @@ -13,6 +14,13 @@ import { createCloudflareOAuthProvider, D1CloudflareCredentials, } from './providers/cloudflare/oauth.js' +import { createContext7Adapter } from './providers/context7/adapter.js' +import { loadContext7Config } from './providers/context7/config.js' +import { + context7ProviderScopes, + createContext7ExternalAuthorization, + D1Context7Credentials, +} from './providers/context7/oauth.js' import { createGitHubAdapter } from './providers/github/adapter.js' import { createGitHubConnectionProvider, createGitHubProvider } from './providers/github/client.js' import { loadGitHubConfig } from './providers/github/config.js' @@ -35,6 +43,7 @@ export default { const config = loadConfig(env, request.url) const githubConfig = loadGitHubConfig(env, config) const cloudflareConfig = loadCloudflareConfig(env, config) + const context7Config = loadContext7Config(env, config) const linearConfig = loadLinearConfig(env, config) const state = new D1RuntimeState(env.DB) const oauthStore = new D1ExternalOAuthStore(env.DB) @@ -166,6 +175,43 @@ export default { }), ) } + if (context7Config.context7CredentialEncryptionKey) { + if (!signingPrivateJwk) throw new Error('Context7 external authorization is not configured.') + const context7Credentials = new D1Context7Credentials( + env.DB, + createCredentialCipher(context7Config.context7CredentialEncryptionKey), + ) + const context7Provider = createDynamicOAuthClient({ + providerId: 'context7', + clientName: 'Realmroot Context7 Adapter', + issuer: context7Config.context7OAuthIssuer, + redirectUri: `${config.origin}/oauth/context7/provider/callback`, + scopes: context7ProviderScopes, + registrationStore: new D1DynamicOAuthRegistrationStore(env.DB), + fetcher: fetch, + }) + const context7Authorization = await createExternalAuthorizationServer({ + origin: config.origin, + provider: createContext7ExternalAuthorization({ + origin: config.origin, + provider: context7Provider, + credentials: context7Credentials, + }), + store: oauthStore, + signingPrivateJwk, + replayStore: state, + }) + adapters.push( + context7Authorization, + createContext7Adapter(context7Config, { + authenticator: context7Authorization.authenticator, + provider: context7Provider, + credentials: context7Credentials, + audit: (record) => state.recordAudit(record), + fetch, + }), + ) + } const app = createApp(adapters) return tracing.enterSpan('adapter.router.dispatch', () => app.fetch(request, env, executionContext)) }) diff --git a/test/core/dynamic-oauth-client.test.ts b/test/core/dynamic-oauth-client.test.ts new file mode 100644 index 0000000..fb00563 --- /dev/null +++ b/test/core/dynamic-oauth-client.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' +import { createDynamicOAuthClient, type DynamicOAuthRegistrationStore } from '../../src/core/dynamic-oauth-client.js' + +describe('Dynamic OAuth client', () => { + it('[spec: context7-adapter/context7-provider-oauth] registers a public client once and uses PKCE', async () => { + let clientId: string | null = null + const store: DynamicOAuthRegistrationStore = { + clientId: vi.fn(async () => clientId), + saveClientId: vi.fn(async (_providerId, value) => { + clientId = value + return value + }), + } + const fetcher = vi.fn(async (request: string | URL | Request, init?: RequestInit) => { + const url = new URL(request instanceof Request ? request.url : request) + if (url.pathname === '/oauth/register') { + expect(JSON.parse(String(init?.body))).toMatchObject({ + token_endpoint_auth_method: 'none', + scope: 'openid offline_access', + }) + return Response.json({ client_id: 'dynamic-client' }, { status: 201 }) + } + if (url.pathname === '/oauth/token') { + return Response.json({ + access_token: 'context7-access', + refresh_token: 'context7-refresh', + expires_in: 3600, + scope: 'openid offline_access', + }) + } + throw new Error(`Unexpected request ${url}`) + }) + const client = createDynamicOAuthClient({ + providerId: 'context7', + clientName: 'Realmroot Context7 Adapter', + issuer: 'https://clerk.context7.com', + redirectUri: 'https://adapter.example/oauth/context7/provider/callback', + scopes: ['openid', 'offline_access'], + registrationStore: store, + fetcher: fetcher as typeof fetch, + now: () => 1_000, + }) + + const first = await client.authorizationUrl('provider-state') + const second = await client.authorizationUrl('another-state') + const url = new URL(first.url) + expect(url.searchParams.get('client_id')).toBe('dynamic-client') + expect(url.searchParams.get('code_challenge_method')).toBe('S256') + expect(url.searchParams.get('code_challenge')).toHaveLength(43) + expect(first.verifier).not.toBe(second.verifier) + expect( + fetcher.mock.calls.filter(([request]) => new URL(String(request)).pathname === '/oauth/register'), + ).toHaveLength(1) + + await expect(client.exchangeCode('provider-code', first.verifier)).resolves.toMatchObject({ + accessToken: 'context7-access', + refreshToken: 'context7-refresh', + expiresAt: 3_601_000, + }) + const tokenRequest = fetcher.mock.calls.find(([request]) => new URL(String(request)).pathname === '/oauth/token') + const tokenInit = tokenRequest?.[1] as RequestInit + expect(String(tokenInit.body)).toContain(`code_verifier=${first.verifier}`) + expect(String(tokenInit.body)).toContain('client_id=dynamic-client') + }) +}) diff --git a/test/core/managed-openapi-adapter.test.ts b/test/core/managed-openapi-adapter.test.ts new file mode 100644 index 0000000..85bb7fe --- /dev/null +++ b/test/core/managed-openapi-adapter.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from 'vitest' +import { createApp } from '../../src/app.js' +import { createManagedOpenApiAdapter } from '../../src/core/managed-openapi-adapter.js' +import type { RealmrootAuthenticator } from '../../src/core/realmroot-auth.js' + +describe('Managed OpenAPI adapter', () => { + it('[spec: context7-adapter/context7-library-discovery] forwards only configured operations with sanitized credentials', async () => { + const upstream = vi.fn(async (_request: string | URL | Request, _init?: RequestInit) => + Response.json( + { results: [{ id: '/honojs/hono', title: 'Hono' }] }, + { headers: { 'x-upstream-request': 'request-1', 'set-cookie': 'secret=cookie' } }, + ), + ) + const audit = vi.fn(async () => {}) + const app = createApp([ + createManagedOpenApiAdapter(definition(), { + authenticator: authenticator(['documentation:read']), + credential: vi.fn(async () => ({ + authorization: 'Bearer upstream-secret', + scopes: ['documentation:read'], + actorType: 'oauth_delegated_user', + })), + audit, + fetch: upstream as typeof fetch, + }), + ]) + + const response = await app.request( + '/context7/libraries?libraryName=hono&query=middleware', + { headers: { authorization: 'DPoP agent-secret', dpop: 'proof', cookie: 'session=secret' } }, + { requestId: 'unused' }, + ) + expect(response.status).toBe(200) + expect(response.headers.get('x-upstream-request')).toBe('request-1') + expect(response.headers.get('set-cookie')).toBeNull() + const [url, init] = upstream.mock.calls[0] ?? [] + expect(String(url)).toBe('https://context7.com/api/v2/libs/search?libraryName=hono&query=middleware') + const headers = new Headers((init as RequestInit).headers) + expect(headers.get('authorization')).toBe('Bearer upstream-secret') + expect(headers.has('dpop')).toBe(false) + expect(headers.has('cookie')).toBe(false) + expect(audit).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'context7', + operationId: 'listContext7Libraries', + pathTemplate: '/libraries', + scope: 'documentation:read', + result: { status: 200 }, + }), + ) + expect(JSON.stringify(audit.mock.calls)).not.toContain('middleware') + expect(JSON.stringify(audit.mock.calls)).not.toContain('upstream-secret') + + const hidden = await app.request('/context7/v2/libs/search') + expect(hidden.status).toBe(404) + }) + + it('[spec: context7-adapter/context7-contract] publishes RFC 9728 metadata and scope-bearing OpenAPI', async () => { + const app = createApp([ + createManagedOpenApiAdapter(definition(), { + authenticator: authenticator(['documentation:read']), + credential: vi.fn(), + audit: vi.fn(), + }), + ]) + await expect((await app.request('/.well-known/oauth-protected-resource/context7')).json()).resolves.toMatchObject({ + resource: 'https://adapter.example/context7', + authorization_servers: ['https://adapter.example/oauth/context7'], + scopes_supported: ['documentation:read'], + dpop_bound_access_tokens_required: true, + }) + const resource = await app.request('/context7') + expect(resource.headers.get('link')).toContain('rel="service-desc"') + const openapi = await app.request('/context7/openapi.json') + expect(openapi.headers.get('content-type')).toContain('application/vnd.oai.openapi+json') + }) + + it('returns an insufficient-scope challenge before resolving an upstream credential', async () => { + const credential = vi.fn() + const app = createApp([ + createManagedOpenApiAdapter(definition(), { + authenticator: authenticator([]), + credential, + audit: vi.fn(), + }), + ]) + const response = await app.request('/context7/libraries?libraryName=hono&query=middleware') + expect(response.status).toBe(403) + expect(response.headers.get('www-authenticate')).toContain('documentation:read') + expect(credential).not.toHaveBeenCalled() + }) +}) + +function definition() { + return { + id: 'context7', + resource: 'https://adapter.example/context7', + issuer: 'https://adapter.example/oauth/context7', + upstreamOrigin: 'https://context7.com/api', + scopes: { 'documentation:read': 'Read documentation.' }, + operations: [ + { + operationId: 'listContext7Libraries', + method: 'GET', + path: '/libraries', + upstreamPath: '/v2/libs/search', + scopes: ['documentation:read'], + }, + ], + openapi: { openapi: '3.1.0' }, + manifest: { provider: 'context7' }, + } as const +} + +function authenticator(scopes: string[]): RealmrootAuthenticator { + return { + authenticate: vi.fn(async () => ({ + subject: 'context7-user-1', + issuer: 'https://adapter.example/oauth/context7', + actor: { issuer: 'https://id.example/api/auth', subject: 'agent-1', profile: 'ai_agent' as const }, + scopes: new Set(scopes), + connectionId: 'connection-1', + authorizationDetails: [], + })), + } +} diff --git a/test/integration/context7-credentials.test.ts b/test/integration/context7-credentials.test.ts new file mode 100644 index 0000000..e26af46 --- /dev/null +++ b/test/integration/context7-credentials.test.ts @@ -0,0 +1,44 @@ +import { env } from 'cloudflare:test' +import { describe, expect, it } from 'vitest' +import { createCredentialCipher } from '../../src/core/credential-cipher.js' +import { D1DynamicOAuthRegistrationStore } from '../../src/core/dynamic-oauth-client.js' +import { D1Context7Credentials } from '../../src/providers/context7/oauth.js' + +describe('Context7 D1 state', () => { + it('[spec: context7-adapter/context7-provider-oauth] persists one dynamic client and encrypts provider credentials', async () => { + const registrations = new D1DynamicOAuthRegistrationStore(env.DB) + await expect(registrations.saveClientId('context7', 'client-first')).resolves.toBe('client-first') + await expect(registrations.saveClientId('context7', 'client-racing')).resolves.toBe('client-first') + + const credentials = new D1Context7Credentials( + env.DB, + createCredentialCipher('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), + ) + await credentials.upsert( + { subject: 'context7-user', displayName: 'Context User' }, + { + accessToken: 'plain-access-token', + refreshToken: 'plain-refresh-token', + expiresAt: 10_000, + scopes: ['openid', 'offline_access'], + }, + ) + const row = await env.DB.prepare( + `SELECT access_token_ciphertext AS accessToken, refresh_token_ciphertext AS refreshToken + FROM context7_external_credential WHERE subject = ?`, + ) + .bind('context7-user') + .first<{ accessToken: string; refreshToken: string }>() + expect(row?.accessToken).not.toContain('plain-access-token') + expect(row?.refreshToken).not.toContain('plain-refresh-token') + await expect(credentials.credential('context7-user')).resolves.toMatchObject({ + accessToken: 'plain-access-token', + refreshToken: 'plain-refresh-token', + displayName: 'Context User', + }) + + const sealedVerifier = await credentials.sealVerifier('pkce-verifier') + expect(sealedVerifier).not.toContain('pkce-verifier') + await expect(credentials.openVerifier(sealedVerifier)).resolves.toBe('pkce-verifier') + }) +}) diff --git a/test/providers/context7-openapi.test.ts b/test/providers/context7-openapi.test.ts new file mode 100644 index 0000000..6a3a630 --- /dev/null +++ b/test/providers/context7-openapi.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { context7OpenApi } from '../../src/providers/context7/openapi.js' + +describe('Context7 OpenAPI', () => { + it('[spec: context7-adapter/context7-contract] publishes noun-based resources with explicit OAuth authority', () => { + const document = context7OpenApi({ + resource: 'https://adapter.example/context7', + issuer: 'https://adapter.example/oauth/context7', + }) + expect(Object.keys(document.paths)).toEqual(['/libraries', '/documentation']) + for (const path of Object.values(document.paths)) { + expect(path.get.security).toEqual([{ context7Documentation: ['documentation:read'] }]) + expect(path.get.operationId).not.toMatch(/search|query|execute/i) + } + expect(document.components.securitySchemes.context7Documentation).toMatchObject({ + type: 'openIdConnect', + 'x-dpop-required': true, + }) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 2e3f376..4396da5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -36,6 +36,8 @@ export default defineConfig({ LINEAR_API_ORIGIN: 'https://api.linear.app', LINEAR_AUTHORIZATION_ORIGIN: 'https://linear.app', LINEAR_CREDENTIAL_ENCRYPTION_KEY: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + CONTEXT7_API_ORIGIN: 'https://context7.com/api', + CONTEXT7_OAUTH_ISSUER: 'https://clerk.context7.com', }, }, })), diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 9c0a40c..f35fca4 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --env-file=.dev.vars.example --strict-vars=false` (hash: b7a9d5af36095ca8d444ae9520538651) +// Generated by Wrangler by running `wrangler types --env-file=.dev.vars.example --strict-vars=false` (hash: 5057dec8cc23a8e43b9eb3ad422511f7) // Runtime types generated with workerd@1.20260801.1 2026-08-08 nodejs_compat interface __BaseEnv_Env { DB: D1Database; @@ -12,6 +12,8 @@ interface __BaseEnv_Env { LINEAR_AUTHORIZATION_ORIGIN: string; CLOUDFLARE_API_ORIGIN: string; CLOUDFLARE_AUTHORIZATION_ORIGIN: string; + CONTEXT7_API_ORIGIN: string; + CONTEXT7_OAUTH_ISSUER: string; GITHUB_APP_ID: string; GITHUB_PRIVATE_KEY: string; GITHUB_CLIENT_ID: string; @@ -25,6 +27,7 @@ interface __BaseEnv_Env { CLOUDFLARE_CLIENT_ID: string; CLOUDFLARE_CLIENT_SECRET: string; CLOUDFLARE_CREDENTIAL_ENCRYPTION_KEY: string; + CONTEXT7_CREDENTIAL_ENCRYPTION_KEY: string; } declare namespace Cloudflare { interface GlobalProps { @@ -37,7 +40,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/wrangler.jsonc b/wrangler.jsonc index ee6c71e..bc8d765 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -16,7 +16,9 @@ "LINEAR_API_ORIGIN": "https://api.linear.app", "LINEAR_AUTHORIZATION_ORIGIN": "https://linear.app", "CLOUDFLARE_API_ORIGIN": "https://api.cloudflare.com/client/v4", - "CLOUDFLARE_AUTHORIZATION_ORIGIN": "https://dash.cloudflare.com" + "CLOUDFLARE_AUTHORIZATION_ORIGIN": "https://dash.cloudflare.com", + "CONTEXT7_API_ORIGIN": "https://context7.com/api", + "CONTEXT7_OAUTH_ISSUER": "https://clerk.context7.com" }, "assets": { "directory": "./public" }, "secrets": { @@ -33,7 +35,8 @@ "ADAPTER_OAUTH_SIGNING_PRIVATE_JWK", "CLOUDFLARE_CLIENT_ID", "CLOUDFLARE_CLIENT_SECRET", - "CLOUDFLARE_CREDENTIAL_ENCRYPTION_KEY" + "CLOUDFLARE_CREDENTIAL_ENCRYPTION_KEY", + "CONTEXT7_CREDENTIAL_ENCRYPTION_KEY" ] }, "d1_databases": [ From 907e66354063b2a532a7a61759ec68dec18de6d4 Mon Sep 17 00:00:00 2001 From: jarvis Date: Fri, 4 Sep 2026 14:40:47 -0400 Subject: [PATCH 2/2] fix(context7): publish canonical OIDC discovery URL --- src/core/external-authorization-server.ts | 12 +++++++++++- src/providers/context7/openapi.ts | 4 +++- test/core/external-authorization-server.test.ts | 9 +++++++++ test/providers/context7-openapi.test.ts | 1 + 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/core/external-authorization-server.ts b/src/core/external-authorization-server.ts index e9c3760..05fbeea 100644 --- a/src/core/external-authorization-server.ts +++ b/src/core/external-authorization-server.ts @@ -87,6 +87,15 @@ export type ExternalAuthorizationServer = { authenticator: RealmrootAuthenticator } +export function openIdConfigurationUrl(issuer: string) { + const url = new URL(issuer) + const issuerPath = url.pathname.replace(/\/$/, '') + url.pathname = `/.well-known/openid-configuration${issuerPath}` + url.search = '' + url.hash = '' + return url.toString() +} + export async function createExternalAuthorizationServer(input: { origin: string provider: ExternalProviderAuthorization @@ -96,6 +105,7 @@ export async function createExternalAuthorizationServer(input: { replayStore: DpopReplayStore }): Promise { const issuer = `${input.origin}/oauth/${input.provider.id}` + const openIdConfiguration = new URL(openIdConfigurationUrl(issuer)) const providerCallbackPath = input.providerCallbackPath ?? `/oauth/${input.provider.id}/provider/callback` const privateKey = await importJWK(input.signingPrivateJwk, 'ES256') const signingKid = input.signingPrivateJwk.kid ?? 'adapter-oauth-signing-key' @@ -112,7 +122,7 @@ export async function createExternalAuthorizationServer(input: { id: `${input.provider.id}-authorization-server`, register(app) { app.get(`/.well-known/oauth-authorization-server/oauth/${input.provider.id}`, (c) => c.json(metadata())) - app.get(`/.well-known/openid-configuration/oauth/${input.provider.id}`, (c) => c.json(metadata())) + app.get(openIdConfiguration.pathname, (c) => c.json(metadata())) app.get(`/oauth/${input.provider.id}/jwks`, (c) => c.json({ keys: [publicJwk] })) app.post(`/oauth/${input.provider.id}/register`, async (c) => { const body = await c.req.json>() diff --git a/src/providers/context7/openapi.ts b/src/providers/context7/openapi.ts index 7219a69..b0d32f8 100644 --- a/src/providers/context7/openapi.ts +++ b/src/providers/context7/openapi.ts @@ -1,3 +1,5 @@ +import { openIdConfigurationUrl } from '../../core/external-authorization-server.js' + const documentationScope = 'documentation:read' export function context7OpenApi(input: { resource: string; issuer: string }) { @@ -116,7 +118,7 @@ export function context7OpenApi(input: { resource: string; issuer: string }) { securitySchemes: { context7Documentation: { type: 'openIdConnect', - openIdConnectUrl: `${input.issuer}/.well-known/openid-configuration`, + openIdConnectUrl: openIdConfigurationUrl(input.issuer), 'x-dpop-required': true, description: 'Realmroot Agent credential with approved Context7 documentation access.', }, diff --git a/test/core/external-authorization-server.test.ts b/test/core/external-authorization-server.test.ts index 4d25050..d03c4c2 100644 --- a/test/core/external-authorization-server.test.ts +++ b/test/core/external-authorization-server.test.ts @@ -4,16 +4,25 @@ import { createApp } from '../../src/app.js' import { createExternalAuthorizationServer, type ExternalProviderAuthorization, + openIdConfigurationUrl, } from '../../src/core/external-authorization-server.js' import { type D1ExternalOAuthStore, type ExternalOAuthIntent, sha256 } from '../../src/core/external-oauth-store.js' describe('external authorization server', () => { + it('places OpenID configuration before an issuer path', () => { + expect(openIdConfigurationUrl('https://adapter.example/oauth/example')).toBe( + 'https://adapter.example/.well-known/openid-configuration/oauth/example', + ) + }) + it('publishes the standard OAuth surface for one provider', async () => { const { app } = await testServer() const response = await app.request('/.well-known/oauth-authorization-server/oauth/example') + const openIdResponse = await app.request('/.well-known/openid-configuration/oauth/example') expect(response.status).toBe(200) + expect(openIdResponse.status).toBe(200) await expect(response.json()).resolves.toMatchObject({ issuer: 'https://adapter.example/oauth/example', authorization_endpoint: 'https://adapter.example/oauth/example/authorize', diff --git a/test/providers/context7-openapi.test.ts b/test/providers/context7-openapi.test.ts index 6a3a630..8c74c75 100644 --- a/test/providers/context7-openapi.test.ts +++ b/test/providers/context7-openapi.test.ts @@ -14,6 +14,7 @@ describe('Context7 OpenAPI', () => { } expect(document.components.securitySchemes.context7Documentation).toMatchObject({ type: 'openIdConnect', + openIdConnectUrl: 'https://adapter.example/.well-known/openid-configuration/oauth/context7', 'x-dpop-required': true, }) })