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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 2 additions & 9 deletions .dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
22 changes: 13 additions & 9 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 29 additions & 0 deletions providers/fastio/README.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 31 additions & 0 deletions providers/search1api/README.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 2 additions & 3 deletions providers/todoist/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
138 changes: 138 additions & 0 deletions src/core/configured-managed-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import type { JWK } from 'jose'
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<Record<string, string>>
operations: readonly ManagedOpenApiOperation[]
authorizationScopeSeparator?: ' ' | ','
authorizationWrapper?: DynamicOAuthAuthorizationWrapper
identity(value: unknown): { subject: string; displayName: string }
openapi(input: { resource: string; issuer: string }): Record<string, unknown>
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: JWK
oauthStore: D1ExternalOAuthStore
replayStore: DpopReplayStore
audit(record: Record<string, unknown>): Promise<void>
fetcher?: typeof fetch
}): Promise<AdapterModule[]> {
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]
}
10 changes: 2 additions & 8 deletions src/providers/cloudflare/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(/\/+$/, '')
}
Loading