Unifies credential resolution around auth connections and OAuth state so runtime, ingest, and local PM flows can refresh and retry tokens consistently without legacy binding/token tables.
Unified · Split
| @@ -16,3 +16,68 @@ apps/convex/node_modules/.vite/ · 15 unmodified lines | |||
| 16 | apps/convex/node_modules/@convex-dev/action-cache | 16 | apps/convex/node_modules/@convex-dev/action-cache |
| 17 | *.tsbuildinfo | 17 | *.tsbuildinfo |
| 18 | tsconfig.tsbuildinfo | 18 | tsconfig.tsbuildinfo |
| 19 | personal-notes/ | ||
| 20 | # dependencies (bun install) | ||
| 21 | node_modules | ||
| 22 | executor/bun.lock | ||
| 23 | |||
| 24 | # output | ||
| 25 | out | ||
| 26 | dist | ||
| 27 | *.tgz | ||
| 28 | |||
| 29 | # code coverage | ||
| 30 | coverage | ||
| 31 | *.lcov | ||
| 32 | |||
| 33 | # logs | ||
| 34 | logs | ||
| 35 | *.log | ||
| 36 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json | ||
| 37 | |||
| 38 | # dotenv environment variable files | ||
| 39 | .env | ||
| 40 | .env.production | ||
| 41 | .env.development.local | ||
| 42 | .env.test.local | ||
| 43 | .env.production.local | ||
| 44 | .env.local | ||
| 45 | |||
| 46 | # caches | ||
| 47 | .eslintcache | ||
| 48 | .cache | ||
| 49 | *.tsbuildinfo | ||
| 50 | .reference/ | ||
| 51 | |||
| 52 | # IntelliJ based IDEs | ||
| 53 | .idea | ||
| 54 | |||
| 55 | # Finder (MacOS) folder config | ||
| 56 | .DS_Store | ||
| 57 | |||
| 58 | # Convex local backend state | ||
| 59 | convex_local_backend.sqlite3 | ||
| 60 | convex_local_storage/ | ||
| 61 | |||
| 62 | # Local OpenAPI sources service state | ||
| 63 | sources/data/*.sqlite | ||
| 64 | sources/data/*.sqlite-shm | ||
| 65 | sources/data/*.sqlite-wal | ||
| 66 | |||
| 67 | # Composio toolkit scrape outputs | ||
| 68 | sources/composio-scrape/data/ | ||
| 69 | |||
| 70 | # Cloudflare Workers local state | ||
| 71 | .wrangler/ | ||
| 72 | .dev.vars | ||
| 73 | |||
| 74 | # Generated push env file | ||
| 75 | executor/.env.executor-push | ||
| 76 | |||
| 77 | # Dev runner PID file | ||
| 78 | .dev.pids | ||
| 79 | *.har | ||
| 80 | .vercel | ||
| 81 | .env*.local | ||
| 82 | .chat-links.json | ||
| 83 | |||
| @@ -1,19 +1,29 @@ | |||
| 1 | import { | 1 | import { |
| 2 | buildCredentialHeaders, | 2 | type UpsertCredentialBindingPayload, |
| 3 | selectOAuthAccessToken, | 3 | } from "@executor-v2/management-api"; |
| 4 | } from "@executor-v2/engine"; | ||
| 5 | import { type UpsertCredentialBindingPayload } from "@executor-v2/management-api"; | ||
| 6 | import { | 4 | import { |
| 7 | OAuthTokenSchema, | 5 | AuthConnectionSchema, |
| 6 | AuthMaterialSchema, | ||
| 7 | OAuthStateSchema, | ||
| 8 | SourceAuthBindingSchema, | ||
| 8 | SourceCredentialBindingSchema, | 9 | SourceCredentialBindingSchema, |
| 9 | type OAuthToken, | 10 | type AuthConnection, |
| 11 | type AuthConnectionStatus, | ||
| 12 | type AuthConnectionStrategy, | ||
| 13 | type AuthMaterial, | ||
| 14 | type OAuthState, | ||
| 15 | type SourceAuthBinding, | ||
| 10 | type SourceCredentialBinding, | 16 | type SourceCredentialBinding, |
| 11 | } from "@executor-v2/schema"; | 17 | } from "@executor-v2/schema"; |
| 12 | import { v } from "convex/values"; | 18 | import { v } from "convex/values"; |
| 13 | import * as Schema from "effect/Schema"; | 19 | import * as Schema from "effect/Schema"; |
| 14 | 20 | ||
| 15 | import { internal } from "../_generated/api"; | 21 | import { internal } from "../_generated/api"; |
| 16 | import { decryptSecretValue, encryptSecretValue } from "../credential_crypto"; | 22 | import { |
| 23 | currentCredentialEncryptionKeyVersion, | ||
| 24 | decryptSecretValue, | ||
| 25 | encryptSecretValue, | ||
| 26 | } from "../credential_crypto"; | ||
| 17 | import { | 27 | import { |
| 18 | internalAction, | 28 | internalAction, |
| 19 | internalMutation, | 29 | internalMutation, |
| @@ -24,27 +34,19 @@ import { · 4 unmodified lines | |||
| 24 | 34 | ||
| 25 | const runtimeInternal = internal; | 35 | const runtimeInternal = internal; |
| 26 | 36 | ||
| 27 | const decodeSourceCredentialBinding = Schema.decodeUnknownSync( | 37 | const decodeAuthConnection = Schema.decodeUnknownSync(AuthConnectionSchema); |
| 38 | const decodeSourceAuthBinding = Schema.decodeUnknownSync(SourceAuthBindingSchema); | ||
| 39 | const decodeAuthMaterial = Schema.decodeUnknownSync(AuthMaterialSchema); | ||
| 40 | const decodeOAuthState = Schema.decodeUnknownSync(OAuthStateSchema); | ||
| 41 | const decodeCompatSourceCredentialBinding = Schema.decodeUnknownSync( | ||
| 28 | SourceCredentialBindingSchema, | 42 | SourceCredentialBindingSchema, |
| 29 | ); | 43 | ); |
| 30 | const decodeOAuthToken = Schema.decodeUnknownSync(OAuthTokenSchema); | ||
| 31 | |||
| 32 | const stripConvexSystemFields = ( | ||
| 33 | value: Record<string, unknown>, | ||
| 34 | ): Record<string, unknown> => { | ||
| 35 | const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value; | ||
| 36 | return rest; | ||
| 37 | }; | ||
| 38 | |||
| 39 | const toSourceCredentialBinding = ( | ||
| 40 | document: Record<string, unknown>, | ||
| 41 | ): SourceCredentialBinding => | ||
| 42 | decodeSourceCredentialBinding(stripConvexSystemFields(document)); | ||
| 43 | 44 | ||
| 44 | const credentialProviderValidator = v.union( | 45 | const credentialProviderValidator = v.union( |
| 45 | v.literal("api_key"), | 46 | v.literal("api_key"), |
| 46 | v.literal("bearer"), | 47 | v.literal("bearer"), |
| 47 | v.literal("oauth2"), | 48 | v.literal("oauth2"), |
| 49 | v.literal("basic"), | ||
| 48 | v.literal("custom"), | 50 | v.literal("custom"), |
| 49 | ); | 51 | ); |
| 50 | 52 | ||
| @@ -67,9 +69,305 @@ const sourceCredentialBindingPayloadValidator = v.object({ · 16 unmodified lines | |||
| 67 | accountId: v.optional(v.union(v.string(), v.null())), | 69 | accountId: v.optional(v.union(v.string(), v.null())), |
| 68 | additionalHeadersJson: v.optional(v.union(v.string(), v.null())), | 70 | additionalHeadersJson: v.optional(v.union(v.string(), v.null())), |
| 69 | boundAuthFingerprint: v.optional(v.union(v.string(), v.null())), | 71 | boundAuthFingerprint: v.optional(v.union(v.string(), v.null())), |
| 72 | oauthRefreshToken: v.optional(v.union(v.string(), v.null())), | ||
| 73 | oauthExpiresAt: v.optional(v.union(v.number(), v.null())), | ||
| 74 | oauthScope: v.optional(v.union(v.string(), v.null())), | ||
| 75 | oauthIssuer: v.optional(v.union(v.string(), v.null())), | ||
| 76 | oauthTokenEndpoint: v.optional(v.union(v.string(), v.null())), | ||
| 77 | oauthAuthorizationServer: v.optional(v.union(v.string(), v.null())), | ||
| 78 | oauthClientId: v.optional(v.union(v.string(), v.null())), | ||
| 79 | oauthClientSecret: v.optional(v.union(v.string(), v.null())), | ||
| 80 | oauthSourceUrl: v.optional(v.union(v.string(), v.null())), | ||
| 81 | oauthClientInformationJson: v.optional(v.union(v.string(), v.null())), | ||
| 70 | }); | 82 | }); |
| 71 | 83 | ||
| 72 | const sortSourceCredentialBindings = ( | 84 | type ExtendedUpsertCredentialBindingPayload = UpsertCredentialBindingPayload & { |
| 85 | oauthRefreshToken?: string | null; | ||
| 86 | oauthExpiresAt?: number | null; | ||
| 87 | oauthScope?: string | null; | ||
| 88 | oauthIssuer?: string | null; | ||
| 89 | oauthTokenEndpoint?: string | null; | ||
| 90 | oauthAuthorizationServer?: string | null; | ||
| 91 | oauthClientId?: string | null; | ||
| 92 | oauthClientSecret?: string | null; | ||
| 93 | oauthSourceUrl?: string | null; | ||
| 94 | oauthClientInformationJson?: string | null; | ||
| 95 | }; | ||
| 96 | |||
| 97 | const stripConvexSystemFields = ( | ||
| 98 | value: Record<string, unknown>, | ||
| 99 | ): Record<string, unknown> => { | ||
| 100 | const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value; | ||
| 101 | return rest; | ||
| 102 | }; | ||
| 103 | |||
| 104 | const normalizeString = (value: unknown): string | null => { | ||
| 105 | if (typeof value !== "string") { | ||
| 106 | return null; | ||
| 107 | } | ||
| 108 | |||
| 109 | const trimmed = value.trim(); | ||
| 110 | return trimmed.length > 0 ? trimmed : null; | ||
| 111 | }; | ||
| 112 | |||
| 113 | const parseJsonObject = (value: string | null | undefined): Record<string, unknown> => { | ||
| 114 | if (!value) { | ||
| 115 | return {}; | ||
| 116 | } | ||
| 117 | |||
| 118 | try { | ||
| 119 | const parsed = JSON.parse(value) as unknown; | ||
| 120 | if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { | ||
| 121 | return {}; | ||
| 122 | } | ||
| 123 | |||
| 124 | return parsed as Record<string, unknown>; | ||
| 125 | } catch { | ||
| 126 | return {}; | ||
| 127 | } | ||
| 128 | }; | ||
| 129 | |||
| 130 | const toStringRecord = ( | ||
| 131 | value: Record<string, unknown>, | ||
| 132 | ): Record<string, string> => { | ||
| 133 | const record: Record<string, string> = {}; | ||
| 134 | |||
| 135 | for (const [rawKey, rawValue] of Object.entries(value)) { | ||
| 136 | const key = normalizeString(rawKey); | ||
| 137 | const normalizedValue = normalizeString(rawValue); | ||
| 138 | if (!key || !normalizedValue) { | ||
| 139 | continue; | ||
| 140 | } | ||
| 141 | |||
| 142 | record[key] = normalizedValue; | ||
| 143 | } | ||
| 144 | |||
| 145 | return record; | ||
| 146 | }; | ||
| 147 | |||
| 148 | const parseAdditionalHeaders = ( | ||
| 149 | additionalHeadersJson: string | null, | ||
| 150 | ): Record<string, string> => { | ||
| 151 | if (!additionalHeadersJson) { | ||
| 152 | return {}; | ||
| 153 | } | ||
| 154 | |||
| 155 | return toStringRecord(parseJsonObject(additionalHeadersJson)); | ||
| 156 | }; | ||
| 157 | |||
| 158 | const parseConnectionMetadata = ( | ||
| 159 | metadataJson: string | null, | ||
| 160 | ): Record<string, unknown> => parseJsonObject(metadataJson); | ||
| 161 | |||
| 162 | const base64Encode = (value: string): string => { | ||
| 163 | if (typeof btoa === "function") { | ||
| 164 | return btoa(value); | ||
| 165 | } | ||
| 166 | |||
| 167 | const maybeBuffer = (globalThis as { Buffer?: typeof Buffer }).Buffer; | ||
| 168 | if (maybeBuffer) { | ||
| 169 | return maybeBuffer.from(value, "utf8").toString("base64"); | ||
| 170 | } | ||
| 171 | |||
| 172 | throw new Error("No base64 encoder available"); | ||
| 173 | }; | ||
| 174 | |||
| 175 | const strategyFromProvider = ( | ||
| 176 | provider: UpsertCredentialBindingPayload["provider"], | ||
| 177 | ): AuthConnectionStrategy => { | ||
| 178 | if (provider === "api_key") return "api_key"; | ||
| 179 | if (provider === "bearer") return "bearer"; | ||
| 180 | if (provider === "oauth2") return "oauth2"; | ||
| 181 | if (provider === "basic") return "basic"; | ||
| 182 | return "custom"; | ||
| 183 | }; | ||
| 184 | |||
| 185 | const providerFromStrategy = ( | ||
| 186 | strategy: AuthConnectionStrategy, | ||
| 187 | ): SourceCredentialBinding["provider"] => { | ||
| 188 | if (strategy === "api_key") return "api_key"; | ||
| 189 | if (strategy === "bearer") return "bearer"; | ||
| 190 | if (strategy === "oauth2") return "oauth2"; | ||
| 191 | if (strategy === "basic") return "basic"; | ||
| 192 | return "custom"; | ||
| 193 | }; | ||
| 194 | |||
| 195 | const scopeScore = ( | ||
| 196 | binding: SourceAuthBinding, | ||
| 197 | input: { | ||
| 198 | workspaceId: string; | ||
| 199 | organizationId: string; | ||
| 200 | accountId: string | null; | ||
| 201 | }, | ||
| 202 | ): number => { | ||
| 203 | if (!binding.enabled) { | ||
| 204 | return -1; | ||
| 205 | } | ||
| 206 | |||
| 207 | if (binding.scopeType === "account") { | ||
| 208 | if (!input.accountId || binding.accountId !== input.accountId) { | ||
| 209 | return -1; | ||
| 210 | } | ||
| 211 | |||
| 212 | return binding.organizationId === input.organizationId ? 30 : -1; | ||
| 213 | } | ||
| 214 | |||
| 215 | if (binding.scopeType === "workspace") { | ||
| 216 | return binding.workspaceId === input.workspaceId ? 20 : -1; | ||
| 217 | } | ||
| 218 | |||
| 219 | if (binding.scopeType === "organization") { | ||
| 220 | return binding.organizationId === input.organizationId ? 10 : -1; | ||
| 221 | } | ||
| 222 | |||
| 223 | return -1; | ||
| 224 | }; | ||
| 225 | |||
| 226 | const selectBestBinding = ( | ||
| 227 | bindings: ReadonlyArray<SourceAuthBinding>, | ||
| 228 | input: { | ||
| 229 | workspaceId: string; | ||
| 230 | organizationId: string; | ||
| 231 | accountId: string | null; | ||
| 232 | }, | ||
| 233 | ): SourceAuthBinding | null => { | ||
| 234 | const ranked = bindings | ||
| 235 | .map((binding) => ({ | ||
| 236 | binding, | ||
| 237 | score: scopeScore(binding, input), | ||
| 238 | })) | ||
| 239 | .filter((entry) => entry.score >= 0) | ||
| 240 | .sort((left, right) => { | ||
| 241 | if (left.score !== right.score) { | ||
| 242 | return right.score - left.score; | ||
| 243 | } | ||
| 244 | |||
| 245 | if (left.binding.updatedAt !== right.binding.updatedAt) { | ||
| 246 | return right.binding.updatedAt - left.binding.updatedAt; | ||
| 247 | } | ||
| 248 | |||
| 249 | return right.binding.createdAt - left.binding.createdAt; | ||
| 250 | }); | ||
| 251 | |||
| 252 | return ranked[0]?.binding ?? null; | ||
| 253 | }; | ||
| 254 | |||
| 255 | const sourceIdFromSourceKey = (sourceKey: string): string | null => { | ||
| 256 | const trimmed = sourceKey.trim(); | ||
| 257 | if (!trimmed.startsWith("source:")) { | ||
| 258 | return null; | ||
| 259 | } | ||
| 260 | |||
| 261 | const sourceId = trimmed.slice("source:".length).trim(); | ||
| 262 | return sourceId.length > 0 ? sourceId : null; | ||
| 263 | }; | ||
| 264 | |||
| 265 | const sourceKeyFromSourceId = (sourceId: string): string => `source:${sourceId}`; | ||
| 266 | |||
| 267 | const toAuthConnection = (document: Record<string, unknown>): AuthConnection => | ||
| 268 | decodeAuthConnection(stripConvexSystemFields(document)); | ||
| 269 | |||
| 270 | const toSourceAuthBinding = (document: Record<string, unknown>): SourceAuthBinding => | ||
| 271 | decodeSourceAuthBinding(stripConvexSystemFields(document)); | ||
| 272 | |||
| 273 | const toAuthMaterial = (document: Record<string, unknown>): AuthMaterial => | ||
| 274 | decodeAuthMaterial(stripConvexSystemFields(document)); | ||
| 275 | |||
| 276 | const toOAuthState = (document: Record<string, unknown>): OAuthState => | ||
| 277 | decodeOAuthState(stripConvexSystemFields(document)); | ||
| 278 | |||
| 279 | const resolveWorkspaceOrganizationId = async ( | ||
| 280 | ctx: QueryCtx | MutationCtx, | ||
| 281 | workspaceId: string, | ||
| 282 | ): Promise<string> => { | ||
| 283 | const workspace = await ctx.db | ||
| 284 | .query("workspaces") | ||
| 285 | .withIndex("by_domainId", (q) => q.eq("id", workspaceId)) | ||
| 286 | .unique(); | ||
| 287 | |||
| 288 | if (!workspace) { | ||
| 289 | throw new Error(`Workspace not found: ${workspaceId}`); | ||
| 290 | } | ||
| 291 | |||
| 292 | return workspace.organizationId; | ||
| 293 | }; | ||
| 294 | |||
| 295 | const mergeHeaders = (...sets: ReadonlyArray<Record<string, string>>): Record<string, string> => { | ||
| 296 | const merged: Record<string, string> = {}; | ||
| 297 | const keyByLower = new Map<string, string>(); | ||
| 298 | |||
| 299 | for (const set of sets) { | ||
| 300 | for (const [rawKey, rawValue] of Object.entries(set)) { | ||
| 301 | const key = rawKey.trim(); | ||
| 302 | const value = rawValue.trim(); | ||
| 303 | if (key.length === 0 || value.length === 0) { | ||
| 304 | continue; | ||
| 305 | } | ||
| 306 | |||
| 307 | const lower = key.toLowerCase(); | ||
| 308 | const existing = keyByLower.get(lower); | ||
| 309 | if (existing && existing !== key) { | ||
| 310 | delete merged[existing]; | ||
| 311 | } | ||
| 312 | |||
| 313 | keyByLower.set(lower, key); | ||
| 314 | merged[key] = value; | ||
| 315 | } | ||
| 316 | } | ||
| 317 | |||
| 318 | return merged; | ||
| 319 | }; | ||
| 320 | |||
| 321 | const buildSecretHeaders = ( | ||
| 322 | strategy: AuthConnectionStrategy, | ||
| 323 | secret: string, | ||
| 324 | metadata: Record<string, unknown>, | ||
| 325 | ): Record<string, string> => { | ||
| 326 | const trimmed = secret.trim(); | ||
| 327 | if (trimmed.length === 0) { | ||
| 328 | return {}; | ||
| 329 | } | ||
| 330 | |||
| 331 | if (strategy === "api_key") { | ||
| 332 | const header = normalizeString(metadata.apiKeyHeader) ?? "x-api-key"; | ||
| 333 | return { [header]: trimmed }; | ||
| 334 | } | ||
| 335 | |||
| 336 | if (strategy === "bearer") { | ||
| 337 | return { Authorization: `Bearer ${trimmed}` }; | ||
| 338 | } | ||
| 339 | |||
| 340 | if (strategy === "basic") { | ||
| 341 | const asJson = parseJsonObject(trimmed); | ||
| 342 | const usernameFromJson = normalizeString(asJson.username); | ||
| 343 | const passwordFromJson = normalizeString(asJson.password); | ||
| 344 | |||
| 345 | const pair = usernameFromJson && passwordFromJson | ||
| 346 | ? `${usernameFromJson}:${passwordFromJson}` | ||
| 347 | : trimmed.includes(":") | ||
| 348 | ? trimmed | ||
| 349 | : null; | ||
| 350 | |||
| 351 | if (!pair) { | ||
| 352 | return {}; | ||
| 353 | } | ||
| 354 | |||
| 355 | return { | ||
| 356 | Authorization: `Basic ${base64Encode(pair)}`, | ||
| 357 | }; | ||
| 358 | } | ||
| 359 | |||
| 360 | if (strategy === "custom") { | ||
| 361 | const customHeaderName = normalizeString(metadata.customHeaderName); | ||
| 362 | if (customHeaderName) { | ||
| 363 | return { [customHeaderName]: trimmed }; | ||
| 364 | } | ||
| 365 | } | ||
| 366 | |||
| 367 | return {}; | ||
| 368 | }; | ||
| 369 | |||
| 370 | const sortCompatBindings = ( | ||
| 73 | bindings: ReadonlyArray<SourceCredentialBinding>, | 371 | bindings: ReadonlyArray<SourceCredentialBinding>, |
| 74 | ): Array<SourceCredentialBinding> => | 372 | ): Array<SourceCredentialBinding> => |
| 75 | [...bindings].sort((left, right) => { | 373 | [...bindings].sort((left, right) => { |
| @@ -83,204 +381,835 @@ const sortSourceCredentialBindings = ( · 7 unmodified lines | |||
| 83 | return leftKey.localeCompare(rightKey); | 381 | return leftKey.localeCompare(rightKey); |
| 84 | }); | 382 | }); |
| 85 | 383 | ||
| 86 | const resolveWorkspaceOrganizationId = async ( | 384 | const maskedSecretRef = (connection: AuthConnection): string => { |
| 385 | if (connection.strategy === "oauth2") { | ||
| 386 | if (connection.status === "reauth_required") { | ||
| 387 | return "oauth2://reauth_required"; | ||
| 388 | } | ||
| 389 | |||
| 390 | if (connection.status === "active") { | ||
| 391 | return "oauth2://connected"; | ||
| 392 | } | ||
| 393 | } | ||
| 394 | |||
| 395 | return "********"; | ||
| 396 | }; | ||
| 397 | |||
| 398 | const toCompatSourceCredentialBinding = ( | ||
| 399 | binding: SourceAuthBinding, | ||
| 400 | connection: AuthConnection, | ||
| 401 | ): SourceCredentialBinding => | ||
| 402 | decodeCompatSourceCredentialBinding({ | ||
| 403 | id: binding.id, | ||
| 404 | credentialId: connection.id, | ||
| 405 | organizationId: binding.organizationId, | ||
| 406 | workspaceId: binding.workspaceId, | ||
| 407 | accountId: binding.accountId, | ||
| 408 | scopeType: binding.scopeType, | ||
| 409 | sourceKey: sourceKeyFromSourceId(binding.sourceId), | ||
| 410 | provider: providerFromStrategy(connection.strategy), | ||
| 411 | secretProvider: "local", | ||
| 412 | secretRef: maskedSecretRef(connection), | ||
| 413 | additionalHeadersJson: connection.additionalHeadersJson, | ||
| 414 | boundAuthFingerprint: null, | ||
| 415 | createdAt: binding.createdAt, | ||
| 416 | updatedAt: Math.max(binding.updatedAt, connection.updatedAt), | ||
| 417 | }); | ||
| 418 | |||
| 419 | const normalizeAuthConnectionStatus = ( | ||
| 420 | connection: AuthConnection, | ||
| 421 | ): AuthConnectionStatus => { | ||
| 422 | if (connection.status === "active") return "active"; | ||
| 423 | if (connection.status === "reauth_required") return "reauth_required"; | ||
| 424 | if (connection.status === "revoked") return "revoked"; | ||
| 425 | if (connection.status === "disabled") return "disabled"; | ||
| 426 | return "error"; | ||
| 427 | }; | ||
| 428 | |||
| 429 | type OAuthRefreshConfig = { | ||
| 430 | tokenEndpoint?: string; | ||
| 431 | authorizationServer?: string; | ||
| 432 | clientId?: string; | ||
| 433 | clientSecretCiphertext?: string; | ||
| 434 | sourceUrl?: string; | ||
| 435 | clientInformationJson?: string; | ||
| 436 | }; | ||
| 437 | |||
| 438 | const parseOAuthRefreshConfig = (value: string | null): OAuthRefreshConfig => { | ||
| 439 | if (!value) { | ||
| 440 | return {}; | ||
| 441 | } | ||
| 442 | |||
| 443 | const parsed = parseJsonObject(value); | ||
| 444 | return { | ||
| 445 | ...(normalizeString(parsed.tokenEndpoint) | ||
| 446 | ? { tokenEndpoint: normalizeString(parsed.tokenEndpoint)! } | ||
| 447 | : {}), | ||
| 448 | ...(normalizeString(parsed.authorizationServer) | ||
| 449 | ? { authorizationServer: normalizeString(parsed.authorizationServer)! } | ||
| 450 | : {}), | ||
| 451 | ...(normalizeString(parsed.clientId) | ||
| 452 | ? { clientId: normalizeString(parsed.clientId)! } | ||
| 453 | : {}), | ||
| 454 | ...(normalizeString(parsed.clientSecretCiphertext) | ||
| 455 | ? { clientSecretCiphertext: normalizeString(parsed.clientSecretCiphertext)! } | ||
| 456 | : {}), | ||
| 457 | ...(normalizeString(parsed.sourceUrl) | ||
| 458 | ? { sourceUrl: normalizeString(parsed.sourceUrl)! } | ||
| 459 | : {}), | ||
| 460 | ...(normalizeString(parsed.clientInformationJson) | ||
| 461 | ? { clientInformationJson: normalizeString(parsed.clientInformationJson)! } | ||
| 462 | : {}), | ||
| 463 | }; | ||
| 464 | }; | ||
| 465 | |||
| 466 | const encodeOAuthRefreshConfig = (config: OAuthRefreshConfig): string | null => { | ||
| 467 | const payload: Record<string, string> = {}; | ||
| 468 | |||
| 469 | if (config.tokenEndpoint) payload.tokenEndpoint = config.tokenEndpoint; | ||
| 470 | if (config.authorizationServer) payload.authorizationServer = config.authorizationServer; | ||
| 471 | if (config.clientId) payload.clientId = config.clientId; | ||
| 472 | if (config.clientSecretCiphertext) { | ||
| 473 | payload.clientSecretCiphertext = config.clientSecretCiphertext; | ||
| 474 | } | ||
| 475 | if (config.sourceUrl) payload.sourceUrl = config.sourceUrl; | ||
| 476 | if (config.clientInformationJson) { | ||
| 477 | payload.clientInformationJson = config.clientInformationJson; | ||
| 478 | } | ||
| 479 | |||
| 480 | if (Object.keys(payload).length === 0) { | ||
| 481 | return null; | ||
| 482 | } | ||
| 483 | |||
| 484 | return JSON.stringify(payload); | ||
| 485 | }; | ||
| 486 | |||
| 487 | const oauthRefreshWindowMs = 5 * 60 * 1000; | ||
| 488 | const oauthRefreshLeaseMs = 30 * 1000; | ||
| 489 | |||
| 490 | type OAuthRefreshErrorClass = | ||
| 491 | | "transient" | ||
| 492 | | "invalid_grant" | ||
| 493 | | "invalid_client" | ||
| 494 | | "unsupported" | ||
| 495 | | "unknown"; | ||
| 496 | |||
| 497 | const classifyOAuthRefreshFailure = ( | ||
| 498 | responseStatus: number, | ||
| 499 | payload: Record<string, unknown>, | ||
| 500 | ): OAuthRefreshErrorClass => { | ||
| 501 | const errorCode = normalizeString(payload.error)?.toLowerCase() ?? ""; | ||
| 502 | |||
| 503 | if (errorCode === "invalid_grant") { | ||
| 504 | return "invalid_grant"; | ||
| 505 | } | ||
| 506 | |||
| 507 | if (errorCode === "invalid_client") { | ||
| 508 | return "invalid_client"; | ||
| 509 | } | ||
| 510 | |||
| 511 | if (responseStatus >= 500) { | ||
| 512 | return "transient"; | ||
| 513 | } | ||
| 514 | |||
| 515 | if (responseStatus === 400 || responseStatus === 401 || responseStatus === 403) { | ||
| 516 | return errorCode.length > 0 ? "unknown" : "transient"; | ||
| 517 | } | ||
| 518 | |||
| 519 | return "unknown"; | ||
| 520 | }; | ||
| 521 | |||
| 522 | const readJsonResponse = async ( | ||
| 523 | response: Response, | ||
| 524 | ): Promise<Record<string, unknown>> => { | ||
| 525 | try { | ||
| 526 | const parsed = (await response.json()) as unknown; | ||
| 527 | if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { | ||
| 528 | return {}; | ||
| 529 | } | ||
| 530 | |||
| 531 | return parsed as Record<string, unknown>; | ||
| 532 | } catch { | ||
| 533 | return {}; | ||
| 534 | } | ||
| 535 | }; | ||
| 536 | |||
| 537 | const resolveTokenEndpointFromAuthorizationServer = async ( | ||
| 538 | authorizationServer: string, | ||
| 539 | ): Promise<string | null> => { | ||
| 540 | const normalized = normalizeString(authorizationServer); | ||
| 541 | if (!normalized) { | ||
| 542 | return null; | ||
| 543 | } | ||
| 544 | |||
| 545 | try { | ||
| 546 | const endpoint = new URL("/.well-known/oauth-authorization-server", normalized); | ||
| 547 | const response = await fetch(endpoint.toString(), { | ||
| 548 | method: "GET", | ||
| 549 | headers: { | ||
| 550 | accept: "application/json", | ||
| 551 | }, | ||
| 552 | }); | ||
| 553 | |||
| 554 | if (!response.ok) { | ||
| 555 | return null; | ||
| 556 | } | ||
| 557 | |||
| 558 | const payload = await readJsonResponse(response); | ||
| 559 | const tokenEndpoint = normalizeString(payload.token_endpoint); | ||
| 560 | return tokenEndpoint ?? null; | ||
| 561 | } catch { | ||
| 562 | return null; | ||
| 563 | } | ||
| 564 | }; | ||
| 565 | |||
| 566 | const refreshTokenFromOAuthEndpoint = async ( | ||
| 567 | oauthState: OAuthState, | ||
| 568 | refreshConfig: OAuthRefreshConfig, | ||
| 569 | ): Promise< | ||
| 570 | | { | ||
| 571 | ok: true; | ||
| 572 | accessToken: string; | ||
| 573 | refreshToken: string | null; | ||
| 574 | expiresAt: number | null; | ||
| 575 | scope: string | null; | ||
| 576 | issuer: string | null; | ||
| 577 | tokenType: string | null; | ||
| 578 | } | ||
| 579 | | { | ||
| 580 | ok: false; | ||
| 581 | errorClass: OAuthRefreshErrorClass; | ||
| 582 | message: string; | ||
| 583 | } | ||
| 584 | > => { | ||
| 585 | const refreshTokenCiphertext = oauthState.refreshTokenCiphertext; | ||
| 586 | if (!refreshTokenCiphertext) { | ||
| 587 | return { | ||
| 588 | ok: false, | ||
| 589 | errorClass: "unsupported", | ||
| 590 | message: "Refresh token is not available", | ||
| 591 | }; | ||
| 592 | } | ||
| 593 | |||
| 594 | const refreshToken = await decryptSecretValue(refreshTokenCiphertext); | ||
| 595 | const clientId = normalizeString(refreshConfig.clientId); | ||
| 596 | if (!clientId) { | ||
| 597 | return { | ||
| 598 | ok: false, | ||
| 599 | errorClass: "unsupported", | ||
| 600 | message: "OAuth client id is required to refresh token", | ||
| 601 | }; | ||
| 602 | } | ||
| 603 | |||
| 604 | const tokenEndpoint = normalizeString(refreshConfig.tokenEndpoint) | ||
| 605 | ?? (refreshConfig.authorizationServer | ||
| 606 | ? await resolveTokenEndpointFromAuthorizationServer(refreshConfig.authorizationServer) | ||
| 607 | : null); | ||
| 608 | |||
| 609 | if (!tokenEndpoint) { | ||
| 610 | return { | ||
| 611 | ok: false, | ||
| 612 | errorClass: "unsupported", | ||
| 613 | message: "OAuth token endpoint is not configured", | ||
| 614 | }; | ||
| 615 | } | ||
| 616 | |||
| 617 | const body = new URLSearchParams(); | ||
| 618 | body.set("grant_type", "refresh_token"); | ||
| 619 | body.set("refresh_token", refreshToken); | ||
| 620 | body.set("client_id", clientId); | ||
| 621 | |||
| 622 | const clientSecretCiphertext = normalizeString(refreshConfig.clientSecretCiphertext); | ||
| 623 | if (clientSecretCiphertext) { | ||
| 624 | const decryptedClientSecret = await decryptSecretValue(clientSecretCiphertext); | ||
| 625 | if (decryptedClientSecret.trim().length > 0) { | ||
| 626 | body.set("client_secret", decryptedClientSecret.trim()); | ||
| 627 | } | ||
| 628 | } | ||
| 629 | |||
| 630 | let response: Response; | ||
| 631 | try { | ||
| 632 | response = await fetch(tokenEndpoint, { | ||
| 633 | method: "POST", | ||
| 634 | headers: { | ||
| 635 | "content-type": "application/x-www-form-urlencoded", | ||
| 636 | accept: "application/json", | ||
| 637 | }, | ||
| 638 | body: body.toString(), | ||
| 639 | }); | ||
| 640 | } catch (error) { | ||
| 641 | return { | ||
| 642 | ok: false, | ||
| 643 | errorClass: "transient", | ||
| 644 | message: error instanceof Error ? error.message : "OAuth refresh request failed", | ||
| 645 | }; | ||
| 646 | } | ||
| 647 | |||
| 648 | const payload = await readJsonResponse(response); | ||
| 649 | |||
| 650 | if (!response.ok) { | ||
| 651 | const errorClass = classifyOAuthRefreshFailure(response.status, payload); | ||
| 652 | const message = normalizeString(payload.error_description) | ||
| 653 | ?? normalizeString(payload.error) | ||
| 654 | ?? `OAuth refresh failed (${response.status})`; | ||
| 655 | return { | ||
| 656 | ok: false, | ||
| 657 | errorClass, | ||
| 658 | message, | ||
| 659 | }; | ||
| 660 | } | ||
| 661 | |||
| 662 | const accessToken = normalizeString(payload.access_token); | ||
| 663 | if (!accessToken) { | ||
| 664 | return { | ||
| 665 | ok: false, | ||
| 666 | errorClass: "unknown", | ||
| 667 | message: "OAuth refresh response is missing access_token", | ||
| 668 | }; | ||
| 669 | } | ||
| 670 | |||
| 671 | const maybeExpiresIn = payload.expires_in; | ||
| 672 | const expiresAt = typeof maybeExpiresIn === "number" && Number.isFinite(maybeExpiresIn) | ||
| 673 | ? Date.now() + Math.max(0, Math.floor(maybeExpiresIn)) * 1000 | ||
| 674 | : null; | ||
| 675 | |||
| 676 | const refreshTokenFromResponse = normalizeString(payload.refresh_token); | ||
| 677 | const scope = normalizeString(payload.scope) ?? oauthState.scope; | ||
| 678 | const tokenType = normalizeString(payload.token_type) ?? oauthState.tokenType; | ||
| 679 | |||
| 680 | return { | ||
| 681 | ok: true, | ||
| 682 | accessToken, | ||
| 683 | refreshToken: refreshTokenFromResponse, | ||
| 684 | expiresAt, | ||
| 685 | scope, | ||
| 686 | issuer: oauthState.issuer, | ||
| 687 | tokenType, | ||
| 688 | }; | ||
| 689 | }; | ||
| 690 | |||
| 691 | const insertAuthAuditEvent = async ( | ||
| 692 | ctx: MutationCtx, | ||
| 693 | input: { | ||
| 694 | organizationId: string; | ||
| 695 | connectionId: string; | ||
| 696 | sourceId: string | null; | ||
| 697 | eventType: "created" | "updated" | "bound" | "unbound" | "refresh_success" | "refresh_failed" | "reauth_required" | "revoked" | "deleted"; | ||
| 698 | actorType: "system" | "account"; | ||
| 699 | actorId?: string | null; | ||
| 700 | outcome: string; | ||
| 701 | reasonCode?: string | null; | ||
| 702 | detailsJson?: string | null; | ||
| 703 | correlationId?: string | null; | ||
| 704 | }, | ||
| 705 | ): Promise<void> => { | ||
| 706 | const now = Date.now(); | ||
| 707 | await ctx.db.insert("authAuditEvents", { | ||
| 708 | id: `auth_event_${crypto.randomUUID()}`, | ||
| 709 | organizationId: input.organizationId, | ||
| 710 | connectionId: input.connectionId, | ||
| 711 | sourceId: input.sourceId, | ||
| 712 | eventType: input.eventType, | ||
| 713 | actorType: input.actorType, | ||
| 714 | actorId: input.actorId ?? null, | ||
| 715 | outcome: input.outcome, | ||
| 716 | reasonCode: input.reasonCode ?? null, | ||
| 717 | detailsJson: input.detailsJson ?? null, | ||
| 718 | correlationId: input.correlationId ?? null, | ||
| 719 | createdAt: now, | ||
| 720 | }); | ||
| 721 | }; | ||
| 722 | |||
| 723 | const loadAuthConnectionById = async ( | ||
| 87 | ctx: QueryCtx | MutationCtx, | 724 | ctx: QueryCtx | MutationCtx, |
| 88 | workspaceId: string, | 725 | connectionId: string, |
| 89 | ): Promise<string> => { | 726 | ): Promise<AuthConnection | null> => { |
| 90 | const workspace = await ctx.db | 727 | const row = await ctx.db |
| 91 | .query("workspaces") | 728 | .query("authConnections") |
| 92 | .withIndex("by_domainId", (q) => q.eq("id", workspaceId)) | 729 | .withIndex("by_domainId", (q) => q.eq("id", connectionId)) |
| 730 | .unique(); | ||
| 731 | |||
| 732 | if (!row) { | ||
| 733 | return null; | ||
| 734 | } | ||
| 735 | |||
| 736 | return toAuthConnection(row as unknown as Record<string, unknown>); | ||
| 737 | }; | ||
| 738 | |||
| 739 | const loadOAuthStateByConnectionId = async ( | ||
| 740 | ctx: QueryCtx | MutationCtx, | ||
| 741 | connectionId: string, | ||
| 742 | ): Promise<OAuthState | null> => { | ||
| 743 | const row = await ctx.db | ||
| 744 | .query("oauthState") | ||
| 745 | .withIndex("by_connectionId", (q) => q.eq("connectionId", connectionId)) | ||
| 746 | .unique(); | ||
| 747 | |||
| 748 | if (!row) { | ||
| 749 | return null; | ||
| 750 | } | ||
| 751 | |||
| 752 | return toOAuthState(row as unknown as Record<string, unknown>); | ||
| 753 | }; | ||
| 754 | |||
| 755 | const loadAuthMaterialByConnectionId = async ( | ||
| 756 | ctx: QueryCtx | MutationCtx, | ||
| 757 | connectionId: string, | ||
| 758 | ): Promise<AuthMaterial | null> => { | ||
| 759 | const row = await ctx.db | ||
| 760 | .query("authMaterials") | ||
| 761 | .withIndex("by_connectionId", (q) => q.eq("connectionId", connectionId)) | ||
| 93 | .unique(); | 762 | .unique(); |
| 94 | 763 | ||
| 95 | if (!workspace) { | 764 | if (!row) { |
| 96 | throw new Error(`Workspace not found: ${workspaceId}`); | 765 | return null; |
| 97 | } | 766 | } |
| 767 | |||
| 768 | return toAuthMaterial(row as unknown as Record<string, unknown>); | ||
| 769 | }; | ||
| 770 | |||
| 771 | const listBindingsForWorkspace = async ( | ||
| 772 | ctx: QueryCtx, | ||
| 773 | input: { | ||
| 774 | workspaceId: string; | ||
| 775 | organizationId: string; | ||
| 776 | }, | ||
| 777 | ): Promise<Array<SourceAuthBinding>> => { | ||
| 778 | const workspaceRows = await ctx.db | ||
| 779 | .query("sourceAuthBindings") | ||
| 780 | .withIndex("by_workspaceId", (q) => q.eq("workspaceId", input.workspaceId)) | ||
| 781 | .collect(); | ||
| 782 | |||
| 783 | const organizationRows = await ctx.db | ||
| 784 | .query("sourceAuthBindings") | ||
| 785 | .withIndex("by_organizationId", (q) => q.eq("organizationId", input.organizationId)) | ||
| 786 | .collect(); | ||
| 787 | |||
| 788 | const bindings = [...workspaceRows, ...organizationRows] | ||
| 789 | .map((row) => toSourceAuthBinding(row as unknown as Record<string, unknown>)) | ||
| 790 | .filter((binding) => | ||
| 791 | binding.workspaceId === input.workspaceId | ||
| 792 | || (binding.workspaceId === null && binding.organizationId === input.organizationId) | ||
| 793 | ); | ||
| 794 | |||
| 795 | return Array.from(new Map(bindings.map((binding) => [binding.id, binding])).values()); | ||
| 796 | }; | ||
| 797 | |||
| 798 | export const listCredentialBindings = internalQuery({ | ||
| 799 | args: { | ||
| 800 | workspaceId: v.string(), | ||
| 801 | }, | ||
| 802 | handler: async (ctx, args): Promise<Array<SourceCredentialBinding>> => { | ||
| 803 | const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); | ||
| 804 | const bindings = await listBindingsForWorkspace(ctx, { | ||
| 805 | workspaceId: args.workspaceId, | ||
| 806 | organizationId, | ||
| 807 | }); | ||
| 808 | |||
| 809 | const compatBindings: Array<SourceCredentialBinding> = []; | ||
| 810 | |||
| 811 | for (const binding of bindings) { | ||
| 812 | const connection = await loadAuthConnectionById(ctx, binding.connectionId); | ||
| 813 | if (!connection) { | ||
| 814 | continue; | ||
| 815 | } | ||
| 816 | |||
| 817 | compatBindings.push(toCompatSourceCredentialBinding(binding, connection)); | ||
| 818 | } | ||
| 819 | |||
| 820 | return sortCompatBindings(compatBindings); | ||
| 821 | }, | ||
| 822 | }); | ||
| 823 | |||
| 824 | export const resolveSourceAuthSelection = internalQuery({ | ||
| 825 | args: { | ||
| 826 | workspaceId: v.string(), | ||
| 827 | sourceId: v.string(), | ||
| 828 | accountId: v.optional(v.union(v.string(), v.null())), | ||
| 829 | }, | ||
| 830 | handler: async ( | ||
| 831 | ctx, | ||
| 832 | args, | ||
| 833 | ): Promise<{ | ||
| 834 | binding: SourceAuthBinding | null; | ||
| 835 | connection: AuthConnection | null; | ||
| 836 | oauthState: OAuthState | null; | ||
| 837 | }> => { | ||
| 838 | const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); | ||
| 839 | |||
| 840 | const rows = await ctx.db | ||
| 841 | .query("sourceAuthBindings") | ||
| 842 | .withIndex("by_sourceId", (q) => q.eq("sourceId", args.sourceId)) | ||
| 843 | .collect(); | ||
| 844 | |||
| 845 | const binding = selectBestBinding( | ||
| 846 | rows.map((row) => toSourceAuthBinding(row as unknown as Record<string, unknown>)), | ||
| 847 | { | ||
| 848 | workspaceId: args.workspaceId, | ||
| 849 | organizationId, | ||
| 850 | accountId: args.accountId ?? null, | ||
| 851 | }, | ||
| 852 | ); | ||
| 853 | |||
| 854 | if (!binding) { | ||
| 855 | return { | ||
| 856 | binding: null, | ||
| 857 | connection: null, | ||
| 858 | oauthState: null, | ||
| 859 | }; | ||
| 860 | } | ||
| 861 | |||
| 862 | const connection = await loadAuthConnectionById(ctx, binding.connectionId); | ||
| 863 | if (!connection) { | ||
| 864 | return { | ||
| 865 | binding: null, | ||
| 866 | connection: null, | ||
| 867 | oauthState: null, | ||
| 868 | }; | ||
| 869 | } | ||
| 870 | |||
| 871 | const oauthState = connection.strategy === "oauth2" | ||
| 872 | ? await loadOAuthStateByConnectionId(ctx, connection.id) | ||
| 873 | : null; | ||
| 874 | |||
| 875 | return { | ||
| 876 | binding, | ||
| 877 | connection, | ||
| 878 | oauthState, | ||
| 879 | }; | ||
| 880 | }, | ||
| 881 | }); | ||
| 882 | |||
| 883 | export const beginOAuthRefresh = internalMutation({ | ||
| 884 | args: { | ||
| 885 | connectionId: v.string(), | ||
| 886 | leaseHolder: v.string(), | ||
| 887 | leaseMs: v.number(), | ||
| 888 | }, | ||
| 889 | handler: async ( | ||
| 890 | ctx, | ||
| 891 | args, | ||
| 892 | ): Promise< | ||
| 893 | | { | ||
| 894 | kind: "missing"; | ||
| 895 | } | ||
| 896 | | { | ||
| 897 | kind: "locked"; | ||
| 898 | leaseExpiresAt: number | null; | ||
| 899 | } | ||
| 900 | | { | ||
| 901 | kind: "ready"; | ||
| 902 | oauthState: OAuthState; | ||
| 903 | connection: AuthConnection; | ||
| 904 | } | ||
| 905 | > => { | ||
| 906 | const stateRow = await ctx.db | ||
| 907 | .query("oauthState") | ||
| 908 | .withIndex("by_connectionId", (q) => q.eq("connectionId", args.connectionId)) | ||
| 909 | .unique(); | ||
| 910 | const connectionRow = await ctx.db | ||
| 911 | .query("authConnections") | ||
| 912 | .withIndex("by_domainId", (q) => q.eq("id", args.connectionId)) | ||
| 913 | .unique(); | ||
| 914 | |||
| 915 | if (!stateRow || !connectionRow) { | ||
| 916 | return { kind: "missing" }; | ||
| 917 | } | ||
| 918 | |||
| 919 | const state = toOAuthState(stateRow as unknown as Record<string, unknown>); | ||
| 920 | const connection = toAuthConnection(connectionRow as unknown as Record<string, unknown>); | ||
| 921 | const now = Date.now(); | ||
| 922 | const leaseExpiresAt = state.leaseExpiresAt; | ||
| 923 | const leaseActive = | ||
| 924 | state.leaseHolder !== null | ||
| 925 | && leaseExpiresAt !== null | ||
| 926 | && leaseExpiresAt > now | ||
| 927 | && state.leaseHolder !== args.leaseHolder; | ||
| 928 | |||
| 929 | if (leaseActive) { | ||
| 930 | return { | ||
| 931 | kind: "locked", | ||
| 932 | leaseExpiresAt, | ||
| 933 | }; | ||
| 934 | } | ||
| 935 | |||
| 936 | await ctx.db.patch(stateRow._id, { | ||
| 937 | leaseHolder: args.leaseHolder, | ||
| 938 | leaseExpiresAt: now + Math.max(1_000, Math.floor(args.leaseMs)), | ||
| 939 | leaseFence: state.leaseFence + 1, | ||
| 940 | lastRefreshErrorClass: null, | ||
| 941 | lastRefreshError: null, | ||
| 942 | updatedAt: now, | ||
| 943 | }); | ||
| 944 | |||
| 945 | const nextState = await ctx.db | ||
| 946 | .query("oauthState") | ||
| 947 | .withIndex("by_connectionId", (q) => q.eq("connectionId", args.connectionId)) | ||
| 948 | .unique(); | ||
| 949 | |||
| 950 | if (!nextState) { | ||
| 951 | return { kind: "missing" }; | ||
| 952 | } | ||
| 98 | 953 | ||
| 99 | return workspace.organizationId; | 954 | return { |
| 100 | }; | 955 | kind: "ready", |
| 956 | oauthState: toOAuthState(nextState as unknown as Record<string, unknown>), | ||
| 957 | connection, | ||
| 958 | }; | ||
| 959 | }, | ||
| 960 | }); | ||
| 101 | 961 | ||
| 102 | const canAccessSourceCredentialBinding = ( | 962 | export const finalizeOAuthRefreshSuccess = internalMutation({ |
| 103 | binding: SourceCredentialBinding, | 963 | args: { |
| 104 | input: { | 964 | connectionId: v.string(), |
| 105 | workspaceId: string; | 965 | leaseHolder: v.string(), |
| 106 | organizationId: string; | 966 | expectedTokenVersion: v.number(), |
| 967 | expectedLeaseFence: v.number(), | ||
| 968 | accessTokenCiphertext: v.string(), | ||
| 969 | refreshTokenCiphertext: v.optional(v.union(v.string(), v.null())), | ||
| 970 | expiresAt: v.optional(v.union(v.number(), v.null())), | ||
| 971 | scope: v.optional(v.union(v.string(), v.null())), | ||
| 972 | tokenType: v.optional(v.union(v.string(), v.null())), | ||
| 973 | issuer: v.optional(v.union(v.string(), v.null())), | ||
| 107 | }, | 974 | }, |
| 108 | ): boolean => | 975 | handler: async (ctx, args): Promise<{ committed: boolean }> => { |
| 109 | binding.workspaceId === input.workspaceId | 976 | const stateRow = await ctx.db |
| 110 | || (binding.workspaceId === null && binding.organizationId === input.organizationId); | 977 | .query("oauthState") |
| 978 | .withIndex("by_connectionId", (q) => q.eq("connectionId", args.connectionId)) | ||
| 979 | .unique(); | ||
| 980 | const connectionRow = await ctx.db | ||
| 981 | .query("authConnections") | ||
| 982 | .withIndex("by_domainId", (q) => q.eq("id", args.connectionId)) | ||
| 983 | .unique(); | ||
| 111 | 984 | ||
| 112 | const toOAuthToken = (document: Record<string, unknown>): OAuthToken => | 985 | if (!stateRow || !connectionRow) { |
| 113 | decodeOAuthToken(stripConvexSystemFields(document)); | 986 | return { committed: false }; |
| 987 | } | ||
| 114 | 988 | ||
| 115 | const sourceKeyFromSourceId = (sourceId: string): string | null => { | 989 | const state = toOAuthState(stateRow as unknown as Record<string, unknown>); |
| 116 | const trimmed = sourceId.trim(); | 990 | const connection = toAuthConnection(connectionRow as unknown as Record<string, unknown>); |
| 117 | if (trimmed.length === 0) { | ||
| 118 | return null; | ||
| 119 | } | ||
| 120 | 991 | ||
| 121 | return `source:${trimmed}`; | 992 | if (state.leaseHolder !== args.leaseHolder) { |
| 122 | }; | 993 | return { committed: false }; |
| 994 | } | ||
| 123 | 995 | ||
| 124 | const bindingScopeScore = ( | 996 | if (state.tokenVersion !== args.expectedTokenVersion) { |
| 125 | binding: SourceCredentialBinding, | 997 | return { committed: false }; |
| 126 | input: { | ||
| 127 | workspaceId: string; | ||
| 128 | organizationId: string; | ||
| 129 | accountId: string | null; | ||
| 130 | }, | ||
| 131 | ): number => { | ||
| 132 | if (binding.scopeType === "account") { | ||
| 133 | if (!input.accountId || binding.accountId !== input.accountId) { | ||
| 134 | return -1; | ||
| 135 | } | 998 | } |
| 136 | 999 | ||
| 137 | return binding.organizationId === input.organizationId ? 30 : -1; | 1000 | if (state.leaseFence !== args.expectedLeaseFence) { |
| 138 | } | 1001 | return { committed: false }; |
| 1002 | } | ||
| 139 | 1003 | ||
| 140 | if (binding.scopeType === "workspace") { | 1004 | const now = Date.now(); |
| 141 | return binding.workspaceId === input.workspaceId ? 20 : -1; | 1005 | const nextRefreshToken = args.refreshTokenCiphertext === undefined |
| 142 | } | 1006 | ? state.refreshTokenCiphertext |
| 1007 | : args.refreshTokenCiphertext; | ||
| 1008 | |||
| 1009 | await ctx.db.patch(stateRow._id, { | ||
| 1010 | accessTokenCiphertext: args.accessTokenCiphertext, | ||
| 1011 | refreshTokenCiphertext: nextRefreshToken, | ||
| 1012 | expiresAt: args.expiresAt !== undefined ? args.expiresAt : state.expiresAt, | ||
| 1013 | scope: args.scope !== undefined ? args.scope : state.scope, | ||
| 1014 | tokenType: args.tokenType !== undefined ? args.tokenType : state.tokenType, | ||
| 1015 | issuer: args.issuer !== undefined ? args.issuer : state.issuer, | ||
| 1016 | tokenVersion: state.tokenVersion + 1, | ||
| 1017 | leaseHolder: null, | ||
| 1018 | leaseExpiresAt: null, | ||
| 1019 | lastRefreshAt: now, | ||
| 1020 | lastRefreshErrorClass: null, | ||
| 1021 | lastRefreshError: null, | ||
| 1022 | reauthRequiredAt: null, | ||
| 1023 | updatedAt: now, | ||
| 1024 | }); | ||
| 143 | 1025 | ||
| 144 | if (binding.scopeType === "organization") { | 1026 | await ctx.db.patch(connectionRow._id, { |
| 145 | return binding.organizationId === input.organizationId ? 10 : -1; | 1027 | status: "active", |
| 146 | } | 1028 | statusReason: null, |
| 1029 | lastAuthErrorClass: null, | ||
| 1030 | lastUsedAt: now, | ||
| 1031 | updatedAt: now, | ||
| 1032 | }); | ||
| 147 | 1033 | ||
| 148 | return -1; | 1034 | await insertAuthAuditEvent(ctx, { |
| 149 | }; | 1035 | organizationId: connection.organizationId, |
| 1036 | connectionId: connection.id, | ||
| 1037 | sourceId: null, | ||
| 1038 | eventType: "refresh_success", | ||
| 1039 | actorType: "system", | ||
| 1040 | outcome: "success", | ||
| 1041 | }); | ||
| 150 | 1042 | ||
| 151 | const selectBestSourceBinding = ( | 1043 | return { committed: true }; |
| 152 | bindings: ReadonlyArray<SourceCredentialBinding>, | ||
| 153 | input: { | ||
| 154 | workspaceId: string; | ||
| 155 | organizationId: string; | ||
| 156 | accountId: string | null; | ||
| 157 | }, | 1044 | }, |
| 158 | ): SourceCredentialBinding | null => { | 1045 | }); |
| 159 | const ranked = bindings | ||
| 160 | .map((binding) => ({ | ||
| 161 | binding, | ||
| 162 | scopeScore: bindingScopeScore(binding, input), | ||
| 163 | })) | ||
| 164 | .filter((candidate) => candidate.scopeScore >= 0) | ||
| 165 | .sort((left, right) => { | ||
| 166 | if (left.scopeScore !== right.scopeScore) { | ||
| 167 | return right.scopeScore - left.scopeScore; | ||
| 168 | } | ||
| 169 | 1046 | ||
| 170 | if (left.binding.updatedAt !== right.binding.updatedAt) { | 1047 | export const finalizeOAuthRefreshFailure = internalMutation({ |
| 171 | return right.binding.updatedAt - left.binding.updatedAt; | 1048 | args: { |
| 172 | } | 1049 | connectionId: v.string(), |
| 1050 | leaseHolder: v.string(), | ||
| 1051 | errorClass: v.string(), | ||
| 1052 | message: v.string(), | ||
| 1053 | reauthRequired: v.boolean(), | ||
| 1054 | }, | ||
| 1055 | handler: async (ctx, args): Promise<void> => { | ||
| 1056 | const stateRow = await ctx.db | ||
| 1057 | .query("oauthState") | ||
| 1058 | .withIndex("by_connectionId", (q) => q.eq("connectionId", args.connectionId)) | ||
| 1059 | .unique(); | ||
| 1060 | const connectionRow = await ctx.db | ||
| 1061 | .query("authConnections") | ||
| 1062 | .withIndex("by_domainId", (q) => q.eq("id", args.connectionId)) | ||
| 1063 | .unique(); | ||
| 173 | 1064 | ||
| 174 | return right.binding.createdAt - left.binding.createdAt; | 1065 | if (!stateRow || !connectionRow) { |
| 175 | }); | 1066 | return; |
| 1067 | } | ||
| 176 | 1068 | ||
| 177 | return ranked[0]?.binding ?? null; | 1069 | const state = toOAuthState(stateRow as unknown as Record<string, unknown>); |
| 178 | }; | 1070 | const connection = toAuthConnection(connectionRow as unknown as Record<string, unknown>); |
| 179 | 1071 | ||
| 180 | const requireLocalSecretProvider = ( | 1072 | if (state.leaseHolder !== args.leaseHolder) { |
| 181 | secretProvider: SourceCredentialBinding["secretProvider"], | 1073 | return; |
| 182 | ): "local" => { | 1074 | } |
| 183 | if (secretProvider !== "local") { | ||
| 184 | throw new Error("Convex credentials require secretProvider 'local'"); | ||
| 185 | } | ||
| 186 | 1075 | ||
| 187 | return "local"; | 1076 | const now = Date.now(); |
| 188 | }; | 1077 | await ctx.db.patch(stateRow._id, { |
| 1078 | leaseHolder: null, | ||
| 1079 | leaseExpiresAt: null, | ||
| 1080 | lastRefreshErrorClass: args.errorClass, | ||
| 1081 | lastRefreshError: args.message, | ||
| 1082 | reauthRequiredAt: args.reauthRequired ? now : state.reauthRequiredAt, | ||
| 1083 | updatedAt: now, | ||
| 1084 | }); | ||
| 189 | 1085 | ||
| 190 | const defaultSecretProvider = (): "local" => "local"; | 1086 | await ctx.db.patch(connectionRow._id, { |
| 1087 | status: args.reauthRequired ? "reauth_required" : "error", | ||
| 1088 | statusReason: args.message, | ||
| 1089 | lastAuthErrorClass: args.errorClass, | ||
| 1090 | updatedAt: now, | ||
| 1091 | }); | ||
| 191 | 1092 | ||
| 192 | const resolveSecretRefForHeaders = async ( | 1093 | await insertAuthAuditEvent(ctx, { |
| 193 | binding: SourceCredentialBinding, | 1094 | organizationId: connection.organizationId, |
| 194 | ): Promise<string> => { | 1095 | connectionId: connection.id, |
| 195 | requireLocalSecretProvider(binding.secretProvider); | 1096 | sourceId: null, |
| 196 | return await decryptSecretValue(binding.secretRef); | 1097 | eventType: args.reauthRequired ? "reauth_required" : "refresh_failed", |
| 197 | }; | 1098 | actorType: "system", |
| 1099 | outcome: "failure", | ||
| 1100 | reasonCode: args.errorClass, | ||
| 1101 | detailsJson: JSON.stringify({ message: args.message }), | ||
| 1102 | }); | ||
| 1103 | }, | ||
| 1104 | }); | ||
| 198 | 1105 | ||
| 199 | const resolveSourceSelection = async ( | 1106 | const refreshOAuthStateIfNeeded = async ( |
| 200 | ctx: QueryCtx, | 1107 | ctx: any, |
| 201 | args: { | 1108 | input: { |
| 202 | workspaceId: string; | 1109 | connectionId: string; |
| 203 | sourceId: string; | 1110 | forceRefresh: boolean; |
| 204 | accountId: string | null; | 1111 | oauthState: OAuthState; |
| 205 | }, | 1112 | }, |
| 206 | ): Promise<{ | 1113 | ): Promise<OAuthState | null> => { |
| 207 | binding: SourceCredentialBinding | null; | 1114 | const now = Date.now(); |
| 208 | oauthAccessToken: string | null; | 1115 | const isExpiring = |
| 209 | }> => { | 1116 | input.oauthState.expiresAt !== null |
| 210 | const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); | 1117 | && input.oauthState.expiresAt <= now + oauthRefreshWindowMs; |
| 211 | const sourceKey = sourceKeyFromSourceId(args.sourceId); | 1118 | const shouldAttemptRefresh = input.forceRefresh || isExpiring; |
| 212 | if (!sourceKey) { | 1119 | |
| 213 | return { | 1120 | if (!shouldAttemptRefresh) { |
| 214 | binding: null, | 1121 | return input.oauthState; |
| 215 | oauthAccessToken: null, | ||
| 216 | }; | ||
| 217 | } | 1122 | } |
| 218 | 1123 | ||
| 219 | const rows = await ctx.db | 1124 | const leaseHolder = `oauth_refresh_${crypto.randomUUID()}`; |
| 220 | .query("sourceCredentialBindings") | 1125 | const begin = await ctx.runMutation( |
| 221 | .withIndex("by_sourceKey", (q) => q.eq("sourceKey", sourceKey)) | 1126 | runtimeInternal.control_plane.credentials.beginOAuthRefresh, |
| 222 | .collect(); | ||
| 223 | |||
| 224 | const binding = selectBestSourceBinding( | ||
| 225 | rows.map((row) => toSourceCredentialBinding(row)), | ||
| 226 | { | 1127 | { |
| 227 | workspaceId: args.workspaceId, | 1128 | connectionId: input.connectionId, |
| 228 | organizationId, | 1129 | leaseHolder, |
| 229 | accountId: args.accountId, | 1130 | leaseMs: oauthRefreshLeaseMs, |
| 230 | }, | 1131 | }, |
| 231 | ); | 1132 | ); |
| 232 | 1133 | ||
| 233 | if (!binding) { | 1134 | if (begin.kind === "missing") { |
| 234 | return { | 1135 | return null; |
| 235 | binding: null, | ||
| 236 | oauthAccessToken: null, | ||
| 237 | }; | ||
| 238 | } | 1136 | } |
| 239 | 1137 | ||
| 240 | const oauthTokens = binding.provider === "oauth2" | 1138 | if (begin.kind === "locked") { |
| 241 | ? (await ctx.db | 1139 | const fallback = await ctx.runQuery( |
| 242 | .query("oauthTokens") | 1140 | runtimeInternal.control_plane.credentials.getOAuthStateByConnection, |
| 243 | .withIndex("by_sourceId", (q) => q.eq("sourceId", args.sourceId)) | 1141 | { |
| 244 | .collect()).map((row) => toOAuthToken(row)) | 1142 | connectionId: input.connectionId, |
| 245 | : []; | 1143 | }, |
| 1144 | ); | ||
| 1145 | return fallback; | ||
| 1146 | } | ||
| 246 | 1147 | ||
| 247 | const oauthAccessToken = binding.provider === "oauth2" | 1148 | const state = begin.oauthState; |
| 248 | ? selectOAuthAccessToken(oauthTokens, { | 1149 | const refreshConfig = parseOAuthRefreshConfig(state.refreshConfigJson); |
| 249 | workspaceId: args.workspaceId, | ||
| 250 | organizationId, | ||
| 251 | accountId: args.accountId, | ||
| 252 | sourceKey, | ||
| 253 | }, args.sourceId) | ||
| 254 | : null; | ||
| 255 | 1150 | ||
| 256 | return { | 1151 | const refreshed = await refreshTokenFromOAuthEndpoint(state, refreshConfig); |
| 257 | binding, | 1152 | if (!refreshed.ok) { |
| 258 | oauthAccessToken, | 1153 | await ctx.runMutation( |
| 259 | }; | 1154 | runtimeInternal.control_plane.credentials.finalizeOAuthRefreshFailure, |
| 260 | }; | 1155 | { |
| 1156 | connectionId: input.connectionId, | ||
| 1157 | leaseHolder, | ||
| 1158 | errorClass: refreshed.errorClass, | ||
| 1159 | message: refreshed.message, | ||
| 1160 | reauthRequired: | ||
| 1161 | refreshed.errorClass === "invalid_grant" | ||
| 1162 | || refreshed.errorClass === "invalid_client" | ||
| 1163 | || refreshed.errorClass === "unsupported", | ||
| 1164 | }, | ||
| 1165 | ); | ||
| 261 | 1166 | ||
| 262 | const withDecryptedSecret = async ( | 1167 | const fallback = await ctx.runQuery( |
| 263 | binding: SourceCredentialBinding, | 1168 | runtimeInternal.control_plane.credentials.getOAuthStateByConnection, |
| 264 | ): Promise<SourceCredentialBinding> => { | 1169 | { |
| 265 | const secretRef = await resolveSecretRefForHeaders(binding); | 1170 | connectionId: input.connectionId, |
| 266 | return { | 1171 | }, |
| 267 | ...binding, | 1172 | ); |
| 268 | secretProvider: "local", | 1173 | return fallback; |
| 269 | secretRef, | 1174 | } |
| 270 | }; | 1175 | |
| 1176 | const accessTokenCiphertext = await encryptSecretValue(refreshed.accessToken); | ||
| 1177 | const refreshTokenCiphertext = refreshed.refreshToken | ||
| 1178 | ? await encryptSecretValue(refreshed.refreshToken) | ||
| 1179 | : undefined; | ||
| 1180 | |||
| 1181 | await ctx.runMutation( | ||
| 1182 | runtimeInternal.control_plane.credentials.finalizeOAuthRefreshSuccess, | ||
| 1183 | { | ||
| 1184 | connectionId: input.connectionId, | ||
| 1185 | leaseHolder, | ||
| 1186 | expectedTokenVersion: state.tokenVersion, | ||
| 1187 | expectedLeaseFence: state.leaseFence, | ||
| 1188 | accessTokenCiphertext, | ||
| 1189 | ...(refreshTokenCiphertext !== undefined | ||
| 1190 | ? { refreshTokenCiphertext } | ||
| 1191 | : {}), | ||
| 1192 | ...(refreshed.expiresAt !== null ? { expiresAt: refreshed.expiresAt } : {}), | ||
| 1193 | ...(refreshed.scope !== null ? { scope: refreshed.scope } : {}), | ||
| 1194 | ...(refreshed.tokenType !== null ? { tokenType: refreshed.tokenType } : {}), | ||
| 1195 | ...(refreshed.issuer !== null ? { issuer: refreshed.issuer } : {}), | ||
| 1196 | }, | ||
| 1197 | ); | ||
| 1198 | |||
| 1199 | return await ctx.runQuery( | ||
| 1200 | runtimeInternal.control_plane.credentials.getOAuthStateByConnection, | ||
| 1201 | { | ||
| 1202 | connectionId: input.connectionId, | ||
| 1203 | }, | ||
| 1204 | ); | ||
| 271 | }; | 1205 | }; |
| 272 | 1206 | ||
| 273 | export const resolveSourceCredentialSelection = internalQuery({ | 1207 | export const getOAuthStateByConnection = internalQuery({ |
| 274 | args: { | 1208 | args: { |
| 275 | workspaceId: v.string(), | 1209 | connectionId: v.string(), |
| 276 | sourceId: v.string(), | ||
| 277 | accountId: v.optional(v.union(v.string(), v.null())), | ||
| 278 | }, | 1210 | }, |
| 279 | handler: async (ctx, args) => resolveSourceSelection(ctx, { | 1211 | handler: async (ctx, args): Promise<OAuthState | null> => |
| 280 | workspaceId: args.workspaceId, | 1212 | await loadOAuthStateByConnectionId(ctx, args.connectionId), |
| 281 | sourceId: args.sourceId, | ||
| 282 | accountId: args.accountId ?? null, | ||
| 283 | }), | ||
| 284 | }); | 1213 | }); |
| 285 | 1214 | ||
| 286 | export const resolveSourceCredentialHeaders = internalAction({ | 1215 | export const resolveSourceCredentialHeaders = internalAction({ |
| @@ -288,12 +1217,17 @@ export const resolveSourceCredentialHeaders = internalAction({ · 1 unmodified line | |||
| 288 | workspaceId: v.string(), | 1217 | workspaceId: v.string(), |
| 289 | sourceId: v.string(), | 1218 | sourceId: v.string(), |
| 290 | accountId: v.optional(v.union(v.string(), v.null())), | 1219 | accountId: v.optional(v.union(v.string(), v.null())), |
| 1220 | forceRefresh: v.optional(v.boolean()), | ||
| 291 | }, | 1221 | }, |
| 292 | handler: async (ctx, args): Promise<{ | 1222 | handler: async ( |
| 1223 | ctx, | ||
| 1224 | args, | ||
| 1225 | ): Promise<{ | ||
| 293 | headers: Record<string, string>; | 1226 | headers: Record<string, string>; |
| 1227 | oauthConnectionId: string | null; | ||
| 294 | }> => { | 1228 | }> => { |
| 295 | const selected = await ctx.runQuery( | 1229 | const selected = await ctx.runQuery( |
| 296 | runtimeInternal.control_plane.credentials.resolveSourceCredentialSelection, | 1230 | runtimeInternal.control_plane.credentials.resolveSourceAuthSelection, |
| 297 | { | 1231 | { |
| 298 | workspaceId: args.workspaceId, | 1232 | workspaceId: args.workspaceId, |
| 299 | sourceId: args.sourceId, | 1233 | sourceId: args.sourceId, |
| @@ -301,127 +1235,353 @@ export const resolveSourceCredentialHeaders = internalAction({ · 1 unmodified line | |||
| 301 | }, | 1235 | }, |
| 302 | ); | 1236 | ); |
| 303 | 1237 | ||
| 304 | if (!selected.binding) { | 1238 | if (!selected.binding || !selected.connection) { |
| 305 | return { | 1239 | return { |
| 306 | headers: {}, | 1240 | headers: {}, |
| 1241 | oauthConnectionId: null, | ||
| 307 | }; | 1242 | }; |
| 308 | } | 1243 | } |
| 309 | 1244 | ||
| 310 | const decrypted = await withDecryptedSecret(selected.binding); | 1245 | const connection = selected.connection; |
| 1246 | const connectionStatus = normalizeAuthConnectionStatus(connection); | ||
| 1247 | if (connectionStatus !== "active") { | ||
| 1248 | return { | ||
| 1249 | headers: {}, | ||
| 1250 | oauthConnectionId: connection.strategy === "oauth2" ? connection.id : null, | ||
| 1251 | }; | ||
| 1252 | } | ||
| 311 | 1253 | ||
| 312 | const headers = buildCredentialHeaders(decrypted, { | 1254 | const metadata = parseConnectionMetadata(connection.metadataJson); |
| 313 | oauthAccessToken: selected.oauthAccessToken, | 1255 | const additionalHeaders = parseAdditionalHeaders(connection.additionalHeadersJson); |
| 314 | }); | ||
| 315 | 1256 | ||
| 316 | return { headers }; | 1257 | if (connection.strategy === "oauth2") { |
| 317 | }, | 1258 | let oauthState = selected.oauthState; |
| 318 | }); | 1259 | if (!oauthState) { |
| 1260 | return { | ||
| 1261 | headers: additionalHeaders, | ||
| 1262 | oauthConnectionId: connection.id, | ||
| 1263 | }; | ||
| 1264 | } | ||
| 319 | 1265 | ||
| 320 | export const listCredentialBindings = internalQuery({ | 1266 | oauthState = await refreshOAuthStateIfNeeded(ctx, { |
| 321 | args: { | 1267 | connectionId: connection.id, |
| 322 | workspaceId: v.string(), | 1268 | forceRefresh: args.forceRefresh === true, |
| 323 | }, | 1269 | oauthState, |
| 324 | handler: async (ctx, args): Promise<Array<SourceCredentialBinding>> => { | 1270 | }); |
| 325 | const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); | 1271 | |
| 1272 | if (!oauthState) { | ||
| 1273 | return { | ||
| 1274 | headers: additionalHeaders, | ||
| 1275 | oauthConnectionId: connection.id, | ||
| 1276 | }; | ||
| 1277 | } | ||
| 326 | 1278 | ||
| 327 | const workspaceRows = await ctx.db | 1279 | const now = Date.now(); |
| 328 | .query("sourceCredentialBindings") | 1280 | if (oauthState.expiresAt !== null && oauthState.expiresAt <= now) { |
| 329 | .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId)) | 1281 | return { |
| 330 | .collect(); | 1282 | headers: additionalHeaders, |
| 1283 | oauthConnectionId: connection.id, | ||
| 1284 | }; | ||
| 1285 | } | ||
| 331 | 1286 | ||
| 332 | const organizationRows = await ctx.db | 1287 | const accessToken = await decryptSecretValue(oauthState.accessTokenCiphertext); |
| 333 | .query("sourceCredentialBindings") | 1288 | const oauthHeaders = accessToken.trim().length > 0 |
| 334 | .withIndex("by_organizationId", (q) => q.eq("organizationId", organizationId)) | 1289 | ? ({ Authorization: `Bearer ${accessToken}` } as Record<string, string>) |
| 335 | .collect(); | 1290 | : {}; |
| 336 | 1291 | ||
| 337 | const bindings = [...workspaceRows, ...organizationRows] | 1292 | return { |
| 338 | .map((row) => | 1293 | headers: mergeHeaders(oauthHeaders, additionalHeaders), |
| 339 | toSourceCredentialBinding(row), | 1294 | oauthConnectionId: connection.id, |
| 340 | ) | 1295 | }; |
| 341 | .filter((binding) => | 1296 | } |
| 342 | canAccessSourceCredentialBinding(binding, { | ||
| 343 | workspaceId: args.workspaceId, | ||
| 344 | organizationId, | ||
| 345 | }) | ||
| 346 | ); | ||
| 347 | 1297 | ||
| 348 | const uniqueBindings = Array.from( | 1298 | const material = await ctx.runQuery( |
| 349 | new Map(bindings.map((binding) => [binding.id, binding])).values(), | 1299 | runtimeInternal.control_plane.credentials.getAuthMaterialByConnection, |
| 1300 | { | ||
| 1301 | connectionId: connection.id, | ||
| 1302 | }, | ||
| 350 | ); | 1303 | ); |
| 351 | 1304 | ||
| 352 | const decryptedBindings = await Promise.all( | 1305 | if (!material) { |
| 353 | uniqueBindings.map((binding) => withDecryptedSecret(binding)), | 1306 | return { |
| 354 | ); | 1307 | headers: additionalHeaders, |
| 1308 | oauthConnectionId: null, | ||
| 1309 | }; | ||
| 1310 | } | ||
| 1311 | |||
| 1312 | const secret = await decryptSecretValue(material.ciphertext); | ||
| 1313 | const strategyHeaders = buildSecretHeaders(connection.strategy, secret, metadata); | ||
| 1314 | |||
| 1315 | return { | ||
| 1316 | headers: mergeHeaders(strategyHeaders, additionalHeaders), | ||
| 1317 | oauthConnectionId: null, | ||
| 1318 | }; | ||
| 1319 | }, | ||
| 1320 | }); | ||
| 355 | 1321 | ||
| 356 | return sortSourceCredentialBindings(decryptedBindings); | 1322 | export const getAuthMaterialByConnection = internalQuery({ |
| 1323 | args: { | ||
| 1324 | connectionId: v.string(), | ||
| 357 | }, | 1325 | }, |
| 1326 | handler: async (ctx, args): Promise<AuthMaterial | null> => | ||
| 1327 | await loadAuthMaterialByConnectionId(ctx, args.connectionId), | ||
| 1328 | }); | ||
| 1329 | |||
| 1330 | const coerceScopeOwnerType = ( | ||
| 1331 | scopeType: SourceCredentialBinding["scopeType"], | ||
| 1332 | ): AuthConnection["ownerType"] => { | ||
| 1333 | if (scopeType === "organization") return "organization"; | ||
| 1334 | if (scopeType === "account") return "account"; | ||
| 1335 | return "workspace"; | ||
| 1336 | }; | ||
| 1337 | |||
| 1338 | const buildOAuthRefreshConfigFromPayload = ( | ||
| 1339 | payload: ExtendedUpsertCredentialBindingPayload, | ||
| 1340 | existing: OAuthRefreshConfig, | ||
| 1341 | encryptedClientSecret: string | null, | ||
| 1342 | ): OAuthRefreshConfig => ({ | ||
| 1343 | tokenEndpoint: | ||
| 1344 | normalizeString(payload.oauthTokenEndpoint) | ||
| 1345 | ?? existing.tokenEndpoint, | ||
| 1346 | authorizationServer: | ||
| 1347 | normalizeString(payload.oauthAuthorizationServer) | ||
| 1348 | ?? existing.authorizationServer, | ||
| 1349 | clientId: normalizeString(payload.oauthClientId) ?? existing.clientId, | ||
| 1350 | clientSecretCiphertext: | ||
| 1351 | encryptedClientSecret | ||
| 1352 | ?? existing.clientSecretCiphertext, | ||
| 1353 | sourceUrl: normalizeString(payload.oauthSourceUrl) ?? existing.sourceUrl, | ||
| 1354 | clientInformationJson: | ||
| 1355 | normalizeString(payload.oauthClientInformationJson) | ||
| 1356 | ?? existing.clientInformationJson, | ||
| 358 | }); | 1357 | }); |
| 359 | 1358 | ||
| 360 | export const upsertCredentialBindingRecord = internalMutation({ | 1359 | export const upsertCredentialBindingRecord = internalMutation({ |
| 361 | args: { | 1360 | args: { |
| 362 | workspaceId: v.string(), | 1361 | workspaceId: v.string(), |
| 363 | payload: sourceCredentialBindingPayloadValidator, | 1362 | payload: sourceCredentialBindingPayloadValidator, |
| 1363 | secretCiphertext: v.string(), | ||
| 1364 | oauthRefreshCiphertext: v.optional(v.union(v.string(), v.null())), | ||
| 1365 | oauthClientSecretCiphertext: v.optional(v.union(v.string(), v.null())), | ||
| 364 | }, | 1366 | }, |
| 365 | handler: async (ctx, args): Promise<SourceCredentialBinding> => { | 1367 | handler: async (ctx, args): Promise<SourceCredentialBinding> => { |
| 366 | const payload = args.payload; | 1368 | const payload = args.payload as ExtendedUpsertCredentialBindingPayload; |
| 1369 | const now = Date.now(); | ||
| 1370 | const sourceId = sourceIdFromSourceKey(payload.sourceKey); | ||
| 1371 | if (!sourceId) { | ||
| 1372 | throw new Error("Credentials require sourceKey in the form 'source:<id>'"); | ||
| 1373 | } | ||
| 367 | 1374 | ||
| 368 | if (payload.scopeType === "account" && payload.accountId === null) { | 1375 | if (payload.scopeType === "account" && payload.accountId === null) { |
| 369 | throw new Error("Account scope credentials require accountId"); | 1376 | throw new Error("Account scope credentials require accountId"); |
| 370 | } | 1377 | } |
| 371 | 1378 | ||
| 372 | const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); | 1379 | const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); |
| 373 | const now = Date.now(); | ||
| 374 | const bindingId = payload.id ?? `credential_binding_${crypto.randomUUID()}`; | ||
| 375 | 1380 | ||
| 376 | const existing = await ctx.db | 1381 | const existingBindingRow = payload.id |
| 377 | .query("sourceCredentialBindings") | 1382 | ? await ctx.db |
| 378 | .withIndex("by_domainId", (q) => q.eq("id", bindingId)) | 1383 | .query("sourceAuthBindings") |
| 379 | .unique(); | 1384 | .withIndex("by_domainId", (q) => q.eq("id", payload.id!)) |
| 1385 | .unique() | ||
| 1386 | : null; | ||
| 1387 | const existingBinding = existingBindingRow | ||
| 1388 | ? toSourceAuthBinding(existingBindingRow as unknown as Record<string, unknown>) | ||
| 1389 | : null; | ||
| 1390 | |||
| 1391 | const scopeWorkspaceId = payload.scopeType === "workspace" ? args.workspaceId : null; | ||
| 1392 | const scopeAccountId = payload.scopeType === "account" ? (payload.accountId ?? null) : null; | ||
| 1393 | |||
| 1394 | const resolvedBindingId = existingBinding?.id | ||
| 1395 | ?? payload.id | ||
| 1396 | ?? (() => { | ||
| 1397 | return `auth_binding_${crypto.randomUUID()}`; | ||
| 1398 | })(); | ||
| 380 | 1399 | ||
| 381 | const existingBinding = existing | 1400 | const requestedConnectionId = normalizeString(payload.credentialId) |
| 382 | ? toSourceCredentialBinding(existing) | 1401 | ?? existingBinding?.connectionId |
| 1402 | ?? `conn_${crypto.randomUUID()}`; | ||
| 1403 | |||
| 1404 | const existingConnectionRow = await ctx.db | ||
| 1405 | .query("authConnections") | ||
| 1406 | .withIndex("by_domainId", (q) => q.eq("id", requestedConnectionId)) | ||
| 1407 | .unique(); | ||
| 1408 | const existingConnection = existingConnectionRow | ||
| 1409 | ? toAuthConnection(existingConnectionRow as unknown as Record<string, unknown>) | ||
| 383 | : null; | 1410 | : null; |
| 384 | 1411 | ||
| 385 | if ( | 1412 | if (existingConnection && existingConnection.organizationId !== organizationId) { |
| 386 | existingBinding !== null | 1413 | throw new Error("Connection id belongs to another organization"); |
| 387 | && !canAccessSourceCredentialBinding(existingBinding, { | ||
| 388 | workspaceId: args.workspaceId, | ||
| 389 | organizationId, | ||
| 390 | }) | ||
| 391 | ) { | ||
| 392 | throw new Error(`Credential binding not found: ${bindingId}`); | ||
| 393 | } | 1414 | } |
| 394 | 1415 | ||
| 395 | const secretProvider = requireLocalSecretProvider( | 1416 | const nextConnection = decodeAuthConnection({ |
| 396 | payload.secretProvider | 1417 | id: requestedConnectionId, |
| 397 | ?? existingBinding?.secretProvider | 1418 | organizationId, |
| 398 | ?? defaultSecretProvider(), | 1419 | workspaceId: scopeWorkspaceId, |
| 399 | ); | 1420 | accountId: scopeAccountId, |
| 1421 | ownerType: coerceScopeOwnerType(payload.scopeType), | ||
| 1422 | strategy: strategyFromProvider(payload.provider), | ||
| 1423 | displayName: | ||
| 1424 | normalizeString(existingConnection?.displayName) | ||
| 1425 | ?? sourceKeyFromSourceId(sourceId), | ||
| 1426 | status: "active", | ||
| 1427 | statusReason: null, | ||
| 1428 | lastAuthErrorClass: null, | ||
| 1429 | metadataJson: existingConnection?.metadataJson ?? null, | ||
| 1430 | additionalHeadersJson: | ||
| 1431 | payload.additionalHeadersJson !== undefined | ||
| 1432 | ? payload.additionalHeadersJson | ||
| 1433 | : existingConnection?.additionalHeadersJson ?? null, | ||
| 1434 | createdByAccountId: existingConnection?.createdByAccountId ?? null, | ||
| 1435 | createdAt: existingConnection?.createdAt ?? now, | ||
| 1436 | updatedAt: now, | ||
| 1437 | lastUsedAt: existingConnection?.lastUsedAt ?? null, | ||
| 1438 | }); | ||
| 1439 | |||
| 1440 | if (existingConnectionRow) { | ||
| 1441 | await ctx.db.patch(existingConnectionRow._id, nextConnection); | ||
| 1442 | } else { | ||
| 1443 | await ctx.db.insert("authConnections", nextConnection); | ||
| 1444 | } | ||
| 400 | 1445 | ||
| 401 | const nextBinding = decodeSourceCredentialBinding({ | 1446 | const nextBinding = decodeSourceAuthBinding({ |
| 402 | id: bindingId, | 1447 | id: resolvedBindingId, |
| 403 | credentialId: payload.credentialId, | 1448 | sourceId, |
| 1449 | connectionId: requestedConnectionId, | ||
| 404 | organizationId, | 1450 | organizationId, |
| 405 | workspaceId: payload.scopeType === "workspace" ? args.workspaceId : null, | 1451 | workspaceId: scopeWorkspaceId, |
| 406 | accountId: payload.scopeType === "account" ? (payload.accountId ?? null) : null, | 1452 | accountId: scopeAccountId, |
| 407 | scopeType: payload.scopeType, | 1453 | scopeType: payload.scopeType, |
| 408 | sourceKey: payload.sourceKey, | 1454 | selector: existingBinding?.selector ?? null, |
| 409 | provider: payload.provider, | 1455 | enabled: true, |
| 410 | secretProvider, | ||
| 411 | secretRef: payload.secretRef, | ||
| 412 | additionalHeadersJson: payload.additionalHeadersJson ?? null, | ||
| 413 | boundAuthFingerprint: payload.boundAuthFingerprint ?? null, | ||
| 414 | createdAt: existingBinding?.createdAt ?? now, | 1456 | createdAt: existingBinding?.createdAt ?? now, |
| 415 | updatedAt: now, | 1457 | updatedAt: now, |
| 416 | }); | 1458 | }); |
| 417 | 1459 | ||
| 418 | if (existing) { | 1460 | if (existingBindingRow) { |
| 419 | await ctx.db.patch(existing._id, nextBinding); | 1461 | await ctx.db.patch(existingBindingRow._id, nextBinding); |
| 1462 | } else { | ||
| 1463 | await ctx.db.insert("sourceAuthBindings", nextBinding); | ||
| 1464 | } | ||
| 1465 | |||
| 1466 | if (nextConnection.strategy === "oauth2") { | ||
| 1467 | const existingOAuthRow = await ctx.db | ||
| 1468 | .query("oauthState") | ||
| 1469 | .withIndex("by_connectionId", (q) => q.eq("connectionId", requestedConnectionId)) | ||
| 1470 | .unique(); | ||
| 1471 | const existingOAuth = existingOAuthRow | ||
| 1472 | ? toOAuthState(existingOAuthRow as unknown as Record<string, unknown>) | ||
| 1473 | : null; | ||
| 1474 | |||
| 1475 | const existingRefreshConfig = existingOAuth | ||
| 1476 | ? parseOAuthRefreshConfig(existingOAuth.refreshConfigJson) | ||
| 1477 | : {}; | ||
| 1478 | const refreshConfig = buildOAuthRefreshConfigFromPayload( | ||
| 1479 | payload, | ||
| 1480 | existingRefreshConfig, | ||
| 1481 | args.oauthClientSecretCiphertext ?? null, | ||
| 1482 | ); | ||
| 1483 | const refreshConfigJson = encodeOAuthRefreshConfig(refreshConfig); | ||
| 1484 | |||
| 1485 | const oauthState = decodeOAuthState({ | ||
| 1486 | id: existingOAuth?.id ?? `oauth_state_${crypto.randomUUID()}`, | ||
| 1487 | connectionId: requestedConnectionId, | ||
| 1488 | accessTokenCiphertext: args.secretCiphertext, | ||
| 1489 | refreshTokenCiphertext: | ||
| 1490 | args.oauthRefreshCiphertext !== undefined | ||
| 1491 | ? args.oauthRefreshCiphertext | ||
| 1492 | : existingOAuth?.refreshTokenCiphertext ?? null, | ||
| 1493 | keyVersion: currentCredentialEncryptionKeyVersion(), | ||
| 1494 | expiresAt: | ||
| 1495 | payload.oauthExpiresAt !== undefined | ||
| 1496 | ? payload.oauthExpiresAt | ||
| 1497 | : existingOAuth?.expiresAt ?? null, | ||
| 1498 | scope: | ||
| 1499 | payload.oauthScope !== undefined | ||
| 1500 | ? payload.oauthScope | ||
| 1501 | : existingOAuth?.scope ?? null, | ||
| 1502 | tokenType: existingOAuth?.tokenType ?? "Bearer", | ||
| 1503 | issuer: | ||
| 1504 | payload.oauthIssuer !== undefined | ||
| 1505 | ? payload.oauthIssuer | ||
| 1506 | : existingOAuth?.issuer ?? null, | ||
| 1507 | refreshConfigJson, | ||
| 1508 | tokenVersion: (existingOAuth?.tokenVersion ?? 0) + 1, | ||
| 1509 | leaseHolder: null, | ||
| 1510 | leaseExpiresAt: null, | ||
| 1511 | leaseFence: existingOAuth?.leaseFence ?? 0, | ||
| 1512 | lastRefreshAt: existingOAuth?.lastRefreshAt ?? null, | ||
| 1513 | lastRefreshErrorClass: null, | ||
| 1514 | lastRefreshError: null, | ||
| 1515 | reauthRequiredAt: null, | ||
| 1516 | createdAt: existingOAuth?.createdAt ?? now, | ||
| 1517 | updatedAt: now, | ||
| 1518 | }); | ||
| 1519 | |||
| 1520 | if (existingOAuthRow) { | ||
| 1521 | await ctx.db.patch(existingOAuthRow._id, oauthState); | ||
| 1522 | } else { | ||
| 1523 | await ctx.db.insert("oauthState", oauthState); | ||
| 1524 | } | ||
| 1525 | |||
| 1526 | const existingMaterialRow = await ctx.db | ||
| 1527 | .query("authMaterials") | ||
| 1528 | .withIndex("by_connectionId", (q) => q.eq("connectionId", requestedConnectionId)) | ||
| 1529 | .unique(); | ||
| 1530 | if (existingMaterialRow) { | ||
| 1531 | await ctx.db.delete(existingMaterialRow._id); | ||
| 1532 | } | ||
| 420 | } else { | 1533 | } else { |
| 421 | await ctx.db.insert("sourceCredentialBindings", nextBinding); | 1534 | const existingMaterialRow = await ctx.db |
| 1535 | .query("authMaterials") | ||
| 1536 | .withIndex("by_connectionId", (q) => q.eq("connectionId", requestedConnectionId)) | ||
| 1537 | .unique(); | ||
| 1538 | const existingMaterial = existingMaterialRow | ||
| 1539 | ? toAuthMaterial(existingMaterialRow as unknown as Record<string, unknown>) | ||
| 1540 | : null; | ||
| 1541 | |||
| 1542 | const material = decodeAuthMaterial({ | ||
| 1543 | id: existingMaterial?.id ?? `auth_material_${crypto.randomUUID()}`, | ||
| 1544 | connectionId: requestedConnectionId, | ||
| 1545 | ciphertext: args.secretCiphertext, | ||
| 1546 | keyVersion: currentCredentialEncryptionKeyVersion(), | ||
| 1547 | createdAt: existingMaterial?.createdAt ?? now, | ||
| 1548 | updatedAt: now, | ||
| 1549 | }); | ||
| 1550 | |||
| 1551 | if (existingMaterialRow) { | ||
| 1552 | await ctx.db.patch(existingMaterialRow._id, material); | ||
| 1553 | } else { | ||
| 1554 | await ctx.db.insert("authMaterials", material); | ||
| 1555 | } | ||
| 1556 | |||
| 1557 | const existingOAuthRow = await ctx.db | ||
| 1558 | .query("oauthState") | ||
| 1559 | .withIndex("by_connectionId", (q) => q.eq("connectionId", requestedConnectionId)) | ||
| 1560 | .unique(); | ||
| 1561 | if (existingOAuthRow) { | ||
| 1562 | await ctx.db.delete(existingOAuthRow._id); | ||
| 1563 | } | ||
| 422 | } | 1564 | } |
| 423 | 1565 | ||
| 424 | return nextBinding; | 1566 | await insertAuthAuditEvent(ctx, { |
| 1567 | organizationId, | ||
| 1568 | connectionId: requestedConnectionId, | ||
| 1569 | sourceId, | ||
| 1570 | eventType: existingConnection ? "updated" : "created", | ||
| 1571 | actorType: "system", | ||
| 1572 | outcome: "success", | ||
| 1573 | }); | ||
| 1574 | |||
| 1575 | await insertAuthAuditEvent(ctx, { | ||
| 1576 | organizationId, | ||
| 1577 | connectionId: requestedConnectionId, | ||
| 1578 | sourceId, | ||
| 1579 | eventType: "bound", | ||
| 1580 | actorType: "system", | ||
| 1581 | outcome: "success", | ||
| 1582 | }); | ||
| 1583 | |||
| 1584 | return toCompatSourceCredentialBinding(nextBinding, nextConnection); | ||
| 425 | }, | 1585 | }, |
| 426 | }); | 1586 | }); |
| 427 | 1587 | ||
| @@ -431,31 +1591,41 @@ export const upsertCredentialBinding = internalAction({ · 3 unmodified lines | |||
| 431 | payload: sourceCredentialBindingPayloadValidator, | 1591 | payload: sourceCredentialBindingPayloadValidator, |
| 432 | }, | 1592 | }, |
| 433 | handler: async (ctx, args): Promise<SourceCredentialBinding> => { | 1593 | handler: async (ctx, args): Promise<SourceCredentialBinding> => { |
| 434 | const payload = args.payload; | 1594 | const payload = args.payload as ExtendedUpsertCredentialBindingPayload; |
| 435 | const requestedSecretRef = payload.secretRef.trim(); | 1595 | const requestedSecretRef = payload.secretRef.trim(); |
| 436 | if (requestedSecretRef.length === 0) { | 1596 | if (requestedSecretRef.length === 0) { |
| 437 | throw new Error("Credential secret is required"); | 1597 | throw new Error("Credential secret is required"); |
| 438 | } | 1598 | } |
| 439 | 1599 | ||
| 440 | const encryptedSecretRef = await encryptSecretValue(requestedSecretRef); | 1600 | const encryptedSecretRef = await encryptSecretValue(requestedSecretRef); |
| 441 | 1601 | const refreshToken = normalizeString(payload.oauthRefreshToken); | |
| 442 | const written = await ctx.runMutation( | 1602 | const encryptedOauthRefreshToken = refreshToken |
| 1603 | ? await encryptSecretValue(refreshToken) | ||
| 1604 | : payload.oauthRefreshToken !== undefined | ||
| 1605 | ? null | ||
| 1606 | : undefined; | ||
| 1607 | |||
| 1608 | const clientSecret = normalizeString(payload.oauthClientSecret); | ||
| 1609 | const encryptedOauthClientSecret = clientSecret | ||
| 1610 | ? await encryptSecretValue(clientSecret) | ||
| 1611 | : payload.oauthClientSecret !== undefined | ||
| 1612 | ? null | ||
| 1613 | : undefined; | ||
| 1614 | |||
| 1615 | return await ctx.runMutation( | ||
| 443 | runtimeInternal.control_plane.credentials.upsertCredentialBindingRecord, | 1616 | runtimeInternal.control_plane.credentials.upsertCredentialBindingRecord, |
| 444 | { | 1617 | { |
| 445 | workspaceId: args.workspaceId, | 1618 | workspaceId: args.workspaceId, |
| 446 | payload: { | 1619 | payload, |
| 447 | ...payload, | 1620 | secretCiphertext: encryptedSecretRef, |
| 448 | secretProvider: "local", | 1621 | ...(encryptedOauthRefreshToken !== undefined |
| 449 | secretRef: encryptedSecretRef, | 1622 | ? { oauthRefreshCiphertext: encryptedOauthRefreshToken } |
| 450 | }, | 1623 | : {}), |
| 1624 | ...(encryptedOauthClientSecret !== undefined | ||
| 1625 | ? { oauthClientSecretCiphertext: encryptedOauthClientSecret } | ||
| 1626 | : {}), | ||
| 451 | }, | 1627 | }, |
| 452 | ); | 1628 | ); |
| 453 | |||
| 454 | return { | ||
| 455 | ...written, | ||
| 456 | secretProvider: "local", | ||
| 457 | secretRef: requestedSecretRef, | ||
| 458 | }; | ||
| 459 | }, | 1629 | }, |
| 460 | }); | 1630 | }); |
| 461 | 1631 | ||
| @@ -472,30 +1642,139 @@ export const removeCredentialBinding = internalMutation({ · 10 unmodified lines | |||
| 472 | }> => { | 1642 | }> => { |
| 473 | const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); | 1643 | const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); |
| 474 | 1644 | ||
| 475 | const existing = await ctx.db | 1645 | const bindingRow = await ctx.db |
| 476 | .query("sourceCredentialBindings") | 1646 | .query("sourceAuthBindings") |
| 477 | .withIndex("by_domainId", (q) => q.eq("id", args.credentialBindingId)) | 1647 | .withIndex("by_domainId", (q) => q.eq("id", args.credentialBindingId)) |
| 478 | .unique(); | 1648 | .unique(); |
| 479 | 1649 | ||
| 480 | if (!existing) { | 1650 | if (!bindingRow) { |
| 481 | return { removed: false }; | 1651 | return { removed: false }; |
| 482 | } | 1652 | } |
| 483 | 1653 | ||
| 484 | const existingBinding = toSourceCredentialBinding( | 1654 | const binding = toSourceAuthBinding(bindingRow as unknown as Record<string, unknown>); |
| 485 | existing, | ||
| 486 | ); | ||
| 487 | |||
| 488 | if ( | 1655 | if ( |
| 489 | !canAccessSourceCredentialBinding(existingBinding, { | 1656 | binding.workspaceId !== args.workspaceId |
| 490 | workspaceId: args.workspaceId, | 1657 | && !(binding.workspaceId === null && binding.organizationId === organizationId) |
| 491 | organizationId, | ||
| 492 | }) | ||
| 493 | ) { | 1658 | ) { |
| 494 | return { removed: false }; | 1659 | return { removed: false }; |
| 495 | } | 1660 | } |
| 496 | 1661 | ||
| 497 | await ctx.db.delete(existing._id); | 1662 | await ctx.db.delete(bindingRow._id); |
| 1663 | |||
| 1664 | const remainingBindings = await ctx.db | ||
| 1665 | .query("sourceAuthBindings") | ||
| 1666 | .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId)) | ||
| 1667 | .collect(); | ||
| 1668 | |||
| 1669 | const connectionRow = await ctx.db | ||
| 1670 | .query("authConnections") | ||
| 1671 | .withIndex("by_domainId", (q) => q.eq("id", binding.connectionId)) | ||
| 1672 | .unique(); | ||
| 1673 | |||
| 1674 | if (remainingBindings.length === 0) { | ||
| 1675 | const materialRow = await ctx.db | ||
| 1676 | .query("authMaterials") | ||
| 1677 | .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId)) | ||
| 1678 | .unique(); | ||
| 1679 | if (materialRow) { | ||
| 1680 | await ctx.db.delete(materialRow._id); | ||
| 1681 | } | ||
| 1682 | |||
| 1683 | const oauthRow = await ctx.db | ||
| 1684 | .query("oauthState") | ||
| 1685 | .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId)) | ||
| 1686 | .unique(); | ||
| 1687 | if (oauthRow) { | ||
| 1688 | await ctx.db.delete(oauthRow._id); | ||
| 1689 | } | ||
| 1690 | |||
| 1691 | if (connectionRow) { | ||
| 1692 | await ctx.db.delete(connectionRow._id); | ||
| 1693 | } | ||
| 1694 | } | ||
| 1695 | |||
| 1696 | if (connectionRow) { | ||
| 1697 | const connection = toAuthConnection(connectionRow as unknown as Record<string, unknown>); | ||
| 1698 | await insertAuthAuditEvent(ctx, { | ||
| 1699 | organizationId: connection.organizationId, | ||
| 1700 | connectionId: connection.id, | ||
| 1701 | sourceId: binding.sourceId, | ||
| 1702 | eventType: "unbound", | ||
| 1703 | actorType: "system", | ||
| 1704 | outcome: "success", | ||
| 1705 | }); | ||
| 1706 | |||
| 1707 | if (remainingBindings.length === 0) { | ||
| 1708 | await insertAuthAuditEvent(ctx, { | ||
| 1709 | organizationId: connection.organizationId, | ||
| 1710 | connectionId: connection.id, | ||
| 1711 | sourceId: null, | ||
| 1712 | eventType: "deleted", | ||
| 1713 | actorType: "system", | ||
| 1714 | outcome: "success", | ||
| 1715 | }); | ||
| 1716 | } | ||
| 1717 | } | ||
| 498 | 1718 | ||
| 499 | return { removed: true }; | 1719 | return { removed: true }; |
| 500 | }, | 1720 | }, |
| 501 | }); | 1721 | }); |
| 1722 | |||
| 1723 | export const removeSourceAuthBindings = internalMutation({ | ||
| 1724 | args: { | ||
| 1725 | sourceId: v.string(), | ||
| 1726 | }, | ||
| 1727 | handler: async (ctx, args): Promise<void> => { | ||
| 1728 | const rows = await ctx.db | ||
| 1729 | .query("sourceAuthBindings") | ||
| 1730 | .withIndex("by_sourceId", (q) => q.eq("sourceId", args.sourceId)) | ||
| 1731 | .collect(); | ||
| 1732 | |||
| 1733 | const bindingIds = rows.map((row) => (row as unknown as { id: string }).id); | ||
| 1734 | |||
| 1735 | for (const bindingId of bindingIds) { | ||
| 1736 | const row = await ctx.db | ||
| 1737 | .query("sourceAuthBindings") | ||
| 1738 | .withIndex("by_domainId", (q) => q.eq("id", bindingId)) | ||
| 1739 | .unique(); | ||
| 1740 | if (!row) { | ||
| 1741 | continue; | ||
| 1742 | } | ||
| 1743 | |||
| 1744 | const binding = toSourceAuthBinding(row as unknown as Record<string, unknown>); | ||
| 1745 | await ctx.db.delete(row._id); | ||
| 1746 | |||
| 1747 | const remaining = await ctx.db | ||
| 1748 | .query("sourceAuthBindings") | ||
| 1749 | .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId)) | ||
| 1750 | .collect(); | ||
| 1751 | if (remaining.length > 0) { | ||
| 1752 | continue; | ||
| 1753 | } | ||
| 1754 | |||
| 1755 | const material = await ctx.db | ||
| 1756 | .query("authMaterials") | ||
| 1757 | .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId)) | ||
| 1758 | .unique(); | ||
| 1759 | if (material) { | ||
| 1760 | await ctx.db.delete(material._id); | ||
| 1761 | } | ||
| 1762 | |||
| 1763 | const oauth = await ctx.db | ||
| 1764 | .query("oauthState") | ||
| 1765 | .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId)) | ||
| 1766 | .unique(); | ||
| 1767 | if (oauth) { | ||
| 1768 | await ctx.db.delete(oauth._id); | ||
| 1769 | } | ||
| 1770 | |||
| 1771 | const connection = await ctx.db | ||
| 1772 | .query("authConnections") | ||
| 1773 | .withIndex("by_domainId", (q) => q.eq("id", binding.connectionId)) | ||
| 1774 | .unique(); | ||
| 1775 | if (connection) { | ||
| 1776 | await ctx.db.delete(connection._id); | ||
| 1777 | } | ||
| 1778 | } | ||
| 1779 | }, | ||
| 1780 | }); | ||
| @@ -313,42 +313,6 @@ const parseSourceConfig = (configJson: string): Record<string, unknown> => { · 312 unmodified lines | |||
| 313 | } | 313 | } |
| 314 | }; | 314 | }; |
| 315 | 315 | ||
| 316 | const collectAuthHeadersFromSourceConfig = (config: Record<string, unknown>): Record<string, string> => { | ||
| 317 | const headers: Record<string, string> = {}; | ||
| 318 | |||
| 319 | const auth = config.auth; | ||
| 320 | if (!isUnknownRecord(auth)) { | ||
| 321 | return headers; | ||
| 322 | } | ||
| 323 | |||
| 324 | const authType = normalizeString(auth.type)?.toLowerCase(); | ||
| 325 | |||
| 326 | if (authType === "bearer") { | ||
| 327 | const token = normalizeString(auth.token) ?? normalizeString(auth.value); | ||
| 328 | if (token) { | ||
| 329 | headers.Authorization = `Bearer ${token}`; | ||
| 330 | } | ||
| 331 | } | ||
| 332 | |||
| 333 | if (authType === "apikey" || authType === "api_key") { | ||
| 334 | const value = normalizeString(auth.value) ?? normalizeString(auth.token); | ||
| 335 | if (value) { | ||
| 336 | const headerName = normalizeString(auth.header) ?? "Authorization"; | ||
| 337 | headers[headerName] = value; | ||
| 338 | } | ||
| 339 | } | ||
| 340 | |||
| 341 | if (authType === "basic") { | ||
| 342 | const username = normalizeString(auth.username); | ||
| 343 | const password = normalizeString(auth.password); | ||
| 344 | if (username && password) { | ||
| 345 | headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; | ||
| 346 | } | ||
| 347 | } | ||
| 348 | |||
| 349 | return headers; | ||
| 350 | }; | ||
| 351 | |||
| 352 | const collectConfiguredHeadersFromSourceConfig = (config: Record<string, unknown>): Record<string, string> => { | 316 | const collectConfiguredHeadersFromSourceConfig = (config: Record<string, unknown>): Record<string, string> => { |
| 353 | const headers: Record<string, string> = {}; | 317 | const headers: Record<string, string> = {}; |
| 354 | const configuredHeaders = config.headers; | 318 | const configuredHeaders = config.headers; |
| @@ -373,7 +337,6 @@ const collectGraphqlHeadersFromSourceConfig = (source: Source): Record<string, s · 18 unmodified lines | |||
| 373 | return { | 337 | return { |
| 374 | "content-type": "application/json", | 338 | "content-type": "application/json", |
| 375 | ...collectConfiguredHeadersFromSourceConfig(config), | 339 | ...collectConfiguredHeadersFromSourceConfig(config), |
| 376 | ...collectAuthHeadersFromSourceConfig(config), | ||
| 377 | }; | 340 | }; |
| 378 | }; | 341 | }; |
| 379 | 342 | ||
| @@ -381,7 +344,6 @@ const collectMcpHeadersFromSourceConfig = (source: Source): Record<string, strin · 1 unmodified line | |||
| 381 | const config = parseSourceConfig(source.configJson); | 344 | const config = parseSourceConfig(source.configJson); |
| 382 | return { | 345 | return { |
| 383 | ...collectConfiguredHeadersFromSourceConfig(config), | 346 | ...collectConfiguredHeadersFromSourceConfig(config), |
| 384 | ...collectAuthHeadersFromSourceConfig(config), | ||
| 385 | }; | 347 | }; |
| 386 | }; | 348 | }; |
| 387 | 349 | ||
| @@ -32,6 +32,36 @@ const parseConfigJson = (configJson: string | undefined): Record<string, unknown · 31 unmodified lines | |||
| 32 | } | 32 | } |
| 33 | }; | 33 | }; |
| 34 | 34 | ||
| 35 | const protectedHeaderNames = new Set([ | ||
| 36 | "authorization", | ||
| 37 | "proxy-authorization", | ||
| 38 | "x-api-key", | ||
| 39 | ]); | ||
| 40 | |||
| 41 | const validateSourceConfigSecurity = (config: Record<string, unknown>): void => { | ||
| 42 | if (Object.prototype.hasOwnProperty.call(config, "auth")) { | ||
| 43 | throw new Error("Source config must not include auth; configure credentials via connections"); | ||
| 44 | } | ||
| 45 | |||
| 46 | if (Object.prototype.hasOwnProperty.call(config, "useCredentialedFetch")) { | ||
| 47 | throw new Error("Source config must not include useCredentialedFetch"); | ||
| 48 | } | ||
| 49 | |||
| 50 | const headers = config.headers; | ||
| 51 | if (!isRecord(headers)) { | ||
| 52 | return; | ||
| 53 | } | ||
| 54 | |||
| 55 | for (const headerName of Object.keys(headers)) { | ||
| 56 | const normalized = headerName.trim().toLowerCase(); | ||
| 57 | if (protectedHeaderNames.has(normalized)) { | ||
| 58 | throw new Error( | ||
| 59 | `Source config headers must not include '${headerName}'; use credential connections instead`, | ||
| 60 | ); | ||
| 61 | } | ||
| 62 | } | ||
| 63 | }; | ||
| 64 | |||
| 35 | const normalizeHttpUrl = (value: string): string | null => { | 65 | const normalizeHttpUrl = (value: string): string | null => { |
| 36 | const trimmed = value.trim(); | 66 | const trimmed = value.trim(); |
| 37 | if (trimmed.length === 0) { | 67 | if (trimmed.length === 0) { |
| @@ -158,16 +188,25 @@ export const upsertSourceRecord = internalMutation({ · 120 unmodified lines | |||
| 158 | const payload = args.payload as UpsertSourcePayload; | 188 | const payload = args.payload as UpsertSourcePayload; |
| 159 | 189 | ||
| 160 | const configJson = (() => { | 190 | const configJson = (() => { |
| 191 | const parsedConfig = parseConfigJson(payload.configJson); | ||
| 192 | validateSourceConfigSecurity(parsedConfig); | ||
| 193 | |||
| 161 | if (payload.kind !== "openapi") { | 194 | if (payload.kind !== "openapi") { |
| 162 | return payload.configJson ?? "{}"; | 195 | return JSON.stringify(parsedConfig); |
| 163 | } | 196 | } |
| 164 | 197 | ||
| 165 | const configuredBaseUrl = requireHttpUrl( | 198 | const configuredBaseUrl = requireHttpUrl( |
| 166 | parseConfigJson(payload.configJson).baseUrl, | 199 | parsedConfig.baseUrl, |
| 167 | "OpenAPI source requires configJson.baseUrl", | 200 | "OpenAPI source requires configJson.baseUrl", |
| 168 | ); | 201 | ); |
| 169 | 202 | ||
| 170 | return buildOpenApiConfigJson(payload, configuredBaseUrl); | 203 | return buildOpenApiConfigJson( |
| 204 | { | ||
| 205 | ...payload, | ||
| 206 | configJson: JSON.stringify(parsedConfig), | ||
| 207 | }, | ||
| 208 | configuredBaseUrl, | ||
| 209 | ); | ||
| 171 | })(); | 210 | })(); |
| 172 | 211 | ||
| 173 | const source = decodeSource({ | 212 | const source = decodeSource({ |
| @@ -216,6 +255,7 @@ export const upsertSource = internalAction({ · 42 unmodified lines | |||
| 216 | 255 | ||
| 217 | if (payload.kind === "openapi") { | 256 | if (payload.kind === "openapi") { |
| 218 | const config = parseConfigJson(payload.configJson); | 257 | const config = parseConfigJson(payload.configJson); |
| 258 | validateSourceConfigSecurity(config); | ||
| 219 | let resolvedBaseUrl = typeof config.baseUrl === "string" | 259 | let resolvedBaseUrl = typeof config.baseUrl === "string" |
| 220 | ? normalizeHttpUrl(config.baseUrl) | 260 | ? normalizeHttpUrl(config.baseUrl) |
| 221 | : null; | 261 | : null; |
| @@ -241,6 +281,8 @@ export const upsertSource = internalAction({ · 19 unmodified lines | |||
| 241 | ...payload, | 281 | ...payload, |
| 242 | configJson: buildOpenApiConfigJson(payload, baseUrl), | 282 | configJson: buildOpenApiConfigJson(payload, baseUrl), |
| 243 | }; | 283 | }; |
| 284 | } else { | ||
| 285 | validateSourceConfigSecurity(parseConfigJson(payload.configJson)); | ||
| 244 | } | 286 | } |
| 245 | 287 | ||
| 246 | const source = await ctx.runMutation(runtimeInternal.control_plane.sources.upsertSourceRecord, { | 288 | const source = await ctx.runMutation(runtimeInternal.control_plane.sources.upsertSourceRecord, { |
| @@ -342,6 +384,10 @@ export const removeSource = internalMutation({ · 95 unmodified lines | |||
| 342 | sourceId: args.sourceId, | 384 | sourceId: args.sourceId, |
| 343 | }); | 385 | }); |
| 344 | 386 | ||
| 387 | await ctx.runMutation(runtimeInternal.control_plane.credentials.removeSourceAuthBindings, { | ||
| 388 | sourceId: args.sourceId, | ||
| 389 | }); | ||
| 390 | |||
| 345 | await ctx.db.delete(existing._id); | 391 | await ctx.db.delete(existing._id); |
| 346 | 392 | ||
| 347 | return { removed: true }; | 393 | return { removed: true }; |
| @@ -3,6 +3,11 @@ const encryptedPrefix = "enc:v1:"; · 2 unmodified lines | |||
| 3 | const encoder = new TextEncoder(); | 3 | const encoder = new TextEncoder(); |
| 4 | const decoder = new TextDecoder(); | 4 | const decoder = new TextDecoder(); |
| 5 | 5 | ||
| 6 | type EncryptionKeyEntry = { | ||
| 7 | keyVersion: string; | ||
| 8 | passphrase: string; | ||
| 9 | }; | ||
| 10 | |||
| 6 | const bytesToBase64 = (bytes: Uint8Array): string => { | 11 | const bytesToBase64 = (bytes: Uint8Array): string => { |
| 7 | let binary = ""; | 12 | let binary = ""; |
| 8 | for (const byte of bytes) { | 13 | for (const byte of bytes) { |
| @@ -20,55 +25,139 @@ const base64ToBytes = (base64: string): Uint8Array => { · 11 unmodified lines | |||
| 20 | return bytes; | 25 | return bytes; |
| 21 | }; | 26 | }; |
| 22 | 27 | ||
| 23 | const resolveEncryptionPassphrase = (): string => { | 28 | const normalizeKeyVersion = (value: string): string => { |
| 24 | const passphrase = process.env.CREDENTIAL_ENCRYPTION_KEY?.trim() ?? ""; | 29 | const trimmed = value.trim(); |
| 25 | if (passphrase.length === 0) { | 30 | return trimmed.length > 0 ? trimmed : "v1"; |
| 26 | throw new Error("Encrypted credential storage requires CREDENTIAL_ENCRYPTION_KEY"); | 31 | }; |
| 32 | |||
| 33 | const parseEncryptionKeyEntries = (): Array<EncryptionKeyEntry> => { | ||
| 34 | const configured = process.env.CREDENTIAL_ENCRYPTION_KEYS?.trim() ?? ""; | ||
| 35 | if (configured.length > 0) { | ||
| 36 | const entries = configured | ||
| 37 | .split(",") | ||
| 38 | .map((part) => part.trim()) | ||
| 39 | .filter((part) => part.length > 0) | ||
| 40 | .flatMap((part) => { | ||
| 41 | const separatorIndex = part.indexOf("=") >= 0 | ||
| 42 | ? part.indexOf("=") | ||
| 43 | : part.indexOf(":"); | ||
| 44 | if (separatorIndex <= 0) { | ||
| 45 | return []; | ||
| 46 | } | ||
| 47 | |||
| 48 | const keyVersion = normalizeKeyVersion(part.slice(0, separatorIndex)); | ||
| 49 | const passphrase = part.slice(separatorIndex + 1).trim(); | ||
| 50 | if (passphrase.length === 0) { | ||
| 51 | return []; | ||
| 52 | } | ||
| 53 | |||
| 54 | return [{ keyVersion, passphrase }]; | ||
| 55 | }); | ||
| 56 | |||
| 57 | if (entries.length > 0) { | ||
| 58 | return entries; | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | const fallback = process.env.CREDENTIAL_ENCRYPTION_KEY?.trim() ?? ""; | ||
| 63 | if (fallback.length === 0) { | ||
| 64 | throw new Error( | ||
| 65 | "Encrypted credential storage requires CREDENTIAL_ENCRYPTION_KEY or CREDENTIAL_ENCRYPTION_KEYS", | ||
| 66 | ); | ||
| 27 | } | 67 | } |
| 28 | 68 | ||
| 29 | return passphrase; | 69 | return [{ keyVersion: "v1", passphrase: fallback }]; |
| 30 | }; | 70 | }; |
| 31 | 71 | ||
| 32 | const deriveAesKey = async (): Promise<CryptoKey> => { | 72 | const resolveEncryptionKeyEntries = (): Array<EncryptionKeyEntry> => |
| 33 | const passphraseBytes = encoder.encode(resolveEncryptionPassphrase()); | 73 | parseEncryptionKeyEntries(); |
| 34 | const hashBuffer = await crypto.subtle.digest("SHA-256", passphraseBytes); | 74 | |
| 35 | return await crypto.subtle.importKey( | 75 | const keyCache = new Map<string, Promise<CryptoKey>>(); |
| 36 | "raw", | 76 | |
| 37 | hashBuffer, | 77 | const deriveAesKey = async (passphrase: string): Promise<CryptoKey> => { |
| 38 | { | 78 | const cacheKey = passphrase; |
| 39 | name: "AES-GCM", | 79 | const existing = keyCache.get(cacheKey); |
| 40 | }, | 80 | if (existing) { |
| 41 | false, | 81 | return await existing; |
| 42 | ["encrypt", "decrypt"], | 82 | } |
| 43 | ); | 83 | |
| 84 | const keyPromise = (async () => { | ||
| 85 | const passphraseBytes = encoder.encode(passphrase); | ||
| 86 | const hashBuffer = await crypto.subtle.digest("SHA-256", passphraseBytes); | ||
| 87 | return await crypto.subtle.importKey( | ||
| 88 | "raw", | ||
| 89 | hashBuffer, | ||
| 90 | { | ||
| 91 | name: "AES-GCM", | ||
| 92 | }, | ||
| 93 | false, | ||
| 94 | ["encrypt", "decrypt"], | ||
| 95 | ); | ||
| 96 | })(); | ||
| 97 | |||
| 98 | keyCache.set(cacheKey, keyPromise); | ||
| 99 | return await keyPromise; | ||
| 44 | }; | 100 | }; |
| 45 | 101 | ||
| 46 | const parseEncryptedValue = (value: string): { iv: Uint8Array; cipher: Uint8Array } | null => { | 102 | const parseEncryptedValue = ( |
| 103 | value: string, | ||
| 104 | ): { keyVersion: string | null; iv: Uint8Array; cipher: Uint8Array } | null => { | ||
| 47 | if (!value.startsWith(encryptedPrefix)) { | 105 | if (!value.startsWith(encryptedPrefix)) { |
| 48 | return null; | 106 | return null; |
| 49 | } | 107 | } |
| 50 | 108 | ||
| 51 | const payload = value.slice(encryptedPrefix.length); | 109 | const payload = value.slice(encryptedPrefix.length); |
| 52 | const parts = payload.split(":"); | 110 | const parts = payload.split(":"); |
| 53 | if (parts.length !== 2) { | ||
| 54 | return null; | ||
| 55 | } | ||
| 56 | 111 | ||
| 57 | try { | 112 | try { |
| 58 | return { | 113 | if (parts.length === 2) { |
| 59 | iv: base64ToBytes(parts[0] ?? ""), | 114 | return { |
| 60 | cipher: base64ToBytes(parts[1] ?? ""), | 115 | keyVersion: null, |
| 61 | }; | 116 | iv: base64ToBytes(parts[0] ?? ""), |
| 117 | cipher: base64ToBytes(parts[1] ?? ""), | ||
| 118 | }; | ||
| 119 | } | ||
| 120 | |||
| 121 | if (parts.length === 3) { | ||
| 122 | return { | ||
| 123 | keyVersion: normalizeKeyVersion(parts[0] ?? ""), | ||
| 124 | iv: base64ToBytes(parts[1] ?? ""), | ||
| 125 | cipher: base64ToBytes(parts[2] ?? ""), | ||
| 126 | }; | ||
| 127 | } | ||
| 128 | |||
| 129 | return null; | ||
| 62 | } catch { | 130 | } catch { |
| 63 | return null; | 131 | return null; |
| 64 | } | 132 | } |
| 65 | }; | 133 | }; |
| 66 | 134 | ||
| 135 | const resolveKeyEntryForDecrypt = ( | ||
| 136 | keyVersion: string | null, | ||
| 137 | ): EncryptionKeyEntry => { | ||
| 138 | const entries = resolveEncryptionKeyEntries(); | ||
| 139 | |||
| 140 | if (!keyVersion) { | ||
| 141 | return entries[0]!; | ||
| 142 | } | ||
| 143 | |||
| 144 | const matched = entries.find((entry) => entry.keyVersion === keyVersion); | ||
| 145 | if (matched) { | ||
| 146 | return matched; | ||
| 147 | } | ||
| 148 | |||
| 149 | throw new Error(`Credential encryption key version is not configured: ${keyVersion}`); | ||
| 150 | }; | ||
| 151 | |||
| 152 | export const currentCredentialEncryptionKeyVersion = (): string => | ||
| 153 | resolveEncryptionKeyEntries()[0]!.keyVersion; | ||
| 154 | |||
| 67 | export const isEncryptedSecret = (value: string): boolean => | 155 | export const isEncryptedSecret = (value: string): boolean => |
| 68 | value.startsWith(encryptedPrefix); | 156 | value.startsWith(encryptedPrefix); |
| 69 | 157 | ||
| 70 | export const encryptSecretValue = async (plainText: string): Promise<string> => { | 158 | export const encryptSecretValue = async (plainText: string): Promise<string> => { |
| 71 | const key = await deriveAesKey(); | 159 | const activeKey = resolveEncryptionKeyEntries()[0]!; |
| 160 | const key = await deriveAesKey(activeKey.passphrase); | ||
| 72 | const iv = crypto.getRandomValues(new Uint8Array(12)); | 161 | const iv = crypto.getRandomValues(new Uint8Array(12)); |
| 73 | const encrypted = await crypto.subtle.encrypt( | 162 | const encrypted = await crypto.subtle.encrypt( |
| 74 | { | 163 | { |
| @@ -79,7 +168,7 @@ export const encryptSecretValue = async (plainText: string): Promise<string> => · 4 unmodified lines | |||
| 79 | encoder.encode(plainText), | 168 | encoder.encode(plainText), |
| 80 | ); | 169 | ); |
| 81 | 170 | ||
| 82 | return `${encryptedPrefix}${bytesToBase64(iv)}:${bytesToBase64(new Uint8Array(encrypted))}`; | 171 | return `${encryptedPrefix}${activeKey.keyVersion}:${bytesToBase64(iv)}:${bytesToBase64(new Uint8Array(encrypted))}`; |
| 83 | }; | 172 | }; |
| 84 | 173 | ||
| 85 | export const decryptSecretValue = async (storedValue: string): Promise<string> => { | 174 | export const decryptSecretValue = async (storedValue: string): Promise<string> => { |
| @@ -88,7 +177,8 @@ export const decryptSecretValue = async (storedValue: string): Promise<string> = · 2 unmodified lines | |||
| 88 | throw new Error("Credential secret is not encrypted"); | 177 | throw new Error("Credential secret is not encrypted"); |
| 89 | } | 178 | } |
| 90 | 179 | ||
| 91 | const key = await deriveAesKey(); | 180 | const keyEntry = resolveKeyEntryForDecrypt(parsed.keyVersion); |
| 181 | const key = await deriveAesKey(keyEntry.passphrase); | ||
| 92 | const decrypted = await crypto.subtle.decrypt( | 182 | const decrypted = await crypto.subtle.decrypt( |
| 93 | { | 183 | { |
| 94 | name: "AES-GCM", | 184 | name: "AES-GCM", |
| @@ -52,6 +52,7 @@ describe("Convex executor and control-plane", () => { · 51 unmodified lines | |||
| 52 | runUrl: process.env.CLOUDFLARE_SANDBOX_RUN_URL, | 52 | runUrl: process.env.CLOUDFLARE_SANDBOX_RUN_URL, |
| 53 | authToken: process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN, | 53 | authToken: process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN, |
| 54 | callbackUrl: process.env.CLOUDFLARE_SANDBOX_CALLBACK_URL, | 54 | callbackUrl: process.env.CLOUDFLARE_SANDBOX_CALLBACK_URL, |
| 55 | callbackSecret: process.env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET, | ||
| 55 | }; | 56 | }; |
| 56 | const originalFetch = globalThis.fetch; | 57 | const originalFetch = globalThis.fetch; |
| 57 | 58 | ||
| @@ -59,6 +60,7 @@ describe("Convex executor and control-plane", () => { · 1 unmodified line | |||
| 59 | process.env.CLOUDFLARE_SANDBOX_RUN_URL = "https://sandbox.local/run"; | 60 | process.env.CLOUDFLARE_SANDBOX_RUN_URL = "https://sandbox.local/run"; |
| 60 | process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN = "sandbox-token"; | 61 | process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN = "sandbox-token"; |
| 61 | process.env.CLOUDFLARE_SANDBOX_CALLBACK_URL = "https://convex.local/callback"; | 62 | process.env.CLOUDFLARE_SANDBOX_CALLBACK_URL = "https://convex.local/callback"; |
| 63 | process.env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET = "sandbox-callback-secret"; | ||
| 62 | 64 | ||
| 63 | globalThis.fetch = (async ( | 65 | globalThis.fetch = (async ( |
| 64 | input: RequestInfo | URL, | 66 | input: RequestInfo | URL, |
| @@ -115,6 +117,12 @@ describe("Convex executor and control-plane", () => { · 50 unmodified lines | |||
| 115 | } else { | 117 | } else { |
| 116 | process.env.CLOUDFLARE_SANDBOX_CALLBACK_URL = originalEnv.callbackUrl; | 118 | process.env.CLOUDFLARE_SANDBOX_CALLBACK_URL = originalEnv.callbackUrl; |
| 117 | } | 119 | } |
| 120 | |||
| 121 | if (originalEnv.callbackSecret === undefined) { | ||
| 122 | delete process.env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET; | ||
| 123 | } else { | ||
| 124 | process.env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET = originalEnv.callbackSecret; | ||
| 125 | } | ||
| 118 | } | 126 | } |
| 119 | }), | 127 | }), |
| 120 | ); | 128 | ); |
| @@ -195,7 +203,7 @@ describe("Convex executor and control-plane", () => { · 74 unmodified lines | |||
| 195 | id: "credential_binding_1", | 203 | id: "credential_binding_1", |
| 196 | credentialId: "cred_1", | 204 | credentialId: "cred_1", |
| 197 | scopeType: "workspace", | 205 | scopeType: "workspace", |
| 198 | sourceKey: "github", | 206 | sourceKey: "source:github", |
| 199 | provider: "bearer", | 207 | provider: "bearer", |
| 200 | secretRef: "secret://github/token", | 208 | secretRef: "secret://github/token", |
| 201 | accountId: null, | 209 | accountId: null, |
| @@ -1383,6 +1391,286 @@ describe("Convex executor and control-plane", () => { · 1181 unmodified lines | |||
| 1383 | }), | 1391 | }), |
| 1384 | ); | 1392 | ); |
| 1385 | 1393 | ||
| 1394 | it.effect("retries OpenAPI invocation once after oauth invalid_token", () => | ||
| 1395 | Effect.gen(function* () { | ||
| 1396 | const t = setup(); | ||
| 1397 | const originalFetch = globalThis.fetch; | ||
| 1398 | |||
| 1399 | yield* Effect.tryPromise(() => | ||
| 1400 | ensureWorkspace(t, "ws_oauth_runtime", "org_oauth_runtime") | ||
| 1401 | ); | ||
| 1402 | |||
| 1403 | const observedAuthorizationHeaders: Array<string | null> = []; | ||
| 1404 | let refreshCallCount = 0; | ||
| 1405 | |||
| 1406 | globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { | ||
| 1407 | const url = | ||
| 1408 | typeof input === "string" | ||
| 1409 | ? input | ||
| 1410 | : input instanceof URL | ||
| 1411 | ? input.toString() | ||
| 1412 | : input.url; | ||
| 1413 | |||
| 1414 | if (url === "https://oauth.example.com/token") { | ||
| 1415 | refreshCallCount += 1; | ||
| 1416 | const bodyText = typeof init?.body === "string" ? init.body : ""; | ||
| 1417 | if (!bodyText.includes("grant_type=refresh_token")) { | ||
| 1418 | return new Response( | ||
| 1419 | JSON.stringify({ error: "invalid_request" }), | ||
| 1420 | { | ||
| 1421 | status: 400, | ||
| 1422 | headers: { | ||
| 1423 | "content-type": "application/json", | ||
| 1424 | }, | ||
| 1425 | }, | ||
| 1426 | ); | ||
| 1427 | } | ||
| 1428 | |||
| 1429 | return new Response( | ||
| 1430 | JSON.stringify({ | ||
| 1431 | access_token: "fresh_token", | ||
| 1432 | token_type: "Bearer", | ||
| 1433 | expires_in: 3600, | ||
| 1434 | }), | ||
| 1435 | { | ||
| 1436 | status: 200, | ||
| 1437 | headers: { | ||
| 1438 | "content-type": "application/json", | ||
| 1439 | }, | ||
| 1440 | }, | ||
| 1441 | ); | ||
| 1442 | } | ||
| 1443 | |||
| 1444 | if (url === "https://api.example.com/weather") { | ||
| 1445 | const authHeader = new Headers(init?.headers).get("authorization"); | ||
| 1446 | observedAuthorizationHeaders.push(authHeader); | ||
| 1447 | |||
| 1448 | if (authHeader === "Bearer stale_token") { | ||
| 1449 | return new Response( | ||
| 1450 | JSON.stringify({ | ||
| 1451 | error: "invalid_token", | ||
| 1452 | }), | ||
| 1453 | { | ||
| 1454 | status: 401, | ||
| 1455 | headers: { | ||
| 1456 | "content-type": "application/json", | ||
| 1457 | "www-authenticate": 'Bearer error="invalid_token"', | ||
| 1458 | }, | ||
| 1459 | }, | ||
| 1460 | ); | ||
| 1461 | } | ||
| 1462 | |||
| 1463 | if (authHeader === "Bearer fresh_token") { | ||
| 1464 | return new Response( | ||
| 1465 | JSON.stringify({ | ||
| 1466 | temperature: 72, | ||
| 1467 | }), | ||
| 1468 | { | ||
| 1469 | status: 200, | ||
| 1470 | headers: { | ||
| 1471 | "content-type": "application/json", | ||
| 1472 | }, | ||
| 1473 | }, | ||
| 1474 | ); | ||
| 1475 | } | ||
| 1476 | |||
| 1477 | return new Response( | ||
| 1478 | JSON.stringify({ | ||
| 1479 | error: "unauthorized", | ||
| 1480 | }), | ||
| 1481 | { | ||
| 1482 | status: 401, | ||
| 1483 | headers: { | ||
| 1484 | "content-type": "application/json", | ||
| 1485 | }, | ||
| 1486 | }, | ||
| 1487 | ); | ||
| 1488 | } | ||
| 1489 | |||
| 1490 | throw new Error(`Unexpected URL: ${url}`); | ||
| 1491 | }) as unknown as typeof fetch; | ||
| 1492 | |||
| 1493 | try { | ||
| 1494 | const sourceId = "src_openapi_oauth_runtime_1"; | ||
| 1495 | const workspaceId = "ws_oauth_runtime"; | ||
| 1496 | const accountId = "acct_oauth_runtime"; | ||
| 1497 | const runId = "run_oauth_runtime_1"; | ||
| 1498 | const callId = "call_oauth_runtime_1"; | ||
| 1499 | const now = Date.now(); | ||
| 1500 | |||
| 1501 | yield* Effect.tryPromise(() => | ||
| 1502 | t.run(async (ctx) => { | ||
| 1503 | await ctx.db.insert("sources", { | ||
| 1504 | id: sourceId, | ||
| 1505 | workspaceId, | ||
| 1506 | name: "Weather API", | ||
| 1507 | kind: "openapi", | ||
| 1508 | endpoint: "https://api.example.com/openapi.json", | ||
| 1509 | enabled: true, | ||
| 1510 | configJson: JSON.stringify({ | ||
| 1511 | baseUrl: "https://api.example.com", | ||
| 1512 | }), | ||
| 1513 | status: "draft", | ||
| 1514 | sourceHash: null, | ||
| 1515 | lastError: null, | ||
| 1516 | createdAt: now, | ||
| 1517 | updatedAt: now, | ||
| 1518 | }); | ||
| 1519 | }), | ||
| 1520 | ); | ||
| 1521 | |||
| 1522 | const manifest = yield* extractOpenApiManifest("Weather API", { | ||
| 1523 | openapi: "3.0.0", | ||
| 1524 | info: { | ||
| 1525 | title: "Weather API", | ||
| 1526 | version: "1.0.0", | ||
| 1527 | }, | ||
| 1528 | paths: { | ||
| 1529 | "/weather": { | ||
| 1530 | get: { | ||
| 1531 | operationId: "getWeather", | ||
| 1532 | responses: { | ||
| 1533 | "200": { | ||
| 1534 | description: "ok", | ||
| 1535 | }, | ||
| 1536 | }, | ||
| 1537 | }, | ||
| 1538 | }, | ||
| 1539 | }, | ||
| 1540 | }); | ||
| 1541 | |||
| 1542 | const artifactMeta = (yield* Effect.tryPromise(() => | ||
| 1543 | t.mutation(runtimeInternal.control_plane.tool_registry.upsertArtifactMeta, { | ||
| 1544 | protocol: "openapi", | ||
| 1545 | contentHash: manifest.sourceHash, | ||
| 1546 | extractorVersion: "openapi_v2", | ||
| 1547 | toolCount: manifest.tools.length, | ||
| 1548 | refHintTableJson: manifest.refHintTable ? JSON.stringify(manifest.refHintTable) : null, | ||
| 1549 | }), | ||
| 1550 | )) as { | ||
| 1551 | artifactId: string; | ||
| 1552 | created: boolean; | ||
| 1553 | }; | ||
| 1554 | |||
| 1555 | yield* Effect.tryPromise(() => | ||
| 1556 | t.mutation(runtimeInternal.control_plane.tool_registry.putArtifactToolsBatch, { | ||
| 1557 | artifactId: artifactMeta.artifactId, | ||
| 1558 | protocol: "openapi", | ||
| 1559 | insertOnly: true, | ||
| 1560 | tools: manifest.tools.map((tool) => ({ | ||
| 1561 | toolId: tool.toolId, | ||
| 1562 | name: tool.name, | ||
| 1563 | description: tool.description, | ||
| 1564 | canonicalPath: `${tool.method.toUpperCase()} ${tool.path}`, | ||
| 1565 | operationHash: tool.operationHash, | ||
| 1566 | invocationJson: JSON.stringify(tool.invocation), | ||
| 1567 | inputSchemaJson: tool.typing?.inputSchemaJson ?? null, | ||
| 1568 | outputSchemaJson: tool.typing?.outputSchemaJson ?? null, | ||
| 1569 | metadataJson: JSON.stringify({ | ||
| 1570 | method: tool.method, | ||
| 1571 | path: tool.path, | ||
| 1572 | }), | ||
| 1573 | })), | ||
| 1574 | }), | ||
| 1575 | ); | ||
| 1576 | |||
| 1577 | yield* Effect.tryPromise(() => | ||
| 1578 | t.mutation(runtimeInternal.control_plane.tool_registry.bindSourceToArtifact, { | ||
| 1579 | workspaceId, | ||
| 1580 | sourceId, | ||
| 1581 | artifactId: artifactMeta.artifactId, | ||
| 1582 | }), | ||
| 1583 | ); | ||
| 1584 | |||
| 1585 | const namespace = "weather_api_oauth_runtime_1"; | ||
| 1586 | |||
| 1587 | yield* Effect.tryPromise(() => | ||
| 1588 | t.mutation(runtimeInternal.control_plane.tool_registry.replaceWorkspaceSourceToolIndex, { | ||
| 1589 | workspaceId, | ||
| 1590 | sourceId, | ||
| 1591 | sourceName: "Weather API", | ||
| 1592 | sourceKind: "openapi", | ||
| 1593 | artifactId: artifactMeta.artifactId, | ||
| 1594 | namespace, | ||
| 1595 | refHintTableJson: manifest.refHintTable ? JSON.stringify(manifest.refHintTable) : null, | ||
| 1596 | rows: manifest.tools.map((tool) => ({ | ||
| 1597 | toolId: tool.toolId, | ||
| 1598 | protocol: "openapi", | ||
| 1599 | method: tool.method, | ||
| 1600 | path: `${namespace}.${tool.toolId}`, | ||
| 1601 | name: tool.name, | ||
| 1602 | description: tool.description, | ||
| 1603 | searchText: `weather api ${tool.name.toLowerCase()} ${tool.path.toLowerCase()} ${tool.method.toLowerCase()}`, | ||
| 1604 | operationHash: tool.operationHash, | ||
| 1605 | approvalMode: "auto", | ||
| 1606 | status: "active", | ||
| 1607 | })), | ||
| 1608 | }), | ||
| 1609 | ); | ||
| 1610 | |||
| 1611 | yield* Effect.tryPromise(() => | ||
| 1612 | t.action(api.controlPlane.upsertCredentialBinding, { | ||
| 1613 | workspaceId, | ||
| 1614 | payload: { | ||
| 1615 | id: "credential_binding_oauth_runtime_1", | ||
| 1616 | credentialId: "conn_oauth_runtime_1", | ||
| 1617 | scopeType: "workspace", | ||
| 1618 | sourceKey: `source:${sourceId}`, | ||
| 1619 | provider: "oauth2", | ||
| 1620 | secretRef: "stale_token", | ||
| 1621 | oauthRefreshToken: "refresh_token_1", | ||
| 1622 | oauthTokenEndpoint: "https://oauth.example.com/token", | ||
| 1623 | oauthClientId: "client_oauth_runtime_1", | ||
| 1624 | accountId: null, | ||
| 1625 | additionalHeadersJson: null, | ||
| 1626 | boundAuthFingerprint: null, | ||
| 1627 | }, | ||
| 1628 | }), | ||
| 1629 | ); | ||
| 1630 | |||
| 1631 | yield* Effect.tryPromise(() => | ||
| 1632 | t.mutation(runtimeInternal.task_runs.startTaskRun, { | ||
| 1633 | workspaceId, | ||
| 1634 | runId, | ||
| 1635 | accountId, | ||
| 1636 | }), | ||
| 1637 | ); | ||
| 1638 | |||
| 1639 | const callResult = (yield* Effect.tryPromise(() => | ||
| 1640 | t.action(runtimeInternal.source_tool_registry.invokeWorkspaceTool, { | ||
| 1641 | workspaceId, | ||
| 1642 | accountId, | ||
| 1643 | runId, | ||
| 1644 | callId, | ||
| 1645 | toolPath: `${namespace}.${manifest.tools[0]!.toolId}`, | ||
| 1646 | input: {}, | ||
| 1647 | }), | ||
| 1648 | )) as { | ||
| 1649 | ok: boolean; | ||
| 1650 | value?: { | ||
| 1651 | status?: number; | ||
| 1652 | body?: unknown; | ||
| 1653 | }; | ||
| 1654 | error?: string; | ||
| 1655 | }; | ||
| 1656 | |||
| 1657 | expect(callResult.ok).toBe(true); | ||
| 1658 | expect(callResult.value?.status).toBe(200); | ||
| 1659 | expect(callResult.value?.body).toEqual({ | ||
| 1660 | temperature: 72, | ||
| 1661 | }); | ||
| 1662 | |||
| 1663 | expect(refreshCallCount).toBe(1); | ||
| 1664 | expect(observedAuthorizationHeaders).toEqual([ | ||
| 1665 | "Bearer stale_token", | ||
| 1666 | "Bearer fresh_token", | ||
| 1667 | ]); | ||
| 1668 | } finally { | ||
| 1669 | globalThis.fetch = originalFetch; | ||
| 1670 | } | ||
| 1671 | }), | ||
| 1672 | ); | ||
| 1673 | |||
| 1386 | it.effect("persists approval state for runtime tool calls", () => | 1674 | it.effect("persists approval state for runtime tool calls", () => |
| 1387 | Effect.gen(function* () { | 1675 | Effect.gen(function* () { |
| 1388 | const t = setup(); | 1676 | const t = setup(); |
| @@ -1,13 +1,16 @@ | |||
| 1 | import { defineSchema, defineTable, type TablesFromSchemaDefinition } from "@executor-v2/confect"; | 1 | import { defineSchema, defineTable, type TablesFromSchemaDefinition } from "@executor-v2/confect"; |
| 2 | import { | 2 | import { |
| 3 | ApprovalSchema, | 3 | ApprovalSchema, |
| 4 | AuthAuditEventSchema, | ||
| 5 | AuthConnectionSchema, | ||
| 6 | AuthMaterialSchema, | ||
| 4 | EventEnvelopeSchema, | 7 | EventEnvelopeSchema, |
| 5 | OAuthTokenSchema, | 8 | OAuthStateSchema, |
| 6 | OrganizationMembershipSchema, | 9 | OrganizationMembershipSchema, |
| 7 | OrganizationSchema, | 10 | OrganizationSchema, |
| 8 | PolicySchema, | 11 | PolicySchema, |
| 9 | ProfileSchema, | 12 | ProfileSchema, |
| 10 | SourceCredentialBindingSchema, | 13 | SourceAuthBindingSchema, |
| 11 | SourceSchema, | 14 | SourceSchema, |
| 12 | StorageInstanceSchema, | 15 | StorageInstanceSchema, |
| 13 | SyncStateSchema, | 16 | SyncStateSchema, |
| @@ -181,18 +184,32 @@ export const executorConfectSchema = defineSchema({ · 167 unmodified lines | |||
| 181 | searchField: "searchText", | 184 | searchField: "searchText", |
| 182 | filterFields: ["workspaceId", "sourceId", "status", "namespace"], | 185 | filterFields: ["workspaceId", "sourceId", "status", "namespace"], |
| 183 | }), | 186 | }), |
| 184 | sourceCredentialBindings: defineTable(SourceCredentialBindingSchema) | 187 | authConnections: defineTable(AuthConnectionSchema) |
| 185 | .index("by_domainId", ["id"]) | 188 | .index("by_domainId", ["id"]) |
| 186 | .index("by_credentialId", ["credentialId"]) | ||
| 187 | .index("by_workspaceId", ["workspaceId"]) | ||
| 188 | .index("by_organizationId", ["organizationId"]) | 189 | .index("by_organizationId", ["organizationId"]) |
| 190 | .index("by_workspaceId", ["workspaceId"]) | ||
| 189 | .index("by_accountId", ["accountId"]) | 191 | .index("by_accountId", ["accountId"]) |
| 190 | .index("by_sourceKey", ["sourceKey"]), | 192 | .index("by_status", ["status"]) |
| 191 | oauthTokens: defineTable(OAuthTokenSchema) | 193 | .index("by_strategy", ["strategy"]), |
| 194 | sourceAuthBindings: defineTable(SourceAuthBindingSchema) | ||
| 192 | .index("by_domainId", ["id"]) | 195 | .index("by_domainId", ["id"]) |
| 196 | .index("by_sourceId", ["sourceId"]) | ||
| 197 | .index("by_connectionId", ["connectionId"]) | ||
| 193 | .index("by_workspaceId", ["workspaceId"]) | 198 | .index("by_workspaceId", ["workspaceId"]) |
| 194 | .index("by_organizationId", ["organizationId"]) | 199 | .index("by_organizationId", ["organizationId"]) |
| 195 | .index("by_accountId", ["accountId"]) | 200 | .index("by_accountId", ["accountId"]), |
| 201 | authMaterials: defineTable(AuthMaterialSchema) | ||
| 202 | .index("by_domainId", ["id"]) | ||
| 203 | .index("by_connectionId", ["connectionId"]), | ||
| 204 | oauthState: defineTable(OAuthStateSchema) | ||
| 205 | .index("by_domainId", ["id"]) | ||
| 206 | .index("by_connectionId", ["connectionId"]) | ||
| 207 | .index("by_expiresAt", ["expiresAt"]) | ||
| 208 | .index("by_reauthRequiredAt", ["reauthRequiredAt"]), | ||
| 209 | authAuditEvents: defineTable(AuthAuditEventSchema) | ||
| 210 | .index("by_domainId", ["id"]) | ||
| 211 | .index("by_organizationId", ["organizationId"]) | ||
| 212 | .index("by_connectionId", ["connectionId"]) | ||
| 196 | .index("by_sourceId", ["sourceId"]), | 213 | .index("by_sourceId", ["sourceId"]), |
| 197 | policies: defineTable(PolicySchema) | 214 | policies: defineTable(PolicySchema) |
| 198 | .index("by_domainId", ["id"]) | 215 | .index("by_domainId", ["id"]) |
| @@ -33,7 +33,7 @@ import * as ParseResult from "effect/ParseResult"; · 32 unmodified lines | |||
| 33 | import * as Schema from "effect/Schema"; | 33 | import * as Schema from "effect/Schema"; |
| 34 | 34 | ||
| 35 | import { internal } from "./_generated/api"; | 35 | import { internal } from "./_generated/api"; |
| 36 | import { internalMutation, type ActionCtx } from "./_generated/server"; | 36 | import { internalAction, internalMutation, type ActionCtx } from "./_generated/server"; |
| 37 | 37 | ||
| 38 | const runtimeInternal = internal as any; | 38 | const runtimeInternal = internal as any; |
| 39 | 39 | ||
| @@ -186,6 +186,20 @@ const normalizeHeaderString = (value: unknown): string | null => { · 146 unmodified lines | |||
| 186 | return trimmed.length > 0 ? trimmed : null; | 186 | return trimmed.length > 0 ? trimmed : null; |
| 187 | }; | 187 | }; |
| 188 | 188 | ||
| 189 | const toStringRecord = (value: Record<string, unknown>): Record<string, string> => { | ||
| 190 | const normalized: Record<string, string> = {}; | ||
| 191 | |||
| 192 | for (const [rawKey, rawValue] of Object.entries(value)) { | ||
| 193 | const key = normalizeHeaderString(rawKey); | ||
| 194 | const headerValue = normalizeHeaderString(rawValue); | ||
| 195 | if (key && headerValue) { | ||
| 196 | normalized[key] = headerValue; | ||
| 197 | } | ||
| 198 | } | ||
| 199 | |||
| 200 | return normalized; | ||
| 201 | }; | ||
| 202 | |||
| 189 | const parseSourceConfig = (source: Source): Record<string, unknown> => | 203 | const parseSourceConfig = (source: Source): Record<string, unknown> => |
| 190 | jsonObjectFromUnknown(safeJsonParse(source.configJson)); | 204 | jsonObjectFromUnknown(safeJsonParse(source.configJson)); |
| 191 | 205 | ||
| @@ -206,39 +220,6 @@ const collectConfiguredHeadersFromSourceConfig = ( · 14 unmodified lines | |||
| 206 | return nextHeaders; | 220 | return nextHeaders; |
| 207 | }; | 221 | }; |
| 208 | 222 | ||
| 209 | const collectAuthHeadersFromSourceConfig = ( | ||
| 210 | config: Record<string, unknown>, | ||
| 211 | ): Record<string, string> => { | ||
| 212 | const auth = jsonObjectFromUnknown(config.auth); | ||
| 213 | const authType = normalizeHeaderString(auth.type)?.toLowerCase(); | ||
| 214 | const nextHeaders: Record<string, string> = {}; | ||
| 215 | |||
| 216 | if (authType === "bearer") { | ||
| 217 | const token = normalizeHeaderString(auth.token) ?? normalizeHeaderString(auth.value); | ||
| 218 | if (token) { | ||
| 219 | nextHeaders.Authorization = `Bearer ${token}`; | ||
| 220 | } | ||
| 221 | } | ||
| 222 | |||
| 223 | if (authType === "apikey" || authType === "api_key") { | ||
| 224 | const token = normalizeHeaderString(auth.value) ?? normalizeHeaderString(auth.token); | ||
| 225 | if (token) { | ||
| 226 | const headerName = normalizeHeaderString(auth.header) ?? "Authorization"; | ||
| 227 | nextHeaders[headerName] = token; | ||
| 228 | } | ||
| 229 | } | ||
| 230 | |||
| 231 | if (authType === "basic") { | ||
| 232 | const username = normalizeHeaderString(auth.username); | ||
| 233 | const password = normalizeHeaderString(auth.password); | ||
| 234 | if (username && password && typeof btoa === "function") { | ||
| 235 | nextHeaders.Authorization = `Basic ${btoa(`${username}:${password}`)}`; | ||
| 236 | } | ||
| 237 | } | ||
| 238 | |||
| 239 | return nextHeaders; | ||
| 240 | }; | ||
| 241 | |||
| 242 | const mergeHeaders = (...sets: ReadonlyArray<Record<string, string>>): Record<string, string> => { | 223 | const mergeHeaders = (...sets: ReadonlyArray<Record<string, string>>): Record<string, string> => { |
| 243 | const merged: Record<string, string> = {}; | 224 | const merged: Record<string, string> = {}; |
| 244 | const keyByLower = new Map<string, string>(); | 225 | const keyByLower = new Map<string, string>(); |
| @@ -269,7 +250,13 @@ const resolveSourceCredentialHeadersForInvocation = async ( · 24 unmodified lines | |||
| 269 | ctx: ActionCtx, | 250 | ctx: ActionCtx, |
| 270 | source: Source, | 251 | source: Source, |
| 271 | accountId: string, | 252 | accountId: string, |
| 272 | ): Promise<Record<string, string>> => { | 253 | options?: { |
| 254 | forceRefresh?: boolean; | ||
| 255 | }, | ||
| 256 | ): Promise<{ | ||
| 257 | headers: Record<string, string>; | ||
| 258 | oauthConnectionId: string | null; | ||
| 259 | }> => { | ||
| 273 | try { | 260 | try { |
| 274 | const resolved = (await ctx.runAction( | 261 | const resolved = (await ctx.runAction( |
| 275 | runtimeInternal.control_plane.credentials.resolveSourceCredentialHeaders, | 262 | runtimeInternal.control_plane.credentials.resolveSourceCredentialHeaders, |
| @@ -277,8 +264,9 @@ const resolveSourceCredentialHeadersForInvocation = async ( · 1 unmodified line | |||
| 277 | workspaceId: source.workspaceId, | 264 | workspaceId: source.workspaceId, |
| 278 | sourceId: source.id, | 265 | sourceId: source.id, |
| 279 | accountId, | 266 | accountId, |
| 267 | ...(options?.forceRefresh === true ? { forceRefresh: true } : {}), | ||
| 280 | }, | 268 | }, |
| 281 | )) as { headers?: unknown }; | 269 | )) as { headers?: unknown; oauthConnectionId?: unknown }; |
| 282 | 270 | ||
| 283 | const headers = jsonObjectFromUnknown(resolved.headers); | 271 | const headers = jsonObjectFromUnknown(resolved.headers); |
| 284 | const normalized: Record<string, string> = {}; | 272 | const normalized: Record<string, string> = {}; |
| @@ -291,9 +279,18 @@ const resolveSourceCredentialHeadersForInvocation = async ( · 6 unmodified lines | |||
| 291 | } | 279 | } |
| 292 | } | 280 | } |
| 293 | 281 | ||
| 294 | return normalized; | 282 | return { |
| 283 | headers: normalized, | ||
| 284 | oauthConnectionId: | ||
| 285 | typeof resolved.oauthConnectionId === "string" && resolved.oauthConnectionId.trim().length > 0 | ||
| 286 | ? resolved.oauthConnectionId | ||
| 287 | : null, | ||
| 288 | }; | ||
| 295 | } catch { | 289 | } catch { |
| 296 | return {}; | 290 | return { |
| 291 | headers: {}, | ||
| 292 | oauthConnectionId: null, | ||
| 293 | }; | ||
| 297 | } | 294 | } |
| 298 | }; | 295 | }; |
| 299 | 296 | ||
| @@ -301,14 +298,167 @@ const resolveSourceHeadersForInvocation = async ( · 1 unmodified line | |||
| 301 | ctx: ActionCtx, | 298 | ctx: ActionCtx, |
| 302 | source: Source, | 299 | source: Source, |
| 303 | accountId: string, | 300 | accountId: string, |
| 304 | ): Promise<Record<string, string>> => { | 301 | options?: { |
| 302 | forceRefresh?: boolean; | ||
| 303 | }, | ||
| 304 | ): Promise<{ | ||
| 305 | headers: Record<string, string>; | ||
| 306 | oauthConnectionId: string | null; | ||
| 307 | }> => { | ||
| 305 | const config = parseSourceConfig(source); | 308 | const config = parseSourceConfig(source); |
| 309 | const credentialResolved = await resolveSourceCredentialHeadersForInvocation( | ||
| 310 | ctx, | ||
| 311 | source, | ||
| 312 | accountId, | ||
| 313 | options, | ||
| 314 | ); | ||
| 306 | 315 | ||
| 307 | return mergeHeaders( | 316 | return { |
| 317 | headers: mergeHeaders( | ||
| 318 | collectConfiguredHeadersFromSourceConfig(config), | ||
| 319 | credentialResolved.headers, | ||
| 320 | ), | ||
| 321 | oauthConnectionId: credentialResolved.oauthConnectionId, | ||
| 322 | }; | ||
| 323 | }; | ||
| 324 | |||
| 325 | const sourceWithInvocationHeaders = ( | ||
| 326 | source: Source, | ||
| 327 | invocationHeaders: Record<string, string>, | ||
| 328 | ): Source => { | ||
| 329 | const config = parseSourceConfig(source); | ||
| 330 | const mergedHeaders = mergeHeaders( | ||
| 308 | collectConfiguredHeadersFromSourceConfig(config), | 331 | collectConfiguredHeadersFromSourceConfig(config), |
| 309 | collectAuthHeadersFromSourceConfig(config), | 332 | invocationHeaders, |
| 310 | await resolveSourceCredentialHeadersForInvocation(ctx, source, accountId), | ||
| 311 | ); | 333 | ); |
| 334 | |||
| 335 | const nextConfig: Record<string, unknown> = { | ||
| 336 | ...config, | ||
| 337 | }; | ||
| 338 | |||
| 339 | if (Object.keys(mergedHeaders).length > 0) { | ||
| 340 | nextConfig.headers = mergedHeaders; | ||
| 341 | } else { | ||
| 342 | delete nextConfig.headers; | ||
| 343 | } | ||
| 344 | |||
| 345 | return { | ||
| 346 | ...source, | ||
| 347 | configJson: JSON.stringify(nextConfig), | ||
| 348 | }; | ||
| 349 | }; | ||
| 350 | |||
| 351 | const readHeaderCaseInsensitive = ( | ||
| 352 | headers: Record<string, string>, | ||
| 353 | name: string, | ||
| 354 | ): string | null => { | ||
| 355 | const target = name.toLowerCase(); | ||
| 356 | |||
| 357 | for (const [key, value] of Object.entries(headers)) { | ||
| 358 | if (key.toLowerCase() === target) { | ||
| 359 | return value; | ||
| 360 | } | ||
| 361 | } | ||
| 362 | |||
| 363 | return null; | ||
| 364 | }; | ||
| 365 | |||
| 366 | const textHasInvalidTokenMarker = (value: string): boolean => { | ||
| 367 | const normalized = value.toLowerCase(); | ||
| 368 | return normalized.includes("invalid_token") || normalized.includes("invalid token"); | ||
| 369 | }; | ||
| 370 | |||
| 371 | const valueHasInvalidTokenMarker = (value: unknown): boolean => { | ||
| 372 | if (typeof value === "string") { | ||
| 373 | return textHasInvalidTokenMarker(value); | ||
| 374 | } | ||
| 375 | |||
| 376 | if (Array.isArray(value)) { | ||
| 377 | return value.some((entry) => valueHasInvalidTokenMarker(entry)); | ||
| 378 | } | ||
| 379 | |||
| 380 | if (!value || typeof value !== "object") { | ||
| 381 | return false; | ||
| 382 | } | ||
| 383 | |||
| 384 | const record = value as Record<string, unknown>; | ||
| 385 | if (valueHasInvalidTokenMarker(record.error)) { | ||
| 386 | return true; | ||
| 387 | } | ||
| 388 | |||
| 389 | if (valueHasInvalidTokenMarker(record.error_description)) { | ||
| 390 | return true; | ||
| 391 | } | ||
| 392 | |||
| 393 | if (valueHasInvalidTokenMarker(record.message)) { | ||
| 394 | return true; | ||
| 395 | } | ||
| 396 | |||
| 397 | if (valueHasInvalidTokenMarker(record.code)) { | ||
| 398 | return true; | ||
| 399 | } | ||
| 400 | |||
| 401 | if (Array.isArray(record.errors) && record.errors.some((entry) => valueHasInvalidTokenMarker(entry))) { | ||
| 402 | return true; | ||
| 403 | } | ||
| 404 | |||
| 405 | return Object.values(record).some((entry) => valueHasInvalidTokenMarker(entry)); | ||
| 406 | }; | ||
| 407 | |||
| 408 | type HttpInvocationEnvelope = { | ||
| 409 | status: number | null; | ||
| 410 | headers: Record<string, string>; | ||
| 411 | body: unknown; | ||
| 412 | }; | ||
| 413 | |||
| 414 | const toHttpInvocationEnvelope = (output: unknown): HttpInvocationEnvelope => { | ||
| 415 | if (!output || typeof output !== "object" || Array.isArray(output)) { | ||
| 416 | return { | ||
| 417 | status: null, | ||
| 418 | headers: {}, | ||
| 419 | body: null, | ||
| 420 | }; | ||
| 421 | } | ||
| 422 | |||
| 423 | const record = output as Record<string, unknown>; | ||
| 424 | const rawStatus = record.status; | ||
| 425 | const rawHeaders = record.headers; | ||
| 426 | |||
| 427 | return { | ||
| 428 | status: | ||
| 429 | typeof rawStatus === "number" && Number.isFinite(rawStatus) | ||
| 430 | ? Math.floor(rawStatus) | ||
| 431 | : null, | ||
| 432 | headers: | ||
| 433 | rawHeaders && typeof rawHeaders === "object" && !Array.isArray(rawHeaders) | ||
| 434 | ? toStringRecord(rawHeaders as Record<string, unknown>) | ||
| 435 | : {}, | ||
| 436 | body: record.body, | ||
| 437 | }; | ||
| 438 | }; | ||
| 439 | |||
| 440 | const responseHasInvalidTokenSignal = (envelope: HttpInvocationEnvelope): boolean => { | ||
| 441 | if (envelope.status !== 401) { | ||
| 442 | return false; | ||
| 443 | } | ||
| 444 | |||
| 445 | const wwwAuthenticate = readHeaderCaseInsensitive(envelope.headers, "www-authenticate"); | ||
| 446 | if (wwwAuthenticate && textHasInvalidTokenMarker(wwwAuthenticate)) { | ||
| 447 | return true; | ||
| 448 | } | ||
| 449 | |||
| 450 | return valueHasInvalidTokenMarker(envelope.body); | ||
| 451 | }; | ||
| 452 | |||
| 453 | const shouldRetryOAuthAfterUnauthorized = ( | ||
| 454 | oauthConnectionId: string | null, | ||
| 455 | output: unknown, | ||
| 456 | ): boolean => { | ||
| 457 | if (!oauthConnectionId) { | ||
| 458 | return false; | ||
| 459 | } | ||
| 460 | |||
| 461 | return responseHasInvalidTokenSignal(toHttpInvocationEnvelope(output)); | ||
| 312 | }; | 462 | }; |
| 313 | 463 | ||
| 314 | const headersToRecord = (headers: Headers): Record<string, string> => { | 464 | const headersToRecord = (headers: Headers): Record<string, string> => { |
| @@ -1044,7 +1194,58 @@ const invokeToolWithRegistry = ( · 729 unmodified lines | |||
| 1044 | accountId: string, | 1194 | accountId: string, |
| 1045 | ): Effect.Effect<{ output: unknown; isError: boolean }, RuntimeAdapterError> => | 1195 | ): Effect.Effect<{ output: unknown; isError: boolean }, RuntimeAdapterError> => |
| 1046 | Effect.gen(function* () { | 1196 | Effect.gen(function* () { |
| 1047 | const openApiProvider = makeOpenApiToolProvider(); | 1197 | const baseOpenApiProvider = makeOpenApiToolProvider(); |
| 1198 | |||
| 1199 | const openApiProvider = { | ||
| 1200 | kind: "openapi" as const, | ||
| 1201 | invoke: (input: { source: Source | null; tool: CanonicalToolDescriptor; args: unknown }) => | ||
| 1202 | Effect.gen(function* () { | ||
| 1203 | const sourceForInvoke = input.source; | ||
| 1204 | if (!sourceForInvoke) { | ||
| 1205 | return yield* toToolProviderError( | ||
| 1206 | "openapi", | ||
| 1207 | "invoke_tool", | ||
| 1208 | `OpenAPI invocation failed for tool: ${input.tool.toolId}`, | ||
| 1209 | "OpenAPI provider requires a source", | ||
| 1210 | ); | ||
| 1211 | } | ||
| 1212 | |||
| 1213 | const resolveHeaders = (forceRefresh: boolean) => | ||
| 1214 | Effect.tryPromise({ | ||
| 1215 | try: () => | ||
| 1216 | resolveSourceHeadersForInvocation( | ||
| 1217 | ctx, | ||
| 1218 | sourceForInvoke, | ||
| 1219 | accountId, | ||
| 1220 | forceRefresh ? { forceRefresh: true } : undefined, | ||
| 1221 | ), | ||
| 1222 | catch: (cause) => | ||
| 1223 | toToolProviderError( | ||
| 1224 | "openapi", | ||
| 1225 | "resolve_credentials", | ||
| 1226 | `OpenAPI credential resolution failed for tool: ${input.tool.toolId}`, | ||
| 1227 | cause, | ||
| 1228 | ), | ||
| 1229 | }); | ||
| 1230 | |||
| 1231 | const invokeOpenApi = (resolvedHeaders: Record<string, string>) => | ||
| 1232 | baseOpenApiProvider.invoke({ | ||
| 1233 | source: sourceWithInvocationHeaders(sourceForInvoke, resolvedHeaders), | ||
| 1234 | tool: input.tool, | ||
| 1235 | args: input.args, | ||
| 1236 | }); | ||
| 1237 | |||
| 1238 | const sourceHeaders = yield* resolveHeaders(false); | ||
| 1239 | let result = yield* invokeOpenApi(sourceHeaders.headers); | ||
| 1240 | |||
| 1241 | if (shouldRetryOAuthAfterUnauthorized(sourceHeaders.oauthConnectionId, result.output)) { | ||
| 1242 | const refreshedHeaders = yield* resolveHeaders(true); | ||
| 1243 | result = yield* invokeOpenApi(refreshedHeaders.headers); | ||
| 1244 | } | ||
| 1245 | |||
| 1246 | return result; | ||
| 1247 | }), | ||
| 1248 | }; | ||
| 1048 | 1249 | ||
| 1049 | const graphqlProvider = { | 1250 | const graphqlProvider = { |
| 1050 | kind: "graphql" as const, | 1251 | kind: "graphql" as const, |
| @@ -1078,39 +1279,58 @@ const invokeToolWithRegistry = ( · 27 unmodified lines | |||
| 1078 | operationName = payload.fieldName; | 1279 | operationName = payload.fieldName; |
| 1079 | } | 1280 | } |
| 1080 | 1281 | ||
| 1282 | const invokeGraphql = async ( | ||
| 1283 | resolvedHeaders: Record<string, string>, | ||
| 1284 | ): Promise<{ output: unknown; isError: boolean }> => { | ||
| 1285 | const response = await fetch(payload.endpoint, { | ||
| 1286 | method: "POST", | ||
| 1287 | headers: mergeHeaders( | ||
| 1288 | { | ||
| 1289 | "content-type": "application/json", | ||
| 1290 | }, | ||
| 1291 | resolvedHeaders, | ||
| 1292 | ), | ||
| 1293 | body: JSON.stringify({ | ||
| 1294 | query, | ||
| 1295 | variables, | ||
| 1296 | operationName, | ||
| 1297 | }), | ||
| 1298 | }); | ||
| 1299 | |||
| 1300 | const body = await graphqlResponseBody(response); | ||
| 1301 | const bodyRecord = jsonObjectFromUnknown(body); | ||
| 1302 | const hasErrors = Array.isArray(bodyRecord.errors); | ||
| 1303 | |||
| 1304 | return { | ||
| 1305 | output: { | ||
| 1306 | status: response.status, | ||
| 1307 | headers: headersToRecord(response.headers), | ||
| 1308 | body, | ||
| 1309 | }, | ||
| 1310 | isError: response.status >= 400 || hasErrors, | ||
| 1311 | }; | ||
| 1312 | }; | ||
| 1313 | |||
| 1081 | const sourceHeaders = await resolveSourceHeadersForInvocation( | 1314 | const sourceHeaders = await resolveSourceHeadersForInvocation( |
| 1082 | ctx, | 1315 | ctx, |
| 1083 | input.source, | 1316 | input.source, |
| 1084 | accountId, | 1317 | accountId, |
| 1085 | ); | 1318 | ); |
| 1086 | 1319 | ||
| 1087 | const response = await fetch(payload.endpoint, { | 1320 | let result = await invokeGraphql(sourceHeaders.headers); |
| 1088 | method: "POST", | ||
| 1089 | headers: mergeHeaders( | ||
| 1090 | { | ||
| 1091 | "content-type": "application/json", | ||
| 1092 | }, | ||
| 1093 | sourceHeaders, | ||
| 1094 | ), | ||
| 1095 | body: JSON.stringify({ | ||
| 1096 | query, | ||
| 1097 | variables, | ||
| 1098 | operationName, | ||
| 1099 | }), | ||
| 1100 | }); | ||
| 1101 | 1321 | ||
| 1102 | const body = await graphqlResponseBody(response); | 1322 | if (shouldRetryOAuthAfterUnauthorized(sourceHeaders.oauthConnectionId, result.output)) { |
| 1103 | const bodyRecord = jsonObjectFromUnknown(body); | 1323 | const refreshedHeaders = await resolveSourceHeadersForInvocation( |
| 1104 | const hasErrors = Array.isArray(bodyRecord.errors); | 1324 | ctx, |
| 1325 | input.source, | ||
| 1326 | accountId, | ||
| 1327 | { forceRefresh: true }, | ||
| 1328 | ); | ||
| 1105 | 1329 | ||
| 1106 | return { | 1330 | result = await invokeGraphql(refreshedHeaders.headers); |
| 1107 | output: { | 1331 | } |
| 1108 | status: response.status, | 1332 | |
| 1109 | headers: headersToRecord(response.headers), | 1333 | return result; |
| 1110 | body, | ||
| 1111 | }, | ||
| 1112 | isError: response.status >= 400 || hasErrors, | ||
| 1113 | }; | ||
| 1114 | }, | 1334 | }, |
| 1115 | catch: (cause) => | 1335 | catch: (cause) => |
| 1116 | toToolProviderError( | 1336 | toToolProviderError( |
| @@ -1133,11 +1353,6 @@ const invokeToolWithRegistry = ( · 16 unmodified lines | |||
| 1133 | 1353 | ||
| 1134 | const payload = decodeMcpInvocationPayload(input.tool.providerPayload); | 1354 | const payload = decodeMcpInvocationPayload(input.tool.providerPayload); |
| 1135 | const invokeArgs = normalizeInvokeInput(input.args); | 1355 | const invokeArgs = normalizeInvokeInput(input.args); |
| 1136 | const sourceHeaders = await resolveSourceHeadersForInvocation( | ||
| 1137 | ctx, | ||
| 1138 | input.source, | ||
| 1139 | accountId, | ||
| 1140 | ); | ||
| 1141 | 1356 | ||
| 1142 | if (payload.transport !== "streamable-http") { | 1357 | if (payload.transport !== "streamable-http") { |
| 1143 | throw new Error( | 1358 | throw new Error( |
| @@ -1146,74 +1361,99 @@ const invokeToolWithRegistry = ( · 2 unmodified lines | |||
| 1146 | } | 1361 | } |
| 1147 | 1362 | ||
| 1148 | const endpoint = createMcpEndpointUrl(payload); | 1363 | const endpoint = createMcpEndpointUrl(payload); |
| 1149 | const initializeResponse = await postMcpJsonRpc( | 1364 | const invokeMcp = async ( |
| 1150 | endpoint, | 1365 | resolvedHeaders: Record<string, string>, |
| 1151 | { | 1366 | ): Promise<{ output: unknown; isError: boolean }> => { |
| 1152 | jsonrpc: "2.0", | 1367 | const initializeResponse = await postMcpJsonRpc( |
| 1153 | id: `init_${crypto.randomUUID()}`, | 1368 | endpoint, |
| 1154 | method: "initialize", | 1369 | { |
| 1155 | params: { | 1370 | jsonrpc: "2.0", |
| 1156 | protocolVersion: "2024-11-05", | 1371 | id: `init_${crypto.randomUUID()}`, |
| 1157 | capabilities: {}, | 1372 | method: "initialize", |
| 1158 | clientInfo: { | 1373 | params: { |
| 1159 | name: "executor-v2-runtime", | 1374 | protocolVersion: "2024-11-05", |
| 1160 | version: "0.1.0", | 1375 | capabilities: {}, |
| 1376 | clientInfo: { | ||
| 1377 | name: "executor-v2-runtime", | ||
| 1378 | version: "0.1.0", | ||
| 1379 | }, | ||
| 1161 | }, | 1380 | }, |
| 1162 | }, | 1381 | }, |
| 1163 | }, | 1382 | null, |
| 1164 | null, | 1383 | resolvedHeaders, |
| 1165 | sourceHeaders, | 1384 | ); |
| 1166 | ); | 1385 | const initializeBody = await decodeMcpJsonResponse(initializeResponse); |
| 1167 | const initializeBody = await decodeMcpJsonResponse(initializeResponse); | 1386 | |
| 1387 | const sessionId = initializeResponse.headers.get("mcp-session-id"); | ||
| 1388 | |||
| 1389 | if (initializeResponse.status >= 400 || initializeBody.error !== undefined) { | ||
| 1390 | return { | ||
| 1391 | output: { | ||
| 1392 | status: initializeResponse.status, | ||
| 1393 | headers: headersToRecord(initializeResponse.headers), | ||
| 1394 | body: initializeBody, | ||
| 1395 | }, | ||
| 1396 | isError: true, | ||
| 1397 | }; | ||
| 1398 | } | ||
| 1168 | 1399 | ||
| 1169 | const sessionId = initializeResponse.headers.get("mcp-session-id"); | 1400 | await postMcpJsonRpc( |
| 1401 | endpoint, | ||
| 1402 | { | ||
| 1403 | jsonrpc: "2.0", | ||
| 1404 | method: "notifications/initialized", | ||
| 1405 | params: {}, | ||
| 1406 | }, | ||
| 1407 | sessionId, | ||
| 1408 | resolvedHeaders, | ||
| 1409 | ); | ||
| 1410 | |||
| 1411 | const callResponse = await postMcpJsonRpc( | ||
| 1412 | endpoint, | ||
| 1413 | { | ||
| 1414 | jsonrpc: "2.0", | ||
| 1415 | id: `call_${crypto.randomUUID()}`, | ||
| 1416 | method: "tools/call", | ||
| 1417 | params: { | ||
| 1418 | name: payload.toolName, | ||
| 1419 | arguments: invokeArgs, | ||
| 1420 | }, | ||
| 1421 | }, | ||
| 1422 | sessionId, | ||
| 1423 | resolvedHeaders, | ||
| 1424 | ); | ||
| 1425 | const callBody = await decodeMcpJsonResponse(callResponse); | ||
| 1170 | 1426 | ||
| 1171 | if (initializeResponse.status >= 400 || initializeBody.error !== undefined) { | ||
| 1172 | return { | 1427 | return { |
| 1173 | output: { | 1428 | output: { |
| 1174 | status: initializeResponse.status, | 1429 | status: callResponse.status, |
| 1175 | headers: headersToRecord(initializeResponse.headers), | 1430 | headers: headersToRecord(callResponse.headers), |
| 1176 | body: initializeBody, | 1431 | body: callBody, |
| 1177 | }, | 1432 | }, |
| 1178 | isError: true, | 1433 | isError: callResponse.status >= 400 || callBody.error !== undefined, |
| 1179 | }; | 1434 | }; |
| 1180 | } | 1435 | }; |
| 1181 | 1436 | ||
| 1182 | await postMcpJsonRpc( | 1437 | const sourceHeaders = await resolveSourceHeadersForInvocation( |
| 1183 | endpoint, | 1438 | ctx, |
| 1184 | { | 1439 | input.source, |
| 1185 | jsonrpc: "2.0", | 1440 | accountId, |
| 1186 | method: "notifications/initialized", | ||
| 1187 | params: {}, | ||
| 1188 | }, | ||
| 1189 | sessionId, | ||
| 1190 | sourceHeaders, | ||
| 1191 | ); | 1441 | ); |
| 1192 | 1442 | ||
| 1193 | const callResponse = await postMcpJsonRpc( | 1443 | let result = await invokeMcp(sourceHeaders.headers); |
| 1194 | endpoint, | ||
| 1195 | { | ||
| 1196 | jsonrpc: "2.0", | ||
| 1197 | id: `call_${crypto.randomUUID()}`, | ||
| 1198 | method: "tools/call", | ||
| 1199 | params: { | ||
| 1200 | name: payload.toolName, | ||
| 1201 | arguments: invokeArgs, | ||
| 1202 | }, | ||
| 1203 | }, | ||
| 1204 | sessionId, | ||
| 1205 | sourceHeaders, | ||
| 1206 | ); | ||
| 1207 | const callBody = await decodeMcpJsonResponse(callResponse); | ||
| 1208 | 1444 | ||
| 1209 | return { | 1445 | if (shouldRetryOAuthAfterUnauthorized(sourceHeaders.oauthConnectionId, result.output)) { |
| 1210 | output: { | 1446 | const refreshedHeaders = await resolveSourceHeadersForInvocation( |
| 1211 | status: callResponse.status, | 1447 | ctx, |
| 1212 | headers: headersToRecord(callResponse.headers), | 1448 | input.source, |
| 1213 | body: callBody, | 1449 | accountId, |
| 1214 | }, | 1450 | { forceRefresh: true }, |
| 1215 | isError: callResponse.status >= 400 || callBody.error !== undefined, | 1451 | ); |
| 1216 | }; | 1452 | |
| 1453 | result = await invokeMcp(refreshedHeaders.headers); | ||
| 1454 | } | ||
| 1455 | |||
| 1456 | return result; | ||
| 1217 | }, | 1457 | }, |
| 1218 | catch: (cause) => | 1458 | catch: (cause) => |
| 1219 | toToolProviderError( | 1459 | toToolProviderError( |
| @@ -1226,10 +1466,7 @@ const invokeToolWithRegistry = ( · 6 unmodified lines | |||
| 1226 | }; | 1466 | }; |
| 1227 | 1467 | ||
| 1228 | const registry = makeToolProviderRegistry([ | 1468 | const registry = makeToolProviderRegistry([ |
| 1229 | { | 1469 | openApiProvider, |
| 1230 | kind: "openapi", | ||
| 1231 | invoke: (input) => openApiProvider.invoke(input), | ||
| 1232 | }, | ||
| 1233 | graphqlProvider, | 1470 | graphqlProvider, |
| 1234 | mcpProvider, | 1471 | mcpProvider, |
| 1235 | ]); | 1472 | ]); |
| @@ -1237,7 +1474,7 @@ const invokeToolWithRegistry = ( · 1 unmodified line | |||
| 1237 | const providerKind = artifactTool.protocol; | 1474 | const providerKind = artifactTool.protocol; |
| 1238 | const invocationPayload = safeJsonParse(artifactTool.invocationJson); | 1475 | const invocationPayload = safeJsonParse(artifactTool.invocationJson); |
| 1239 | 1476 | ||
| 1240 | const descriptor: CanonicalToolDescriptor = { | 1477 | const descriptor = { |
| 1241 | providerKind: providerKind as any, | 1478 | providerKind: providerKind as any, |
| 1242 | sourceId: source.id, | 1479 | sourceId: source.id, |
| 1243 | workspaceId: source.workspaceId, | 1480 | workspaceId: source.workspaceId, |
| @@ -1257,7 +1494,7 @@ const invokeToolWithRegistry = ( · 13 unmodified lines | |||
| 1257 | : providerKind === "graphql" | 1494 | : providerKind === "graphql" |
| 1258 | ? decodeGraphqlInvocationPayload(invocationPayload) | 1495 | ? decodeGraphqlInvocationPayload(invocationPayload) |
| 1259 | : decodeMcpInvocationPayload(invocationPayload), | 1496 | : decodeMcpInvocationPayload(invocationPayload), |
| 1260 | }; | 1497 | } as CanonicalToolDescriptor; |
| 1261 | 1498 | ||
| 1262 | const invocation = yield* registry | 1499 | const invocation = yield* registry |
| 1263 | .invoke({ | 1500 | .invoke({ |
| @@ -1864,3 +2101,28 @@ export const createConvexSourceToolRegistry = ( · 600 unmodified lines | |||
| 1864 | }), | 2101 | }), |
| 1865 | } as ToolRegistry; | 2102 | } as ToolRegistry; |
| 1866 | }; | 2103 | }; |
| 2104 | |||
| 2105 | export const invokeWorkspaceTool = internalAction({ | ||
| 2106 | args: { | ||
| 2107 | workspaceId: v.string(), | ||
| 2108 | accountId: v.string(), | ||
| 2109 | runId: v.string(), | ||
| 2110 | callId: v.string(), | ||
| 2111 | toolPath: v.string(), | ||
| 2112 | input: v.optional(v.any()), | ||
| 2113 | }, | ||
| 2114 | handler: async (ctx, args): Promise<RuntimeToolCallResult> => { | ||
| 2115 | const toolRegistry = createConvexSourceToolRegistry(ctx, args.workspaceId, { | ||
| 2116 | accountId: args.accountId, | ||
| 2117 | }); | ||
| 2118 | |||
| 2119 | return await Effect.runPromise( | ||
| 2120 | toolRegistry.callTool({ | ||
| 2121 | runId: args.runId, | ||
| 2122 | callId: args.callId, | ||
| 2123 | toolPath: args.toolPath, | ||
| 2124 | input: normalizeInvokeInput(args.input), | ||
| 2125 | }), | ||
| 2126 | ); | ||
| 2127 | }, | ||
| 2128 | }); | ||
| @@ -1,10 +1,7 @@ | |||
| 1 | import { | 1 | import { |
| 2 | buildCredentialHeaders, | ||
| 3 | CredentialResolverError, | 2 | CredentialResolverError, |
| 4 | extractCredentialResolutionContext, | 3 | extractCredentialResolutionContext, |
| 5 | makeCredentialResolver, | 4 | makeCredentialResolver, |
| 6 | selectCredentialBinding, | ||
| 7 | selectOAuthAccessToken, | ||
| 8 | sourceIdFromSourceKey, | 5 | sourceIdFromSourceKey, |
| 9 | type ResolveToolCredentials, | 6 | type ResolveToolCredentials, |
| 10 | } from "@executor-v2/engine"; | 7 | } from "@executor-v2/engine"; |
| @@ -23,6 +20,136 @@ const toCredentialResolverError = ( · 12 unmodified lines | |||
| 23 | details, | 20 | details, |
| 24 | }); | 21 | }); |
| 25 | 22 | ||
| 23 | const normalizeString = (value: unknown): string | null => { | ||
| 24 | if (typeof value !== "string") { | ||
| 25 | return null; | ||
| 26 | } | ||
| 27 | |||
| 28 | const trimmed = value.trim(); | ||
| 29 | return trimmed.length > 0 ? trimmed : null; | ||
| 30 | }; | ||
| 31 | |||
| 32 | const parseJsonObject = (value: string | null | undefined): Record<string, unknown> => { | ||
| 33 | if (!value) { | ||
| 34 | return {}; | ||
| 35 | } | ||
| 36 | |||
| 37 | try { | ||
| 38 | const parsed = JSON.parse(value) as unknown; | ||
| 39 | if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { | ||
| 40 | return {}; | ||
| 41 | } | ||
| 42 | |||
| 43 | return parsed as Record<string, unknown>; | ||
| 44 | } catch { | ||
| 45 | return {}; | ||
| 46 | } | ||
| 47 | }; | ||
| 48 | |||
| 49 | const toStringRecord = (value: Record<string, unknown>): Record<string, string> => { | ||
| 50 | const normalized: Record<string, string> = {}; | ||
| 51 | |||
| 52 | for (const [rawKey, rawValue] of Object.entries(value)) { | ||
| 53 | const key = normalizeString(rawKey); | ||
| 54 | const resolvedValue = normalizeString(rawValue); | ||
| 55 | if (!key || !resolvedValue) { | ||
| 56 | continue; | ||
| 57 | } | ||
| 58 | |||
| 59 | normalized[key] = resolvedValue; | ||
| 60 | } | ||
| 61 | |||
| 62 | return normalized; | ||
| 63 | }; | ||
| 64 | |||
| 65 | const mergeHeaders = (...sets: ReadonlyArray<Record<string, string>>): Record<string, string> => { | ||
| 66 | const merged: Record<string, string> = {}; | ||
| 67 | const keyByLower = new Map<string, string>(); | ||
| 68 | |||
| 69 | for (const set of sets) { | ||
| 70 | for (const [rawKey, rawValue] of Object.entries(set)) { | ||
| 71 | const key = rawKey.trim(); | ||
| 72 | const value = rawValue.trim(); | ||
| 73 | if (key.length === 0 || value.length === 0) { | ||
| 74 | continue; | ||
| 75 | } | ||
| 76 | |||
| 77 | const lower = key.toLowerCase(); | ||
| 78 | const existing = keyByLower.get(lower); | ||
| 79 | if (existing && existing !== key) { | ||
| 80 | delete merged[existing]; | ||
| 81 | } | ||
| 82 | |||
| 83 | keyByLower.set(lower, key); | ||
| 84 | merged[key] = value; | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | return merged; | ||
| 89 | }; | ||
| 90 | |||
| 91 | const base64Encode = (value: string): string => | ||
| 92 | Buffer.from(value, "utf8").toString("base64"); | ||
| 93 | |||
| 94 | const buildSecretHeaders = ( | ||
| 95 | strategy: string, | ||
| 96 | secret: string, | ||
| 97 | metadataJson: string | null, | ||
| 98 | ): Record<string, string> => { | ||
| 99 | const trimmedSecret = secret.trim(); | ||
| 100 | if (trimmedSecret.length === 0) { | ||
| 101 | return {}; | ||
| 102 | } | ||
| 103 | |||
| 104 | const metadata = parseJsonObject(metadataJson); | ||
| 105 | |||
| 106 | if (strategy === "api_key") { | ||
| 107 | const headerName = normalizeString(metadata.apiKeyHeader) ?? "x-api-key"; | ||
| 108 | return { | ||
| 109 | [headerName]: trimmedSecret, | ||
| 110 | }; | ||
| 111 | } | ||
| 112 | |||
| 113 | if (strategy === "bearer") { | ||
| 114 | return { | ||
| 115 | Authorization: `Bearer ${trimmedSecret}`, | ||
| 116 | }; | ||
| 117 | } | ||
| 118 | |||
| 119 | if (strategy === "basic") { | ||
| 120 | const asJson = parseJsonObject(trimmedSecret); | ||
| 121 | const username = normalizeString(asJson.username); | ||
| 122 | const password = normalizeString(asJson.password); | ||
| 123 | |||
| 124 | const pair = username && password | ||
| 125 | ? `${username}:${password}` | ||
| 126 | : trimmedSecret.includes(":") | ||
| 127 | ? trimmedSecret | ||
| 128 | : null; | ||
| 129 | |||
| 130 | if (!pair) { | ||
| 131 | return {}; | ||
| 132 | } | ||
| 133 | |||
| 134 | return { | ||
| 135 | Authorization: `Basic ${base64Encode(pair)}`, | ||
| 136 | }; | ||
| 137 | } | ||
| 138 | |||
| 139 | if (strategy === "custom") { | ||
| 140 | const headerName = normalizeString(metadata.customHeaderName); | ||
| 141 | if (!headerName) { | ||
| 142 | return {}; | ||
| 143 | } | ||
| 144 | |||
| 145 | return { | ||
| 146 | [headerName]: trimmedSecret, | ||
| 147 | }; | ||
| 148 | } | ||
| 149 | |||
| 150 | return {}; | ||
| 151 | }; | ||
| 152 | |||
| 26 | export const createPmResolveToolCredentials = ( | 153 | export const createPmResolveToolCredentials = ( |
| 27 | localStateStore: LocalStateStore, | 154 | localStateStore: LocalStateStore, |
| 28 | ): ResolveToolCredentials => | 155 | ): ResolveToolCredentials => |
| @@ -52,27 +179,104 @@ export const createPmResolveToolCredentials = ( · 23 unmodified lines | |||
| 52 | }; | 179 | }; |
| 53 | } | 180 | } |
| 54 | 181 | ||
| 55 | const binding = selectCredentialBinding( | 182 | const sourceId = sourceIdFromSourceKey(context.sourceKey); |
| 56 | snapshot.credentialBindings, | 183 | if (!sourceId) { |
| 57 | context, | 184 | return { |
| 185 | headers: {}, | ||
| 186 | }; | ||
| 187 | } | ||
| 188 | |||
| 189 | const workspace = snapshot.workspaces.find( | ||
| 190 | (item) => item.id === context.workspaceId, | ||
| 58 | ); | 191 | ); |
| 192 | const organizationId = context.organizationId ?? workspace?.organizationId ?? null; | ||
| 193 | |||
| 194 | const binding = snapshot.sourceAuthBindings | ||
| 195 | .filter((candidate) => candidate.sourceId === sourceId) | ||
| 196 | .map((candidate) => { | ||
| 197 | let score = -1; | ||
| 198 | |||
| 199 | if (candidate.scopeType === "account") { | ||
| 200 | if (context.accountId && candidate.accountId === context.accountId) { | ||
| 201 | score = organizationId && candidate.organizationId === organizationId ? 30 : -1; | ||
| 202 | } | ||
| 203 | } else if (candidate.scopeType === "workspace") { | ||
| 204 | score = candidate.workspaceId === context.workspaceId ? 20 : -1; | ||
| 205 | } else if (candidate.scopeType === "organization") { | ||
| 206 | score = organizationId && candidate.organizationId === organizationId ? 10 : -1; | ||
| 207 | } | ||
| 208 | |||
| 209 | return { | ||
| 210 | candidate, | ||
| 211 | score, | ||
| 212 | }; | ||
| 213 | }) | ||
| 214 | .filter((entry) => entry.score >= 0) | ||
| 215 | .sort((left, right) => { | ||
| 216 | if (left.score !== right.score) { | ||
| 217 | return right.score - left.score; | ||
| 218 | } | ||
| 219 | |||
| 220 | if (left.candidate.updatedAt !== right.candidate.updatedAt) { | ||
| 221 | return right.candidate.updatedAt - left.candidate.updatedAt; | ||
| 222 | } | ||
| 223 | |||
| 224 | return right.candidate.createdAt - left.candidate.createdAt; | ||
| 225 | })[0]?.candidate ?? null; | ||
| 226 | |||
| 59 | if (binding === null) { | 227 | if (binding === null) { |
| 60 | return { | 228 | return { |
| 61 | headers: {}, | 229 | headers: {}, |
| 62 | }; | 230 | }; |
| 63 | } | 231 | } |
| 64 | 232 | ||
| 65 | const sourceId = sourceIdFromSourceKey(context.sourceKey); | 233 | const connection = snapshot.authConnections.find( |
| 234 | (candidate) => candidate.id === binding.connectionId, | ||
| 235 | ); | ||
| 236 | if (!connection || connection.status !== "active") { | ||
| 237 | return { | ||
| 238 | headers: {}, | ||
| 239 | }; | ||
| 240 | } | ||
| 66 | 241 | ||
| 67 | const oauthAccessToken = | 242 | const additionalHeaders = toStringRecord( |
| 68 | binding.provider === "oauth2" && sourceId | 243 | parseJsonObject(connection.additionalHeadersJson), |
| 69 | ? selectOAuthAccessToken(snapshot.oauthTokens, context, sourceId) | 244 | ); |
| 70 | : null; | 245 | |
| 246 | if (connection.strategy === "oauth2") { | ||
| 247 | const oauthState = snapshot.oauthStates.find( | ||
| 248 | (candidate) => candidate.connectionId === connection.id, | ||
| 249 | ); | ||
| 250 | |||
| 251 | const accessToken = normalizeString(oauthState?.accessTokenCiphertext); | ||
| 252 | const oauthHeaders = accessToken | ||
| 253 | ? ({ Authorization: `Bearer ${accessToken}` } as Record<string, string>) | ||
| 254 | : {}; | ||
| 255 | |||
| 256 | return { | ||
| 257 | headers: mergeHeaders(oauthHeaders, additionalHeaders), | ||
| 258 | }; | ||
| 259 | } | ||
| 260 | |||
| 261 | const material = snapshot.authMaterials.find( | ||
| 262 | (candidate) => candidate.connectionId === connection.id, | ||
| 263 | ); | ||
| 264 | |||
| 265 | if (!material) { | ||
| 266 | return { | ||
| 267 | headers: additionalHeaders, | ||
| 268 | }; | ||
| 269 | } | ||
| 71 | 270 | ||
| 72 | return { | 271 | return { |
| 73 | headers: buildCredentialHeaders(binding, { | 272 | headers: mergeHeaders( |
| 74 | oauthAccessToken, | 273 | buildSecretHeaders( |
| 75 | }), | 274 | connection.strategy, |
| 275 | material.ciphertext, | ||
| 276 | connection.metadataJson, | ||
| 277 | ), | ||
| 278 | additionalHeaders, | ||
| 279 | ), | ||
| 76 | }; | 280 | }; |
| 77 | }), | 281 | }), |
| 78 | ); | 282 | ); |
| @@ -7,9 +7,15 @@ import { · 6 unmodified lines | |||
| 7 | import { | 7 | import { |
| 8 | makeControlPlaneCredentialsService, | 8 | makeControlPlaneCredentialsService, |
| 9 | type ControlPlaneCredentialsServiceShape, | 9 | type ControlPlaneCredentialsServiceShape, |
| 10 | type UpsertCredentialBindingPayload, | ||
| 10 | } from "@executor-v2/management-api"; | 11 | } from "@executor-v2/management-api"; |
| 11 | import { | 12 | import { |
| 13 | type AuthConnection, | ||
| 14 | type AuthConnectionStrategy, | ||
| 15 | type AuthMaterial, | ||
| 16 | type OAuthState, | ||
| 12 | type OrganizationId, | 17 | type OrganizationId, |
| 18 | type SourceAuthBinding, | ||
| 13 | type SourceCredentialBinding, | 19 | type SourceCredentialBinding, |
| 14 | type WorkspaceId, | 20 | type WorkspaceId, |
| 15 | } from "@executor-v2/schema"; | 21 | } from "@executor-v2/schema"; |
| @@ -49,6 +55,81 @@ const resolveWorkspaceOrganizationId = ( · 33 unmodified lines | |||
| 49 | return workspace.organizationId; | 55 | return workspace.organizationId; |
| 50 | }; | 56 | }; |
| 51 | 57 | ||
| 58 | const normalizeString = (value: unknown): string | null => { | ||
| 59 | if (typeof value !== "string") { | ||
| 60 | return null; | ||
| 61 | } | ||
| 62 | |||
| 63 | const trimmed = value.trim(); | ||
| 64 | return trimmed.length > 0 ? trimmed : null; | ||
| 65 | }; | ||
| 66 | |||
| 67 | const sourceIdFromSourceKey = (sourceKey: string): string | null => { | ||
| 68 | const trimmed = sourceKey.trim(); | ||
| 69 | if (!trimmed.startsWith("source:")) { | ||
| 70 | return null; | ||
| 71 | } | ||
| 72 | |||
| 73 | const sourceId = trimmed.slice("source:".length).trim(); | ||
| 74 | return sourceId.length > 0 ? sourceId : null; | ||
| 75 | }; | ||
| 76 | |||
| 77 | const sourceKeyFromSourceId = (sourceId: string): string => `source:${sourceId}`; | ||
| 78 | |||
| 79 | const strategyFromProvider = ( | ||
| 80 | provider: SourceCredentialBinding["provider"], | ||
| 81 | ): AuthConnectionStrategy => { | ||
| 82 | if (provider === "api_key") return "api_key"; | ||
| 83 | if (provider === "bearer") return "bearer"; | ||
| 84 | if (provider === "oauth2") return "oauth2"; | ||
| 85 | if (provider === "basic") return "basic"; | ||
| 86 | return "custom"; | ||
| 87 | }; | ||
| 88 | |||
| 89 | const providerFromStrategy = ( | ||
| 90 | strategy: AuthConnectionStrategy, | ||
| 91 | ): SourceCredentialBinding["provider"] => { | ||
| 92 | if (strategy === "api_key") return "api_key"; | ||
| 93 | if (strategy === "bearer") return "bearer"; | ||
| 94 | if (strategy === "oauth2") return "oauth2"; | ||
| 95 | if (strategy === "basic") return "basic"; | ||
| 96 | return "custom"; | ||
| 97 | }; | ||
| 98 | |||
| 99 | const maskedSecretRef = (connection: AuthConnection): string => { | ||
| 100 | if (connection.strategy === "oauth2") { | ||
| 101 | if (connection.status === "reauth_required") { | ||
| 102 | return "oauth2://reauth_required"; | ||
| 103 | } | ||
| 104 | |||
| 105 | if (connection.status === "active") { | ||
| 106 | return "oauth2://connected"; | ||
| 107 | } | ||
| 108 | } | ||
| 109 | |||
| 110 | return "********"; | ||
| 111 | }; | ||
| 112 | |||
| 113 | const toCompatSourceCredentialBinding = ( | ||
| 114 | binding: SourceAuthBinding, | ||
| 115 | connection: AuthConnection, | ||
| 116 | ): SourceCredentialBinding => ({ | ||
| 117 | id: binding.id as unknown as SourceCredentialBinding["id"], | ||
| 118 | credentialId: connection.id as unknown as SourceCredentialBinding["credentialId"], | ||
| 119 | organizationId: binding.organizationId, | ||
| 120 | workspaceId: binding.workspaceId, | ||
| 121 | accountId: binding.accountId, | ||
| 122 | scopeType: binding.scopeType, | ||
| 123 | sourceKey: sourceKeyFromSourceId(binding.sourceId), | ||
| 124 | provider: providerFromStrategy(connection.strategy), | ||
| 125 | secretProvider: "local", | ||
| 126 | secretRef: maskedSecretRef(connection), | ||
| 127 | additionalHeadersJson: connection.additionalHeadersJson, | ||
| 128 | boundAuthFingerprint: null, | ||
| 129 | createdAt: binding.createdAt, | ||
| 130 | updatedAt: Math.max(binding.updatedAt, connection.updatedAt), | ||
| 131 | }); | ||
| 132 | |||
| 52 | const sortCredentialBindings = ( | 133 | const sortCredentialBindings = ( |
| 53 | bindings: ReadonlyArray<SourceCredentialBinding>, | 134 | bindings: ReadonlyArray<SourceCredentialBinding>, |
| 54 | ): Array<SourceCredentialBinding> => | 135 | ): Array<SourceCredentialBinding> => |
| @@ -58,28 +139,110 @@ const sortCredentialBindings = ( · 3 unmodified lines | |||
| 58 | return leftKey.localeCompare(rightKey); | 139 | return leftKey.localeCompare(rightKey); |
| 59 | }); | 140 | }); |
| 60 | 141 | ||
| 61 | const replaceCredentialBindingAt = ( | 142 | const upsertById = <A extends { id: string }>( |
| 62 | snapshot: LocalStateSnapshot, | 143 | values: ReadonlyArray<A>, |
| 63 | index: number, | 144 | nextValue: A, |
| 64 | binding: SourceCredentialBinding, | 145 | ): Array<A> => { |
| 65 | ): LocalStateSnapshot => { | 146 | const next = [...values]; |
| 66 | const next = [...snapshot.credentialBindings]; | 147 | const index = next.findIndex((value) => value.id === nextValue.id); |
| 67 | next[index] = binding; | 148 | if (index >= 0) { |
| 149 | next[index] = nextValue; | ||
| 150 | return next; | ||
| 151 | } | ||
| 152 | |||
| 153 | next.push(nextValue); | ||
| 154 | return next; | ||
| 155 | }; | ||
| 156 | |||
| 157 | const parseJsonObject = (value: string | null | undefined): Record<string, unknown> => { | ||
| 158 | if (!value) { | ||
| 159 | return {}; | ||
| 160 | } | ||
| 161 | |||
| 162 | try { | ||
| 163 | const parsed = JSON.parse(value) as unknown; | ||
| 164 | if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { | ||
| 165 | return {}; | ||
| 166 | } | ||
| 167 | |||
| 168 | return parsed as Record<string, unknown>; | ||
| 169 | } catch { | ||
| 170 | return {}; | ||
| 171 | } | ||
| 172 | }; | ||
| 173 | |||
| 174 | type OAuthRefreshConfig = { | ||
| 175 | tokenEndpoint?: string; | ||
| 176 | authorizationServer?: string; | ||
| 177 | clientId?: string; | ||
| 178 | clientSecretCiphertext?: string; | ||
| 179 | sourceUrl?: string; | ||
| 180 | clientInformationJson?: string; | ||
| 181 | }; | ||
| 68 | 182 | ||
| 183 | const parseOAuthRefreshConfig = (value: string | null): OAuthRefreshConfig => { | ||
| 184 | if (!value) { | ||
| 185 | return {}; | ||
| 186 | } | ||
| 187 | |||
| 188 | const parsed = parseJsonObject(value); | ||
| 69 | return { | 189 | return { |
| 70 | ...snapshot, | 190 | ...(normalizeString(parsed.tokenEndpoint) |
| 71 | generatedAt: Date.now(), | 191 | ? { tokenEndpoint: normalizeString(parsed.tokenEndpoint)! } |
| 72 | credentialBindings: next, | 192 | : {}), |
| 193 | ...(normalizeString(parsed.authorizationServer) | ||
| 194 | ? { authorizationServer: normalizeString(parsed.authorizationServer)! } | ||
| 195 | : {}), | ||
| 196 | ...(normalizeString(parsed.clientId) | ||
| 197 | ? { clientId: normalizeString(parsed.clientId)! } | ||
| 198 | : {}), | ||
| 199 | ...(normalizeString(parsed.clientSecretCiphertext) | ||
| 200 | ? { clientSecretCiphertext: normalizeString(parsed.clientSecretCiphertext)! } | ||
| 201 | : {}), | ||
| 202 | ...(normalizeString(parsed.sourceUrl) | ||
| 203 | ? { sourceUrl: normalizeString(parsed.sourceUrl)! } | ||
| 204 | : {}), | ||
| 205 | ...(normalizeString(parsed.clientInformationJson) | ||
| 206 | ? { clientInformationJson: normalizeString(parsed.clientInformationJson)! } | ||
| 207 | : {}), | ||
| 73 | }; | 208 | }; |
| 74 | }; | 209 | }; |
| 75 | 210 | ||
| 76 | const appendCredentialBinding = ( | 211 | const encodeOAuthRefreshConfig = (config: OAuthRefreshConfig): string | null => { |
| 77 | snapshot: LocalStateSnapshot, | 212 | const payload: Record<string, string> = {}; |
| 78 | binding: SourceCredentialBinding, | 213 | |
| 79 | ): LocalStateSnapshot => ({ | 214 | if (config.tokenEndpoint) payload.tokenEndpoint = config.tokenEndpoint; |
| 80 | ...snapshot, | 215 | if (config.authorizationServer) payload.authorizationServer = config.authorizationServer; |
| 81 | generatedAt: Date.now(), | 216 | if (config.clientId) payload.clientId = config.clientId; |
| 82 | credentialBindings: [...snapshot.credentialBindings, binding], | 217 | if (config.clientSecretCiphertext) payload.clientSecretCiphertext = config.clientSecretCiphertext; |
| 218 | if (config.sourceUrl) payload.sourceUrl = config.sourceUrl; | ||
| 219 | if (config.clientInformationJson) payload.clientInformationJson = config.clientInformationJson; | ||
| 220 | |||
| 221 | if (Object.keys(payload).length === 0) { | ||
| 222 | return null; | ||
| 223 | } | ||
| 224 | |||
| 225 | return JSON.stringify(payload); | ||
| 226 | }; | ||
| 227 | |||
| 228 | const buildOAuthRefreshConfigFromPayload = ( | ||
| 229 | payload: UpsertCredentialBindingPayload, | ||
| 230 | existing: OAuthRefreshConfig, | ||
| 231 | ): OAuthRefreshConfig => ({ | ||
| 232 | tokenEndpoint: | ||
| 233 | normalizeString(payload.oauthTokenEndpoint) | ||
| 234 | ?? existing.tokenEndpoint, | ||
| 235 | authorizationServer: | ||
| 236 | normalizeString(payload.oauthAuthorizationServer) | ||
| 237 | ?? existing.authorizationServer, | ||
| 238 | clientId: normalizeString(payload.oauthClientId) ?? existing.clientId, | ||
| 239 | clientSecretCiphertext: | ||
| 240 | normalizeString(payload.oauthClientSecret) | ||
| 241 | ?? existing.clientSecretCiphertext, | ||
| 242 | sourceUrl: normalizeString(payload.oauthSourceUrl) ?? existing.sourceUrl, | ||
| 243 | clientInformationJson: | ||
| 244 | normalizeString(payload.oauthClientInformationJson) | ||
| 245 | ?? existing.clientInformationJson, | ||
| 83 | }); | 246 | }); |
| 84 | 247 | ||
| 85 | const removeCredentialBindingForWorkspace = ( | 248 | const removeCredentialBindingForWorkspace = ( |
| @@ -88,7 +251,7 @@ const removeCredentialBindingForWorkspace = ( · 2 unmodified lines | |||
| 88 | credentialBindingId: string, | 251 | credentialBindingId: string, |
| 89 | ): { snapshot: LocalStateSnapshot; removed: boolean } => { | 252 | ): { snapshot: LocalStateSnapshot; removed: boolean } => { |
| 90 | const organizationId = resolveWorkspaceOrganizationId(snapshot, workspaceId); | 253 | const organizationId = resolveWorkspaceOrganizationId(snapshot, workspaceId); |
| 91 | const removeIndex = snapshot.credentialBindings.findIndex( | 254 | const removeIndex = snapshot.sourceAuthBindings.findIndex( |
| 92 | (binding) => | 255 | (binding) => |
| 93 | binding.id === credentialBindingId | 256 | binding.id === credentialBindingId |
| 94 | && ( | 257 | && ( |
| @@ -104,15 +267,42 @@ const removeCredentialBindingForWorkspace = ( · 9 unmodified lines | |||
| 104 | }; | 267 | }; |
| 105 | } | 268 | } |
| 106 | 269 | ||
| 107 | const next = [...snapshot.credentialBindings]; | 270 | const bindingToRemove = snapshot.sourceAuthBindings[removeIndex]; |
| 108 | next.splice(removeIndex, 1); | 271 | if (!bindingToRemove) { |
| 272 | return { | ||
| 273 | snapshot, | ||
| 274 | removed: false, | ||
| 275 | }; | ||
| 276 | } | ||
| 277 | |||
| 278 | const nextBindings = [...snapshot.sourceAuthBindings]; | ||
| 279 | nextBindings.splice(removeIndex, 1); | ||
| 280 | |||
| 281 | const hasRemainingBindings = nextBindings.some( | ||
| 282 | (binding) => binding.connectionId === bindingToRemove.connectionId, | ||
| 283 | ); | ||
| 109 | 284 | ||
| 110 | return { | 285 | return { |
| 111 | removed: true, | 286 | removed: true, |
| 112 | snapshot: { | 287 | snapshot: { |
| 113 | ...snapshot, | 288 | ...snapshot, |
| 114 | generatedAt: Date.now(), | 289 | generatedAt: Date.now(), |
| 115 | credentialBindings: next, | 290 | sourceAuthBindings: nextBindings, |
| 291 | authConnections: hasRemainingBindings | ||
| 292 | ? snapshot.authConnections | ||
| 293 | : snapshot.authConnections.filter( | ||
| 294 | (connection) => connection.id !== bindingToRemove.connectionId, | ||
| 295 | ), | ||
| 296 | authMaterials: hasRemainingBindings | ||
| 297 | ? snapshot.authMaterials | ||
| 298 | : snapshot.authMaterials.filter( | ||
| 299 | (material) => material.connectionId !== bindingToRemove.connectionId, | ||
| 300 | ), | ||
| 301 | oauthStates: hasRemainingBindings | ||
| 302 | ? snapshot.oauthStates | ||
| 303 | : snapshot.oauthStates.filter( | ||
| 304 | (state) => state.connectionId !== bindingToRemove.connectionId, | ||
| 305 | ), | ||
| 116 | }, | 306 | }, |
| 117 | }; | 307 | }; |
| 118 | }; | 308 | }; |
| @@ -136,13 +326,27 @@ export const createPmCredentialsService = ( · 17 unmodified lines | |||
| 136 | 326 | ||
| 137 | const organizationId = resolveWorkspaceOrganizationId(snapshot, workspaceId); | 327 | const organizationId = resolveWorkspaceOrganizationId(snapshot, workspaceId); |
| 138 | 328 | ||
| 139 | return sortCredentialBindings( | 329 | const scopedBindings = snapshot.sourceAuthBindings.filter( |
| 140 | snapshot.credentialBindings.filter( | 330 | (binding) => |
| 141 | (binding) => | 331 | binding.workspaceId === workspaceId |
| 142 | binding.workspaceId === workspaceId | 332 | || (binding.workspaceId === null && binding.organizationId === organizationId), |
| 143 | || (binding.workspaceId === null && binding.organizationId === organizationId), | ||
| 144 | ), | ||
| 145 | ); | 333 | ); |
| 334 | |||
| 335 | const compatBindings: Array<SourceCredentialBinding> = []; | ||
| 336 | |||
| 337 | for (const binding of scopedBindings) { | ||
| 338 | const connection = snapshot.authConnections.find( | ||
| 339 | (candidate) => candidate.id === binding.connectionId, | ||
| 340 | ); | ||
| 341 | |||
| 342 | if (!connection) { | ||
| 343 | continue; | ||
| 344 | } | ||
| 345 | |||
| 346 | compatBindings.push(toCompatSourceCredentialBinding(binding, connection)); | ||
| 347 | } | ||
| 348 | |||
| 349 | return sortCredentialBindings(compatBindings); | ||
| 146 | }), | 350 | }), |
| 147 | 351 | ||
| 148 | upsertCredentialBinding: (input) => | 352 | upsertCredentialBinding: (input) => |
| @@ -170,49 +374,181 @@ export const createPmCredentialsService = ( · 21 unmodified lines | |||
| 170 | ); | 374 | ); |
| 171 | } | 375 | } |
| 172 | 376 | ||
| 377 | const sourceId = sourceIdFromSourceKey(input.payload.sourceKey); | ||
| 378 | if (!sourceId) { | ||
| 379 | return yield* toSourceStoreError( | ||
| 380 | "credentials.upsert", | ||
| 381 | "Credentials require sourceKey in the form 'source:<id>'", | ||
| 382 | `workspace=${input.workspaceId}`, | ||
| 383 | ); | ||
| 384 | } | ||
| 385 | |||
| 173 | const now = Date.now(); | 386 | const now = Date.now(); |
| 174 | const requestedId = input.payload.id; | 387 | const requestedId = input.payload.id; |
| 388 | const requestedBindingId = requestedId as SourceAuthBinding["id"] | undefined; | ||
| 175 | 389 | ||
| 176 | const existingIndex = requestedId | 390 | const existingBinding = requestedBindingId |
| 177 | ? snapshot.credentialBindings.findIndex( | 391 | ? snapshot.sourceAuthBindings.find((binding) => binding.id === requestedBindingId) ?? null |
| 178 | (binding) => | 392 | : null; |
| 179 | binding.workspaceId === input.workspaceId && binding.id === requestedId, | ||
| 180 | ) | ||
| 181 | : -1; | ||
| 182 | 393 | ||
| 183 | const existing = existingIndex >= 0 ? snapshot.credentialBindings[existingIndex] : null; | ||
| 184 | const organizationId = resolveWorkspaceOrganizationId(snapshot, input.workspaceId); | 394 | const organizationId = resolveWorkspaceOrganizationId(snapshot, input.workspaceId); |
| 185 | 395 | ||
| 186 | const nextBinding: SourceCredentialBinding = { | 396 | const scopeWorkspaceId = |
| 187 | id: | 397 | input.payload.scopeType === "workspace" ? input.workspaceId : null; |
| 188 | existing?.id | 398 | const scopeAccountId = |
| 189 | ?? (requestedId | 399 | input.payload.scopeType === "account" ? (input.payload.accountId ?? null) : null; |
| 190 | ?? (`credential_binding_${crypto.randomUUID()}` as SourceCredentialBinding["id"])), | 400 | |
| 191 | credentialId: input.payload.credentialId, | 401 | const resolvedBindingId = ( |
| 402 | existingBinding?.id | ||
| 403 | ?? requestedBindingId | ||
| 404 | ?? (`auth_binding_${crypto.randomUUID()}` as SourceAuthBinding["id"]) | ||
| 405 | ) as SourceAuthBinding["id"]; | ||
| 406 | |||
| 407 | const requestedConnectionId = ( | ||
| 408 | normalizeString(input.payload.credentialId) | ||
| 409 | ?? existingBinding?.connectionId | ||
| 410 | ?? (`conn_${crypto.randomUUID()}` as AuthConnection["id"]) | ||
| 411 | ) as AuthConnection["id"]; | ||
| 412 | |||
| 413 | const existingConnection = snapshot.authConnections.find( | ||
| 414 | (connection) => connection.id === requestedConnectionId, | ||
| 415 | ) ?? null; | ||
| 416 | |||
| 417 | if (existingConnection && existingConnection.organizationId !== organizationId) { | ||
| 418 | return yield* toSourceStoreError( | ||
| 419 | "credentials.upsert", | ||
| 420 | "Connection id belongs to another organization", | ||
| 421 | `workspace=${input.workspaceId}`, | ||
| 422 | ); | ||
| 423 | } | ||
| 424 | |||
| 425 | const nextConnection: AuthConnection = { | ||
| 426 | id: requestedConnectionId, | ||
| 427 | organizationId, | ||
| 428 | workspaceId: scopeWorkspaceId, | ||
| 429 | accountId: scopeAccountId, | ||
| 430 | ownerType: | ||
| 431 | input.payload.scopeType === "organization" | ||
| 432 | ? "organization" | ||
| 433 | : input.payload.scopeType === "account" | ||
| 434 | ? "account" | ||
| 435 | : "workspace", | ||
| 436 | strategy: strategyFromProvider(input.payload.provider), | ||
| 437 | displayName: | ||
| 438 | normalizeString(existingConnection?.displayName) | ||
| 439 | ?? sourceKeyFromSourceId(sourceId), | ||
| 440 | status: "active", | ||
| 441 | statusReason: null, | ||
| 442 | lastAuthErrorClass: null, | ||
| 443 | metadataJson: existingConnection?.metadataJson ?? null, | ||
| 444 | additionalHeadersJson: | ||
| 445 | input.payload.additionalHeadersJson !== undefined | ||
| 446 | ? input.payload.additionalHeadersJson | ||
| 447 | : existingConnection?.additionalHeadersJson ?? null, | ||
| 448 | createdByAccountId: existingConnection?.createdByAccountId ?? null, | ||
| 449 | createdAt: existingConnection?.createdAt ?? now, | ||
| 450 | updatedAt: now, | ||
| 451 | lastUsedAt: existingConnection?.lastUsedAt ?? null, | ||
| 452 | }; | ||
| 453 | |||
| 454 | const nextBinding: SourceAuthBinding = { | ||
| 455 | id: resolvedBindingId, | ||
| 456 | sourceId: sourceId as SourceAuthBinding["sourceId"], | ||
| 457 | connectionId: requestedConnectionId, | ||
| 192 | organizationId, | 458 | organizationId, |
| 193 | workspaceId: | 459 | workspaceId: scopeWorkspaceId, |
| 194 | input.payload.scopeType === "workspace" ? input.workspaceId : null, | 460 | accountId: scopeAccountId, |
| 195 | accountId: | ||
| 196 | input.payload.scopeType === "account" | ||
| 197 | ? (input.payload.accountId ?? null) | ||
| 198 | : null, | ||
| 199 | scopeType: input.payload.scopeType, | 461 | scopeType: input.payload.scopeType, |
| 200 | sourceKey: input.payload.sourceKey, | 462 | selector: existingBinding?.selector ?? null, |
| 201 | provider: input.payload.provider, | 463 | enabled: true, |
| 202 | secretProvider: | 464 | createdAt: existingBinding?.createdAt ?? now, |
| 203 | input.payload.secretProvider | ||
| 204 | ?? existing?.secretProvider | ||
| 205 | ?? "local", | ||
| 206 | secretRef: input.payload.secretRef, | ||
| 207 | additionalHeadersJson: input.payload.additionalHeadersJson ?? null, | ||
| 208 | boundAuthFingerprint: input.payload.boundAuthFingerprint ?? null, | ||
| 209 | createdAt: existing?.createdAt ?? now, | ||
| 210 | updatedAt: now, | 465 | updatedAt: now, |
| 211 | }; | 466 | }; |
| 212 | 467 | ||
| 213 | const nextSnapshot = existingIndex >= 0 | 468 | let nextSnapshot: LocalStateSnapshot = { |
| 214 | ? replaceCredentialBindingAt(snapshot, existingIndex, nextBinding) | 469 | ...snapshot, |
| 215 | : appendCredentialBinding(snapshot, nextBinding); | 470 | generatedAt: now, |
| 471 | authConnections: upsertById(snapshot.authConnections, nextConnection), | ||
| 472 | sourceAuthBindings: upsertById(snapshot.sourceAuthBindings, nextBinding), | ||
| 473 | }; | ||
| 474 | |||
| 475 | if (nextConnection.strategy === "oauth2") { | ||
| 476 | const existingOAuth = snapshot.oauthStates.find( | ||
| 477 | (state) => state.connectionId === requestedConnectionId, | ||
| 478 | ) ?? null; | ||
| 479 | const refreshConfig = buildOAuthRefreshConfigFromPayload( | ||
| 480 | input.payload, | ||
| 481 | parseOAuthRefreshConfig(existingOAuth?.refreshConfigJson ?? null), | ||
| 482 | ); | ||
| 483 | |||
| 484 | const oauthState: OAuthState = { | ||
| 485 | id: | ||
| 486 | existingOAuth?.id | ||
| 487 | ?? (`oauth_state_${crypto.randomUUID()}` as OAuthState["id"]), | ||
| 488 | connectionId: requestedConnectionId, | ||
| 489 | accessTokenCiphertext: input.payload.secretRef, | ||
| 490 | refreshTokenCiphertext: | ||
| 491 | input.payload.oauthRefreshToken !== undefined | ||
| 492 | ? normalizeString(input.payload.oauthRefreshToken) | ||
| 493 | : existingOAuth?.refreshTokenCiphertext ?? null, | ||
| 494 | keyVersion: existingOAuth?.keyVersion ?? "local", | ||
| 495 | expiresAt: | ||
| 496 | input.payload.oauthExpiresAt !== undefined | ||
| 497 | ? input.payload.oauthExpiresAt | ||
| 498 | : existingOAuth?.expiresAt ?? null, | ||
| 499 | scope: | ||
| 500 | input.payload.oauthScope !== undefined | ||
| 501 | ? input.payload.oauthScope | ||
| 502 | : existingOAuth?.scope ?? null, | ||
| 503 | tokenType: existingOAuth?.tokenType ?? "Bearer", | ||
| 504 | issuer: | ||
| 505 | input.payload.oauthIssuer !== undefined | ||
| 506 | ? input.payload.oauthIssuer | ||
| 507 | : existingOAuth?.issuer ?? null, | ||
| 508 | refreshConfigJson: encodeOAuthRefreshConfig(refreshConfig), | ||
| 509 | tokenVersion: (existingOAuth?.tokenVersion ?? 0) + 1, | ||
| 510 | leaseHolder: null, | ||
| 511 | leaseExpiresAt: null, | ||
| 512 | leaseFence: existingOAuth?.leaseFence ?? 0, | ||
| 513 | lastRefreshAt: existingOAuth?.lastRefreshAt ?? null, | ||
| 514 | lastRefreshErrorClass: null, | ||
| 515 | lastRefreshError: null, | ||
| 516 | reauthRequiredAt: null, | ||
| 517 | createdAt: existingOAuth?.createdAt ?? now, | ||
| 518 | updatedAt: now, | ||
| 519 | }; | ||
| 520 | |||
| 521 | nextSnapshot = { | ||
| 522 | ...nextSnapshot, | ||
| 523 | authMaterials: nextSnapshot.authMaterials.filter( | ||
| 524 | (material) => material.connectionId !== requestedConnectionId, | ||
| 525 | ), | ||
| 526 | oauthStates: upsertById(nextSnapshot.oauthStates, oauthState), | ||
| 527 | }; | ||
| 528 | } else { | ||
| 529 | const existingMaterial = snapshot.authMaterials.find( | ||
| 530 | (material) => material.connectionId === requestedConnectionId, | ||
| 531 | ) ?? null; | ||
| 532 | |||
| 533 | const material: AuthMaterial = { | ||
| 534 | id: | ||
| 535 | existingMaterial?.id | ||
| 536 | ?? (`auth_material_${crypto.randomUUID()}` as AuthMaterial["id"]), | ||
| 537 | connectionId: requestedConnectionId, | ||
| 538 | ciphertext: input.payload.secretRef, | ||
| 539 | keyVersion: existingMaterial?.keyVersion ?? "local", | ||
| 540 | createdAt: existingMaterial?.createdAt ?? now, | ||
| 541 | updatedAt: now, | ||
| 542 | }; | ||
| 543 | |||
| 544 | nextSnapshot = { | ||
| 545 | ...nextSnapshot, | ||
| 546 | authMaterials: upsertById(nextSnapshot.authMaterials, material), | ||
| 547 | oauthStates: nextSnapshot.oauthStates.filter( | ||
| 548 | (state) => state.connectionId !== requestedConnectionId, | ||
| 549 | ), | ||
| 550 | }; | ||
| 551 | } | ||
| 216 | 552 | ||
| 217 | yield* localStateStore.writeSnapshot(nextSnapshot).pipe( | 553 | yield* localStateStore.writeSnapshot(nextSnapshot).pipe( |
| 218 | Effect.mapError((error) => | 554 | Effect.mapError((error) => |
| @@ -220,7 +556,7 @@ export const createPmCredentialsService = ( · 1 unmodified line | |||
| 220 | ), | 556 | ), |
| 221 | ); | 557 | ); |
| 222 | 558 | ||
| 223 | return nextBinding; | 559 | return toCompatSourceCredentialBinding(nextBinding, nextConnection); |
| 224 | }), | 560 | }), |
| 225 | 561 | ||
| 226 | removeCredentialBinding: (input) => | 562 | removeCredentialBinding: (input) => |
| @@ -28,8 +28,10 @@ const createTestSnapshot = (): LocalStateSnapshot => · 27 unmodified lines | |||
| 28 | workspaces: [], | 28 | workspaces: [], |
| 29 | sources: [], | 29 | sources: [], |
| 30 | toolArtifacts: [], | 30 | toolArtifacts: [], |
| 31 | credentialBindings: [], | 31 | authConnections: [], |
| 32 | oauthTokens: [], | 32 | sourceAuthBindings: [], |
| 33 | authMaterials: [], | ||
| 34 | oauthStates: [], | ||
| 33 | policies: [], | 35 | policies: [], |
| 34 | approvals: [], | 36 | approvals: [], |
| 35 | taskRuns: [], | 37 | taskRuns: [], |
| @@ -38,8 +38,10 @@ const createTestSnapshot = (): LocalStateSnapshot => · 37 unmodified lines | |||
| 38 | ], | 38 | ], |
| 39 | sources: [], | 39 | sources: [], |
| 40 | toolArtifacts: [], | 40 | toolArtifacts: [], |
| 41 | credentialBindings: [], | 41 | authConnections: [], |
| 42 | oauthTokens: [], | 42 | sourceAuthBindings: [], |
| 43 | authMaterials: [], | ||
| 44 | oauthStates: [], | ||
| 43 | policies: [], | 45 | policies: [], |
| 44 | approvals: [], | 46 | approvals: [], |
| 45 | taskRuns: [], | 47 | taskRuns: [], |
| @@ -149,6 +149,14 @@ export async function GET(request: NextRequest): Promise<Response> { · 148 unmodified lines | |||
| 149 | } | 149 | } |
| 150 | 150 | ||
| 151 | const tokens = provider.getTokens(); | 151 | const tokens = provider.getTokens(); |
| 152 | const clientInformation = provider.clientInformation() ?? pending.clientInformation; | ||
| 153 | const clientId = | ||
| 154 | clientInformation && typeof clientInformation.client_id === "string" | ||
| 155 | ? clientInformation.client_id.trim() || undefined | ||
| 156 | : undefined; | ||
| 157 | const clientInformationJson = clientInformation | ||
| 158 | ? JSON.stringify(clientInformation) | ||
| 159 | : undefined; | ||
| 152 | const accessToken = tokens?.access_token?.trim() ?? ""; | 160 | const accessToken = tokens?.access_token?.trim() ?? ""; |
| 153 | 161 | ||
| 154 | if (accessToken.length === 0) { | 162 | if (accessToken.length === 0) { |
| @@ -165,5 +173,7 @@ export async function GET(request: NextRequest): Promise<Response> { · 10 unmodified lines | |||
| 165 | refreshToken: tokens?.refresh_token, | 173 | refreshToken: tokens?.refresh_token, |
| 166 | scope: tokens?.scope, | 174 | scope: tokens?.scope, |
| 167 | expiresIn: typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined, | 175 | expiresIn: typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined, |
| 176 | clientId, | ||
| 177 | clientInformationJson, | ||
| 168 | }); | 178 | }); |
| 169 | } | 179 | } |
| @@ -12,6 +12,8 @@ type PopupMessage = · 11 unmodified lines | |||
| 12 | refreshToken?: string; | 12 | refreshToken?: string; |
| 13 | scope?: string; | 13 | scope?: string; |
| 14 | expiresIn?: number; | 14 | expiresIn?: number; |
| 15 | clientId?: string; | ||
| 16 | clientInformationJson?: string; | ||
| 15 | }; | 17 | }; |
| 16 | } | 18 | } |
| 17 | | { | 19 | | { |
| @@ -17,6 +17,8 @@ type PopupMessage = · 16 unmodified lines | |||
| 17 | refreshToken?: string; | 17 | refreshToken?: string; |
| 18 | scope?: string; | 18 | scope?: string; |
| 19 | expiresIn?: number; | 19 | expiresIn?: number; |
| 20 | clientId?: string; | ||
| 21 | clientInformationJson?: string; | ||
| 20 | }; | 22 | }; |
| 21 | } | 23 | } |
| 22 | | { | 24 | | { |
| @@ -62,6 +64,10 @@ const toPopupMessage = (result: McpOAuthPopupResult | null): PopupMessage => { · 39 unmodified lines | |||
| 62 | ...(typeof result.expiresIn === "number" | 64 | ...(typeof result.expiresIn === "number" |
| 63 | ? { expiresIn: result.expiresIn } | 65 | ? { expiresIn: result.expiresIn } |
| 64 | : {}), | 66 | : {}), |
| 67 | ...(result.clientId ? { clientId: result.clientId } : {}), | ||
| 68 | ...(result.clientInformationJson | ||
| 69 | ? { clientInformationJson: result.clientInformationJson } | ||
| 70 | : {}), | ||
| 65 | }, | 71 | }, |
| 66 | }; | 72 | }; |
| 67 | }; | 73 | }; |
| @@ -110,6 +110,14 @@ export async function GET(request: NextRequest): Promise<Response> { · 109 unmodified lines | |||
| 110 | 110 | ||
| 111 | if (authResult === "AUTHORIZED") { | 111 | if (authResult === "AUTHORIZED") { |
| 112 | const tokens = provider.getTokens(); | 112 | const tokens = provider.getTokens(); |
| 113 | const clientInformation = provider.clientInformation(); | ||
| 114 | const clientId = | ||
| 115 | clientInformation && typeof clientInformation.client_id === "string" | ||
| 116 | ? clientInformation.client_id.trim() || undefined | ||
| 117 | : undefined; | ||
| 118 | const clientInformationJson = clientInformation | ||
| 119 | ? JSON.stringify(clientInformation) | ||
| 120 | : undefined; | ||
| 113 | const accessToken = tokens?.access_token?.trim() ?? ""; | 121 | const accessToken = tokens?.access_token?.trim() ?? ""; |
| 114 | if (accessToken.length === 0) { | 122 | if (accessToken.length === 0) { |
| 115 | return badPopupResponse( | 123 | return badPopupResponse( |
| @@ -126,6 +134,8 @@ export async function GET(request: NextRequest): Promise<Response> { · 10 unmodified lines | |||
| 126 | scope: tokens?.scope, | 134 | scope: tokens?.scope, |
| 127 | expiresIn: | 135 | expiresIn: |
| 128 | typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined, | 136 | typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined, |
| 137 | clientId, | ||
| 138 | clientInformationJson, | ||
| 129 | }); | 139 | }); |
| 130 | } | 140 | } |
| 131 | 141 | ||
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | approvalsByWorkspace, | 9 | approvalsByWorkspace, |
| 10 | optimisticResolveApproval, | 10 | optimisticResolveApproval, |
| 11 | resolveApproval, | 11 | resolveApproval, |
| 12 | toResolveApprovalRequest, | ||
| 12 | } from "../../lib/control-plane/atoms"; | 13 | } from "../../lib/control-plane/atoms"; |
| 13 | import { Button } from "../ui/button"; | 14 | import { Button } from "../ui/button"; |
| 14 | import { | 15 | import { |
| @@ -137,16 +138,14 @@ export function ApprovalsView() { · 122 unmodified lines | |||
| 137 | 138 | ||
| 138 | setOptimisticApprovals(nextOptimistic); | 139 | setOptimisticApprovals(nextOptimistic); |
| 139 | 140 | ||
| 140 | void runResolveApproval({ | 141 | void runResolveApproval(toResolveApprovalRequest({ |
| 141 | path: { | 142 | workspaceId, |
| 142 | workspaceId, | 143 | approvalId, |
| 143 | approvalId, | ||
| 144 | }, | ||
| 145 | payload: { | 144 | payload: { |
| 146 | status, | 145 | status, |
| 147 | reason: null, | 146 | reason: null, |
| 148 | }, | 147 | }, |
| 149 | }) | 148 | })) |
| 150 | .then(() => { | 149 | .then(() => { |
| 151 | setStatus(status === "approved" ? "Approval granted." : "Approval denied."); | 150 | setStatus(status === "approved" ? "Approval granted." : "Approval denied."); |
| 152 | setOptimisticApprovals(null); | 151 | setOptimisticApprovals(null); |
| @@ -10,7 +10,9 @@ import { useWorkspace } from "../../lib/hooks/use-workspace"; · 9 unmodified lines | |||
| 10 | import { | 10 | import { |
| 11 | credentialBindingsByWorkspace, | 11 | credentialBindingsByWorkspace, |
| 12 | removeCredentialBinding, | 12 | removeCredentialBinding, |
| 13 | toCredentialBindingRemoveRequest, | ||
| 13 | toCredentialBindingRemoveResult, | 14 | toCredentialBindingRemoveResult, |
| 15 | toCredentialBindingUpsertRequest, | ||
| 14 | toCredentialBindingUpsertPayload, | 16 | toCredentialBindingUpsertPayload, |
| 15 | upsertCredentialBinding, | 17 | upsertCredentialBinding, |
| 16 | } from "../../lib/control-plane/atoms"; | 18 | } from "../../lib/control-plane/atoms"; |
| @@ -93,7 +95,6 @@ export function CredentialsView() { · 76 unmodified lines | |||
| 93 | binding.provider.toLowerCase().includes(query) || | 95 | binding.provider.toLowerCase().includes(query) || |
| 94 | binding.scopeType.toLowerCase().includes(query) || | 96 | binding.scopeType.toLowerCase().includes(query) || |
| 95 | binding.credentialId.toLowerCase().includes(query) || | 97 | binding.credentialId.toLowerCase().includes(query) || |
| 96 | binding.secretRef.toLowerCase().includes(query) || | ||
| 97 | (binding.accountId ?? "").toLowerCase().includes(query) | 98 | (binding.accountId ?? "").toLowerCase().includes(query) |
| 98 | ); | 99 | ); |
| 99 | }); | 100 | }); |
| @@ -133,11 +134,11 @@ export function CredentialsView() { · 33 unmodified lines | |||
| 133 | setCredentialProvider(binding.provider); | 134 | setCredentialProvider(binding.provider); |
| 134 | setCredentialScopeType(binding.scopeType); | 135 | setCredentialScopeType(binding.scopeType); |
| 135 | setCredentialIdInput(binding.credentialId); | 136 | setCredentialIdInput(binding.credentialId); |
| 136 | setCredentialSecretRef(binding.secretRef); | 137 | setCredentialSecretRef(""); |
| 137 | setCredentialAccountId(binding.accountId ?? ""); | 138 | setCredentialAccountId(binding.accountId ?? ""); |
| 138 | setCredentialAdditionalHeadersJson(binding.additionalHeadersJson ?? ""); | 139 | setCredentialAdditionalHeadersJson(binding.additionalHeadersJson ?? ""); |
| 139 | setCredentialBoundAuthFingerprint(binding.boundAuthFingerprint ?? ""); | 140 | setCredentialBoundAuthFingerprint(binding.boundAuthFingerprint ?? ""); |
| 140 | setInfoStatus(`Loaded binding ${binding.sourceKey} for editing.`); | 141 | setInfoStatus(`Loaded binding ${binding.sourceKey} for editing. Enter a new secret to rotate credentials.`); |
| 141 | }; | 142 | }; |
| 142 | 143 | ||
| 143 | const handleCancelCredentialEdit = () => { | 144 | const handleCancelCredentialEdit = () => { |
| @@ -175,8 +176,8 @@ export function CredentialsView() { · 31 unmodified lines | |||
| 175 | 176 | ||
| 176 | setCredentialBusyId(requestId); | 177 | setCredentialBusyId(requestId); |
| 177 | 178 | ||
| 178 | void runUpsertCredentialBinding({ | 179 | void runUpsertCredentialBinding(toCredentialBindingUpsertRequest({ |
| 179 | path: { workspaceId }, | 180 | workspaceId, |
| 180 | payload: toCredentialBindingUpsertPayload({ | 181 | payload: toCredentialBindingUpsertPayload({ |
| 181 | id: credentialEditingId ?? undefined, | 182 | id: credentialEditingId ?? undefined, |
| 182 | credentialId: credentialId as SourceCredentialBinding["credentialId"], | 183 | credentialId: credentialId as SourceCredentialBinding["credentialId"], |
| @@ -191,7 +192,7 @@ export function CredentialsView() { · 8 unmodified lines | |||
| 191 | additionalHeadersJson: additionalHeadersJson.length > 0 ? additionalHeadersJson : null, | 192 | additionalHeadersJson: additionalHeadersJson.length > 0 ? additionalHeadersJson : null, |
| 192 | boundAuthFingerprint: boundAuthFingerprint.length > 0 ? boundAuthFingerprint : null, | 193 | boundAuthFingerprint: boundAuthFingerprint.length > 0 ? boundAuthFingerprint : null, |
| 193 | }), | 194 | }), |
| 194 | }) | 195 | })) |
| 195 | .then(() => { | 196 | .then(() => { |
| 196 | setInfoStatus( | 197 | setInfoStatus( |
| 197 | credentialEditingId | 198 | credentialEditingId |
| @@ -215,12 +216,9 @@ export function CredentialsView() { · 17 unmodified lines | |||
| 215 | 216 | ||
| 216 | setCredentialBusyId(credentialBindingId); | 217 | setCredentialBusyId(credentialBindingId); |
| 217 | 218 | ||
| 218 | void runRemoveCredentialBinding({ | 219 | void runRemoveCredentialBinding( |
| 219 | path: { | 220 | toCredentialBindingRemoveRequest({ workspaceId, credentialBindingId }), |
| 220 | workspaceId, | 221 | ) |
| 221 | credentialBindingId, | ||
| 222 | }, | ||
| 223 | }) | ||
| 224 | .then((result) => { | 222 | .then((result) => { |
| 225 | const removed = toCredentialBindingRemoveResult(result); | 223 | const removed = toCredentialBindingRemoveResult(result); |
| 226 | setInfoStatus( | 224 | setInfoStatus( |
| @@ -444,7 +442,7 @@ export function CredentialsView() { · 217 unmodified lines | |||
| 444 | id="credential-search" | 442 | id="credential-search" |
| 445 | value={credentialSearchQuery} | 443 | value={credentialSearchQuery} |
| 446 | onChange={(event) => setCredentialSearchQuery(event.target.value)} | 444 | onChange={(event) => setCredentialSearchQuery(event.target.value)} |
| 447 | placeholder="source key, provider, scope, credential id, secret" | 445 | placeholder="source key, provider, scope, credential id" |
| 448 | disabled={credentialBindingsState.state === "loading"} | 446 | disabled={credentialBindingsState.state === "loading"} |
| 449 | /> | 447 | /> |
| 450 | </div> | 448 | </div> |
| @@ -11,8 +11,9 @@ import { · 10 unmodified lines | |||
| 11 | optimisticUpsertPolicy, | 11 | optimisticUpsertPolicy, |
| 12 | policiesByWorkspace, | 12 | policiesByWorkspace, |
| 13 | removePolicy, | 13 | removePolicy, |
| 14 | toPolicyRemoveRequest, | ||
| 14 | toPolicyRemoveResult, | 15 | toPolicyRemoveResult, |
| 15 | toPolicyUpsertPayload, | 16 | toPolicyUpsertRequest, |
| 16 | upsertPolicy, | 17 | upsertPolicy, |
| 17 | } from "../../lib/control-plane/atoms"; | 18 | } from "../../lib/control-plane/atoms"; |
| 18 | import { Badge } from "../ui/badge"; | 19 | import { Badge } from "../ui/badge"; |
| @@ -107,14 +108,12 @@ export function PoliciesView() { · 88 unmodified lines | |||
| 107 | setPolicyBusyId(policyId); | 108 | setPolicyBusyId(policyId); |
| 108 | setOptimisticPolicies(nextPolicies); | 109 | setOptimisticPolicies(nextPolicies); |
| 109 | 110 | ||
| 110 | void runUpsertPolicy({ | 111 | void runUpsertPolicy(toPolicyUpsertRequest({ |
| 111 | path: { workspaceId }, | 112 | workspaceId, |
| 112 | payload: toPolicyUpsertPayload({ | 113 | id: policyEditingId ?? undefined, |
| 113 | id: policyEditingId ?? undefined, | 114 | toolPathPattern, |
| 114 | toolPathPattern, | 115 | decision: policyDecision, |
| 115 | decision: policyDecision, | 116 | })) |
| 116 | }), | ||
| 117 | }) | ||
| 118 | .then(() => { | 117 | .then(() => { |
| 119 | setStatus(policyEditingId ? `Updated policy ${toolPathPattern}.` : `Added policy ${toolPathPattern}.`); | 118 | setStatus(policyEditingId ? `Updated policy ${toolPathPattern}.` : `Added policy ${toolPathPattern}.`); |
| 120 | clearPolicyForm(); | 119 | clearPolicyForm(); |
| @@ -136,7 +135,7 @@ export function PoliciesView() { · 15 unmodified lines | |||
| 136 | setPolicyBusyId(policyId); | 135 | setPolicyBusyId(policyId); |
| 137 | setOptimisticPolicies(nextPolicies); | 136 | setOptimisticPolicies(nextPolicies); |
| 138 | 137 | ||
| 139 | void runRemovePolicy({ path: { workspaceId, policyId } }) | 138 | void runRemovePolicy(toPolicyRemoveRequest({ workspaceId, policyId })) |
| 140 | .then((result) => { | 139 | .then((result) => { |
| 141 | const removed = toPolicyRemoveResult(result); | 140 | const removed = toPolicyRemoveResult(result); |
| 142 | setStatus(removed ? "Policy removed." : "Policy not found."); | 141 | setStatus(removed ? "Policy removed." : "Policy not found."); |
| @@ -7,6 +7,8 @@ import type { Organization, Workspace } from "@executor-v2/schema"; · 6 unmodified lines | |||
| 7 | import { useWorkspace } from "../../lib/hooks/use-workspace"; | 7 | import { useWorkspace } from "../../lib/hooks/use-workspace"; |
| 8 | import { | 8 | import { |
| 9 | organizationsState, | 9 | organizationsState, |
| 10 | toUpsertOrganizationRequest, | ||
| 11 | toUpsertWorkspaceRequest, | ||
| 10 | upsertOrganization, | 12 | upsertOrganization, |
| 11 | upsertWorkspace, | 13 | upsertWorkspace, |
| 12 | toOrganizationUpsertPayload, | 14 | toOrganizationUpsertPayload, |
| @@ -168,7 +170,7 @@ export default function SettingsView() { · 155 unmodified lines | |||
| 168 | status: organizationForm.status, | 170 | status: organizationForm.status, |
| 169 | }); | 171 | }); |
| 170 | 172 | ||
| 171 | void runUpsertOrganization({ payload }) | 173 | void runUpsertOrganization(toUpsertOrganizationRequest({ payload })) |
| 172 | .then((organization) => { | 174 | .then((organization) => { |
| 173 | setOrganizationStatusText(`Saved organization ${organization.slug}.`); | 175 | setOrganizationStatusText(`Saved organization ${organization.slug}.`); |
| 174 | setOrganizationForm({ | 176 | setOrganizationForm({ |
| @@ -209,7 +211,7 @@ export default function SettingsView() { · 34 unmodified lines | |||
| 209 | organizationId: organizationId as Workspace["organizationId"], | 211 | organizationId: organizationId as Workspace["organizationId"], |
| 210 | }); | 212 | }); |
| 211 | 213 | ||
| 212 | void runUpsertWorkspace({ payload }) | 214 | void runUpsertWorkspace(toUpsertWorkspaceRequest({ payload })) |
| 213 | .then((workspace) => { | 215 | .then((workspace) => { |
| 214 | setWorkspaceStatusText(`Saved workspace ${workspace.name}.`); | 216 | setWorkspaceStatusText(`Saved workspace ${workspace.name}.`); |
| 215 | setWorkspaceForm({ | 217 | setWorkspaceForm({ |
| @@ -6,6 +6,8 @@ import type { Organization, Workspace } from "@executor-v2/schema"; · 5 unmodified lines | |||
| 6 | 6 | ||
| 7 | import { | 7 | import { |
| 8 | organizationsState, | 8 | organizationsState, |
| 9 | toUpsertOrganizationRequest, | ||
| 10 | toUpsertWorkspaceRequest, | ||
| 9 | toOrganizationUpsertPayload, | 11 | toOrganizationUpsertPayload, |
| 10 | toWorkspaceUpsertPayload, | 12 | toWorkspaceUpsertPayload, |
| 11 | upsertOrganization, | 13 | upsertOrganization, |
| @@ -278,13 +280,15 @@ export function WorkspaceSelector() { · 266 unmodified lines | |||
| 278 | 280 | ||
| 279 | setCreatingWorkspace(true); | 281 | setCreatingWorkspace(true); |
| 280 | 282 | ||
| 281 | void runUpsertWorkspace({ | 283 | void runUpsertWorkspace( |
| 282 | payload: toWorkspaceUpsertPayload({ | 284 | toUpsertWorkspaceRequest({ |
| 283 | id: createWorkspaceId(), | 285 | payload: toWorkspaceUpsertPayload({ |
| 284 | name, | 286 | id: createWorkspaceId(), |
| 285 | organizationId: organizationId as Workspace["organizationId"], | 287 | name, |
| 288 | organizationId: organizationId as Workspace["organizationId"], | ||
| 289 | }), | ||
| 286 | }), | 290 | }), |
| 287 | }) | 291 | ) |
| 288 | .then((workspace) => { | 292 | .then((workspace) => { |
| 289 | setWorkspaceId(workspace.id); | 293 | setWorkspaceId(workspace.id); |
| 290 | setWorkspaceName(""); | 294 | setWorkspaceName(""); |
| @@ -334,23 +338,27 @@ export function WorkspaceSelector() { · 43 unmodified lines | |||
| 334 | 338 | ||
| 335 | const organizationId = createOrganizationId(); | 339 | const organizationId = createOrganizationId(); |
| 336 | 340 | ||
| 337 | void runUpsertOrganization({ | 341 | void runUpsertOrganization( |
| 338 | payload: toOrganizationUpsertPayload({ | 342 | toUpsertOrganizationRequest({ |
| 339 | id: organizationId, | 343 | payload: toOrganizationUpsertPayload({ |
| 340 | name, | 344 | id: organizationId, |
| 341 | slug, | 345 | name, |
| 342 | status: "active", | 346 | slug, |
| 347 | status: "active", | ||
| 348 | }), | ||
| 343 | }), | 349 | }), |
| 344 | }) | 350 | ) |
| 345 | .then(async (organization) => { | 351 | .then(async (organization) => { |
| 346 | try { | 352 | try { |
| 347 | const workspace = await runUpsertWorkspace({ | 353 | const workspace = await runUpsertWorkspace( |
| 348 | payload: toWorkspaceUpsertPayload({ | 354 | toUpsertWorkspaceRequest({ |
| 349 | id: createWorkspaceId(), | 355 | payload: toWorkspaceUpsertPayload({ |
| 350 | name, | 356 | id: createWorkspaceId(), |
| 351 | organizationId: organization.id as Workspace["organizationId"], | 357 | name, |
| 358 | organizationId: organization.id as Workspace["organizationId"], | ||
| 359 | }), | ||
| 352 | }), | 360 | }), |
| 353 | }); | 361 | ); |
| 354 | 362 | ||
| 355 | setWorkspaceId(workspace.id); | 363 | setWorkspaceId(workspace.id); |
| 356 | setWorkspaceOrganizationId(organization.id); | 364 | setWorkspaceOrganizationId(organization.id); |
| @@ -24,11 +24,14 @@ import { · 23 unmodified lines | |||
| 24 | readStorageFile, | 24 | readStorageFile, |
| 25 | removeStorageInstance, | 25 | removeStorageInstance, |
| 26 | storageByWorkspace, | 26 | storageByWorkspace, |
| 27 | toCloseStorageRequest, | ||
| 27 | toListStorageDirectoryPayload, | 28 | toListStorageDirectoryPayload, |
| 28 | toListStorageKvPayload, | 29 | toListStorageKvPayload, |
| 29 | toOpenStoragePayload, | 30 | toOpenStoragePayload, |
| 31 | toOpenStorageRequest, | ||
| 30 | toQueryStorageSqlPayload, | 32 | toQueryStorageSqlPayload, |
| 31 | toReadStorageFilePayload, | 33 | toReadStorageFilePayload, |
| 34 | toRemoveStorageRequest, | ||
| 32 | toStorageRemoveResult, | 35 | toStorageRemoveResult, |
| 33 | } from "../../lib/control-plane/atoms" | 36 | } from "../../lib/control-plane/atoms" |
| 34 | import { Badge } from "../ui/badge" | 37 | import { Badge } from "../ui/badge" |
| @@ -216,20 +219,22 @@ export default function StorageView() { · 181 unmodified lines | |||
| 216 | 219 | ||
| 217 | setStorageBusyId("create") | 220 | setStorageBusyId("create") |
| 218 | 221 | ||
| 219 | void runOpenStorageInstance({ | 222 | void runOpenStorageInstance( |
| 220 | path: { workspaceId }, | 223 | toOpenStorageRequest({ |
| 221 | payload: toOpenStoragePayload({ | 224 | workspaceId, |
| 222 | scopeType: storageScopeType, | 225 | payload: toOpenStoragePayload({ |
| 223 | durability: storageDurability, | 226 | scopeType: storageScopeType, |
| 224 | provider: storageProvider, | 227 | durability: storageDurability, |
| 225 | purpose: purpose.length > 0 ? purpose : undefined, | 228 | provider: storageProvider, |
| 226 | ttlHours: storageDurability === "ephemeral" ? ttlHours : undefined, | 229 | purpose: purpose.length > 0 ? purpose : undefined, |
| 227 | accountId: | 230 | ttlHours: storageDurability === "ephemeral" ? ttlHours : undefined, |
| 228 | storageScopeType === "account" | 231 | accountId: |
| 229 | ? (accountId as Exclude<StorageInstance["accountId"], null>) | 232 | storageScopeType === "account" |
| 230 | : undefined, | 233 | ? (accountId as Exclude<StorageInstance["accountId"], null>) |
| 234 | : undefined, | ||
| 235 | }), | ||
| 231 | }), | 236 | }), |
| 232 | }) | 237 | ) |
| 233 | .then((storageInstance) => { | 238 | .then((storageInstance) => { |
| 234 | setStorageSelectedId(storageInstance.id) | 239 | setStorageSelectedId(storageInstance.id) |
| 235 | setStoragePurposeInput("") | 240 | setStoragePurposeInput("") |
| @@ -253,9 +258,7 @@ export default function StorageView() { · 17 unmodified lines | |||
| 253 | 258 | ||
| 254 | setStorageBusyId(storageInstanceId) | 259 | setStorageBusyId(storageInstanceId) |
| 255 | 260 | ||
| 256 | void runCloseStorageInstance({ | 261 | void runCloseStorageInstance(toCloseStorageRequest({ workspaceId, storageInstanceId })) |
| 257 | path: { workspaceId, storageInstanceId }, | ||
| 258 | }) | ||
| 259 | .then(() => { | 262 | .then(() => { |
| 260 | setInfoStatus("Storage instance closed.") | 263 | setInfoStatus("Storage instance closed.") |
| 261 | }) | 264 | }) |
| @@ -274,9 +277,7 @@ export default function StorageView() { · 12 unmodified lines | |||
| 274 | 277 | ||
| 275 | setStorageBusyId(storageInstanceId) | 278 | setStorageBusyId(storageInstanceId) |
| 276 | 279 | ||
| 277 | void runRemoveStorageInstance({ | 280 | void runRemoveStorageInstance(toRemoveStorageRequest({ workspaceId, storageInstanceId })) |
| 278 | path: { workspaceId, storageInstanceId }, | ||
| 279 | }) | ||
| 280 | .then((result) => { | 281 | .then((result) => { |
| 281 | const removed = toStorageRemoveResult(result) | 282 | const removed = toStorageRemoveResult(result) |
| 282 | setInfoStatus( | 283 | setInfoStatus( |
| @@ -14,7 +14,10 @@ import { · 13 unmodified lines | |||
| 14 | removeSource, | 14 | removeSource, |
| 15 | sourcesByWorkspace, | 15 | sourcesByWorkspace, |
| 16 | sourcesPendingByWorkspace, | 16 | sourcesPendingByWorkspace, |
| 17 | toCredentialBindingUpsertRequest, | ||
| 17 | toCredentialBindingUpsertPayload, | 18 | toCredentialBindingUpsertPayload, |
| 19 | toRemoveSourceRequest, | ||
| 20 | toUpsertSourceRequest, | ||
| 18 | upsertCredentialBinding, | 21 | upsertCredentialBinding, |
| 19 | upsertSource, | 22 | upsertSource, |
| 20 | } from "../../lib/control-plane/atoms"; | 23 | } from "../../lib/control-plane/atoms"; |
| @@ -468,7 +471,7 @@ export function SourceManagementPanel({ editSource, onDone }: SourceManagementPa · 447 unmodified lines | |||
| 468 | 471 | ||
| 469 | setOptimisticSources({ items: optimistic.items, pendingAck: { kind: "upsert", sourceId: optimistic.sourceId } }); | 472 | setOptimisticSources({ items: optimistic.items, pendingAck: { kind: "upsert", sourceId: optimistic.sourceId } }); |
| 470 | 473 | ||
| 471 | void runUpsertSource({ path: { workspaceId }, payload }) | 474 | void runUpsertSource(toUpsertSourceRequest({ workspaceId, payload })) |
| 472 | .then(async () => { | 475 | .then(async () => { |
| 473 | let oauthLinked = false; | 476 | let oauthLinked = false; |
| 474 | let oauthLinkNote: string | null = null; | 477 | let oauthLinkNote: string | null = null; |
| @@ -482,21 +485,37 @@ export function SourceManagementPanel({ editSource, onDone }: SourceManagementPa · 7 unmodified lines | |||
| 482 | if (formState.authMode === "account") { | 485 | if (formState.authMode === "account") { |
| 483 | oauthLinkNote = "Account scope is not supported from this flow; OAuth credential was saved as workspace scope."; | 486 | oauthLinkNote = "Account scope is not supported from this flow; OAuth credential was saved as workspace scope."; |
| 484 | } | 487 | } |
| 488 | const oauthExpiresAt = | ||
| 489 | typeof oauthSessionForEndpoint.expiresIn === "number" | ||
| 490 | ? Date.now() + Math.max(0, Math.floor(oauthSessionForEndpoint.expiresIn)) * 1000 | ||
| 491 | : null; | ||
| 492 | const oauthAuthorizationServer = | ||
| 493 | mcpOAuthDetection.authorizationServers[0] ?? null; | ||
| 485 | try { | 494 | try { |
| 486 | await runUpsertCredentialBinding({ | 495 | await runUpsertCredentialBinding( |
| 487 | path: { workspaceId }, | 496 | toCredentialBindingUpsertRequest({ |
| 488 | payload: toCredentialBindingUpsertPayload({ | 497 | workspaceId, |
| 489 | ...(existingBinding ? { id: existingBinding.id } : {}), | 498 | payload: toCredentialBindingUpsertPayload({ |
| 490 | credentialId: (existingBinding?.credentialId ?? createLocalId("cred_")) as SourceCredentialBinding["credentialId"], | 499 | ...(existingBinding ? { id: existingBinding.id } : {}), |
| 491 | scopeType, | 500 | credentialId: (existingBinding?.credentialId ?? createLocalId("cred_")) as SourceCredentialBinding["credentialId"], |
| 492 | sourceKey, | 501 | scopeType, |
| 493 | provider: "oauth2", | 502 | sourceKey, |
| 494 | secretRef: oauthSessionForEndpoint.accessToken, | 503 | provider: "oauth2", |
| 495 | accountId: null, | 504 | secretRef: oauthSessionForEndpoint.accessToken, |
| 496 | additionalHeadersJson: null, | 505 | accountId: null, |
| 497 | boundAuthFingerprint: null, | 506 | additionalHeadersJson: null, |
| 507 | boundAuthFingerprint: null, | ||
| 508 | oauthRefreshToken: oauthSessionForEndpoint.refreshToken ?? null, | ||
| 509 | oauthExpiresAt, | ||
| 510 | oauthScope: oauthSessionForEndpoint.scope ?? null, | ||
| 511 | oauthAuthorizationServer, | ||
| 512 | oauthClientId: oauthSessionForEndpoint.clientId ?? null, | ||
| 513 | oauthSourceUrl: oauthSessionForEndpoint.sourceUrl, | ||
| 514 | oauthClientInformationJson: | ||
| 515 | oauthSessionForEndpoint.clientInformationJson ?? null, | ||
| 516 | }), | ||
| 498 | }), | 517 | }), |
| 499 | }); | 518 | ); |
| 500 | oauthLinked = true; | 519 | oauthLinked = true; |
| 501 | setMcpOAuthSession(null); | 520 | setMcpOAuthSession(null); |
| 502 | } catch { | 521 | } catch { |
| @@ -526,7 +545,7 @@ export function SourceManagementPanel({ editSource, onDone }: SourceManagementPa · 23 unmodified lines | |||
| 526 | const optimistic = optimisticRemoveSources(previousSources, sourceId); | 545 | const optimistic = optimisticRemoveSources(previousSources, sourceId); |
| 527 | setOptimisticSources({ items: optimistic.items, pendingAck: { kind: "remove", sourceId: optimistic.sourceId } }); | 546 | setOptimisticSources({ items: optimistic.items, pendingAck: { kind: "remove", sourceId: optimistic.sourceId } }); |
| 528 | 547 | ||
| 529 | void runRemoveSource({ path: { workspaceId, sourceId } }) | 548 | void runRemoveSource(toRemoveSourceRequest({ workspaceId, sourceId })) |
| 530 | .then(() => { | 549 | .then(() => { |
| 531 | setStatusText("Source removed."); | 550 | setStatusText("Source removed."); |
| 532 | resetForm(); | 551 | resetForm(); |
| @@ -4,7 +4,7 @@ import type { Approval, ApprovalId, WorkspaceId } from "@executor-v2/schema"; · 3 unmodified lines | |||
| 4 | 4 | ||
| 5 | import { controlPlaneClient } from "../client"; | 5 | import { controlPlaneClient } from "../client"; |
| 6 | import { workspaceEntity, type EntityState } from "./entity"; | 6 | import { workspaceEntity, type EntityState } from "./entity"; |
| 7 | import { approvalsKeys } from "./keys"; | 7 | import { approvalsKeys, approvalsMutationKeys } from "./keys"; |
| 8 | 8 | ||
| 9 | // --------------------------------------------------------------------------- | 9 | // --------------------------------------------------------------------------- |
| 10 | // Query atoms | 10 | // Query atoms |
| @@ -14,7 +14,7 @@ export const approvalsResultByWorkspace = Atom.family( · 3 unmodified lines | |||
| 14 | (workspaceId: WorkspaceId) => | 14 | (workspaceId: WorkspaceId) => |
| 15 | controlPlaneClient.query("approvals", "list", { | 15 | controlPlaneClient.query("approvals", "list", { |
| 16 | path: { workspaceId }, | 16 | path: { workspaceId }, |
| 17 | reactivityKeys: approvalsKeys, | 17 | reactivityKeys: approvalsKeys(workspaceId), |
| 18 | }), | 18 | }), |
| 19 | ); | 19 | ); |
| 20 | 20 | ||
| @@ -49,6 +49,19 @@ export const approvalPendingByWorkspace = Atom.family((workspaceId: WorkspaceId) · 28 unmodified lines | |||
| 49 | 49 | ||
| 50 | export const resolveApproval = controlPlaneClient.mutation("approvals", "resolve"); | 50 | export const resolveApproval = controlPlaneClient.mutation("approvals", "resolve"); |
| 51 | 51 | ||
| 52 | export const toResolveApprovalRequest = (input: { | ||
| 53 | workspaceId: WorkspaceId; | ||
| 54 | approvalId: ApprovalId; | ||
| 55 | payload: ResolveApprovalPayload; | ||
| 56 | }) => ({ | ||
| 57 | path: { | ||
| 58 | workspaceId: input.workspaceId, | ||
| 59 | approvalId: input.approvalId, | ||
| 60 | }, | ||
| 61 | payload: input.payload, | ||
| 62 | reactivityKeys: approvalsMutationKeys(input.workspaceId), | ||
| 63 | }); | ||
| 64 | |||
| 52 | // --------------------------------------------------------------------------- | 65 | // --------------------------------------------------------------------------- |
| 53 | // Optimistic helpers | 66 | // Optimistic helpers |
| 54 | // --------------------------------------------------------------------------- | 67 | // --------------------------------------------------------------------------- |
| @@ -13,7 +13,7 @@ import type { · 12 unmodified lines | |||
| 13 | 13 | ||
| 14 | import { controlPlaneClient } from "../client"; | 14 | import { controlPlaneClient } from "../client"; |
| 15 | import { workspaceEntity, type EntityState } from "./entity"; | 15 | import { workspaceEntity, type EntityState } from "./entity"; |
| 16 | import { credentialsKeys } from "./keys"; | 16 | import { credentialsKeys, credentialsMutationKeys } from "./keys"; |
| 17 | 17 | ||
| 18 | // --------------------------------------------------------------------------- | 18 | // --------------------------------------------------------------------------- |
| 19 | // Query atoms | 19 | // Query atoms |
| @@ -23,7 +23,7 @@ export const credentialBindingsResultByWorkspace = Atom.family( · 3 unmodified lines | |||
| 23 | (workspaceId: WorkspaceId) => | 23 | (workspaceId: WorkspaceId) => |
| 24 | controlPlaneClient.query("credentials", "list", { | 24 | controlPlaneClient.query("credentials", "list", { |
| 25 | path: { workspaceId }, | 25 | path: { workspaceId }, |
| 26 | reactivityKeys: credentialsKeys, | 26 | reactivityKeys: credentialsKeys(workspaceId), |
| 27 | }), | 27 | }), |
| 28 | ); | 28 | ); |
| 29 | 29 | ||
| @@ -64,6 +64,16 @@ export const toCredentialBindingUpsertPayload = (input: { · 34 unmodified lines | |||
| 64 | accountId?: SourceCredentialBinding["accountId"]; | 64 | accountId?: SourceCredentialBinding["accountId"]; |
| 65 | additionalHeadersJson?: SourceCredentialBinding["additionalHeadersJson"]; | 65 | additionalHeadersJson?: SourceCredentialBinding["additionalHeadersJson"]; |
| 66 | boundAuthFingerprint?: SourceCredentialBinding["boundAuthFingerprint"]; | 66 | boundAuthFingerprint?: SourceCredentialBinding["boundAuthFingerprint"]; |
| 67 | oauthRefreshToken?: string | null; | ||
| 68 | oauthExpiresAt?: number | null; | ||
| 69 | oauthScope?: string | null; | ||
| 70 | oauthIssuer?: string | null; | ||
| 71 | oauthTokenEndpoint?: string | null; | ||
| 72 | oauthAuthorizationServer?: string | null; | ||
| 73 | oauthClientId?: string | null; | ||
| 74 | oauthClientSecret?: string | null; | ||
| 75 | oauthSourceUrl?: string | null; | ||
| 76 | oauthClientInformationJson?: string | null; | ||
| 67 | }): UpsertCredentialBindingPayload => ({ | 77 | }): UpsertCredentialBindingPayload => ({ |
| 68 | ...(input.id ? { id: input.id } : {}), | 78 | ...(input.id ? { id: input.id } : {}), |
| 69 | credentialId: input.credentialId, | 79 | credentialId: input.credentialId, |
| @@ -78,6 +88,52 @@ export const toCredentialBindingUpsertPayload = (input: { · 8 unmodified lines | |||
| 78 | ...(input.boundAuthFingerprint !== undefined | 88 | ...(input.boundAuthFingerprint !== undefined |
| 79 | ? { boundAuthFingerprint: input.boundAuthFingerprint } | 89 | ? { boundAuthFingerprint: input.boundAuthFingerprint } |
| 80 | : {}), | 90 | : {}), |
| 91 | ...(input.oauthRefreshToken !== undefined | ||
| 92 | ? { oauthRefreshToken: input.oauthRefreshToken } | ||
| 93 | : {}), | ||
| 94 | ...(input.oauthExpiresAt !== undefined | ||
| 95 | ? { oauthExpiresAt: input.oauthExpiresAt } | ||
| 96 | : {}), | ||
| 97 | ...(input.oauthScope !== undefined ? { oauthScope: input.oauthScope } : {}), | ||
| 98 | ...(input.oauthIssuer !== undefined ? { oauthIssuer: input.oauthIssuer } : {}), | ||
| 99 | ...(input.oauthTokenEndpoint !== undefined | ||
| 100 | ? { oauthTokenEndpoint: input.oauthTokenEndpoint } | ||
| 101 | : {}), | ||
| 102 | ...(input.oauthAuthorizationServer !== undefined | ||
| 103 | ? { oauthAuthorizationServer: input.oauthAuthorizationServer } | ||
| 104 | : {}), | ||
| 105 | ...(input.oauthClientId !== undefined | ||
| 106 | ? { oauthClientId: input.oauthClientId } | ||
| 107 | : {}), | ||
| 108 | ...(input.oauthClientSecret !== undefined | ||
| 109 | ? { oauthClientSecret: input.oauthClientSecret } | ||
| 110 | : {}), | ||
| 111 | ...(input.oauthSourceUrl !== undefined | ||
| 112 | ? { oauthSourceUrl: input.oauthSourceUrl } | ||
| 113 | : {}), | ||
| 114 | ...(input.oauthClientInformationJson !== undefined | ||
| 115 | ? { oauthClientInformationJson: input.oauthClientInformationJson } | ||
| 116 | : {}), | ||
| 117 | }); | ||
| 118 | |||
| 119 | export const toCredentialBindingUpsertRequest = (input: { | ||
| 120 | workspaceId: WorkspaceId; | ||
| 121 | payload: UpsertCredentialBindingPayload; | ||
| 122 | }) => ({ | ||
| 123 | path: { workspaceId: input.workspaceId }, | ||
| 124 | payload: input.payload, | ||
| 125 | reactivityKeys: credentialsMutationKeys(input.workspaceId), | ||
| 126 | }); | ||
| 127 | |||
| 128 | export const toCredentialBindingRemoveRequest = (input: { | ||
| 129 | workspaceId: WorkspaceId; | ||
| 130 | credentialBindingId: SourceCredentialBinding["id"]; | ||
| 131 | }) => ({ | ||
| 132 | path: { | ||
| 133 | workspaceId: input.workspaceId, | ||
| 134 | credentialBindingId: input.credentialBindingId, | ||
| 135 | }, | ||
| 136 | reactivityKeys: credentialsMutationKeys(input.workspaceId), | ||
| 81 | }); | 137 | }); |
| 82 | 138 | ||
| 83 | export const toCredentialBindingRemoveResult = ( | 139 | export const toCredentialBindingRemoveResult = ( |
| @@ -25,6 +25,8 @@ export { · 24 unmodified lines | |||
| 25 | removeSource, | 25 | removeSource, |
| 26 | optimisticUpsertSources, | 26 | optimisticUpsertSources, |
| 27 | optimisticRemoveSources, | 27 | optimisticRemoveSources, |
| 28 | toUpsertSourceRequest, | ||
| 29 | toRemoveSourceRequest, | ||
| 28 | } from "./sources"; | 30 | } from "./sources"; |
| 29 | 31 | ||
| 30 | export { | 32 | export { |
| @@ -40,6 +42,7 @@ export { · 9 unmodified lines | |||
| 40 | approvalPendingByWorkspace, | 42 | approvalPendingByWorkspace, |
| 41 | resolveApproval, | 43 | resolveApproval, |
| 42 | optimisticResolveApproval, | 44 | optimisticResolveApproval, |
| 45 | toResolveApprovalRequest, | ||
| 43 | } from "./approvals"; | 46 | } from "./approvals"; |
| 44 | 47 | ||
| 45 | export { | 48 | export { |
| @@ -50,6 +53,8 @@ export { · 4 unmodified lines | |||
| 50 | optimisticUpsertPolicy, | 53 | optimisticUpsertPolicy, |
| 51 | optimisticRemovePolicy, | 54 | optimisticRemovePolicy, |
| 52 | toPolicyUpsertPayload, | 55 | toPolicyUpsertPayload, |
| 56 | toPolicyUpsertRequest, | ||
| 57 | toPolicyRemoveRequest, | ||
| 53 | toPolicyRemoveResult, | 58 | toPolicyRemoveResult, |
| 54 | } from "./policies"; | 59 | } from "./policies"; |
| 55 | 60 | ||
| @@ -59,6 +64,8 @@ export { · 3 unmodified lines | |||
| 59 | upsertCredentialBinding, | 64 | upsertCredentialBinding, |
| 60 | removeCredentialBinding, | 65 | removeCredentialBinding, |
| 61 | toCredentialBindingUpsertPayload, | 66 | toCredentialBindingUpsertPayload, |
| 67 | toCredentialBindingUpsertRequest, | ||
| 68 | toCredentialBindingRemoveRequest, | ||
| 62 | toCredentialBindingRemoveResult, | 69 | toCredentialBindingRemoveResult, |
| 63 | } from "./credentials"; | 70 | } from "./credentials"; |
| 64 | 71 | ||
| @@ -73,6 +80,9 @@ export { · 8 unmodified lines | |||
| 73 | listStorageKv, | 80 | listStorageKv, |
| 74 | queryStorageSql, | 81 | queryStorageSql, |
| 75 | toOpenStoragePayload, | 82 | toOpenStoragePayload, |
| 83 | toOpenStorageRequest, | ||
| 84 | toCloseStorageRequest, | ||
| 85 | toRemoveStorageRequest, | ||
| 76 | toStorageRemoveResult, | 86 | toStorageRemoveResult, |
| 77 | toListStorageDirectoryPayload, | 87 | toListStorageDirectoryPayload, |
| 78 | toReadStorageFilePayload, | 88 | toReadStorageFilePayload, |
| @@ -101,8 +111,10 @@ export { · 22 unmodified lines | |||
| 101 | organizationsState, | 111 | organizationsState, |
| 102 | upsertOrganization, | 112 | upsertOrganization, |
| 103 | toOrganizationUpsertPayload, | 113 | toOrganizationUpsertPayload, |
| 114 | toUpsertOrganizationRequest, | ||
| 104 | workspacesResult, | 115 | workspacesResult, |
| 105 | workspacesState, | 116 | workspacesState, |
| 106 | upsertWorkspace, | 117 | upsertWorkspace, |
| 107 | toWorkspaceUpsertPayload, | 118 | toWorkspaceUpsertPayload, |
| 119 | toUpsertWorkspaceRequest, | ||
| 108 | } from "./settings"; | 120 | } from "./settings"; |
| @@ -1,22 +1,65 @@ | |||
| 1 | import type { WorkspaceId } from "@executor-v2/schema"; | ||
| 2 | |||
| 1 | // --------------------------------------------------------------------------- | 3 | // --------------------------------------------------------------------------- |
| 2 | // Reactivity keys | 4 | // Reactivity keys |
| 3 | // | 5 | // |
| 4 | // Queries subscribe to keys; mutations invalidate keys. | 6 | // Queries subscribe to keys; mutations invalidate keys. |
| 5 | // When a mutation shares keys with a query, the query auto-refetches. | 7 | // Matching keys trigger auto-refetches. |
| 6 | // | 8 | // |
| 7 | // Following the opencode-control pattern: each domain declares its own keys | 9 | // Workspace-scoped keys include the concrete workspace id so invalidation stays |
| 8 | // and spreads a shared workspace key so that switching workspace invalidates | 10 | // precise instead of refreshing unrelated workspace queries. |
| 9 | // all workspace-scoped data. | ||
| 10 | // --------------------------------------------------------------------------- | 11 | // --------------------------------------------------------------------------- |
| 11 | 12 | ||
| 12 | export const workspaceKeys = { workspace: ["selected"] } as const; | 13 | const workspaceScope = (workspaceId: WorkspaceId) => ({ |
| 14 | workspace: [workspaceId], | ||
| 15 | } as const); | ||
| 16 | |||
| 17 | export const sourcesKeys = (workspaceId: WorkspaceId) => ({ | ||
| 18 | ...workspaceScope(workspaceId), | ||
| 19 | sources: ["list"], | ||
| 20 | } as const); | ||
| 21 | |||
| 22 | export const toolsKeys = (workspaceId: WorkspaceId) => ({ | ||
| 23 | ...workspaceScope(workspaceId), | ||
| 24 | tools: ["list"], | ||
| 25 | } as const); | ||
| 26 | |||
| 27 | export const toolDetailKeys = (workspaceId: WorkspaceId) => ({ | ||
| 28 | ...workspaceScope(workspaceId), | ||
| 29 | toolDetail: ["single"], | ||
| 30 | } as const); | ||
| 31 | |||
| 32 | export const approvalsKeys = (workspaceId: WorkspaceId) => ({ | ||
| 33 | ...workspaceScope(workspaceId), | ||
| 34 | approvals: ["list"], | ||
| 35 | } as const); | ||
| 36 | |||
| 37 | export const policiesKeys = (workspaceId: WorkspaceId) => ({ | ||
| 38 | ...workspaceScope(workspaceId), | ||
| 39 | policies: ["list"], | ||
| 40 | } as const); | ||
| 41 | |||
| 42 | export const credentialsKeys = (workspaceId: WorkspaceId) => ({ | ||
| 43 | ...workspaceScope(workspaceId), | ||
| 44 | credentials: ["list"], | ||
| 45 | } as const); | ||
| 46 | |||
| 47 | export const storageKeys = (workspaceId: WorkspaceId) => ({ | ||
| 48 | ...workspaceScope(workspaceId), | ||
| 49 | storage: ["list"], | ||
| 50 | } as const); | ||
| 51 | |||
| 52 | export const sourceMutationKeys = (workspaceId: WorkspaceId) => ({ | ||
| 53 | ...workspaceScope(workspaceId), | ||
| 54 | sources: ["list"], | ||
| 55 | tools: ["list"], | ||
| 56 | toolDetail: ["single"], | ||
| 57 | } as const); | ||
| 58 | |||
| 59 | export const approvalsMutationKeys = approvalsKeys; | ||
| 60 | export const policiesMutationKeys = policiesKeys; | ||
| 61 | export const credentialsMutationKeys = credentialsKeys; | ||
| 62 | export const storageMutationKeys = storageKeys; | ||
| 13 | 63 | ||
| 14 | export const sourcesKeys = { ...workspaceKeys, sources: ["list"] } as const; | ||
| 15 | export const toolsKeys = { ...workspaceKeys, tools: ["list"] } as const; | ||
| 16 | export const toolDetailKeys = { ...workspaceKeys, toolDetail: ["single"] } as const; | ||
| 17 | export const approvalsKeys = { ...workspaceKeys, approvals: ["list"] } as const; | ||
| 18 | export const policiesKeys = { ...workspaceKeys, policies: ["list"] } as const; | ||
| 19 | export const credentialsKeys = { ...workspaceKeys, credentials: ["list"] } as const; | ||
| 20 | export const storageKeys = { ...workspaceKeys, storage: ["list"] } as const; | ||
| 21 | export const organizationsKeys = { organizations: ["list"] } as const; | 64 | export const organizationsKeys = { organizations: ["list"] } as const; |
| 22 | export const workspacesKeys = { workspaces: ["list"] } as const; | 65 | export const workspacesKeys = { workspaces: ["list"] } as const; |
| @@ -4,7 +4,7 @@ import type { Policy, PolicyDecision, PolicyId, WorkspaceId } from "@executor-v2 · 3 unmodified lines | |||
| 4 | 4 | ||
| 5 | import { controlPlaneClient } from "../client"; | 5 | import { controlPlaneClient } from "../client"; |
| 6 | import { workspaceEntity, type EntityState } from "./entity"; | 6 | import { workspaceEntity, type EntityState } from "./entity"; |
| 7 | import { policiesKeys } from "./keys"; | 7 | import { policiesKeys, policiesMutationKeys } from "./keys"; |
| 8 | 8 | ||
| 9 | // --------------------------------------------------------------------------- | 9 | // --------------------------------------------------------------------------- |
| 10 | // Query atoms | 10 | // Query atoms |
| @@ -14,7 +14,7 @@ export const policiesResultByWorkspace = Atom.family( · 3 unmodified lines | |||
| 14 | (workspaceId: WorkspaceId) => | 14 | (workspaceId: WorkspaceId) => |
| 15 | controlPlaneClient.query("policies", "list", { | 15 | controlPlaneClient.query("policies", "list", { |
| 16 | path: { workspaceId }, | 16 | path: { workspaceId }, |
| 17 | reactivityKeys: policiesKeys, | 17 | reactivityKeys: policiesKeys(workspaceId), |
| 18 | }), | 18 | }), |
| 19 | ); | 19 | ); |
| 20 | 20 | ||
| @@ -87,6 +87,32 @@ export const toPolicyUpsertPayload = (input: { · 66 unmodified lines | |||
| 87 | decision: input.decision, | 87 | decision: input.decision, |
| 88 | }); | 88 | }); |
| 89 | 89 | ||
| 90 | export const toPolicyUpsertRequest = (input: { | ||
| 91 | workspaceId: WorkspaceId; | ||
| 92 | id?: PolicyId; | ||
| 93 | toolPathPattern: string; | ||
| 94 | decision: PolicyDecision; | ||
| 95 | }) => ({ | ||
| 96 | path: { workspaceId: input.workspaceId }, | ||
| 97 | payload: toPolicyUpsertPayload({ | ||
| 98 | id: input.id, | ||
| 99 | toolPathPattern: input.toolPathPattern, | ||
| 100 | decision: input.decision, | ||
| 101 | }), | ||
| 102 | reactivityKeys: policiesMutationKeys(input.workspaceId), | ||
| 103 | }); | ||
| 104 | |||
| 105 | export const toPolicyRemoveRequest = (input: { | ||
| 106 | workspaceId: WorkspaceId; | ||
| 107 | policyId: PolicyId; | ||
| 108 | }) => ({ | ||
| 109 | path: { | ||
| 110 | workspaceId: input.workspaceId, | ||
| 111 | policyId: input.policyId, | ||
| 112 | }, | ||
| 113 | reactivityKeys: policiesMutationKeys(input.workspaceId), | ||
| 114 | }); | ||
| 115 | |||
| 90 | export const toPolicyRemoveResult = (result: RemovePolicyResult): boolean => result.removed; | 116 | export const toPolicyRemoveResult = (result: RemovePolicyResult): boolean => result.removed; |
| 91 | 117 | ||
| 92 | export type PoliciesState = EntityState<Policy>; | 118 | export type PoliciesState = EntityState<Policy>; |
| @@ -43,6 +43,13 @@ export const toOrganizationUpsertPayload = (input: { · 42 unmodified lines | |||
| 43 | ...(input.status !== undefined ? { status: input.status } : {}), | 43 | ...(input.status !== undefined ? { status: input.status } : {}), |
| 44 | }); | 44 | }); |
| 45 | 45 | ||
| 46 | export const toUpsertOrganizationRequest = (input: { | ||
| 47 | payload: UpsertOrganizationPayload; | ||
| 48 | }) => ({ | ||
| 49 | payload: input.payload, | ||
| 50 | reactivityKeys: organizationsKeys, | ||
| 51 | }); | ||
| 52 | |||
| 46 | // --------------------------------------------------------------------------- | 53 | // --------------------------------------------------------------------------- |
| 47 | // Workspaces | 54 | // Workspaces |
| 48 | // --------------------------------------------------------------------------- | 55 | // --------------------------------------------------------------------------- |
| @@ -74,5 +81,12 @@ export const toWorkspaceUpsertPayload = (input: { · 25 unmodified lines | |||
| 74 | name: input.name, | 81 | name: input.name, |
| 75 | }); | 82 | }); |
| 76 | 83 | ||
| 84 | export const toUpsertWorkspaceRequest = (input: { | ||
| 85 | payload: UpsertWorkspacePayload; | ||
| 86 | }) => ({ | ||
| 87 | payload: input.payload, | ||
| 88 | reactivityKeys: workspacesKeys, | ||
| 89 | }); | ||
| 90 | |||
| 77 | export type OrganizationsState = EntityState<Organization>; | 91 | export type OrganizationsState = EntityState<Organization>; |
| 78 | export type WorkspacesState = EntityState<Workspace>; | 92 | export type WorkspacesState = EntityState<Workspace>; |
| @@ -5,7 +5,7 @@ import * as Option from "effect/Option"; · 4 unmodified lines | |||
| 5 | 5 | ||
| 6 | import { controlPlaneClient } from "../client"; | 6 | import { controlPlaneClient } from "../client"; |
| 7 | import { stateFromResult, type EntityState } from "./entity"; | 7 | import { stateFromResult, type EntityState } from "./entity"; |
| 8 | import { sourcesKeys } from "./keys"; | 8 | import { sourceMutationKeys, sourcesKeys } from "./keys"; |
| 9 | 9 | ||
| 10 | // --------------------------------------------------------------------------- | 10 | // --------------------------------------------------------------------------- |
| 11 | // Query atoms | 11 | // Query atoms |
| @@ -15,7 +15,7 @@ export const sourcesResultByWorkspace = Atom.family( · 3 unmodified lines | |||
| 15 | (workspaceId: WorkspaceId) => | 15 | (workspaceId: WorkspaceId) => |
| 16 | controlPlaneClient.query("sources", "list", { | 16 | controlPlaneClient.query("sources", "list", { |
| 17 | path: { workspaceId }, | 17 | path: { workspaceId }, |
| 18 | reactivityKeys: sourcesKeys, | 18 | reactivityKeys: sourcesKeys(workspaceId), |
| 19 | }), | 19 | }), |
| 20 | ); | 20 | ); |
| 21 | 21 | ||
| @@ -97,6 +97,26 @@ export const sourcesByWorkspace = Atom.family((workspaceId: WorkspaceId) => · 75 unmodified lines | |||
| 97 | export const upsertSource = controlPlaneClient.mutation("sources", "upsert"); | 97 | export const upsertSource = controlPlaneClient.mutation("sources", "upsert"); |
| 98 | export const removeSource = controlPlaneClient.mutation("sources", "remove"); | 98 | export const removeSource = controlPlaneClient.mutation("sources", "remove"); |
| 99 | 99 | ||
| 100 | export const toUpsertSourceRequest = (input: { | ||
| 101 | workspaceId: WorkspaceId; | ||
| 102 | payload: UpsertSourcePayload; | ||
| 103 | }) => ({ | ||
| 104 | path: { workspaceId: input.workspaceId }, | ||
| 105 | payload: input.payload, | ||
| 106 | reactivityKeys: sourceMutationKeys(input.workspaceId), | ||
| 107 | }); | ||
| 108 | |||
| 109 | export const toRemoveSourceRequest = (input: { | ||
| 110 | workspaceId: WorkspaceId; | ||
| 111 | sourceId: SourceId; | ||
| 112 | }) => ({ | ||
| 113 | path: { | ||
| 114 | workspaceId: input.workspaceId, | ||
| 115 | sourceId: input.sourceId, | ||
| 116 | }, | ||
| 117 | reactivityKeys: sourceMutationKeys(input.workspaceId), | ||
| 118 | }); | ||
| 119 | |||
| 100 | // --------------------------------------------------------------------------- | 120 | // --------------------------------------------------------------------------- |
| 101 | // Optimistic helpers | 121 | // Optimistic helpers |
| 102 | // --------------------------------------------------------------------------- | 122 | // --------------------------------------------------------------------------- |
| @@ -20,7 +20,7 @@ import type { · 19 unmodified lines | |||
| 20 | 20 | ||
| 21 | import { controlPlaneClient } from "../client"; | 21 | import { controlPlaneClient } from "../client"; |
| 22 | import { workspaceEntity, type EntityState } from "./entity"; | 22 | import { workspaceEntity, type EntityState } from "./entity"; |
| 23 | import { storageKeys } from "./keys"; | 23 | import { storageKeys, storageMutationKeys } from "./keys"; |
| 24 | 24 | ||
| 25 | // --------------------------------------------------------------------------- | 25 | // --------------------------------------------------------------------------- |
| 26 | // Query atoms | 26 | // Query atoms |
| @@ -30,7 +30,7 @@ export const storageResultByWorkspace = Atom.family( · 3 unmodified lines | |||
| 30 | (workspaceId: WorkspaceId) => | 30 | (workspaceId: WorkspaceId) => |
| 31 | controlPlaneClient.query("storage", "list", { | 31 | controlPlaneClient.query("storage", "list", { |
| 32 | path: { workspaceId }, | 32 | path: { workspaceId }, |
| 33 | reactivityKeys: storageKeys, | 33 | reactivityKeys: storageKeys(workspaceId), |
| 34 | }), | 34 | }), |
| 35 | ); | 35 | ); |
| 36 | 36 | ||
| @@ -82,6 +82,37 @@ export const toOpenStoragePayload = (input: { · 45 unmodified lines | |||
| 82 | ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}), | 82 | ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}), |
| 83 | }); | 83 | }); |
| 84 | 84 | ||
| 85 | export const toOpenStorageRequest = (input: { | ||
| 86 | workspaceId: WorkspaceId; | ||
| 87 | payload: OpenStorageInstancePayload; | ||
| 88 | }) => ({ | ||
| 89 | path: { workspaceId: input.workspaceId }, | ||
| 90 | payload: input.payload, | ||
| 91 | reactivityKeys: storageMutationKeys(input.workspaceId), | ||
| 92 | }); | ||
| 93 | |||
| 94 | export const toCloseStorageRequest = (input: { | ||
| 95 | workspaceId: WorkspaceId; | ||
| 96 | storageInstanceId: StorageInstance["id"]; | ||
| 97 | }) => ({ | ||
| 98 | path: { | ||
| 99 | workspaceId: input.workspaceId, | ||
| 100 | storageInstanceId: input.storageInstanceId, | ||
| 101 | }, | ||
| 102 | reactivityKeys: storageMutationKeys(input.workspaceId), | ||
| 103 | }); | ||
| 104 | |||
| 105 | export const toRemoveStorageRequest = (input: { | ||
| 106 | workspaceId: WorkspaceId; | ||
| 107 | storageInstanceId: StorageInstance["id"]; | ||
| 108 | }) => ({ | ||
| 109 | path: { | ||
| 110 | workspaceId: input.workspaceId, | ||
| 111 | storageInstanceId: input.storageInstanceId, | ||
| 112 | }, | ||
| 113 | reactivityKeys: storageMutationKeys(input.workspaceId), | ||
| 114 | }); | ||
| 115 | |||
| 85 | export const toStorageRemoveResult = (result: RemoveStorageInstanceResult): boolean => | 116 | export const toStorageRemoveResult = (result: RemoveStorageInstanceResult): boolean => |
| 86 | result.removed; | 117 | result.removed; |
| 87 | 118 | ||
| @@ -15,7 +15,7 @@ export const workspaceToolsResultByWorkspace = Atom.family( · 14 unmodified lines | |||
| 15 | (workspaceId: WorkspaceId) => | 15 | (workspaceId: WorkspaceId) => |
| 16 | controlPlaneClient.query("tools", "listWorkspaceTools", { | 16 | controlPlaneClient.query("tools", "listWorkspaceTools", { |
| 17 | path: { workspaceId }, | 17 | path: { workspaceId }, |
| 18 | reactivityKeys: toolsKeys, | 18 | reactivityKeys: toolsKeys(workspaceId), |
| 19 | }), | 19 | }), |
| 20 | ); | 20 | ); |
| 21 | 21 | ||
| @@ -23,7 +23,7 @@ export const sourceToolsResultBySource = Atom.family( · 1 unmodified line | |||
| 23 | (input: { workspaceId: WorkspaceId; sourceId: SourceId }) => | 23 | (input: { workspaceId: WorkspaceId; sourceId: SourceId }) => |
| 24 | controlPlaneClient.query("tools", "listSourceTools", { | 24 | controlPlaneClient.query("tools", "listSourceTools", { |
| 25 | path: { workspaceId: input.workspaceId, sourceId: input.sourceId }, | 25 | path: { workspaceId: input.workspaceId, sourceId: input.sourceId }, |
| 26 | reactivityKeys: toolsKeys, | 26 | reactivityKeys: toolsKeys(input.workspaceId), |
| 27 | }), | 27 | }), |
| 28 | ); | 28 | ); |
| 29 | 29 | ||
| @@ -35,7 +35,7 @@ export const toolDetailResult = Atom.family( · 5 unmodified lines | |||
| 35 | sourceId: input.sourceId, | 35 | sourceId: input.sourceId, |
| 36 | operationHash: input.operationHash, | 36 | operationHash: input.operationHash, |
| 37 | }, | 37 | }, |
| 38 | reactivityKeys: toolDetailKeys, | 38 | reactivityKeys: toolDetailKeys(input.workspaceId), |
| 39 | }), | 39 | }), |
| 40 | ); | 40 | ); |
| 41 | 41 | ||
| @@ -162,39 +162,16 @@ export const buildSourceConfig = (input: { · 161 unmodified lines | |||
| 162 | }): Record<string, unknown> => { | 162 | }): Record<string, unknown> => { |
| 163 | const endpoint = input.endpoint.trim(); | 163 | const endpoint = input.endpoint.trim(); |
| 164 | 164 | ||
| 165 | const auth = (() => { | ||
| 166 | if (input.authType === "none") { | ||
| 167 | return undefined; | ||
| 168 | } | ||
| 169 | |||
| 170 | if (input.authType === "apiKey") { | ||
| 171 | return { | ||
| 172 | type: "apiKey", | ||
| 173 | mode: input.authMode, | ||
| 174 | header: input.apiKeyHeader.trim() || "Authorization", | ||
| 175 | }; | ||
| 176 | } | ||
| 177 | |||
| 178 | return { | ||
| 179 | type: input.authType, | ||
| 180 | mode: input.authMode, | ||
| 181 | }; | ||
| 182 | })(); | ||
| 183 | |||
| 184 | if (input.type === "mcp") { | 165 | if (input.type === "mcp") { |
| 185 | return { | 166 | return { |
| 186 | url: endpoint, | 167 | url: endpoint, |
| 187 | ...(input.mcpTransport !== "auto" ? { transport: input.mcpTransport } : {}), | 168 | ...(input.mcpTransport !== "auto" ? { transport: input.mcpTransport } : {}), |
| 188 | ...(auth ? { auth } : {}), | ||
| 189 | ...(input.authType !== "none" ? { useCredentialedFetch: true } : {}), | ||
| 190 | }; | 169 | }; |
| 191 | } | 170 | } |
| 192 | 171 | ||
| 193 | if (input.type === "graphql") { | 172 | if (input.type === "graphql") { |
| 194 | return { | 173 | return { |
| 195 | endpoint, | 174 | endpoint, |
| 196 | ...(auth ? { auth } : {}), | ||
| 197 | ...(input.authType !== "none" ? { useCredentialedFetch: true } : {}), | ||
| 198 | }; | 175 | }; |
| 199 | } | 176 | } |
| 200 | 177 | ||
| @@ -202,8 +179,6 @@ export const buildSourceConfig = (input: { · 1 unmodified line | |||
| 202 | spec: endpoint, | 179 | spec: endpoint, |
| 203 | specUrl: endpoint, | 180 | specUrl: endpoint, |
| 204 | ...(input.baseUrl.trim().length > 0 ? { baseUrl: input.baseUrl.trim() } : {}), | 181 | ...(input.baseUrl.trim().length > 0 ? { baseUrl: input.baseUrl.trim() } : {}), |
| 205 | ...(auth ? { auth } : {}), | ||
| 206 | ...(input.authType !== "none" ? { useCredentialedFetch: true } : {}), | ||
| 207 | }; | 182 | }; |
| 208 | }; | 183 | }; |
| 209 | 184 | ||
| @@ -4,6 +4,8 @@ export type McpOAuthPopupSuccess = { · 3 unmodified lines | |||
| 4 | refreshToken?: string; | 4 | refreshToken?: string; |
| 5 | scope?: string; | 5 | scope?: string; |
| 6 | expiresIn?: number; | 6 | expiresIn?: number; |
| 7 | clientId?: string; | ||
| 8 | clientInformationJson?: string; | ||
| 7 | }; | 9 | }; |
| 8 | 10 | ||
| 9 | type McpOAuthPopupMessage = | 11 | type McpOAuthPopupMessage = |
| @@ -16,6 +18,8 @@ type McpOAuthPopupMessage = · 6 unmodified lines | |||
| 16 | refreshToken?: string; | 18 | refreshToken?: string; |
| 17 | scope?: string; | 19 | scope?: string; |
| 18 | expiresIn?: number; | 20 | expiresIn?: number; |
| 21 | clientId?: string; | ||
| 22 | clientInformationJson?: string; | ||
| 19 | }; | 23 | }; |
| 20 | } | 24 | } |
| 21 | | { | 25 | | { |
| @@ -110,6 +114,10 @@ export const startMcpOAuthPopup = async ( · 88 unmodified lines | |||
| 110 | ...(typeof data.payload.expiresIn === "number" | 114 | ...(typeof data.payload.expiresIn === "number" |
| 111 | ? { expiresIn: data.payload.expiresIn } | 115 | ? { expiresIn: data.payload.expiresIn } |
| 112 | : {}), | 116 | : {}), |
| 117 | ...(data.payload.clientId ? { clientId: data.payload.clientId } : {}), | ||
| 118 | ...(data.payload.clientInformationJson | ||
| 119 | ? { clientInformationJson: data.payload.clientInformationJson } | ||
| 120 | : {}), | ||
| 113 | }); | 121 | }); |
| 114 | }; | 122 | }; |
| 115 | 123 | ||
| @@ -24,6 +24,8 @@ export type McpOAuthPopupResult = { · 23 unmodified lines | |||
| 24 | refreshToken?: string; | 24 | refreshToken?: string; |
| 25 | scope?: string; | 25 | scope?: string; |
| 26 | expiresIn?: number; | 26 | expiresIn?: number; |
| 27 | clientId?: string; | ||
| 28 | clientInformationJson?: string; | ||
| 27 | error?: string; | 29 | error?: string; |
| 28 | }; | 30 | }; |
| 29 | 31 | ||
| @@ -106,6 +108,8 @@ const decodePopupResult = (value: unknown): McpOAuthPopupResult | null => { · 76 unmodified lines | |||
| 106 | refreshToken: readOptionalString(record, "refreshToken"), | 108 | refreshToken: readOptionalString(record, "refreshToken"), |
| 107 | scope: readOptionalString(record, "scope"), | 109 | scope: readOptionalString(record, "scope"), |
| 108 | expiresIn: readOptionalNumber(record, "expiresIn"), | 110 | expiresIn: readOptionalNumber(record, "expiresIn"), |
| 111 | clientId: readOptionalString(record, "clientId"), | ||
| 112 | clientInformationJson: readOptionalString(record, "clientInformationJson"), | ||
| 109 | error: readOptionalString(record, "error"), | 113 | error: readOptionalString(record, "error"), |
| 110 | }; | 114 | }; |
| 111 | }; | 115 | }; |
| @@ -12,6 +12,7 @@ import { pipe } from "effect/Function"; · 11 unmodified lines | |||
| 12 | import * as ParseResult from "effect/ParseResult"; | 12 | import * as ParseResult from "effect/ParseResult"; |
| 13 | import * as Schema from "effect/Schema"; | 13 | import * as Schema from "effect/Schema"; |
| 14 | 14 | ||
| 15 | import { collectSourceHeaders } from "./source-config"; | ||
| 15 | import { ToolProviderError, type ToolProvider } from "./tool-providers"; | 16 | import { ToolProviderError, type ToolProvider } from "./tool-providers"; |
| 16 | 17 | ||
| 17 | const OpenApiToolArgsSchema = Schema.Record({ | 18 | const OpenApiToolArgsSchema = Schema.Record({ |
| @@ -175,8 +176,9 @@ const buildFetchRequest = ( · 157 unmodified lines | |||
| 175 | const resolvedPath = yield* replacePathTemplate(payload.pathTemplate, args, payload); | 176 | const resolvedPath = yield* replacePathTemplate(payload.pathTemplate, args, payload); |
| 176 | const baseUrl = yield* resolveBaseUrl(source); | 177 | const baseUrl = yield* resolveBaseUrl(source); |
| 177 | const url = new URL(resolvedPath, baseUrl); | 178 | const url = new URL(resolvedPath, baseUrl); |
| 179 | const config = parseSourceConfig(source); | ||
| 178 | 180 | ||
| 179 | const headers = new Headers(); | 181 | const headers = new Headers(collectSourceHeaders(config)); |
| 180 | const cookieParts: Array<string> = []; | 182 | const cookieParts: Array<string> = []; |
| 181 | 183 | ||
| 182 | for (const parameter of payload.parameters) { | 184 | for (const parameter of payload.parameters) { |
| @@ -105,39 +105,5 @@ export const readMcpTransportFromConfig = ( · 104 unmodified lines | |||
| 105 | export const collectSourceHeaders = ( | 105 | export const collectSourceHeaders = ( |
| 106 | config: SourceConfig, | 106 | config: SourceConfig, |
| 107 | ): Record<string, string> => { | 107 | ): Record<string, string> => { |
| 108 | const configuredHeaders = readStringRecord(config.headers); | 108 | return readStringRecord(config.headers); |
| 109 | const auth = asRecord(config.auth); | ||
| 110 | const authType = normalizeString(auth.type)?.toLowerCase(); | ||
| 111 | const authHeaders: Record<string, string> = {}; | ||
| 112 | |||
| 113 | if (authType === "bearer") { | ||
| 114 | const token = normalizeString(auth.token) ?? normalizeString(auth.value); | ||
| 115 | if (token) { | ||
| 116 | authHeaders.Authorization = `Bearer ${token}`; | ||
| 117 | } | ||
| 118 | } | ||
| 119 | |||
| 120 | if (authType === "apikey" || authType === "api_key" || authType === "apiKey") { | ||
| 121 | const headerName = normalizeString(auth.header) ?? "Authorization"; | ||
| 122 | const apiKey = normalizeString(auth.value); | ||
| 123 | if (apiKey) { | ||
| 124 | authHeaders[headerName] = apiKey; | ||
| 125 | } | ||
| 126 | } | ||
| 127 | |||
| 128 | if (authType === "basic") { | ||
| 129 | const username = normalizeString(auth.username); | ||
| 130 | const password = normalizeString(auth.password); | ||
| 131 | if (username && password) { | ||
| 132 | authHeaders.Authorization = `Basic ${Buffer.from( | ||
| 133 | `${username}:${password}`, | ||
| 134 | "utf8", | ||
| 135 | ).toString("base64")}`; | ||
| 136 | } | ||
| 137 | } | ||
| 138 | |||
| 139 | return { | ||
| 140 | ...configuredHeaders, | ||
| 141 | ...authHeaders, | ||
| 142 | }; | ||
| 143 | }; | 109 | }; |
| @@ -28,9 +28,23 @@ const OptionalUpsertCredentialBindingPayloadSchema = SourceCredentialBindingSche · 27 unmodified lines | |||
| 28 | Schema.partialWith({ exact: true }), | 28 | Schema.partialWith({ exact: true }), |
| 29 | ); | 29 | ); |
| 30 | 30 | ||
| 31 | const OptionalOAuthUpsertFieldsSchema = Schema.Struct({ | ||
| 32 | oauthRefreshToken: Schema.NullOr(Schema.String), | ||
| 33 | oauthExpiresAt: Schema.NullOr(Schema.Number), | ||
| 34 | oauthScope: Schema.NullOr(Schema.String), | ||
| 35 | oauthIssuer: Schema.NullOr(Schema.String), | ||
| 36 | oauthTokenEndpoint: Schema.NullOr(Schema.String), | ||
| 37 | oauthAuthorizationServer: Schema.NullOr(Schema.String), | ||
| 38 | oauthClientId: Schema.NullOr(Schema.String), | ||
| 39 | oauthClientSecret: Schema.NullOr(Schema.String), | ||
| 40 | oauthSourceUrl: Schema.NullOr(Schema.String), | ||
| 41 | oauthClientInformationJson: Schema.NullOr(Schema.String), | ||
| 42 | }).pipe(Schema.partialWith({ exact: true })); | ||
| 43 | |||
| 31 | export const UpsertCredentialBindingPayloadSchema = | 44 | export const UpsertCredentialBindingPayloadSchema = |
| 32 | RequiredUpsertCredentialBindingPayloadSchema.pipe( | 45 | RequiredUpsertCredentialBindingPayloadSchema.pipe( |
| 33 | Schema.extend(OptionalUpsertCredentialBindingPayloadSchema), | 46 | Schema.extend(OptionalUpsertCredentialBindingPayloadSchema), |
| 47 | Schema.extend(OptionalOAuthUpsertFieldsSchema), | ||
| 34 | ); | 48 | ); |
| 35 | 49 | ||
| 36 | export type UpsertCredentialBindingPayload = | 50 | export type UpsertCredentialBindingPayload = |
| @@ -11,6 +11,7 @@ export const CredentialProviderSchema = Schema.Literal( · 10 unmodified lines | |||
| 11 | "api_key", | 11 | "api_key", |
| 12 | "bearer", | 12 | "bearer", |
| 13 | "oauth2", | 13 | "oauth2", |
| 14 | "basic", | ||
| 14 | "custom", | 15 | "custom", |
| 15 | ); | 16 | ); |
| 16 | export const CredentialSecretProviderSchema = Schema.Literal( | 17 | export const CredentialSecretProviderSchema = Schema.Literal( |
| @@ -21,6 +22,49 @@ export const CredentialScopeTypeSchema = Schema.Literal( · 4 unmodified lines | |||
| 21 | "organization", | 22 | "organization", |
| 22 | "account", | 23 | "account", |
| 23 | ); | 24 | ); |
| 25 | export const AuthConnectionStrategySchema = Schema.Literal( | ||
| 26 | "oauth2", | ||
| 27 | "api_key", | ||
| 28 | "bearer", | ||
| 29 | "basic", | ||
| 30 | "custom", | ||
| 31 | ); | ||
| 32 | export const AuthConnectionStatusSchema = Schema.Literal( | ||
| 33 | "active", | ||
| 34 | "reauth_required", | ||
| 35 | "revoked", | ||
| 36 | "disabled", | ||
| 37 | "error", | ||
| 38 | ); | ||
| 39 | export const AuthOwnerTypeSchema = Schema.Literal( | ||
| 40 | "organization", | ||
| 41 | "workspace", | ||
| 42 | "account", | ||
| 43 | ); | ||
| 44 | export const AuthBindingScopeTypeSchema = Schema.Literal( | ||
| 45 | "workspace", | ||
| 46 | "organization", | ||
| 47 | "account", | ||
| 48 | ); | ||
| 49 | export const OAuthStateStatusSchema = Schema.Literal( | ||
| 50 | "valid", | ||
| 51 | "expiring", | ||
| 52 | "refreshing", | ||
| 53 | "reauth_required", | ||
| 54 | "revoked", | ||
| 55 | ); | ||
| 56 | export const AuthAuditEventTypeSchema = Schema.Literal( | ||
| 57 | "created", | ||
| 58 | "updated", | ||
| 59 | "bound", | ||
| 60 | "unbound", | ||
| 61 | "refresh_success", | ||
| 62 | "refresh_failed", | ||
| 63 | "reauth_required", | ||
| 64 | "revoked", | ||
| 65 | "deleted", | ||
| 66 | ); | ||
| 67 | export const AuthActorTypeSchema = Schema.Literal("system", "account"); | ||
| 24 | export const StorageScopeTypeSchema = Schema.Literal( | 68 | export const StorageScopeTypeSchema = Schema.Literal( |
| 25 | "scratch", | 69 | "scratch", |
| 26 | "account", | 70 | "account", |
| @@ -49,6 +93,13 @@ export type CredentialMode = typeof CredentialModeSchema.Type; · 22 unmodified lines | |||
| 49 | export type CredentialProvider = typeof CredentialProviderSchema.Type; | 93 | export type CredentialProvider = typeof CredentialProviderSchema.Type; |
| 50 | export type CredentialSecretProvider = typeof CredentialSecretProviderSchema.Type; | 94 | export type CredentialSecretProvider = typeof CredentialSecretProviderSchema.Type; |
| 51 | export type CredentialScopeType = typeof CredentialScopeTypeSchema.Type; | 95 | export type CredentialScopeType = typeof CredentialScopeTypeSchema.Type; |
| 96 | export type AuthConnectionStrategy = typeof AuthConnectionStrategySchema.Type; | ||
| 97 | export type AuthConnectionStatus = typeof AuthConnectionStatusSchema.Type; | ||
| 98 | export type AuthOwnerType = typeof AuthOwnerTypeSchema.Type; | ||
| 99 | export type AuthBindingScopeType = typeof AuthBindingScopeTypeSchema.Type; | ||
| 100 | export type OAuthStateStatus = typeof OAuthStateStatusSchema.Type; | ||
| 101 | export type AuthAuditEventType = typeof AuthAuditEventTypeSchema.Type; | ||
| 102 | export type AuthActorType = typeof AuthActorTypeSchema.Type; | ||
| 52 | export type StorageScopeType = typeof StorageScopeTypeSchema.Type; | 103 | export type StorageScopeType = typeof StorageScopeTypeSchema.Type; |
| 53 | export type StorageDurability = typeof StorageDurabilitySchema.Type; | 104 | export type StorageDurability = typeof StorageDurabilitySchema.Type; |
| 54 | export type StorageInstanceStatus = typeof StorageInstanceStatusSchema.Type; | 105 | export type StorageInstanceStatus = typeof StorageInstanceStatusSchema.Type; |
| @@ -11,6 +11,19 @@ export const CredentialBindingIdSchema = Schema.String.pipe( · 10 unmodified lines | |||
| 11 | ); | 11 | ); |
| 12 | export const CredentialIdSchema = Schema.String.pipe(Schema.brand("CredentialId")); | 12 | export const CredentialIdSchema = Schema.String.pipe(Schema.brand("CredentialId")); |
| 13 | export const OAuthTokenIdSchema = Schema.String.pipe(Schema.brand("OAuthTokenId")); | 13 | export const OAuthTokenIdSchema = Schema.String.pipe(Schema.brand("OAuthTokenId")); |
| 14 | export const AuthConnectionIdSchema = Schema.String.pipe( | ||
| 15 | Schema.brand("AuthConnectionId"), | ||
| 16 | ); | ||
| 17 | export const SourceAuthBindingIdSchema = Schema.String.pipe( | ||
| 18 | Schema.brand("SourceAuthBindingId"), | ||
| 19 | ); | ||
| 20 | export const AuthMaterialIdSchema = Schema.String.pipe( | ||
| 21 | Schema.brand("AuthMaterialId"), | ||
| 22 | ); | ||
| 23 | export const OAuthStateIdSchema = Schema.String.pipe(Schema.brand("OAuthStateId")); | ||
| 24 | export const AuthAuditEventIdSchema = Schema.String.pipe( | ||
| 25 | Schema.brand("AuthAuditEventId"), | ||
| 26 | ); | ||
| 14 | export const OrganizationMemberIdSchema = Schema.String.pipe( | 27 | export const OrganizationMemberIdSchema = Schema.String.pipe( |
| 15 | Schema.brand("OrganizationMemberId"), | 28 | Schema.brand("OrganizationMemberId"), |
| 16 | ); | 29 | ); |
| @@ -32,6 +45,11 @@ export type ToolArtifactId = typeof ToolArtifactIdSchema.Type; · 15 unmodified lines | |||
| 32 | export type CredentialBindingId = typeof CredentialBindingIdSchema.Type; | 45 | export type CredentialBindingId = typeof CredentialBindingIdSchema.Type; |
| 33 | export type CredentialId = typeof CredentialIdSchema.Type; | 46 | export type CredentialId = typeof CredentialIdSchema.Type; |
| 34 | export type OAuthTokenId = typeof OAuthTokenIdSchema.Type; | 47 | export type OAuthTokenId = typeof OAuthTokenIdSchema.Type; |
| 48 | export type AuthConnectionId = typeof AuthConnectionIdSchema.Type; | ||
| 49 | export type SourceAuthBindingId = typeof SourceAuthBindingIdSchema.Type; | ||
| 50 | export type AuthMaterialId = typeof AuthMaterialIdSchema.Type; | ||
| 51 | export type OAuthStateId = typeof OAuthStateIdSchema.Type; | ||
| 52 | export type AuthAuditEventId = typeof AuthAuditEventIdSchema.Type; | ||
| 35 | export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type; | 53 | export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type; |
| 36 | export type PolicyId = typeof PolicyIdSchema.Type; | 54 | export type PolicyId = typeof PolicyIdSchema.Type; |
| 37 | export type ApprovalId = typeof ApprovalIdSchema.Type; | 55 | export type ApprovalId = typeof ApprovalIdSchema.Type; |
| @@ -8,6 +8,11 @@ export * from "./models/source"; · 7 unmodified lines | |||
| 8 | export * from "./models/tool-artifact"; | 8 | export * from "./models/tool-artifact"; |
| 9 | export * from "./models/oauth-token"; | 9 | export * from "./models/oauth-token"; |
| 10 | export * from "./models/source-credential-binding"; | 10 | export * from "./models/source-credential-binding"; |
| 11 | export * from "./models/auth-connection"; | ||
| 12 | export * from "./models/source-auth-binding"; | ||
| 13 | export * from "./models/auth-material"; | ||
| 14 | export * from "./models/oauth-state"; | ||
| 15 | export * from "./models/auth-audit-event"; | ||
| 11 | export * from "./models/policy"; | 16 | export * from "./models/policy"; |
| 12 | export * from "./models/approval"; | 17 | export * from "./models/approval"; |
| 13 | export * from "./models/task-run"; | 18 | export * from "./models/task-run"; |
| @@ -16,4 +21,3 @@ export * from "./models/storage-instance"; · 2 unmodified lines | |||
| 16 | export * from "./models/event-envelope"; | 21 | export * from "./models/event-envelope"; |
| 17 | export * from "./models/runtime-tool"; | 22 | export * from "./models/runtime-tool"; |
| 18 | export * from "./models/auth"; | 23 | export * from "./models/auth"; |
| 19 | |||
| @@ -0,0 +1,28 @@ | |||
| 1 | import { Schema } from "effect"; | ||
| 2 | |||
| 3 | import { TimestampMsSchema } from "../common"; | ||
| 4 | import { AuthActorTypeSchema, AuthAuditEventTypeSchema } from "../enums"; | ||
| 5 | import { | ||
| 6 | AccountIdSchema, | ||
| 7 | AuthAuditEventIdSchema, | ||
| 8 | AuthConnectionIdSchema, | ||
| 9 | OrganizationIdSchema, | ||
| 10 | SourceIdSchema, | ||
| 11 | } from "../ids"; | ||
| 12 | |||
| 13 | export const AuthAuditEventSchema = Schema.Struct({ | ||
| 14 | id: AuthAuditEventIdSchema, | ||
| 15 | organizationId: OrganizationIdSchema, | ||
| 16 | connectionId: AuthConnectionIdSchema, | ||
| 17 | sourceId: Schema.NullOr(SourceIdSchema), | ||
| 18 | eventType: AuthAuditEventTypeSchema, | ||
| 19 | actorType: AuthActorTypeSchema, | ||
| 20 | actorId: Schema.NullOr(AccountIdSchema), | ||
| 21 | outcome: Schema.String, | ||
| 22 | reasonCode: Schema.NullOr(Schema.String), | ||
| 23 | detailsJson: Schema.NullOr(Schema.String), | ||
| 24 | correlationId: Schema.NullOr(Schema.String), | ||
| 25 | createdAt: TimestampMsSchema, | ||
| 26 | }); | ||
| 27 | |||
| 28 | export type AuthAuditEvent = typeof AuthAuditEventSchema.Type; | ||
| @@ -0,0 +1,35 @@ | |||
| 1 | import { Schema } from "effect"; | ||
| 2 | |||
| 3 | import { TimestampMsSchema } from "../common"; | ||
| 4 | import { | ||
| 5 | AuthConnectionStatusSchema, | ||
| 6 | AuthConnectionStrategySchema, | ||
| 7 | AuthOwnerTypeSchema, | ||
| 8 | } from "../enums"; | ||
| 9 | import { | ||
| 10 | AccountIdSchema, | ||
| 11 | AuthConnectionIdSchema, | ||
| 12 | OrganizationIdSchema, | ||
| 13 | WorkspaceIdSchema, | ||
| 14 | } from "../ids"; | ||
| 15 | |||
| 16 | export const AuthConnectionSchema = Schema.Struct({ | ||
| 17 | id: AuthConnectionIdSchema, | ||
| 18 | organizationId: OrganizationIdSchema, | ||
| 19 | workspaceId: Schema.NullOr(WorkspaceIdSchema), | ||
| 20 | accountId: Schema.NullOr(AccountIdSchema), | ||
| 21 | ownerType: AuthOwnerTypeSchema, | ||
| 22 | strategy: AuthConnectionStrategySchema, | ||
| 23 | displayName: Schema.String, | ||
| 24 | status: AuthConnectionStatusSchema, | ||
| 25 | statusReason: Schema.NullOr(Schema.String), | ||
| 26 | lastAuthErrorClass: Schema.NullOr(Schema.String), | ||
| 27 | metadataJson: Schema.NullOr(Schema.String), | ||
| 28 | additionalHeadersJson: Schema.NullOr(Schema.String), | ||
| 29 | createdByAccountId: Schema.NullOr(AccountIdSchema), | ||
| 30 | createdAt: TimestampMsSchema, | ||
| 31 | updatedAt: TimestampMsSchema, | ||
| 32 | lastUsedAt: Schema.NullOr(TimestampMsSchema), | ||
| 33 | }); | ||
| 34 | |||
| 35 | export type AuthConnection = typeof AuthConnectionSchema.Type; | ||
| @@ -0,0 +1,15 @@ | |||
| 1 | import { Schema } from "effect"; | ||
| 2 | |||
| 3 | import { TimestampMsSchema } from "../common"; | ||
| 4 | import { AuthConnectionIdSchema, AuthMaterialIdSchema } from "../ids"; | ||
| 5 | |||
| 6 | export const AuthMaterialSchema = Schema.Struct({ | ||
| 7 | id: AuthMaterialIdSchema, | ||
| 8 | connectionId: AuthConnectionIdSchema, | ||
| 9 | ciphertext: Schema.String, | ||
| 10 | keyVersion: Schema.String, | ||
| 11 | createdAt: TimestampMsSchema, | ||
| 12 | updatedAt: TimestampMsSchema, | ||
| 13 | }); | ||
| 14 | |||
| 15 | export type AuthMaterial = typeof AuthMaterialSchema.Type; | ||
| @@ -0,0 +1,29 @@ | |||
| 1 | import { Schema } from "effect"; | ||
| 2 | |||
| 3 | import { TimestampMsSchema } from "../common"; | ||
| 4 | import { AuthConnectionIdSchema, OAuthStateIdSchema } from "../ids"; | ||
| 5 | |||
| 6 | export const OAuthStateSchema = Schema.Struct({ | ||
| 7 | id: OAuthStateIdSchema, | ||
| 8 | connectionId: AuthConnectionIdSchema, | ||
| 9 | accessTokenCiphertext: Schema.String, | ||
| 10 | refreshTokenCiphertext: Schema.NullOr(Schema.String), | ||
| 11 | keyVersion: Schema.String, | ||
| 12 | expiresAt: Schema.NullOr(TimestampMsSchema), | ||
| 13 | scope: Schema.NullOr(Schema.String), | ||
| 14 | tokenType: Schema.NullOr(Schema.String), | ||
| 15 | issuer: Schema.NullOr(Schema.String), | ||
| 16 | refreshConfigJson: Schema.NullOr(Schema.String), | ||
| 17 | tokenVersion: Schema.Number, | ||
| 18 | leaseHolder: Schema.NullOr(Schema.String), | ||
| 19 | leaseExpiresAt: Schema.NullOr(TimestampMsSchema), | ||
| 20 | leaseFence: Schema.Number, | ||
| 21 | lastRefreshAt: Schema.NullOr(TimestampMsSchema), | ||
| 22 | lastRefreshErrorClass: Schema.NullOr(Schema.String), | ||
| 23 | lastRefreshError: Schema.NullOr(Schema.String), | ||
| 24 | reauthRequiredAt: Schema.NullOr(TimestampMsSchema), | ||
| 25 | createdAt: TimestampMsSchema, | ||
| 26 | updatedAt: TimestampMsSchema, | ||
| 27 | }); | ||
| 28 | |||
| 29 | export type OAuthState = typeof OAuthStateSchema.Type; | ||
| @@ -0,0 +1,28 @@ | |||
| 1 | import { Schema } from "effect"; | ||
| 2 | |||
| 3 | import { TimestampMsSchema } from "../common"; | ||
| 4 | import { AuthBindingScopeTypeSchema } from "../enums"; | ||
| 5 | import { | ||
| 6 | AccountIdSchema, | ||
| 7 | AuthConnectionIdSchema, | ||
| 8 | OrganizationIdSchema, | ||
| 9 | SourceAuthBindingIdSchema, | ||
| 10 | SourceIdSchema, | ||
| 11 | WorkspaceIdSchema, | ||
| 12 | } from "../ids"; | ||
| 13 | |||
| 14 | export const SourceAuthBindingSchema = Schema.Struct({ | ||
| 15 | id: SourceAuthBindingIdSchema, | ||
| 16 | sourceId: SourceIdSchema, | ||
| 17 | connectionId: AuthConnectionIdSchema, | ||
| 18 | organizationId: OrganizationIdSchema, | ||
| 19 | workspaceId: Schema.NullOr(WorkspaceIdSchema), | ||
| 20 | accountId: Schema.NullOr(AccountIdSchema), | ||
| 21 | scopeType: AuthBindingScopeTypeSchema, | ||
| 22 | selector: Schema.NullOr(Schema.String), | ||
| 23 | enabled: Schema.Boolean, | ||
| 24 | createdAt: TimestampMsSchema, | ||
| 25 | updatedAt: TimestampMsSchema, | ||
| 26 | }); | ||
| 27 | |||
| 28 | export type SourceAuthBinding = typeof SourceAuthBindingSchema.Type; | ||
| @@ -1,13 +1,15 @@ | |||
| 1 | import { | 1 | import { |
| 2 | AuthConnectionSchema, | ||
| 3 | AuthMaterialSchema, | ||
| 2 | ApprovalSchema, | 4 | ApprovalSchema, |
| 3 | EventEnvelopeSchema, | 5 | EventEnvelopeSchema, |
| 4 | OAuthTokenSchema, | 6 | OAuthStateSchema, |
| 5 | OrganizationMembershipSchema, | 7 | OrganizationMembershipSchema, |
| 6 | OrganizationSchema, | 8 | OrganizationSchema, |
| 7 | PolicySchema, | 9 | PolicySchema, |
| 8 | ProfileSchema, | 10 | ProfileSchema, |
| 9 | SchemaVersionSchema, | 11 | SchemaVersionSchema, |
| 10 | SourceCredentialBindingSchema, | 12 | SourceAuthBindingSchema, |
| 11 | SourceSchema, | 13 | SourceSchema, |
| 12 | StorageInstanceSchema, | 14 | StorageInstanceSchema, |
| 13 | SyncStateSchema, | 15 | SyncStateSchema, |
| @@ -27,8 +29,10 @@ export const LocalStateSnapshotSchema = Schema.Struct({ · 13 unmodified lines | |||
| 27 | workspaces: Schema.Array(WorkspaceSchema), | 29 | workspaces: Schema.Array(WorkspaceSchema), |
| 28 | sources: Schema.Array(SourceSchema), | 30 | sources: Schema.Array(SourceSchema), |
| 29 | toolArtifacts: Schema.Array(ToolArtifactSchema), | 31 | toolArtifacts: Schema.Array(ToolArtifactSchema), |
| 30 | credentialBindings: Schema.Array(SourceCredentialBindingSchema), | 32 | authConnections: Schema.Array(AuthConnectionSchema), |
| 31 | oauthTokens: Schema.Array(OAuthTokenSchema), | 33 | sourceAuthBindings: Schema.Array(SourceAuthBindingSchema), |
| 34 | authMaterials: Schema.Array(AuthMaterialSchema), | ||
| 35 | oauthStates: Schema.Array(OAuthStateSchema), | ||
| 32 | policies: Schema.Array(PolicySchema), | 36 | policies: Schema.Array(PolicySchema), |
| 33 | approvals: Schema.Array(ApprovalSchema), | 37 | approvals: Schema.Array(ApprovalSchema), |
| 34 | taskRuns: Schema.Array(TaskRunSchema), | 38 | taskRuns: Schema.Array(TaskRunSchema), |
| @@ -69,8 +69,10 @@ describe("makeLocalStateStore", () => { · 68 unmodified lines | |||
| 69 | ], | 69 | ], |
| 70 | sources: [], | 70 | sources: [], |
| 71 | toolArtifacts: [], | 71 | toolArtifacts: [], |
| 72 | credentialBindings: [], | 72 | authConnections: [], |
| 73 | oauthTokens: [], | 73 | sourceAuthBindings: [], |
| 74 | authMaterials: [], | ||
| 75 | oauthStates: [], | ||
| 74 | policies: [], | 76 | policies: [], |
| 75 | approvals: [], | 77 | approvals: [], |
| 76 | taskRuns: [], | 78 | taskRuns: [], |