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
1 change: 1 addition & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ 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
LINEAR_API_ORIGIN=https://api.linear.app
LINEAR_AUTHORIZATION_ORIGIN=https://linear.app
LINEAR_CLIENT_ID=replace-with-linear-oauth-client-id
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ identity model has already passed a capability review.
| 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 |
| Todoist | Provider-delegated user | Read-only 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 |
Expand Down Expand Up @@ -163,6 +164,7 @@ providers/
github/ Provider capability report
cloudflare/ Provider design and implementation
linear/ Provider design and implementation
todoist/ Managed read-only OAuth provider configuration
docs/
architecture.md
github-design.md
Expand Down Expand Up @@ -221,6 +223,12 @@ 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.

Todoist exercises the same runtime with provider endpoints on different hosts,
comma-separated authorization scopes, a provider-specific identity shape, and
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.

Configure the GitHub App callbacks as:

```text
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Agent 能以自己的稳定身份直接进入各种平台。详见
| Linear | 代理的原生 App actor | 共享 App user,以及逐次操作中的可信 Agent 名称/头像 | 1 | 实验性 |
| Cloudflare | 原生 service principal | 独立 account-owned token actor 出现在审计日志中 | 1 | 设计中 |
| Context7 | 代理用户身份 | 共享 OAuth 用户授权,并由 Adapter 审计记录具体 Agent | 1 | 实验性 |
| Todoist | 代理用户身份 | 只读 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 | 提案 |
Expand Down
7 changes: 7 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,13 @@ is stabilized. It is expected to contain these cohesive capabilities:
The application behavior owns these contracts. Provider SDK objects, HTTP
responses, token formats, and error types remain inside provider adapters.

Managed OpenAPI providers reuse a declarative runtime for explicit operation
allowlists, OAuth endpoints, scope serialization, encrypted credentials,
refresh rotation, external authorization, and request forwarding. A new
provider supplies endpoint configuration, an identity decoder, an OpenAPI
contract, and an Agent-to-provider scope mapping. Capabilities such as upstream
revocation remain optional and are declared as readiness gaps when absent.

## Capability manifest

Every provider will publish a machine-readable, versioned manifest containing
Expand Down
12 changes: 12 additions & 0 deletions migrations/0011_managed_oauth_credentials.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
CREATE TABLE managed_oauth_credential (
provider_id TEXT NOT NULL,
subject TEXT 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,
PRIMARY KEY (provider_id, subject)
);
27 changes: 27 additions & 0 deletions providers/todoist/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Todoist Adapter

The Todoist adapter exposes a deliberately small, read-only Resource Server at
`/todoist`. It uses Todoist's RFC 7591 dynamic client registration with a
public OAuth client and PKCE, so operators do not provision a Todoist client ID
or client secret.

## Published operations

- `GET /todoist/projects` maps to `GET /api/v1/projects`.
- `GET /todoist/tasks` maps to `GET /api/v1/tasks`.

Both operations require the Agent-facing `tasks:read` scope, which maps to
Todoist's `data:read` provider scope.

## Configuration

The provider URLs have production defaults. 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 persists one dynamically registered public client and stores each
provider credential encrypted in D1. Todoist's current revocation endpoints
require confidential-client authentication, so disconnecting revokes the
adapter-side grant and deletes the local credential but cannot revoke the
upstream public-client grant.
7 changes: 7 additions & 0 deletions public/.well-known/agent-skills/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
"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"
},
{
"name": "use-todoist",
"type": "skill-md",
"description": "Use Todoist through Realmroot Toolbox to list the user's active projects and tasks with controller-approved, read-only Agent authority.",
"url": "/.well-known/agent-skills/todoist/SKILL.md",
"digest": "sha256:074c2632102ab7d034f579b91c3e49c53da2095a5d9ec6c806bc5cc1ce9a659f"
}
]
}
30 changes: 30 additions & 0 deletions public/.well-known/agent-skills/todoist/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
name: use-todoist
description: Use Todoist through Realmroot Toolbox to list the user's active projects and tasks with controller-approved, read-only Agent authority.
---

# Use Todoist

Use the `realmroot` command so every Todoist request runs as the Agent with
controller-approved authority. Never ask the user for a Todoist API token.

Before the first operation, run:

```bash
realmroot toolbox todoist
```

If the command reports missing access, request only the `tasks:read` scope and
continue after controller approval.

Use the published resources as follows:

1. Call `GET /projects` to discover active projects. Preserve `next_cursor`
when another page is needed.
2. Call `GET /tasks` to list active tasks. Prefer narrowing by `project_id`,
`section_id`, `parent_id`, `label`, or explicit `ids` when the task permits.
3. Follow cursor pagination until `next_cursor` is null or enough information
has been gathered.

This Resource is read-only. Do not infer that creating, completing, updating,
or deleting Todoist data is available.
23 changes: 23 additions & 0 deletions specs/todoist-adapter.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
Feature: Todoist managed OpenAPI adapter

Scenario: Todoist provider OAuth
Given Todoist supports dynamic public-client registration
When the adapter starts an authorization code flow
Then it uses PKCE and requests only read-only provider scopes
And encrypted credentials are stored per provider subject

Scenario: Todoist contract
Given the Todoist adapter is enabled
When an Agent discovers the Resource Server
Then the OpenAPI document publishes project and task collections
And every operation requires the tasks:read scope

Scenario: Todoist project discovery
Given an Agent has approved tasks:read access
When it lists Todoist projects
Then the adapter forwards the request with the delegated Todoist credential

Scenario: Todoist task discovery
Given an Agent has approved tasks:read access
When it lists Todoist tasks with optional collection filters
Then the adapter forwards only the published query operation
50 changes: 34 additions & 16 deletions src/core/dynamic-oauth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ export type DynamicOAuthToken = Readonly<{
scopes: readonly string[]
}>

export type DynamicOAuthEndpoints = Readonly<{
authorization: string
registration: string
token: string
userInfo: string
revocation?: string
}>

export class D1DynamicOAuthRegistrationStore implements DynamicOAuthRegistrationStore {
constructor(private readonly db: D1Database) {}

Expand All @@ -47,25 +55,27 @@ export class D1DynamicOAuthRegistrationStore implements DynamicOAuthRegistration
export function createDynamicOAuthClient(input: {
providerId: string
clientName: string
issuer: string
endpoints: DynamicOAuthEndpoints
redirectUri: string
scopes: readonly string[]
authorizationScopeSeparator?: ' ' | ','
registrationStore: DynamicOAuthRegistrationStore
fetcher?: typeof fetch
now?: () => number
}) {
const fetcher = input.fetcher ?? fetch
const now = input.now ?? Date.now
const revocationEndpoint = input.endpoints.revocation

return {
async authorizationUrl(state: string) {
const verifier = randomVerifier()
const clientId = await registeredClientId()
const url = new URL('/oauth/authorize', input.issuer)
const url = new URL(input.endpoints.authorization)
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('scope', input.scopes.join(input.authorizationScopeSeparator ?? ' '))
url.searchParams.set('state', state)
url.searchParams.set('code_challenge', await sha256Base64Url(verifier))
url.searchParams.set('code_challenge_method', 'S256')
Expand All @@ -82,17 +92,25 @@ export function createDynamicOAuthClient(input: {
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')
},
...(revocationEndpoint
? {
async revoke(token: string) {
const response = await fetcher(new URL(revocationEndpoint), {
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), {
const response = await fetcher(new URL(input.endpoints.userInfo), {
headers: { authorization: `Bearer ${accessToken}` },
signal: AbortSignal.timeout(10_000),
})
Expand All @@ -104,7 +122,7 @@ export function createDynamicOAuthClient(input: {
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), {
const response = await fetcher(new URL(input.endpoints.registration), {
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify({
Expand All @@ -125,7 +143,7 @@ export function createDynamicOAuthClient(input: {
}

async function tokenRequest(parameters: Record<string, string>): Promise<DynamicOAuthToken> {
const response = await fetcher(new URL('/oauth/token', input.issuer), {
const response = await fetcher(new URL(input.endpoints.token), {
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ ...parameters, client_id: await registeredClientId() }),
Expand All @@ -151,7 +169,7 @@ function randomVerifier() {
}

function normalizeScopes(value: string | readonly string[]) {
const scopes = typeof value === 'string' ? value.split(/\s+/) : value
const scopes = typeof value === 'string' ? value.split(/[\s,]+/) : value
return [...new Set(scopes.filter(Boolean))].sort()
}

Expand Down
Loading