From 8c2a15d3f7aeea6e20f2bfa0c85350b7a7f03b66 Mon Sep 17 00:00:00 2001 From: jarvis Date: Fri, 4 Sep 2026 18:17:16 -0400 Subject: [PATCH 1/3] feat(adapters): add configured Search1API and Fast.io providers --- .dev.vars.example | 11 +- README.md | 14 ++ docs/architecture.md | 22 ++-- providers/fastio/README.md | 29 +++++ providers/search1api/README.md | 31 +++++ providers/todoist/README.md | 5 +- src/core/configured-managed-provider.ts | 137 ++++++++++++++++++++ src/providers/cloudflare/config.ts | 10 +- src/providers/context7/adapter.ts | 81 ------------ src/providers/context7/config.ts | 26 ---- src/providers/context7/definition.ts | 60 +++++++++ src/providers/context7/oauth.ts | 44 ------- src/providers/fastio/definition.ts | 60 +++++++++ src/providers/fastio/openapi.ts | 85 ++++++++++++ src/providers/github/config.ts | 9 +- src/providers/linear/config.ts | 6 +- src/providers/search1api/definition.ts | 55 ++++++++ src/providers/search1api/openapi.ts | 77 +++++++++++ src/providers/todoist/adapter.ts | 84 ------------ src/providers/todoist/config.ts | 38 ------ src/providers/todoist/definition.ts | 61 +++++++++ src/providers/todoist/oauth.ts | 38 ------ src/providers/todoist/openapi.ts | 4 +- src/worker.ts | 124 ++++-------------- test/config.test.ts | 12 +- test/providers/configured-providers.test.ts | 75 +++++++++++ test/providers/todoist-oauth.test.ts | 15 ++- vitest.config.ts | 11 -- worker-configuration.d.ts | 20 +-- wrangler.jsonc | 20 +-- 30 files changed, 760 insertions(+), 504 deletions(-) create mode 100644 providers/fastio/README.md create mode 100644 providers/search1api/README.md create mode 100644 src/core/configured-managed-provider.ts delete mode 100644 src/providers/context7/adapter.ts delete mode 100644 src/providers/context7/config.ts create mode 100644 src/providers/context7/definition.ts delete mode 100644 src/providers/context7/oauth.ts create mode 100644 src/providers/fastio/definition.ts create mode 100644 src/providers/fastio/openapi.ts create mode 100644 src/providers/search1api/definition.ts create mode 100644 src/providers/search1api/openapi.ts delete mode 100644 src/providers/todoist/adapter.ts delete mode 100644 src/providers/todoist/config.ts create mode 100644 src/providers/todoist/definition.ts delete mode 100644 src/providers/todoist/oauth.ts create mode 100644 test/providers/configured-providers.test.ts diff --git a/.dev.vars.example b/.dev.vars.example index 271d251..93631d0 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -2,25 +2,18 @@ REALMROOT_ISSUER=https://local.realmroot.dev/api/auth REALMROOT_JWKS_URL=https://local.realmroot.dev/api/auth/jwks REALMROOT_AGENT_PROFILE_URI_TEMPLATE=https://local.realmroot.dev/api/public/agents/{subject} ADAPTER_OAUTH_SIGNING_PRIVATE_JWK={"kty":"EC","crv":"P-256","x":"replace","y":"replace","d":"replace","kid":"adapter-oauth-signing-key"} -GITHUB_API_ORIGIN=https://api.github.com -GITHUB_UPLOADS_ORIGIN=https://uploads.github.com GITHUB_APP_ID=replace-with-github-app-id GITHUB_PRIVATE_KEY=replace-with-pkcs1-or-pkcs8-private-key GITHUB_CLIENT_ID=replace-with-github-app-client-id GITHUB_CLIENT_SECRET=replace-with-github-app-client-secret GITHUB_WEBHOOK_SECRET=replace-with-github-app-webhook-secret -CLOUDFLARE_API_ORIGIN=https://api.cloudflare.com/client/v4 -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 TODOIST_CREDENTIAL_ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key -TODOIST_LOGIN_ENDPOINT=https://app.todoist.com/users/showlogin -LINEAR_API_ORIGIN=https://api.linear.app -LINEAR_AUTHORIZATION_ORIGIN=https://linear.app +SEARCH1API_CREDENTIAL_ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key +FASTIO_CREDENTIAL_ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key LINEAR_CLIENT_ID=replace-with-linear-oauth-client-id LINEAR_CLIENT_SECRET=replace-with-linear-oauth-client-secret LINEAR_CREDENTIAL_ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key diff --git a/README.md b/README.md index f623e49..abbf11b 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ identity model has already passed a capability review. | 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 | | Todoist | Provider-delegated user | Read-only OAuth user grant with Agent-attributed adapter audit | 1 | Experimental | +| Search1API | Provider-delegated user | Search OAuth grant with Agent-attributed adapter audit | 1 | Experimental | +| Fast.io | Provider-delegated user | Read-only workspace OAuth 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 | @@ -229,6 +231,18 @@ no public-client token revocation. Set only `TODOIST_CREDENTIAL_ENCRYPTION_KEY`; the Adapter dynamically registers the public client and publishes read-only project and task collections. +Search1API and Fast.io are configured through the same provider-definition +factory. Both support public dynamic client registration, S256 PKCE, refresh +tokens, and token revocation, so they need no provisioned client ID or secret. +Set `SEARCH1API_CREDENTIAL_ENCRYPTION_KEY` and +`FASTIO_CREDENTIAL_ENCRYPTION_KEY` respectively. Search1API publishes web/news +searches and usage; Fast.io publishes accessible workspaces and profile +availability. + +Provider API origins and OAuth endpoints are immutable code configuration, not +environment variables. Environment configuration is reserved for secrets and +deployment-specific Realmroot URLs. + Configure the GitHub App callbacks as: ```text diff --git a/docs/architecture.md b/docs/architecture.md index a4cfa50..c173cfe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -50,15 +50,19 @@ 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`. +Upstream OAuth is composed by the same provider-definition factory. Providers +with RFC 7591 dynamic client registration use the shared public-client +implementation with S256 PKCE, so deployment needs an encryption key but no +manually provisioned client ID or secret. Fixed provider API origins and OAuth +endpoints live in the definition; environment variables are reserved for +secrets and deployment-specific Realmroot URLs. More complex provider consent, +context selection, or lifecycle rules remain isolated in a provider module +while reusing the same Agent boundary. + +Context7, Todoist, Search1API, and Fast.io use this path. Their provider files +contain only identity decoding, fixed upstream/OAuth configuration, scopes, +the operation allowlist, a canonical OpenAPI document, and lifecycle metadata; +the Worker composition and credential lifecycle are shared. ## Target architecture diff --git a/providers/fastio/README.md b/providers/fastio/README.md new file mode 100644 index 0000000..33472a9 --- /dev/null +++ b/providers/fastio/README.md @@ -0,0 +1,29 @@ +# Fast.io Adapter + +Status: Experimental + +Fast.io is a provider-delegated, read-only workspace Resource at `/fastio`. +The connected Fast.io user remains the upstream principal while Realmroot and +Adapter audit retain the originating Agent. + +## Published resources + +| Agent operation | Upstream operation | Scope | +| --- | --- | --- | +| `GET /fastio/workspaces` | `GET /current/workspaces/all/` | `workspace:read` | +| `GET /fastio/profile-availability` | `GET /current/user/available_profiles/` | `workspace:read` | + +Unlisted Fast.io operations are not reachable. Calls are read-only and follow +Fast.io's native retry behavior; the Adapter adds no retries. + +## Authorization and lifecycle + +Fast.io supports public RFC 7591 registration, authorization code with S256 +PKCE, refresh tokens, and RFC 7009 token revocation. The definition requests +the `all_workspaces` provider scope. The Adapter stores the public client ID +and encrypts user credentials with `FASTIO_CREDENTIAL_ENCRYPTION_KEY`. +Provider API and OAuth endpoints are fixed in code. + +The Adapter can retire when Fast.io accepts and audits the stable Realmroot +Agent with proof-bound delegated authority directly. Current gaps are native +Agent identity, provider-visible Agent attribution, and upstream DPoP. diff --git a/providers/search1api/README.md b/providers/search1api/README.md new file mode 100644 index 0000000..bc4a07c --- /dev/null +++ b/providers/search1api/README.md @@ -0,0 +1,31 @@ +# Search1API Adapter + +Status: Experimental + +Search1API is a provider-delegated query Resource at `/search1api`. The +connected Search1API user is the upstream security principal; Realmroot and +Adapter audit preserve the originating Agent, which Search1API does not +natively display or enforce. + +## Published resources + +| Agent operation | Upstream operation | Scope | +| --- | --- | --- | +| `POST /search1api/searches` | `POST /search` | `search:read` | +| `POST /search1api/news-searches` | `POST /news` | `search:read` | +| `GET /search1api/usage` | `GET /usage` | `search:read` | + +Only these allowlisted operations are forwarded. Search requests follow the +upstream retry and billing semantics; the Adapter does not retry them. + +## Authorization and lifecycle + +Search1API supports public RFC 7591 registration, authorization code with S256 +PKCE, refresh tokens, and token revocation. The Adapter stores one public +client ID and encrypts each user's access and refresh credentials with +`SEARCH1API_CREDENTIAL_ENCRYPTION_KEY`. Provider endpoints are fixed in the +definition and require no environment configuration. + +The Adapter can retire when Search1API accepts and audits the stable Realmroot +Agent with proof-bound delegated authority directly. Current gaps are native +Agent identity, provider-visible Agent attribution, and upstream DPoP. diff --git a/providers/todoist/README.md b/providers/todoist/README.md index 6a1442b..915789a 100644 --- a/providers/todoist/README.md +++ b/providers/todoist/README.md @@ -15,10 +15,9 @@ Todoist's `data:read` provider scope. ## Configuration -The provider URLs have production defaults. Set +Provider URLs are fixed in the Todoist provider definition. Set `TODOIST_CREDENTIAL_ENCRYPTION_KEY` to a base64-encoded 32-byte key to enable -the adapter. Optional URL overrides are declared in `wrangler.jsonc` for local -or test environments. +the adapter; there are no URL environment variables. The authorization URL is wrapped in Todoist's login page with the complete OAuth request as `success_page`. Todoist otherwise drops PKCE parameters when diff --git a/src/core/configured-managed-provider.ts b/src/core/configured-managed-provider.ts new file mode 100644 index 0000000..50a33d4 --- /dev/null +++ b/src/core/configured-managed-provider.ts @@ -0,0 +1,137 @@ +import type { AdapterModule } from './adapter.js' +import { createCredentialCipher } from './credential-cipher.js' +import { + createDynamicOAuthClient, + D1DynamicOAuthRegistrationStore, + type DynamicOAuthAuthorizationWrapper, + type DynamicOAuthEndpoints, +} from './dynamic-oauth-client.js' +import { createExternalAuthorizationServer } from './external-authorization-server.js' +import type { D1ExternalOAuthStore } from './external-oauth-store.js' +import { + createManagedOAuthCredentialSource, + createManagedOAuthExternalAuthorization, + D1ManagedOAuthCredentials, +} from './managed-oauth.js' +import { createManagedOpenApiAdapter, type ManagedOpenApiOperation } from './managed-openapi-adapter.js' +import type { DpopReplayStore } from './realmroot-auth.js' + +export type ConfiguredManagedProvider = Readonly<{ + id: string + name: string + clientName: string + upstreamOrigin: string + endpoints: DynamicOAuthEndpoints + providerScopes: readonly string[] + agentScopes: Readonly> + operations: readonly ManagedOpenApiOperation[] + authorizationScopeSeparator?: ' ' | ',' + authorizationWrapper?: DynamicOAuthAuthorizationWrapper + identity(value: unknown): { subject: string; displayName: string } + openapi(input: { resource: string; issuer: string }): Record + manifest: Readonly<{ + resourceTypes: readonly string[] + revocationSignals: readonly string[] + nativeReadinessGaps: readonly string[] + retirementCondition: string + }> +}> + +export async function createConfiguredManagedProvider(input: { + definition: ConfiguredManagedProvider + origin: string + db: D1Database + credentialEncryptionKey: string + signingPrivateJwk: JsonWebKey + oauthStore: D1ExternalOAuthStore + replayStore: DpopReplayStore + audit(record: Record): Promise + fetcher?: typeof fetch +}): Promise { + const { definition } = input + const resource = `${input.origin}/${definition.id}` + const issuer = `${input.origin}/oauth/${definition.id}` + const credentials = new D1ManagedOAuthCredentials( + definition.id, + definition.name, + input.db, + createCredentialCipher(input.credentialEncryptionKey), + ) + const provider = createDynamicOAuthClient({ + providerId: definition.id, + clientName: definition.clientName, + endpoints: definition.endpoints, + redirectUri: `${issuer}/provider/callback`, + scopes: definition.providerScopes, + ...(definition.authorizationScopeSeparator + ? { authorizationScopeSeparator: definition.authorizationScopeSeparator } + : {}), + ...(definition.authorizationWrapper ? { authorizationWrapper: definition.authorizationWrapper } : {}), + registrationStore: new D1DynamicOAuthRegistrationStore(input.db), + ...(input.fetcher ? { fetcher: input.fetcher } : {}), + }) + const authorization = await createExternalAuthorizationServer({ + origin: input.origin, + provider: createManagedOAuthExternalAuthorization({ + id: definition.id, + name: definition.name, + origin: input.origin, + agentScopes: Object.keys(definition.agentScopes), + providerScopes: definition.providerScopes, + provider, + credentials, + identity: definition.identity, + }), + store: input.oauthStore, + signingPrivateJwk: input.signingPrivateJwk, + replayStore: input.replayStore, + }) + const adapter = createManagedOpenApiAdapter( + { + id: definition.id, + resource, + issuer, + upstreamOrigin: definition.upstreamOrigin, + scopes: definition.agentScopes, + operations: definition.operations, + openapi: definition.openapi({ resource, issuer }), + representation: { upstream: definition.id, operationMode: 'configured-openapi' }, + manifest: { + schemaVersion: '0.1', + provider: definition.id, + status: 'experimental', + identity: { + level: 'provider-delegated', + visibleInProduct: false, + visibleInAuditLog: false, + attribution: 'audit-only', + }, + actorModes: ['oauth-delegated-user'], + credentialModes: ['adapter-dynamic-public-oauth'], + resourceTypes: definition.manifest.resourceTypes, + scopes: Object.fromEntries( + Object.keys(definition.agentScopes).map((scope) => [ + scope, + { providerPermissions: { oauthScope: definition.providerScopes.join(' ') } }, + ]), + ), + operations: definition.operations, + revocationSignals: definition.manifest.revocationSignals, + nativeReadinessGaps: definition.manifest.nativeReadinessGaps, + retirementCondition: definition.manifest.retirementCondition, + }, + }, + { + authenticator: authorization.authenticator, + audit: input.audit, + ...(input.fetcher ? { fetch: input.fetcher } : {}), + credential: createManagedOAuthCredentialSource({ + agentScopes: Object.keys(definition.agentScopes), + providerScopes: definition.providerScopes, + provider, + credentials, + }), + }, + ) + return [authorization, adapter] +} diff --git a/src/providers/cloudflare/config.ts b/src/providers/cloudflare/config.ts index 6e46e04..67dc49a 100644 --- a/src/providers/cloudflare/config.ts +++ b/src/providers/cloudflare/config.ts @@ -3,8 +3,6 @@ import type { AppConfig } from '../../config.js' const schema = z .object({ - CLOUDFLARE_API_ORIGIN: z.url().optional(), - CLOUDFLARE_AUTHORIZATION_ORIGIN: z.url().optional(), CLOUDFLARE_CLIENT_ID: z.string().min(1).optional(), CLOUDFLARE_CLIENT_SECRET: z.string().min(1).optional(), CLOUDFLARE_CREDENTIAL_ENCRYPTION_KEY: z.string().min(1).optional(), @@ -31,11 +29,7 @@ export function loadCloudflareConfig(environment: unknown, app: AppConfig) { clientId: parsed.CLOUDFLARE_CLIENT_ID, clientSecret: parsed.CLOUDFLARE_CLIENT_SECRET, credentialEncryptionKey: parsed.CLOUDFLARE_CREDENTIAL_ENCRYPTION_KEY, - authorizationOrigin: strip(parsed.CLOUDFLARE_AUTHORIZATION_ORIGIN ?? 'https://dash.cloudflare.com'), - cloudflareApiOrigin: strip(parsed.CLOUDFLARE_API_ORIGIN ?? 'https://api.cloudflare.com/client/v4'), + authorizationOrigin: 'https://dash.cloudflare.com', + cloudflareApiOrigin: 'https://api.cloudflare.com/client/v4', } } - -function strip(value: string) { - return value.replace(/\/+$/, '') -} diff --git a/src/providers/context7/adapter.ts b/src/providers/context7/adapter.ts deleted file mode 100644 index 681e1fd..0000000 --- a/src/providers/context7/adapter.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { AdapterModule } from '../../core/adapter.js' -import { createManagedOAuthCredentialSource, type ManagedOAuthCredentials } from '../../core/managed-oauth.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, context7ProviderScopes } from './oauth.js' -import { context7OpenApi } from './openapi.js' - -export function createContext7Adapter( - config: Context7AdapterConfig, - dependencies: { - authenticator: RealmrootAuthenticator - provider: Context7OAuthClient - credentials: ManagedOAuthCredentials - 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 - const credential = createManagedOAuthCredentialSource({ - agentScopes: [context7AgentScope], - providerScopes: context7ProviderScopes, - provider: dependencies.provider, - credentials: dependencies.credentials, - }) - - 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 } : {}), - credential, - }, - ) -} diff --git a/src/providers/context7/config.ts b/src/providers/context7/config.ts deleted file mode 100644 index d9af1fb..0000000 --- a/src/providers/context7/config.ts +++ /dev/null @@ -1,26 +0,0 @@ -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/definition.ts b/src/providers/context7/definition.ts new file mode 100644 index 0000000..11405c1 --- /dev/null +++ b/src/providers/context7/definition.ts @@ -0,0 +1,60 @@ +import { z } from 'zod' +import type { ConfiguredManagedProvider } from '../../core/configured-managed-provider.js' +import { context7OpenApi } from './openapi.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.email().optional(), + }) + .passthrough() + +const scope = 'documentation:read' + +export const context7Definition = { + id: 'context7', + name: 'Context7', + clientName: 'Realmroot Context7 Adapter', + upstreamOrigin: 'https://context7.com/api', + endpoints: { + authorization: 'https://clerk.context7.com/oauth/authorize', + registration: 'https://clerk.context7.com/oauth/register', + token: 'https://clerk.context7.com/oauth/token', + userInfo: 'https://clerk.context7.com/oauth/userinfo', + revocation: 'https://clerk.context7.com/oauth/token/revoke', + }, + providerScopes: ['openid', 'profile', 'email', 'offline_access'], + agentScopes: { [scope]: 'Resolve libraries and read current software documentation.' }, + operations: [ + { + operationId: 'listContext7Libraries', + method: 'GET', + path: '/libraries', + upstreamPath: '/v2/libs/search', + scopes: [scope], + }, + { + operationId: 'getContext7Documentation', + method: 'GET', + path: '/documentation', + upstreamPath: '/v2/context', + scopes: [scope], + }, + ], + identity(value) { + const identity = identitySchema.parse(value) + return { + subject: identity.sub, + displayName: identity.name ?? identity.preferred_username ?? identity.email ?? identity.sub, + } + }, + openapi: context7OpenApi, + manifest: { + resourceTypes: ['library', 'documentation'], + 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.', + }, +} satisfies ConfiguredManagedProvider diff --git a/src/providers/context7/oauth.ts b/src/providers/context7/oauth.ts deleted file mode 100644 index 167cb11..0000000 --- a/src/providers/context7/oauth.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { z } from 'zod' -import type { ExternalProviderAuthorization } from '../../core/external-authorization-server.js' -import { - createManagedOAuthExternalAuthorization, - type ManagedOAuthClient, - type ManagedOAuthCredentials, -} from '../../core/managed-oauth.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 = ManagedOAuthClient - -export function createContext7ExternalAuthorization(input: { - origin: string - provider: Context7OAuthClient - credentials: ManagedOAuthCredentials -}): ExternalProviderAuthorization { - return createManagedOAuthExternalAuthorization({ - id: 'context7', - name: 'Context7', - origin: input.origin, - agentScopes: [context7AgentScope], - providerScopes: context7ProviderScopes, - provider: input.provider, - credentials: input.credentials, - identity(value) { - const identity = identitySchema.parse(value) - return { - subject: identity.sub, - displayName: identity.name ?? identity.preferred_username ?? identity.email ?? identity.sub, - } - }, - }) -} diff --git a/src/providers/fastio/definition.ts b/src/providers/fastio/definition.ts new file mode 100644 index 0000000..35c3ffd --- /dev/null +++ b/src/providers/fastio/definition.ts @@ -0,0 +1,60 @@ +import { z } from 'zod' +import type { ConfiguredManagedProvider } from '../../core/configured-managed-provider.js' +import { fastioOpenApi } from './openapi.js' + +const identitySchema = z + .object({ + user: z.object({ + id: z.string().min(1), + first_name: z.string().optional(), + last_name: z.string().optional(), + email_address: z.email().optional(), + }), + }) + .passthrough() + +const scope = 'workspace:read' + +export const fastioDefinition = { + id: 'fastio', + name: 'Fast.io', + clientName: 'Realmroot Fast.io Adapter', + upstreamOrigin: 'https://api.fast.io', + endpoints: { + authorization: 'https://go.fast.io/api/current/oauth/authorize', + registration: 'https://go.fast.io/api/current/oauth/register', + token: 'https://go.fast.io/api/current/oauth/token', + userInfo: 'https://api.fast.io/current/user/details/', + revocation: 'https://go.fast.io/api/current/oauth/revoke', + }, + providerScopes: ['all_workspaces'], + agentScopes: { [scope]: 'List accessible Fast.io workspaces and profile availability.' }, + operations: [ + { + operationId: 'listFastioWorkspaces', + method: 'GET', + path: '/workspaces', + upstreamPath: '/current/workspaces/all/', + scopes: [scope], + }, + { + operationId: 'getFastioProfileAvailability', + method: 'GET', + path: '/profile-availability', + upstreamPath: '/current/user/available_profiles/', + scopes: [scope], + }, + ], + identity(value) { + const { user } = identitySchema.parse(value) + const displayName = [user.first_name, user.last_name].filter(Boolean).join(' ') + return { subject: user.id, displayName: displayName || user.email_address || user.id } + }, + openapi: fastioOpenApi, + manifest: { + resourceTypes: ['workspace', 'profile-availability'], + revocationSignals: ['adapter-oauth-revocation', 'fastio-oauth-revocation'], + nativeReadinessGaps: ['ACTOR-NATIVE', 'AGENT-DISPLAY', 'DPOP'], + retirementCondition: 'Fast.io accepts Realmroot Agent identity and proof-bound delegated authority directly.', + }, +} satisfies ConfiguredManagedProvider diff --git a/src/providers/fastio/openapi.ts b/src/providers/fastio/openapi.ts new file mode 100644 index 0000000..e8a85c4 --- /dev/null +++ b/src/providers/fastio/openapi.ts @@ -0,0 +1,85 @@ +import { openIdConfigurationUrl } from '../../core/external-authorization-server.js' + +const scope = 'workspace:read' + +export function fastioOpenApi(input: { resource: string; issuer: string }) { + const security = [{ fastio: [scope] }] + return { + openapi: '3.1.0', + info: { + title: 'Fast.io through Realmroot', + version: '2026-09-04', + description: 'Inspect Fast.io workspaces through an Agent-bound, read-only OAuth resource.', + }, + servers: [{ url: input.resource }], + paths: { + '/workspaces': { + get: { + operationId: 'listFastioWorkspaces', + summary: 'List accessible workspaces', + security, + responses: { + 200: { + description: 'All workspaces accessible to the connected user.', + content: { 'application/json': { schema: { $ref: '#/components/schemas/WorkspaceCollection' } } }, + }, + default: { description: 'Fast.io response or Realmroot authorization failure.' }, + }, + }, + }, + '/profile-availability': { + get: { + operationId: 'getFastioProfileAvailability', + summary: 'Get available profile types', + security, + responses: { + 200: { + description: 'Availability of organizations, workspaces, shares, and pending invitations.', + content: { 'application/json': { schema: { type: 'object', additionalProperties: true } } }, + }, + default: { description: 'Fast.io response or Realmroot authorization failure.' }, + }, + }, + }, + }, + components: { + schemas: { + WorkspaceCollection: { + type: 'object', + required: ['result', 'workspaces'], + properties: { + result: { type: 'boolean' }, + workspaces: { + type: 'array', + items: { + type: 'object', + required: ['id', 'name'], + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + folder_name: { type: 'string' }, + description: { type: ['string', 'null'] }, + closed: { type: 'boolean' }, + archived: { type: 'boolean' }, + user_status: { type: 'string' }, + org_domain: { type: 'string' }, + }, + additionalProperties: true, + }, + }, + }, + additionalProperties: true, + }, + }, + securitySchemes: { + fastio: { + type: 'openIdConnect', + openIdConnectUrl: openIdConfigurationUrl(input.issuer), + 'x-dpop-required': true, + description: 'Realmroot Agent credential with approved Fast.io workspace access.', + }, + }, + }, + 'x-provider-upstream': 'https://api.fast.io', + } +} diff --git a/src/providers/github/config.ts b/src/providers/github/config.ts index ff4d571..6f6e88c 100644 --- a/src/providers/github/config.ts +++ b/src/providers/github/config.ts @@ -2,9 +2,6 @@ import { z } from 'zod' import type { AppConfig } from '../../config.js' const githubEnvironmentSchema = z.object({ - GITHUB_API_ORIGIN: z.url().default('https://api.github.com'), - GITHUB_UPLOADS_ORIGIN: z.url().default('https://uploads.github.com'), - GITHUB_GIT_ORIGIN: z.url().default('https://github.com'), GITHUB_APP_ID: z.string().trim().min(1).optional(), GITHUB_PRIVATE_KEY: z.string().trim().min(1).optional(), GITHUB_CLIENT_ID: z.string().trim().min(1).optional(), @@ -27,9 +24,9 @@ export function loadGitHubConfig(environment: unknown, config: AppConfig): GitHu const parsed = githubEnvironmentSchema.parse(environment) return { ...config, - githubApiOrigin: parsed.GITHUB_API_ORIGIN.replace(/\/+$/, ''), - githubUploadsOrigin: parsed.GITHUB_UPLOADS_ORIGIN.replace(/\/+$/, ''), - githubGitOrigin: parsed.GITHUB_GIT_ORIGIN.replace(/\/+$/, ''), + githubApiOrigin: 'https://api.github.com', + githubUploadsOrigin: 'https://uploads.github.com', + githubGitOrigin: 'https://github.com', ...(parsed.GITHUB_APP_ID ? { githubAppId: parsed.GITHUB_APP_ID } : {}), ...(parsed.GITHUB_PRIVATE_KEY ? { githubPrivateKey: parsed.GITHUB_PRIVATE_KEY } : {}), ...(parsed.GITHUB_CLIENT_ID ? { githubClientId: parsed.GITHUB_CLIENT_ID } : {}), diff --git a/src/providers/linear/config.ts b/src/providers/linear/config.ts index 853bd6f..aca43a3 100644 --- a/src/providers/linear/config.ts +++ b/src/providers/linear/config.ts @@ -2,8 +2,6 @@ import { z } from 'zod' import type { AppConfig } from '../../config.js' const linearEnvironmentSchema = z.object({ - LINEAR_API_ORIGIN: z.url().default('https://api.linear.app'), - LINEAR_AUTHORIZATION_ORIGIN: z.url().default('https://linear.app'), LINEAR_CLIENT_ID: z.string().trim().min(1).optional(), LINEAR_CLIENT_SECRET: z.string().trim().min(1).optional(), LINEAR_CREDENTIAL_ENCRYPTION_KEY: z.string().trim().min(1).optional(), @@ -23,8 +21,8 @@ export function loadLinearConfig(environment: unknown, config: AppConfig): Linea const parsed = linearEnvironmentSchema.parse(environment) return { ...config, - linearApiOrigin: parsed.LINEAR_API_ORIGIN.replace(/\/+$/, ''), - linearAuthorizationOrigin: parsed.LINEAR_AUTHORIZATION_ORIGIN.replace(/\/+$/, ''), + linearApiOrigin: 'https://api.linear.app', + linearAuthorizationOrigin: 'https://linear.app', ...(parsed.LINEAR_CLIENT_ID ? { linearClientId: parsed.LINEAR_CLIENT_ID } : {}), ...(parsed.LINEAR_CLIENT_SECRET ? { linearClientSecret: parsed.LINEAR_CLIENT_SECRET } : {}), ...(parsed.LINEAR_CREDENTIAL_ENCRYPTION_KEY diff --git a/src/providers/search1api/definition.ts b/src/providers/search1api/definition.ts new file mode 100644 index 0000000..4815c17 --- /dev/null +++ b/src/providers/search1api/definition.ts @@ -0,0 +1,55 @@ +import { z } from 'zod' +import type { ConfiguredManagedProvider } from '../../core/configured-managed-provider.js' +import { search1ApiOpenApi } from './openapi.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.email().optional(), + }) + .passthrough() + +const scope = 'search:read' + +export const search1ApiDefinition = { + id: 'search1api', + name: 'Search1API', + clientName: 'Realmroot Search1API Adapter', + upstreamOrigin: 'https://api.search1api.com', + endpoints: { + authorization: 'https://clerk.s1.dev/oauth/authorize', + registration: 'https://clerk.s1.dev/oauth/register', + token: 'https://clerk.s1.dev/oauth/token', + userInfo: 'https://clerk.s1.dev/oauth/userinfo', + revocation: 'https://clerk.s1.dev/oauth/token/revoke', + }, + providerScopes: ['openid', 'profile', 'email', 'offline_access'], + agentScopes: { [scope]: 'Search the web and news, and inspect Search1API usage.' }, + operations: [ + { operationId: 'createWebSearch', method: 'POST', path: '/searches', upstreamPath: '/search', scopes: [scope] }, + { + operationId: 'createNewsSearch', + method: 'POST', + path: '/news-searches', + upstreamPath: '/news', + scopes: [scope], + }, + { operationId: 'getSearchUsage', method: 'GET', path: '/usage', upstreamPath: '/usage', scopes: [scope] }, + ], + identity(value) { + const identity = identitySchema.parse(value) + return { + subject: identity.sub, + displayName: identity.name ?? identity.preferred_username ?? identity.email ?? identity.sub, + } + }, + openapi: search1ApiOpenApi, + manifest: { + resourceTypes: ['web-search', 'news-search', 'usage'], + revocationSignals: ['adapter-oauth-revocation', 'search1api-oauth-revocation'], + nativeReadinessGaps: ['ACTOR-NATIVE', 'AGENT-DISPLAY', 'DPOP'], + retirementCondition: 'Search1API accepts Realmroot Agent identity and proof-bound delegated authority directly.', + }, +} satisfies ConfiguredManagedProvider diff --git a/src/providers/search1api/openapi.ts b/src/providers/search1api/openapi.ts new file mode 100644 index 0000000..5f7ba92 --- /dev/null +++ b/src/providers/search1api/openapi.ts @@ -0,0 +1,77 @@ +import { openIdConfigurationUrl } from '../../core/external-authorization-server.js' + +const scope = 'search:read' + +export function search1ApiOpenApi(input: { resource: string; issuer: string }) { + const security = [{ search1Api: [scope] }] + const searchRequest = { + type: 'object', + required: ['query'], + properties: { + query: { type: 'string', minLength: 1 }, + search_service: { type: 'string' }, + max_results: { type: 'integer', minimum: 1, maximum: 50, default: 5 }, + crawl_results: { type: 'integer', minimum: 0, maximum: 50, default: 0 }, + image: { type: 'boolean', default: false }, + include_sites: { type: 'array', items: { type: 'string' } }, + exclude_sites: { type: 'array', items: { type: 'string' } }, + language: { type: 'string' }, + time_range: { type: 'string', enum: ['day', 'week', 'month', 'year', ''] }, + }, + additionalProperties: false, + } + const createSearch = (operationId: string, summary: string) => ({ + operationId, + summary, + security, + requestBody: { + required: true, + content: { 'application/json': { schema: searchRequest } }, + }, + responses: { + 200: { + description: 'Search results.', + content: { 'application/json': { schema: { type: 'object', additionalProperties: true } } }, + }, + default: { description: 'Search1API response or Realmroot authorization failure.' }, + }, + }) + return { + openapi: '3.1.0', + info: { + title: 'Search1API through Realmroot', + version: '2026-09-04', + description: 'Search the web and news through an Agent-bound OAuth resource.', + }, + servers: [{ url: input.resource }], + paths: { + '/searches': { post: createSearch('createWebSearch', 'Create a web search') }, + '/news-searches': { post: createSearch('createNewsSearch', 'Create a news search') }, + '/usage': { + get: { + operationId: 'getSearchUsage', + summary: 'Get current Search1API usage', + security, + responses: { + 200: { + description: 'Current usage and limits.', + content: { 'application/json': { schema: { type: 'object', additionalProperties: true } } }, + }, + default: { description: 'Search1API response or Realmroot authorization failure.' }, + }, + }, + }, + }, + components: { + securitySchemes: { + search1Api: { + type: 'openIdConnect', + openIdConnectUrl: openIdConfigurationUrl(input.issuer), + 'x-dpop-required': true, + description: 'Realmroot Agent credential with approved Search1API access.', + }, + }, + }, + 'x-provider-upstream': 'https://api.search1api.com', + } +} diff --git a/src/providers/todoist/adapter.ts b/src/providers/todoist/adapter.ts deleted file mode 100644 index f270bb4..0000000 --- a/src/providers/todoist/adapter.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { AdapterModule } from '../../core/adapter.js' -import { - createManagedOAuthCredentialSource, - type ManagedOAuthClient, - type ManagedOAuthCredentials, -} from '../../core/managed-oauth.js' -import { createManagedOpenApiAdapter } from '../../core/managed-openapi-adapter.js' -import type { RealmrootAuthenticator } from '../../core/realmroot-auth.js' -import type { TodoistAdapterConfig } from './config.js' -import { todoistAgentScope, todoistProviderScopes } from './oauth.js' -import { todoistOpenApi } from './openapi.js' - -export function createTodoistAdapter( - config: TodoistAdapterConfig, - dependencies: { - authenticator: RealmrootAuthenticator - provider: ManagedOAuthClient - credentials: ManagedOAuthCredentials - audit(record: Record): Promise - fetch?: typeof fetch - }, -): AdapterModule { - const resource = `${config.origin}/todoist` - const issuer = `${config.origin}/oauth/todoist` - const operations = [ - { - operationId: 'listTodoistProjects', - method: 'GET', - path: '/projects', - upstreamPath: '/projects', - scopes: [todoistAgentScope], - }, - { - operationId: 'listTodoistTasks', - method: 'GET', - path: '/tasks', - upstreamPath: '/tasks', - scopes: [todoistAgentScope], - }, - ] as const - - return createManagedOpenApiAdapter( - { - id: 'todoist', - resource, - issuer, - upstreamOrigin: config.todoistApiOrigin, - scopes: { [todoistAgentScope]: 'List active Todoist projects and tasks.' }, - operations, - openapi: todoistOpenApi({ resource, issuer }), - representation: { upstream: 'todoist', operationMode: 'configured-openapi' }, - manifest: { - schemaVersion: '0.1', - provider: 'todoist', - status: 'experimental', - identity: { - level: 'provider-delegated', - visibleInProduct: false, - visibleInAuditLog: false, - attribution: 'audit-only', - }, - actorModes: ['oauth-delegated-user'], - credentialModes: ['adapter-dynamic-public-oauth'], - resourceTypes: ['project', 'task'], - scopes: { [todoistAgentScope]: { providerPermissions: { oauthScope: 'data:read' } } }, - operations, - revocationSignals: ['adapter-local-revocation'], - nativeReadinessGaps: ['ACTOR-NATIVE', 'AGENT-DISPLAY', 'DPOP', 'PROVIDER-REVOCATION'], - retirementCondition: 'Todoist accepts Realmroot Agent identity and proof-bound delegated authority directly.', - }, - }, - { - authenticator: dependencies.authenticator, - audit: dependencies.audit, - ...(dependencies.fetch ? { fetch: dependencies.fetch } : {}), - credential: createManagedOAuthCredentialSource({ - agentScopes: [todoistAgentScope], - providerScopes: todoistProviderScopes, - provider: dependencies.provider, - credentials: dependencies.credentials, - }), - }, - ) -} diff --git a/src/providers/todoist/config.ts b/src/providers/todoist/config.ts deleted file mode 100644 index 4613639..0000000 --- a/src/providers/todoist/config.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { z } from 'zod' -import type { AppConfig } from '../../config.js' - -const environmentSchema = z.object({ - TODOIST_API_ORIGIN: z.url().default('https://api.todoist.com/api/v1'), - TODOIST_AUTHORIZATION_ENDPOINT: z.url().default('https://app.todoist.com/oauth/authorize'), - TODOIST_LOGIN_ENDPOINT: z.url().default('https://app.todoist.com/users/showlogin'), - TODOIST_TOKEN_ENDPOINT: z.url().default('https://api.todoist.com/oauth/access_token'), - TODOIST_REGISTRATION_ENDPOINT: z.url().default('https://api.todoist.com/oauth/register'), - TODOIST_USERINFO_ENDPOINT: z.url().default('https://api.todoist.com/api/v1/user'), - TODOIST_CREDENTIAL_ENCRYPTION_KEY: z.string().trim().min(1).optional(), -}) - -export type TodoistAdapterConfig = AppConfig & { - todoistApiOrigin: string - todoistAuthorizationEndpoint: string - todoistLoginEndpoint: string - todoistTokenEndpoint: string - todoistRegistrationEndpoint: string - todoistUserInfoEndpoint: string - todoistCredentialEncryptionKey?: string -} - -export function loadTodoistConfig(environment: unknown, config: AppConfig): TodoistAdapterConfig { - const parsed = environmentSchema.parse(environment) - return { - ...config, - todoistApiOrigin: parsed.TODOIST_API_ORIGIN.replace(/\/+$/, ''), - todoistAuthorizationEndpoint: parsed.TODOIST_AUTHORIZATION_ENDPOINT, - todoistLoginEndpoint: parsed.TODOIST_LOGIN_ENDPOINT, - todoistTokenEndpoint: parsed.TODOIST_TOKEN_ENDPOINT, - todoistRegistrationEndpoint: parsed.TODOIST_REGISTRATION_ENDPOINT, - todoistUserInfoEndpoint: parsed.TODOIST_USERINFO_ENDPOINT, - ...(parsed.TODOIST_CREDENTIAL_ENCRYPTION_KEY - ? { todoistCredentialEncryptionKey: parsed.TODOIST_CREDENTIAL_ENCRYPTION_KEY } - : {}), - } -} diff --git a/src/providers/todoist/definition.ts b/src/providers/todoist/definition.ts new file mode 100644 index 0000000..b15034a --- /dev/null +++ b/src/providers/todoist/definition.ts @@ -0,0 +1,61 @@ +import { z } from 'zod' +import type { ConfiguredManagedProvider } from '../../core/configured-managed-provider.js' +import { todoistOpenApi } from './openapi.js' + +const identitySchema = z + .object({ + id: z.union([z.string().min(1), z.number().int().positive()]), + full_name: z.string().min(1).optional(), + email: z.email().optional(), + }) + .passthrough() + +const scope = 'tasks:read' + +export const todoistDefinition = { + id: 'todoist', + name: 'Todoist', + clientName: 'Realmroot Todoist Adapter', + upstreamOrigin: 'https://api.todoist.com/api/v1', + endpoints: { + authorization: 'https://app.todoist.com/oauth/authorize', + registration: 'https://api.todoist.com/oauth/register', + token: 'https://api.todoist.com/oauth/access_token', + userInfo: 'https://api.todoist.com/api/v1/user', + }, + providerScopes: ['data:read'], + agentScopes: { [scope]: 'List active Todoist projects and tasks.' }, + operations: [ + { + operationId: 'listTodoistProjects', + method: 'GET', + path: '/projects', + upstreamPath: '/projects', + scopes: [scope], + }, + { + operationId: 'listTodoistTasks', + method: 'GET', + path: '/tasks', + upstreamPath: '/tasks', + scopes: [scope], + }, + ], + authorizationScopeSeparator: ',', + authorizationWrapper: { + endpoint: 'https://app.todoist.com/users/showlogin', + returnUrlParameter: 'success_page', + }, + identity(value) { + const identity = identitySchema.parse(value) + const subject = String(identity.id) + return { subject, displayName: identity.full_name ?? identity.email ?? subject } + }, + openapi: todoistOpenApi, + manifest: { + resourceTypes: ['project', 'task'], + revocationSignals: ['adapter-local-revocation'], + nativeReadinessGaps: ['ACTOR-NATIVE', 'AGENT-DISPLAY', 'DPOP', 'PROVIDER-REVOCATION'], + retirementCondition: 'Todoist accepts Realmroot Agent identity and proof-bound delegated authority directly.', + }, +} satisfies ConfiguredManagedProvider diff --git a/src/providers/todoist/oauth.ts b/src/providers/todoist/oauth.ts deleted file mode 100644 index b4f857a..0000000 --- a/src/providers/todoist/oauth.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { z } from 'zod' -import { - createManagedOAuthExternalAuthorization, - type ManagedOAuthClient, - type ManagedOAuthCredentials, -} from '../../core/managed-oauth.js' - -const identitySchema = z - .object({ - id: z.union([z.string().min(1), z.number().int().positive()]), - full_name: z.string().min(1).optional(), - email: z.email().optional(), - }) - .passthrough() - -export const todoistAgentScope = 'tasks:read' -export const todoistProviderScopes = ['data:read'] as const - -export function createTodoistExternalAuthorization(input: { - origin: string - provider: ManagedOAuthClient - credentials: ManagedOAuthCredentials -}) { - return createManagedOAuthExternalAuthorization({ - id: 'todoist', - name: 'Todoist', - origin: input.origin, - agentScopes: [todoistAgentScope], - providerScopes: todoistProviderScopes, - provider: input.provider, - credentials: input.credentials, - identity(value) { - const identity = identitySchema.parse(value) - const subject = String(identity.id) - return { subject, displayName: identity.full_name ?? identity.email ?? subject } - }, - }) -} diff --git a/src/providers/todoist/openapi.ts b/src/providers/todoist/openapi.ts index 4b9eac6..dd9eca2 100644 --- a/src/providers/todoist/openapi.ts +++ b/src/providers/todoist/openapi.ts @@ -1,8 +1,6 @@ import { openIdConfigurationUrl } from '../../core/external-authorization-server.js' -import { todoistAgentScope } from './oauth.js' - export function todoistOpenApi(input: { resource: string; issuer: string }) { - const security = [{ todoistTasks: [todoistAgentScope] }] + const security = [{ todoistTasks: ['tasks:read'] }] const cursor = { name: 'cursor', in: 'query', diff --git a/src/worker.ts b/src/worker.ts index e55edde..00c2a9f 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -2,11 +2,10 @@ import { tracing } from 'cloudflare:workers' import { createApp } from './app.js' import { loadConfig } from './config.js' import type { AdapterModule } from './core/adapter.js' +import { createConfiguredManagedProvider } from './core/configured-managed-provider.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 { D1ManagedOAuthCredentials } from './core/managed-oauth.js' import { createCloudflareAdapter } from './providers/cloudflare/adapter.js' import { loadCloudflareConfig } from './providers/cloudflare/config.js' import { cloudflareManifest } from './providers/cloudflare/manifest.js' @@ -15,9 +14,8 @@ 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 } from './providers/context7/oauth.js' +import { context7Definition } from './providers/context7/definition.js' +import { fastioDefinition } from './providers/fastio/definition.js' import { createGitHubAdapter } from './providers/github/adapter.js' import { createGitHubConnectionProvider, createGitHubProvider } from './providers/github/client.js' import { loadGitHubConfig } from './providers/github/config.js' @@ -31,9 +29,8 @@ import { D1LinearConnections } from './providers/linear/connections.js' import { createLinearCredentialCipher } from './providers/linear/credentials.js' import { createLinearExternalAuthorization } from './providers/linear/external-authorization.js' import { linearScopes } from './providers/linear/scopes.js' -import { createTodoistAdapter } from './providers/todoist/adapter.js' -import { loadTodoistConfig } from './providers/todoist/config.js' -import { createTodoistExternalAuthorization, todoistProviderScopes } from './providers/todoist/oauth.js' +import { search1ApiDefinition } from './providers/search1api/definition.js' +import { todoistDefinition } from './providers/todoist/definition.js' import { D1RuntimeState } from './storage/d1-runtime-state.js' export default { @@ -43,9 +40,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 todoistConfig = loadTodoistConfig(env, config) const state = new D1RuntimeState(env.DB) const oauthStore = new D1ExternalOAuthStore(env.DB) const signingPrivateJwk = config.oauthSigningPrivateJwk ? JSON.parse(config.oauthSigningPrivateJwk) : undefined @@ -176,98 +171,29 @@ export default { }), ) } - if (context7Config.context7CredentialEncryptionKey) { - if (!signingPrivateJwk) throw new Error('Context7 external authorization is not configured.') - const context7Credentials = new D1ManagedOAuthCredentials( - 'context7', - 'Context7', - env.DB, - createCredentialCipher(context7Config.context7CredentialEncryptionKey), - ) - const context7Provider = createDynamicOAuthClient({ - providerId: 'context7', - clientName: 'Realmroot Context7 Adapter', - endpoints: { - authorization: `${context7Config.context7OAuthIssuer}/oauth/authorize`, - registration: `${context7Config.context7OAuthIssuer}/oauth/register`, - token: `${context7Config.context7OAuthIssuer}/oauth/token`, - userInfo: `${context7Config.context7OAuthIssuer}/oauth/userinfo`, - revocation: `${context7Config.context7OAuthIssuer}/oauth/token/revoke`, - }, - 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, - }) + const managedProviders = [ + { definition: context7Definition, credentialEncryptionKey: env.CONTEXT7_CREDENTIAL_ENCRYPTION_KEY }, + { definition: todoistDefinition, credentialEncryptionKey: env.TODOIST_CREDENTIAL_ENCRYPTION_KEY }, + { definition: search1ApiDefinition, credentialEncryptionKey: env.SEARCH1API_CREDENTIAL_ENCRYPTION_KEY }, + { definition: fastioDefinition, credentialEncryptionKey: env.FASTIO_CREDENTIAL_ENCRYPTION_KEY }, + ] + for (const managed of managedProviders) { + if (!managed.credentialEncryptionKey) continue + if (!signingPrivateJwk) { + throw new Error(`${managed.definition.name} external authorization is not configured.`) + } adapters.push( - context7Authorization, - createContext7Adapter(context7Config, { - authenticator: context7Authorization.authenticator, - provider: context7Provider, - credentials: context7Credentials, - audit: (record) => state.recordAudit(record), - fetch, - }), - ) - } - if (todoistConfig.todoistCredentialEncryptionKey) { - if (!signingPrivateJwk) throw new Error('Todoist external authorization is not configured.') - const todoistCredentials = new D1ManagedOAuthCredentials( - 'todoist', - 'Todoist', - env.DB, - createCredentialCipher(todoistConfig.todoistCredentialEncryptionKey), - ) - const todoistProvider = createDynamicOAuthClient({ - providerId: 'todoist', - clientName: 'Realmroot Todoist Adapter', - endpoints: { - authorization: todoistConfig.todoistAuthorizationEndpoint, - registration: todoistConfig.todoistRegistrationEndpoint, - token: todoistConfig.todoistTokenEndpoint, - userInfo: todoistConfig.todoistUserInfoEndpoint, - }, - redirectUri: `${config.origin}/oauth/todoist/provider/callback`, - scopes: todoistProviderScopes, - authorizationScopeSeparator: ',', - authorizationWrapper: { - endpoint: todoistConfig.todoistLoginEndpoint, - returnUrlParameter: 'success_page', - }, - registrationStore: new D1DynamicOAuthRegistrationStore(env.DB), - fetcher: fetch, - }) - const todoistAuthorization = await createExternalAuthorizationServer({ - origin: config.origin, - provider: createTodoistExternalAuthorization({ + ...(await createConfiguredManagedProvider({ + definition: managed.definition, origin: config.origin, - provider: todoistProvider, - credentials: todoistCredentials, - }), - store: oauthStore, - signingPrivateJwk, - replayStore: state, - }) - adapters.push( - todoistAuthorization, - createTodoistAdapter(todoistConfig, { - authenticator: todoistAuthorization.authenticator, - provider: todoistProvider, - credentials: todoistCredentials, + db: env.DB, + credentialEncryptionKey: managed.credentialEncryptionKey, + signingPrivateJwk, + oauthStore, + replayStore: state, audit: (record) => state.recordAudit(record), - fetch, - }), + fetcher: fetch, + })), ) } const app = createApp(adapters) diff --git a/test/config.test.ts b/test/config.test.ts index 4ffb176..f9248b4 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -9,13 +9,13 @@ describe('adapter Worker configuration', () => { REALMROOT_ISSUER: 'https://local.realmroot.dev/api/auth/', REALMROOT_JWKS_URL: 'https://local.realmroot.dev/api/auth/jwks', REALMROOT_AGENT_PROFILE_URI_TEMPLATE: 'https://local.realmroot.dev/api/public/agents/{subject}', - GITHUB_API_ORIGIN: 'https://api.github.com/', - GITHUB_UPLOADS_ORIGIN: 'https://uploads.github.com/', + GITHUB_API_ORIGIN: 'https://untrusted-github.example/', + GITHUB_UPLOADS_ORIGIN: 'https://untrusted-uploads.example/', GITHUB_APP_ID: '123', GITHUB_PRIVATE_KEY: 'private-key', GITHUB_WEBHOOK_SECRET: 'github-webhook-secret-with-32-characters', - LINEAR_API_ORIGIN: 'https://api.linear.example/', - LINEAR_AUTHORIZATION_ORIGIN: 'https://linear.example/', + LINEAR_API_ORIGIN: 'https://untrusted-linear-api.example/', + LINEAR_AUTHORIZATION_ORIGIN: 'https://untrusted-linear.example/', LINEAR_CLIENT_ID: 'linear-client', LINEAR_CLIENT_SECRET: 'linear-secret', LINEAR_CREDENTIAL_ENCRYPTION_KEY: 'linear-encryption-key', @@ -37,8 +37,8 @@ describe('adapter Worker configuration', () => { githubWebhookSecret: 'github-webhook-secret-with-32-characters', }) expect(loadLinearConfig(environment, config)).toMatchObject({ - linearApiOrigin: 'https://api.linear.example', - linearAuthorizationOrigin: 'https://linear.example', + linearApiOrigin: 'https://api.linear.app', + linearAuthorizationOrigin: 'https://linear.app', linearClientId: 'linear-client', linearClientSecret: 'linear-secret', linearCredentialEncryptionKey: 'linear-encryption-key', diff --git a/test/providers/configured-providers.test.ts b/test/providers/configured-providers.test.ts new file mode 100644 index 0000000..745b041 --- /dev/null +++ b/test/providers/configured-providers.test.ts @@ -0,0 +1,75 @@ +import { exportJWK, generateKeyPair } from 'jose' +import { describe, expect, it, vi } from 'vitest' +import { createApp } from '../../src/app.js' +import { createConfiguredManagedProvider } from '../../src/core/configured-managed-provider.js' +import { D1ExternalOAuthStore } from '../../src/core/external-oauth-store.js' +import { context7Definition } from '../../src/providers/context7/definition.js' +import { fastioDefinition } from '../../src/providers/fastio/definition.js' +import { search1ApiDefinition } from '../../src/providers/search1api/definition.js' +import { todoistDefinition } from '../../src/providers/todoist/definition.js' + +const definitions = [context7Definition, todoistDefinition, search1ApiDefinition, fastioDefinition] + +describe('configured managed providers', () => { + it.each(definitions)('$name publishes exactly its configured operation allowlist', (definition) => { + const document = definition.openapi({ + resource: `https://adapter.example/${definition.id}`, + issuer: `https://adapter.example/oauth/${definition.id}`, + }) as { paths: Record> } + const published = Object.entries(document.paths) + .flatMap(([path, item]) => + Object.entries(item).map(([method, operation]) => ({ method: method.toUpperCase(), path, ...operation })), + ) + .map(({ method, path, operationId }) => ({ method, path, operationId })) + + expect(published).toEqual( + definition.operations.map(({ method, path, operationId }) => ({ method, path, operationId })), + ) + expect(definition.upstreamOrigin).toMatch(/^https:\/\//) + expect(Object.values(definition.endpoints).every((value) => value.startsWith('https://'))).toBe(true) + }) + + it('decodes Search1API and Fast.io provider identities from their documented shapes', () => { + expect(search1ApiDefinition.identity({ sub: 'search-user', email: 'search@example.com' })).toEqual({ + subject: 'search-user', + displayName: 'search@example.com', + }) + expect( + fastioDefinition.identity({ + result: true, + user: { id: '1234567890123456789', first_name: 'Fast', last_name: 'User' }, + }), + ).toEqual({ subject: '1234567890123456789', displayName: 'Fast User' }) + }) + + it('composes discovery, authorization, and resource modules from a definition', async () => { + const { privateKey } = await generateKeyPair('ES256', { extractable: true }) + const db = {} as D1Database + const modules = await createConfiguredManagedProvider({ + definition: search1ApiDefinition, + origin: 'https://adapter.example', + db, + credentialEncryptionKey: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + signingPrivateJwk: await exportJWK(privateKey), + oauthStore: new D1ExternalOAuthStore(db), + replayStore: { claim: vi.fn(async () => true) }, + audit: vi.fn(async () => {}), + }) + const app = createApp(modules) + + await expect((await app.request('/search1api')).json()).resolves.toMatchObject({ + resource: 'https://adapter.example/search1api', + serviceDescription: 'https://adapter.example/search1api/openapi.json', + }) + await expect((await app.request('/providers/search1api/manifest')).json()).resolves.toMatchObject({ + provider: 'search1api', + credentialModes: ['adapter-dynamic-public-oauth'], + }) + await expect((await app.request('/.well-known/oauth-protected-resource/search1api')).json()).resolves.toMatchObject( + { + resource: 'https://adapter.example/search1api', + authorization_servers: ['https://adapter.example/oauth/search1api'], + }, + ) + }) +}) diff --git a/test/providers/todoist-oauth.test.ts b/test/providers/todoist-oauth.test.ts index 6b51625..58fd5c8 100644 --- a/test/providers/todoist-oauth.test.ts +++ b/test/providers/todoist-oauth.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import type { ExternalOAuthIntent } from '../../src/core/external-oauth-store.js' -import type { ManagedOAuthClient, ManagedOAuthCredentials } from '../../src/core/managed-oauth.js' -import { createTodoistExternalAuthorization } from '../../src/providers/todoist/oauth.js' +import { + createManagedOAuthExternalAuthorization, + type ManagedOAuthClient, + type ManagedOAuthCredentials, +} from '../../src/core/managed-oauth.js' +import { todoistDefinition } from '../../src/providers/todoist/definition.js' describe('Todoist external authorization', () => { it('[spec: todoist-adapter/todoist-provider-oauth] resolves identity and supports local-only revocation', async () => { @@ -32,10 +36,15 @@ describe('Todoist external authorization', () => { replace: vi.fn(async () => true), revoke: vi.fn(async () => {}), } - const authorization = createTodoistExternalAuthorization({ + const authorization = createManagedOAuthExternalAuthorization({ + id: todoistDefinition.id, + name: todoistDefinition.name, origin: 'https://adapter.example', + agentScopes: Object.keys(todoistDefinition.agentScopes), + providerScopes: todoistDefinition.providerScopes, provider, credentials, + identity: todoistDefinition.identity, }) await expect( authorization.validateGrant?.({ subject: 'todoist-user-1', scopes: ['tasks:read'], authorizationDetails: [] }), diff --git a/vitest.config.ts b/vitest.config.ts index f7185a4..8164b40 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -32,18 +32,7 @@ export default defineConfig({ REALMROOT_ISSUER: 'https://id.example/api/auth', REALMROOT_JWKS_URL: 'https://id.example/api/auth/jwks', REALMROOT_AGENT_PROFILE_URI_TEMPLATE: 'https://id.example/api/public/agents/{subject}', - GITHUB_API_ORIGIN: 'https://api.github.com', - 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', - TODOIST_API_ORIGIN: 'https://api.todoist.com/api/v1', - TODOIST_AUTHORIZATION_ENDPOINT: 'https://app.todoist.com/oauth/authorize', - TODOIST_LOGIN_ENDPOINT: 'https://app.todoist.com/users/showlogin', - TODOIST_TOKEN_ENDPOINT: 'https://api.todoist.com/oauth/access_token', - TODOIST_REGISTRATION_ENDPOINT: 'https://api.todoist.com/oauth/register', - TODOIST_USERINFO_ENDPOINT: 'https://api.todoist.com/api/v1/user', }, }, })), diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 66b53b1..4c42755 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,25 +1,11 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --env-file=.dev.vars.example --strict-vars=false` (hash: 3395f9fa4ba66e7161740a66c8c7a39e) +// Generated by Wrangler by running `wrangler types --env-file=.dev.vars.example --strict-vars=false` (hash: bfa99c28dea77868ebc905e1fdfcba7a) // Runtime types generated with workerd@1.20260801.1 2026-08-08 nodejs_compat interface __BaseEnv_Env { DB: D1Database; REALMROOT_ISSUER: string; REALMROOT_JWKS_URL: string; REALMROOT_AGENT_PROFILE_URI_TEMPLATE: string; - GITHUB_API_ORIGIN: string; - GITHUB_UPLOADS_ORIGIN: string; - LINEAR_API_ORIGIN: string; - LINEAR_AUTHORIZATION_ORIGIN: string; - CLOUDFLARE_API_ORIGIN: string; - CLOUDFLARE_AUTHORIZATION_ORIGIN: string; - CONTEXT7_API_ORIGIN: string; - CONTEXT7_OAUTH_ISSUER: string; - TODOIST_API_ORIGIN: string; - TODOIST_AUTHORIZATION_ENDPOINT: string; - TODOIST_LOGIN_ENDPOINT: string; - TODOIST_TOKEN_ENDPOINT: string; - TODOIST_REGISTRATION_ENDPOINT: string; - TODOIST_USERINFO_ENDPOINT: string; GITHUB_APP_ID: string; GITHUB_PRIVATE_KEY: string; GITHUB_CLIENT_ID: string; @@ -35,6 +21,8 @@ interface __BaseEnv_Env { CLOUDFLARE_CREDENTIAL_ENCRYPTION_KEY: string; CONTEXT7_CREDENTIAL_ENCRYPTION_KEY: string; TODOIST_CREDENTIAL_ENCRYPTION_KEY: string; + SEARCH1API_CREDENTIAL_ENCRYPTION_KEY: string; + FASTIO_CREDENTIAL_ENCRYPTION_KEY: string; } declare namespace Cloudflare { interface GlobalProps { @@ -47,7 +35,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 58b15c7..b5a2755 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -10,21 +10,7 @@ "vars": { "REALMROOT_ISSUER": "https://id.realmroot.dev/api/auth", "REALMROOT_JWKS_URL": "https://id.realmroot.dev/api/auth/jwks", - "REALMROOT_AGENT_PROFILE_URI_TEMPLATE": "https://id.realmroot.dev/api/public/agents/{subject}", - "GITHUB_API_ORIGIN": "https://api.github.com", - "GITHUB_UPLOADS_ORIGIN": "https://uploads.github.com", - "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", - "CONTEXT7_API_ORIGIN": "https://context7.com/api", - "CONTEXT7_OAUTH_ISSUER": "https://clerk.context7.com", - "TODOIST_API_ORIGIN": "https://api.todoist.com/api/v1", - "TODOIST_AUTHORIZATION_ENDPOINT": "https://app.todoist.com/oauth/authorize", - "TODOIST_LOGIN_ENDPOINT": "https://app.todoist.com/users/showlogin", - "TODOIST_TOKEN_ENDPOINT": "https://api.todoist.com/oauth/access_token", - "TODOIST_REGISTRATION_ENDPOINT": "https://api.todoist.com/oauth/register", - "TODOIST_USERINFO_ENDPOINT": "https://api.todoist.com/api/v1/user" + "REALMROOT_AGENT_PROFILE_URI_TEMPLATE": "https://id.realmroot.dev/api/public/agents/{subject}" }, "assets": { "directory": "./public" }, "secrets": { @@ -43,7 +29,9 @@ "CLOUDFLARE_CLIENT_SECRET", "CLOUDFLARE_CREDENTIAL_ENCRYPTION_KEY", "CONTEXT7_CREDENTIAL_ENCRYPTION_KEY", - "TODOIST_CREDENTIAL_ENCRYPTION_KEY" + "TODOIST_CREDENTIAL_ENCRYPTION_KEY", + "SEARCH1API_CREDENTIAL_ENCRYPTION_KEY", + "FASTIO_CREDENTIAL_ENCRYPTION_KEY" ] }, "d1_databases": [ From d12d1ffbd6e69b36638592e4e84a0f362258c165 Mon Sep 17 00:00:00 2001 From: jarvis Date: Fri, 4 Sep 2026 18:18:55 -0400 Subject: [PATCH 2/3] fix(adapters): use jose signing key type --- src/core/configured-managed-provider.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/configured-managed-provider.ts b/src/core/configured-managed-provider.ts index 50a33d4..bf5a2f5 100644 --- a/src/core/configured-managed-provider.ts +++ b/src/core/configured-managed-provider.ts @@ -1,3 +1,4 @@ +import type { JWK } from 'jose' import type { AdapterModule } from './adapter.js' import { createCredentialCipher } from './credential-cipher.js' import { @@ -42,7 +43,7 @@ export async function createConfiguredManagedProvider(input: { origin: string db: D1Database credentialEncryptionKey: string - signingPrivateJwk: JsonWebKey + signingPrivateJwk: JWK oauthStore: D1ExternalOAuthStore replayStore: DpopReplayStore audit(record: Record): Promise From 46d1029e4052dbd9d9ecf29e3199581f4bf87e03 Mon Sep 17 00:00:00 2001 From: jarvis Date: Fri, 4 Sep 2026 18:26:37 -0400 Subject: [PATCH 3/3] test(config): lock provider origins to code --- test/config.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/config.test.ts b/test/config.test.ts index f9248b4..6fdc0bd 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { loadConfig } from '../src/config.js' +import { loadCloudflareConfig } from '../src/providers/cloudflare/config.js' import { loadGitHubConfig } from '../src/providers/github/config.js' import { loadLinearConfig } from '../src/providers/linear/config.js' @@ -20,6 +21,11 @@ describe('adapter Worker configuration', () => { LINEAR_CLIENT_SECRET: 'linear-secret', LINEAR_CREDENTIAL_ENCRYPTION_KEY: 'linear-encryption-key', LINEAR_WEBHOOK_SECRET: 'linear-webhook-secret', + CLOUDFLARE_API_ORIGIN: 'https://untrusted-cloudflare-api.example/', + CLOUDFLARE_AUTHORIZATION_ORIGIN: 'https://untrusted-cloudflare.example/', + CLOUDFLARE_CLIENT_ID: 'cloudflare-client', + CLOUDFLARE_CLIENT_SECRET: 'cloudflare-secret', + CLOUDFLARE_CREDENTIAL_ENCRYPTION_KEY: 'cloudflare-encryption-key', } const config = loadConfig(environment, 'https://adapter.example/health') @@ -44,6 +50,12 @@ describe('adapter Worker configuration', () => { linearCredentialEncryptionKey: 'linear-encryption-key', linearWebhookSecret: 'linear-webhook-secret', }) + expect(loadCloudflareConfig(environment, config)).toMatchObject({ + cloudflareApiOrigin: 'https://api.cloudflare.com/client/v4', + authorizationOrigin: 'https://dash.cloudflare.com', + clientId: 'cloudflare-client', + clientSecret: 'cloudflare-secret', + }) }) it('fails when required Realmroot bindings are missing', () => {