History

refactor(v2): adopt connection-first auth model for credentials

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.

Rhys Sullivan committed 8 months ago

Commit 6d8f0441 parent 94b440b48 files · +3755 −750Browse files

Unified · Split

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