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 | 16 | apps/convex/node_modules/@convex-dev/action-cache |
| 17 | 17 | *.tsbuildinfo |
| 18 | 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 | 1 | import { |
| 2 | − buildCredentialHeaders, | |
| 3 | − selectOAuthAccessToken, | |
| 4 | −} from "@executor-v2/engine"; | |
| 5 | −import { type UpsertCredentialBindingPayload } from "@executor-v2/management-api"; | |
| 2 | + type UpsertCredentialBindingPayload, | |
| 3 | +} from "@executor-v2/management-api"; | |
| 6 | 4 | import { |
| 7 | − OAuthTokenSchema, | |
| 5 | + AuthConnectionSchema, | |
| 6 | + AuthMaterialSchema, | |
| 7 | + OAuthStateSchema, | |
| 8 | + SourceAuthBindingSchema, | |
| 8 | 9 | SourceCredentialBindingSchema, |
| 9 | − type OAuthToken, | |
| 10 | + type AuthConnection, | |
| 11 | + type AuthConnectionStatus, | |
| 12 | + type AuthConnectionStrategy, | |
| 13 | + type AuthMaterial, | |
| 14 | + type OAuthState, | |
| 15 | + type SourceAuthBinding, | |
| 10 | 16 | type SourceCredentialBinding, |
| 11 | 17 | } from "@executor-v2/schema"; |
| 12 | 18 | import { v } from "convex/values"; |
| 13 | 19 | import * as Schema from "effect/Schema"; |
| 14 | 20 | |
| 15 | 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 | 27 | import { |
| 18 | 28 | internalAction, |
| 19 | 29 | internalMutation, |
| @@ -24,27 +34,19 @@ import { · 4 unmodified lines | ||
| 24 | 34 | |
| 25 | 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 | 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 | 45 | const credentialProviderValidator = v.union( |
| 45 | 46 | v.literal("api_key"), |
| 46 | 47 | v.literal("bearer"), |
| 47 | 48 | v.literal("oauth2"), |
| 49 | + v.literal("basic"), | |
| 48 | 50 | v.literal("custom"), |
| 49 | 51 | ); |
| 50 | 52 | |
| @@ -67,9 +69,305 @@ const sourceCredentialBindingPayloadValidator = v.object({ · 16 unmodified lines | ||
| 67 | 69 | accountId: v.optional(v.union(v.string(), v.null())), |
| 68 | 70 | additionalHeadersJson: v.optional(v.union(v.string(), v.null())), |
| 69 | 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 | 371 | bindings: ReadonlyArray<SourceCredentialBinding>, |
| 74 | 372 | ): Array<SourceCredentialBinding> => |
| 75 | 373 | [...bindings].sort((left, right) => { |
| @@ -83,204 +381,835 @@ const sortSourceCredentialBindings = ( · 7 unmodified lines | ||
| 83 | 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 | 724 | ctx: QueryCtx | MutationCtx, |
| 88 | − workspaceId: string, | |
| 89 | −): Promise<string> => { | |
| 90 | − const workspace = await ctx.db | |
| 91 | − .query("workspaces") | |
| 92 | − .withIndex("by_domainId", (q) => q.eq("id", workspaceId)) | |
| 725 | + connectionId: string, | |
| 726 | +): Promise<AuthConnection | null> => { | |
| 727 | + const row = await ctx.db | |
| 728 | + .query("authConnections") | |
| 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 | 762 | .unique(); |
| 94 | 763 | |
| 95 | − if (!workspace) { | |
| 96 | − throw new Error(`Workspace not found: ${workspaceId}`); | |
| 97 | − } | |
| 764 | + if (!row) { | |
| 765 | + return null; | |
| 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; | |
| 100 | −}; | |
| 954 | + return { | |
| 955 | + kind: "ready", | |
| 956 | + oauthState: toOAuthState(nextState as unknown as Record<string, unknown>), | |
| 957 | + connection, | |
| 958 | + }; | |
| 959 | + }, | |
| 960 | +}); | |
| 101 | 961 | |
| 102 | −const canAccessSourceCredentialBinding = ( | |
| 103 | − binding: SourceCredentialBinding, | |
| 104 | − input: { | |
| 105 | − workspaceId: string; | |
| 106 | − organizationId: string; | |
| 962 | +export const finalizeOAuthRefreshSuccess = internalMutation({ | |
| 963 | + args: { | |
| 964 | + connectionId: v.string(), | |
| 965 | + leaseHolder: v.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 => | |
| 109 | − binding.workspaceId === input.workspaceId | |
| 110 | − || (binding.workspaceId === null && binding.organizationId === input.organizationId); | |
| 975 | + handler: async (ctx, args): Promise<{ committed: boolean }> => { | |
| 976 | + const stateRow = await ctx.db | |
| 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 => | |
| 113 | − decodeOAuthToken(stripConvexSystemFields(document)); | |
| 985 | + if (!stateRow || !connectionRow) { | |
| 986 | + return { committed: false }; | |
| 987 | + } | |
| 114 | 988 | |
| 115 | −const sourceKeyFromSourceId = (sourceId: string): string | null => { | |
| 116 | − const trimmed = sourceId.trim(); | |
| 117 | − if (trimmed.length === 0) { | |
| 118 | − return null; | |
| 119 | − } | |
| 989 | + const state = toOAuthState(stateRow as unknown as Record<string, unknown>); | |
| 990 | + const connection = toAuthConnection(connectionRow as unknown as Record<string, unknown>); | |
| 120 | 991 | |
| 121 | − return `source:${trimmed}`; | |
| 122 | −}; | |
| 992 | + if (state.leaseHolder !== args.leaseHolder) { | |
| 993 | + return { committed: false }; | |
| 994 | + } | |
| 123 | 995 | |
| 124 | −const bindingScopeScore = ( | |
| 125 | − binding: SourceCredentialBinding, | |
| 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; | |
| 996 | + if (state.tokenVersion !== args.expectedTokenVersion) { | |
| 997 | + return { committed: false }; | |
| 135 | 998 | } |
| 136 | 999 | |
| 137 | − return binding.organizationId === input.organizationId ? 30 : -1; | |
| 138 | − } | |
| 1000 | + if (state.leaseFence !== args.expectedLeaseFence) { | |
| 1001 | + return { committed: false }; | |
| 1002 | + } | |
| 139 | 1003 | |
| 140 | − if (binding.scopeType === "workspace") { | |
| 141 | − return binding.workspaceId === input.workspaceId ? 20 : -1; | |
| 142 | − } | |
| 1004 | + const now = Date.now(); | |
| 1005 | + const nextRefreshToken = args.refreshTokenCiphertext === undefined | |
| 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") { | |
| 145 | − return binding.organizationId === input.organizationId ? 10 : -1; | |
| 146 | − } | |
| 1026 | + await ctx.db.patch(connectionRow._id, { | |
| 1027 | + status: "active", | |
| 1028 | + statusReason: null, | |
| 1029 | + lastAuthErrorClass: null, | |
| 1030 | + lastUsedAt: now, | |
| 1031 | + updatedAt: now, | |
| 1032 | + }); | |
| 147 | 1033 | |
| 148 | − return -1; | |
| 149 | −}; | |
| 1034 | + await insertAuthAuditEvent(ctx, { | |
| 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 = ( | |
| 152 | − bindings: ReadonlyArray<SourceCredentialBinding>, | |
| 153 | − input: { | |
| 154 | − workspaceId: string; | |
| 155 | − organizationId: string; | |
| 156 | − accountId: string | null; | |
| 1043 | + return { committed: true }; | |
| 157 | 1044 | }, |
| 158 | −): SourceCredentialBinding | null => { | |
| 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 | − } | |
| 1045 | +}); | |
| 169 | 1046 | |
| 170 | − if (left.binding.updatedAt !== right.binding.updatedAt) { | |
| 171 | − return right.binding.updatedAt - left.binding.updatedAt; | |
| 172 | − } | |
| 1047 | +export const finalizeOAuthRefreshFailure = internalMutation({ | |
| 1048 | + args: { | |
| 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; | |
| 175 | − }); | |
| 1065 | + if (!stateRow || !connectionRow) { | |
| 1066 | + return; | |
| 1067 | + } | |
| 176 | 1068 | |
| 177 | − return ranked[0]?.binding ?? null; | |
| 178 | −}; | |
| 1069 | + const state = toOAuthState(stateRow as unknown as Record<string, unknown>); | |
| 1070 | + const connection = toAuthConnection(connectionRow as unknown as Record<string, unknown>); | |
| 179 | 1071 | |
| 180 | −const requireLocalSecretProvider = ( | |
| 181 | − secretProvider: SourceCredentialBinding["secretProvider"], | |
| 182 | −): "local" => { | |
| 183 | − if (secretProvider !== "local") { | |
| 184 | − throw new Error("Convex credentials require secretProvider 'local'"); | |
| 185 | − } | |
| 1072 | + if (state.leaseHolder !== args.leaseHolder) { | |
| 1073 | + return; | |
| 1074 | + } | |
| 186 | 1075 | |
| 187 | − return "local"; | |
| 188 | −}; | |
| 1076 | + const now = Date.now(); | |
| 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 ( | |
| 193 | − binding: SourceCredentialBinding, | |
| 194 | −): Promise<string> => { | |
| 195 | − requireLocalSecretProvider(binding.secretProvider); | |
| 196 | − return await decryptSecretValue(binding.secretRef); | |
| 197 | −}; | |
| 1093 | + await insertAuthAuditEvent(ctx, { | |
| 1094 | + organizationId: connection.organizationId, | |
| 1095 | + connectionId: connection.id, | |
| 1096 | + sourceId: null, | |
| 1097 | + eventType: args.reauthRequired ? "reauth_required" : "refresh_failed", | |
| 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 ( | |
| 200 | − ctx: QueryCtx, | |
| 201 | − args: { | |
| 202 | − workspaceId: string; | |
| 203 | − sourceId: string; | |
| 204 | − accountId: string | null; | |
| 1106 | +const refreshOAuthStateIfNeeded = async ( | |
| 1107 | + ctx: any, | |
| 1108 | + input: { | |
| 1109 | + connectionId: string; | |
| 1110 | + forceRefresh: boolean; | |
| 1111 | + oauthState: OAuthState; | |
| 205 | 1112 | }, |
| 206 | −): Promise<{ | |
| 207 | − binding: SourceCredentialBinding | null; | |
| 208 | − oauthAccessToken: string | null; | |
| 209 | −}> => { | |
| 210 | − const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); | |
| 211 | − const sourceKey = sourceKeyFromSourceId(args.sourceId); | |
| 212 | − if (!sourceKey) { | |
| 213 | − return { | |
| 214 | − binding: null, | |
| 215 | − oauthAccessToken: null, | |
| 216 | − }; | |
| 1113 | +): Promise<OAuthState | null> => { | |
| 1114 | + const now = Date.now(); | |
| 1115 | + const isExpiring = | |
| 1116 | + input.oauthState.expiresAt !== null | |
| 1117 | + && input.oauthState.expiresAt <= now + oauthRefreshWindowMs; | |
| 1118 | + const shouldAttemptRefresh = input.forceRefresh || isExpiring; | |
| 1119 | + | |
| 1120 | + if (!shouldAttemptRefresh) { | |
| 1121 | + return input.oauthState; | |
| 217 | 1122 | } |
| 218 | 1123 | |
| 219 | − const rows = await ctx.db | |
| 220 | − .query("sourceCredentialBindings") | |
| 221 | − .withIndex("by_sourceKey", (q) => q.eq("sourceKey", sourceKey)) | |
| 222 | − .collect(); | |
| 223 | − | |
| 224 | − const binding = selectBestSourceBinding( | |
| 225 | − rows.map((row) => toSourceCredentialBinding(row)), | |
| 1124 | + const leaseHolder = `oauth_refresh_${crypto.randomUUID()}`; | |
| 1125 | + const begin = await ctx.runMutation( | |
| 1126 | + runtimeInternal.control_plane.credentials.beginOAuthRefresh, | |
| 226 | 1127 | { |
| 227 | − workspaceId: args.workspaceId, | |
| 228 | − organizationId, | |
| 229 | − accountId: args.accountId, | |
| 1128 | + connectionId: input.connectionId, | |
| 1129 | + leaseHolder, | |
| 1130 | + leaseMs: oauthRefreshLeaseMs, | |
| 230 | 1131 | }, |
| 231 | 1132 | ); |
| 232 | 1133 | |
| 233 | − if (!binding) { | |
| 234 | − return { | |
| 235 | − binding: null, | |
| 236 | − oauthAccessToken: null, | |
| 237 | − }; | |
| 1134 | + if (begin.kind === "missing") { | |
| 1135 | + return null; | |
| 238 | 1136 | } |
| 239 | 1137 | |
| 240 | − const oauthTokens = binding.provider === "oauth2" | |
| 241 | − ? (await ctx.db | |
| 242 | − .query("oauthTokens") | |
| 243 | − .withIndex("by_sourceId", (q) => q.eq("sourceId", args.sourceId)) | |
| 244 | − .collect()).map((row) => toOAuthToken(row)) | |
| 245 | − : []; | |
| 1138 | + if (begin.kind === "locked") { | |
| 1139 | + const fallback = await ctx.runQuery( | |
| 1140 | + runtimeInternal.control_plane.credentials.getOAuthStateByConnection, | |
| 1141 | + { | |
| 1142 | + connectionId: input.connectionId, | |
| 1143 | + }, | |
| 1144 | + ); | |
| 1145 | + return fallback; | |
| 1146 | + } | |
| 246 | 1147 | |
| 247 | − const oauthAccessToken = binding.provider === "oauth2" | |
| 248 | − ? selectOAuthAccessToken(oauthTokens, { | |
| 249 | − workspaceId: args.workspaceId, | |
| 250 | − organizationId, | |
| 251 | − accountId: args.accountId, | |
| 252 | − sourceKey, | |
| 253 | − }, args.sourceId) | |
| 254 | − : null; | |
| 1148 | + const state = begin.oauthState; | |
| 1149 | + const refreshConfig = parseOAuthRefreshConfig(state.refreshConfigJson); | |
| 255 | 1150 | |
| 256 | − return { | |
| 257 | − binding, | |
| 258 | − oauthAccessToken, | |
| 259 | − }; | |
| 260 | −}; | |
| 1151 | + const refreshed = await refreshTokenFromOAuthEndpoint(state, refreshConfig); | |
| 1152 | + if (!refreshed.ok) { | |
| 1153 | + await ctx.runMutation( | |
| 1154 | + runtimeInternal.control_plane.credentials.finalizeOAuthRefreshFailure, | |
| 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 ( | |
| 263 | − binding: SourceCredentialBinding, | |
| 264 | −): Promise<SourceCredentialBinding> => { | |
| 265 | − const secretRef = await resolveSecretRefForHeaders(binding); | |
| 266 | − return { | |
| 267 | − ...binding, | |
| 268 | − secretProvider: "local", | |
| 269 | − secretRef, | |
| 270 | − }; | |
| 1167 | + const fallback = await ctx.runQuery( | |
| 1168 | + runtimeInternal.control_plane.credentials.getOAuthStateByConnection, | |
| 1169 | + { | |
| 1170 | + connectionId: input.connectionId, | |
| 1171 | + }, | |
| 1172 | + ); | |
| 1173 | + return fallback; | |
| 1174 | + } | |
| 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 | 1208 | args: { |
| 275 | − workspaceId: v.string(), | |
| 276 | − sourceId: v.string(), | |
| 277 | − accountId: v.optional(v.union(v.string(), v.null())), | |
| 1209 | + connectionId: v.string(), | |
| 278 | 1210 | }, |
| 279 | − handler: async (ctx, args) => resolveSourceSelection(ctx, { | |
| 280 | − workspaceId: args.workspaceId, | |
| 281 | − sourceId: args.sourceId, | |
| 282 | − accountId: args.accountId ?? null, | |
| 283 | − }), | |
| 1211 | + handler: async (ctx, args): Promise<OAuthState | null> => | |
| 1212 | + await loadOAuthStateByConnectionId(ctx, args.connectionId), | |
| 284 | 1213 | }); |
| 285 | 1214 | |
| 286 | 1215 | export const resolveSourceCredentialHeaders = internalAction({ |
| @@ -288,12 +1217,17 @@ export const resolveSourceCredentialHeaders = internalAction({ · 1 unmodified line | ||
| 288 | 1217 | workspaceId: v.string(), |
| 289 | 1218 | sourceId: v.string(), |
| 290 | 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 | 1226 | headers: Record<string, string>; |
| 1227 | + oauthConnectionId: string | null; | |
| 294 | 1228 | }> => { |
| 295 | 1229 | const selected = await ctx.runQuery( |
| 296 | − runtimeInternal.control_plane.credentials.resolveSourceCredentialSelection, | |
| 1230 | + runtimeInternal.control_plane.credentials.resolveSourceAuthSelection, | |
| 297 | 1231 | { |
| 298 | 1232 | workspaceId: args.workspaceId, |
| 299 | 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 | 1239 | return { |
| 306 | 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, { | |
| 313 | − oauthAccessToken: selected.oauthAccessToken, | |
| 314 | − }); | |
| 1254 | + const metadata = parseConnectionMetadata(connection.metadataJson); | |
| 1255 | + const additionalHeaders = parseAdditionalHeaders(connection.additionalHeadersJson); | |
| 315 | 1256 | |
| 316 | − return { headers }; | |
| 317 | − }, | |
| 318 | −}); | |
| 1257 | + if (connection.strategy === "oauth2") { | |
| 1258 | + let oauthState = selected.oauthState; | |
| 1259 | + if (!oauthState) { | |
| 1260 | + return { | |
| 1261 | + headers: additionalHeaders, | |
| 1262 | + oauthConnectionId: connection.id, | |
| 1263 | + }; | |
| 1264 | + } | |
| 319 | 1265 | |
| 320 | −export const listCredentialBindings = internalQuery({ | |
| 321 | − args: { | |
| 322 | − workspaceId: v.string(), | |
| 323 | − }, | |
| 324 | − handler: async (ctx, args): Promise<Array<SourceCredentialBinding>> => { | |
| 325 | − const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); | |
| 1266 | + oauthState = await refreshOAuthStateIfNeeded(ctx, { | |
| 1267 | + connectionId: connection.id, | |
| 1268 | + forceRefresh: args.forceRefresh === true, | |
| 1269 | + oauthState, | |
| 1270 | + }); | |
| 1271 | + | |
| 1272 | + if (!oauthState) { | |
| 1273 | + return { | |
| 1274 | + headers: additionalHeaders, | |
| 1275 | + oauthConnectionId: connection.id, | |
| 1276 | + }; | |
| 1277 | + } | |
| 326 | 1278 | |
| 327 | − const workspaceRows = await ctx.db | |
| 328 | − .query("sourceCredentialBindings") | |
| 329 | − .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 330 | − .collect(); | |
| 1279 | + const now = Date.now(); | |
| 1280 | + if (oauthState.expiresAt !== null && oauthState.expiresAt <= now) { | |
| 1281 | + return { | |
| 1282 | + headers: additionalHeaders, | |
| 1283 | + oauthConnectionId: connection.id, | |
| 1284 | + }; | |
| 1285 | + } | |
| 331 | 1286 | |
| 332 | − const organizationRows = await ctx.db | |
| 333 | − .query("sourceCredentialBindings") | |
| 334 | − .withIndex("by_organizationId", (q) => q.eq("organizationId", organizationId)) | |
| 335 | − .collect(); | |
| 1287 | + const accessToken = await decryptSecretValue(oauthState.accessTokenCiphertext); | |
| 1288 | + const oauthHeaders = accessToken.trim().length > 0 | |
| 1289 | + ? ({ Authorization: `Bearer ${accessToken}` } as Record<string, string>) | |
| 1290 | + : {}; | |
| 336 | 1291 | |
| 337 | − const bindings = [...workspaceRows, ...organizationRows] | |
| 338 | − .map((row) => | |
| 339 | − toSourceCredentialBinding(row), | |
| 340 | − ) | |
| 341 | − .filter((binding) => | |
| 342 | − canAccessSourceCredentialBinding(binding, { | |
| 343 | − workspaceId: args.workspaceId, | |
| 344 | − organizationId, | |
| 345 | − }) | |
| 346 | − ); | |
| 1292 | + return { | |
| 1293 | + headers: mergeHeaders(oauthHeaders, additionalHeaders), | |
| 1294 | + oauthConnectionId: connection.id, | |
| 1295 | + }; | |
| 1296 | + } | |
| 347 | 1297 | |
| 348 | − const uniqueBindings = Array.from( | |
| 349 | − new Map(bindings.map((binding) => [binding.id, binding])).values(), | |
| 1298 | + const material = await ctx.runQuery( | |
| 1299 | + runtimeInternal.control_plane.credentials.getAuthMaterialByConnection, | |
| 1300 | + { | |
| 1301 | + connectionId: connection.id, | |
| 1302 | + }, | |
| 350 | 1303 | ); |
| 351 | 1304 | |
| 352 | − const decryptedBindings = await Promise.all( | |
| 353 | − uniqueBindings.map((binding) => withDecryptedSecret(binding)), | |
| 354 | − ); | |
| 1305 | + if (!material) { | |
| 1306 | + return { | |
| 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 | 1359 | export const upsertCredentialBindingRecord = internalMutation({ |
| 361 | 1360 | args: { |
| 362 | 1361 | workspaceId: v.string(), |
| 363 | 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 | 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 | 1375 | if (payload.scopeType === "account" && payload.accountId === null) { |
| 369 | 1376 | throw new Error("Account scope credentials require accountId"); |
| 370 | 1377 | } |
| 371 | 1378 | |
| 372 | 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 | |
| 377 | − .query("sourceCredentialBindings") | |
| 378 | − .withIndex("by_domainId", (q) => q.eq("id", bindingId)) | |
| 379 | − .unique(); | |
| 1381 | + const existingBindingRow = payload.id | |
| 1382 | + ? await ctx.db | |
| 1383 | + .query("sourceAuthBindings") | |
| 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 | |
| 382 | − ? toSourceCredentialBinding(existing) | |
| 1400 | + const requestedConnectionId = normalizeString(payload.credentialId) | |
| 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 | 1410 | : null; |
| 384 | 1411 | |
| 385 | − if ( | |
| 386 | − existingBinding !== null | |
| 387 | − && !canAccessSourceCredentialBinding(existingBinding, { | |
| 388 | − workspaceId: args.workspaceId, | |
| 389 | − organizationId, | |
| 390 | − }) | |
| 391 | − ) { | |
| 392 | − throw new Error(`Credential binding not found: ${bindingId}`); | |
| 1412 | + if (existingConnection && existingConnection.organizationId !== organizationId) { | |
| 1413 | + throw new Error("Connection id belongs to another organization"); | |
| 393 | 1414 | } |
| 394 | 1415 | |
| 395 | − const secretProvider = requireLocalSecretProvider( | |
| 396 | − payload.secretProvider | |
| 397 | − ?? existingBinding?.secretProvider | |
| 398 | − ?? defaultSecretProvider(), | |
| 399 | − ); | |
| 1416 | + const nextConnection = decodeAuthConnection({ | |
| 1417 | + id: requestedConnectionId, | |
| 1418 | + organizationId, | |
| 1419 | + workspaceId: scopeWorkspaceId, | |
| 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({ | |
| 402 | − id: bindingId, | |
| 403 | − credentialId: payload.credentialId, | |
| 1446 | + const nextBinding = decodeSourceAuthBinding({ | |
| 1447 | + id: resolvedBindingId, | |
| 1448 | + sourceId, | |
| 1449 | + connectionId: requestedConnectionId, | |
| 404 | 1450 | organizationId, |
| 405 | − workspaceId: payload.scopeType === "workspace" ? args.workspaceId : null, | |
| 406 | − accountId: payload.scopeType === "account" ? (payload.accountId ?? null) : null, | |
| 1451 | + workspaceId: scopeWorkspaceId, | |
| 1452 | + accountId: scopeAccountId, | |
| 407 | 1453 | scopeType: payload.scopeType, |
| 408 | − sourceKey: payload.sourceKey, | |
| 409 | − provider: payload.provider, | |
| 410 | − secretProvider, | |
| 411 | − secretRef: payload.secretRef, | |
| 412 | − additionalHeadersJson: payload.additionalHeadersJson ?? null, | |
| 413 | − boundAuthFingerprint: payload.boundAuthFingerprint ?? null, | |
| 1454 | + selector: existingBinding?.selector ?? null, | |
| 1455 | + enabled: true, | |
| 414 | 1456 | createdAt: existingBinding?.createdAt ?? now, |
| 415 | 1457 | updatedAt: now, |
| 416 | 1458 | }); |
| 417 | 1459 | |
| 418 | − if (existing) { | |
| 419 | − await ctx.db.patch(existing._id, nextBinding); | |
| 1460 | + if (existingBindingRow) { | |
| 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 | 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 | 1591 | payload: sourceCredentialBindingPayloadValidator, |
| 432 | 1592 | }, |
| 433 | 1593 | handler: async (ctx, args): Promise<SourceCredentialBinding> => { |
| 434 | − const payload = args.payload; | |
| 1594 | + const payload = args.payload as ExtendedUpsertCredentialBindingPayload; | |
| 435 | 1595 | const requestedSecretRef = payload.secretRef.trim(); |
| 436 | 1596 | if (requestedSecretRef.length === 0) { |
| 437 | 1597 | throw new Error("Credential secret is required"); |
| 438 | 1598 | } |
| 439 | 1599 | |
| 440 | 1600 | const encryptedSecretRef = await encryptSecretValue(requestedSecretRef); |
| 441 | − | |
| 442 | − const written = await ctx.runMutation( | |
| 1601 | + const refreshToken = normalizeString(payload.oauthRefreshToken); | |
| 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 | 1616 | runtimeInternal.control_plane.credentials.upsertCredentialBindingRecord, |
| 444 | 1617 | { |
| 445 | 1618 | workspaceId: args.workspaceId, |
| 446 | − payload: { | |
| 447 | − ...payload, | |
| 448 | − secretProvider: "local", | |
| 449 | − secretRef: encryptedSecretRef, | |
| 450 | − }, | |
| 1619 | + payload, | |
| 1620 | + secretCiphertext: encryptedSecretRef, | |
| 1621 | + ...(encryptedOauthRefreshToken !== undefined | |
| 1622 | + ? { oauthRefreshCiphertext: encryptedOauthRefreshToken } | |
| 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 | 1643 | const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); |
| 474 | 1644 | |
| 475 | − const existing = await ctx.db | |
| 476 | − .query("sourceCredentialBindings") | |
| 1645 | + const bindingRow = await ctx.db | |
| 1646 | + .query("sourceAuthBindings") | |
| 477 | 1647 | .withIndex("by_domainId", (q) => q.eq("id", args.credentialBindingId)) |
| 478 | 1648 | .unique(); |
| 479 | 1649 | |
| 480 | − if (!existing) { | |
| 1650 | + if (!bindingRow) { | |
| 481 | 1651 | return { removed: false }; |
| 482 | 1652 | } |
| 483 | 1653 | |
| 484 | − const existingBinding = toSourceCredentialBinding( | |
| 485 | − existing, | |
| 486 | − ); | |
| 487 | − | |
| 1654 | + const binding = toSourceAuthBinding(bindingRow as unknown as Record<string, unknown>); | |
| 488 | 1655 | if ( |
| 489 | − !canAccessSourceCredentialBinding(existingBinding, { | |
| 490 | − workspaceId: args.workspaceId, | |
| 491 | − organizationId, | |
| 492 | − }) | |
| 1656 | + binding.workspaceId !== args.workspaceId | |
| 1657 | + && !(binding.workspaceId === null && binding.organizationId === organizationId) | |
| 493 | 1658 | ) { |
| 494 | 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 | 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 | 316 | const collectConfiguredHeadersFromSourceConfig = (config: Record<string, unknown>): Record<string, string> => { |
| 353 | 317 | const headers: Record<string, string> = {}; |
| 354 | 318 | const configuredHeaders = config.headers; |
| @@ -373,7 +337,6 @@ const collectGraphqlHeadersFromSourceConfig = (source: Source): Record<string, s · 18 unmodified lines | ||
| 373 | 337 | return { |
| 374 | 338 | "content-type": "application/json", |
| 375 | 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 | 344 | const config = parseSourceConfig(source.configJson); |
| 382 | 345 | return { |
| 383 | 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 | 65 | const normalizeHttpUrl = (value: string): string | null => { |
| 36 | 66 | const trimmed = value.trim(); |
| 37 | 67 | if (trimmed.length === 0) { |
| @@ -158,16 +188,25 @@ export const upsertSourceRecord = internalMutation({ · 120 unmodified lines | ||
| 158 | 188 | const payload = args.payload as UpsertSourcePayload; |
| 159 | 189 | |
| 160 | 190 | const configJson = (() => { |
| 191 | + const parsedConfig = parseConfigJson(payload.configJson); | |
| 192 | + validateSourceConfigSecurity(parsedConfig); | |
| 193 | + | |
| 161 | 194 | if (payload.kind !== "openapi") { |
| 162 | − return payload.configJson ?? "{}"; | |
| 195 | + return JSON.stringify(parsedConfig); | |
| 163 | 196 | } |
| 164 | 197 | |
| 165 | 198 | const configuredBaseUrl = requireHttpUrl( |
| 166 | − parseConfigJson(payload.configJson).baseUrl, | |
| 199 | + parsedConfig.baseUrl, | |
| 167 | 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 | 212 | const source = decodeSource({ |
| @@ -216,6 +255,7 @@ export const upsertSource = internalAction({ · 42 unmodified lines | ||
| 216 | 255 | |
| 217 | 256 | if (payload.kind === "openapi") { |
| 218 | 257 | const config = parseConfigJson(payload.configJson); |
| 258 | + validateSourceConfigSecurity(config); | |
| 219 | 259 | let resolvedBaseUrl = typeof config.baseUrl === "string" |
| 220 | 260 | ? normalizeHttpUrl(config.baseUrl) |
| 221 | 261 | : null; |
| @@ -241,6 +281,8 @@ export const upsertSource = internalAction({ · 19 unmodified lines | ||
| 241 | 281 | ...payload, |
| 242 | 282 | configJson: buildOpenApiConfigJson(payload, baseUrl), |
| 243 | 283 | }; |
| 284 | + } else { | |
| 285 | + validateSourceConfigSecurity(parseConfigJson(payload.configJson)); | |
| 244 | 286 | } |
| 245 | 287 | |
| 246 | 288 | const source = await ctx.runMutation(runtimeInternal.control_plane.sources.upsertSourceRecord, { |
| @@ -342,6 +384,10 @@ export const removeSource = internalMutation({ · 95 unmodified lines | ||
| 342 | 384 | sourceId: args.sourceId, |
| 343 | 385 | }); |
| 344 | 386 | |
| 387 | + await ctx.runMutation(runtimeInternal.control_plane.credentials.removeSourceAuthBindings, { | |
| 388 | + sourceId: args.sourceId, | |
| 389 | + }); | |
| 390 | + | |
| 345 | 391 | await ctx.db.delete(existing._id); |
| 346 | 392 | |
| 347 | 393 | return { removed: true }; |
| @@ -3,6 +3,11 @@ const encryptedPrefix = "enc:v1:"; · 2 unmodified lines | ||
| 3 | 3 | const encoder = new TextEncoder(); |
| 4 | 4 | const decoder = new TextDecoder(); |
| 5 | 5 | |
| 6 | +type EncryptionKeyEntry = { | |
| 7 | + keyVersion: string; | |
| 8 | + passphrase: string; | |
| 9 | +}; | |
| 10 | + | |
| 6 | 11 | const bytesToBase64 = (bytes: Uint8Array): string => { |
| 7 | 12 | let binary = ""; |
| 8 | 13 | for (const byte of bytes) { |
| @@ -20,55 +25,139 @@ const base64ToBytes = (base64: string): Uint8Array => { · 11 unmodified lines | ||
| 20 | 25 | return bytes; |
| 21 | 26 | }; |
| 22 | 27 | |
| 23 | −const resolveEncryptionPassphrase = (): string => { | |
| 24 | − const passphrase = process.env.CREDENTIAL_ENCRYPTION_KEY?.trim() ?? ""; | |
| 25 | − if (passphrase.length === 0) { | |
| 26 | − throw new Error("Encrypted credential storage requires CREDENTIAL_ENCRYPTION_KEY"); | |
| 28 | +const normalizeKeyVersion = (value: string): string => { | |
| 29 | + const trimmed = value.trim(); | |
| 30 | + return trimmed.length > 0 ? trimmed : "v1"; | |
| 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> => { | |
| 33 | − const passphraseBytes = encoder.encode(resolveEncryptionPassphrase()); | |
| 34 | − const hashBuffer = await crypto.subtle.digest("SHA-256", passphraseBytes); | |
| 35 | − return await crypto.subtle.importKey( | |
| 36 | − "raw", | |
| 37 | − hashBuffer, | |
| 38 | − { | |
| 39 | − name: "AES-GCM", | |
| 40 | − }, | |
| 41 | − false, | |
| 42 | − ["encrypt", "decrypt"], | |
| 43 | − ); | |
| 72 | +const resolveEncryptionKeyEntries = (): Array<EncryptionKeyEntry> => | |
| 73 | + parseEncryptionKeyEntries(); | |
| 74 | + | |
| 75 | +const keyCache = new Map<string, Promise<CryptoKey>>(); | |
| 76 | + | |
| 77 | +const deriveAesKey = async (passphrase: string): Promise<CryptoKey> => { | |
| 78 | + const cacheKey = passphrase; | |
| 79 | + const existing = keyCache.get(cacheKey); | |
| 80 | + if (existing) { | |
| 81 | + return await existing; | |
| 82 | + } | |
| 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 | 105 | if (!value.startsWith(encryptedPrefix)) { |
| 48 | 106 | return null; |
| 49 | 107 | } |
| 50 | 108 | |
| 51 | 109 | const payload = value.slice(encryptedPrefix.length); |
| 52 | 110 | const parts = payload.split(":"); |
| 53 | − if (parts.length !== 2) { | |
| 54 | − return null; | |
| 55 | − } | |
| 56 | 111 | |
| 57 | 112 | try { |
| 58 | − return { | |
| 59 | − iv: base64ToBytes(parts[0] ?? ""), | |
| 60 | − cipher: base64ToBytes(parts[1] ?? ""), | |
| 61 | − }; | |
| 113 | + if (parts.length === 2) { | |
| 114 | + return { | |
| 115 | + keyVersion: null, | |
| 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 | 130 | } catch { |
| 63 | 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 | 155 | export const isEncryptedSecret = (value: string): boolean => |
| 68 | 156 | value.startsWith(encryptedPrefix); |
| 69 | 157 | |
| 70 | 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 | 161 | const iv = crypto.getRandomValues(new Uint8Array(12)); |
| 73 | 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 | 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 | 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 | 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 | 182 | const decrypted = await crypto.subtle.decrypt( |
| 93 | 183 | { |
| 94 | 184 | name: "AES-GCM", |
| @@ -52,6 +52,7 @@ describe("Convex executor and control-plane", () => { · 51 unmodified lines | ||
| 52 | 52 | runUrl: process.env.CLOUDFLARE_SANDBOX_RUN_URL, |
| 53 | 53 | authToken: process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN, |
| 54 | 54 | callbackUrl: process.env.CLOUDFLARE_SANDBOX_CALLBACK_URL, |
| 55 | + callbackSecret: process.env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET, | |
| 55 | 56 | }; |
| 56 | 57 | const originalFetch = globalThis.fetch; |
| 57 | 58 | |
| @@ -59,6 +60,7 @@ describe("Convex executor and control-plane", () => { · 1 unmodified line | ||
| 59 | 60 | process.env.CLOUDFLARE_SANDBOX_RUN_URL = "https://sandbox.local/run"; |
| 60 | 61 | process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN = "sandbox-token"; |
| 61 | 62 | process.env.CLOUDFLARE_SANDBOX_CALLBACK_URL = "https://convex.local/callback"; |
| 63 | + process.env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET = "sandbox-callback-secret"; | |
| 62 | 64 | |
| 63 | 65 | globalThis.fetch = (async ( |
| 64 | 66 | input: RequestInfo | URL, |
| @@ -115,6 +117,12 @@ describe("Convex executor and control-plane", () => { · 50 unmodified lines | ||
| 115 | 117 | } else { |
| 116 | 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 | 203 | id: "credential_binding_1", |
| 196 | 204 | credentialId: "cred_1", |
| 197 | 205 | scopeType: "workspace", |
| 198 | − sourceKey: "github", | |
| 206 | + sourceKey: "source:github", | |
| 199 | 207 | provider: "bearer", |
| 200 | 208 | secretRef: "secret://github/token", |
| 201 | 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 | 1674 | it.effect("persists approval state for runtime tool calls", () => |
| 1387 | 1675 | Effect.gen(function* () { |
| 1388 | 1676 | const t = setup(); |
| @@ -1,13 +1,16 @@ | ||
| 1 | 1 | import { defineSchema, defineTable, type TablesFromSchemaDefinition } from "@executor-v2/confect"; |
| 2 | 2 | import { |
| 3 | 3 | ApprovalSchema, |
| 4 | + AuthAuditEventSchema, | |
| 5 | + AuthConnectionSchema, | |
| 6 | + AuthMaterialSchema, | |
| 4 | 7 | EventEnvelopeSchema, |
| 5 | − OAuthTokenSchema, | |
| 8 | + OAuthStateSchema, | |
| 6 | 9 | OrganizationMembershipSchema, |
| 7 | 10 | OrganizationSchema, |
| 8 | 11 | PolicySchema, |
| 9 | 12 | ProfileSchema, |
| 10 | − SourceCredentialBindingSchema, | |
| 13 | + SourceAuthBindingSchema, | |
| 11 | 14 | SourceSchema, |
| 12 | 15 | StorageInstanceSchema, |
| 13 | 16 | SyncStateSchema, |
| @@ -181,18 +184,32 @@ export const executorConfectSchema = defineSchema({ · 167 unmodified lines | ||
| 181 | 184 | searchField: "searchText", |
| 182 | 185 | filterFields: ["workspaceId", "sourceId", "status", "namespace"], |
| 183 | 186 | }), |
| 184 | − sourceCredentialBindings: defineTable(SourceCredentialBindingSchema) | |
| 187 | + authConnections: defineTable(AuthConnectionSchema) | |
| 185 | 188 | .index("by_domainId", ["id"]) |
| 186 | − .index("by_credentialId", ["credentialId"]) | |
| 187 | − .index("by_workspaceId", ["workspaceId"]) | |
| 188 | 189 | .index("by_organizationId", ["organizationId"]) |
| 190 | + .index("by_workspaceId", ["workspaceId"]) | |
| 189 | 191 | .index("by_accountId", ["accountId"]) |
| 190 | − .index("by_sourceKey", ["sourceKey"]), | |
| 191 | − oauthTokens: defineTable(OAuthTokenSchema) | |
| 192 | + .index("by_status", ["status"]) | |
| 193 | + .index("by_strategy", ["strategy"]), | |
| 194 | + sourceAuthBindings: defineTable(SourceAuthBindingSchema) | |
| 192 | 195 | .index("by_domainId", ["id"]) |
| 196 | + .index("by_sourceId", ["sourceId"]) | |
| 197 | + .index("by_connectionId", ["connectionId"]) | |
| 193 | 198 | .index("by_workspaceId", ["workspaceId"]) |
| 194 | 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 | 213 | .index("by_sourceId", ["sourceId"]), |
| 197 | 214 | policies: defineTable(PolicySchema) |
| 198 | 215 | .index("by_domainId", ["id"]) |
| @@ -33,7 +33,7 @@ import * as ParseResult from "effect/ParseResult"; · 32 unmodified lines | ||
| 33 | 33 | import * as Schema from "effect/Schema"; |
| 34 | 34 | |
| 35 | 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 | 38 | const runtimeInternal = internal as any; |
| 39 | 39 | |
| @@ -186,6 +186,20 @@ const normalizeHeaderString = (value: unknown): string | null => { · 146 unmodified lines | ||
| 186 | 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 | 203 | const parseSourceConfig = (source: Source): Record<string, unknown> => |
| 190 | 204 | jsonObjectFromUnknown(safeJsonParse(source.configJson)); |
| 191 | 205 | |
| @@ -206,39 +220,6 @@ const collectConfiguredHeadersFromSourceConfig = ( · 14 unmodified lines | ||
| 206 | 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 | 223 | const mergeHeaders = (...sets: ReadonlyArray<Record<string, string>>): Record<string, string> => { |
| 243 | 224 | const merged: Record<string, string> = {}; |
| 244 | 225 | const keyByLower = new Map<string, string>(); |
| @@ -269,7 +250,13 @@ const resolveSourceCredentialHeadersForInvocation = async ( · 24 unmodified lines | ||
| 269 | 250 | ctx: ActionCtx, |
| 270 | 251 | source: Source, |
| 271 | 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 | 260 | try { |
| 274 | 261 | const resolved = (await ctx.runAction( |
| 275 | 262 | runtimeInternal.control_plane.credentials.resolveSourceCredentialHeaders, |
| @@ -277,8 +264,9 @@ const resolveSourceCredentialHeadersForInvocation = async ( · 1 unmodified line | ||
| 277 | 264 | workspaceId: source.workspaceId, |
| 278 | 265 | sourceId: source.id, |
| 279 | 266 | accountId, |
| 267 | + ...(options?.forceRefresh === true ? { forceRefresh: true } : {}), | |
| 280 | 268 | }, |
| 281 | − )) as { headers?: unknown }; | |
| 269 | + )) as { headers?: unknown; oauthConnectionId?: unknown }; | |
| 282 | 270 | |
| 283 | 271 | const headers = jsonObjectFromUnknown(resolved.headers); |
| 284 | 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 | 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 | 298 | ctx: ActionCtx, |
| 302 | 299 | source: Source, |
| 303 | 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 | 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 | 331 | collectConfiguredHeadersFromSourceConfig(config), |
| 309 | − collectAuthHeadersFromSourceConfig(config), | |
| 310 | − await resolveSourceCredentialHeadersForInvocation(ctx, source, accountId), | |
| 332 | + invocationHeaders, | |
| 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 | 464 | const headersToRecord = (headers: Headers): Record<string, string> => { |
| @@ -1044,7 +1194,58 @@ const invokeToolWithRegistry = ( · 729 unmodified lines | ||
| 1044 | 1194 | accountId: string, |
| 1045 | 1195 | ): Effect.Effect<{ output: unknown; isError: boolean }, RuntimeAdapterError> => |
| 1046 | 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 | 1250 | const graphqlProvider = { |
| 1050 | 1251 | kind: "graphql" as const, |
| @@ -1078,39 +1279,58 @@ const invokeToolWithRegistry = ( · 27 unmodified lines | ||
| 1078 | 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 | 1314 | const sourceHeaders = await resolveSourceHeadersForInvocation( |
| 1082 | 1315 | ctx, |
| 1083 | 1316 | input.source, |
| 1084 | 1317 | accountId, |
| 1085 | 1318 | ); |
| 1086 | 1319 | |
| 1087 | − const response = await fetch(payload.endpoint, { | |
| 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 | − }); | |
| 1320 | + let result = await invokeGraphql(sourceHeaders.headers); | |
| 1101 | 1321 | |
| 1102 | − const body = await graphqlResponseBody(response); | |
| 1103 | − const bodyRecord = jsonObjectFromUnknown(body); | |
| 1104 | − const hasErrors = Array.isArray(bodyRecord.errors); | |
| 1322 | + if (shouldRetryOAuthAfterUnauthorized(sourceHeaders.oauthConnectionId, result.output)) { | |
| 1323 | + const refreshedHeaders = await resolveSourceHeadersForInvocation( | |
| 1324 | + ctx, | |
| 1325 | + input.source, | |
| 1326 | + accountId, | |
| 1327 | + { forceRefresh: true }, | |
| 1328 | + ); | |
| 1105 | 1329 | |
| 1106 | − return { | |
| 1107 | − output: { | |
| 1108 | − status: response.status, | |
| 1109 | − headers: headersToRecord(response.headers), | |
| 1110 | − body, | |
| 1111 | − }, | |
| 1112 | − isError: response.status >= 400 || hasErrors, | |
| 1113 | − }; | |
| 1330 | + result = await invokeGraphql(refreshedHeaders.headers); | |
| 1331 | + } | |
| 1332 | + | |
| 1333 | + return result; | |
| 1114 | 1334 | }, |
| 1115 | 1335 | catch: (cause) => |
| 1116 | 1336 | toToolProviderError( |
| @@ -1133,11 +1353,6 @@ const invokeToolWithRegistry = ( · 16 unmodified lines | ||
| 1133 | 1353 | |
| 1134 | 1354 | const payload = decodeMcpInvocationPayload(input.tool.providerPayload); |
| 1135 | 1355 | const invokeArgs = normalizeInvokeInput(input.args); |
| 1136 | − const sourceHeaders = await resolveSourceHeadersForInvocation( | |
| 1137 | − ctx, | |
| 1138 | − input.source, | |
| 1139 | − accountId, | |
| 1140 | − ); | |
| 1141 | 1356 | |
| 1142 | 1357 | if (payload.transport !== "streamable-http") { |
| 1143 | 1358 | throw new Error( |
| @@ -1146,74 +1361,99 @@ const invokeToolWithRegistry = ( · 2 unmodified lines | ||
| 1146 | 1361 | } |
| 1147 | 1362 | |
| 1148 | 1363 | const endpoint = createMcpEndpointUrl(payload); |
| 1149 | − const initializeResponse = await postMcpJsonRpc( | |
| 1150 | − endpoint, | |
| 1151 | − { | |
| 1152 | − jsonrpc: "2.0", | |
| 1153 | − id: `init_${crypto.randomUUID()}`, | |
| 1154 | − method: "initialize", | |
| 1155 | − params: { | |
| 1156 | − protocolVersion: "2024-11-05", | |
| 1157 | − capabilities: {}, | |
| 1158 | − clientInfo: { | |
| 1159 | − name: "executor-v2-runtime", | |
| 1160 | − version: "0.1.0", | |
| 1364 | + const invokeMcp = async ( | |
| 1365 | + resolvedHeaders: Record<string, string>, | |
| 1366 | + ): Promise<{ output: unknown; isError: boolean }> => { | |
| 1367 | + const initializeResponse = await postMcpJsonRpc( | |
| 1368 | + endpoint, | |
| 1369 | + { | |
| 1370 | + jsonrpc: "2.0", | |
| 1371 | + id: `init_${crypto.randomUUID()}`, | |
| 1372 | + method: "initialize", | |
| 1373 | + params: { | |
| 1374 | + protocolVersion: "2024-11-05", | |
| 1375 | + capabilities: {}, | |
| 1376 | + clientInfo: { | |
| 1377 | + name: "executor-v2-runtime", | |
| 1378 | + version: "0.1.0", | |
| 1379 | + }, | |
| 1161 | 1380 | }, |
| 1162 | 1381 | }, |
| 1163 | − }, | |
| 1164 | − null, | |
| 1165 | − sourceHeaders, | |
| 1166 | − ); | |
| 1167 | − const initializeBody = await decodeMcpJsonResponse(initializeResponse); | |
| 1382 | + null, | |
| 1383 | + resolvedHeaders, | |
| 1384 | + ); | |
| 1385 | + 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 | 1427 | return { |
| 1173 | 1428 | output: { |
| 1174 | − status: initializeResponse.status, | |
| 1175 | − headers: headersToRecord(initializeResponse.headers), | |
| 1176 | − body: initializeBody, | |
| 1429 | + status: callResponse.status, | |
| 1430 | + headers: headersToRecord(callResponse.headers), | |
| 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( | |
| 1183 | − endpoint, | |
| 1184 | − { | |
| 1185 | − jsonrpc: "2.0", | |
| 1186 | − method: "notifications/initialized", | |
| 1187 | − params: {}, | |
| 1188 | − }, | |
| 1189 | − sessionId, | |
| 1190 | − sourceHeaders, | |
| 1437 | + const sourceHeaders = await resolveSourceHeadersForInvocation( | |
| 1438 | + ctx, | |
| 1439 | + input.source, | |
| 1440 | + accountId, | |
| 1191 | 1441 | ); |
| 1192 | 1442 | |
| 1193 | − const callResponse = await postMcpJsonRpc( | |
| 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); | |
| 1443 | + let result = await invokeMcp(sourceHeaders.headers); | |
| 1208 | 1444 | |
| 1209 | − return { | |
| 1210 | − output: { | |
| 1211 | − status: callResponse.status, | |
| 1212 | − headers: headersToRecord(callResponse.headers), | |
| 1213 | − body: callBody, | |
| 1214 | − }, | |
| 1215 | − isError: callResponse.status >= 400 || callBody.error !== undefined, | |
| 1216 | − }; | |
| 1445 | + if (shouldRetryOAuthAfterUnauthorized(sourceHeaders.oauthConnectionId, result.output)) { | |
| 1446 | + const refreshedHeaders = await resolveSourceHeadersForInvocation( | |
| 1447 | + ctx, | |
| 1448 | + input.source, | |
| 1449 | + accountId, | |
| 1450 | + { forceRefresh: true }, | |
| 1451 | + ); | |
| 1452 | + | |
| 1453 | + result = await invokeMcp(refreshedHeaders.headers); | |
| 1454 | + } | |
| 1455 | + | |
| 1456 | + return result; | |
| 1217 | 1457 | }, |
| 1218 | 1458 | catch: (cause) => |
| 1219 | 1459 | toToolProviderError( |
| @@ -1226,10 +1466,7 @@ const invokeToolWithRegistry = ( · 6 unmodified lines | ||
| 1226 | 1466 | }; |
| 1227 | 1467 | |
| 1228 | 1468 | const registry = makeToolProviderRegistry([ |
| 1229 | − { | |
| 1230 | − kind: "openapi", | |
| 1231 | − invoke: (input) => openApiProvider.invoke(input), | |
| 1232 | − }, | |
| 1469 | + openApiProvider, | |
| 1233 | 1470 | graphqlProvider, |
| 1234 | 1471 | mcpProvider, |
| 1235 | 1472 | ]); |
| @@ -1237,7 +1474,7 @@ const invokeToolWithRegistry = ( · 1 unmodified line | ||
| 1237 | 1474 | const providerKind = artifactTool.protocol; |
| 1238 | 1475 | const invocationPayload = safeJsonParse(artifactTool.invocationJson); |
| 1239 | 1476 | |
| 1240 | − const descriptor: CanonicalToolDescriptor = { | |
| 1477 | + const descriptor = { | |
| 1241 | 1478 | providerKind: providerKind as any, |
| 1242 | 1479 | sourceId: source.id, |
| 1243 | 1480 | workspaceId: source.workspaceId, |
| @@ -1257,7 +1494,7 @@ const invokeToolWithRegistry = ( · 13 unmodified lines | ||
| 1257 | 1494 | : providerKind === "graphql" |
| 1258 | 1495 | ? decodeGraphqlInvocationPayload(invocationPayload) |
| 1259 | 1496 | : decodeMcpInvocationPayload(invocationPayload), |
| 1260 | − }; | |
| 1497 | + } as CanonicalToolDescriptor; | |
| 1261 | 1498 | |
| 1262 | 1499 | const invocation = yield* registry |
| 1263 | 1500 | .invoke({ |
| @@ -1864,3 +2101,28 @@ export const createConvexSourceToolRegistry = ( · 600 unmodified lines | ||
| 1864 | 2101 | }), |
| 1865 | 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 | 1 | import { |
| 2 | − buildCredentialHeaders, | |
| 3 | 2 | CredentialResolverError, |
| 4 | 3 | extractCredentialResolutionContext, |
| 5 | 4 | makeCredentialResolver, |
| 6 | − selectCredentialBinding, | |
| 7 | − selectOAuthAccessToken, | |
| 8 | 5 | sourceIdFromSourceKey, |
| 9 | 6 | type ResolveToolCredentials, |
| 10 | 7 | } from "@executor-v2/engine"; |
| @@ -23,6 +20,136 @@ const toCredentialResolverError = ( · 12 unmodified lines | ||
| 23 | 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 | 153 | export const createPmResolveToolCredentials = ( |
| 27 | 154 | localStateStore: LocalStateStore, |
| 28 | 155 | ): ResolveToolCredentials => |
| @@ -52,27 +179,104 @@ export const createPmResolveToolCredentials = ( · 23 unmodified lines | ||
| 52 | 179 | }; |
| 53 | 180 | } |
| 54 | 181 | |
| 55 | − const binding = selectCredentialBinding( | |
| 56 | − snapshot.credentialBindings, | |
| 57 | − context, | |
| 182 | + const sourceId = sourceIdFromSourceKey(context.sourceKey); | |
| 183 | + if (!sourceId) { | |
| 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 | 227 | if (binding === null) { |
| 60 | 228 | return { |
| 61 | 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 = | |
| 68 | − binding.provider === "oauth2" && sourceId | |
| 69 | − ? selectOAuthAccessToken(snapshot.oauthTokens, context, sourceId) | |
| 70 | − : null; | |
| 242 | + const additionalHeaders = toStringRecord( | |
| 243 | + parseJsonObject(connection.additionalHeadersJson), | |
| 244 | + ); | |
| 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 | 271 | return { |
| 73 | − headers: buildCredentialHeaders(binding, { | |
| 74 | − oauthAccessToken, | |
| 75 | − }), | |
| 272 | + headers: mergeHeaders( | |
| 273 | + buildSecretHeaders( | |
| 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 | 7 | import { |
| 8 | 8 | makeControlPlaneCredentialsService, |
| 9 | 9 | type ControlPlaneCredentialsServiceShape, |
| 10 | + type UpsertCredentialBindingPayload, | |
| 10 | 11 | } from "@executor-v2/management-api"; |
| 11 | 12 | import { |
| 13 | + type AuthConnection, | |
| 14 | + type AuthConnectionStrategy, | |
| 15 | + type AuthMaterial, | |
| 16 | + type OAuthState, | |
| 12 | 17 | type OrganizationId, |
| 18 | + type SourceAuthBinding, | |
| 13 | 19 | type SourceCredentialBinding, |
| 14 | 20 | type WorkspaceId, |
| 15 | 21 | } from "@executor-v2/schema"; |
| @@ -49,6 +55,81 @@ const resolveWorkspaceOrganizationId = ( · 33 unmodified lines | ||
| 49 | 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 | 133 | const sortCredentialBindings = ( |
| 53 | 134 | bindings: ReadonlyArray<SourceCredentialBinding>, |
| 54 | 135 | ): Array<SourceCredentialBinding> => |
| @@ -58,28 +139,110 @@ const sortCredentialBindings = ( · 3 unmodified lines | ||
| 58 | 139 | return leftKey.localeCompare(rightKey); |
| 59 | 140 | }); |
| 60 | 141 | |
| 61 | −const replaceCredentialBindingAt = ( | |
| 62 | − snapshot: LocalStateSnapshot, | |
| 63 | − index: number, | |
| 64 | − binding: SourceCredentialBinding, | |
| 65 | −): LocalStateSnapshot => { | |
| 66 | − const next = [...snapshot.credentialBindings]; | |
| 67 | − next[index] = binding; | |
| 142 | +const upsertById = <A extends { id: string }>( | |
| 143 | + values: ReadonlyArray<A>, | |
| 144 | + nextValue: A, | |
| 145 | +): Array<A> => { | |
| 146 | + const next = [...values]; | |
| 147 | + const index = next.findIndex((value) => value.id === nextValue.id); | |
| 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 | 189 | return { |
| 70 | − ...snapshot, | |
| 71 | − generatedAt: Date.now(), | |
| 72 | − credentialBindings: next, | |
| 190 | + ...(normalizeString(parsed.tokenEndpoint) | |
| 191 | + ? { tokenEndpoint: normalizeString(parsed.tokenEndpoint)! } | |
| 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 = ( | |
| 77 | − snapshot: LocalStateSnapshot, | |
| 78 | − binding: SourceCredentialBinding, | |
| 79 | −): LocalStateSnapshot => ({ | |
| 80 | − ...snapshot, | |
| 81 | − generatedAt: Date.now(), | |
| 82 | − credentialBindings: [...snapshot.credentialBindings, binding], | |
| 211 | +const encodeOAuthRefreshConfig = (config: OAuthRefreshConfig): string | null => { | |
| 212 | + const payload: Record<string, string> = {}; | |
| 213 | + | |
| 214 | + if (config.tokenEndpoint) payload.tokenEndpoint = config.tokenEndpoint; | |
| 215 | + if (config.authorizationServer) payload.authorizationServer = config.authorizationServer; | |
| 216 | + if (config.clientId) payload.clientId = config.clientId; | |
| 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 | 248 | const removeCredentialBindingForWorkspace = ( |
| @@ -88,7 +251,7 @@ const removeCredentialBindingForWorkspace = ( · 2 unmodified lines | ||
| 88 | 251 | credentialBindingId: string, |
| 89 | 252 | ): { snapshot: LocalStateSnapshot; removed: boolean } => { |
| 90 | 253 | const organizationId = resolveWorkspaceOrganizationId(snapshot, workspaceId); |
| 91 | − const removeIndex = snapshot.credentialBindings.findIndex( | |
| 254 | + const removeIndex = snapshot.sourceAuthBindings.findIndex( | |
| 92 | 255 | (binding) => |
| 93 | 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]; | |
| 108 | − next.splice(removeIndex, 1); | |
| 270 | + const bindingToRemove = snapshot.sourceAuthBindings[removeIndex]; | |
| 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 | 285 | return { |
| 111 | 286 | removed: true, |
| 112 | 287 | snapshot: { |
| 113 | 288 | ...snapshot, |
| 114 | 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 | 327 | const organizationId = resolveWorkspaceOrganizationId(snapshot, workspaceId); |
| 138 | 328 | |
| 139 | − return sortCredentialBindings( | |
| 140 | − snapshot.credentialBindings.filter( | |
| 141 | − (binding) => | |
| 142 | − binding.workspaceId === workspaceId | |
| 143 | − || (binding.workspaceId === null && binding.organizationId === organizationId), | |
| 144 | − ), | |
| 329 | + const scopedBindings = snapshot.sourceAuthBindings.filter( | |
| 330 | + (binding) => | |
| 331 | + binding.workspaceId === workspaceId | |
| 332 | + || (binding.workspaceId === null && binding.organizationId === organizationId), | |
| 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 | 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 | 386 | const now = Date.now(); |
| 174 | 387 | const requestedId = input.payload.id; |
| 388 | + const requestedBindingId = requestedId as SourceAuthBinding["id"] | undefined; | |
| 175 | 389 | |
| 176 | − const existingIndex = requestedId | |
| 177 | − ? snapshot.credentialBindings.findIndex( | |
| 178 | − (binding) => | |
| 179 | − binding.workspaceId === input.workspaceId && binding.id === requestedId, | |
| 180 | − ) | |
| 181 | − : -1; | |
| 390 | + const existingBinding = requestedBindingId | |
| 391 | + ? snapshot.sourceAuthBindings.find((binding) => binding.id === requestedBindingId) ?? null | |
| 392 | + : null; | |
| 182 | 393 | |
| 183 | − const existing = existingIndex >= 0 ? snapshot.credentialBindings[existingIndex] : null; | |
| 184 | 394 | const organizationId = resolveWorkspaceOrganizationId(snapshot, input.workspaceId); |
| 185 | 395 | |
| 186 | − const nextBinding: SourceCredentialBinding = { | |
| 187 | − id: | |
| 188 | − existing?.id | |
| 189 | − ?? (requestedId | |
| 190 | − ?? (`credential_binding_${crypto.randomUUID()}` as SourceCredentialBinding["id"])), | |
| 191 | − credentialId: input.payload.credentialId, | |
| 396 | + const scopeWorkspaceId = | |
| 397 | + input.payload.scopeType === "workspace" ? input.workspaceId : null; | |
| 398 | + const scopeAccountId = | |
| 399 | + input.payload.scopeType === "account" ? (input.payload.accountId ?? null) : null; | |
| 400 | + | |
| 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 | 458 | organizationId, |
| 193 | − workspaceId: | |
| 194 | − input.payload.scopeType === "workspace" ? input.workspaceId : null, | |
| 195 | − accountId: | |
| 196 | − input.payload.scopeType === "account" | |
| 197 | − ? (input.payload.accountId ?? null) | |
| 198 | − : null, | |
| 459 | + workspaceId: scopeWorkspaceId, | |
| 460 | + accountId: scopeAccountId, | |
| 199 | 461 | scopeType: input.payload.scopeType, |
| 200 | − sourceKey: input.payload.sourceKey, | |
| 201 | − provider: input.payload.provider, | |
| 202 | − secretProvider: | |
| 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, | |
| 462 | + selector: existingBinding?.selector ?? null, | |
| 463 | + enabled: true, | |
| 464 | + createdAt: existingBinding?.createdAt ?? now, | |
| 210 | 465 | updatedAt: now, |
| 211 | 466 | }; |
| 212 | 467 | |
| 213 | − const nextSnapshot = existingIndex >= 0 | |
| 214 | − ? replaceCredentialBindingAt(snapshot, existingIndex, nextBinding) | |
| 215 | − : appendCredentialBinding(snapshot, nextBinding); | |
| 468 | + let nextSnapshot: LocalStateSnapshot = { | |
| 469 | + ...snapshot, | |
| 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 | 553 | yield* localStateStore.writeSnapshot(nextSnapshot).pipe( |
| 218 | 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 | 562 | removeCredentialBinding: (input) => |
| @@ -28,8 +28,10 @@ const createTestSnapshot = (): LocalStateSnapshot => · 27 unmodified lines | ||
| 28 | 28 | workspaces: [], |
| 29 | 29 | sources: [], |
| 30 | 30 | toolArtifacts: [], |
| 31 | − credentialBindings: [], | |
| 32 | − oauthTokens: [], | |
| 31 | + authConnections: [], | |
| 32 | + sourceAuthBindings: [], | |
| 33 | + authMaterials: [], | |
| 34 | + oauthStates: [], | |
| 33 | 35 | policies: [], |
| 34 | 36 | approvals: [], |
| 35 | 37 | taskRuns: [], |
| @@ -38,8 +38,10 @@ const createTestSnapshot = (): LocalStateSnapshot => · 37 unmodified lines | ||
| 38 | 38 | ], |
| 39 | 39 | sources: [], |
| 40 | 40 | toolArtifacts: [], |
| 41 | − credentialBindings: [], | |
| 42 | − oauthTokens: [], | |
| 41 | + authConnections: [], | |
| 42 | + sourceAuthBindings: [], | |
| 43 | + authMaterials: [], | |
| 44 | + oauthStates: [], | |
| 43 | 45 | policies: [], |
| 44 | 46 | approvals: [], |
| 45 | 47 | taskRuns: [], |
| @@ -149,6 +149,14 @@ export async function GET(request: NextRequest): Promise<Response> { · 148 unmodified lines | ||
| 149 | 149 | } |
| 150 | 150 | |
| 151 | 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 | 160 | const accessToken = tokens?.access_token?.trim() ?? ""; |
| 153 | 161 | |
| 154 | 162 | if (accessToken.length === 0) { |
| @@ -165,5 +173,7 @@ export async function GET(request: NextRequest): Promise<Response> { · 10 unmodified lines | ||
| 165 | 173 | refreshToken: tokens?.refresh_token, |
| 166 | 174 | scope: tokens?.scope, |
| 167 | 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 | 12 | refreshToken?: string; |
| 13 | 13 | scope?: string; |
| 14 | 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 | 17 | refreshToken?: string; |
| 18 | 18 | scope?: string; |
| 19 | 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 | 64 | ...(typeof result.expiresIn === "number" |
| 63 | 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 | 111 | if (authResult === "AUTHORIZED") { |
| 112 | 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 | 121 | const accessToken = tokens?.access_token?.trim() ?? ""; |
| 114 | 122 | if (accessToken.length === 0) { |
| 115 | 123 | return badPopupResponse( |
| @@ -126,6 +134,8 @@ export async function GET(request: NextRequest): Promise<Response> { · 10 unmodified lines | ||
| 126 | 134 | scope: tokens?.scope, |
| 127 | 135 | expiresIn: |
| 128 | 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 | 9 | approvalsByWorkspace, |
| 10 | 10 | optimisticResolveApproval, |
| 11 | 11 | resolveApproval, |
| 12 | + toResolveApprovalRequest, | |
| 12 | 13 | } from "../../lib/control-plane/atoms"; |
| 13 | 14 | import { Button } from "../ui/button"; |
| 14 | 15 | import { |
| @@ -137,16 +138,14 @@ export function ApprovalsView() { · 122 unmodified lines | ||
| 137 | 138 | |
| 138 | 139 | setOptimisticApprovals(nextOptimistic); |
| 139 | 140 | |
| 140 | − void runResolveApproval({ | |
| 141 | − path: { | |
| 142 | − workspaceId, | |
| 143 | − approvalId, | |
| 144 | − }, | |
| 141 | + void runResolveApproval(toResolveApprovalRequest({ | |
| 142 | + workspaceId, | |
| 143 | + approvalId, | |
| 145 | 144 | payload: { |
| 146 | 145 | status, |
| 147 | 146 | reason: null, |
| 148 | 147 | }, |
| 149 | − }) | |
| 148 | + })) | |
| 150 | 149 | .then(() => { |
| 151 | 150 | setStatus(status === "approved" ? "Approval granted." : "Approval denied."); |
| 152 | 151 | setOptimisticApprovals(null); |
| @@ -10,7 +10,9 @@ import { useWorkspace } from "../../lib/hooks/use-workspace"; · 9 unmodified lines | ||
| 10 | 10 | import { |
| 11 | 11 | credentialBindingsByWorkspace, |
| 12 | 12 | removeCredentialBinding, |
| 13 | + toCredentialBindingRemoveRequest, | |
| 13 | 14 | toCredentialBindingRemoveResult, |
| 15 | + toCredentialBindingUpsertRequest, | |
| 14 | 16 | toCredentialBindingUpsertPayload, |
| 15 | 17 | upsertCredentialBinding, |
| 16 | 18 | } from "../../lib/control-plane/atoms"; |
| @@ -93,7 +95,6 @@ export function CredentialsView() { · 76 unmodified lines | ||
| 93 | 95 | binding.provider.toLowerCase().includes(query) || |
| 94 | 96 | binding.scopeType.toLowerCase().includes(query) || |
| 95 | 97 | binding.credentialId.toLowerCase().includes(query) || |
| 96 | − binding.secretRef.toLowerCase().includes(query) || | |
| 97 | 98 | (binding.accountId ?? "").toLowerCase().includes(query) |
| 98 | 99 | ); |
| 99 | 100 | }); |
| @@ -133,11 +134,11 @@ export function CredentialsView() { · 33 unmodified lines | ||
| 133 | 134 | setCredentialProvider(binding.provider); |
| 134 | 135 | setCredentialScopeType(binding.scopeType); |
| 135 | 136 | setCredentialIdInput(binding.credentialId); |
| 136 | − setCredentialSecretRef(binding.secretRef); | |
| 137 | + setCredentialSecretRef(""); | |
| 137 | 138 | setCredentialAccountId(binding.accountId ?? ""); |
| 138 | 139 | setCredentialAdditionalHeadersJson(binding.additionalHeadersJson ?? ""); |
| 139 | 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 | 144 | const handleCancelCredentialEdit = () => { |
| @@ -175,8 +176,8 @@ export function CredentialsView() { · 31 unmodified lines | ||
| 175 | 176 | |
| 176 | 177 | setCredentialBusyId(requestId); |
| 177 | 178 | |
| 178 | − void runUpsertCredentialBinding({ | |
| 179 | − path: { workspaceId }, | |
| 179 | + void runUpsertCredentialBinding(toCredentialBindingUpsertRequest({ | |
| 180 | + workspaceId, | |
| 180 | 181 | payload: toCredentialBindingUpsertPayload({ |
| 181 | 182 | id: credentialEditingId ?? undefined, |
| 182 | 183 | credentialId: credentialId as SourceCredentialBinding["credentialId"], |
| @@ -191,7 +192,7 @@ export function CredentialsView() { · 8 unmodified lines | ||
| 191 | 192 | additionalHeadersJson: additionalHeadersJson.length > 0 ? additionalHeadersJson : null, |
| 192 | 193 | boundAuthFingerprint: boundAuthFingerprint.length > 0 ? boundAuthFingerprint : null, |
| 193 | 194 | }), |
| 194 | − }) | |
| 195 | + })) | |
| 195 | 196 | .then(() => { |
| 196 | 197 | setInfoStatus( |
| 197 | 198 | credentialEditingId |
| @@ -215,12 +216,9 @@ export function CredentialsView() { · 17 unmodified lines | ||
| 215 | 216 | |
| 216 | 217 | setCredentialBusyId(credentialBindingId); |
| 217 | 218 | |
| 218 | − void runRemoveCredentialBinding({ | |
| 219 | − path: { | |
| 220 | − workspaceId, | |
| 221 | − credentialBindingId, | |
| 222 | − }, | |
| 223 | − }) | |
| 219 | + void runRemoveCredentialBinding( | |
| 220 | + toCredentialBindingRemoveRequest({ workspaceId, credentialBindingId }), | |
| 221 | + ) | |
| 224 | 222 | .then((result) => { |
| 225 | 223 | const removed = toCredentialBindingRemoveResult(result); |
| 226 | 224 | setInfoStatus( |
| @@ -444,7 +442,7 @@ export function CredentialsView() { · 217 unmodified lines | ||
| 444 | 442 | id="credential-search" |
| 445 | 443 | value={credentialSearchQuery} |
| 446 | 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 | 446 | disabled={credentialBindingsState.state === "loading"} |
| 449 | 447 | /> |
| 450 | 448 | </div> |
| @@ -11,8 +11,9 @@ import { · 10 unmodified lines | ||
| 11 | 11 | optimisticUpsertPolicy, |
| 12 | 12 | policiesByWorkspace, |
| 13 | 13 | removePolicy, |
| 14 | + toPolicyRemoveRequest, | |
| 14 | 15 | toPolicyRemoveResult, |
| 15 | − toPolicyUpsertPayload, | |
| 16 | + toPolicyUpsertRequest, | |
| 16 | 17 | upsertPolicy, |
| 17 | 18 | } from "../../lib/control-plane/atoms"; |
| 18 | 19 | import { Badge } from "../ui/badge"; |
| @@ -107,14 +108,12 @@ export function PoliciesView() { · 88 unmodified lines | ||
| 107 | 108 | setPolicyBusyId(policyId); |
| 108 | 109 | setOptimisticPolicies(nextPolicies); |
| 109 | 110 | |
| 110 | − void runUpsertPolicy({ | |
| 111 | − path: { workspaceId }, | |
| 112 | − payload: toPolicyUpsertPayload({ | |
| 113 | − id: policyEditingId ?? undefined, | |
| 114 | − toolPathPattern, | |
| 115 | − decision: policyDecision, | |
| 116 | − }), | |
| 117 | − }) | |
| 111 | + void runUpsertPolicy(toPolicyUpsertRequest({ | |
| 112 | + workspaceId, | |
| 113 | + id: policyEditingId ?? undefined, | |
| 114 | + toolPathPattern, | |
| 115 | + decision: policyDecision, | |
| 116 | + })) | |
| 118 | 117 | .then(() => { |
| 119 | 118 | setStatus(policyEditingId ? `Updated policy ${toolPathPattern}.` : `Added policy ${toolPathPattern}.`); |
| 120 | 119 | clearPolicyForm(); |
| @@ -136,7 +135,7 @@ export function PoliciesView() { · 15 unmodified lines | ||
| 136 | 135 | setPolicyBusyId(policyId); |
| 137 | 136 | setOptimisticPolicies(nextPolicies); |
| 138 | 137 | |
| 139 | − void runRemovePolicy({ path: { workspaceId, policyId } }) | |
| 138 | + void runRemovePolicy(toPolicyRemoveRequest({ workspaceId, policyId })) | |
| 140 | 139 | .then((result) => { |
| 141 | 140 | const removed = toPolicyRemoveResult(result); |
| 142 | 141 | setStatus(removed ? "Policy removed." : "Policy not found."); |
| @@ -7,6 +7,8 @@ import type { Organization, Workspace } from "@executor-v2/schema"; · 6 unmodified lines | ||
| 7 | 7 | import { useWorkspace } from "../../lib/hooks/use-workspace"; |
| 8 | 8 | import { |
| 9 | 9 | organizationsState, |
| 10 | + toUpsertOrganizationRequest, | |
| 11 | + toUpsertWorkspaceRequest, | |
| 10 | 12 | upsertOrganization, |
| 11 | 13 | upsertWorkspace, |
| 12 | 14 | toOrganizationUpsertPayload, |
| @@ -168,7 +170,7 @@ export default function SettingsView() { · 155 unmodified lines | ||
| 168 | 170 | status: organizationForm.status, |
| 169 | 171 | }); |
| 170 | 172 | |
| 171 | − void runUpsertOrganization({ payload }) | |
| 173 | + void runUpsertOrganization(toUpsertOrganizationRequest({ payload })) | |
| 172 | 174 | .then((organization) => { |
| 173 | 175 | setOrganizationStatusText(`Saved organization ${organization.slug}.`); |
| 174 | 176 | setOrganizationForm({ |
| @@ -209,7 +211,7 @@ export default function SettingsView() { · 34 unmodified lines | ||
| 209 | 211 | organizationId: organizationId as Workspace["organizationId"], |
| 210 | 212 | }); |
| 211 | 213 | |
| 212 | − void runUpsertWorkspace({ payload }) | |
| 214 | + void runUpsertWorkspace(toUpsertWorkspaceRequest({ payload })) | |
| 213 | 215 | .then((workspace) => { |
| 214 | 216 | setWorkspaceStatusText(`Saved workspace ${workspace.name}.`); |
| 215 | 217 | setWorkspaceForm({ |
| @@ -6,6 +6,8 @@ import type { Organization, Workspace } from "@executor-v2/schema"; · 5 unmodified lines | ||
| 6 | 6 | |
| 7 | 7 | import { |
| 8 | 8 | organizationsState, |
| 9 | + toUpsertOrganizationRequest, | |
| 10 | + toUpsertWorkspaceRequest, | |
| 9 | 11 | toOrganizationUpsertPayload, |
| 10 | 12 | toWorkspaceUpsertPayload, |
| 11 | 13 | upsertOrganization, |
| @@ -278,13 +280,15 @@ export function WorkspaceSelector() { · 266 unmodified lines | ||
| 278 | 280 | |
| 279 | 281 | setCreatingWorkspace(true); |
| 280 | 282 | |
| 281 | − void runUpsertWorkspace({ | |
| 282 | − payload: toWorkspaceUpsertPayload({ | |
| 283 | − id: createWorkspaceId(), | |
| 284 | − name, | |
| 285 | − organizationId: organizationId as Workspace["organizationId"], | |
| 283 | + void runUpsertWorkspace( | |
| 284 | + toUpsertWorkspaceRequest({ | |
| 285 | + payload: toWorkspaceUpsertPayload({ | |
| 286 | + id: createWorkspaceId(), | |
| 287 | + name, | |
| 288 | + organizationId: organizationId as Workspace["organizationId"], | |
| 289 | + }), | |
| 286 | 290 | }), |
| 287 | − }) | |
| 291 | + ) | |
| 288 | 292 | .then((workspace) => { |
| 289 | 293 | setWorkspaceId(workspace.id); |
| 290 | 294 | setWorkspaceName(""); |
| @@ -334,23 +338,27 @@ export function WorkspaceSelector() { · 43 unmodified lines | ||
| 334 | 338 | |
| 335 | 339 | const organizationId = createOrganizationId(); |
| 336 | 340 | |
| 337 | − void runUpsertOrganization({ | |
| 338 | − payload: toOrganizationUpsertPayload({ | |
| 339 | − id: organizationId, | |
| 340 | − name, | |
| 341 | − slug, | |
| 342 | − status: "active", | |
| 341 | + void runUpsertOrganization( | |
| 342 | + toUpsertOrganizationRequest({ | |
| 343 | + payload: toOrganizationUpsertPayload({ | |
| 344 | + id: organizationId, | |
| 345 | + name, | |
| 346 | + slug, | |
| 347 | + status: "active", | |
| 348 | + }), | |
| 343 | 349 | }), |
| 344 | − }) | |
| 350 | + ) | |
| 345 | 351 | .then(async (organization) => { |
| 346 | 352 | try { |
| 347 | − const workspace = await runUpsertWorkspace({ | |
| 348 | − payload: toWorkspaceUpsertPayload({ | |
| 349 | − id: createWorkspaceId(), | |
| 350 | − name, | |
| 351 | − organizationId: organization.id as Workspace["organizationId"], | |
| 353 | + const workspace = await runUpsertWorkspace( | |
| 354 | + toUpsertWorkspaceRequest({ | |
| 355 | + payload: toWorkspaceUpsertPayload({ | |
| 356 | + id: createWorkspaceId(), | |
| 357 | + name, | |
| 358 | + organizationId: organization.id as Workspace["organizationId"], | |
| 359 | + }), | |
| 352 | 360 | }), |
| 353 | − }); | |
| 361 | + ); | |
| 354 | 362 | |
| 355 | 363 | setWorkspaceId(workspace.id); |
| 356 | 364 | setWorkspaceOrganizationId(organization.id); |
| @@ -24,11 +24,14 @@ import { · 23 unmodified lines | ||
| 24 | 24 | readStorageFile, |
| 25 | 25 | removeStorageInstance, |
| 26 | 26 | storageByWorkspace, |
| 27 | + toCloseStorageRequest, | |
| 27 | 28 | toListStorageDirectoryPayload, |
| 28 | 29 | toListStorageKvPayload, |
| 29 | 30 | toOpenStoragePayload, |
| 31 | + toOpenStorageRequest, | |
| 30 | 32 | toQueryStorageSqlPayload, |
| 31 | 33 | toReadStorageFilePayload, |
| 34 | + toRemoveStorageRequest, | |
| 32 | 35 | toStorageRemoveResult, |
| 33 | 36 | } from "../../lib/control-plane/atoms" |
| 34 | 37 | import { Badge } from "../ui/badge" |
| @@ -216,20 +219,22 @@ export default function StorageView() { · 181 unmodified lines | ||
| 216 | 219 | |
| 217 | 220 | setStorageBusyId("create") |
| 218 | 221 | |
| 219 | − void runOpenStorageInstance({ | |
| 220 | − path: { workspaceId }, | |
| 221 | − payload: toOpenStoragePayload({ | |
| 222 | − scopeType: storageScopeType, | |
| 223 | − durability: storageDurability, | |
| 224 | − provider: storageProvider, | |
| 225 | − purpose: purpose.length > 0 ? purpose : undefined, | |
| 226 | − ttlHours: storageDurability === "ephemeral" ? ttlHours : undefined, | |
| 227 | − accountId: | |
| 228 | − storageScopeType === "account" | |
| 229 | − ? (accountId as Exclude<StorageInstance["accountId"], null>) | |
| 230 | − : undefined, | |
| 222 | + void runOpenStorageInstance( | |
| 223 | + toOpenStorageRequest({ | |
| 224 | + workspaceId, | |
| 225 | + payload: toOpenStoragePayload({ | |
| 226 | + scopeType: storageScopeType, | |
| 227 | + durability: storageDurability, | |
| 228 | + provider: storageProvider, | |
| 229 | + purpose: purpose.length > 0 ? purpose : undefined, | |
| 230 | + ttlHours: storageDurability === "ephemeral" ? ttlHours : undefined, | |
| 231 | + accountId: | |
| 232 | + storageScopeType === "account" | |
| 233 | + ? (accountId as Exclude<StorageInstance["accountId"], null>) | |
| 234 | + : undefined, | |
| 235 | + }), | |
| 231 | 236 | }), |
| 232 | − }) | |
| 237 | + ) | |
| 233 | 238 | .then((storageInstance) => { |
| 234 | 239 | setStorageSelectedId(storageInstance.id) |
| 235 | 240 | setStoragePurposeInput("") |
| @@ -253,9 +258,7 @@ export default function StorageView() { · 17 unmodified lines | ||
| 253 | 258 | |
| 254 | 259 | setStorageBusyId(storageInstanceId) |
| 255 | 260 | |
| 256 | − void runCloseStorageInstance({ | |
| 257 | − path: { workspaceId, storageInstanceId }, | |
| 258 | − }) | |
| 261 | + void runCloseStorageInstance(toCloseStorageRequest({ workspaceId, storageInstanceId })) | |
| 259 | 262 | .then(() => { |
| 260 | 263 | setInfoStatus("Storage instance closed.") |
| 261 | 264 | }) |
| @@ -274,9 +277,7 @@ export default function StorageView() { · 12 unmodified lines | ||
| 274 | 277 | |
| 275 | 278 | setStorageBusyId(storageInstanceId) |
| 276 | 279 | |
| 277 | − void runRemoveStorageInstance({ | |
| 278 | − path: { workspaceId, storageInstanceId }, | |
| 279 | − }) | |
| 280 | + void runRemoveStorageInstance(toRemoveStorageRequest({ workspaceId, storageInstanceId })) | |
| 280 | 281 | .then((result) => { |
| 281 | 282 | const removed = toStorageRemoveResult(result) |
| 282 | 283 | setInfoStatus( |
| @@ -14,7 +14,10 @@ import { · 13 unmodified lines | ||
| 14 | 14 | removeSource, |
| 15 | 15 | sourcesByWorkspace, |
| 16 | 16 | sourcesPendingByWorkspace, |
| 17 | + toCredentialBindingUpsertRequest, | |
| 17 | 18 | toCredentialBindingUpsertPayload, |
| 19 | + toRemoveSourceRequest, | |
| 20 | + toUpsertSourceRequest, | |
| 18 | 21 | upsertCredentialBinding, |
| 19 | 22 | upsertSource, |
| 20 | 23 | } from "../../lib/control-plane/atoms"; |
| @@ -468,7 +471,7 @@ export function SourceManagementPanel({ editSource, onDone }: SourceManagementPa · 447 unmodified lines | ||
| 468 | 471 | |
| 469 | 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 | 475 | .then(async () => { |
| 473 | 476 | let oauthLinked = false; |
| 474 | 477 | let oauthLinkNote: string | null = null; |
| @@ -482,21 +485,37 @@ export function SourceManagementPanel({ editSource, onDone }: SourceManagementPa · 7 unmodified lines | ||
| 482 | 485 | if (formState.authMode === "account") { |
| 483 | 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 | 494 | try { |
| 486 | − await runUpsertCredentialBinding({ | |
| 487 | − path: { workspaceId }, | |
| 488 | − payload: toCredentialBindingUpsertPayload({ | |
| 489 | − ...(existingBinding ? { id: existingBinding.id } : {}), | |
| 490 | − credentialId: (existingBinding?.credentialId ?? createLocalId("cred_")) as SourceCredentialBinding["credentialId"], | |
| 491 | − scopeType, | |
| 492 | − sourceKey, | |
| 493 | − provider: "oauth2", | |
| 494 | − secretRef: oauthSessionForEndpoint.accessToken, | |
| 495 | − accountId: null, | |
| 496 | − additionalHeadersJson: null, | |
| 497 | − boundAuthFingerprint: null, | |
| 495 | + await runUpsertCredentialBinding( | |
| 496 | + toCredentialBindingUpsertRequest({ | |
| 497 | + workspaceId, | |
| 498 | + payload: toCredentialBindingUpsertPayload({ | |
| 499 | + ...(existingBinding ? { id: existingBinding.id } : {}), | |
| 500 | + credentialId: (existingBinding?.credentialId ?? createLocalId("cred_")) as SourceCredentialBinding["credentialId"], | |
| 501 | + scopeType, | |
| 502 | + sourceKey, | |
| 503 | + provider: "oauth2", | |
| 504 | + secretRef: oauthSessionForEndpoint.accessToken, | |
| 505 | + accountId: 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 | 519 | oauthLinked = true; |
| 501 | 520 | setMcpOAuthSession(null); |
| 502 | 521 | } catch { |
| @@ -526,7 +545,7 @@ export function SourceManagementPanel({ editSource, onDone }: SourceManagementPa · 23 unmodified lines | ||
| 526 | 545 | const optimistic = optimisticRemoveSources(previousSources, sourceId); |
| 527 | 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 | 549 | .then(() => { |
| 531 | 550 | setStatusText("Source removed."); |
| 532 | 551 | resetForm(); |
| @@ -4,7 +4,7 @@ import type { Approval, ApprovalId, WorkspaceId } from "@executor-v2/schema"; · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { controlPlaneClient } from "../client"; |
| 6 | 6 | import { workspaceEntity, type EntityState } from "./entity"; |
| 7 | −import { approvalsKeys } from "./keys"; | |
| 7 | +import { approvalsKeys, approvalsMutationKeys } from "./keys"; | |
| 8 | 8 | |
| 9 | 9 | // --------------------------------------------------------------------------- |
| 10 | 10 | // Query atoms |
| @@ -14,7 +14,7 @@ export const approvalsResultByWorkspace = Atom.family( · 3 unmodified lines | ||
| 14 | 14 | (workspaceId: WorkspaceId) => |
| 15 | 15 | controlPlaneClient.query("approvals", "list", { |
| 16 | 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 | 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 | 66 | // Optimistic helpers |
| 54 | 67 | // --------------------------------------------------------------------------- |
| @@ -13,7 +13,7 @@ import type { · 12 unmodified lines | ||
| 13 | 13 | |
| 14 | 14 | import { controlPlaneClient } from "../client"; |
| 15 | 15 | import { workspaceEntity, type EntityState } from "./entity"; |
| 16 | −import { credentialsKeys } from "./keys"; | |
| 16 | +import { credentialsKeys, credentialsMutationKeys } from "./keys"; | |
| 17 | 17 | |
| 18 | 18 | // --------------------------------------------------------------------------- |
| 19 | 19 | // Query atoms |
| @@ -23,7 +23,7 @@ export const credentialBindingsResultByWorkspace = Atom.family( · 3 unmodified lines | ||
| 23 | 23 | (workspaceId: WorkspaceId) => |
| 24 | 24 | controlPlaneClient.query("credentials", "list", { |
| 25 | 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 | 64 | accountId?: SourceCredentialBinding["accountId"]; |
| 65 | 65 | additionalHeadersJson?: SourceCredentialBinding["additionalHeadersJson"]; |
| 66 | 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 | 77 | }): UpsertCredentialBindingPayload => ({ |
| 68 | 78 | ...(input.id ? { id: input.id } : {}), |
| 69 | 79 | credentialId: input.credentialId, |
| @@ -78,6 +88,52 @@ export const toCredentialBindingUpsertPayload = (input: { · 8 unmodified lines | ||
| 78 | 88 | ...(input.boundAuthFingerprint !== undefined |
| 79 | 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 | 139 | export const toCredentialBindingRemoveResult = ( |
| @@ -25,6 +25,8 @@ export { · 24 unmodified lines | ||
| 25 | 25 | removeSource, |
| 26 | 26 | optimisticUpsertSources, |
| 27 | 27 | optimisticRemoveSources, |
| 28 | + toUpsertSourceRequest, | |
| 29 | + toRemoveSourceRequest, | |
| 28 | 30 | } from "./sources"; |
| 29 | 31 | |
| 30 | 32 | export { |
| @@ -40,6 +42,7 @@ export { · 9 unmodified lines | ||
| 40 | 42 | approvalPendingByWorkspace, |
| 41 | 43 | resolveApproval, |
| 42 | 44 | optimisticResolveApproval, |
| 45 | + toResolveApprovalRequest, | |
| 43 | 46 | } from "./approvals"; |
| 44 | 47 | |
| 45 | 48 | export { |
| @@ -50,6 +53,8 @@ export { · 4 unmodified lines | ||
| 50 | 53 | optimisticUpsertPolicy, |
| 51 | 54 | optimisticRemovePolicy, |
| 52 | 55 | toPolicyUpsertPayload, |
| 56 | + toPolicyUpsertRequest, | |
| 57 | + toPolicyRemoveRequest, | |
| 53 | 58 | toPolicyRemoveResult, |
| 54 | 59 | } from "./policies"; |
| 55 | 60 | |
| @@ -59,6 +64,8 @@ export { · 3 unmodified lines | ||
| 59 | 64 | upsertCredentialBinding, |
| 60 | 65 | removeCredentialBinding, |
| 61 | 66 | toCredentialBindingUpsertPayload, |
| 67 | + toCredentialBindingUpsertRequest, | |
| 68 | + toCredentialBindingRemoveRequest, | |
| 62 | 69 | toCredentialBindingRemoveResult, |
| 63 | 70 | } from "./credentials"; |
| 64 | 71 | |
| @@ -73,6 +80,9 @@ export { · 8 unmodified lines | ||
| 73 | 80 | listStorageKv, |
| 74 | 81 | queryStorageSql, |
| 75 | 82 | toOpenStoragePayload, |
| 83 | + toOpenStorageRequest, | |
| 84 | + toCloseStorageRequest, | |
| 85 | + toRemoveStorageRequest, | |
| 76 | 86 | toStorageRemoveResult, |
| 77 | 87 | toListStorageDirectoryPayload, |
| 78 | 88 | toReadStorageFilePayload, |
| @@ -101,8 +111,10 @@ export { · 22 unmodified lines | ||
| 101 | 111 | organizationsState, |
| 102 | 112 | upsertOrganization, |
| 103 | 113 | toOrganizationUpsertPayload, |
| 114 | + toUpsertOrganizationRequest, | |
| 104 | 115 | workspacesResult, |
| 105 | 116 | workspacesState, |
| 106 | 117 | upsertWorkspace, |
| 107 | 118 | toWorkspaceUpsertPayload, |
| 119 | + toUpsertWorkspaceRequest, | |
| 108 | 120 | } from "./settings"; |
| @@ -1,22 +1,65 @@ | ||
| 1 | +import type { WorkspaceId } from "@executor-v2/schema"; | |
| 2 | + | |
| 1 | 3 | // --------------------------------------------------------------------------- |
| 2 | 4 | // Reactivity keys |
| 3 | 5 | // |
| 4 | 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 | |
| 8 | −// and spreads a shared workspace key so that switching workspace invalidates | |
| 9 | −// all workspace-scoped data. | |
| 9 | +// Workspace-scoped keys include the concrete workspace id so invalidation stays | |
| 10 | +// precise instead of refreshing unrelated workspace queries. | |
| 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 | 64 | export const organizationsKeys = { organizations: ["list"] } as const; |
| 22 | 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 | 5 | import { controlPlaneClient } from "../client"; |
| 6 | 6 | import { workspaceEntity, type EntityState } from "./entity"; |
| 7 | −import { policiesKeys } from "./keys"; | |
| 7 | +import { policiesKeys, policiesMutationKeys } from "./keys"; | |
| 8 | 8 | |
| 9 | 9 | // --------------------------------------------------------------------------- |
| 10 | 10 | // Query atoms |
| @@ -14,7 +14,7 @@ export const policiesResultByWorkspace = Atom.family( · 3 unmodified lines | ||
| 14 | 14 | (workspaceId: WorkspaceId) => |
| 15 | 15 | controlPlaneClient.query("policies", "list", { |
| 16 | 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 | 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 | 116 | export const toPolicyRemoveResult = (result: RemovePolicyResult): boolean => result.removed; |
| 91 | 117 | |
| 92 | 118 | export type PoliciesState = EntityState<Policy>; |
| @@ -43,6 +43,13 @@ export const toOrganizationUpsertPayload = (input: { · 42 unmodified lines | ||
| 43 | 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 | 54 | // Workspaces |
| 48 | 55 | // --------------------------------------------------------------------------- |
| @@ -74,5 +81,12 @@ export const toWorkspaceUpsertPayload = (input: { · 25 unmodified lines | ||
| 74 | 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 | 91 | export type OrganizationsState = EntityState<Organization>; |
| 78 | 92 | export type WorkspacesState = EntityState<Workspace>; |
| @@ -5,7 +5,7 @@ import * as Option from "effect/Option"; · 4 unmodified lines | ||
| 5 | 5 | |
| 6 | 6 | import { controlPlaneClient } from "../client"; |
| 7 | 7 | import { stateFromResult, type EntityState } from "./entity"; |
| 8 | −import { sourcesKeys } from "./keys"; | |
| 8 | +import { sourceMutationKeys, sourcesKeys } from "./keys"; | |
| 9 | 9 | |
| 10 | 10 | // --------------------------------------------------------------------------- |
| 11 | 11 | // Query atoms |
| @@ -15,7 +15,7 @@ export const sourcesResultByWorkspace = Atom.family( · 3 unmodified lines | ||
| 15 | 15 | (workspaceId: WorkspaceId) => |
| 16 | 16 | controlPlaneClient.query("sources", "list", { |
| 17 | 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 | 97 | export const upsertSource = controlPlaneClient.mutation("sources", "upsert"); |
| 98 | 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 | 121 | // Optimistic helpers |
| 102 | 122 | // --------------------------------------------------------------------------- |
| @@ -20,7 +20,7 @@ import type { · 19 unmodified lines | ||
| 20 | 20 | |
| 21 | 21 | import { controlPlaneClient } from "../client"; |
| 22 | 22 | import { workspaceEntity, type EntityState } from "./entity"; |
| 23 | −import { storageKeys } from "./keys"; | |
| 23 | +import { storageKeys, storageMutationKeys } from "./keys"; | |
| 24 | 24 | |
| 25 | 25 | // --------------------------------------------------------------------------- |
| 26 | 26 | // Query atoms |
| @@ -30,7 +30,7 @@ export const storageResultByWorkspace = Atom.family( · 3 unmodified lines | ||
| 30 | 30 | (workspaceId: WorkspaceId) => |
| 31 | 31 | controlPlaneClient.query("storage", "list", { |
| 32 | 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 | 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 | 116 | export const toStorageRemoveResult = (result: RemoveStorageInstanceResult): boolean => |
| 86 | 117 | result.removed; |
| 87 | 118 | |
| @@ -15,7 +15,7 @@ export const workspaceToolsResultByWorkspace = Atom.family( · 14 unmodified lines | ||
| 15 | 15 | (workspaceId: WorkspaceId) => |
| 16 | 16 | controlPlaneClient.query("tools", "listWorkspaceTools", { |
| 17 | 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 | 23 | (input: { workspaceId: WorkspaceId; sourceId: SourceId }) => |
| 24 | 24 | controlPlaneClient.query("tools", "listSourceTools", { |
| 25 | 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 | 35 | sourceId: input.sourceId, |
| 36 | 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 | 162 | }): Record<string, unknown> => { |
| 163 | 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 | 165 | if (input.type === "mcp") { |
| 185 | 166 | return { |
| 186 | 167 | url: endpoint, |
| 187 | 168 | ...(input.mcpTransport !== "auto" ? { transport: input.mcpTransport } : {}), |
| 188 | − ...(auth ? { auth } : {}), | |
| 189 | − ...(input.authType !== "none" ? { useCredentialedFetch: true } : {}), | |
| 190 | 169 | }; |
| 191 | 170 | } |
| 192 | 171 | |
| 193 | 172 | if (input.type === "graphql") { |
| 194 | 173 | return { |
| 195 | 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 | 179 | spec: endpoint, |
| 203 | 180 | specUrl: endpoint, |
| 204 | 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 | 4 | refreshToken?: string; |
| 5 | 5 | scope?: string; |
| 6 | 6 | expiresIn?: number; |
| 7 | + clientId?: string; | |
| 8 | + clientInformationJson?: string; | |
| 7 | 9 | }; |
| 8 | 10 | |
| 9 | 11 | type McpOAuthPopupMessage = |
| @@ -16,6 +18,8 @@ type McpOAuthPopupMessage = · 6 unmodified lines | ||
| 16 | 18 | refreshToken?: string; |
| 17 | 19 | scope?: string; |
| 18 | 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 | 114 | ...(typeof data.payload.expiresIn === "number" |
| 111 | 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 | 24 | refreshToken?: string; |
| 25 | 25 | scope?: string; |
| 26 | 26 | expiresIn?: number; |
| 27 | + clientId?: string; | |
| 28 | + clientInformationJson?: string; | |
| 27 | 29 | error?: string; |
| 28 | 30 | }; |
| 29 | 31 | |
| @@ -106,6 +108,8 @@ const decodePopupResult = (value: unknown): McpOAuthPopupResult | null => { · 76 unmodified lines | ||
| 106 | 108 | refreshToken: readOptionalString(record, "refreshToken"), |
| 107 | 109 | scope: readOptionalString(record, "scope"), |
| 108 | 110 | expiresIn: readOptionalNumber(record, "expiresIn"), |
| 111 | + clientId: readOptionalString(record, "clientId"), | |
| 112 | + clientInformationJson: readOptionalString(record, "clientInformationJson"), | |
| 109 | 113 | error: readOptionalString(record, "error"), |
| 110 | 114 | }; |
| 111 | 115 | }; |
| @@ -12,6 +12,7 @@ import { pipe } from "effect/Function"; · 11 unmodified lines | ||
| 12 | 12 | import * as ParseResult from "effect/ParseResult"; |
| 13 | 13 | import * as Schema from "effect/Schema"; |
| 14 | 14 | |
| 15 | +import { collectSourceHeaders } from "./source-config"; | |
| 15 | 16 | import { ToolProviderError, type ToolProvider } from "./tool-providers"; |
| 16 | 17 | |
| 17 | 18 | const OpenApiToolArgsSchema = Schema.Record({ |
| @@ -175,8 +176,9 @@ const buildFetchRequest = ( · 157 unmodified lines | ||
| 175 | 176 | const resolvedPath = yield* replacePathTemplate(payload.pathTemplate, args, payload); |
| 176 | 177 | const baseUrl = yield* resolveBaseUrl(source); |
| 177 | 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 | 182 | const cookieParts: Array<string> = []; |
| 181 | 183 | |
| 182 | 184 | for (const parameter of payload.parameters) { |
| @@ -105,39 +105,5 @@ export const readMcpTransportFromConfig = ( · 104 unmodified lines | ||
| 105 | 105 | export const collectSourceHeaders = ( |
| 106 | 106 | config: SourceConfig, |
| 107 | 107 | ): Record<string, string> => { |
| 108 | − const configuredHeaders = 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 | − }; | |
| 108 | + return readStringRecord(config.headers); | |
| 143 | 109 | }; |
| @@ -28,9 +28,23 @@ const OptionalUpsertCredentialBindingPayloadSchema = SourceCredentialBindingSche · 27 unmodified lines | ||
| 28 | 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 | 44 | export const UpsertCredentialBindingPayloadSchema = |
| 32 | 45 | RequiredUpsertCredentialBindingPayloadSchema.pipe( |
| 33 | 46 | Schema.extend(OptionalUpsertCredentialBindingPayloadSchema), |
| 47 | + Schema.extend(OptionalOAuthUpsertFieldsSchema), | |
| 34 | 48 | ); |
| 35 | 49 | |
| 36 | 50 | export type UpsertCredentialBindingPayload = |
| @@ -11,6 +11,7 @@ export const CredentialProviderSchema = Schema.Literal( · 10 unmodified lines | ||
| 11 | 11 | "api_key", |
| 12 | 12 | "bearer", |
| 13 | 13 | "oauth2", |
| 14 | + "basic", | |
| 14 | 15 | "custom", |
| 15 | 16 | ); |
| 16 | 17 | export const CredentialSecretProviderSchema = Schema.Literal( |
| @@ -21,6 +22,49 @@ export const CredentialScopeTypeSchema = Schema.Literal( · 4 unmodified lines | ||
| 21 | 22 | "organization", |
| 22 | 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 | 68 | export const StorageScopeTypeSchema = Schema.Literal( |
| 25 | 69 | "scratch", |
| 26 | 70 | "account", |
| @@ -49,6 +93,13 @@ export type CredentialMode = typeof CredentialModeSchema.Type; · 22 unmodified lines | ||
| 49 | 93 | export type CredentialProvider = typeof CredentialProviderSchema.Type; |
| 50 | 94 | export type CredentialSecretProvider = typeof CredentialSecretProviderSchema.Type; |
| 51 | 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 | 103 | export type StorageScopeType = typeof StorageScopeTypeSchema.Type; |
| 53 | 104 | export type StorageDurability = typeof StorageDurabilitySchema.Type; |
| 54 | 105 | export type StorageInstanceStatus = typeof StorageInstanceStatusSchema.Type; |
| @@ -11,6 +11,19 @@ export const CredentialBindingIdSchema = Schema.String.pipe( · 10 unmodified lines | ||
| 11 | 11 | ); |
| 12 | 12 | export const CredentialIdSchema = Schema.String.pipe(Schema.brand("CredentialId")); |
| 13 | 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 | 27 | export const OrganizationMemberIdSchema = Schema.String.pipe( |
| 15 | 28 | Schema.brand("OrganizationMemberId"), |
| 16 | 29 | ); |
| @@ -32,6 +45,11 @@ export type ToolArtifactId = typeof ToolArtifactIdSchema.Type; · 15 unmodified lines | ||
| 32 | 45 | export type CredentialBindingId = typeof CredentialBindingIdSchema.Type; |
| 33 | 46 | export type CredentialId = typeof CredentialIdSchema.Type; |
| 34 | 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 | 53 | export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type; |
| 36 | 54 | export type PolicyId = typeof PolicyIdSchema.Type; |
| 37 | 55 | export type ApprovalId = typeof ApprovalIdSchema.Type; |
| @@ -8,6 +8,11 @@ export * from "./models/source"; · 7 unmodified lines | ||
| 8 | 8 | export * from "./models/tool-artifact"; |
| 9 | 9 | export * from "./models/oauth-token"; |
| 10 | 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 | 16 | export * from "./models/policy"; |
| 12 | 17 | export * from "./models/approval"; |
| 13 | 18 | export * from "./models/task-run"; |
| @@ -16,4 +21,3 @@ export * from "./models/storage-instance"; · 2 unmodified lines | ||
| 16 | 21 | export * from "./models/event-envelope"; |
| 17 | 22 | export * from "./models/runtime-tool"; |
| 18 | 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 | 1 | import { |
| 2 | + AuthConnectionSchema, | |
| 3 | + AuthMaterialSchema, | |
| 2 | 4 | ApprovalSchema, |
| 3 | 5 | EventEnvelopeSchema, |
| 4 | − OAuthTokenSchema, | |
| 6 | + OAuthStateSchema, | |
| 5 | 7 | OrganizationMembershipSchema, |
| 6 | 8 | OrganizationSchema, |
| 7 | 9 | PolicySchema, |
| 8 | 10 | ProfileSchema, |
| 9 | 11 | SchemaVersionSchema, |
| 10 | − SourceCredentialBindingSchema, | |
| 12 | + SourceAuthBindingSchema, | |
| 11 | 13 | SourceSchema, |
| 12 | 14 | StorageInstanceSchema, |
| 13 | 15 | SyncStateSchema, |
| @@ -27,8 +29,10 @@ export const LocalStateSnapshotSchema = Schema.Struct({ · 13 unmodified lines | ||
| 27 | 29 | workspaces: Schema.Array(WorkspaceSchema), |
| 28 | 30 | sources: Schema.Array(SourceSchema), |
| 29 | 31 | toolArtifacts: Schema.Array(ToolArtifactSchema), |
| 30 | − credentialBindings: Schema.Array(SourceCredentialBindingSchema), | |
| 31 | − oauthTokens: Schema.Array(OAuthTokenSchema), | |
| 32 | + authConnections: Schema.Array(AuthConnectionSchema), | |
| 33 | + sourceAuthBindings: Schema.Array(SourceAuthBindingSchema), | |
| 34 | + authMaterials: Schema.Array(AuthMaterialSchema), | |
| 35 | + oauthStates: Schema.Array(OAuthStateSchema), | |
| 32 | 36 | policies: Schema.Array(PolicySchema), |
| 33 | 37 | approvals: Schema.Array(ApprovalSchema), |
| 34 | 38 | taskRuns: Schema.Array(TaskRunSchema), |
| @@ -69,8 +69,10 @@ describe("makeLocalStateStore", () => { · 68 unmodified lines | ||
| 69 | 69 | ], |
| 70 | 70 | sources: [], |
| 71 | 71 | toolArtifacts: [], |
| 72 | − credentialBindings: [], | |
| 73 | − oauthTokens: [], | |
| 72 | + authConnections: [], | |
| 73 | + sourceAuthBindings: [], | |
| 74 | + authMaterials: [], | |
| 75 | + oauthStates: [], | |
| 74 | 76 | policies: [], |
| 75 | 77 | approvals: [], |
| 76 | 78 | taskRuns: [], |