From 51bc662dee666938bbabd0ae9b027f07858ad7fa Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 28 Sep 2026 13:35:07 -0600 Subject: [PATCH 1/2] feat(connectors): export OAuth helpers as a narrow entrypoint --- README.md | 12 ++++++++++++ package.json | 5 +++++ scripts/test-release-artifact.mjs | 17 +++++++++++++++++ tsdown.config.ts | 1 + 4 files changed, 35 insertions(+) diff --git a/README.md b/README.md index cdf892a6..7eb2aea4 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,18 @@ Main boundaries: pnpm add @tangle-network/agent-integrations ``` +Consumers that need only OAuth protocol helpers can import the narrow entrypoint: + +```ts +import { + exchangeAuthorizationCode, + refreshAccessToken, + startOAuthFlow, +} from '@tangle-network/agent-integrations/connectors/oauth' +``` + +This entrypoint keeps the concrete connector catalog out of OAuth-only runtime bundles. + ## Quick Start ```ts diff --git a/package.json b/package.json index 7f14bc05..fde9cebe 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,11 @@ "import": "./dist/connectors/index.js", "default": "./dist/connectors/index.js" }, + "./connectors/oauth": { + "types": "./dist/connectors/oauth.d.ts", + "import": "./dist/connectors/oauth.js", + "default": "./dist/connectors/oauth.js" + }, "./connectors/adapters": { "types": "./dist/connectors/adapters/index.d.ts", "import": "./dist/connectors/adapters/index.js", diff --git a/scripts/test-release-artifact.mjs b/scripts/test-release-artifact.mjs index 1a85cc7b..5ccd0bdc 100644 --- a/scripts/test-release-artifact.mjs +++ b/scripts/test-release-artifact.mjs @@ -45,6 +45,23 @@ try { } } + const oauthModulePath = join(packageDirectory, 'dist/connectors/oauth.js') + const oauthModule = readFileSync(oauthModulePath, 'utf8') + if (/graceful-fs|read-excel-file|unzipper-esm|connectors\/index/.test(oauthModule)) { + throw new Error('OAuth-only release entrypoint includes the concrete connector catalog.') + } + const oauthConsumerPath = join(packageDirectory, '.release-artifact-oauth-consumer.mjs') + await import('node:fs/promises').then(({ writeFile }) => writeFile( + oauthConsumerPath, + `import * as oauth from '@tangle-network/agent-integrations/connectors/oauth'\n` + + `const values = new Map()\n` + + `const store = { async get(key) { return values.get(key) ?? null }, async put(key, value) { values.set(key, value) }, async delete(key) { values.delete(key) } }\n` + + `const flow = await oauth.startOAuthFlow({ provider: 'google', authorizationUrl: 'https://accounts.example.test/authorize', clientId: 'client', redirectUri: 'https://example.test/callback', scopes: [], store })\n` + + `const pending = values.get(flow.state)\n` + + `if (!flow.authorizationUrl.includes('code_challenge_method=S256') || !pending.codeVerifier) throw new Error('OAuth package consumer proof failed')\n`, + )) + await import(pathToFileURL(oauthConsumerPath).href) + const { checkBundledManifestFreshness } = await import( pathToFileURL(join(rootDirectory, 'scripts/check-bundled-manifest-freshness.mjs')).href, ) diff --git a/tsdown.config.ts b/tsdown.config.ts index e49976b6..b2acaa5e 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ runtime: 'src/runtime.ts', specs: 'src/specs/index.ts', 'connectors/index': 'src/connectors/index.ts', + 'connectors/oauth': 'src/connectors/oauth.ts', 'connectors/adapters/index': 'src/connectors/adapters/index.ts', 'connect/index': 'src/connect/index.ts', 'middleware/index': 'src/middleware/index.ts', From c85fc0eeafa351a5d58f7026c77b65882a9cf8c1 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 28 Sep 2026 13:58:57 -0600 Subject: [PATCH 2/2] feat(agent-integrations): expose worker-safe consumer entries --- README.md | 2 +- package.json | 10 + scripts/test-release-artifact.mjs | 9 +- src/hub.ts | 324 ++++++++++++++++++++++++++++++ src/index.ts | 295 +-------------------------- tsdown.config.ts | 2 + 6 files changed, 343 insertions(+), 299 deletions(-) create mode 100644 src/hub.ts diff --git a/README.md b/README.md index 7eb2aea4..2b742071 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ import { } from '@tangle-network/agent-integrations/connectors/oauth' ``` -This entrypoint keeps the concrete connector catalog out of OAuth-only runtime bundles. +The hub entrypoint exposes capability issuance without loading concrete connectors. Both entrypoints keep the connector catalog out of consumers that only need runtime helpers. ## Quick Start diff --git a/package.json b/package.json index fde9cebe..8bd28557 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,16 @@ "import": "./dist/connectors/oauth.js", "default": "./dist/connectors/oauth.js" }, + "./hub": { + "types": "./dist/hub.d.ts", + "import": "./dist/hub.js", + "default": "./dist/hub.js" + }, + "./sandbox": { + "types": "./dist/sandbox.d.ts", + "import": "./dist/sandbox.js", + "default": "./dist/sandbox.js" + }, "./connectors/adapters": { "types": "./dist/connectors/adapters/index.d.ts", "import": "./dist/connectors/adapters/index.js", diff --git a/scripts/test-release-artifact.mjs b/scripts/test-release-artifact.mjs index 5ccd0bdc..9079d27c 100644 --- a/scripts/test-release-artifact.mjs +++ b/scripts/test-release-artifact.mjs @@ -45,10 +45,11 @@ try { } } - const oauthModulePath = join(packageDirectory, 'dist/connectors/oauth.js') - const oauthModule = readFileSync(oauthModulePath, 'utf8') - if (/graceful-fs|read-excel-file|unzipper-esm|connectors\/index/.test(oauthModule)) { - throw new Error('OAuth-only release entrypoint includes the concrete connector catalog.') + for (const workerEntryPath of ['dist/connectors/oauth.js', 'dist/hub.js']) { + const workerEntry = readFileSync(join(packageDirectory, workerEntryPath), 'utf8') + if (/graceful-fs|read-excel-file|unzipper-esm|connectors\/index|node:module|createRequire/.test(workerEntry)) { + throw new Error('Worker-safe release entrypoint includes a Node-only connector dependency: ' + workerEntryPath) + } } const oauthConsumerPath = join(packageDirectory, '.release-artifact-oauth-consumer.mjs') await import('node:fs/promises').then(({ writeFile }) => writeFile( diff --git a/src/hub.ts b/src/hub.ts new file mode 100644 index 00000000..4001235d --- /dev/null +++ b/src/hub.ts @@ -0,0 +1,324 @@ +import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto' +import { + composeIntegrationRegistry, + type ComposeIntegrationRegistryOptions, + type IntegrationRegistry, +} from './registry-core.js' +import { + InMemoryIntegrationOAuthStateStore, + InMemoryIntegrationSecretStore, + type IntegrationOAuthState, +} from './credentials.js' +import { IntegrationError } from './core-error.js' +import type { + CompleteAuthRequest, + IntegrationActionGuard, + IntegrationActionRequest, + IntegrationActionResult, + IntegrationActor, + IntegrationCapability, + IntegrationConnection, + IntegrationConnectionStore, + IntegrationConnector, + IntegrationHubOptions, + IntegrationPolicyEngine, + IntegrationProvider, + IntegrationTriggerEvent, + IntegrationTriggerSubscription, + InvokeWithCapabilityRequest, + IssueCapabilityRequest, + IssuedIntegrationCapability, + StartAuthRequest, + StartAuthResult, +} from './core-types.js' + +export class IntegrationHub { + private readonly providers = new Map() + private readonly store: IntegrationConnectionStore + private readonly capabilitySecret: string + private readonly guard: IntegrationActionGuard | undefined + private readonly policy: IntegrationPolicyEngine | undefined + /** Host-injected (or in-memory default) secret store. The hub re-persists + * rotated credentials here when a connection carries a secretRef. */ + readonly secretStore: IntegrationHubOptions['secretStore'] + private readonly oauthStateStore: NonNullable + private readonly oauthStateTtlMs: number + private readonly credentialsRotated: IntegrationHubOptions['credentialsRotated'] + private readonly now: () => Date + + constructor(options: IntegrationHubOptions) { + if (!options.capabilitySecret) { + throw new IntegrationError('capabilitySecret is required.', 'capability_invalid') + } + for (const provider of options.providers) this.providers.set(provider.id, provider) + this.store = options.store + this.capabilitySecret = options.capabilitySecret + this.guard = options.guard + this.policy = options.policy + this.secretStore = options.secretStore ?? new InMemoryIntegrationSecretStore() + this.oauthStateStore = options.oauthStateStore ?? new InMemoryIntegrationOAuthStateStore() + this.oauthStateTtlMs = options.oauthStateTtlMs ?? 10 * 60 * 1000 + this.credentialsRotated = options.credentialsRotated + this.now = options.now ?? (() => new Date()) + } + + async listConnectors(): Promise { + const catalogs = await Promise.all([...this.providers.values()].map((provider) => provider.listConnectors())) + return catalogs.flat() + } + + async listRegistry(options: ComposeIntegrationRegistryOptions = {}): Promise { + const sources = await Promise.all([...this.providers.values()].map(async (provider) => ({ + id: provider.id, + connectors: await provider.listConnectors(), + }))) + return composeIntegrationRegistry(sources, options) + } + + async startAuth(providerId: string, request: StartAuthRequest): Promise { + const provider = this.requireProvider(providerId) + if (!provider.startAuth) throw new IntegrationError(`Provider ${providerId} does not support auth start.`, 'auth_not_supported') + const connector = await this.requireConnector(provider, request.connectorId) + const pkce = connectorOAuthPkceMode(connector) + const pkcePair = pkce === 'unsupported' ? undefined : createPkcePair() + const result = await provider.startAuth({ + ...request, + codeChallenge: pkcePair?.challenge, + }) + const record: IntegrationOAuthState = { + state: result.state, + providerId, + connectorId: request.connectorId, + owner: request.owner, + requestedScopes: request.requestedScopes, + redirectUri: request.redirectUri, + codeVerifier: pkcePair?.verifier, + expiresAt: this.now().getTime() + this.oauthStateTtlMs, + metadata: request.metadata, + } + await this.oauthStateStore.put(record) + return result + } + + async completeAuth(providerId: string, request: CompleteAuthRequest): Promise { + const provider = this.requireProvider(providerId) + if (!provider.completeAuth) throw new IntegrationError(`Provider ${providerId} does not support auth completion.`, 'auth_not_supported') + const outcome = await this.oauthStateStore.consume(request.state) + if (!outcome.ok) { + throw new IntegrationError(`Integration OAuth state ${outcome.reason}: possible CSRF, replay, or stale flow.`, 'capability_invalid') + } + if (outcome.state.providerId !== providerId || outcome.state.connectorId !== request.connectorId) { + throw new IntegrationError('Integration OAuth state does not match completion request.', 'capability_invalid') + } + if (outcome.state.redirectUri !== request.redirectUri) { + throw new IntegrationError('Integration OAuth redirect URI does not match the start request.', 'capability_invalid') + } + const pinnedMetadata = request.metadata || outcome.state.metadata + ? { ...request.metadata, ...outcome.state.metadata } + : undefined + const connection = await provider.completeAuth({ + ...request, + connectorId: outcome.state.connectorId, + owner: outcome.state.owner, + state: outcome.state.state, + redirectUri: outcome.state.redirectUri, + codeVerifier: outcome.state.codeVerifier, + metadata: pinnedMetadata, + }) + await this.store.put(connection) + return connection + } + + async upsertConnection(connection: IntegrationConnection): Promise { + await this.store.put(connection) + return connection + } + + async listConnections(owner: IntegrationActor): Promise { + return this.store.listByOwner(owner) + } + + async getConnection(connectionId: string): Promise { + return this.store.get(connectionId) + } + + async issueCapability(request: IssueCapabilityRequest): Promise { + const connection = await this.requireConnection(request.connectionId) + this.assertConnectionActive(connection) + assertScopes(connection, request.scopes) + const now = this.now() + const capability: IntegrationCapability = { + id: `cap_${randomUUID()}`, + subject: request.subject, + connectionId: request.connectionId, + scopes: unique(request.scopes), + allowedActions: unique(request.allowedActions), + issuedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + request.ttlMs).toISOString(), + metadata: request.metadata, + } + return { capability, token: signCapability(capability, this.capabilitySecret) } + } + + verifyCapability(token: string): IntegrationCapability { + const capability = verifyCapabilityToken(token, this.capabilitySecret) + if (Date.parse(capability.expiresAt) <= this.now().getTime()) { + throw new IntegrationError('Integration capability expired.', 'capability_expired') + } + return capability + } + + async invokeWithCapability(token: string, request: InvokeWithCapabilityRequest): Promise { + const capability = this.verifyCapability(token) + if (!capability.allowedActions.includes(request.action)) { + throw new IntegrationError(`Capability does not allow action ${request.action}.`, 'action_denied') + } + const connection = await this.requireConnection(capability.connectionId) + this.assertConnectionActive(connection) + const provider = this.requireProvider(connection.providerId) + const connector = await this.requireConnector(provider, connection.connectorId) + const action = connector.actions.find((candidate) => candidate.id === request.action) + if (!action) throw new IntegrationError(`Action ${request.action} is not defined by connector ${connector.id}.`, 'action_not_found') + assertScopes(connection, action.requiredScopes) + assertScopes({ ...connection, grantedScopes: capability.scopes }, action.requiredScopes) + const fullRequest: IntegrationActionRequest = { ...request, connectionId: connection.id } + const proceed = async () => { + if (!this.policy) return provider.invokeAction(connection, fullRequest) + const decision = await this.policy.decide({ + connection, + request: fullRequest, + action, + subject: capability.subject, + }) + if (decision.decision === 'deny') { + throw new IntegrationError(decision.reason, 'policy_denied') + } + if (decision.decision === 'require_approval') { + return { + ok: false, + action: request.action, + output: { approvalRequired: true, approval: decision.approval }, + metadata: { policyDecision: decision.decision, reason: decision.reason, ...decision.metadata }, + } + } + return provider.invokeAction(connection, fullRequest) + } + if (this.guard) { + return this.guard.invokeAction({ connection, request: fullRequest, action }, proceed) + } + return proceed() + } + + async subscribeTrigger(connectionId: string, trigger: string, targetUrl?: string): Promise { + const connection = await this.requireConnection(connectionId) + this.assertConnectionActive(connection) + const provider = this.requireProvider(connection.providerId) + const connector = await this.requireConnector(provider, connection.connectorId) + const spec = connector.triggers?.find((candidate) => candidate.id === trigger) + if (!spec) throw new IntegrationError(`Trigger ${trigger} is not defined by connector ${connector.id}.`, 'trigger_not_found') + assertScopes(connection, spec.requiredScopes) + if (!provider.subscribeTrigger) { + throw new IntegrationError(`Provider ${provider.id} does not support triggers.`, 'auth_not_supported') + } + return provider.subscribeTrigger(connection, trigger, targetUrl) + } + + private requireProvider(providerId: string): IntegrationProvider { + const provider = this.providers.get(providerId) + if (!provider) throw new IntegrationError(`Provider ${providerId} not found.`, 'provider_not_found') + return provider + } + + private async requireConnector(provider: IntegrationProvider, connectorId: string): Promise { + const connector = (await provider.listConnectors()).find((candidate) => candidate.id === connectorId) + if (!connector) throw new IntegrationError(`Connector ${connectorId} not found.`, 'connector_not_found') + return connector + } + + private async requireConnection(connectionId: string): Promise { + const connection = await this.store.get(connectionId) + if (!connection) throw new IntegrationError(`Connection ${connectionId} not found.`, 'connection_not_found') + return connection + } + + private assertConnectionActive(connection: IntegrationConnection): void { + if (connection.status !== 'active') { + throw new IntegrationError(`Connection ${connection.id} is ${connection.status}.`, 'connection_not_active') + } + if (connection.expiresAt && Date.parse(connection.expiresAt) <= this.now().getTime()) { + throw new IntegrationError(`Connection ${connection.id} is expired.`, 'connection_not_active') + } + } +} + + +export function signCapability(capability: IntegrationCapability, secret: string): string { + const payload = base64UrlEncode(JSON.stringify(capability)) + const signature = hmac(payload, secret) + return `${payload}.${signature}` +} + +export function verifyCapabilityToken(token: string, secret: string): IntegrationCapability { + const [payload, signature] = token.split('.') + if (!payload || !signature) throw new IntegrationError('Malformed integration capability.', 'capability_invalid') + const expected = hmac(payload, secret) + if (!constantTimeEqual(signature, expected)) throw new IntegrationError('Invalid integration capability signature.', 'capability_invalid') + let parsed: IntegrationCapability + try { + parsed = JSON.parse(base64UrlDecode(payload)) as IntegrationCapability + } catch { + throw new IntegrationError('Invalid integration capability payload.', 'capability_invalid') + } + if (!parsed.id || !parsed.connectionId || !Array.isArray(parsed.scopes) || !Array.isArray(parsed.allowedActions)) { + throw new IntegrationError('Invalid integration capability payload.', 'capability_invalid') + } + return parsed +} + + +function assertScopes(connection: Pick, requiredScopes: string[]): void { + const missing = requiredScopes.filter((scope) => !connection.grantedScopes.includes(scope)) + if (missing.length > 0) throw new IntegrationError(`Missing integration scopes: ${missing.join(', ')}`, 'scope_denied') +} + + +type IntegrationOAuthPkceMode = 'required' | 'supported' | 'unsupported' + +function connectorOAuthPkceMode(connector: IntegrationConnector): IntegrationOAuthPkceMode { + const value = connector.metadata?.oauthPkce + if (value === undefined) return 'required' + if (value === 'required' || value === 'supported' || value === 'unsupported') return value + throw new IntegrationError( + `Connector ${connector.id} declares an invalid OAuth PKCE posture.`, + 'config_missing', + ) +} + +function createPkcePair(): { verifier: string; challenge: string } { + const verifier = randomBytes(48).toString('base64url') + const challenge = createHash('sha256').update(verifier).digest('base64url') + return { verifier, challenge } +} + +function hmac(payload: string, secret: string): string { + return createHmac('sha256', secret).update(payload).digest('base64url') +} + +function constantTimeEqual(a: string, b: string): boolean { + const left = Buffer.from(a) + const right = Buffer.from(b) + return left.length === right.length && timingSafeEqual(left, right) +} + +function base64UrlEncode(value: string): string { + return Buffer.from(value, 'utf8').toString('base64url') +} + +function base64UrlDecode(value: string): string { + return Buffer.from(value, 'base64url').toString('utf8') +} + +function unique(values: T[]): T[] { + return [...new Set(values)] +} + diff --git a/src/index.ts b/src/index.ts index d6452e02..814165f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,3 @@ -import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto' -import { - composeIntegrationRegistry, - type ComposeIntegrationRegistryOptions, - type IntegrationRegistry, -} from './registry.js' -import { - InMemoryIntegrationOAuthStateStore, - InMemoryIntegrationSecretStore, - type IntegrationOAuthState, -} from './credentials.js' import { IntegrationError } from './core-error.js' import type { CompleteAuthRequest, @@ -79,224 +68,6 @@ export class InMemoryConnectionStore implements IntegrationConnectionStore { } } -export class IntegrationHub { - private readonly providers = new Map() - private readonly store: IntegrationConnectionStore - private readonly capabilitySecret: string - private readonly guard: IntegrationActionGuard | undefined - private readonly policy: IntegrationPolicyEngine | undefined - /** Host-injected (or in-memory default) secret store. The hub re-persists - * rotated credentials here when a connection carries a secretRef. */ - readonly secretStore: IntegrationHubOptions['secretStore'] - private readonly oauthStateStore: NonNullable - private readonly oauthStateTtlMs: number - private readonly credentialsRotated: IntegrationHubOptions['credentialsRotated'] - private readonly now: () => Date - - constructor(options: IntegrationHubOptions) { - if (!options.capabilitySecret) { - throw new IntegrationError('capabilitySecret is required.', 'capability_invalid') - } - for (const provider of options.providers) this.providers.set(provider.id, provider) - this.store = options.store - this.capabilitySecret = options.capabilitySecret - this.guard = options.guard - this.policy = options.policy - this.secretStore = options.secretStore ?? new InMemoryIntegrationSecretStore() - this.oauthStateStore = options.oauthStateStore ?? new InMemoryIntegrationOAuthStateStore() - this.oauthStateTtlMs = options.oauthStateTtlMs ?? 10 * 60 * 1000 - this.credentialsRotated = options.credentialsRotated - this.now = options.now ?? (() => new Date()) - } - - async listConnectors(): Promise { - const catalogs = await Promise.all([...this.providers.values()].map((provider) => provider.listConnectors())) - return catalogs.flat() - } - - async listRegistry(options: ComposeIntegrationRegistryOptions = {}): Promise { - const sources = await Promise.all([...this.providers.values()].map(async (provider) => ({ - id: provider.id, - connectors: await provider.listConnectors(), - }))) - return composeIntegrationRegistry(sources, options) - } - - async startAuth(providerId: string, request: StartAuthRequest): Promise { - const provider = this.requireProvider(providerId) - if (!provider.startAuth) throw new IntegrationError(`Provider ${providerId} does not support auth start.`, 'auth_not_supported') - const connector = await this.requireConnector(provider, request.connectorId) - const pkce = connectorOAuthPkceMode(connector) - const pkcePair = pkce === 'unsupported' ? undefined : createPkcePair() - const result = await provider.startAuth({ - ...request, - codeChallenge: pkcePair?.challenge, - }) - const record: IntegrationOAuthState = { - state: result.state, - providerId, - connectorId: request.connectorId, - owner: request.owner, - requestedScopes: request.requestedScopes, - redirectUri: request.redirectUri, - codeVerifier: pkcePair?.verifier, - expiresAt: this.now().getTime() + this.oauthStateTtlMs, - metadata: request.metadata, - } - await this.oauthStateStore.put(record) - return result - } - - async completeAuth(providerId: string, request: CompleteAuthRequest): Promise { - const provider = this.requireProvider(providerId) - if (!provider.completeAuth) throw new IntegrationError(`Provider ${providerId} does not support auth completion.`, 'auth_not_supported') - const outcome = await this.oauthStateStore.consume(request.state) - if (!outcome.ok) { - throw new IntegrationError(`Integration OAuth state ${outcome.reason}: possible CSRF, replay, or stale flow.`, 'capability_invalid') - } - if (outcome.state.providerId !== providerId || outcome.state.connectorId !== request.connectorId) { - throw new IntegrationError('Integration OAuth state does not match completion request.', 'capability_invalid') - } - if (outcome.state.redirectUri !== request.redirectUri) { - throw new IntegrationError('Integration OAuth redirect URI does not match the start request.', 'capability_invalid') - } - const pinnedMetadata = request.metadata || outcome.state.metadata - ? { ...request.metadata, ...outcome.state.metadata } - : undefined - const connection = await provider.completeAuth({ - ...request, - connectorId: outcome.state.connectorId, - owner: outcome.state.owner, - state: outcome.state.state, - redirectUri: outcome.state.redirectUri, - codeVerifier: outcome.state.codeVerifier, - metadata: pinnedMetadata, - }) - await this.store.put(connection) - return connection - } - - async upsertConnection(connection: IntegrationConnection): Promise { - await this.store.put(connection) - return connection - } - - async listConnections(owner: IntegrationActor): Promise { - return this.store.listByOwner(owner) - } - - async getConnection(connectionId: string): Promise { - return this.store.get(connectionId) - } - - async issueCapability(request: IssueCapabilityRequest): Promise { - const connection = await this.requireConnection(request.connectionId) - this.assertConnectionActive(connection) - assertScopes(connection, request.scopes) - const now = this.now() - const capability: IntegrationCapability = { - id: `cap_${randomUUID()}`, - subject: request.subject, - connectionId: request.connectionId, - scopes: unique(request.scopes), - allowedActions: unique(request.allowedActions), - issuedAt: now.toISOString(), - expiresAt: new Date(now.getTime() + request.ttlMs).toISOString(), - metadata: request.metadata, - } - return { capability, token: signCapability(capability, this.capabilitySecret) } - } - - verifyCapability(token: string): IntegrationCapability { - const capability = verifyCapabilityToken(token, this.capabilitySecret) - if (Date.parse(capability.expiresAt) <= this.now().getTime()) { - throw new IntegrationError('Integration capability expired.', 'capability_expired') - } - return capability - } - - async invokeWithCapability(token: string, request: InvokeWithCapabilityRequest): Promise { - const capability = this.verifyCapability(token) - if (!capability.allowedActions.includes(request.action)) { - throw new IntegrationError(`Capability does not allow action ${request.action}.`, 'action_denied') - } - const connection = await this.requireConnection(capability.connectionId) - this.assertConnectionActive(connection) - const provider = this.requireProvider(connection.providerId) - const connector = await this.requireConnector(provider, connection.connectorId) - const action = connector.actions.find((candidate) => candidate.id === request.action) - if (!action) throw new IntegrationError(`Action ${request.action} is not defined by connector ${connector.id}.`, 'action_not_found') - assertScopes(connection, action.requiredScopes) - assertScopes({ ...connection, grantedScopes: capability.scopes }, action.requiredScopes) - const fullRequest: IntegrationActionRequest = { ...request, connectionId: connection.id } - const proceed = async () => { - if (!this.policy) return provider.invokeAction(connection, fullRequest) - const decision = await this.policy.decide({ - connection, - request: fullRequest, - action, - subject: capability.subject, - }) - if (decision.decision === 'deny') { - throw new IntegrationError(decision.reason, 'policy_denied') - } - if (decision.decision === 'require_approval') { - return { - ok: false, - action: request.action, - output: { approvalRequired: true, approval: decision.approval }, - metadata: { policyDecision: decision.decision, reason: decision.reason, ...decision.metadata }, - } - } - return provider.invokeAction(connection, fullRequest) - } - if (this.guard) { - return this.guard.invokeAction({ connection, request: fullRequest, action }, proceed) - } - return proceed() - } - - async subscribeTrigger(connectionId: string, trigger: string, targetUrl?: string): Promise { - const connection = await this.requireConnection(connectionId) - this.assertConnectionActive(connection) - const provider = this.requireProvider(connection.providerId) - const connector = await this.requireConnector(provider, connection.connectorId) - const spec = connector.triggers?.find((candidate) => candidate.id === trigger) - if (!spec) throw new IntegrationError(`Trigger ${trigger} is not defined by connector ${connector.id}.`, 'trigger_not_found') - assertScopes(connection, spec.requiredScopes) - if (!provider.subscribeTrigger) { - throw new IntegrationError(`Provider ${provider.id} does not support triggers.`, 'auth_not_supported') - } - return provider.subscribeTrigger(connection, trigger, targetUrl) - } - - private requireProvider(providerId: string): IntegrationProvider { - const provider = this.providers.get(providerId) - if (!provider) throw new IntegrationError(`Provider ${providerId} not found.`, 'provider_not_found') - return provider - } - - private async requireConnector(provider: IntegrationProvider, connectorId: string): Promise { - const connector = (await provider.listConnectors()).find((candidate) => candidate.id === connectorId) - if (!connector) throw new IntegrationError(`Connector ${connectorId} not found.`, 'connector_not_found') - return connector - } - - private async requireConnection(connectionId: string): Promise { - const connection = await this.store.get(connectionId) - if (!connection) throw new IntegrationError(`Connection ${connectionId} not found.`, 'connection_not_found') - return connection - } - - private assertConnectionActive(connection: IntegrationConnection): void { - if (connection.status !== 'active') { - throw new IntegrationError(`Connection ${connection.id} is ${connection.status}.`, 'connection_not_active') - } - if (connection.expiresAt && Date.parse(connection.expiresAt) <= this.now().getTime()) { - throw new IntegrationError(`Connection ${connection.id} is expired.`, 'connection_not_active') - } - } -} export function sanitizeConnection(connection: IntegrationConnection): Record { return { @@ -410,28 +181,6 @@ export function createHttpIntegrationProvider(options: HttpIntegrationProviderOp } } -export function signCapability(capability: IntegrationCapability, secret: string): string { - const payload = base64UrlEncode(JSON.stringify(capability)) - const signature = hmac(payload, secret) - return `${payload}.${signature}` -} - -export function verifyCapabilityToken(token: string, secret: string): IntegrationCapability { - const [payload, signature] = token.split('.') - if (!payload || !signature) throw new IntegrationError('Malformed integration capability.', 'capability_invalid') - const expected = hmac(payload, secret) - if (!constantTimeEqual(signature, expected)) throw new IntegrationError('Invalid integration capability signature.', 'capability_invalid') - let parsed: IntegrationCapability - try { - parsed = JSON.parse(base64UrlDecode(payload)) as IntegrationCapability - } catch { - throw new IntegrationError('Invalid integration capability payload.', 'capability_invalid') - } - if (!parsed.id || !parsed.connectionId || !Array.isArray(parsed.scopes) || !Array.isArray(parsed.allowedActions)) { - throw new IntegrationError('Invalid integration capability payload.', 'capability_invalid') - } - return parsed -} async function postJson( fetcher: typeof fetch, @@ -451,50 +200,7 @@ async function postJson( return response.json() as Promise } -function assertScopes(connection: Pick, requiredScopes: string[]): void { - const missing = requiredScopes.filter((scope) => !connection.grantedScopes.includes(scope)) - if (missing.length > 0) throw new IntegrationError(`Missing integration scopes: ${missing.join(', ')}`, 'scope_denied') -} - -type IntegrationOAuthPkceMode = 'required' | 'supported' | 'unsupported' - -function connectorOAuthPkceMode(connector: IntegrationConnector): IntegrationOAuthPkceMode { - const value = connector.metadata?.oauthPkce - if (value === undefined) return 'required' - if (value === 'required' || value === 'supported' || value === 'unsupported') return value - throw new IntegrationError( - `Connector ${connector.id} declares an invalid OAuth PKCE posture.`, - 'config_missing', - ) -} - -function createPkcePair(): { verifier: string; challenge: string } { - const verifier = randomBytes(48).toString('base64url') - const challenge = createHash('sha256').update(verifier).digest('base64url') - return { verifier, challenge } -} - -function hmac(payload: string, secret: string): string { - return createHmac('sha256', secret).update(payload).digest('base64url') -} - -function constantTimeEqual(a: string, b: string): boolean { - const left = Buffer.from(a) - const right = Buffer.from(b) - return left.length === right.length && timingSafeEqual(left, right) -} - -function base64UrlEncode(value: string): string { - return Buffer.from(value, 'utf8').toString('base64url') -} -function base64UrlDecode(value: string): string { - return Buffer.from(value, 'base64url').toString('utf8') -} - -function unique(values: T[]): T[] { - return [...new Set(values)] -} // ─── Connectors namespace ─────────────────────────────────────────────── // @@ -503,6 +209,7 @@ function unique(values: T[]): T[] { // hub-side `IntegrationProvider` interface is the *catalog* facade above // these; one provider can wrap many connectors. See `src/connectors/types.ts` // for the layering details. +export { IntegrationHub, signCapability, verifyCapabilityToken } from './hub.js' export * from './connectors/index.js' export * from './catalog.js' export * from './catalog-executor.js' diff --git a/tsdown.config.ts b/tsdown.config.ts index b2acaa5e..a8439ad9 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -12,6 +12,8 @@ export default defineConfig({ specs: 'src/specs/index.ts', 'connectors/index': 'src/connectors/index.ts', 'connectors/oauth': 'src/connectors/oauth.ts', + hub: 'src/hub.ts', + sandbox: 'src/sandbox.ts', 'connectors/adapters/index': 'src/connectors/adapters/index.ts', 'connect/index': 'src/connect/index.ts', 'middleware/index': 'src/middleware/index.ts',