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
1616 apps/convex/node_modules/@convex-dev/action-cache
1717 *.tsbuildinfo
1818 tsconfig.tsbuildinfo
19+personal-notes/
20+# dependencies (bun install)
21+node_modules
22+executor/bun.lock
23+
24+# output
25+out
26+dist
27+*.tgz
28+
29+# code coverage
30+coverage
31+*.lcov
32+
33+# logs
34+logs
35+*.log
36+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
37+
38+# dotenv environment variable files
39+.env
40+.env.production
41+.env.development.local
42+.env.test.local
43+.env.production.local
44+.env.local
45+
46+# caches
47+.eslintcache
48+.cache
49+*.tsbuildinfo
50+.reference/
51+
52+# IntelliJ based IDEs
53+.idea
54+
55+# Finder (MacOS) folder config
56+.DS_Store
57+
58+# Convex local backend state
59+convex_local_backend.sqlite3
60+convex_local_storage/
61+
62+# Local OpenAPI sources service state
63+sources/data/*.sqlite
64+sources/data/*.sqlite-shm
65+sources/data/*.sqlite-wal
66+
67+# Composio toolkit scrape outputs
68+sources/composio-scrape/data/
69+
70+# Cloudflare Workers local state
71+.wrangler/
72+.dev.vars
73+
74+# Generated push env file
75+executor/.env.executor-push
76+
77+# Dev runner PID file
78+.dev.pids
79+*.har
80+.vercel
81+.env*.local
82+.chat-links.json
83+
apps/convex/convex/control_plane/credentials.ts+1561−282
@@ -1,19 +1,29 @@
11 import {
2− buildCredentialHeaders,
3− selectOAuthAccessToken,
4−} from "@executor-v2/engine";
5−import { type UpsertCredentialBindingPayload } from "@executor-v2/management-api";
2+ type UpsertCredentialBindingPayload,
3+} from "@executor-v2/management-api";
64 import {
7− OAuthTokenSchema,
5+ AuthConnectionSchema,
6+ AuthMaterialSchema,
7+ OAuthStateSchema,
8+ SourceAuthBindingSchema,
89 SourceCredentialBindingSchema,
9− type OAuthToken,
10+ type AuthConnection,
11+ type AuthConnectionStatus,
12+ type AuthConnectionStrategy,
13+ type AuthMaterial,
14+ type OAuthState,
15+ type SourceAuthBinding,
1016 type SourceCredentialBinding,
1117 } from "@executor-v2/schema";
1218 import { v } from "convex/values";
1319 import * as Schema from "effect/Schema";
1420
1521 import { internal } from "../_generated/api";
16−import { decryptSecretValue, encryptSecretValue } from "../credential_crypto";
22+import {
23+ currentCredentialEncryptionKeyVersion,
24+ decryptSecretValue,
25+ encryptSecretValue,
26+} from "../credential_crypto";
1727 import {
1828 internalAction,
1929 internalMutation,
@@ -24,27 +34,19 @@ import { · 4 unmodified lines
2434
2535 const runtimeInternal = internal;
2636
27−const decodeSourceCredentialBinding = Schema.decodeUnknownSync(
37+const decodeAuthConnection = Schema.decodeUnknownSync(AuthConnectionSchema);
38+const decodeSourceAuthBinding = Schema.decodeUnknownSync(SourceAuthBindingSchema);
39+const decodeAuthMaterial = Schema.decodeUnknownSync(AuthMaterialSchema);
40+const decodeOAuthState = Schema.decodeUnknownSync(OAuthStateSchema);
41+const decodeCompatSourceCredentialBinding = Schema.decodeUnknownSync(
2842 SourceCredentialBindingSchema,
2943 );
30−const decodeOAuthToken = Schema.decodeUnknownSync(OAuthTokenSchema);
31−
32−const stripConvexSystemFields = (
33− value: Record<string, unknown>,
34−): Record<string, unknown> => {
35− const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
36− return rest;
37−};
38−
39−const toSourceCredentialBinding = (
40− document: Record<string, unknown>,
41−): SourceCredentialBinding =>
42− decodeSourceCredentialBinding(stripConvexSystemFields(document));
4344
4445 const credentialProviderValidator = v.union(
4546 v.literal("api_key"),
4647 v.literal("bearer"),
4748 v.literal("oauth2"),
49+ v.literal("basic"),
4850 v.literal("custom"),
4951 );
5052
@@ -67,9 +69,305 @@ const sourceCredentialBindingPayloadValidator = v.object({ · 16 unmodified lines
6769 accountId: v.optional(v.union(v.string(), v.null())),
6870 additionalHeadersJson: v.optional(v.union(v.string(), v.null())),
6971 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())),
7082 });
7183
72−const sortSourceCredentialBindings = (
84+type ExtendedUpsertCredentialBindingPayload = UpsertCredentialBindingPayload & {
85+ oauthRefreshToken?: string | null;
86+ oauthExpiresAt?: number | null;
87+ oauthScope?: string | null;
88+ oauthIssuer?: string | null;
89+ oauthTokenEndpoint?: string | null;
90+ oauthAuthorizationServer?: string | null;
91+ oauthClientId?: string | null;
92+ oauthClientSecret?: string | null;
93+ oauthSourceUrl?: string | null;
94+ oauthClientInformationJson?: string | null;
95+};
96+
97+const stripConvexSystemFields = (
98+ value: Record<string, unknown>,
99+): Record<string, unknown> => {
100+ const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
101+ return rest;
102+};
103+
104+const normalizeString = (value: unknown): string | null => {
105+ if (typeof value !== "string") {
106+ return null;
107+ }
108+
109+ const trimmed = value.trim();
110+ return trimmed.length > 0 ? trimmed : null;
111+};
112+
113+const parseJsonObject = (value: string | null | undefined): Record<string, unknown> => {
114+ if (!value) {
115+ return {};
116+ }
117+
118+ try {
119+ const parsed = JSON.parse(value) as unknown;
120+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
121+ return {};
122+ }
123+
124+ return parsed as Record<string, unknown>;
125+ } catch {
126+ return {};
127+ }
128+};
129+
130+const toStringRecord = (
131+ value: Record<string, unknown>,
132+): Record<string, string> => {
133+ const record: Record<string, string> = {};
134+
135+ for (const [rawKey, rawValue] of Object.entries(value)) {
136+ const key = normalizeString(rawKey);
137+ const normalizedValue = normalizeString(rawValue);
138+ if (!key || !normalizedValue) {
139+ continue;
140+ }
141+
142+ record[key] = normalizedValue;
143+ }
144+
145+ return record;
146+};
147+
148+const parseAdditionalHeaders = (
149+ additionalHeadersJson: string | null,
150+): Record<string, string> => {
151+ if (!additionalHeadersJson) {
152+ return {};
153+ }
154+
155+ return toStringRecord(parseJsonObject(additionalHeadersJson));
156+};
157+
158+const parseConnectionMetadata = (
159+ metadataJson: string | null,
160+): Record<string, unknown> => parseJsonObject(metadataJson);
161+
162+const base64Encode = (value: string): string => {
163+ if (typeof btoa === "function") {
164+ return btoa(value);
165+ }
166+
167+ const maybeBuffer = (globalThis as { Buffer?: typeof Buffer }).Buffer;
168+ if (maybeBuffer) {
169+ return maybeBuffer.from(value, "utf8").toString("base64");
170+ }
171+
172+ throw new Error("No base64 encoder available");
173+};
174+
175+const strategyFromProvider = (
176+ provider: UpsertCredentialBindingPayload["provider"],
177+): AuthConnectionStrategy => {
178+ if (provider === "api_key") return "api_key";
179+ if (provider === "bearer") return "bearer";
180+ if (provider === "oauth2") return "oauth2";
181+ if (provider === "basic") return "basic";
182+ return "custom";
183+};
184+
185+const providerFromStrategy = (
186+ strategy: AuthConnectionStrategy,
187+): SourceCredentialBinding["provider"] => {
188+ if (strategy === "api_key") return "api_key";
189+ if (strategy === "bearer") return "bearer";
190+ if (strategy === "oauth2") return "oauth2";
191+ if (strategy === "basic") return "basic";
192+ return "custom";
193+};
194+
195+const scopeScore = (
196+ binding: SourceAuthBinding,
197+ input: {
198+ workspaceId: string;
199+ organizationId: string;
200+ accountId: string | null;
201+ },
202+): number => {
203+ if (!binding.enabled) {
204+ return -1;
205+ }
206+
207+ if (binding.scopeType === "account") {
208+ if (!input.accountId || binding.accountId !== input.accountId) {
209+ return -1;
210+ }
211+
212+ return binding.organizationId === input.organizationId ? 30 : -1;
213+ }
214+
215+ if (binding.scopeType === "workspace") {
216+ return binding.workspaceId === input.workspaceId ? 20 : -1;
217+ }
218+
219+ if (binding.scopeType === "organization") {
220+ return binding.organizationId === input.organizationId ? 10 : -1;
221+ }
222+
223+ return -1;
224+};
225+
226+const selectBestBinding = (
227+ bindings: ReadonlyArray<SourceAuthBinding>,
228+ input: {
229+ workspaceId: string;
230+ organizationId: string;
231+ accountId: string | null;
232+ },
233+): SourceAuthBinding | null => {
234+ const ranked = bindings
235+ .map((binding) => ({
236+ binding,
237+ score: scopeScore(binding, input),
238+ }))
239+ .filter((entry) => entry.score >= 0)
240+ .sort((left, right) => {
241+ if (left.score !== right.score) {
242+ return right.score - left.score;
243+ }
244+
245+ if (left.binding.updatedAt !== right.binding.updatedAt) {
246+ return right.binding.updatedAt - left.binding.updatedAt;
247+ }
248+
249+ return right.binding.createdAt - left.binding.createdAt;
250+ });
251+
252+ return ranked[0]?.binding ?? null;
253+};
254+
255+const sourceIdFromSourceKey = (sourceKey: string): string | null => {
256+ const trimmed = sourceKey.trim();
257+ if (!trimmed.startsWith("source:")) {
258+ return null;
259+ }
260+
261+ const sourceId = trimmed.slice("source:".length).trim();
262+ return sourceId.length > 0 ? sourceId : null;
263+};
264+
265+const sourceKeyFromSourceId = (sourceId: string): string => `source:${sourceId}`;
266+
267+const toAuthConnection = (document: Record<string, unknown>): AuthConnection =>
268+ decodeAuthConnection(stripConvexSystemFields(document));
269+
270+const toSourceAuthBinding = (document: Record<string, unknown>): SourceAuthBinding =>
271+ decodeSourceAuthBinding(stripConvexSystemFields(document));
272+
273+const toAuthMaterial = (document: Record<string, unknown>): AuthMaterial =>
274+ decodeAuthMaterial(stripConvexSystemFields(document));
275+
276+const toOAuthState = (document: Record<string, unknown>): OAuthState =>
277+ decodeOAuthState(stripConvexSystemFields(document));
278+
279+const resolveWorkspaceOrganizationId = async (
280+ ctx: QueryCtx | MutationCtx,
281+ workspaceId: string,
282+): Promise<string> => {
283+ const workspace = await ctx.db
284+ .query("workspaces")
285+ .withIndex("by_domainId", (q) => q.eq("id", workspaceId))
286+ .unique();
287+
288+ if (!workspace) {
289+ throw new Error(`Workspace not found: ${workspaceId}`);
290+ }
291+
292+ return workspace.organizationId;
293+};
294+
295+const mergeHeaders = (...sets: ReadonlyArray<Record<string, string>>): Record<string, string> => {
296+ const merged: Record<string, string> = {};
297+ const keyByLower = new Map<string, string>();
298+
299+ for (const set of sets) {
300+ for (const [rawKey, rawValue] of Object.entries(set)) {
301+ const key = rawKey.trim();
302+ const value = rawValue.trim();
303+ if (key.length === 0 || value.length === 0) {
304+ continue;
305+ }
306+
307+ const lower = key.toLowerCase();
308+ const existing = keyByLower.get(lower);
309+ if (existing && existing !== key) {
310+ delete merged[existing];
311+ }
312+
313+ keyByLower.set(lower, key);
314+ merged[key] = value;
315+ }
316+ }
317+
318+ return merged;
319+};
320+
321+const buildSecretHeaders = (
322+ strategy: AuthConnectionStrategy,
323+ secret: string,
324+ metadata: Record<string, unknown>,
325+): Record<string, string> => {
326+ const trimmed = secret.trim();
327+ if (trimmed.length === 0) {
328+ return {};
329+ }
330+
331+ if (strategy === "api_key") {
332+ const header = normalizeString(metadata.apiKeyHeader) ?? "x-api-key";
333+ return { [header]: trimmed };
334+ }
335+
336+ if (strategy === "bearer") {
337+ return { Authorization: `Bearer ${trimmed}` };
338+ }
339+
340+ if (strategy === "basic") {
341+ const asJson = parseJsonObject(trimmed);
342+ const usernameFromJson = normalizeString(asJson.username);
343+ const passwordFromJson = normalizeString(asJson.password);
344+
345+ const pair = usernameFromJson && passwordFromJson
346+ ? `${usernameFromJson}:${passwordFromJson}`
347+ : trimmed.includes(":")
348+ ? trimmed
349+ : null;
350+
351+ if (!pair) {
352+ return {};
353+ }
354+
355+ return {
356+ Authorization: `Basic ${base64Encode(pair)}`,
357+ };
358+ }
359+
360+ if (strategy === "custom") {
361+ const customHeaderName = normalizeString(metadata.customHeaderName);
362+ if (customHeaderName) {
363+ return { [customHeaderName]: trimmed };
364+ }
365+ }
366+
367+ return {};
368+};
369+
370+const sortCompatBindings = (
73371 bindings: ReadonlyArray<SourceCredentialBinding>,
74372 ): Array<SourceCredentialBinding> =>
75373 [...bindings].sort((left, right) => {
@@ -83,204 +381,835 @@ const sortSourceCredentialBindings = ( · 7 unmodified lines
83381 return leftKey.localeCompare(rightKey);
84382 });
85383
86−const resolveWorkspaceOrganizationId = async (
384+const maskedSecretRef = (connection: AuthConnection): string => {
385+ if (connection.strategy === "oauth2") {
386+ if (connection.status === "reauth_required") {
387+ return "oauth2://reauth_required";
388+ }
389+
390+ if (connection.status === "active") {
391+ return "oauth2://connected";
392+ }
393+ }
394+
395+ return "********";
396+};
397+
398+const toCompatSourceCredentialBinding = (
399+ binding: SourceAuthBinding,
400+ connection: AuthConnection,
401+): SourceCredentialBinding =>
402+ decodeCompatSourceCredentialBinding({
403+ id: binding.id,
404+ credentialId: connection.id,
405+ organizationId: binding.organizationId,
406+ workspaceId: binding.workspaceId,
407+ accountId: binding.accountId,
408+ scopeType: binding.scopeType,
409+ sourceKey: sourceKeyFromSourceId(binding.sourceId),
410+ provider: providerFromStrategy(connection.strategy),
411+ secretProvider: "local",
412+ secretRef: maskedSecretRef(connection),
413+ additionalHeadersJson: connection.additionalHeadersJson,
414+ boundAuthFingerprint: null,
415+ createdAt: binding.createdAt,
416+ updatedAt: Math.max(binding.updatedAt, connection.updatedAt),
417+ });
418+
419+const normalizeAuthConnectionStatus = (
420+ connection: AuthConnection,
421+): AuthConnectionStatus => {
422+ if (connection.status === "active") return "active";
423+ if (connection.status === "reauth_required") return "reauth_required";
424+ if (connection.status === "revoked") return "revoked";
425+ if (connection.status === "disabled") return "disabled";
426+ return "error";
427+};
428+
429+type OAuthRefreshConfig = {
430+ tokenEndpoint?: string;
431+ authorizationServer?: string;
432+ clientId?: string;
433+ clientSecretCiphertext?: string;
434+ sourceUrl?: string;
435+ clientInformationJson?: string;
436+};
437+
438+const parseOAuthRefreshConfig = (value: string | null): OAuthRefreshConfig => {
439+ if (!value) {
440+ return {};
441+ }
442+
443+ const parsed = parseJsonObject(value);
444+ return {
445+ ...(normalizeString(parsed.tokenEndpoint)
446+ ? { tokenEndpoint: normalizeString(parsed.tokenEndpoint)! }
447+ : {}),
448+ ...(normalizeString(parsed.authorizationServer)
449+ ? { authorizationServer: normalizeString(parsed.authorizationServer)! }
450+ : {}),
451+ ...(normalizeString(parsed.clientId)
452+ ? { clientId: normalizeString(parsed.clientId)! }
453+ : {}),
454+ ...(normalizeString(parsed.clientSecretCiphertext)
455+ ? { clientSecretCiphertext: normalizeString(parsed.clientSecretCiphertext)! }
456+ : {}),
457+ ...(normalizeString(parsed.sourceUrl)
458+ ? { sourceUrl: normalizeString(parsed.sourceUrl)! }
459+ : {}),
460+ ...(normalizeString(parsed.clientInformationJson)
461+ ? { clientInformationJson: normalizeString(parsed.clientInformationJson)! }
462+ : {}),
463+ };
464+};
465+
466+const encodeOAuthRefreshConfig = (config: OAuthRefreshConfig): string | null => {
467+ const payload: Record<string, string> = {};
468+
469+ if (config.tokenEndpoint) payload.tokenEndpoint = config.tokenEndpoint;
470+ if (config.authorizationServer) payload.authorizationServer = config.authorizationServer;
471+ if (config.clientId) payload.clientId = config.clientId;
472+ if (config.clientSecretCiphertext) {
473+ payload.clientSecretCiphertext = config.clientSecretCiphertext;
474+ }
475+ if (config.sourceUrl) payload.sourceUrl = config.sourceUrl;
476+ if (config.clientInformationJson) {
477+ payload.clientInformationJson = config.clientInformationJson;
478+ }
479+
480+ if (Object.keys(payload).length === 0) {
481+ return null;
482+ }
483+
484+ return JSON.stringify(payload);
485+};
486+
487+const oauthRefreshWindowMs = 5 * 60 * 1000;
488+const oauthRefreshLeaseMs = 30 * 1000;
489+
490+type OAuthRefreshErrorClass =
491+ | "transient"
492+ | "invalid_grant"
493+ | "invalid_client"
494+ | "unsupported"
495+ | "unknown";
496+
497+const classifyOAuthRefreshFailure = (
498+ responseStatus: number,
499+ payload: Record<string, unknown>,
500+): OAuthRefreshErrorClass => {
501+ const errorCode = normalizeString(payload.error)?.toLowerCase() ?? "";
502+
503+ if (errorCode === "invalid_grant") {
504+ return "invalid_grant";
505+ }
506+
507+ if (errorCode === "invalid_client") {
508+ return "invalid_client";
509+ }
510+
511+ if (responseStatus >= 500) {
512+ return "transient";
513+ }
514+
515+ if (responseStatus === 400 || responseStatus === 401 || responseStatus === 403) {
516+ return errorCode.length > 0 ? "unknown" : "transient";
517+ }
518+
519+ return "unknown";
520+};
521+
522+const readJsonResponse = async (
523+ response: Response,
524+): Promise<Record<string, unknown>> => {
525+ try {
526+ const parsed = (await response.json()) as unknown;
527+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
528+ return {};
529+ }
530+
531+ return parsed as Record<string, unknown>;
532+ } catch {
533+ return {};
534+ }
535+};
536+
537+const resolveTokenEndpointFromAuthorizationServer = async (
538+ authorizationServer: string,
539+): Promise<string | null> => {
540+ const normalized = normalizeString(authorizationServer);
541+ if (!normalized) {
542+ return null;
543+ }
544+
545+ try {
546+ const endpoint = new URL("/.well-known/oauth-authorization-server", normalized);
547+ const response = await fetch(endpoint.toString(), {
548+ method: "GET",
549+ headers: {
550+ accept: "application/json",
551+ },
552+ });
553+
554+ if (!response.ok) {
555+ return null;
556+ }
557+
558+ const payload = await readJsonResponse(response);
559+ const tokenEndpoint = normalizeString(payload.token_endpoint);
560+ return tokenEndpoint ?? null;
561+ } catch {
562+ return null;
563+ }
564+};
565+
566+const refreshTokenFromOAuthEndpoint = async (
567+ oauthState: OAuthState,
568+ refreshConfig: OAuthRefreshConfig,
569+): Promise<
570+ | {
571+ ok: true;
572+ accessToken: string;
573+ refreshToken: string | null;
574+ expiresAt: number | null;
575+ scope: string | null;
576+ issuer: string | null;
577+ tokenType: string | null;
578+ }
579+ | {
580+ ok: false;
581+ errorClass: OAuthRefreshErrorClass;
582+ message: string;
583+ }
584+> => {
585+ const refreshTokenCiphertext = oauthState.refreshTokenCiphertext;
586+ if (!refreshTokenCiphertext) {
587+ return {
588+ ok: false,
589+ errorClass: "unsupported",
590+ message: "Refresh token is not available",
591+ };
592+ }
593+
594+ const refreshToken = await decryptSecretValue(refreshTokenCiphertext);
595+ const clientId = normalizeString(refreshConfig.clientId);
596+ if (!clientId) {
597+ return {
598+ ok: false,
599+ errorClass: "unsupported",
600+ message: "OAuth client id is required to refresh token",
601+ };
602+ }
603+
604+ const tokenEndpoint = normalizeString(refreshConfig.tokenEndpoint)
605+ ?? (refreshConfig.authorizationServer
606+ ? await resolveTokenEndpointFromAuthorizationServer(refreshConfig.authorizationServer)
607+ : null);
608+
609+ if (!tokenEndpoint) {
610+ return {
611+ ok: false,
612+ errorClass: "unsupported",
613+ message: "OAuth token endpoint is not configured",
614+ };
615+ }
616+
617+ const body = new URLSearchParams();
618+ body.set("grant_type", "refresh_token");
619+ body.set("refresh_token", refreshToken);
620+ body.set("client_id", clientId);
621+
622+ const clientSecretCiphertext = normalizeString(refreshConfig.clientSecretCiphertext);
623+ if (clientSecretCiphertext) {
624+ const decryptedClientSecret = await decryptSecretValue(clientSecretCiphertext);
625+ if (decryptedClientSecret.trim().length > 0) {
626+ body.set("client_secret", decryptedClientSecret.trim());
627+ }
628+ }
629+
630+ let response: Response;
631+ try {
632+ response = await fetch(tokenEndpoint, {
633+ method: "POST",
634+ headers: {
635+ "content-type": "application/x-www-form-urlencoded",
636+ accept: "application/json",
637+ },
638+ body: body.toString(),
639+ });
640+ } catch (error) {
641+ return {
642+ ok: false,
643+ errorClass: "transient",
644+ message: error instanceof Error ? error.message : "OAuth refresh request failed",
645+ };
646+ }
647+
648+ const payload = await readJsonResponse(response);
649+
650+ if (!response.ok) {
651+ const errorClass = classifyOAuthRefreshFailure(response.status, payload);
652+ const message = normalizeString(payload.error_description)
653+ ?? normalizeString(payload.error)
654+ ?? `OAuth refresh failed (${response.status})`;
655+ return {
656+ ok: false,
657+ errorClass,
658+ message,
659+ };
660+ }
661+
662+ const accessToken = normalizeString(payload.access_token);
663+ if (!accessToken) {
664+ return {
665+ ok: false,
666+ errorClass: "unknown",
667+ message: "OAuth refresh response is missing access_token",
668+ };
669+ }
670+
671+ const maybeExpiresIn = payload.expires_in;
672+ const expiresAt = typeof maybeExpiresIn === "number" && Number.isFinite(maybeExpiresIn)
673+ ? Date.now() + Math.max(0, Math.floor(maybeExpiresIn)) * 1000
674+ : null;
675+
676+ const refreshTokenFromResponse = normalizeString(payload.refresh_token);
677+ const scope = normalizeString(payload.scope) ?? oauthState.scope;
678+ const tokenType = normalizeString(payload.token_type) ?? oauthState.tokenType;
679+
680+ return {
681+ ok: true,
682+ accessToken,
683+ refreshToken: refreshTokenFromResponse,
684+ expiresAt,
685+ scope,
686+ issuer: oauthState.issuer,
687+ tokenType,
688+ };
689+};
690+
691+const insertAuthAuditEvent = async (
692+ ctx: MutationCtx,
693+ input: {
694+ organizationId: string;
695+ connectionId: string;
696+ sourceId: string | null;
697+ eventType: "created" | "updated" | "bound" | "unbound" | "refresh_success" | "refresh_failed" | "reauth_required" | "revoked" | "deleted";
698+ actorType: "system" | "account";
699+ actorId?: string | null;
700+ outcome: string;
701+ reasonCode?: string | null;
702+ detailsJson?: string | null;
703+ correlationId?: string | null;
704+ },
705+): Promise<void> => {
706+ const now = Date.now();
707+ await ctx.db.insert("authAuditEvents", {
708+ id: `auth_event_${crypto.randomUUID()}`,
709+ organizationId: input.organizationId,
710+ connectionId: input.connectionId,
711+ sourceId: input.sourceId,
712+ eventType: input.eventType,
713+ actorType: input.actorType,
714+ actorId: input.actorId ?? null,
715+ outcome: input.outcome,
716+ reasonCode: input.reasonCode ?? null,
717+ detailsJson: input.detailsJson ?? null,
718+ correlationId: input.correlationId ?? null,
719+ createdAt: now,
720+ });
721+};
722+
723+const loadAuthConnectionById = async (
87724 ctx: QueryCtx | MutationCtx,
88− workspaceId: string,
89−): Promise<string> => {
90− const workspace = await ctx.db
91− .query("workspaces")
92− .withIndex("by_domainId", (q) => q.eq("id", workspaceId))
725+ connectionId: string,
726+): Promise<AuthConnection | null> => {
727+ const row = await ctx.db
728+ .query("authConnections")
729+ .withIndex("by_domainId", (q) => q.eq("id", connectionId))
730+ .unique();
731+
732+ if (!row) {
733+ return null;
734+ }
735+
736+ return toAuthConnection(row as unknown as Record<string, unknown>);
737+};
738+
739+const loadOAuthStateByConnectionId = async (
740+ ctx: QueryCtx | MutationCtx,
741+ connectionId: string,
742+): Promise<OAuthState | null> => {
743+ const row = await ctx.db
744+ .query("oauthState")
745+ .withIndex("by_connectionId", (q) => q.eq("connectionId", connectionId))
746+ .unique();
747+
748+ if (!row) {
749+ return null;
750+ }
751+
752+ return toOAuthState(row as unknown as Record<string, unknown>);
753+};
754+
755+const loadAuthMaterialByConnectionId = async (
756+ ctx: QueryCtx | MutationCtx,
757+ connectionId: string,
758+): Promise<AuthMaterial | null> => {
759+ const row = await ctx.db
760+ .query("authMaterials")
761+ .withIndex("by_connectionId", (q) => q.eq("connectionId", connectionId))
93762 .unique();
94763
95− if (!workspace) {
96− throw new Error(`Workspace not found: ${workspaceId}`);
97− }
764+ if (!row) {
765+ return null;
766+ }
767+
768+ return toAuthMaterial(row as unknown as Record<string, unknown>);
769+};
770+
771+const listBindingsForWorkspace = async (
772+ ctx: QueryCtx,
773+ input: {
774+ workspaceId: string;
775+ organizationId: string;
776+ },
777+): Promise<Array<SourceAuthBinding>> => {
778+ const workspaceRows = await ctx.db
779+ .query("sourceAuthBindings")
780+ .withIndex("by_workspaceId", (q) => q.eq("workspaceId", input.workspaceId))
781+ .collect();
782+
783+ const organizationRows = await ctx.db
784+ .query("sourceAuthBindings")
785+ .withIndex("by_organizationId", (q) => q.eq("organizationId", input.organizationId))
786+ .collect();
787+
788+ const bindings = [...workspaceRows, ...organizationRows]
789+ .map((row) => toSourceAuthBinding(row as unknown as Record<string, unknown>))
790+ .filter((binding) =>
791+ binding.workspaceId === input.workspaceId
792+ || (binding.workspaceId === null && binding.organizationId === input.organizationId)
793+ );
794+
795+ return Array.from(new Map(bindings.map((binding) => [binding.id, binding])).values());
796+};
797+
798+export const listCredentialBindings = internalQuery({
799+ args: {
800+ workspaceId: v.string(),
801+ },
802+ handler: async (ctx, args): Promise<Array<SourceCredentialBinding>> => {
803+ const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId);
804+ const bindings = await listBindingsForWorkspace(ctx, {
805+ workspaceId: args.workspaceId,
806+ organizationId,
807+ });
808+
809+ const compatBindings: Array<SourceCredentialBinding> = [];
810+
811+ for (const binding of bindings) {
812+ const connection = await loadAuthConnectionById(ctx, binding.connectionId);
813+ if (!connection) {
814+ continue;
815+ }
816+
817+ compatBindings.push(toCompatSourceCredentialBinding(binding, connection));
818+ }
819+
820+ return sortCompatBindings(compatBindings);
821+ },
822+});
823+
824+export const resolveSourceAuthSelection = internalQuery({
825+ args: {
826+ workspaceId: v.string(),
827+ sourceId: v.string(),
828+ accountId: v.optional(v.union(v.string(), v.null())),
829+ },
830+ handler: async (
831+ ctx,
832+ args,
833+ ): Promise<{
834+ binding: SourceAuthBinding | null;
835+ connection: AuthConnection | null;
836+ oauthState: OAuthState | null;
837+ }> => {
838+ const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId);
839+
840+ const rows = await ctx.db
841+ .query("sourceAuthBindings")
842+ .withIndex("by_sourceId", (q) => q.eq("sourceId", args.sourceId))
843+ .collect();
844+
845+ const binding = selectBestBinding(
846+ rows.map((row) => toSourceAuthBinding(row as unknown as Record<string, unknown>)),
847+ {
848+ workspaceId: args.workspaceId,
849+ organizationId,
850+ accountId: args.accountId ?? null,
851+ },
852+ );
853+
854+ if (!binding) {
855+ return {
856+ binding: null,
857+ connection: null,
858+ oauthState: null,
859+ };
860+ }
861+
862+ const connection = await loadAuthConnectionById(ctx, binding.connectionId);
863+ if (!connection) {
864+ return {
865+ binding: null,
866+ connection: null,
867+ oauthState: null,
868+ };
869+ }
870+
871+ const oauthState = connection.strategy === "oauth2"
872+ ? await loadOAuthStateByConnectionId(ctx, connection.id)
873+ : null;
874+
875+ return {
876+ binding,
877+ connection,
878+ oauthState,
879+ };
880+ },
881+});
882+
883+export const beginOAuthRefresh = internalMutation({
884+ args: {
885+ connectionId: v.string(),
886+ leaseHolder: v.string(),
887+ leaseMs: v.number(),
888+ },
889+ handler: async (
890+ ctx,
891+ args,
892+ ): Promise<
893+ | {
894+ kind: "missing";
895+ }
896+ | {
897+ kind: "locked";
898+ leaseExpiresAt: number | null;
899+ }
900+ | {
901+ kind: "ready";
902+ oauthState: OAuthState;
903+ connection: AuthConnection;
904+ }
905+ > => {
906+ const stateRow = await ctx.db
907+ .query("oauthState")
908+ .withIndex("by_connectionId", (q) => q.eq("connectionId", args.connectionId))
909+ .unique();
910+ const connectionRow = await ctx.db
911+ .query("authConnections")
912+ .withIndex("by_domainId", (q) => q.eq("id", args.connectionId))
913+ .unique();
914+
915+ if (!stateRow || !connectionRow) {
916+ return { kind: "missing" };
917+ }
918+
919+ const state = toOAuthState(stateRow as unknown as Record<string, unknown>);
920+ const connection = toAuthConnection(connectionRow as unknown as Record<string, unknown>);
921+ const now = Date.now();
922+ const leaseExpiresAt = state.leaseExpiresAt;
923+ const leaseActive =
924+ state.leaseHolder !== null
925+ && leaseExpiresAt !== null
926+ && leaseExpiresAt > now
927+ && state.leaseHolder !== args.leaseHolder;
928+
929+ if (leaseActive) {
930+ return {
931+ kind: "locked",
932+ leaseExpiresAt,
933+ };
934+ }
935+
936+ await ctx.db.patch(stateRow._id, {
937+ leaseHolder: args.leaseHolder,
938+ leaseExpiresAt: now + Math.max(1_000, Math.floor(args.leaseMs)),
939+ leaseFence: state.leaseFence + 1,
940+ lastRefreshErrorClass: null,
941+ lastRefreshError: null,
942+ updatedAt: now,
943+ });
944+
945+ const nextState = await ctx.db
946+ .query("oauthState")
947+ .withIndex("by_connectionId", (q) => q.eq("connectionId", args.connectionId))
948+ .unique();
949+
950+ if (!nextState) {
951+ return { kind: "missing" };
952+ }
98953
99− return workspace.organizationId;
100−};
954+ return {
955+ kind: "ready",
956+ oauthState: toOAuthState(nextState as unknown as Record<string, unknown>),
957+ connection,
958+ };
959+ },
960+});
101961
102−const canAccessSourceCredentialBinding = (
103− binding: SourceCredentialBinding,
104− input: {
105− workspaceId: string;
106− organizationId: string;
962+export const finalizeOAuthRefreshSuccess = internalMutation({
963+ args: {
964+ connectionId: v.string(),
965+ leaseHolder: v.string(),
966+ expectedTokenVersion: v.number(),
967+ expectedLeaseFence: v.number(),
968+ accessTokenCiphertext: v.string(),
969+ refreshTokenCiphertext: v.optional(v.union(v.string(), v.null())),
970+ expiresAt: v.optional(v.union(v.number(), v.null())),
971+ scope: v.optional(v.union(v.string(), v.null())),
972+ tokenType: v.optional(v.union(v.string(), v.null())),
973+ issuer: v.optional(v.union(v.string(), v.null())),
107974 },
108−): boolean =>
109− binding.workspaceId === input.workspaceId
110− || (binding.workspaceId === null && binding.organizationId === input.organizationId);
975+ handler: async (ctx, args): Promise<{ committed: boolean }> => {
976+ const stateRow = await ctx.db
977+ .query("oauthState")
978+ .withIndex("by_connectionId", (q) => q.eq("connectionId", args.connectionId))
979+ .unique();
980+ const connectionRow = await ctx.db
981+ .query("authConnections")
982+ .withIndex("by_domainId", (q) => q.eq("id", args.connectionId))
983+ .unique();
111984
112−const toOAuthToken = (document: Record<string, unknown>): OAuthToken =>
113− decodeOAuthToken(stripConvexSystemFields(document));
985+ if (!stateRow || !connectionRow) {
986+ return { committed: false };
987+ }
114988
115−const sourceKeyFromSourceId = (sourceId: string): string | null => {
116− const trimmed = sourceId.trim();
117− if (trimmed.length === 0) {
118− return null;
119− }
989+ const state = toOAuthState(stateRow as unknown as Record<string, unknown>);
990+ const connection = toAuthConnection(connectionRow as unknown as Record<string, unknown>);
120991
121− return `source:${trimmed}`;
122−};
992+ if (state.leaseHolder !== args.leaseHolder) {
993+ return { committed: false };
994+ }
123995
124−const bindingScopeScore = (
125− binding: SourceCredentialBinding,
126− input: {
127− workspaceId: string;
128− organizationId: string;
129− accountId: string | null;
130− },
131−): number => {
132− if (binding.scopeType === "account") {
133− if (!input.accountId || binding.accountId !== input.accountId) {
134− return -1;
996+ if (state.tokenVersion !== args.expectedTokenVersion) {
997+ return { committed: false };
135998 }
136999
137− return binding.organizationId === input.organizationId ? 30 : -1;
138− }
1000+ if (state.leaseFence !== args.expectedLeaseFence) {
1001+ return { committed: false };
1002+ }
1391003
140− if (binding.scopeType === "workspace") {
141− return binding.workspaceId === input.workspaceId ? 20 : -1;
142− }
1004+ const now = Date.now();
1005+ const nextRefreshToken = args.refreshTokenCiphertext === undefined
1006+ ? state.refreshTokenCiphertext
1007+ : args.refreshTokenCiphertext;
1008+
1009+ await ctx.db.patch(stateRow._id, {
1010+ accessTokenCiphertext: args.accessTokenCiphertext,
1011+ refreshTokenCiphertext: nextRefreshToken,
1012+ expiresAt: args.expiresAt !== undefined ? args.expiresAt : state.expiresAt,
1013+ scope: args.scope !== undefined ? args.scope : state.scope,
1014+ tokenType: args.tokenType !== undefined ? args.tokenType : state.tokenType,
1015+ issuer: args.issuer !== undefined ? args.issuer : state.issuer,
1016+ tokenVersion: state.tokenVersion + 1,
1017+ leaseHolder: null,
1018+ leaseExpiresAt: null,
1019+ lastRefreshAt: now,
1020+ lastRefreshErrorClass: null,
1021+ lastRefreshError: null,
1022+ reauthRequiredAt: null,
1023+ updatedAt: now,
1024+ });
1431025
144− if (binding.scopeType === "organization") {
145− return binding.organizationId === input.organizationId ? 10 : -1;
146− }
1026+ await ctx.db.patch(connectionRow._id, {
1027+ status: "active",
1028+ statusReason: null,
1029+ lastAuthErrorClass: null,
1030+ lastUsedAt: now,
1031+ updatedAt: now,
1032+ });
1471033
148− return -1;
149−};
1034+ await insertAuthAuditEvent(ctx, {
1035+ organizationId: connection.organizationId,
1036+ connectionId: connection.id,
1037+ sourceId: null,
1038+ eventType: "refresh_success",
1039+ actorType: "system",
1040+ outcome: "success",
1041+ });
1501042
151−const selectBestSourceBinding = (
152− bindings: ReadonlyArray<SourceCredentialBinding>,
153− input: {
154− workspaceId: string;
155− organizationId: string;
156− accountId: string | null;
1043+ return { committed: true };
1571044 },
158−): SourceCredentialBinding | null => {
159− const ranked = bindings
160− .map((binding) => ({
161− binding,
162− scopeScore: bindingScopeScore(binding, input),
163− }))
164− .filter((candidate) => candidate.scopeScore >= 0)
165− .sort((left, right) => {
166− if (left.scopeScore !== right.scopeScore) {
167− return right.scopeScore - left.scopeScore;
168− }
1045+});
1691046
170− if (left.binding.updatedAt !== right.binding.updatedAt) {
171− return right.binding.updatedAt - left.binding.updatedAt;
172− }
1047+export const finalizeOAuthRefreshFailure = internalMutation({
1048+ args: {
1049+ connectionId: v.string(),
1050+ leaseHolder: v.string(),
1051+ errorClass: v.string(),
1052+ message: v.string(),
1053+ reauthRequired: v.boolean(),
1054+ },
1055+ handler: async (ctx, args): Promise<void> => {
1056+ const stateRow = await ctx.db
1057+ .query("oauthState")
1058+ .withIndex("by_connectionId", (q) => q.eq("connectionId", args.connectionId))
1059+ .unique();
1060+ const connectionRow = await ctx.db
1061+ .query("authConnections")
1062+ .withIndex("by_domainId", (q) => q.eq("id", args.connectionId))
1063+ .unique();
1731064
174− return right.binding.createdAt - left.binding.createdAt;
175− });
1065+ if (!stateRow || !connectionRow) {
1066+ return;
1067+ }
1761068
177− return ranked[0]?.binding ?? null;
178−};
1069+ const state = toOAuthState(stateRow as unknown as Record<string, unknown>);
1070+ const connection = toAuthConnection(connectionRow as unknown as Record<string, unknown>);
1791071
180−const requireLocalSecretProvider = (
181− secretProvider: SourceCredentialBinding["secretProvider"],
182−): "local" => {
183− if (secretProvider !== "local") {
184− throw new Error("Convex credentials require secretProvider 'local'");
185− }
1072+ if (state.leaseHolder !== args.leaseHolder) {
1073+ return;
1074+ }
1861075
187− return "local";
188−};
1076+ const now = Date.now();
1077+ await ctx.db.patch(stateRow._id, {
1078+ leaseHolder: null,
1079+ leaseExpiresAt: null,
1080+ lastRefreshErrorClass: args.errorClass,
1081+ lastRefreshError: args.message,
1082+ reauthRequiredAt: args.reauthRequired ? now : state.reauthRequiredAt,
1083+ updatedAt: now,
1084+ });
1891085
190−const defaultSecretProvider = (): "local" => "local";
1086+ await ctx.db.patch(connectionRow._id, {
1087+ status: args.reauthRequired ? "reauth_required" : "error",
1088+ statusReason: args.message,
1089+ lastAuthErrorClass: args.errorClass,
1090+ updatedAt: now,
1091+ });
1911092
192−const resolveSecretRefForHeaders = async (
193− binding: SourceCredentialBinding,
194−): Promise<string> => {
195− requireLocalSecretProvider(binding.secretProvider);
196− return await decryptSecretValue(binding.secretRef);
197−};
1093+ await insertAuthAuditEvent(ctx, {
1094+ organizationId: connection.organizationId,
1095+ connectionId: connection.id,
1096+ sourceId: null,
1097+ eventType: args.reauthRequired ? "reauth_required" : "refresh_failed",
1098+ actorType: "system",
1099+ outcome: "failure",
1100+ reasonCode: args.errorClass,
1101+ detailsJson: JSON.stringify({ message: args.message }),
1102+ });
1103+ },
1104+});
1981105
199−const resolveSourceSelection = async (
200− ctx: QueryCtx,
201− args: {
202− workspaceId: string;
203− sourceId: string;
204− accountId: string | null;
1106+const refreshOAuthStateIfNeeded = async (
1107+ ctx: any,
1108+ input: {
1109+ connectionId: string;
1110+ forceRefresh: boolean;
1111+ oauthState: OAuthState;
2051112 },
206−): Promise<{
207− binding: SourceCredentialBinding | null;
208− oauthAccessToken: string | null;
209−}> => {
210− const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId);
211− const sourceKey = sourceKeyFromSourceId(args.sourceId);
212− if (!sourceKey) {
213− return {
214− binding: null,
215− oauthAccessToken: null,
216− };
1113+): Promise<OAuthState | null> => {
1114+ const now = Date.now();
1115+ const isExpiring =
1116+ input.oauthState.expiresAt !== null
1117+ && input.oauthState.expiresAt <= now + oauthRefreshWindowMs;
1118+ const shouldAttemptRefresh = input.forceRefresh || isExpiring;
1119+
1120+ if (!shouldAttemptRefresh) {
1121+ return input.oauthState;
2171122 }
2181123
219− const rows = await ctx.db
220− .query("sourceCredentialBindings")
221− .withIndex("by_sourceKey", (q) => q.eq("sourceKey", sourceKey))
222− .collect();
223−
224− const binding = selectBestSourceBinding(
225− rows.map((row) => toSourceCredentialBinding(row)),
1124+ const leaseHolder = `oauth_refresh_${crypto.randomUUID()}`;
1125+ const begin = await ctx.runMutation(
1126+ runtimeInternal.control_plane.credentials.beginOAuthRefresh,
2261127 {
227− workspaceId: args.workspaceId,
228− organizationId,
229− accountId: args.accountId,
1128+ connectionId: input.connectionId,
1129+ leaseHolder,
1130+ leaseMs: oauthRefreshLeaseMs,
2301131 },
2311132 );
2321133
233− if (!binding) {
234− return {
235− binding: null,
236− oauthAccessToken: null,
237− };
1134+ if (begin.kind === "missing") {
1135+ return null;
2381136 }
2391137
240− const oauthTokens = binding.provider === "oauth2"
241− ? (await ctx.db
242− .query("oauthTokens")
243− .withIndex("by_sourceId", (q) => q.eq("sourceId", args.sourceId))
244− .collect()).map((row) => toOAuthToken(row))
245− : [];
1138+ if (begin.kind === "locked") {
1139+ const fallback = await ctx.runQuery(
1140+ runtimeInternal.control_plane.credentials.getOAuthStateByConnection,
1141+ {
1142+ connectionId: input.connectionId,
1143+ },
1144+ );
1145+ return fallback;
1146+ }
2461147
247− const oauthAccessToken = binding.provider === "oauth2"
248− ? selectOAuthAccessToken(oauthTokens, {
249− workspaceId: args.workspaceId,
250− organizationId,
251− accountId: args.accountId,
252− sourceKey,
253− }, args.sourceId)
254− : null;
1148+ const state = begin.oauthState;
1149+ const refreshConfig = parseOAuthRefreshConfig(state.refreshConfigJson);
2551150
256− return {
257− binding,
258− oauthAccessToken,
259− };
260−};
1151+ const refreshed = await refreshTokenFromOAuthEndpoint(state, refreshConfig);
1152+ if (!refreshed.ok) {
1153+ await ctx.runMutation(
1154+ runtimeInternal.control_plane.credentials.finalizeOAuthRefreshFailure,
1155+ {
1156+ connectionId: input.connectionId,
1157+ leaseHolder,
1158+ errorClass: refreshed.errorClass,
1159+ message: refreshed.message,
1160+ reauthRequired:
1161+ refreshed.errorClass === "invalid_grant"
1162+ || refreshed.errorClass === "invalid_client"
1163+ || refreshed.errorClass === "unsupported",
1164+ },
1165+ );
2611166
262−const withDecryptedSecret = async (
263− binding: SourceCredentialBinding,
264−): Promise<SourceCredentialBinding> => {
265− const secretRef = await resolveSecretRefForHeaders(binding);
266− return {
267− ...binding,
268− secretProvider: "local",
269− secretRef,
270− };
1167+ const fallback = await ctx.runQuery(
1168+ runtimeInternal.control_plane.credentials.getOAuthStateByConnection,
1169+ {
1170+ connectionId: input.connectionId,
1171+ },
1172+ );
1173+ return fallback;
1174+ }
1175+
1176+ const accessTokenCiphertext = await encryptSecretValue(refreshed.accessToken);
1177+ const refreshTokenCiphertext = refreshed.refreshToken
1178+ ? await encryptSecretValue(refreshed.refreshToken)
1179+ : undefined;
1180+
1181+ await ctx.runMutation(
1182+ runtimeInternal.control_plane.credentials.finalizeOAuthRefreshSuccess,
1183+ {
1184+ connectionId: input.connectionId,
1185+ leaseHolder,
1186+ expectedTokenVersion: state.tokenVersion,
1187+ expectedLeaseFence: state.leaseFence,
1188+ accessTokenCiphertext,
1189+ ...(refreshTokenCiphertext !== undefined
1190+ ? { refreshTokenCiphertext }
1191+ : {}),
1192+ ...(refreshed.expiresAt !== null ? { expiresAt: refreshed.expiresAt } : {}),
1193+ ...(refreshed.scope !== null ? { scope: refreshed.scope } : {}),
1194+ ...(refreshed.tokenType !== null ? { tokenType: refreshed.tokenType } : {}),
1195+ ...(refreshed.issuer !== null ? { issuer: refreshed.issuer } : {}),
1196+ },
1197+ );
1198+
1199+ return await ctx.runQuery(
1200+ runtimeInternal.control_plane.credentials.getOAuthStateByConnection,
1201+ {
1202+ connectionId: input.connectionId,
1203+ },
1204+ );
2711205 };
2721206
273−export const resolveSourceCredentialSelection = internalQuery({
1207+export const getOAuthStateByConnection = internalQuery({
2741208 args: {
275− workspaceId: v.string(),
276− sourceId: v.string(),
277− accountId: v.optional(v.union(v.string(), v.null())),
1209+ connectionId: v.string(),
2781210 },
279− handler: async (ctx, args) => resolveSourceSelection(ctx, {
280− workspaceId: args.workspaceId,
281− sourceId: args.sourceId,
282− accountId: args.accountId ?? null,
283− }),
1211+ handler: async (ctx, args): Promise<OAuthState | null> =>
1212+ await loadOAuthStateByConnectionId(ctx, args.connectionId),
2841213 });
2851214
2861215 export const resolveSourceCredentialHeaders = internalAction({
@@ -288,12 +1217,17 @@ export const resolveSourceCredentialHeaders = internalAction({ · 1 unmodified line
2881217 workspaceId: v.string(),
2891218 sourceId: v.string(),
2901219 accountId: v.optional(v.union(v.string(), v.null())),
1220+ forceRefresh: v.optional(v.boolean()),
2911221 },
292− handler: async (ctx, args): Promise<{
1222+ handler: async (
1223+ ctx,
1224+ args,
1225+ ): Promise<{
2931226 headers: Record<string, string>;
1227+ oauthConnectionId: string | null;
2941228 }> => {
2951229 const selected = await ctx.runQuery(
296− runtimeInternal.control_plane.credentials.resolveSourceCredentialSelection,
1230+ runtimeInternal.control_plane.credentials.resolveSourceAuthSelection,
2971231 {
2981232 workspaceId: args.workspaceId,
2991233 sourceId: args.sourceId,
@@ -301,127 +1235,353 @@ export const resolveSourceCredentialHeaders = internalAction({ · 1 unmodified line
3011235 },
3021236 );
3031237
304− if (!selected.binding) {
1238+ if (!selected.binding || !selected.connection) {
3051239 return {
3061240 headers: {},
1241+ oauthConnectionId: null,
3071242 };
3081243 }
3091244
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+ }
3111253
312− const headers = buildCredentialHeaders(decrypted, {
313− oauthAccessToken: selected.oauthAccessToken,
314− });
1254+ const metadata = parseConnectionMetadata(connection.metadataJson);
1255+ const additionalHeaders = parseAdditionalHeaders(connection.additionalHeadersJson);
3151256
316− return { headers };
317− },
318−});
1257+ if (connection.strategy === "oauth2") {
1258+ let oauthState = selected.oauthState;
1259+ if (!oauthState) {
1260+ return {
1261+ headers: additionalHeaders,
1262+ oauthConnectionId: connection.id,
1263+ };
1264+ }
3191265
320−export const listCredentialBindings = internalQuery({
321− args: {
322− workspaceId: v.string(),
323− },
324− handler: async (ctx, args): Promise<Array<SourceCredentialBinding>> => {
325− const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId);
1266+ oauthState = await refreshOAuthStateIfNeeded(ctx, {
1267+ connectionId: connection.id,
1268+ forceRefresh: args.forceRefresh === true,
1269+ oauthState,
1270+ });
1271+
1272+ if (!oauthState) {
1273+ return {
1274+ headers: additionalHeaders,
1275+ oauthConnectionId: connection.id,
1276+ };
1277+ }
3261278
327− const workspaceRows = await ctx.db
328− .query("sourceCredentialBindings")
329− .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId))
330− .collect();
1279+ const now = Date.now();
1280+ if (oauthState.expiresAt !== null && oauthState.expiresAt <= now) {
1281+ return {
1282+ headers: additionalHeaders,
1283+ oauthConnectionId: connection.id,
1284+ };
1285+ }
3311286
332− const organizationRows = await ctx.db
333− .query("sourceCredentialBindings")
334− .withIndex("by_organizationId", (q) => q.eq("organizationId", organizationId))
335− .collect();
1287+ const accessToken = await decryptSecretValue(oauthState.accessTokenCiphertext);
1288+ const oauthHeaders = accessToken.trim().length > 0
1289+ ? ({ Authorization: `Bearer ${accessToken}` } as Record<string, string>)
1290+ : {};
3361291
337− const bindings = [...workspaceRows, ...organizationRows]
338− .map((row) =>
339− toSourceCredentialBinding(row),
340− )
341− .filter((binding) =>
342− canAccessSourceCredentialBinding(binding, {
343− workspaceId: args.workspaceId,
344− organizationId,
345− })
346− );
1292+ return {
1293+ headers: mergeHeaders(oauthHeaders, additionalHeaders),
1294+ oauthConnectionId: connection.id,
1295+ };
1296+ }
3471297
348− const uniqueBindings = Array.from(
349− new Map(bindings.map((binding) => [binding.id, binding])).values(),
1298+ const material = await ctx.runQuery(
1299+ runtimeInternal.control_plane.credentials.getAuthMaterialByConnection,
1300+ {
1301+ connectionId: connection.id,
1302+ },
3501303 );
3511304
352− const decryptedBindings = await Promise.all(
353− uniqueBindings.map((binding) => withDecryptedSecret(binding)),
354− );
1305+ if (!material) {
1306+ return {
1307+ headers: additionalHeaders,
1308+ oauthConnectionId: null,
1309+ };
1310+ }
1311+
1312+ const secret = await decryptSecretValue(material.ciphertext);
1313+ const strategyHeaders = buildSecretHeaders(connection.strategy, secret, metadata);
1314+
1315+ return {
1316+ headers: mergeHeaders(strategyHeaders, additionalHeaders),
1317+ oauthConnectionId: null,
1318+ };
1319+ },
1320+});
3551321
356− return sortSourceCredentialBindings(decryptedBindings);
1322+export const getAuthMaterialByConnection = internalQuery({
1323+ args: {
1324+ connectionId: v.string(),
3571325 },
1326+ handler: async (ctx, args): Promise<AuthMaterial | null> =>
1327+ await loadAuthMaterialByConnectionId(ctx, args.connectionId),
1328+});
1329+
1330+const coerceScopeOwnerType = (
1331+ scopeType: SourceCredentialBinding["scopeType"],
1332+): AuthConnection["ownerType"] => {
1333+ if (scopeType === "organization") return "organization";
1334+ if (scopeType === "account") return "account";
1335+ return "workspace";
1336+};
1337+
1338+const buildOAuthRefreshConfigFromPayload = (
1339+ payload: ExtendedUpsertCredentialBindingPayload,
1340+ existing: OAuthRefreshConfig,
1341+ encryptedClientSecret: string | null,
1342+): OAuthRefreshConfig => ({
1343+ tokenEndpoint:
1344+ normalizeString(payload.oauthTokenEndpoint)
1345+ ?? existing.tokenEndpoint,
1346+ authorizationServer:
1347+ normalizeString(payload.oauthAuthorizationServer)
1348+ ?? existing.authorizationServer,
1349+ clientId: normalizeString(payload.oauthClientId) ?? existing.clientId,
1350+ clientSecretCiphertext:
1351+ encryptedClientSecret
1352+ ?? existing.clientSecretCiphertext,
1353+ sourceUrl: normalizeString(payload.oauthSourceUrl) ?? existing.sourceUrl,
1354+ clientInformationJson:
1355+ normalizeString(payload.oauthClientInformationJson)
1356+ ?? existing.clientInformationJson,
3581357 });
3591358
3601359 export const upsertCredentialBindingRecord = internalMutation({
3611360 args: {
3621361 workspaceId: v.string(),
3631362 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())),
3641366 },
3651367 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+ }
3671374
3681375 if (payload.scopeType === "account" && payload.accountId === null) {
3691376 throw new Error("Account scope credentials require accountId");
3701377 }
3711378
3721379 const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId);
373− const now = Date.now();
374− const bindingId = payload.id ?? `credential_binding_${crypto.randomUUID()}`;
3751380
376− const existing = await ctx.db
377− .query("sourceCredentialBindings")
378− .withIndex("by_domainId", (q) => q.eq("id", bindingId))
379− .unique();
1381+ const existingBindingRow = payload.id
1382+ ? await ctx.db
1383+ .query("sourceAuthBindings")
1384+ .withIndex("by_domainId", (q) => q.eq("id", payload.id!))
1385+ .unique()
1386+ : null;
1387+ const existingBinding = existingBindingRow
1388+ ? toSourceAuthBinding(existingBindingRow as unknown as Record<string, unknown>)
1389+ : null;
1390+
1391+ const scopeWorkspaceId = payload.scopeType === "workspace" ? args.workspaceId : null;
1392+ const scopeAccountId = payload.scopeType === "account" ? (payload.accountId ?? null) : null;
1393+
1394+ const resolvedBindingId = existingBinding?.id
1395+ ?? payload.id
1396+ ?? (() => {
1397+ return `auth_binding_${crypto.randomUUID()}`;
1398+ })();
3801399
381− const existingBinding = existing
382− ? toSourceCredentialBinding(existing)
1400+ const requestedConnectionId = normalizeString(payload.credentialId)
1401+ ?? existingBinding?.connectionId
1402+ ?? `conn_${crypto.randomUUID()}`;
1403+
1404+ const existingConnectionRow = await ctx.db
1405+ .query("authConnections")
1406+ .withIndex("by_domainId", (q) => q.eq("id", requestedConnectionId))
1407+ .unique();
1408+ const existingConnection = existingConnectionRow
1409+ ? toAuthConnection(existingConnectionRow as unknown as Record<string, unknown>)
3831410 : null;
3841411
385− if (
386− existingBinding !== null
387− && !canAccessSourceCredentialBinding(existingBinding, {
388− workspaceId: args.workspaceId,
389− organizationId,
390− })
391− ) {
392− throw new Error(`Credential binding not found: ${bindingId}`);
1412+ if (existingConnection && existingConnection.organizationId !== organizationId) {
1413+ throw new Error("Connection id belongs to another organization");
3931414 }
3941415
395− const secretProvider = requireLocalSecretProvider(
396− payload.secretProvider
397− ?? existingBinding?.secretProvider
398− ?? defaultSecretProvider(),
399− );
1416+ const nextConnection = decodeAuthConnection({
1417+ id: requestedConnectionId,
1418+ organizationId,
1419+ workspaceId: scopeWorkspaceId,
1420+ accountId: scopeAccountId,
1421+ ownerType: coerceScopeOwnerType(payload.scopeType),
1422+ strategy: strategyFromProvider(payload.provider),
1423+ displayName:
1424+ normalizeString(existingConnection?.displayName)
1425+ ?? sourceKeyFromSourceId(sourceId),
1426+ status: "active",
1427+ statusReason: null,
1428+ lastAuthErrorClass: null,
1429+ metadataJson: existingConnection?.metadataJson ?? null,
1430+ additionalHeadersJson:
1431+ payload.additionalHeadersJson !== undefined
1432+ ? payload.additionalHeadersJson
1433+ : existingConnection?.additionalHeadersJson ?? null,
1434+ createdByAccountId: existingConnection?.createdByAccountId ?? null,
1435+ createdAt: existingConnection?.createdAt ?? now,
1436+ updatedAt: now,
1437+ lastUsedAt: existingConnection?.lastUsedAt ?? null,
1438+ });
1439+
1440+ if (existingConnectionRow) {
1441+ await ctx.db.patch(existingConnectionRow._id, nextConnection);
1442+ } else {
1443+ await ctx.db.insert("authConnections", nextConnection);
1444+ }
4001445
401− const nextBinding = decodeSourceCredentialBinding({
402− id: bindingId,
403− credentialId: payload.credentialId,
1446+ const nextBinding = decodeSourceAuthBinding({
1447+ id: resolvedBindingId,
1448+ sourceId,
1449+ connectionId: requestedConnectionId,
4041450 organizationId,
405− workspaceId: payload.scopeType === "workspace" ? args.workspaceId : null,
406− accountId: payload.scopeType === "account" ? (payload.accountId ?? null) : null,
1451+ workspaceId: scopeWorkspaceId,
1452+ accountId: scopeAccountId,
4071453 scopeType: payload.scopeType,
408− sourceKey: payload.sourceKey,
409− provider: payload.provider,
410− secretProvider,
411− secretRef: payload.secretRef,
412− additionalHeadersJson: payload.additionalHeadersJson ?? null,
413− boundAuthFingerprint: payload.boundAuthFingerprint ?? null,
1454+ selector: existingBinding?.selector ?? null,
1455+ enabled: true,
4141456 createdAt: existingBinding?.createdAt ?? now,
4151457 updatedAt: now,
4161458 });
4171459
418− if (existing) {
419− await ctx.db.patch(existing._id, nextBinding);
1460+ if (existingBindingRow) {
1461+ await ctx.db.patch(existingBindingRow._id, nextBinding);
1462+ } else {
1463+ await ctx.db.insert("sourceAuthBindings", nextBinding);
1464+ }
1465+
1466+ if (nextConnection.strategy === "oauth2") {
1467+ const existingOAuthRow = await ctx.db
1468+ .query("oauthState")
1469+ .withIndex("by_connectionId", (q) => q.eq("connectionId", requestedConnectionId))
1470+ .unique();
1471+ const existingOAuth = existingOAuthRow
1472+ ? toOAuthState(existingOAuthRow as unknown as Record<string, unknown>)
1473+ : null;
1474+
1475+ const existingRefreshConfig = existingOAuth
1476+ ? parseOAuthRefreshConfig(existingOAuth.refreshConfigJson)
1477+ : {};
1478+ const refreshConfig = buildOAuthRefreshConfigFromPayload(
1479+ payload,
1480+ existingRefreshConfig,
1481+ args.oauthClientSecretCiphertext ?? null,
1482+ );
1483+ const refreshConfigJson = encodeOAuthRefreshConfig(refreshConfig);
1484+
1485+ const oauthState = decodeOAuthState({
1486+ id: existingOAuth?.id ?? `oauth_state_${crypto.randomUUID()}`,
1487+ connectionId: requestedConnectionId,
1488+ accessTokenCiphertext: args.secretCiphertext,
1489+ refreshTokenCiphertext:
1490+ args.oauthRefreshCiphertext !== undefined
1491+ ? args.oauthRefreshCiphertext
1492+ : existingOAuth?.refreshTokenCiphertext ?? null,
1493+ keyVersion: currentCredentialEncryptionKeyVersion(),
1494+ expiresAt:
1495+ payload.oauthExpiresAt !== undefined
1496+ ? payload.oauthExpiresAt
1497+ : existingOAuth?.expiresAt ?? null,
1498+ scope:
1499+ payload.oauthScope !== undefined
1500+ ? payload.oauthScope
1501+ : existingOAuth?.scope ?? null,
1502+ tokenType: existingOAuth?.tokenType ?? "Bearer",
1503+ issuer:
1504+ payload.oauthIssuer !== undefined
1505+ ? payload.oauthIssuer
1506+ : existingOAuth?.issuer ?? null,
1507+ refreshConfigJson,
1508+ tokenVersion: (existingOAuth?.tokenVersion ?? 0) + 1,
1509+ leaseHolder: null,
1510+ leaseExpiresAt: null,
1511+ leaseFence: existingOAuth?.leaseFence ?? 0,
1512+ lastRefreshAt: existingOAuth?.lastRefreshAt ?? null,
1513+ lastRefreshErrorClass: null,
1514+ lastRefreshError: null,
1515+ reauthRequiredAt: null,
1516+ createdAt: existingOAuth?.createdAt ?? now,
1517+ updatedAt: now,
1518+ });
1519+
1520+ if (existingOAuthRow) {
1521+ await ctx.db.patch(existingOAuthRow._id, oauthState);
1522+ } else {
1523+ await ctx.db.insert("oauthState", oauthState);
1524+ }
1525+
1526+ const existingMaterialRow = await ctx.db
1527+ .query("authMaterials")
1528+ .withIndex("by_connectionId", (q) => q.eq("connectionId", requestedConnectionId))
1529+ .unique();
1530+ if (existingMaterialRow) {
1531+ await ctx.db.delete(existingMaterialRow._id);
1532+ }
4201533 } 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+ }
4221564 }
4231565
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);
4251585 },
4261586 });
4271587
@@ -431,31 +1591,41 @@ export const upsertCredentialBinding = internalAction({ · 3 unmodified lines
4311591 payload: sourceCredentialBindingPayloadValidator,
4321592 },
4331593 handler: async (ctx, args): Promise<SourceCredentialBinding> => {
434− const payload = args.payload;
1594+ const payload = args.payload as ExtendedUpsertCredentialBindingPayload;
4351595 const requestedSecretRef = payload.secretRef.trim();
4361596 if (requestedSecretRef.length === 0) {
4371597 throw new Error("Credential secret is required");
4381598 }
4391599
4401600 const encryptedSecretRef = await encryptSecretValue(requestedSecretRef);
441−
442− const written = await ctx.runMutation(
1601+ const refreshToken = normalizeString(payload.oauthRefreshToken);
1602+ const encryptedOauthRefreshToken = refreshToken
1603+ ? await encryptSecretValue(refreshToken)
1604+ : payload.oauthRefreshToken !== undefined
1605+ ? null
1606+ : undefined;
1607+
1608+ const clientSecret = normalizeString(payload.oauthClientSecret);
1609+ const encryptedOauthClientSecret = clientSecret
1610+ ? await encryptSecretValue(clientSecret)
1611+ : payload.oauthClientSecret !== undefined
1612+ ? null
1613+ : undefined;
1614+
1615+ return await ctx.runMutation(
4431616 runtimeInternal.control_plane.credentials.upsertCredentialBindingRecord,
4441617 {
4451618 workspaceId: args.workspaceId,
446− payload: {
447− ...payload,
448− secretProvider: "local",
449− secretRef: encryptedSecretRef,
450− },
1619+ payload,
1620+ secretCiphertext: encryptedSecretRef,
1621+ ...(encryptedOauthRefreshToken !== undefined
1622+ ? { oauthRefreshCiphertext: encryptedOauthRefreshToken }
1623+ : {}),
1624+ ...(encryptedOauthClientSecret !== undefined
1625+ ? { oauthClientSecretCiphertext: encryptedOauthClientSecret }
1626+ : {}),
4511627 },
4521628 );
453−
454− return {
455− ...written,
456− secretProvider: "local",
457− secretRef: requestedSecretRef,
458− };
4591629 },
4601630 });
4611631
@@ -472,30 +1642,139 @@ export const removeCredentialBinding = internalMutation({ · 10 unmodified lines
4721642 }> => {
4731643 const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId);
4741644
475− const existing = await ctx.db
476− .query("sourceCredentialBindings")
1645+ const bindingRow = await ctx.db
1646+ .query("sourceAuthBindings")
4771647 .withIndex("by_domainId", (q) => q.eq("id", args.credentialBindingId))
4781648 .unique();
4791649
480− if (!existing) {
1650+ if (!bindingRow) {
4811651 return { removed: false };
4821652 }
4831653
484− const existingBinding = toSourceCredentialBinding(
485− existing,
486− );
487−
1654+ const binding = toSourceAuthBinding(bindingRow as unknown as Record<string, unknown>);
4881655 if (
489− !canAccessSourceCredentialBinding(existingBinding, {
490− workspaceId: args.workspaceId,
491− organizationId,
492− })
1656+ binding.workspaceId !== args.workspaceId
1657+ && !(binding.workspaceId === null && binding.organizationId === organizationId)
4931658 ) {
4941659 return { removed: false };
4951660 }
4961661
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+ }
4981718
4991719 return { removed: true };
5001720 },
5011721 });
1722+
1723+export const removeSourceAuthBindings = internalMutation({
1724+ args: {
1725+ sourceId: v.string(),
1726+ },
1727+ handler: async (ctx, args): Promise<void> => {
1728+ const rows = await ctx.db
1729+ .query("sourceAuthBindings")
1730+ .withIndex("by_sourceId", (q) => q.eq("sourceId", args.sourceId))
1731+ .collect();
1732+
1733+ const bindingIds = rows.map((row) => (row as unknown as { id: string }).id);
1734+
1735+ for (const bindingId of bindingIds) {
1736+ const row = await ctx.db
1737+ .query("sourceAuthBindings")
1738+ .withIndex("by_domainId", (q) => q.eq("id", bindingId))
1739+ .unique();
1740+ if (!row) {
1741+ continue;
1742+ }
1743+
1744+ const binding = toSourceAuthBinding(row as unknown as Record<string, unknown>);
1745+ await ctx.db.delete(row._id);
1746+
1747+ const remaining = await ctx.db
1748+ .query("sourceAuthBindings")
1749+ .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId))
1750+ .collect();
1751+ if (remaining.length > 0) {
1752+ continue;
1753+ }
1754+
1755+ const material = await ctx.db
1756+ .query("authMaterials")
1757+ .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId))
1758+ .unique();
1759+ if (material) {
1760+ await ctx.db.delete(material._id);
1761+ }
1762+
1763+ const oauth = await ctx.db
1764+ .query("oauthState")
1765+ .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId))
1766+ .unique();
1767+ if (oauth) {
1768+ await ctx.db.delete(oauth._id);
1769+ }
1770+
1771+ const connection = await ctx.db
1772+ .query("authConnections")
1773+ .withIndex("by_domainId", (q) => q.eq("id", binding.connectionId))
1774+ .unique();
1775+ if (connection) {
1776+ await ctx.db.delete(connection._id);
1777+ }
1778+ }
1779+ },
1780+});
apps/convex/convex/control_plane/openapi_ingest.ts+0−38
@@ -313,42 +313,6 @@ const parseSourceConfig = (configJson: string): Record<string, unknown> => { · 312 unmodified lines
313313 }
314314 };
315315
316−const collectAuthHeadersFromSourceConfig = (config: Record<string, unknown>): Record<string, string> => {
317− const headers: Record<string, string> = {};
318−
319− const auth = config.auth;
320− if (!isUnknownRecord(auth)) {
321− return headers;
322− }
323−
324− const authType = normalizeString(auth.type)?.toLowerCase();
325−
326− if (authType === "bearer") {
327− const token = normalizeString(auth.token) ?? normalizeString(auth.value);
328− if (token) {
329− headers.Authorization = `Bearer ${token}`;
330− }
331− }
332−
333− if (authType === "apikey" || authType === "api_key") {
334− const value = normalizeString(auth.value) ?? normalizeString(auth.token);
335− if (value) {
336− const headerName = normalizeString(auth.header) ?? "Authorization";
337− headers[headerName] = value;
338− }
339− }
340−
341− if (authType === "basic") {
342− const username = normalizeString(auth.username);
343− const password = normalizeString(auth.password);
344− if (username && password) {
345− headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
346− }
347− }
348−
349− return headers;
350−};
351−
352316 const collectConfiguredHeadersFromSourceConfig = (config: Record<string, unknown>): Record<string, string> => {
353317 const headers: Record<string, string> = {};
354318 const configuredHeaders = config.headers;
@@ -373,7 +337,6 @@ const collectGraphqlHeadersFromSourceConfig = (source: Source): Record<string, s · 18 unmodified lines
373337 return {
374338 "content-type": "application/json",
375339 ...collectConfiguredHeadersFromSourceConfig(config),
376− ...collectAuthHeadersFromSourceConfig(config),
377340 };
378341 };
379342
@@ -381,7 +344,6 @@ const collectMcpHeadersFromSourceConfig = (source: Source): Record<string, strin · 1 unmodified line
381344 const config = parseSourceConfig(source.configJson);
382345 return {
383346 ...collectConfiguredHeadersFromSourceConfig(config),
384− ...collectAuthHeadersFromSourceConfig(config),
385347 };
386348 };
387349
apps/convex/convex/control_plane/sources.ts+49−3
@@ -32,6 +32,36 @@ const parseConfigJson = (configJson: string | undefined): Record<string, unknown · 31 unmodified lines
3232 }
3333 };
3434
35+const protectedHeaderNames = new Set([
36+ "authorization",
37+ "proxy-authorization",
38+ "x-api-key",
39+]);
40+
41+const validateSourceConfigSecurity = (config: Record<string, unknown>): void => {
42+ if (Object.prototype.hasOwnProperty.call(config, "auth")) {
43+ throw new Error("Source config must not include auth; configure credentials via connections");
44+ }
45+
46+ if (Object.prototype.hasOwnProperty.call(config, "useCredentialedFetch")) {
47+ throw new Error("Source config must not include useCredentialedFetch");
48+ }
49+
50+ const headers = config.headers;
51+ if (!isRecord(headers)) {
52+ return;
53+ }
54+
55+ for (const headerName of Object.keys(headers)) {
56+ const normalized = headerName.trim().toLowerCase();
57+ if (protectedHeaderNames.has(normalized)) {
58+ throw new Error(
59+ `Source config headers must not include '${headerName}'; use credential connections instead`,
60+ );
61+ }
62+ }
63+};
64+
3565 const normalizeHttpUrl = (value: string): string | null => {
3666 const trimmed = value.trim();
3767 if (trimmed.length === 0) {
@@ -158,16 +188,25 @@ export const upsertSourceRecord = internalMutation({ · 120 unmodified lines
158188 const payload = args.payload as UpsertSourcePayload;
159189
160190 const configJson = (() => {
191+ const parsedConfig = parseConfigJson(payload.configJson);
192+ validateSourceConfigSecurity(parsedConfig);
193+
161194 if (payload.kind !== "openapi") {
162− return payload.configJson ?? "{}";
195+ return JSON.stringify(parsedConfig);
163196 }
164197
165198 const configuredBaseUrl = requireHttpUrl(
166− parseConfigJson(payload.configJson).baseUrl,
199+ parsedConfig.baseUrl,
167200 "OpenAPI source requires configJson.baseUrl",
168201 );
169202
170− return buildOpenApiConfigJson(payload, configuredBaseUrl);
203+ return buildOpenApiConfigJson(
204+ {
205+ ...payload,
206+ configJson: JSON.stringify(parsedConfig),
207+ },
208+ configuredBaseUrl,
209+ );
171210 })();
172211
173212 const source = decodeSource({
@@ -216,6 +255,7 @@ export const upsertSource = internalAction({ · 42 unmodified lines
216255
217256 if (payload.kind === "openapi") {
218257 const config = parseConfigJson(payload.configJson);
258+ validateSourceConfigSecurity(config);
219259 let resolvedBaseUrl = typeof config.baseUrl === "string"
220260 ? normalizeHttpUrl(config.baseUrl)
221261 : null;
@@ -241,6 +281,8 @@ export const upsertSource = internalAction({ · 19 unmodified lines
241281 ...payload,
242282 configJson: buildOpenApiConfigJson(payload, baseUrl),
243283 };
284+ } else {
285+ validateSourceConfigSecurity(parseConfigJson(payload.configJson));
244286 }
245287
246288 const source = await ctx.runMutation(runtimeInternal.control_plane.sources.upsertSourceRecord, {
@@ -342,6 +384,10 @@ export const removeSource = internalMutation({ · 95 unmodified lines
342384 sourceId: args.sourceId,
343385 });
344386
387+ await ctx.runMutation(runtimeInternal.control_plane.credentials.removeSourceAuthBindings, {
388+ sourceId: args.sourceId,
389+ });
390+
345391 await ctx.db.delete(existing._id);
346392
347393 return { removed: true };
apps/convex/convex/credential_crypto.ts+118−28
@@ -3,6 +3,11 @@ const encryptedPrefix = "enc:v1:"; · 2 unmodified lines
33 const encoder = new TextEncoder();
44 const decoder = new TextDecoder();
55
6+type EncryptionKeyEntry = {
7+ keyVersion: string;
8+ passphrase: string;
9+};
10+
611 const bytesToBase64 = (bytes: Uint8Array): string => {
712 let binary = "";
813 for (const byte of bytes) {
@@ -20,55 +25,139 @@ const base64ToBytes = (base64: string): Uint8Array => { · 11 unmodified lines
2025 return bytes;
2126 };
2227
23−const resolveEncryptionPassphrase = (): string => {
24− const passphrase = process.env.CREDENTIAL_ENCRYPTION_KEY?.trim() ?? "";
25− if (passphrase.length === 0) {
26− throw new Error("Encrypted credential storage requires CREDENTIAL_ENCRYPTION_KEY");
28+const normalizeKeyVersion = (value: string): string => {
29+ const trimmed = value.trim();
30+ return trimmed.length > 0 ? trimmed : "v1";
31+};
32+
33+const parseEncryptionKeyEntries = (): Array<EncryptionKeyEntry> => {
34+ const configured = process.env.CREDENTIAL_ENCRYPTION_KEYS?.trim() ?? "";
35+ if (configured.length > 0) {
36+ const entries = configured
37+ .split(",")
38+ .map((part) => part.trim())
39+ .filter((part) => part.length > 0)
40+ .flatMap((part) => {
41+ const separatorIndex = part.indexOf("=") >= 0
42+ ? part.indexOf("=")
43+ : part.indexOf(":");
44+ if (separatorIndex <= 0) {
45+ return [];
46+ }
47+
48+ const keyVersion = normalizeKeyVersion(part.slice(0, separatorIndex));
49+ const passphrase = part.slice(separatorIndex + 1).trim();
50+ if (passphrase.length === 0) {
51+ return [];
52+ }
53+
54+ return [{ keyVersion, passphrase }];
55+ });
56+
57+ if (entries.length > 0) {
58+ return entries;
59+ }
60+ }
61+
62+ const fallback = process.env.CREDENTIAL_ENCRYPTION_KEY?.trim() ?? "";
63+ if (fallback.length === 0) {
64+ throw new Error(
65+ "Encrypted credential storage requires CREDENTIAL_ENCRYPTION_KEY or CREDENTIAL_ENCRYPTION_KEYS",
66+ );
2767 }
2868
29− return passphrase;
69+ return [{ keyVersion: "v1", passphrase: fallback }];
3070 };
3171
32−const deriveAesKey = async (): Promise<CryptoKey> => {
33− const passphraseBytes = encoder.encode(resolveEncryptionPassphrase());
34− const hashBuffer = await crypto.subtle.digest("SHA-256", passphraseBytes);
35− return await crypto.subtle.importKey(
36− "raw",
37− hashBuffer,
38− {
39− name: "AES-GCM",
40− },
41− false,
42− ["encrypt", "decrypt"],
43− );
72+const resolveEncryptionKeyEntries = (): Array<EncryptionKeyEntry> =>
73+ parseEncryptionKeyEntries();
74+
75+const keyCache = new Map<string, Promise<CryptoKey>>();
76+
77+const deriveAesKey = async (passphrase: string): Promise<CryptoKey> => {
78+ const cacheKey = passphrase;
79+ const existing = keyCache.get(cacheKey);
80+ if (existing) {
81+ return await existing;
82+ }
83+
84+ const keyPromise = (async () => {
85+ const passphraseBytes = encoder.encode(passphrase);
86+ const hashBuffer = await crypto.subtle.digest("SHA-256", passphraseBytes);
87+ return await crypto.subtle.importKey(
88+ "raw",
89+ hashBuffer,
90+ {
91+ name: "AES-GCM",
92+ },
93+ false,
94+ ["encrypt", "decrypt"],
95+ );
96+ })();
97+
98+ keyCache.set(cacheKey, keyPromise);
99+ return await keyPromise;
44100 };
45101
46−const parseEncryptedValue = (value: string): { iv: Uint8Array; cipher: Uint8Array } | null => {
102+const parseEncryptedValue = (
103+ value: string,
104+): { keyVersion: string | null; iv: Uint8Array; cipher: Uint8Array } | null => {
47105 if (!value.startsWith(encryptedPrefix)) {
48106 return null;
49107 }
50108
51109 const payload = value.slice(encryptedPrefix.length);
52110 const parts = payload.split(":");
53− if (parts.length !== 2) {
54− return null;
55− }
56111
57112 try {
58− return {
59− iv: base64ToBytes(parts[0] ?? ""),
60− cipher: base64ToBytes(parts[1] ?? ""),
61− };
113+ if (parts.length === 2) {
114+ return {
115+ keyVersion: null,
116+ iv: base64ToBytes(parts[0] ?? ""),
117+ cipher: base64ToBytes(parts[1] ?? ""),
118+ };
119+ }
120+
121+ if (parts.length === 3) {
122+ return {
123+ keyVersion: normalizeKeyVersion(parts[0] ?? ""),
124+ iv: base64ToBytes(parts[1] ?? ""),
125+ cipher: base64ToBytes(parts[2] ?? ""),
126+ };
127+ }
128+
129+ return null;
62130 } catch {
63131 return null;
64132 }
65133 };
66134
135+const resolveKeyEntryForDecrypt = (
136+ keyVersion: string | null,
137+): EncryptionKeyEntry => {
138+ const entries = resolveEncryptionKeyEntries();
139+
140+ if (!keyVersion) {
141+ return entries[0]!;
142+ }
143+
144+ const matched = entries.find((entry) => entry.keyVersion === keyVersion);
145+ if (matched) {
146+ return matched;
147+ }
148+
149+ throw new Error(`Credential encryption key version is not configured: ${keyVersion}`);
150+};
151+
152+export const currentCredentialEncryptionKeyVersion = (): string =>
153+ resolveEncryptionKeyEntries()[0]!.keyVersion;
154+
67155 export const isEncryptedSecret = (value: string): boolean =>
68156 value.startsWith(encryptedPrefix);
69157
70158 export const encryptSecretValue = async (plainText: string): Promise<string> => {
71− const key = await deriveAesKey();
159+ const activeKey = resolveEncryptionKeyEntries()[0]!;
160+ const key = await deriveAesKey(activeKey.passphrase);
72161 const iv = crypto.getRandomValues(new Uint8Array(12));
73162 const encrypted = await crypto.subtle.encrypt(
74163 {
@@ -79,7 +168,7 @@ export const encryptSecretValue = async (plainText: string): Promise<string> => · 4 unmodified lines
79168 encoder.encode(plainText),
80169 );
81170
82− return `${encryptedPrefix}${bytesToBase64(iv)}:${bytesToBase64(new Uint8Array(encrypted))}`;
171+ return `${encryptedPrefix}${activeKey.keyVersion}:${bytesToBase64(iv)}:${bytesToBase64(new Uint8Array(encrypted))}`;
83172 };
84173
85174 export const decryptSecretValue = async (storedValue: string): Promise<string> => {
@@ -88,7 +177,8 @@ export const decryptSecretValue = async (storedValue: string): Promise<string> = · 2 unmodified lines
88177 throw new Error("Credential secret is not encrypted");
89178 }
90179
91− const key = await deriveAesKey();
180+ const keyEntry = resolveKeyEntryForDecrypt(parsed.keyVersion);
181+ const key = await deriveAesKey(keyEntry.passphrase);
92182 const decrypted = await crypto.subtle.decrypt(
93183 {
94184 name: "AES-GCM",
apps/convex/convex/executor.test.ts+289−1
@@ -52,6 +52,7 @@ describe("Convex executor and control-plane", () => { · 51 unmodified lines
5252 runUrl: process.env.CLOUDFLARE_SANDBOX_RUN_URL,
5353 authToken: process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN,
5454 callbackUrl: process.env.CLOUDFLARE_SANDBOX_CALLBACK_URL,
55+ callbackSecret: process.env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET,
5556 };
5657 const originalFetch = globalThis.fetch;
5758
@@ -59,6 +60,7 @@ describe("Convex executor and control-plane", () => { · 1 unmodified line
5960 process.env.CLOUDFLARE_SANDBOX_RUN_URL = "https://sandbox.local/run";
6061 process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN = "sandbox-token";
6162 process.env.CLOUDFLARE_SANDBOX_CALLBACK_URL = "https://convex.local/callback";
63+ process.env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET = "sandbox-callback-secret";
6264
6365 globalThis.fetch = (async (
6466 input: RequestInfo | URL,
@@ -115,6 +117,12 @@ describe("Convex executor and control-plane", () => { · 50 unmodified lines
115117 } else {
116118 process.env.CLOUDFLARE_SANDBOX_CALLBACK_URL = originalEnv.callbackUrl;
117119 }
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+ }
118126 }
119127 }),
120128 );
@@ -195,7 +203,7 @@ describe("Convex executor and control-plane", () => { · 74 unmodified lines
195203 id: "credential_binding_1",
196204 credentialId: "cred_1",
197205 scopeType: "workspace",
198− sourceKey: "github",
206+ sourceKey: "source:github",
199207 provider: "bearer",
200208 secretRef: "secret://github/token",
201209 accountId: null,
@@ -1383,6 +1391,286 @@ describe("Convex executor and control-plane", () => { · 1181 unmodified lines
13831391 }),
13841392 );
13851393
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+
13861674 it.effect("persists approval state for runtime tool calls", () =>
13871675 Effect.gen(function* () {
13881676 const t = setup();
apps/convex/convex/schema.ts+25−8
@@ -1,13 +1,16 @@
11 import { defineSchema, defineTable, type TablesFromSchemaDefinition } from "@executor-v2/confect";
22 import {
33 ApprovalSchema,
4+ AuthAuditEventSchema,
5+ AuthConnectionSchema,
6+ AuthMaterialSchema,
47 EventEnvelopeSchema,
5− OAuthTokenSchema,
8+ OAuthStateSchema,
69 OrganizationMembershipSchema,
710 OrganizationSchema,
811 PolicySchema,
912 ProfileSchema,
10− SourceCredentialBindingSchema,
13+ SourceAuthBindingSchema,
1114 SourceSchema,
1215 StorageInstanceSchema,
1316 SyncStateSchema,
@@ -181,18 +184,32 @@ export const executorConfectSchema = defineSchema({ · 167 unmodified lines
181184 searchField: "searchText",
182185 filterFields: ["workspaceId", "sourceId", "status", "namespace"],
183186 }),
184− sourceCredentialBindings: defineTable(SourceCredentialBindingSchema)
187+ authConnections: defineTable(AuthConnectionSchema)
185188 .index("by_domainId", ["id"])
186− .index("by_credentialId", ["credentialId"])
187− .index("by_workspaceId", ["workspaceId"])
188189 .index("by_organizationId", ["organizationId"])
190+ .index("by_workspaceId", ["workspaceId"])
189191 .index("by_accountId", ["accountId"])
190− .index("by_sourceKey", ["sourceKey"]),
191− oauthTokens: defineTable(OAuthTokenSchema)
192+ .index("by_status", ["status"])
193+ .index("by_strategy", ["strategy"]),
194+ sourceAuthBindings: defineTable(SourceAuthBindingSchema)
192195 .index("by_domainId", ["id"])
196+ .index("by_sourceId", ["sourceId"])
197+ .index("by_connectionId", ["connectionId"])
193198 .index("by_workspaceId", ["workspaceId"])
194199 .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"])
196213 .index("by_sourceId", ["sourceId"]),
197214 policies: defineTable(PolicySchema)
198215 .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
3333 import * as Schema from "effect/Schema";
3434
3535 import { internal } from "./_generated/api";
36−import { internalMutation, type ActionCtx } from "./_generated/server";
36+import { internalAction, internalMutation, type ActionCtx } from "./_generated/server";
3737
3838 const runtimeInternal = internal as any;
3939
@@ -186,6 +186,20 @@ const normalizeHeaderString = (value: unknown): string | null => { · 146 unmodified lines
186186 return trimmed.length > 0 ? trimmed : null;
187187 };
188188
189+const toStringRecord = (value: Record<string, unknown>): Record<string, string> => {
190+ const normalized: Record<string, string> = {};
191+
192+ for (const [rawKey, rawValue] of Object.entries(value)) {
193+ const key = normalizeHeaderString(rawKey);
194+ const headerValue = normalizeHeaderString(rawValue);
195+ if (key && headerValue) {
196+ normalized[key] = headerValue;
197+ }
198+ }
199+
200+ return normalized;
201+};
202+
189203 const parseSourceConfig = (source: Source): Record<string, unknown> =>
190204 jsonObjectFromUnknown(safeJsonParse(source.configJson));
191205
@@ -206,39 +220,6 @@ const collectConfiguredHeadersFromSourceConfig = ( · 14 unmodified lines
206220 return nextHeaders;
207221 };
208222
209−const collectAuthHeadersFromSourceConfig = (
210− config: Record<string, unknown>,
211−): Record<string, string> => {
212− const auth = jsonObjectFromUnknown(config.auth);
213− const authType = normalizeHeaderString(auth.type)?.toLowerCase();
214− const nextHeaders: Record<string, string> = {};
215−
216− if (authType === "bearer") {
217− const token = normalizeHeaderString(auth.token) ?? normalizeHeaderString(auth.value);
218− if (token) {
219− nextHeaders.Authorization = `Bearer ${token}`;
220− }
221− }
222−
223− if (authType === "apikey" || authType === "api_key") {
224− const token = normalizeHeaderString(auth.value) ?? normalizeHeaderString(auth.token);
225− if (token) {
226− const headerName = normalizeHeaderString(auth.header) ?? "Authorization";
227− nextHeaders[headerName] = token;
228− }
229− }
230−
231− if (authType === "basic") {
232− const username = normalizeHeaderString(auth.username);
233− const password = normalizeHeaderString(auth.password);
234− if (username && password && typeof btoa === "function") {
235− nextHeaders.Authorization = `Basic ${btoa(`${username}:${password}`)}`;
236− }
237− }
238−
239− return nextHeaders;
240−};
241−
242223 const mergeHeaders = (...sets: ReadonlyArray<Record<string, string>>): Record<string, string> => {
243224 const merged: Record<string, string> = {};
244225 const keyByLower = new Map<string, string>();
@@ -269,7 +250,13 @@ const resolveSourceCredentialHeadersForInvocation = async ( · 24 unmodified lines
269250 ctx: ActionCtx,
270251 source: Source,
271252 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+}> => {
273260 try {
274261 const resolved = (await ctx.runAction(
275262 runtimeInternal.control_plane.credentials.resolveSourceCredentialHeaders,
@@ -277,8 +264,9 @@ const resolveSourceCredentialHeadersForInvocation = async ( · 1 unmodified line
277264 workspaceId: source.workspaceId,
278265 sourceId: source.id,
279266 accountId,
267+ ...(options?.forceRefresh === true ? { forceRefresh: true } : {}),
280268 },
281− )) as { headers?: unknown };
269+ )) as { headers?: unknown; oauthConnectionId?: unknown };
282270
283271 const headers = jsonObjectFromUnknown(resolved.headers);
284272 const normalized: Record<string, string> = {};
@@ -291,9 +279,18 @@ const resolveSourceCredentialHeadersForInvocation = async ( · 6 unmodified lines
291279 }
292280 }
293281
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+ };
295289 } catch {
296− return {};
290+ return {
291+ headers: {},
292+ oauthConnectionId: null,
293+ };
297294 }
298295 };
299296
@@ -301,14 +298,167 @@ const resolveSourceHeadersForInvocation = async ( · 1 unmodified line
301298 ctx: ActionCtx,
302299 source: Source,
303300 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+}> => {
305308 const config = parseSourceConfig(source);
309+ const credentialResolved = await resolveSourceCredentialHeadersForInvocation(
310+ ctx,
311+ source,
312+ accountId,
313+ options,
314+ );
306315
307− return mergeHeaders(
316+ return {
317+ headers: mergeHeaders(
318+ collectConfiguredHeadersFromSourceConfig(config),
319+ credentialResolved.headers,
320+ ),
321+ oauthConnectionId: credentialResolved.oauthConnectionId,
322+ };
323+};
324+
325+const sourceWithInvocationHeaders = (
326+ source: Source,
327+ invocationHeaders: Record<string, string>,
328+): Source => {
329+ const config = parseSourceConfig(source);
330+ const mergedHeaders = mergeHeaders(
308331 collectConfiguredHeadersFromSourceConfig(config),
309− collectAuthHeadersFromSourceConfig(config),
310− await resolveSourceCredentialHeadersForInvocation(ctx, source, accountId),
332+ invocationHeaders,
311333 );
334+
335+ const nextConfig: Record<string, unknown> = {
336+ ...config,
337+ };
338+
339+ if (Object.keys(mergedHeaders).length > 0) {
340+ nextConfig.headers = mergedHeaders;
341+ } else {
342+ delete nextConfig.headers;
343+ }
344+
345+ return {
346+ ...source,
347+ configJson: JSON.stringify(nextConfig),
348+ };
349+};
350+
351+const readHeaderCaseInsensitive = (
352+ headers: Record<string, string>,
353+ name: string,
354+): string | null => {
355+ const target = name.toLowerCase();
356+
357+ for (const [key, value] of Object.entries(headers)) {
358+ if (key.toLowerCase() === target) {
359+ return value;
360+ }
361+ }
362+
363+ return null;
364+};
365+
366+const textHasInvalidTokenMarker = (value: string): boolean => {
367+ const normalized = value.toLowerCase();
368+ return normalized.includes("invalid_token") || normalized.includes("invalid token");
369+};
370+
371+const valueHasInvalidTokenMarker = (value: unknown): boolean => {
372+ if (typeof value === "string") {
373+ return textHasInvalidTokenMarker(value);
374+ }
375+
376+ if (Array.isArray(value)) {
377+ return value.some((entry) => valueHasInvalidTokenMarker(entry));
378+ }
379+
380+ if (!value || typeof value !== "object") {
381+ return false;
382+ }
383+
384+ const record = value as Record<string, unknown>;
385+ if (valueHasInvalidTokenMarker(record.error)) {
386+ return true;
387+ }
388+
389+ if (valueHasInvalidTokenMarker(record.error_description)) {
390+ return true;
391+ }
392+
393+ if (valueHasInvalidTokenMarker(record.message)) {
394+ return true;
395+ }
396+
397+ if (valueHasInvalidTokenMarker(record.code)) {
398+ return true;
399+ }
400+
401+ if (Array.isArray(record.errors) && record.errors.some((entry) => valueHasInvalidTokenMarker(entry))) {
402+ return true;
403+ }
404+
405+ return Object.values(record).some((entry) => valueHasInvalidTokenMarker(entry));
406+};
407+
408+type HttpInvocationEnvelope = {
409+ status: number | null;
410+ headers: Record<string, string>;
411+ body: unknown;
412+};
413+
414+const toHttpInvocationEnvelope = (output: unknown): HttpInvocationEnvelope => {
415+ if (!output || typeof output !== "object" || Array.isArray(output)) {
416+ return {
417+ status: null,
418+ headers: {},
419+ body: null,
420+ };
421+ }
422+
423+ const record = output as Record<string, unknown>;
424+ const rawStatus = record.status;
425+ const rawHeaders = record.headers;
426+
427+ return {
428+ status:
429+ typeof rawStatus === "number" && Number.isFinite(rawStatus)
430+ ? Math.floor(rawStatus)
431+ : null,
432+ headers:
433+ rawHeaders && typeof rawHeaders === "object" && !Array.isArray(rawHeaders)
434+ ? toStringRecord(rawHeaders as Record<string, unknown>)
435+ : {},
436+ body: record.body,
437+ };
438+};
439+
440+const responseHasInvalidTokenSignal = (envelope: HttpInvocationEnvelope): boolean => {
441+ if (envelope.status !== 401) {
442+ return false;
443+ }
444+
445+ const wwwAuthenticate = readHeaderCaseInsensitive(envelope.headers, "www-authenticate");
446+ if (wwwAuthenticate && textHasInvalidTokenMarker(wwwAuthenticate)) {
447+ return true;
448+ }
449+
450+ return valueHasInvalidTokenMarker(envelope.body);
451+};
452+
453+const shouldRetryOAuthAfterUnauthorized = (
454+ oauthConnectionId: string | null,
455+ output: unknown,
456+): boolean => {
457+ if (!oauthConnectionId) {
458+ return false;
459+ }
460+
461+ return responseHasInvalidTokenSignal(toHttpInvocationEnvelope(output));
312462 };
313463
314464 const headersToRecord = (headers: Headers): Record<string, string> => {
@@ -1044,7 +1194,58 @@ const invokeToolWithRegistry = ( · 729 unmodified lines
10441194 accountId: string,
10451195 ): Effect.Effect<{ output: unknown; isError: boolean }, RuntimeAdapterError> =>
10461196 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+ };
10481249
10491250 const graphqlProvider = {
10501251 kind: "graphql" as const,
@@ -1078,39 +1279,58 @@ const invokeToolWithRegistry = ( · 27 unmodified lines
10781279 operationName = payload.fieldName;
10791280 }
10801281
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+
10811314 const sourceHeaders = await resolveSourceHeadersForInvocation(
10821315 ctx,
10831316 input.source,
10841317 accountId,
10851318 );
10861319
1087− const response = await fetch(payload.endpoint, {
1088− method: "POST",
1089− headers: mergeHeaders(
1090− {
1091− "content-type": "application/json",
1092− },
1093− sourceHeaders,
1094− ),
1095− body: JSON.stringify({
1096− query,
1097− variables,
1098− operationName,
1099− }),
1100− });
1320+ let result = await invokeGraphql(sourceHeaders.headers);
11011321
1102− const body = await graphqlResponseBody(response);
1103− const bodyRecord = jsonObjectFromUnknown(body);
1104− const hasErrors = Array.isArray(bodyRecord.errors);
1322+ if (shouldRetryOAuthAfterUnauthorized(sourceHeaders.oauthConnectionId, result.output)) {
1323+ const refreshedHeaders = await resolveSourceHeadersForInvocation(
1324+ ctx,
1325+ input.source,
1326+ accountId,
1327+ { forceRefresh: true },
1328+ );
11051329
1106− return {
1107− output: {
1108− status: response.status,
1109− headers: headersToRecord(response.headers),
1110− body,
1111− },
1112− isError: response.status >= 400 || hasErrors,
1113− };
1330+ result = await invokeGraphql(refreshedHeaders.headers);
1331+ }
1332+
1333+ return result;
11141334 },
11151335 catch: (cause) =>
11161336 toToolProviderError(
@@ -1133,11 +1353,6 @@ const invokeToolWithRegistry = ( · 16 unmodified lines
11331353
11341354 const payload = decodeMcpInvocationPayload(input.tool.providerPayload);
11351355 const invokeArgs = normalizeInvokeInput(input.args);
1136− const sourceHeaders = await resolveSourceHeadersForInvocation(
1137− ctx,
1138− input.source,
1139− accountId,
1140− );
11411356
11421357 if (payload.transport !== "streamable-http") {
11431358 throw new Error(
@@ -1146,74 +1361,99 @@ const invokeToolWithRegistry = ( · 2 unmodified lines
11461361 }
11471362
11481363 const endpoint = createMcpEndpointUrl(payload);
1149− const initializeResponse = await postMcpJsonRpc(
1150− endpoint,
1151− {
1152− jsonrpc: "2.0",
1153− id: `init_${crypto.randomUUID()}`,
1154− method: "initialize",
1155− params: {
1156− protocolVersion: "2024-11-05",
1157− capabilities: {},
1158− clientInfo: {
1159− name: "executor-v2-runtime",
1160− version: "0.1.0",
1364+ const invokeMcp = async (
1365+ resolvedHeaders: Record<string, string>,
1366+ ): Promise<{ output: unknown; isError: boolean }> => {
1367+ const initializeResponse = await postMcpJsonRpc(
1368+ endpoint,
1369+ {
1370+ jsonrpc: "2.0",
1371+ id: `init_${crypto.randomUUID()}`,
1372+ method: "initialize",
1373+ params: {
1374+ protocolVersion: "2024-11-05",
1375+ capabilities: {},
1376+ clientInfo: {
1377+ name: "executor-v2-runtime",
1378+ version: "0.1.0",
1379+ },
11611380 },
11621381 },
1163− },
1164− null,
1165− sourceHeaders,
1166− );
1167− const initializeBody = await decodeMcpJsonResponse(initializeResponse);
1382+ null,
1383+ resolvedHeaders,
1384+ );
1385+ const initializeBody = await decodeMcpJsonResponse(initializeResponse);
1386+
1387+ const sessionId = initializeResponse.headers.get("mcp-session-id");
1388+
1389+ if (initializeResponse.status >= 400 || initializeBody.error !== undefined) {
1390+ return {
1391+ output: {
1392+ status: initializeResponse.status,
1393+ headers: headersToRecord(initializeResponse.headers),
1394+ body: initializeBody,
1395+ },
1396+ isError: true,
1397+ };
1398+ }
11681399
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);
11701426
1171− if (initializeResponse.status >= 400 || initializeBody.error !== undefined) {
11721427 return {
11731428 output: {
1174− status: initializeResponse.status,
1175− headers: headersToRecord(initializeResponse.headers),
1176− body: initializeBody,
1429+ status: callResponse.status,
1430+ headers: headersToRecord(callResponse.headers),
1431+ body: callBody,
11771432 },
1178− isError: true,
1433+ isError: callResponse.status >= 400 || callBody.error !== undefined,
11791434 };
1180− }
1435+ };
11811436
1182− await postMcpJsonRpc(
1183− endpoint,
1184− {
1185− jsonrpc: "2.0",
1186− method: "notifications/initialized",
1187− params: {},
1188− },
1189− sessionId,
1190− sourceHeaders,
1437+ const sourceHeaders = await resolveSourceHeadersForInvocation(
1438+ ctx,
1439+ input.source,
1440+ accountId,
11911441 );
11921442
1193− const callResponse = await postMcpJsonRpc(
1194− endpoint,
1195− {
1196− jsonrpc: "2.0",
1197− id: `call_${crypto.randomUUID()}`,
1198− method: "tools/call",
1199− params: {
1200− name: payload.toolName,
1201− arguments: invokeArgs,
1202− },
1203− },
1204− sessionId,
1205− sourceHeaders,
1206− );
1207− const callBody = await decodeMcpJsonResponse(callResponse);
1443+ let result = await invokeMcp(sourceHeaders.headers);
12081444
1209− return {
1210− output: {
1211− status: callResponse.status,
1212− headers: headersToRecord(callResponse.headers),
1213− body: callBody,
1214− },
1215− isError: callResponse.status >= 400 || callBody.error !== undefined,
1216− };
1445+ if (shouldRetryOAuthAfterUnauthorized(sourceHeaders.oauthConnectionId, result.output)) {
1446+ const refreshedHeaders = await resolveSourceHeadersForInvocation(
1447+ ctx,
1448+ input.source,
1449+ accountId,
1450+ { forceRefresh: true },
1451+ );
1452+
1453+ result = await invokeMcp(refreshedHeaders.headers);
1454+ }
1455+
1456+ return result;
12171457 },
12181458 catch: (cause) =>
12191459 toToolProviderError(
@@ -1226,10 +1466,7 @@ const invokeToolWithRegistry = ( · 6 unmodified lines
12261466 };
12271467
12281468 const registry = makeToolProviderRegistry([
1229− {
1230− kind: "openapi",
1231− invoke: (input) => openApiProvider.invoke(input),
1232− },
1469+ openApiProvider,
12331470 graphqlProvider,
12341471 mcpProvider,
12351472 ]);
@@ -1237,7 +1474,7 @@ const invokeToolWithRegistry = ( · 1 unmodified line
12371474 const providerKind = artifactTool.protocol;
12381475 const invocationPayload = safeJsonParse(artifactTool.invocationJson);
12391476
1240− const descriptor: CanonicalToolDescriptor = {
1477+ const descriptor = {
12411478 providerKind: providerKind as any,
12421479 sourceId: source.id,
12431480 workspaceId: source.workspaceId,
@@ -1257,7 +1494,7 @@ const invokeToolWithRegistry = ( · 13 unmodified lines
12571494 : providerKind === "graphql"
12581495 ? decodeGraphqlInvocationPayload(invocationPayload)
12591496 : decodeMcpInvocationPayload(invocationPayload),
1260− };
1497+ } as CanonicalToolDescriptor;
12611498
12621499 const invocation = yield* registry
12631500 .invoke({
@@ -1864,3 +2101,28 @@ export const createConvexSourceToolRegistry = ( · 600 unmodified lines
18642101 }),
18652102 } as ToolRegistry;
18662103 };
2104+
2105+export const invokeWorkspaceTool = internalAction({
2106+ args: {
2107+ workspaceId: v.string(),
2108+ accountId: v.string(),
2109+ runId: v.string(),
2110+ callId: v.string(),
2111+ toolPath: v.string(),
2112+ input: v.optional(v.any()),
2113+ },
2114+ handler: async (ctx, args): Promise<RuntimeToolCallResult> => {
2115+ const toolRegistry = createConvexSourceToolRegistry(ctx, args.workspaceId, {
2116+ accountId: args.accountId,
2117+ });
2118+
2119+ return await Effect.runPromise(
2120+ toolRegistry.callTool({
2121+ runId: args.runId,
2122+ callId: args.callId,
2123+ toolPath: args.toolPath,
2124+ input: normalizeInvokeInput(args.input),
2125+ }),
2126+ );
2127+ },
2128+});
apps/pm/src/credential-resolver.ts+218−14
@@ -1,10 +1,7 @@
11 import {
2− buildCredentialHeaders,
32 CredentialResolverError,
43 extractCredentialResolutionContext,
54 makeCredentialResolver,
6− selectCredentialBinding,
7− selectOAuthAccessToken,
85 sourceIdFromSourceKey,
96 type ResolveToolCredentials,
107 } from "@executor-v2/engine";
@@ -23,6 +20,136 @@ const toCredentialResolverError = ( · 12 unmodified lines
2320 details,
2421 });
2522
23+const normalizeString = (value: unknown): string | null => {
24+ if (typeof value !== "string") {
25+ return null;
26+ }
27+
28+ const trimmed = value.trim();
29+ return trimmed.length > 0 ? trimmed : null;
30+};
31+
32+const parseJsonObject = (value: string | null | undefined): Record<string, unknown> => {
33+ if (!value) {
34+ return {};
35+ }
36+
37+ try {
38+ const parsed = JSON.parse(value) as unknown;
39+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
40+ return {};
41+ }
42+
43+ return parsed as Record<string, unknown>;
44+ } catch {
45+ return {};
46+ }
47+};
48+
49+const toStringRecord = (value: Record<string, unknown>): Record<string, string> => {
50+ const normalized: Record<string, string> = {};
51+
52+ for (const [rawKey, rawValue] of Object.entries(value)) {
53+ const key = normalizeString(rawKey);
54+ const resolvedValue = normalizeString(rawValue);
55+ if (!key || !resolvedValue) {
56+ continue;
57+ }
58+
59+ normalized[key] = resolvedValue;
60+ }
61+
62+ return normalized;
63+};
64+
65+const mergeHeaders = (...sets: ReadonlyArray<Record<string, string>>): Record<string, string> => {
66+ const merged: Record<string, string> = {};
67+ const keyByLower = new Map<string, string>();
68+
69+ for (const set of sets) {
70+ for (const [rawKey, rawValue] of Object.entries(set)) {
71+ const key = rawKey.trim();
72+ const value = rawValue.trim();
73+ if (key.length === 0 || value.length === 0) {
74+ continue;
75+ }
76+
77+ const lower = key.toLowerCase();
78+ const existing = keyByLower.get(lower);
79+ if (existing && existing !== key) {
80+ delete merged[existing];
81+ }
82+
83+ keyByLower.set(lower, key);
84+ merged[key] = value;
85+ }
86+ }
87+
88+ return merged;
89+};
90+
91+const base64Encode = (value: string): string =>
92+ Buffer.from(value, "utf8").toString("base64");
93+
94+const buildSecretHeaders = (
95+ strategy: string,
96+ secret: string,
97+ metadataJson: string | null,
98+): Record<string, string> => {
99+ const trimmedSecret = secret.trim();
100+ if (trimmedSecret.length === 0) {
101+ return {};
102+ }
103+
104+ const metadata = parseJsonObject(metadataJson);
105+
106+ if (strategy === "api_key") {
107+ const headerName = normalizeString(metadata.apiKeyHeader) ?? "x-api-key";
108+ return {
109+ [headerName]: trimmedSecret,
110+ };
111+ }
112+
113+ if (strategy === "bearer") {
114+ return {
115+ Authorization: `Bearer ${trimmedSecret}`,
116+ };
117+ }
118+
119+ if (strategy === "basic") {
120+ const asJson = parseJsonObject(trimmedSecret);
121+ const username = normalizeString(asJson.username);
122+ const password = normalizeString(asJson.password);
123+
124+ const pair = username && password
125+ ? `${username}:${password}`
126+ : trimmedSecret.includes(":")
127+ ? trimmedSecret
128+ : null;
129+
130+ if (!pair) {
131+ return {};
132+ }
133+
134+ return {
135+ Authorization: `Basic ${base64Encode(pair)}`,
136+ };
137+ }
138+
139+ if (strategy === "custom") {
140+ const headerName = normalizeString(metadata.customHeaderName);
141+ if (!headerName) {
142+ return {};
143+ }
144+
145+ return {
146+ [headerName]: trimmedSecret,
147+ };
148+ }
149+
150+ return {};
151+};
152+
26153 export const createPmResolveToolCredentials = (
27154 localStateStore: LocalStateStore,
28155 ): ResolveToolCredentials =>
@@ -52,27 +179,104 @@ export const createPmResolveToolCredentials = ( · 23 unmodified lines
52179 };
53180 }
54181
55− const binding = selectCredentialBinding(
56− snapshot.credentialBindings,
57− context,
182+ const sourceId = sourceIdFromSourceKey(context.sourceKey);
183+ if (!sourceId) {
184+ return {
185+ headers: {},
186+ };
187+ }
188+
189+ const workspace = snapshot.workspaces.find(
190+ (item) => item.id === context.workspaceId,
58191 );
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+
59227 if (binding === null) {
60228 return {
61229 headers: {},
62230 };
63231 }
64232
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+ }
66241
67− const oauthAccessToken =
68− binding.provider === "oauth2" && sourceId
69− ? selectOAuthAccessToken(snapshot.oauthTokens, context, sourceId)
70− : null;
242+ const additionalHeaders = toStringRecord(
243+ parseJsonObject(connection.additionalHeadersJson),
244+ );
245+
246+ if (connection.strategy === "oauth2") {
247+ const oauthState = snapshot.oauthStates.find(
248+ (candidate) => candidate.connectionId === connection.id,
249+ );
250+
251+ const accessToken = normalizeString(oauthState?.accessTokenCiphertext);
252+ const oauthHeaders = accessToken
253+ ? ({ Authorization: `Bearer ${accessToken}` } as Record<string, string>)
254+ : {};
255+
256+ return {
257+ headers: mergeHeaders(oauthHeaders, additionalHeaders),
258+ };
259+ }
260+
261+ const material = snapshot.authMaterials.find(
262+ (candidate) => candidate.connectionId === connection.id,
263+ );
264+
265+ if (!material) {
266+ return {
267+ headers: additionalHeaders,
268+ };
269+ }
71270
72271 return {
73− headers: buildCredentialHeaders(binding, {
74− oauthAccessToken,
75− }),
272+ headers: mergeHeaders(
273+ buildSecretHeaders(
274+ connection.strategy,
275+ material.ciphertext,
276+ connection.metadataJson,
277+ ),
278+ additionalHeaders,
279+ ),
76280 };
77281 }),
78282 );
apps/pm/src/credentials-service.ts+396−60
@@ -7,9 +7,15 @@ import { · 6 unmodified lines
77 import {
88 makeControlPlaneCredentialsService,
99 type ControlPlaneCredentialsServiceShape,
10+ type UpsertCredentialBindingPayload,
1011 } from "@executor-v2/management-api";
1112 import {
13+ type AuthConnection,
14+ type AuthConnectionStrategy,
15+ type AuthMaterial,
16+ type OAuthState,
1217 type OrganizationId,
18+ type SourceAuthBinding,
1319 type SourceCredentialBinding,
1420 type WorkspaceId,
1521 } from "@executor-v2/schema";
@@ -49,6 +55,81 @@ const resolveWorkspaceOrganizationId = ( · 33 unmodified lines
4955 return workspace.organizationId;
5056 };
5157
58+const normalizeString = (value: unknown): string | null => {
59+ if (typeof value !== "string") {
60+ return null;
61+ }
62+
63+ const trimmed = value.trim();
64+ return trimmed.length > 0 ? trimmed : null;
65+};
66+
67+const sourceIdFromSourceKey = (sourceKey: string): string | null => {
68+ const trimmed = sourceKey.trim();
69+ if (!trimmed.startsWith("source:")) {
70+ return null;
71+ }
72+
73+ const sourceId = trimmed.slice("source:".length).trim();
74+ return sourceId.length > 0 ? sourceId : null;
75+};
76+
77+const sourceKeyFromSourceId = (sourceId: string): string => `source:${sourceId}`;
78+
79+const strategyFromProvider = (
80+ provider: SourceCredentialBinding["provider"],
81+): AuthConnectionStrategy => {
82+ if (provider === "api_key") return "api_key";
83+ if (provider === "bearer") return "bearer";
84+ if (provider === "oauth2") return "oauth2";
85+ if (provider === "basic") return "basic";
86+ return "custom";
87+};
88+
89+const providerFromStrategy = (
90+ strategy: AuthConnectionStrategy,
91+): SourceCredentialBinding["provider"] => {
92+ if (strategy === "api_key") return "api_key";
93+ if (strategy === "bearer") return "bearer";
94+ if (strategy === "oauth2") return "oauth2";
95+ if (strategy === "basic") return "basic";
96+ return "custom";
97+};
98+
99+const maskedSecretRef = (connection: AuthConnection): string => {
100+ if (connection.strategy === "oauth2") {
101+ if (connection.status === "reauth_required") {
102+ return "oauth2://reauth_required";
103+ }
104+
105+ if (connection.status === "active") {
106+ return "oauth2://connected";
107+ }
108+ }
109+
110+ return "********";
111+};
112+
113+const toCompatSourceCredentialBinding = (
114+ binding: SourceAuthBinding,
115+ connection: AuthConnection,
116+): SourceCredentialBinding => ({
117+ id: binding.id as unknown as SourceCredentialBinding["id"],
118+ credentialId: connection.id as unknown as SourceCredentialBinding["credentialId"],
119+ organizationId: binding.organizationId,
120+ workspaceId: binding.workspaceId,
121+ accountId: binding.accountId,
122+ scopeType: binding.scopeType,
123+ sourceKey: sourceKeyFromSourceId(binding.sourceId),
124+ provider: providerFromStrategy(connection.strategy),
125+ secretProvider: "local",
126+ secretRef: maskedSecretRef(connection),
127+ additionalHeadersJson: connection.additionalHeadersJson,
128+ boundAuthFingerprint: null,
129+ createdAt: binding.createdAt,
130+ updatedAt: Math.max(binding.updatedAt, connection.updatedAt),
131+});
132+
52133 const sortCredentialBindings = (
53134 bindings: ReadonlyArray<SourceCredentialBinding>,
54135 ): Array<SourceCredentialBinding> =>
@@ -58,28 +139,110 @@ const sortCredentialBindings = ( · 3 unmodified lines
58139 return leftKey.localeCompare(rightKey);
59140 });
60141
61−const replaceCredentialBindingAt = (
62− snapshot: LocalStateSnapshot,
63− index: number,
64− binding: SourceCredentialBinding,
65−): LocalStateSnapshot => {
66− const next = [...snapshot.credentialBindings];
67− next[index] = binding;
142+const upsertById = <A extends { id: string }>(
143+ values: ReadonlyArray<A>,
144+ nextValue: A,
145+): Array<A> => {
146+ const next = [...values];
147+ const index = next.findIndex((value) => value.id === nextValue.id);
148+ if (index >= 0) {
149+ next[index] = nextValue;
150+ return next;
151+ }
152+
153+ next.push(nextValue);
154+ return next;
155+};
156+
157+const parseJsonObject = (value: string | null | undefined): Record<string, unknown> => {
158+ if (!value) {
159+ return {};
160+ }
161+
162+ try {
163+ const parsed = JSON.parse(value) as unknown;
164+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
165+ return {};
166+ }
167+
168+ return parsed as Record<string, unknown>;
169+ } catch {
170+ return {};
171+ }
172+};
173+
174+type OAuthRefreshConfig = {
175+ tokenEndpoint?: string;
176+ authorizationServer?: string;
177+ clientId?: string;
178+ clientSecretCiphertext?: string;
179+ sourceUrl?: string;
180+ clientInformationJson?: string;
181+};
68182
183+const parseOAuthRefreshConfig = (value: string | null): OAuthRefreshConfig => {
184+ if (!value) {
185+ return {};
186+ }
187+
188+ const parsed = parseJsonObject(value);
69189 return {
70− ...snapshot,
71− generatedAt: Date.now(),
72− credentialBindings: next,
190+ ...(normalizeString(parsed.tokenEndpoint)
191+ ? { tokenEndpoint: normalizeString(parsed.tokenEndpoint)! }
192+ : {}),
193+ ...(normalizeString(parsed.authorizationServer)
194+ ? { authorizationServer: normalizeString(parsed.authorizationServer)! }
195+ : {}),
196+ ...(normalizeString(parsed.clientId)
197+ ? { clientId: normalizeString(parsed.clientId)! }
198+ : {}),
199+ ...(normalizeString(parsed.clientSecretCiphertext)
200+ ? { clientSecretCiphertext: normalizeString(parsed.clientSecretCiphertext)! }
201+ : {}),
202+ ...(normalizeString(parsed.sourceUrl)
203+ ? { sourceUrl: normalizeString(parsed.sourceUrl)! }
204+ : {}),
205+ ...(normalizeString(parsed.clientInformationJson)
206+ ? { clientInformationJson: normalizeString(parsed.clientInformationJson)! }
207+ : {}),
73208 };
74209 };
75210
76−const appendCredentialBinding = (
77− snapshot: LocalStateSnapshot,
78− binding: SourceCredentialBinding,
79−): LocalStateSnapshot => ({
80− ...snapshot,
81− generatedAt: Date.now(),
82− credentialBindings: [...snapshot.credentialBindings, binding],
211+const encodeOAuthRefreshConfig = (config: OAuthRefreshConfig): string | null => {
212+ const payload: Record<string, string> = {};
213+
214+ if (config.tokenEndpoint) payload.tokenEndpoint = config.tokenEndpoint;
215+ if (config.authorizationServer) payload.authorizationServer = config.authorizationServer;
216+ if (config.clientId) payload.clientId = config.clientId;
217+ if (config.clientSecretCiphertext) payload.clientSecretCiphertext = config.clientSecretCiphertext;
218+ if (config.sourceUrl) payload.sourceUrl = config.sourceUrl;
219+ if (config.clientInformationJson) payload.clientInformationJson = config.clientInformationJson;
220+
221+ if (Object.keys(payload).length === 0) {
222+ return null;
223+ }
224+
225+ return JSON.stringify(payload);
226+};
227+
228+const buildOAuthRefreshConfigFromPayload = (
229+ payload: UpsertCredentialBindingPayload,
230+ existing: OAuthRefreshConfig,
231+): OAuthRefreshConfig => ({
232+ tokenEndpoint:
233+ normalizeString(payload.oauthTokenEndpoint)
234+ ?? existing.tokenEndpoint,
235+ authorizationServer:
236+ normalizeString(payload.oauthAuthorizationServer)
237+ ?? existing.authorizationServer,
238+ clientId: normalizeString(payload.oauthClientId) ?? existing.clientId,
239+ clientSecretCiphertext:
240+ normalizeString(payload.oauthClientSecret)
241+ ?? existing.clientSecretCiphertext,
242+ sourceUrl: normalizeString(payload.oauthSourceUrl) ?? existing.sourceUrl,
243+ clientInformationJson:
244+ normalizeString(payload.oauthClientInformationJson)
245+ ?? existing.clientInformationJson,
83246 });
84247
85248 const removeCredentialBindingForWorkspace = (
@@ -88,7 +251,7 @@ const removeCredentialBindingForWorkspace = ( · 2 unmodified lines
88251 credentialBindingId: string,
89252 ): { snapshot: LocalStateSnapshot; removed: boolean } => {
90253 const organizationId = resolveWorkspaceOrganizationId(snapshot, workspaceId);
91− const removeIndex = snapshot.credentialBindings.findIndex(
254+ const removeIndex = snapshot.sourceAuthBindings.findIndex(
92255 (binding) =>
93256 binding.id === credentialBindingId
94257 && (
@@ -104,15 +267,42 @@ const removeCredentialBindingForWorkspace = ( · 9 unmodified lines
104267 };
105268 }
106269
107− const next = [...snapshot.credentialBindings];
108− next.splice(removeIndex, 1);
270+ const bindingToRemove = snapshot.sourceAuthBindings[removeIndex];
271+ if (!bindingToRemove) {
272+ return {
273+ snapshot,
274+ removed: false,
275+ };
276+ }
277+
278+ const nextBindings = [...snapshot.sourceAuthBindings];
279+ nextBindings.splice(removeIndex, 1);
280+
281+ const hasRemainingBindings = nextBindings.some(
282+ (binding) => binding.connectionId === bindingToRemove.connectionId,
283+ );
109284
110285 return {
111286 removed: true,
112287 snapshot: {
113288 ...snapshot,
114289 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+ ),
116306 },
117307 };
118308 };
@@ -136,13 +326,27 @@ export const createPmCredentialsService = ( · 17 unmodified lines
136326
137327 const organizationId = resolveWorkspaceOrganizationId(snapshot, workspaceId);
138328
139− return sortCredentialBindings(
140− snapshot.credentialBindings.filter(
141− (binding) =>
142− binding.workspaceId === workspaceId
143− || (binding.workspaceId === null && binding.organizationId === organizationId),
144− ),
329+ const scopedBindings = snapshot.sourceAuthBindings.filter(
330+ (binding) =>
331+ binding.workspaceId === workspaceId
332+ || (binding.workspaceId === null && binding.organizationId === organizationId),
145333 );
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);
146350 }),
147351
148352 upsertCredentialBinding: (input) =>
@@ -170,49 +374,181 @@ export const createPmCredentialsService = ( · 21 unmodified lines
170374 );
171375 }
172376
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+
173386 const now = Date.now();
174387 const requestedId = input.payload.id;
388+ const requestedBindingId = requestedId as SourceAuthBinding["id"] | undefined;
175389
176− const existingIndex = requestedId
177− ? snapshot.credentialBindings.findIndex(
178− (binding) =>
179− binding.workspaceId === input.workspaceId && binding.id === requestedId,
180− )
181− : -1;
390+ const existingBinding = requestedBindingId
391+ ? snapshot.sourceAuthBindings.find((binding) => binding.id === requestedBindingId) ?? null
392+ : null;
182393
183− const existing = existingIndex >= 0 ? snapshot.credentialBindings[existingIndex] : null;
184394 const organizationId = resolveWorkspaceOrganizationId(snapshot, input.workspaceId);
185395
186− const nextBinding: SourceCredentialBinding = {
187− id:
188− existing?.id
189− ?? (requestedId
190− ?? (`credential_binding_${crypto.randomUUID()}` as SourceCredentialBinding["id"])),
191− credentialId: input.payload.credentialId,
396+ const scopeWorkspaceId =
397+ input.payload.scopeType === "workspace" ? input.workspaceId : null;
398+ const scopeAccountId =
399+ input.payload.scopeType === "account" ? (input.payload.accountId ?? null) : null;
400+
401+ const resolvedBindingId = (
402+ existingBinding?.id
403+ ?? requestedBindingId
404+ ?? (`auth_binding_${crypto.randomUUID()}` as SourceAuthBinding["id"])
405+ ) as SourceAuthBinding["id"];
406+
407+ const requestedConnectionId = (
408+ normalizeString(input.payload.credentialId)
409+ ?? existingBinding?.connectionId
410+ ?? (`conn_${crypto.randomUUID()}` as AuthConnection["id"])
411+ ) as AuthConnection["id"];
412+
413+ const existingConnection = snapshot.authConnections.find(
414+ (connection) => connection.id === requestedConnectionId,
415+ ) ?? null;
416+
417+ if (existingConnection && existingConnection.organizationId !== organizationId) {
418+ return yield* toSourceStoreError(
419+ "credentials.upsert",
420+ "Connection id belongs to another organization",
421+ `workspace=${input.workspaceId}`,
422+ );
423+ }
424+
425+ const nextConnection: AuthConnection = {
426+ id: requestedConnectionId,
427+ organizationId,
428+ workspaceId: scopeWorkspaceId,
429+ accountId: scopeAccountId,
430+ ownerType:
431+ input.payload.scopeType === "organization"
432+ ? "organization"
433+ : input.payload.scopeType === "account"
434+ ? "account"
435+ : "workspace",
436+ strategy: strategyFromProvider(input.payload.provider),
437+ displayName:
438+ normalizeString(existingConnection?.displayName)
439+ ?? sourceKeyFromSourceId(sourceId),
440+ status: "active",
441+ statusReason: null,
442+ lastAuthErrorClass: null,
443+ metadataJson: existingConnection?.metadataJson ?? null,
444+ additionalHeadersJson:
445+ input.payload.additionalHeadersJson !== undefined
446+ ? input.payload.additionalHeadersJson
447+ : existingConnection?.additionalHeadersJson ?? null,
448+ createdByAccountId: existingConnection?.createdByAccountId ?? null,
449+ createdAt: existingConnection?.createdAt ?? now,
450+ updatedAt: now,
451+ lastUsedAt: existingConnection?.lastUsedAt ?? null,
452+ };
453+
454+ const nextBinding: SourceAuthBinding = {
455+ id: resolvedBindingId,
456+ sourceId: sourceId as SourceAuthBinding["sourceId"],
457+ connectionId: requestedConnectionId,
192458 organizationId,
193− workspaceId:
194− input.payload.scopeType === "workspace" ? input.workspaceId : null,
195− accountId:
196− input.payload.scopeType === "account"
197− ? (input.payload.accountId ?? null)
198− : null,
459+ workspaceId: scopeWorkspaceId,
460+ accountId: scopeAccountId,
199461 scopeType: input.payload.scopeType,
200− sourceKey: input.payload.sourceKey,
201− provider: input.payload.provider,
202− secretProvider:
203− input.payload.secretProvider
204− ?? existing?.secretProvider
205− ?? "local",
206− secretRef: input.payload.secretRef,
207− additionalHeadersJson: input.payload.additionalHeadersJson ?? null,
208− boundAuthFingerprint: input.payload.boundAuthFingerprint ?? null,
209− createdAt: existing?.createdAt ?? now,
462+ selector: existingBinding?.selector ?? null,
463+ enabled: true,
464+ createdAt: existingBinding?.createdAt ?? now,
210465 updatedAt: now,
211466 };
212467
213− const nextSnapshot = existingIndex >= 0
214− ? replaceCredentialBindingAt(snapshot, existingIndex, nextBinding)
215− : appendCredentialBinding(snapshot, nextBinding);
468+ let nextSnapshot: LocalStateSnapshot = {
469+ ...snapshot,
470+ generatedAt: now,
471+ authConnections: upsertById(snapshot.authConnections, nextConnection),
472+ sourceAuthBindings: upsertById(snapshot.sourceAuthBindings, nextBinding),
473+ };
474+
475+ if (nextConnection.strategy === "oauth2") {
476+ const existingOAuth = snapshot.oauthStates.find(
477+ (state) => state.connectionId === requestedConnectionId,
478+ ) ?? null;
479+ const refreshConfig = buildOAuthRefreshConfigFromPayload(
480+ input.payload,
481+ parseOAuthRefreshConfig(existingOAuth?.refreshConfigJson ?? null),
482+ );
483+
484+ const oauthState: OAuthState = {
485+ id:
486+ existingOAuth?.id
487+ ?? (`oauth_state_${crypto.randomUUID()}` as OAuthState["id"]),
488+ connectionId: requestedConnectionId,
489+ accessTokenCiphertext: input.payload.secretRef,
490+ refreshTokenCiphertext:
491+ input.payload.oauthRefreshToken !== undefined
492+ ? normalizeString(input.payload.oauthRefreshToken)
493+ : existingOAuth?.refreshTokenCiphertext ?? null,
494+ keyVersion: existingOAuth?.keyVersion ?? "local",
495+ expiresAt:
496+ input.payload.oauthExpiresAt !== undefined
497+ ? input.payload.oauthExpiresAt
498+ : existingOAuth?.expiresAt ?? null,
499+ scope:
500+ input.payload.oauthScope !== undefined
501+ ? input.payload.oauthScope
502+ : existingOAuth?.scope ?? null,
503+ tokenType: existingOAuth?.tokenType ?? "Bearer",
504+ issuer:
505+ input.payload.oauthIssuer !== undefined
506+ ? input.payload.oauthIssuer
507+ : existingOAuth?.issuer ?? null,
508+ refreshConfigJson: encodeOAuthRefreshConfig(refreshConfig),
509+ tokenVersion: (existingOAuth?.tokenVersion ?? 0) + 1,
510+ leaseHolder: null,
511+ leaseExpiresAt: null,
512+ leaseFence: existingOAuth?.leaseFence ?? 0,
513+ lastRefreshAt: existingOAuth?.lastRefreshAt ?? null,
514+ lastRefreshErrorClass: null,
515+ lastRefreshError: null,
516+ reauthRequiredAt: null,
517+ createdAt: existingOAuth?.createdAt ?? now,
518+ updatedAt: now,
519+ };
520+
521+ nextSnapshot = {
522+ ...nextSnapshot,
523+ authMaterials: nextSnapshot.authMaterials.filter(
524+ (material) => material.connectionId !== requestedConnectionId,
525+ ),
526+ oauthStates: upsertById(nextSnapshot.oauthStates, oauthState),
527+ };
528+ } else {
529+ const existingMaterial = snapshot.authMaterials.find(
530+ (material) => material.connectionId === requestedConnectionId,
531+ ) ?? null;
532+
533+ const material: AuthMaterial = {
534+ id:
535+ existingMaterial?.id
536+ ?? (`auth_material_${crypto.randomUUID()}` as AuthMaterial["id"]),
537+ connectionId: requestedConnectionId,
538+ ciphertext: input.payload.secretRef,
539+ keyVersion: existingMaterial?.keyVersion ?? "local",
540+ createdAt: existingMaterial?.createdAt ?? now,
541+ updatedAt: now,
542+ };
543+
544+ nextSnapshot = {
545+ ...nextSnapshot,
546+ authMaterials: upsertById(nextSnapshot.authMaterials, material),
547+ oauthStates: nextSnapshot.oauthStates.filter(
548+ (state) => state.connectionId !== requestedConnectionId,
549+ ),
550+ };
551+ }
216552
217553 yield* localStateStore.writeSnapshot(nextSnapshot).pipe(
218554 Effect.mapError((error) =>
@@ -220,7 +556,7 @@ export const createPmCredentialsService = ( · 1 unmodified line
220556 ),
221557 );
222558
223− return nextBinding;
559+ return toCompatSourceCredentialBinding(nextBinding, nextConnection);
224560 }),
225561
226562 removeCredentialBinding: (input) =>
apps/pm/src/persistent-approval-policy.test.ts+4−2
@@ -28,8 +28,10 @@ const createTestSnapshot = (): LocalStateSnapshot => · 27 unmodified lines
2828 workspaces: [],
2929 sources: [],
3030 toolArtifacts: [],
31− credentialBindings: [],
32− oauthTokens: [],
31+ authConnections: [],
32+ sourceAuthBindings: [],
33+ authMaterials: [],
34+ oauthStates: [],
3335 policies: [],
3436 approvals: [],
3537 taskRuns: [],
apps/pm/src/storage-service.test.ts+4−2
@@ -38,8 +38,10 @@ const createTestSnapshot = (): LocalStateSnapshot => · 37 unmodified lines
3838 ],
3939 sources: [],
4040 toolArtifacts: [],
41− credentialBindings: [],
42− oauthTokens: [],
41+ authConnections: [],
42+ sourceAuthBindings: [],
43+ authMaterials: [],
44+ oauthStates: [],
4345 policies: [],
4446 approvals: [],
4547 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
149149 }
150150
151151 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;
152160 const accessToken = tokens?.access_token?.trim() ?? "";
153161
154162 if (accessToken.length === 0) {
@@ -165,5 +173,7 @@ export async function GET(request: NextRequest): Promise<Response> { · 10 unmodified lines
165173 refreshToken: tokens?.refresh_token,
166174 scope: tokens?.scope,
167175 expiresIn: typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined,
176+ clientId,
177+ clientInformationJson,
168178 });
169179 }
apps/web/app/mcp/oauth/complete/page.tsx+2−0
@@ -12,6 +12,8 @@ type PopupMessage = · 11 unmodified lines
1212 refreshToken?: string;
1313 scope?: string;
1414 expiresIn?: number;
15+ clientId?: string;
16+ clientInformationJson?: string;
1517 };
1618 }
1719 | {
apps/web/app/mcp/oauth/result/route.ts+6−0
@@ -17,6 +17,8 @@ type PopupMessage = · 16 unmodified lines
1717 refreshToken?: string;
1818 scope?: string;
1919 expiresIn?: number;
20+ clientId?: string;
21+ clientInformationJson?: string;
2022 };
2123 }
2224 | {
@@ -62,6 +64,10 @@ const toPopupMessage = (result: McpOAuthPopupResult | null): PopupMessage => { · 39 unmodified lines
6264 ...(typeof result.expiresIn === "number"
6365 ? { expiresIn: result.expiresIn }
6466 : {}),
67+ ...(result.clientId ? { clientId: result.clientId } : {}),
68+ ...(result.clientInformationJson
69+ ? { clientInformationJson: result.clientInformationJson }
70+ : {}),
6571 },
6672 };
6773 };
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
110110
111111 if (authResult === "AUTHORIZED") {
112112 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;
113121 const accessToken = tokens?.access_token?.trim() ?? "";
114122 if (accessToken.length === 0) {
115123 return badPopupResponse(
@@ -126,6 +134,8 @@ export async function GET(request: NextRequest): Promise<Response> { · 10 unmodified lines
126134 scope: tokens?.scope,
127135 expiresIn:
128136 typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined,
137+ clientId,
138+ clientInformationJson,
129139 });
130140 }
131141
apps/web/components/approvals/approvals-view.tsx+5−6
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
99 approvalsByWorkspace,
1010 optimisticResolveApproval,
1111 resolveApproval,
12+ toResolveApprovalRequest,
1213 } from "../../lib/control-plane/atoms";
1314 import { Button } from "../ui/button";
1415 import {
@@ -137,16 +138,14 @@ export function ApprovalsView() { · 122 unmodified lines
137138
138139 setOptimisticApprovals(nextOptimistic);
139140
140− void runResolveApproval({
141− path: {
142− workspaceId,
143− approvalId,
144− },
141+ void runResolveApproval(toResolveApprovalRequest({
142+ workspaceId,
143+ approvalId,
145144 payload: {
146145 status,
147146 reason: null,
148147 },
149− })
148+ }))
150149 .then(() => {
151150 setStatus(status === "approved" ? "Approval granted." : "Approval denied.");
152151 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
1010 import {
1111 credentialBindingsByWorkspace,
1212 removeCredentialBinding,
13+ toCredentialBindingRemoveRequest,
1314 toCredentialBindingRemoveResult,
15+ toCredentialBindingUpsertRequest,
1416 toCredentialBindingUpsertPayload,
1517 upsertCredentialBinding,
1618 } from "../../lib/control-plane/atoms";
@@ -93,7 +95,6 @@ export function CredentialsView() { · 76 unmodified lines
9395 binding.provider.toLowerCase().includes(query) ||
9496 binding.scopeType.toLowerCase().includes(query) ||
9597 binding.credentialId.toLowerCase().includes(query) ||
96− binding.secretRef.toLowerCase().includes(query) ||
9798 (binding.accountId ?? "").toLowerCase().includes(query)
9899 );
99100 });
@@ -133,11 +134,11 @@ export function CredentialsView() { · 33 unmodified lines
133134 setCredentialProvider(binding.provider);
134135 setCredentialScopeType(binding.scopeType);
135136 setCredentialIdInput(binding.credentialId);
136− setCredentialSecretRef(binding.secretRef);
137+ setCredentialSecretRef("");
137138 setCredentialAccountId(binding.accountId ?? "");
138139 setCredentialAdditionalHeadersJson(binding.additionalHeadersJson ?? "");
139140 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.`);
141142 };
142143
143144 const handleCancelCredentialEdit = () => {
@@ -175,8 +176,8 @@ export function CredentialsView() { · 31 unmodified lines
175176
176177 setCredentialBusyId(requestId);
177178
178− void runUpsertCredentialBinding({
179− path: { workspaceId },
179+ void runUpsertCredentialBinding(toCredentialBindingUpsertRequest({
180+ workspaceId,
180181 payload: toCredentialBindingUpsertPayload({
181182 id: credentialEditingId ?? undefined,
182183 credentialId: credentialId as SourceCredentialBinding["credentialId"],
@@ -191,7 +192,7 @@ export function CredentialsView() { · 8 unmodified lines
191192 additionalHeadersJson: additionalHeadersJson.length > 0 ? additionalHeadersJson : null,
192193 boundAuthFingerprint: boundAuthFingerprint.length > 0 ? boundAuthFingerprint : null,
193194 }),
194− })
195+ }))
195196 .then(() => {
196197 setInfoStatus(
197198 credentialEditingId
@@ -215,12 +216,9 @@ export function CredentialsView() { · 17 unmodified lines
215216
216217 setCredentialBusyId(credentialBindingId);
217218
218− void runRemoveCredentialBinding({
219− path: {
220− workspaceId,
221− credentialBindingId,
222− },
223− })
219+ void runRemoveCredentialBinding(
220+ toCredentialBindingRemoveRequest({ workspaceId, credentialBindingId }),
221+ )
224222 .then((result) => {
225223 const removed = toCredentialBindingRemoveResult(result);
226224 setInfoStatus(
@@ -444,7 +442,7 @@ export function CredentialsView() { · 217 unmodified lines
444442 id="credential-search"
445443 value={credentialSearchQuery}
446444 onChange={(event) => setCredentialSearchQuery(event.target.value)}
447− placeholder="source key, provider, scope, credential id, secret"
445+ placeholder="source key, provider, scope, credential id"
448446 disabled={credentialBindingsState.state === "loading"}
449447 />
450448 </div>
apps/web/components/policies/policies-view.tsx+9−10
@@ -11,8 +11,9 @@ import { · 10 unmodified lines
1111 optimisticUpsertPolicy,
1212 policiesByWorkspace,
1313 removePolicy,
14+ toPolicyRemoveRequest,
1415 toPolicyRemoveResult,
15− toPolicyUpsertPayload,
16+ toPolicyUpsertRequest,
1617 upsertPolicy,
1718 } from "../../lib/control-plane/atoms";
1819 import { Badge } from "../ui/badge";
@@ -107,14 +108,12 @@ export function PoliciesView() { · 88 unmodified lines
107108 setPolicyBusyId(policyId);
108109 setOptimisticPolicies(nextPolicies);
109110
110− void runUpsertPolicy({
111− path: { workspaceId },
112− payload: toPolicyUpsertPayload({
113− id: policyEditingId ?? undefined,
114− toolPathPattern,
115− decision: policyDecision,
116− }),
117− })
111+ void runUpsertPolicy(toPolicyUpsertRequest({
112+ workspaceId,
113+ id: policyEditingId ?? undefined,
114+ toolPathPattern,
115+ decision: policyDecision,
116+ }))
118117 .then(() => {
119118 setStatus(policyEditingId ? `Updated policy ${toolPathPattern}.` : `Added policy ${toolPathPattern}.`);
120119 clearPolicyForm();
@@ -136,7 +135,7 @@ export function PoliciesView() { · 15 unmodified lines
136135 setPolicyBusyId(policyId);
137136 setOptimisticPolicies(nextPolicies);
138137
139− void runRemovePolicy({ path: { workspaceId, policyId } })
138+ void runRemovePolicy(toPolicyRemoveRequest({ workspaceId, policyId }))
140139 .then((result) => {
141140 const removed = toPolicyRemoveResult(result);
142141 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
77 import { useWorkspace } from "../../lib/hooks/use-workspace";
88 import {
99 organizationsState,
10+ toUpsertOrganizationRequest,
11+ toUpsertWorkspaceRequest,
1012 upsertOrganization,
1113 upsertWorkspace,
1214 toOrganizationUpsertPayload,
@@ -168,7 +170,7 @@ export default function SettingsView() { · 155 unmodified lines
168170 status: organizationForm.status,
169171 });
170172
171− void runUpsertOrganization({ payload })
173+ void runUpsertOrganization(toUpsertOrganizationRequest({ payload }))
172174 .then((organization) => {
173175 setOrganizationStatusText(`Saved organization ${organization.slug}.`);
174176 setOrganizationForm({
@@ -209,7 +211,7 @@ export default function SettingsView() { · 34 unmodified lines
209211 organizationId: organizationId as Workspace["organizationId"],
210212 });
211213
212− void runUpsertWorkspace({ payload })
214+ void runUpsertWorkspace(toUpsertWorkspaceRequest({ payload }))
213215 .then((workspace) => {
214216 setWorkspaceStatusText(`Saved workspace ${workspace.name}.`);
215217 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
66
77 import {
88 organizationsState,
9+ toUpsertOrganizationRequest,
10+ toUpsertWorkspaceRequest,
911 toOrganizationUpsertPayload,
1012 toWorkspaceUpsertPayload,
1113 upsertOrganization,
@@ -278,13 +280,15 @@ export function WorkspaceSelector() { · 266 unmodified lines
278280
279281 setCreatingWorkspace(true);
280282
281− void runUpsertWorkspace({
282− payload: toWorkspaceUpsertPayload({
283− id: createWorkspaceId(),
284− name,
285− organizationId: organizationId as Workspace["organizationId"],
283+ void runUpsertWorkspace(
284+ toUpsertWorkspaceRequest({
285+ payload: toWorkspaceUpsertPayload({
286+ id: createWorkspaceId(),
287+ name,
288+ organizationId: organizationId as Workspace["organizationId"],
289+ }),
286290 }),
287− })
291+ )
288292 .then((workspace) => {
289293 setWorkspaceId(workspace.id);
290294 setWorkspaceName("");
@@ -334,23 +338,27 @@ export function WorkspaceSelector() { · 43 unmodified lines
334338
335339 const organizationId = createOrganizationId();
336340
337− void runUpsertOrganization({
338− payload: toOrganizationUpsertPayload({
339− id: organizationId,
340− name,
341− slug,
342− status: "active",
341+ void runUpsertOrganization(
342+ toUpsertOrganizationRequest({
343+ payload: toOrganizationUpsertPayload({
344+ id: organizationId,
345+ name,
346+ slug,
347+ status: "active",
348+ }),
343349 }),
344− })
350+ )
345351 .then(async (organization) => {
346352 try {
347− const workspace = await runUpsertWorkspace({
348− payload: toWorkspaceUpsertPayload({
349− id: createWorkspaceId(),
350− name,
351− organizationId: organization.id as Workspace["organizationId"],
353+ const workspace = await runUpsertWorkspace(
354+ toUpsertWorkspaceRequest({
355+ payload: toWorkspaceUpsertPayload({
356+ id: createWorkspaceId(),
357+ name,
358+ organizationId: organization.id as Workspace["organizationId"],
359+ }),
352360 }),
353− });
361+ );
354362
355363 setWorkspaceId(workspace.id);
356364 setWorkspaceOrganizationId(organization.id);
apps/web/components/storage/storage-view.tsx+20−19
@@ -24,11 +24,14 @@ import { · 23 unmodified lines
2424 readStorageFile,
2525 removeStorageInstance,
2626 storageByWorkspace,
27+ toCloseStorageRequest,
2728 toListStorageDirectoryPayload,
2829 toListStorageKvPayload,
2930 toOpenStoragePayload,
31+ toOpenStorageRequest,
3032 toQueryStorageSqlPayload,
3133 toReadStorageFilePayload,
34+ toRemoveStorageRequest,
3235 toStorageRemoveResult,
3336 } from "../../lib/control-plane/atoms"
3437 import { Badge } from "../ui/badge"
@@ -216,20 +219,22 @@ export default function StorageView() { · 181 unmodified lines
216219
217220 setStorageBusyId("create")
218221
219− void runOpenStorageInstance({
220− path: { workspaceId },
221− payload: toOpenStoragePayload({
222− scopeType: storageScopeType,
223− durability: storageDurability,
224− provider: storageProvider,
225− purpose: purpose.length > 0 ? purpose : undefined,
226− ttlHours: storageDurability === "ephemeral" ? ttlHours : undefined,
227− accountId:
228− storageScopeType === "account"
229− ? (accountId as Exclude<StorageInstance["accountId"], null>)
230− : undefined,
222+ void runOpenStorageInstance(
223+ toOpenStorageRequest({
224+ workspaceId,
225+ payload: toOpenStoragePayload({
226+ scopeType: storageScopeType,
227+ durability: storageDurability,
228+ provider: storageProvider,
229+ purpose: purpose.length > 0 ? purpose : undefined,
230+ ttlHours: storageDurability === "ephemeral" ? ttlHours : undefined,
231+ accountId:
232+ storageScopeType === "account"
233+ ? (accountId as Exclude<StorageInstance["accountId"], null>)
234+ : undefined,
235+ }),
231236 }),
232− })
237+ )
233238 .then((storageInstance) => {
234239 setStorageSelectedId(storageInstance.id)
235240 setStoragePurposeInput("")
@@ -253,9 +258,7 @@ export default function StorageView() { · 17 unmodified lines
253258
254259 setStorageBusyId(storageInstanceId)
255260
256− void runCloseStorageInstance({
257− path: { workspaceId, storageInstanceId },
258− })
261+ void runCloseStorageInstance(toCloseStorageRequest({ workspaceId, storageInstanceId }))
259262 .then(() => {
260263 setInfoStatus("Storage instance closed.")
261264 })
@@ -274,9 +277,7 @@ export default function StorageView() { · 12 unmodified lines
274277
275278 setStorageBusyId(storageInstanceId)
276279
277− void runRemoveStorageInstance({
278− path: { workspaceId, storageInstanceId },
279− })
280+ void runRemoveStorageInstance(toRemoveStorageRequest({ workspaceId, storageInstanceId }))
280281 .then((result) => {
281282 const removed = toStorageRemoveResult(result)
282283 setInfoStatus(
apps/web/components/tools/source-management-panel.tsx+34−15
@@ -14,7 +14,10 @@ import { · 13 unmodified lines
1414 removeSource,
1515 sourcesByWorkspace,
1616 sourcesPendingByWorkspace,
17+ toCredentialBindingUpsertRequest,
1718 toCredentialBindingUpsertPayload,
19+ toRemoveSourceRequest,
20+ toUpsertSourceRequest,
1821 upsertCredentialBinding,
1922 upsertSource,
2023 } from "../../lib/control-plane/atoms";
@@ -468,7 +471,7 @@ export function SourceManagementPanel({ editSource, onDone }: SourceManagementPa · 447 unmodified lines
468471
469472 setOptimisticSources({ items: optimistic.items, pendingAck: { kind: "upsert", sourceId: optimistic.sourceId } });
470473
471− void runUpsertSource({ path: { workspaceId }, payload })
474+ void runUpsertSource(toUpsertSourceRequest({ workspaceId, payload }))
472475 .then(async () => {
473476 let oauthLinked = false;
474477 let oauthLinkNote: string | null = null;
@@ -482,21 +485,37 @@ export function SourceManagementPanel({ editSource, onDone }: SourceManagementPa · 7 unmodified lines
482485 if (formState.authMode === "account") {
483486 oauthLinkNote = "Account scope is not supported from this flow; OAuth credential was saved as workspace scope.";
484487 }
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;
485494 try {
486− await runUpsertCredentialBinding({
487− path: { workspaceId },
488− payload: toCredentialBindingUpsertPayload({
489− ...(existingBinding ? { id: existingBinding.id } : {}),
490− credentialId: (existingBinding?.credentialId ?? createLocalId("cred_")) as SourceCredentialBinding["credentialId"],
491− scopeType,
492− sourceKey,
493− provider: "oauth2",
494− secretRef: oauthSessionForEndpoint.accessToken,
495− accountId: null,
496− additionalHeadersJson: null,
497− boundAuthFingerprint: null,
495+ await runUpsertCredentialBinding(
496+ toCredentialBindingUpsertRequest({
497+ workspaceId,
498+ payload: toCredentialBindingUpsertPayload({
499+ ...(existingBinding ? { id: existingBinding.id } : {}),
500+ credentialId: (existingBinding?.credentialId ?? createLocalId("cred_")) as SourceCredentialBinding["credentialId"],
501+ scopeType,
502+ sourceKey,
503+ provider: "oauth2",
504+ secretRef: oauthSessionForEndpoint.accessToken,
505+ accountId: null,
506+ additionalHeadersJson: null,
507+ boundAuthFingerprint: null,
508+ oauthRefreshToken: oauthSessionForEndpoint.refreshToken ?? null,
509+ oauthExpiresAt,
510+ oauthScope: oauthSessionForEndpoint.scope ?? null,
511+ oauthAuthorizationServer,
512+ oauthClientId: oauthSessionForEndpoint.clientId ?? null,
513+ oauthSourceUrl: oauthSessionForEndpoint.sourceUrl,
514+ oauthClientInformationJson:
515+ oauthSessionForEndpoint.clientInformationJson ?? null,
516+ }),
498517 }),
499− });
518+ );
500519 oauthLinked = true;
501520 setMcpOAuthSession(null);
502521 } catch {
@@ -526,7 +545,7 @@ export function SourceManagementPanel({ editSource, onDone }: SourceManagementPa · 23 unmodified lines
526545 const optimistic = optimisticRemoveSources(previousSources, sourceId);
527546 setOptimisticSources({ items: optimistic.items, pendingAck: { kind: "remove", sourceId: optimistic.sourceId } });
528547
529− void runRemoveSource({ path: { workspaceId, sourceId } })
548+ void runRemoveSource(toRemoveSourceRequest({ workspaceId, sourceId }))
530549 .then(() => {
531550 setStatusText("Source removed.");
532551 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
44
55 import { controlPlaneClient } from "../client";
66 import { workspaceEntity, type EntityState } from "./entity";
7−import { approvalsKeys } from "./keys";
7+import { approvalsKeys, approvalsMutationKeys } from "./keys";
88
99 // ---------------------------------------------------------------------------
1010 // Query atoms
@@ -14,7 +14,7 @@ export const approvalsResultByWorkspace = Atom.family( · 3 unmodified lines
1414 (workspaceId: WorkspaceId) =>
1515 controlPlaneClient.query("approvals", "list", {
1616 path: { workspaceId },
17− reactivityKeys: approvalsKeys,
17+ reactivityKeys: approvalsKeys(workspaceId),
1818 }),
1919 );
2020
@@ -49,6 +49,19 @@ export const approvalPendingByWorkspace = Atom.family((workspaceId: WorkspaceId) · 28 unmodified lines
4949
5050 export const resolveApproval = controlPlaneClient.mutation("approvals", "resolve");
5151
52+export const toResolveApprovalRequest = (input: {
53+ workspaceId: WorkspaceId;
54+ approvalId: ApprovalId;
55+ payload: ResolveApprovalPayload;
56+}) => ({
57+ path: {
58+ workspaceId: input.workspaceId,
59+ approvalId: input.approvalId,
60+ },
61+ payload: input.payload,
62+ reactivityKeys: approvalsMutationKeys(input.workspaceId),
63+});
64+
5265 // ---------------------------------------------------------------------------
5366 // Optimistic helpers
5467 // ---------------------------------------------------------------------------
apps/web/lib/control-plane/atoms/credentials.ts+58−2
@@ -13,7 +13,7 @@ import type { · 12 unmodified lines
1313
1414 import { controlPlaneClient } from "../client";
1515 import { workspaceEntity, type EntityState } from "./entity";
16−import { credentialsKeys } from "./keys";
16+import { credentialsKeys, credentialsMutationKeys } from "./keys";
1717
1818 // ---------------------------------------------------------------------------
1919 // Query atoms
@@ -23,7 +23,7 @@ export const credentialBindingsResultByWorkspace = Atom.family( · 3 unmodified lines
2323 (workspaceId: WorkspaceId) =>
2424 controlPlaneClient.query("credentials", "list", {
2525 path: { workspaceId },
26− reactivityKeys: credentialsKeys,
26+ reactivityKeys: credentialsKeys(workspaceId),
2727 }),
2828 );
2929
@@ -64,6 +64,16 @@ export const toCredentialBindingUpsertPayload = (input: { · 34 unmodified lines
6464 accountId?: SourceCredentialBinding["accountId"];
6565 additionalHeadersJson?: SourceCredentialBinding["additionalHeadersJson"];
6666 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;
6777 }): UpsertCredentialBindingPayload => ({
6878 ...(input.id ? { id: input.id } : {}),
6979 credentialId: input.credentialId,
@@ -78,6 +88,52 @@ export const toCredentialBindingUpsertPayload = (input: { · 8 unmodified lines
7888 ...(input.boundAuthFingerprint !== undefined
7989 ? { boundAuthFingerprint: input.boundAuthFingerprint }
8090 : {}),
91+ ...(input.oauthRefreshToken !== undefined
92+ ? { oauthRefreshToken: input.oauthRefreshToken }
93+ : {}),
94+ ...(input.oauthExpiresAt !== undefined
95+ ? { oauthExpiresAt: input.oauthExpiresAt }
96+ : {}),
97+ ...(input.oauthScope !== undefined ? { oauthScope: input.oauthScope } : {}),
98+ ...(input.oauthIssuer !== undefined ? { oauthIssuer: input.oauthIssuer } : {}),
99+ ...(input.oauthTokenEndpoint !== undefined
100+ ? { oauthTokenEndpoint: input.oauthTokenEndpoint }
101+ : {}),
102+ ...(input.oauthAuthorizationServer !== undefined
103+ ? { oauthAuthorizationServer: input.oauthAuthorizationServer }
104+ : {}),
105+ ...(input.oauthClientId !== undefined
106+ ? { oauthClientId: input.oauthClientId }
107+ : {}),
108+ ...(input.oauthClientSecret !== undefined
109+ ? { oauthClientSecret: input.oauthClientSecret }
110+ : {}),
111+ ...(input.oauthSourceUrl !== undefined
112+ ? { oauthSourceUrl: input.oauthSourceUrl }
113+ : {}),
114+ ...(input.oauthClientInformationJson !== undefined
115+ ? { oauthClientInformationJson: input.oauthClientInformationJson }
116+ : {}),
117+});
118+
119+export const toCredentialBindingUpsertRequest = (input: {
120+ workspaceId: WorkspaceId;
121+ payload: UpsertCredentialBindingPayload;
122+}) => ({
123+ path: { workspaceId: input.workspaceId },
124+ payload: input.payload,
125+ reactivityKeys: credentialsMutationKeys(input.workspaceId),
126+});
127+
128+export const toCredentialBindingRemoveRequest = (input: {
129+ workspaceId: WorkspaceId;
130+ credentialBindingId: SourceCredentialBinding["id"];
131+}) => ({
132+ path: {
133+ workspaceId: input.workspaceId,
134+ credentialBindingId: input.credentialBindingId,
135+ },
136+ reactivityKeys: credentialsMutationKeys(input.workspaceId),
81137 });
82138
83139 export const toCredentialBindingRemoveResult = (
apps/web/lib/control-plane/atoms/index.ts+12−0
@@ -25,6 +25,8 @@ export { · 24 unmodified lines
2525 removeSource,
2626 optimisticUpsertSources,
2727 optimisticRemoveSources,
28+ toUpsertSourceRequest,
29+ toRemoveSourceRequest,
2830 } from "./sources";
2931
3032 export {
@@ -40,6 +42,7 @@ export { · 9 unmodified lines
4042 approvalPendingByWorkspace,
4143 resolveApproval,
4244 optimisticResolveApproval,
45+ toResolveApprovalRequest,
4346 } from "./approvals";
4447
4548 export {
@@ -50,6 +53,8 @@ export { · 4 unmodified lines
5053 optimisticUpsertPolicy,
5154 optimisticRemovePolicy,
5255 toPolicyUpsertPayload,
56+ toPolicyUpsertRequest,
57+ toPolicyRemoveRequest,
5358 toPolicyRemoveResult,
5459 } from "./policies";
5560
@@ -59,6 +64,8 @@ export { · 3 unmodified lines
5964 upsertCredentialBinding,
6065 removeCredentialBinding,
6166 toCredentialBindingUpsertPayload,
67+ toCredentialBindingUpsertRequest,
68+ toCredentialBindingRemoveRequest,
6269 toCredentialBindingRemoveResult,
6370 } from "./credentials";
6471
@@ -73,6 +80,9 @@ export { · 8 unmodified lines
7380 listStorageKv,
7481 queryStorageSql,
7582 toOpenStoragePayload,
83+ toOpenStorageRequest,
84+ toCloseStorageRequest,
85+ toRemoveStorageRequest,
7686 toStorageRemoveResult,
7787 toListStorageDirectoryPayload,
7888 toReadStorageFilePayload,
@@ -101,8 +111,10 @@ export { · 22 unmodified lines
101111 organizationsState,
102112 upsertOrganization,
103113 toOrganizationUpsertPayload,
114+ toUpsertOrganizationRequest,
104115 workspacesResult,
105116 workspacesState,
106117 upsertWorkspace,
107118 toWorkspaceUpsertPayload,
119+ toUpsertWorkspaceRequest,
108120 } from "./settings";
apps/web/lib/control-plane/atoms/keys.ts+55−12
@@ -1,22 +1,65 @@
1+import type { WorkspaceId } from "@executor-v2/schema";
2+
13 // ---------------------------------------------------------------------------
24 // Reactivity keys
35 //
46 // 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.
68 //
7−// Following the opencode-control pattern: each domain declares its own keys
8−// and spreads a shared workspace key so that switching workspace invalidates
9−// all workspace-scoped data.
9+// Workspace-scoped keys include the concrete workspace id so invalidation stays
10+// precise instead of refreshing unrelated workspace queries.
1011 // ---------------------------------------------------------------------------
1112
12−export const workspaceKeys = { workspace: ["selected"] } as const;
13+const workspaceScope = (workspaceId: WorkspaceId) => ({
14+ workspace: [workspaceId],
15+} as const);
16+
17+export const sourcesKeys = (workspaceId: WorkspaceId) => ({
18+ ...workspaceScope(workspaceId),
19+ sources: ["list"],
20+} as const);
21+
22+export const toolsKeys = (workspaceId: WorkspaceId) => ({
23+ ...workspaceScope(workspaceId),
24+ tools: ["list"],
25+} as const);
26+
27+export const toolDetailKeys = (workspaceId: WorkspaceId) => ({
28+ ...workspaceScope(workspaceId),
29+ toolDetail: ["single"],
30+} as const);
31+
32+export const approvalsKeys = (workspaceId: WorkspaceId) => ({
33+ ...workspaceScope(workspaceId),
34+ approvals: ["list"],
35+} as const);
36+
37+export const policiesKeys = (workspaceId: WorkspaceId) => ({
38+ ...workspaceScope(workspaceId),
39+ policies: ["list"],
40+} as const);
41+
42+export const credentialsKeys = (workspaceId: WorkspaceId) => ({
43+ ...workspaceScope(workspaceId),
44+ credentials: ["list"],
45+} as const);
46+
47+export const storageKeys = (workspaceId: WorkspaceId) => ({
48+ ...workspaceScope(workspaceId),
49+ storage: ["list"],
50+} as const);
51+
52+export const sourceMutationKeys = (workspaceId: WorkspaceId) => ({
53+ ...workspaceScope(workspaceId),
54+ sources: ["list"],
55+ tools: ["list"],
56+ toolDetail: ["single"],
57+} as const);
58+
59+export const approvalsMutationKeys = approvalsKeys;
60+export const policiesMutationKeys = policiesKeys;
61+export const credentialsMutationKeys = credentialsKeys;
62+export const storageMutationKeys = storageKeys;
1363
14−export const sourcesKeys = { ...workspaceKeys, sources: ["list"] } as const;
15−export const toolsKeys = { ...workspaceKeys, tools: ["list"] } as const;
16−export const toolDetailKeys = { ...workspaceKeys, toolDetail: ["single"] } as const;
17−export const approvalsKeys = { ...workspaceKeys, approvals: ["list"] } as const;
18−export const policiesKeys = { ...workspaceKeys, policies: ["list"] } as const;
19−export const credentialsKeys = { ...workspaceKeys, credentials: ["list"] } as const;
20−export const storageKeys = { ...workspaceKeys, storage: ["list"] } as const;
2164 export const organizationsKeys = { organizations: ["list"] } as const;
2265 export 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
44
55 import { controlPlaneClient } from "../client";
66 import { workspaceEntity, type EntityState } from "./entity";
7−import { policiesKeys } from "./keys";
7+import { policiesKeys, policiesMutationKeys } from "./keys";
88
99 // ---------------------------------------------------------------------------
1010 // Query atoms
@@ -14,7 +14,7 @@ export const policiesResultByWorkspace = Atom.family( · 3 unmodified lines
1414 (workspaceId: WorkspaceId) =>
1515 controlPlaneClient.query("policies", "list", {
1616 path: { workspaceId },
17− reactivityKeys: policiesKeys,
17+ reactivityKeys: policiesKeys(workspaceId),
1818 }),
1919 );
2020
@@ -87,6 +87,32 @@ export const toPolicyUpsertPayload = (input: { · 66 unmodified lines
8787 decision: input.decision,
8888 });
8989
90+export const toPolicyUpsertRequest = (input: {
91+ workspaceId: WorkspaceId;
92+ id?: PolicyId;
93+ toolPathPattern: string;
94+ decision: PolicyDecision;
95+}) => ({
96+ path: { workspaceId: input.workspaceId },
97+ payload: toPolicyUpsertPayload({
98+ id: input.id,
99+ toolPathPattern: input.toolPathPattern,
100+ decision: input.decision,
101+ }),
102+ reactivityKeys: policiesMutationKeys(input.workspaceId),
103+});
104+
105+export const toPolicyRemoveRequest = (input: {
106+ workspaceId: WorkspaceId;
107+ policyId: PolicyId;
108+}) => ({
109+ path: {
110+ workspaceId: input.workspaceId,
111+ policyId: input.policyId,
112+ },
113+ reactivityKeys: policiesMutationKeys(input.workspaceId),
114+});
115+
90116 export const toPolicyRemoveResult = (result: RemovePolicyResult): boolean => result.removed;
91117
92118 export 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
4343 ...(input.status !== undefined ? { status: input.status } : {}),
4444 });
4545
46+export const toUpsertOrganizationRequest = (input: {
47+ payload: UpsertOrganizationPayload;
48+}) => ({
49+ payload: input.payload,
50+ reactivityKeys: organizationsKeys,
51+});
52+
4653 // ---------------------------------------------------------------------------
4754 // Workspaces
4855 // ---------------------------------------------------------------------------
@@ -74,5 +81,12 @@ export const toWorkspaceUpsertPayload = (input: { · 25 unmodified lines
7481 name: input.name,
7582 });
7683
84+export const toUpsertWorkspaceRequest = (input: {
85+ payload: UpsertWorkspacePayload;
86+}) => ({
87+ payload: input.payload,
88+ reactivityKeys: workspacesKeys,
89+});
90+
7791 export type OrganizationsState = EntityState<Organization>;
7892 export 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
55
66 import { controlPlaneClient } from "../client";
77 import { stateFromResult, type EntityState } from "./entity";
8−import { sourcesKeys } from "./keys";
8+import { sourceMutationKeys, sourcesKeys } from "./keys";
99
1010 // ---------------------------------------------------------------------------
1111 // Query atoms
@@ -15,7 +15,7 @@ export const sourcesResultByWorkspace = Atom.family( · 3 unmodified lines
1515 (workspaceId: WorkspaceId) =>
1616 controlPlaneClient.query("sources", "list", {
1717 path: { workspaceId },
18− reactivityKeys: sourcesKeys,
18+ reactivityKeys: sourcesKeys(workspaceId),
1919 }),
2020 );
2121
@@ -97,6 +97,26 @@ export const sourcesByWorkspace = Atom.family((workspaceId: WorkspaceId) => · 75 unmodified lines
9797 export const upsertSource = controlPlaneClient.mutation("sources", "upsert");
9898 export const removeSource = controlPlaneClient.mutation("sources", "remove");
9999
100+export const toUpsertSourceRequest = (input: {
101+ workspaceId: WorkspaceId;
102+ payload: UpsertSourcePayload;
103+}) => ({
104+ path: { workspaceId: input.workspaceId },
105+ payload: input.payload,
106+ reactivityKeys: sourceMutationKeys(input.workspaceId),
107+});
108+
109+export const toRemoveSourceRequest = (input: {
110+ workspaceId: WorkspaceId;
111+ sourceId: SourceId;
112+}) => ({
113+ path: {
114+ workspaceId: input.workspaceId,
115+ sourceId: input.sourceId,
116+ },
117+ reactivityKeys: sourceMutationKeys(input.workspaceId),
118+});
119+
100120 // ---------------------------------------------------------------------------
101121 // Optimistic helpers
102122 // ---------------------------------------------------------------------------
apps/web/lib/control-plane/atoms/storage.ts+33−2
@@ -20,7 +20,7 @@ import type { · 19 unmodified lines
2020
2121 import { controlPlaneClient } from "../client";
2222 import { workspaceEntity, type EntityState } from "./entity";
23−import { storageKeys } from "./keys";
23+import { storageKeys, storageMutationKeys } from "./keys";
2424
2525 // ---------------------------------------------------------------------------
2626 // Query atoms
@@ -30,7 +30,7 @@ export const storageResultByWorkspace = Atom.family( · 3 unmodified lines
3030 (workspaceId: WorkspaceId) =>
3131 controlPlaneClient.query("storage", "list", {
3232 path: { workspaceId },
33− reactivityKeys: storageKeys,
33+ reactivityKeys: storageKeys(workspaceId),
3434 }),
3535 );
3636
@@ -82,6 +82,37 @@ export const toOpenStoragePayload = (input: { · 45 unmodified lines
8282 ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}),
8383 });
8484
85+export const toOpenStorageRequest = (input: {
86+ workspaceId: WorkspaceId;
87+ payload: OpenStorageInstancePayload;
88+}) => ({
89+ path: { workspaceId: input.workspaceId },
90+ payload: input.payload,
91+ reactivityKeys: storageMutationKeys(input.workspaceId),
92+});
93+
94+export const toCloseStorageRequest = (input: {
95+ workspaceId: WorkspaceId;
96+ storageInstanceId: StorageInstance["id"];
97+}) => ({
98+ path: {
99+ workspaceId: input.workspaceId,
100+ storageInstanceId: input.storageInstanceId,
101+ },
102+ reactivityKeys: storageMutationKeys(input.workspaceId),
103+});
104+
105+export const toRemoveStorageRequest = (input: {
106+ workspaceId: WorkspaceId;
107+ storageInstanceId: StorageInstance["id"];
108+}) => ({
109+ path: {
110+ workspaceId: input.workspaceId,
111+ storageInstanceId: input.storageInstanceId,
112+ },
113+ reactivityKeys: storageMutationKeys(input.workspaceId),
114+});
115+
85116 export const toStorageRemoveResult = (result: RemoveStorageInstanceResult): boolean =>
86117 result.removed;
87118
apps/web/lib/control-plane/atoms/tools.ts+3−3
@@ -15,7 +15,7 @@ export const workspaceToolsResultByWorkspace = Atom.family( · 14 unmodified lines
1515 (workspaceId: WorkspaceId) =>
1616 controlPlaneClient.query("tools", "listWorkspaceTools", {
1717 path: { workspaceId },
18− reactivityKeys: toolsKeys,
18+ reactivityKeys: toolsKeys(workspaceId),
1919 }),
2020 );
2121
@@ -23,7 +23,7 @@ export const sourceToolsResultBySource = Atom.family( · 1 unmodified line
2323 (input: { workspaceId: WorkspaceId; sourceId: SourceId }) =>
2424 controlPlaneClient.query("tools", "listSourceTools", {
2525 path: { workspaceId: input.workspaceId, sourceId: input.sourceId },
26− reactivityKeys: toolsKeys,
26+ reactivityKeys: toolsKeys(input.workspaceId),
2727 }),
2828 );
2929
@@ -35,7 +35,7 @@ export const toolDetailResult = Atom.family( · 5 unmodified lines
3535 sourceId: input.sourceId,
3636 operationHash: input.operationHash,
3737 },
38− reactivityKeys: toolDetailKeys,
38+ reactivityKeys: toolDetailKeys(input.workspaceId),
3939 }),
4040 );
4141
apps/web/lib/control-plane/legacy-source.ts+0−25
@@ -162,39 +162,16 @@ export const buildSourceConfig = (input: { · 161 unmodified lines
162162 }): Record<string, unknown> => {
163163 const endpoint = input.endpoint.trim();
164164
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−
184165 if (input.type === "mcp") {
185166 return {
186167 url: endpoint,
187168 ...(input.mcpTransport !== "auto" ? { transport: input.mcpTransport } : {}),
188− ...(auth ? { auth } : {}),
189− ...(input.authType !== "none" ? { useCredentialedFetch: true } : {}),
190169 };
191170 }
192171
193172 if (input.type === "graphql") {
194173 return {
195174 endpoint,
196− ...(auth ? { auth } : {}),
197− ...(input.authType !== "none" ? { useCredentialedFetch: true } : {}),
198175 };
199176 }
200177
@@ -202,8 +179,6 @@ export const buildSourceConfig = (input: { · 1 unmodified line
202179 spec: endpoint,
203180 specUrl: endpoint,
204181 ...(input.baseUrl.trim().length > 0 ? { baseUrl: input.baseUrl.trim() } : {}),
205− ...(auth ? { auth } : {}),
206− ...(input.authType !== "none" ? { useCredentialedFetch: true } : {}),
207182 };
208183 };
209184
apps/web/lib/mcp/oauth-popup.ts+8−0
@@ -4,6 +4,8 @@ export type McpOAuthPopupSuccess = { · 3 unmodified lines
44 refreshToken?: string;
55 scope?: string;
66 expiresIn?: number;
7+ clientId?: string;
8+ clientInformationJson?: string;
79 };
810
911 type McpOAuthPopupMessage =
@@ -16,6 +18,8 @@ type McpOAuthPopupMessage = · 6 unmodified lines
1618 refreshToken?: string;
1719 scope?: string;
1820 expiresIn?: number;
21+ clientId?: string;
22+ clientInformationJson?: string;
1923 };
2024 }
2125 | {
@@ -110,6 +114,10 @@ export const startMcpOAuthPopup = async ( · 88 unmodified lines
110114 ...(typeof data.payload.expiresIn === "number"
111115 ? { expiresIn: data.payload.expiresIn }
112116 : {}),
117+ ...(data.payload.clientId ? { clientId: data.payload.clientId } : {}),
118+ ...(data.payload.clientInformationJson
119+ ? { clientInformationJson: data.payload.clientInformationJson }
120+ : {}),
113121 });
114122 };
115123
apps/web/lib/mcp/oauth-provider.ts+4−0
@@ -24,6 +24,8 @@ export type McpOAuthPopupResult = { · 23 unmodified lines
2424 refreshToken?: string;
2525 scope?: string;
2626 expiresIn?: number;
27+ clientId?: string;
28+ clientInformationJson?: string;
2729 error?: string;
2830 };
2931
@@ -106,6 +108,8 @@ const decodePopupResult = (value: unknown): McpOAuthPopupResult | null => { · 76 unmodified lines
106108 refreshToken: readOptionalString(record, "refreshToken"),
107109 scope: readOptionalString(record, "scope"),
108110 expiresIn: readOptionalNumber(record, "expiresIn"),
111+ clientId: readOptionalString(record, "clientId"),
112+ clientInformationJson: readOptionalString(record, "clientInformationJson"),
109113 error: readOptionalString(record, "error"),
110114 };
111115 };
packages/engine/src/openapi-provider.ts+3−1
@@ -12,6 +12,7 @@ import { pipe } from "effect/Function"; · 11 unmodified lines
1212 import * as ParseResult from "effect/ParseResult";
1313 import * as Schema from "effect/Schema";
1414
15+import { collectSourceHeaders } from "./source-config";
1516 import { ToolProviderError, type ToolProvider } from "./tool-providers";
1617
1718 const OpenApiToolArgsSchema = Schema.Record({
@@ -175,8 +176,9 @@ const buildFetchRequest = ( · 157 unmodified lines
175176 const resolvedPath = yield* replacePathTemplate(payload.pathTemplate, args, payload);
176177 const baseUrl = yield* resolveBaseUrl(source);
177178 const url = new URL(resolvedPath, baseUrl);
179+ const config = parseSourceConfig(source);
178180
179− const headers = new Headers();
181+ const headers = new Headers(collectSourceHeaders(config));
180182 const cookieParts: Array<string> = [];
181183
182184 for (const parameter of payload.parameters) {
packages/engine/src/source-config.ts+1−35
@@ -105,39 +105,5 @@ export const readMcpTransportFromConfig = ( · 104 unmodified lines
105105 export const collectSourceHeaders = (
106106 config: SourceConfig,
107107 ): Record<string, string> => {
108− const configuredHeaders = readStringRecord(config.headers);
109− const auth = asRecord(config.auth);
110− const authType = normalizeString(auth.type)?.toLowerCase();
111− const authHeaders: Record<string, string> = {};
112−
113− if (authType === "bearer") {
114− const token = normalizeString(auth.token) ?? normalizeString(auth.value);
115− if (token) {
116− authHeaders.Authorization = `Bearer ${token}`;
117− }
118− }
119−
120− if (authType === "apikey" || authType === "api_key" || authType === "apiKey") {
121− const headerName = normalizeString(auth.header) ?? "Authorization";
122− const apiKey = normalizeString(auth.value);
123− if (apiKey) {
124− authHeaders[headerName] = apiKey;
125− }
126− }
127−
128− if (authType === "basic") {
129− const username = normalizeString(auth.username);
130− const password = normalizeString(auth.password);
131− if (username && password) {
132− authHeaders.Authorization = `Basic ${Buffer.from(
133− `${username}:${password}`,
134− "utf8",
135− ).toString("base64")}`;
136− }
137− }
138−
139− return {
140− ...configuredHeaders,
141− ...authHeaders,
142− };
108+ return readStringRecord(config.headers);
143109 };
packages/management-api/src/credentials/api.ts+14−0
@@ -28,9 +28,23 @@ const OptionalUpsertCredentialBindingPayloadSchema = SourceCredentialBindingSche · 27 unmodified lines
2828 Schema.partialWith({ exact: true }),
2929 );
3030
31+const OptionalOAuthUpsertFieldsSchema = Schema.Struct({
32+ oauthRefreshToken: Schema.NullOr(Schema.String),
33+ oauthExpiresAt: Schema.NullOr(Schema.Number),
34+ oauthScope: Schema.NullOr(Schema.String),
35+ oauthIssuer: Schema.NullOr(Schema.String),
36+ oauthTokenEndpoint: Schema.NullOr(Schema.String),
37+ oauthAuthorizationServer: Schema.NullOr(Schema.String),
38+ oauthClientId: Schema.NullOr(Schema.String),
39+ oauthClientSecret: Schema.NullOr(Schema.String),
40+ oauthSourceUrl: Schema.NullOr(Schema.String),
41+ oauthClientInformationJson: Schema.NullOr(Schema.String),
42+}).pipe(Schema.partialWith({ exact: true }));
43+
3144 export const UpsertCredentialBindingPayloadSchema =
3245 RequiredUpsertCredentialBindingPayloadSchema.pipe(
3346 Schema.extend(OptionalUpsertCredentialBindingPayloadSchema),
47+ Schema.extend(OptionalOAuthUpsertFieldsSchema),
3448 );
3549
3650 export type UpsertCredentialBindingPayload =
packages/schema/src/enums.ts+51−0
@@ -11,6 +11,7 @@ export const CredentialProviderSchema = Schema.Literal( · 10 unmodified lines
1111 "api_key",
1212 "bearer",
1313 "oauth2",
14+ "basic",
1415 "custom",
1516 );
1617 export const CredentialSecretProviderSchema = Schema.Literal(
@@ -21,6 +22,49 @@ export const CredentialScopeTypeSchema = Schema.Literal( · 4 unmodified lines
2122 "organization",
2223 "account",
2324 );
25+export const AuthConnectionStrategySchema = Schema.Literal(
26+ "oauth2",
27+ "api_key",
28+ "bearer",
29+ "basic",
30+ "custom",
31+);
32+export const AuthConnectionStatusSchema = Schema.Literal(
33+ "active",
34+ "reauth_required",
35+ "revoked",
36+ "disabled",
37+ "error",
38+);
39+export const AuthOwnerTypeSchema = Schema.Literal(
40+ "organization",
41+ "workspace",
42+ "account",
43+);
44+export const AuthBindingScopeTypeSchema = Schema.Literal(
45+ "workspace",
46+ "organization",
47+ "account",
48+);
49+export const OAuthStateStatusSchema = Schema.Literal(
50+ "valid",
51+ "expiring",
52+ "refreshing",
53+ "reauth_required",
54+ "revoked",
55+);
56+export const AuthAuditEventTypeSchema = Schema.Literal(
57+ "created",
58+ "updated",
59+ "bound",
60+ "unbound",
61+ "refresh_success",
62+ "refresh_failed",
63+ "reauth_required",
64+ "revoked",
65+ "deleted",
66+);
67+export const AuthActorTypeSchema = Schema.Literal("system", "account");
2468 export const StorageScopeTypeSchema = Schema.Literal(
2569 "scratch",
2670 "account",
@@ -49,6 +93,13 @@ export type CredentialMode = typeof CredentialModeSchema.Type; · 22 unmodified lines
4993 export type CredentialProvider = typeof CredentialProviderSchema.Type;
5094 export type CredentialSecretProvider = typeof CredentialSecretProviderSchema.Type;
5195 export type CredentialScopeType = typeof CredentialScopeTypeSchema.Type;
96+export type AuthConnectionStrategy = typeof AuthConnectionStrategySchema.Type;
97+export type AuthConnectionStatus = typeof AuthConnectionStatusSchema.Type;
98+export type AuthOwnerType = typeof AuthOwnerTypeSchema.Type;
99+export type AuthBindingScopeType = typeof AuthBindingScopeTypeSchema.Type;
100+export type OAuthStateStatus = typeof OAuthStateStatusSchema.Type;
101+export type AuthAuditEventType = typeof AuthAuditEventTypeSchema.Type;
102+export type AuthActorType = typeof AuthActorTypeSchema.Type;
52103 export type StorageScopeType = typeof StorageScopeTypeSchema.Type;
53104 export type StorageDurability = typeof StorageDurabilitySchema.Type;
54105 export 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
1111 );
1212 export const CredentialIdSchema = Schema.String.pipe(Schema.brand("CredentialId"));
1313 export const OAuthTokenIdSchema = Schema.String.pipe(Schema.brand("OAuthTokenId"));
14+export const AuthConnectionIdSchema = Schema.String.pipe(
15+ Schema.brand("AuthConnectionId"),
16+);
17+export const SourceAuthBindingIdSchema = Schema.String.pipe(
18+ Schema.brand("SourceAuthBindingId"),
19+);
20+export const AuthMaterialIdSchema = Schema.String.pipe(
21+ Schema.brand("AuthMaterialId"),
22+);
23+export const OAuthStateIdSchema = Schema.String.pipe(Schema.brand("OAuthStateId"));
24+export const AuthAuditEventIdSchema = Schema.String.pipe(
25+ Schema.brand("AuthAuditEventId"),
26+);
1427 export const OrganizationMemberIdSchema = Schema.String.pipe(
1528 Schema.brand("OrganizationMemberId"),
1629 );
@@ -32,6 +45,11 @@ export type ToolArtifactId = typeof ToolArtifactIdSchema.Type; · 15 unmodified lines
3245 export type CredentialBindingId = typeof CredentialBindingIdSchema.Type;
3346 export type CredentialId = typeof CredentialIdSchema.Type;
3447 export type OAuthTokenId = typeof OAuthTokenIdSchema.Type;
48+export type AuthConnectionId = typeof AuthConnectionIdSchema.Type;
49+export type SourceAuthBindingId = typeof SourceAuthBindingIdSchema.Type;
50+export type AuthMaterialId = typeof AuthMaterialIdSchema.Type;
51+export type OAuthStateId = typeof OAuthStateIdSchema.Type;
52+export type AuthAuditEventId = typeof AuthAuditEventIdSchema.Type;
3553 export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type;
3654 export type PolicyId = typeof PolicyIdSchema.Type;
3755 export type ApprovalId = typeof ApprovalIdSchema.Type;
packages/schema/src/index.ts+5−1
@@ -8,6 +8,11 @@ export * from "./models/source"; · 7 unmodified lines
88 export * from "./models/tool-artifact";
99 export * from "./models/oauth-token";
1010 export * from "./models/source-credential-binding";
11+export * from "./models/auth-connection";
12+export * from "./models/source-auth-binding";
13+export * from "./models/auth-material";
14+export * from "./models/oauth-state";
15+export * from "./models/auth-audit-event";
1116 export * from "./models/policy";
1217 export * from "./models/approval";
1318 export * from "./models/task-run";
@@ -16,4 +21,3 @@ export * from "./models/storage-instance"; · 2 unmodified lines
1621 export * from "./models/event-envelope";
1722 export * from "./models/runtime-tool";
1823 export * from "./models/auth";
19−
packages/schema/src/models/auth-audit-event.ts+28−0
@@ -0,0 +1,28 @@
1+import { Schema } from "effect";
2+
3+import { TimestampMsSchema } from "../common";
4+import { AuthActorTypeSchema, AuthAuditEventTypeSchema } from "../enums";
5+import {
6+ AccountIdSchema,
7+ AuthAuditEventIdSchema,
8+ AuthConnectionIdSchema,
9+ OrganizationIdSchema,
10+ SourceIdSchema,
11+} from "../ids";
12+
13+export const AuthAuditEventSchema = Schema.Struct({
14+ id: AuthAuditEventIdSchema,
15+ organizationId: OrganizationIdSchema,
16+ connectionId: AuthConnectionIdSchema,
17+ sourceId: Schema.NullOr(SourceIdSchema),
18+ eventType: AuthAuditEventTypeSchema,
19+ actorType: AuthActorTypeSchema,
20+ actorId: Schema.NullOr(AccountIdSchema),
21+ outcome: Schema.String,
22+ reasonCode: Schema.NullOr(Schema.String),
23+ detailsJson: Schema.NullOr(Schema.String),
24+ correlationId: Schema.NullOr(Schema.String),
25+ createdAt: TimestampMsSchema,
26+});
27+
28+export type AuthAuditEvent = typeof AuthAuditEventSchema.Type;
packages/schema/src/models/auth-connection.ts+35−0
@@ -0,0 +1,35 @@
1+import { Schema } from "effect";
2+
3+import { TimestampMsSchema } from "../common";
4+import {
5+ AuthConnectionStatusSchema,
6+ AuthConnectionStrategySchema,
7+ AuthOwnerTypeSchema,
8+} from "../enums";
9+import {
10+ AccountIdSchema,
11+ AuthConnectionIdSchema,
12+ OrganizationIdSchema,
13+ WorkspaceIdSchema,
14+} from "../ids";
15+
16+export const AuthConnectionSchema = Schema.Struct({
17+ id: AuthConnectionIdSchema,
18+ organizationId: OrganizationIdSchema,
19+ workspaceId: Schema.NullOr(WorkspaceIdSchema),
20+ accountId: Schema.NullOr(AccountIdSchema),
21+ ownerType: AuthOwnerTypeSchema,
22+ strategy: AuthConnectionStrategySchema,
23+ displayName: Schema.String,
24+ status: AuthConnectionStatusSchema,
25+ statusReason: Schema.NullOr(Schema.String),
26+ lastAuthErrorClass: Schema.NullOr(Schema.String),
27+ metadataJson: Schema.NullOr(Schema.String),
28+ additionalHeadersJson: Schema.NullOr(Schema.String),
29+ createdByAccountId: Schema.NullOr(AccountIdSchema),
30+ createdAt: TimestampMsSchema,
31+ updatedAt: TimestampMsSchema,
32+ lastUsedAt: Schema.NullOr(TimestampMsSchema),
33+});
34+
35+export type AuthConnection = typeof AuthConnectionSchema.Type;
packages/schema/src/models/auth-material.ts+15−0
@@ -0,0 +1,15 @@
1+import { Schema } from "effect";
2+
3+import { TimestampMsSchema } from "../common";
4+import { AuthConnectionIdSchema, AuthMaterialIdSchema } from "../ids";
5+
6+export const AuthMaterialSchema = Schema.Struct({
7+ id: AuthMaterialIdSchema,
8+ connectionId: AuthConnectionIdSchema,
9+ ciphertext: Schema.String,
10+ keyVersion: Schema.String,
11+ createdAt: TimestampMsSchema,
12+ updatedAt: TimestampMsSchema,
13+});
14+
15+export type AuthMaterial = typeof AuthMaterialSchema.Type;
packages/schema/src/models/oauth-state.ts+29−0
@@ -0,0 +1,29 @@
1+import { Schema } from "effect";
2+
3+import { TimestampMsSchema } from "../common";
4+import { AuthConnectionIdSchema, OAuthStateIdSchema } from "../ids";
5+
6+export const OAuthStateSchema = Schema.Struct({
7+ id: OAuthStateIdSchema,
8+ connectionId: AuthConnectionIdSchema,
9+ accessTokenCiphertext: Schema.String,
10+ refreshTokenCiphertext: Schema.NullOr(Schema.String),
11+ keyVersion: Schema.String,
12+ expiresAt: Schema.NullOr(TimestampMsSchema),
13+ scope: Schema.NullOr(Schema.String),
14+ tokenType: Schema.NullOr(Schema.String),
15+ issuer: Schema.NullOr(Schema.String),
16+ refreshConfigJson: Schema.NullOr(Schema.String),
17+ tokenVersion: Schema.Number,
18+ leaseHolder: Schema.NullOr(Schema.String),
19+ leaseExpiresAt: Schema.NullOr(TimestampMsSchema),
20+ leaseFence: Schema.Number,
21+ lastRefreshAt: Schema.NullOr(TimestampMsSchema),
22+ lastRefreshErrorClass: Schema.NullOr(Schema.String),
23+ lastRefreshError: Schema.NullOr(Schema.String),
24+ reauthRequiredAt: Schema.NullOr(TimestampMsSchema),
25+ createdAt: TimestampMsSchema,
26+ updatedAt: TimestampMsSchema,
27+});
28+
29+export type OAuthState = typeof OAuthStateSchema.Type;
packages/schema/src/models/source-auth-binding.ts+28−0
@@ -0,0 +1,28 @@
1+import { Schema } from "effect";
2+
3+import { TimestampMsSchema } from "../common";
4+import { AuthBindingScopeTypeSchema } from "../enums";
5+import {
6+ AccountIdSchema,
7+ AuthConnectionIdSchema,
8+ OrganizationIdSchema,
9+ SourceAuthBindingIdSchema,
10+ SourceIdSchema,
11+ WorkspaceIdSchema,
12+} from "../ids";
13+
14+export const SourceAuthBindingSchema = Schema.Struct({
15+ id: SourceAuthBindingIdSchema,
16+ sourceId: SourceIdSchema,
17+ connectionId: AuthConnectionIdSchema,
18+ organizationId: OrganizationIdSchema,
19+ workspaceId: Schema.NullOr(WorkspaceIdSchema),
20+ accountId: Schema.NullOr(AccountIdSchema),
21+ scopeType: AuthBindingScopeTypeSchema,
22+ selector: Schema.NullOr(Schema.String),
23+ enabled: Schema.Boolean,
24+ createdAt: TimestampMsSchema,
25+ updatedAt: TimestampMsSchema,
26+});
27+
28+export type SourceAuthBinding = typeof SourceAuthBindingSchema.Type;
packages/state-local-file/src/local-state-snapshot.ts+8−4
@@ -1,13 +1,15 @@
11 import {
2+ AuthConnectionSchema,
3+ AuthMaterialSchema,
24 ApprovalSchema,
35 EventEnvelopeSchema,
4− OAuthTokenSchema,
6+ OAuthStateSchema,
57 OrganizationMembershipSchema,
68 OrganizationSchema,
79 PolicySchema,
810 ProfileSchema,
911 SchemaVersionSchema,
10− SourceCredentialBindingSchema,
12+ SourceAuthBindingSchema,
1113 SourceSchema,
1214 StorageInstanceSchema,
1315 SyncStateSchema,
@@ -27,8 +29,10 @@ export const LocalStateSnapshotSchema = Schema.Struct({ · 13 unmodified lines
2729 workspaces: Schema.Array(WorkspaceSchema),
2830 sources: Schema.Array(SourceSchema),
2931 toolArtifacts: Schema.Array(ToolArtifactSchema),
30− credentialBindings: Schema.Array(SourceCredentialBindingSchema),
31− oauthTokens: Schema.Array(OAuthTokenSchema),
32+ authConnections: Schema.Array(AuthConnectionSchema),
33+ sourceAuthBindings: Schema.Array(SourceAuthBindingSchema),
34+ authMaterials: Schema.Array(AuthMaterialSchema),
35+ oauthStates: Schema.Array(OAuthStateSchema),
3236 policies: Schema.Array(PolicySchema),
3337 approvals: Schema.Array(ApprovalSchema),
3438 taskRuns: Schema.Array(TaskRunSchema),
packages/state-local-file/src/state-store.test.ts+4−2
@@ -69,8 +69,10 @@ describe("makeLocalStateStore", () => { · 68 unmodified lines
6969 ],
7070 sources: [],
7171 toolArtifacts: [],
72− credentialBindings: [],
73− oauthTokens: [],
72+ authConnections: [],
73+ sourceAuthBindings: [],
74+ authMaterials: [],
75+ oauthStates: [],
7476 policies: [],
7577 approvals: [],
7678 taskRuns: [],