@desplega.ai/agent-swarm 1.129.0 → 1.131.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-kfv2dawe.js → actions-w53mbns9.js} +11 -11
  3. package/dist/{anthropic-messages-3xgme8my.js → anthropic-messages-rjmd2e7n.js} +5 -5
  4. package/dist/{app-q8vjk6e8.js → app-m9gwqsqv.js} +8 -8
  5. package/dist/{artifact-hg4t1nx7.js → artifact-jysj3wrg.js} +2 -2
  6. package/dist/{assistant-g037r345.js → assistant-bp65m6f3.js} +13 -13
  7. package/dist/{azure-openai-responses-xa4sh9f6.js → azure-openai-responses-c0yjmbfd.js} +8 -5
  8. package/dist/{boot-reembed-18pzsrxa.js → boot-reembed-62p0at2k.js} +6 -6
  9. package/dist/{boot-reembed-wpks2cj8.js → boot-reembed-xmfp6wdh.js} +7 -7
  10. package/dist/{boot-scrub-logs-n7szd57r.js → boot-scrub-logs-rr8q2dms.js} +5 -5
  11. package/dist/{claude-adapter-qz4r0ajt.js → claude-adapter-2y47mrkd.js} +9 -9
  12. package/dist/{cli-mhjnbgb6.js → cli-0rcyq6d9.js} +450 -186
  13. package/dist/{cli-ttkmehy0.js → cli-2q72qz2s.js} +274 -25
  14. package/dist/{cli-gztgc3hz.js → cli-2wadx7p2.js} +2 -2
  15. package/dist/cli-3fj60etf.js +153 -0
  16. package/dist/{cli-7ecgm5v0.js → cli-3kf51j15.js} +1 -1
  17. package/dist/{cli-vgexpfsr.js → cli-57khth04.js} +6 -3
  18. package/dist/{cli-2mbe7vk0.js → cli-5dsbcjjw.js} +1 -1
  19. package/dist/{cli-etr62zy6.js → cli-5kx36evn.js} +2 -2
  20. package/dist/{cli-v2tr6amf.js → cli-5n6e5yfn.js} +30 -8
  21. package/dist/{cli-hx02s9zq.js → cli-5v4rw6yd.js} +2 -2
  22. package/dist/{cli-v37yr6mf.js → cli-5ze9rv97.js} +4 -4
  23. package/dist/{cli-ch7t5v7n.js → cli-6map995v.js} +9 -4
  24. package/dist/{cli-h0457a67.js → cli-70xqj0z3.js} +3 -3
  25. package/dist/{cli-kk4f9r17.js → cli-72q8m8gk.js} +8 -6
  26. package/dist/{cli-7padk69a.js → cli-78xwvkhj.js} +2 -2
  27. package/dist/{cli-dxtxacy7.js → cli-9dr5qzc0.js} +45 -8
  28. package/dist/{cli-17zxdgsq.js → cli-a40rnf71.js} +6 -6
  29. package/dist/{cli-t53vnnxz.js → cli-a4jzdpa3.js} +2514 -1055
  30. package/dist/{cli-w10fkrf1.js → cli-akdvwc9v.js} +354 -115
  31. package/dist/{cli-1qkk5er0.js → cli-bmze986d.js} +1 -1
  32. package/dist/{cli-ga12dc1d.js → cli-bqw0mt1n.js} +3 -3
  33. package/dist/{cli-0sf92efp.js → cli-cdeg08wx.js} +1 -1
  34. package/dist/{cli-a8ynhkyj.js → cli-cvgjwnh1.js} +17 -15
  35. package/dist/{cli-9fxg3v5m.js → cli-d2tfkcga.js} +4 -4
  36. package/dist/{cli-nhcd7x43.js → cli-d3x3x1c9.js} +2 -2
  37. package/dist/{cli-6nh2qxvh.js → cli-darcwax3.js} +1 -1
  38. package/dist/{cli-yjbgmysx.js → cli-e7ckcg4v.js} +12 -12
  39. package/dist/{cli-4n2gcjpd.js → cli-efxfnq6q.js} +4 -3
  40. package/dist/{cli-z9xqqhfk.js → cli-er3a4bb0.js} +1 -1
  41. package/dist/{cli-xg2eezyv.js → cli-f1am9szv.js} +1 -1
  42. package/dist/{cli-xy4cwsm9.js → cli-fywms69x.js} +1 -1
  43. package/dist/{cli-33jj76y8.js → cli-g6b8x1h8.js} +1 -1
  44. package/dist/{cli-epsvdckx.js → cli-gm9cbhbq.js} +20 -10
  45. package/dist/{cli-rkndnn90.js → cli-k9qkf1hw.js} +6 -4
  46. package/dist/{cli-zejvt395.js → cli-kh3ba2zf.js} +1 -1
  47. package/dist/{cli-gb25p1h2.js → cli-nm5gtp1x.js} +2 -2
  48. package/dist/{cli-akxnpx17.js → cli-pm6xbh84.js} +1829 -1629
  49. package/dist/{cli-2k0ztbfp.js → cli-psh3c4va.js} +1 -1
  50. package/dist/{cli-39vgrsep.js → cli-qv61hp37.js} +2 -2
  51. package/dist/{cli-hv2fndhm.js → cli-r6kqqbj0.js} +6 -4
  52. package/dist/{cli-1y1cm570.js → cli-rzbpmxdr.js} +1 -1
  53. package/dist/{cli-mmrk00vp.js → cli-t1xze5qh.js} +1 -1
  54. package/dist/{cli-g252a9ky.js → cli-t55yw3a1.js} +2 -2
  55. package/dist/{cli-7bz8dzqa.js → cli-t76vw5db.js} +1184 -1522
  56. package/dist/{cli-4n4mdv21.js → cli-xpxde7rm.js} +7 -3
  57. package/dist/{cli-fpaa5k2q.js → cli-yw8vghx2.js} +54 -18
  58. package/dist/cli-zsspvvm2.js +490 -0
  59. package/dist/cli.js +27 -25
  60. package/dist/{codex-adapter-7v6t7ff6.js → codex-adapter-d24q75wk.js} +7 -7
  61. package/dist/{codex-hook-amndawyb.js → codex-hook-hq54t9ft.js} +2 -2
  62. package/dist/{codex-session-runner-e7j4khe2.js → codex-session-runner-g50vcsex.js} +7 -7
  63. package/dist/{commands-ex0k5tbf.js → commands-c3nrc5hg.js} +5 -5
  64. package/dist/{db-df7jatfc.js → db-nt7fg3tw.js} +5 -5
  65. package/dist/{google-generative-ai-y42hnw9p.js → google-generative-ai-hysrd8qx.js} +7 -5
  66. package/dist/{google-vertex-4sg66gb6.js → google-vertex-p5arrga0.js} +7 -5
  67. package/dist/{handlers-1j2cfqt2.js → handlers-fkr6swat.js} +13 -13
  68. package/dist/{hook-nx9a0mj3.js → hook-77wpvnjc.js} +7 -7
  69. package/dist/{hook-w6yaf6sy.js → hook-g019yehy.js} +8 -8
  70. package/dist/{http-4tz5h8wg.js → http-jewj0syk.js} +2993 -716
  71. package/dist/{index-0bpkzb09.js → index-070v4eyb.js} +3 -3
  72. package/dist/{index-0s67qv7h.js → index-6gm5he8h.js} +11 -11
  73. package/dist/{index-35gtv6gj.js → index-7n2xpbd4.js} +310 -40
  74. package/dist/{index-ker3gccb.js → index-9znhar3x.js} +4 -4
  75. package/dist/{index-5n2c8yay.js → index-hase6zbe.js} +2 -2
  76. package/dist/{index-kcf3tm9h.js → index-hfvggd0z.js} +12 -12
  77. package/dist/{index-7c0jqna0.js → index-hp1kdbjj.js} +13 -13
  78. package/dist/{keepalive-3j4a9168.js → keepalive-j0fjvqfd.js} +7 -7
  79. package/dist/{lead-6kpevymb.js → lead-1n8x30xe.js} +33 -33
  80. package/dist/{maintenance-m1k82ean.js → maintenance-gvev11sa.js} +7 -7
  81. package/dist/{mistral-conversations-bjkdfb6z.js → mistral-conversations-81fwav0j.js} +6 -6
  82. package/dist/{oauth-refresh-sweep-881k8e4q.js → oauth-refresh-sweep-s9ecsvh0.js} +6 -6
  83. package/dist/{onboard-c0ykffjn.js → onboard-pvm5b38w.js} +2 -2
  84. package/dist/{openai-codex-responses-xetseqvt.js → openai-codex-responses-5g27pd6f.js} +43 -23
  85. package/dist/{openai-completions-5r2g5axa.js → openai-completions-3vkmfz2a.js} +55 -11
  86. package/dist/{openai-responses-ce67hd23.js → openai-responses-dg4t7j7a.js} +8 -5
  87. package/dist/{openrouter-images-pzfp22jb.js → openrouter-images-g3gm8931.js} +1 -1
  88. package/dist/{otel-impl-sqagjerv.js → otel-impl-p6jvgd9c.js} +6 -4
  89. package/dist/{otel-3mp81ppd.js → otel-nqa12wf3.js} +2 -2
  90. package/dist/{pi-mono-adapter-k584k16g.js → pi-mono-adapter-t091s27g.js} +17117 -7517
  91. package/dist/{pricing-refresh-y82whbf9.js → pricing-refresh-k49rcyed.js} +7 -7
  92. package/dist/{rbac-roles-nvkqmzyd.js → rbac-roles-30pspvy7.js} +7 -7
  93. package/dist/{rbac-roles-4ht8gegc.js → rbac-roles-cr80g96t.js} +6 -6
  94. package/dist/{render-v2-wvqdgvvp.js → render-v2-xm457ng1.js} +8 -8
  95. package/dist/{seed-pricing-fy3rxhde.js → seed-pricing-yhpv35fr.js} +6 -6
  96. package/dist/{setup-ak5cnc6f.js → setup-nytnmwwr.js} +2 -2
  97. package/dist/{worker-d8769q8a.js → worker-msytr2cw.js} +33 -33
  98. package/openapi.json +34039 -11036
  99. package/package.json +8 -6
  100. package/src/apps/definition.ts +368 -26
  101. package/src/apps/row-store.ts +66 -10
  102. package/src/apps/schema-migrate.ts +95 -1
  103. package/src/apps/store.ts +13 -4
  104. package/src/apps/sync-run-as.ts +18 -0
  105. package/src/apps/sync.ts +934 -0
  106. package/src/apps/version.ts +16 -2
  107. package/src/assets/key.ts +8 -1
  108. package/src/be/asset-key-audit.ts +3 -1
  109. package/src/be/db-queries/mcp-oauth.ts +297 -24
  110. package/src/be/db.ts +42 -1
  111. package/src/be/migrations/129_asset_keys_apps_scripts.sql +161 -0
  112. package/src/be/scripts/db.ts +5 -2
  113. package/src/be/scripts/typecheck.ts +1 -0
  114. package/src/be/seed-scripts/catalog/app-sync-run.ts +32 -0
  115. package/src/be/seed-scripts/catalog/github-issues-pull.ts +102 -0
  116. package/src/be/seed-scripts/index.ts +18 -0
  117. package/src/be/seed-skills/index.ts +7 -0
  118. package/src/http/active-sessions.ts +26 -18
  119. package/src/http/agents.ts +94 -27
  120. package/src/http/api-keys.ts +91 -17
  121. package/src/http/approval-requests.ts +87 -10
  122. package/src/http/apps.ts +361 -53
  123. package/src/http/assets.ts +165 -31
  124. package/src/http/budgets.ts +7 -5
  125. package/src/http/codex-oauth-keep-warm.ts +21 -3
  126. package/src/http/config.ts +46 -16
  127. package/src/http/context.ts +33 -6
  128. package/src/http/ecosystem.ts +20 -6
  129. package/src/http/events.ts +24 -9
  130. package/src/http/favorites.ts +20 -6
  131. package/src/http/fs.ts +60 -17
  132. package/src/http/heartbeat.ts +18 -4
  133. package/src/http/inbox-state.ts +12 -6
  134. package/src/http/integrations.ts +25 -5
  135. package/src/http/kv.ts +36 -15
  136. package/src/http/mcp-bridge.ts +5 -1
  137. package/src/http/mcp-oauth.ts +332 -17
  138. package/src/http/mcp-servers.ts +56 -18
  139. package/src/http/memory.ts +282 -29
  140. package/src/http/metrics.ts +80 -17
  141. package/src/http/oauth-callback.ts +10 -4
  142. package/src/http/oauth-generic.ts +5 -1
  143. package/src/http/oauth-locks.ts +6 -3
  144. package/src/http/openapi.ts +57 -9
  145. package/src/http/page-proxy.ts +25 -5
  146. package/src/http/pages-public.ts +37 -20
  147. package/src/http/pages.ts +93 -28
  148. package/src/http/poll.ts +89 -5
  149. package/src/http/pricing.ts +9 -6
  150. package/src/http/prompt-templates.ts +85 -21
  151. package/src/http/route-def.ts +67 -4
  152. package/src/http/schedules.ts +63 -14
  153. package/src/http/script-connection-proxy.ts +30 -4
  154. package/src/http/script-connections.ts +393 -43
  155. package/src/http/script-runs.ts +73 -19
  156. package/src/http/scripts.ts +257 -39
  157. package/src/http/session-data.ts +87 -14
  158. package/src/http/sessions.ts +80 -5
  159. package/src/http/skills.ts +115 -35
  160. package/src/http/stats.ts +94 -13
  161. package/src/http/task-templates.ts +6 -4
  162. package/src/http/tasks.ts +230 -61
  163. package/src/http/trackers/jira.ts +90 -30
  164. package/src/http/trackers/linear.ts +46 -12
  165. package/src/http/users.ts +162 -25
  166. package/src/http/utils.ts +12 -2
  167. package/src/http/webhooks.ts +30 -16
  168. package/src/http/workflow-events.ts +18 -5
  169. package/src/http/workflows.ts +128 -39
  170. package/src/http/x.ts +37 -6
  171. package/src/oauth/ensure-mcp-token.ts +16 -1
  172. package/src/oauth/mcp-wrapper.ts +281 -26
  173. package/src/providers/pi-mono-adapter.ts +1 -1
  174. package/src/scripts-runtime/credential-broker/README.md +1 -1
  175. package/src/scripts-runtime/credential-broker/fetch-patch.ts +12 -2
  176. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  177. package/src/scripts-runtime/stdlib/fetch.ts +6 -0
  178. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  179. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  180. package/src/server.ts +2 -0
  181. package/src/tests/apps-rbac.test.ts +110 -0
  182. package/src/tests/apps-spike2.test.ts +4 -1
  183. package/src/tests/apps-sync-engine.test.ts +2540 -0
  184. package/src/tests/apps-sync.test.ts +1627 -0
  185. package/src/tests/asset-key-api.test.ts +48 -2
  186. package/src/tests/asset-key-invariants.test.ts +92 -0
  187. package/src/tests/asset-key-migration.test.ts +174 -8
  188. package/src/tests/asset-key-normalization.test.ts +2 -0
  189. package/src/tests/credential-broker.test.ts +27 -2
  190. package/src/tests/mcp-oauth-dcr-reuse.test.ts +743 -0
  191. package/src/tests/mcp-oauth-ensure-token.test.ts +45 -0
  192. package/src/tests/mcp-oauth-manual-client.test.ts +135 -2
  193. package/src/tests/mcp-oauth-queries.test.ts +153 -0
  194. package/src/tests/mcp-oauth-wrapper.test.ts +432 -0
  195. package/src/tests/memory-http-recall-gating.test.ts +2 -0
  196. package/src/tests/openapi-response-contract.test.ts +138 -0
  197. package/src/tests/rbac-admission-e2e.test.ts +68 -0
  198. package/src/tests/script-connections.test.ts +3 -0
  199. package/src/tests/scripts-runtime-fetch.test.ts +121 -0
  200. package/src/tests/scripts-runtime-secret-egress.test.ts +4 -2
  201. package/src/tests/seed-scripts.test.ts +2 -2
  202. package/src/tests/swarm-tool-result-gate.test.ts +32 -0
  203. package/src/tests/task-working-dir.test.ts +8 -4
  204. package/src/tests/whoami-route.test.ts +127 -0
  205. package/src/tests/workflow-engine-v2.test.ts +80 -7
  206. package/src/tests/workflow-swarm-script.test.ts +104 -0
  207. package/src/tools/app-get.ts +10 -2
  208. package/src/tools/app-rollback.ts +1 -0
  209. package/src/tools/app-sync.ts +112 -0
  210. package/src/tools/script-common.ts +20 -0
  211. package/src/tools/tool-config.ts +2 -1
  212. package/src/tools/utils.ts +11 -0
  213. package/src/tools/workflows/create-workflow.ts +4 -1
  214. package/src/types.ts +1394 -1206
  215. package/src/utils/template.ts +8 -1
  216. package/src/utils/zod-openapi.ts +11 -0
  217. package/src/workflows/engine.ts +27 -4
  218. package/src/workflows/executors/swarm-script.ts +28 -0
  219. package/src/workflows/retry-poller.ts +19 -2
  220. package/templates/skills/apps/content.md +94 -5
  221. package/templates/skills/asset-namespaces/content.md +2 -2
  222. package/templates/skills/scheduled-task-resilience/SKILL.md +102 -0
  223. package/templates/skills/scheduled-task-resilience/config.json +14 -0
  224. package/templates/skills/scheduled-task-resilience/content.md +97 -0
  225. package/dist/cli-7wmybrt5.js +0 -115
  226. package/dist/cli-d91pvmq1.js +0 -14
@@ -102,7 +102,11 @@ export function decodeAppVersion(appVersion: AppVersion): AppVersion {
102
102
  };
103
103
  }
104
104
 
105
- function rollbackSnapshot(version: AppVersion): {
105
+ function rollbackSnapshot(
106
+ version: AppVersion,
107
+ writer: { writerAgentId?: string | null; writerIsUser?: boolean },
108
+ existingDefinition: unknown,
109
+ ): {
106
110
  name: string;
107
111
  description: string | null;
108
112
  definition: AppDefinition;
@@ -132,9 +136,16 @@ function rollbackSnapshot(version: AppVersion): {
132
136
  ]);
133
137
  }
134
138
 
139
+ // A rollback is an ordinary definition write by whoever invoked it, not a
140
+ // trusted restore: the historical snapshot may reintroduce foreign-owned or
141
+ // lead-run script references the writer could never add directly, so the
142
+ // same ownership/grandfathering checks apply against the CURRENT definition.
135
143
  const parsed = parseAppDefinition(upgradeAppDefinition(snapshot.definition), {
136
144
  currentAppId: version.appId,
137
145
  resolveApp: getApp,
146
+ writerAgentId: writer.writerAgentId ?? null,
147
+ ...(writer.writerIsUser === undefined ? {} : { writerIsUser: writer.writerIsUser }),
148
+ existingDefinition,
138
149
  });
139
150
  if (!parsed.success) throw new AppRollbackDefinitionError(version.version, parsed.issues);
140
151
  return {
@@ -155,6 +166,9 @@ export async function rollbackApp(input: {
155
166
  migration?: AppMigration;
156
167
  forceElementBreak?: string[];
157
168
  changedByAgentId?: string;
169
+ /** Writer principal for the restored definition's ownership checks. */
170
+ writerAgentId?: string | null;
171
+ writerIsUser?: boolean;
158
172
  }): Promise<{ app: AppRecord; migration: AppMigrationReport }> {
159
173
  return withAppDefinitionLock(input.appId, async () => {
160
174
  const existing = getApp(input.appId);
@@ -164,7 +178,7 @@ export async function rollbackApp(input: {
164
178
  (candidate) => candidate.version === input.version,
165
179
  );
166
180
  if (!version) throw new AppRollbackVersionNotFoundError(input.version);
167
- const snapshot = rollbackSnapshot(version);
181
+ const snapshot = rollbackSnapshot(version, input, existing.definition);
168
182
 
169
183
  const migrated = await migrateAppSchema({
170
184
  appId: input.appId,
package/src/assets/key.ts CHANGED
@@ -1,7 +1,14 @@
1
1
  export const DEFAULT_ASSET_KEY = "shared/" as const;
2
2
  export const ASSET_KEY_MAX_LENGTH = 255;
3
3
 
4
- export type AssetKeyResource = "task" | "workflow" | "schedule" | "page" | `fs:${string}`;
4
+ export type AssetKeyResource =
5
+ | "task"
6
+ | "workflow"
7
+ | "schedule"
8
+ | "page"
9
+ | "app"
10
+ | "script"
11
+ | `fs:${string}`;
5
12
 
6
13
  export type SharedAssetNamespace = {
7
14
  root: "shared";
@@ -12,7 +12,7 @@ export type AssetKeyAuditCode =
12
12
  export type AssetKeyAuditIssue = {
13
13
  severity: AssetKeyAuditSeverity;
14
14
  code: AssetKeyAuditCode;
15
- entityType: "task" | "workflow" | "schedule" | "page" | "file";
15
+ entityType: "task" | "workflow" | "schedule" | "page" | "app" | "script" | "file";
16
16
  entityId: string;
17
17
  message: string;
18
18
  };
@@ -33,6 +33,8 @@ const KEY_TABLES = [
33
33
  { entityType: "workflow", sql: 'SELECT id, "key" as key FROM workflows' },
34
34
  { entityType: "schedule", sql: 'SELECT id, "key" as key FROM scheduled_tasks' },
35
35
  { entityType: "page", sql: 'SELECT id, "key" as key FROM pages' },
36
+ { entityType: "app", sql: 'SELECT id, "key" as key FROM apps' },
37
+ { entityType: "script", sql: 'SELECT id, "key" as key FROM scripts' },
36
38
  { entityType: "file", sql: 'SELECT id, "key" as key FROM asset_key_mappings' },
37
39
  ] as const;
38
40
 
@@ -57,6 +57,7 @@ export interface McpOAuthToken {
57
57
  revocationUrl: string | null;
58
58
  dcrClientId: string | null;
59
59
  dcrClientSecret: string | null;
60
+ tokenEndpointAuthMethod: string | null;
60
61
  clientSource: McpOAuthClientSource;
61
62
  status: McpOAuthStatus;
62
63
  lastErrorMessage: string | null;
@@ -74,12 +75,15 @@ export interface McpOAuthPendingRow {
74
75
  nonce: string | null;
75
76
  resourceUrl: string;
76
77
  authorizationServerIssuer: string;
78
+ registrationEndpoint: string | null;
77
79
  authorizeUrl: string;
78
80
  tokenUrl: string;
79
81
  revocationUrl: string | null;
80
82
  scopes: string | null;
83
+ registeredScopes: string[] | null;
81
84
  dcrClientId: string | null;
82
85
  dcrClientSecret: string | null;
86
+ tokenEndpointAuthMethod: string | null;
83
87
  redirectUri: string;
84
88
  finalRedirect: string | null;
85
89
  createdAt: string;
@@ -172,6 +176,10 @@ function decryptTokenRow(row: UnifiedMcpTokenRow): McpOAuthToken {
172
176
  : clientKey
173
177
  ? decryptSecret(row.clientSecret, clientKey)
174
178
  : row.clientSecret,
179
+ tokenEndpointAuthMethod:
180
+ typeof metadata.tokenEndpointAuthMethod === "string"
181
+ ? metadata.tokenEndpointAuthMethod
182
+ : null,
175
183
  clientSource,
176
184
  status: statusFromUnified(row.status),
177
185
  lastErrorMessage: row.lastErrorMessage,
@@ -199,18 +207,28 @@ function upsertMcpApp(input: {
199
207
  mcpServerId: string;
200
208
  resourceUrl: string;
201
209
  authorizationServerIssuer: string;
210
+ registrationEndpoint?: string | null;
202
211
  authorizeUrl: string;
203
212
  tokenUrl: string;
204
213
  revocationUrl?: string | null;
205
214
  scopes?: string | null;
215
+ // The scope set the client was actually REGISTERED with at the provider —
216
+ // distinct from `scopes` above, which becomes the token's GRANTED scope
217
+ // once a callback completes. Omit (undefined) when this call isn't a
218
+ // fresh registration, so the previously-recorded value survives; pass an
219
+ // explicit array (possibly empty) only at the point a registration — fresh
220
+ // or reused-as-is — is known to have happened.
221
+ registeredScopes?: string[];
206
222
  dcrClientId?: string | null;
207
223
  dcrClientSecret?: string | null;
224
+ tokenEndpointAuthMethod?: string | null;
208
225
  clientSource: McpOAuthClientSource;
209
226
  redirectUri?: string;
210
227
  }): string {
211
- // An app is reused only through an existing authorization's exact appId.
212
- // Looking up by mcpServerId alone would collapse the dormant per-user
213
- // dimension and let one user's client context overwrite another's.
228
+ // An app is reused only through an existing authorization's or pending
229
+ // attempt's exact appId. Looking up by mcpServerId alone would collapse
230
+ // the dormant per-user dimension and let one user's client context
231
+ // overwrite another's.
214
232
  const existing = input.appId
215
233
  ? (getDb()
216
234
  .query(
@@ -225,11 +243,23 @@ function upsertMcpApp(input: {
225
243
  scopes: string;
226
244
  } | null)
227
245
  : null;
246
+ // A write means the client is currently in use / believed valid — clear
247
+ // any stale `invalidated` flag left over from a prior provider rejection.
248
+ const existingMetadata = parseObject(existing?.metadata);
228
249
  const metadata = JSON.stringify({
229
- ...parseObject(existing?.metadata),
250
+ ...existingMetadata,
230
251
  resourceUrl: input.resourceUrl,
231
252
  authorizationServerIssuer: input.authorizationServerIssuer,
253
+ registrationEndpoint: input.registrationEndpoint ?? null,
232
254
  clientSource: input.clientSource,
255
+ ...(input.tokenEndpointAuthMethod !== undefined
256
+ ? { tokenEndpointAuthMethod: input.tokenEndpointAuthMethod }
257
+ : {}),
258
+ registeredScopes:
259
+ input.registeredScopes !== undefined
260
+ ? input.registeredScopes
261
+ : (existingMetadata.registeredScopes ?? null),
262
+ invalidated: false,
233
263
  });
234
264
  const encryptedClientSecret =
235
265
  input.dcrClientSecret == null || input.dcrClientSecret === ""
@@ -312,6 +342,185 @@ export function listMcpOAuthTokensForMcp(mcpServerId: string): McpOAuthToken[] {
312
342
  return rows.map(decryptTokenRow);
313
343
  }
314
344
 
345
+ // ─── DCR client reuse (avoid re-registering on every /authorize call) ────────
346
+
347
+ export interface StoredMcpOAuthClient {
348
+ appId: string;
349
+ clientId: string;
350
+ clientSecret: string | null;
351
+ authorizeUrl: string;
352
+ tokenUrl: string;
353
+ revocationUrl: string | null;
354
+ redirectUri: string;
355
+ scopes: string[];
356
+ // The scope set the client was registered with, tracked separately from
357
+ // `scopes` (which holds the token's granted scope once connected). Null
358
+ // means unknown — a legacy row from before this field was recorded.
359
+ registeredScopes: string[] | null;
360
+ resourceUrl: string;
361
+ authorizationServerIssuer: string;
362
+ registrationEndpoint: string | null;
363
+ // Null means unknown — a row written before this field was recorded. Callers
364
+ // must resolve it through authMethodForStoredClient, not the RFC default.
365
+ tokenEndpointAuthMethod: string | null;
366
+ clientSource: McpOAuthClientSource;
367
+ }
368
+
369
+ type RawAppRow = {
370
+ id: string;
371
+ clientId: string;
372
+ clientSecret: string | null;
373
+ clientSecretEncrypted: number;
374
+ authorizeUrl: string;
375
+ tokenUrl: string;
376
+ revocationUrl: string | null;
377
+ redirectUri: string;
378
+ scopes: string;
379
+ metadata: string;
380
+ };
381
+
382
+ function readMcpOAuthApp(appId: string): StoredMcpOAuthClient | null {
383
+ const row = getDb()
384
+ .query(
385
+ `SELECT id, clientId, clientSecret, clientSecretEncrypted, authorizeUrl, tokenUrl,
386
+ revocationUrl, redirectUri, scopes, metadata
387
+ FROM oauth_apps WHERE id = ?`,
388
+ )
389
+ .get(appId) as RawAppRow | null;
390
+ if (!row || !row.clientId) return null;
391
+
392
+ const metadata = parseObject(row.metadata);
393
+ if (metadata.invalidated === true) return null;
394
+
395
+ const clientSource =
396
+ metadata.clientSource === "manual" ||
397
+ metadata.clientSource === "dcr" ||
398
+ metadata.clientSource === "preregistered"
399
+ ? metadata.clientSource
400
+ : "dcr";
401
+ // Manual clients are resolved through manualClientFromToken (http layer);
402
+ // never surface them through this reuse path.
403
+ if (clientSource === "manual") return null;
404
+
405
+ const clientKey = row.clientSecretEncrypted === 1 ? getEncryptionKey() : null;
406
+ return {
407
+ appId: row.id,
408
+ clientId: row.clientId,
409
+ clientSecret:
410
+ row.clientSecret == null
411
+ ? null
412
+ : clientKey
413
+ ? decryptSecret(row.clientSecret, clientKey)
414
+ : row.clientSecret,
415
+ authorizeUrl: row.authorizeUrl,
416
+ tokenUrl: row.tokenUrl,
417
+ revocationUrl: row.revocationUrl,
418
+ redirectUri: row.redirectUri,
419
+ scopes: (() => {
420
+ try {
421
+ const parsed = JSON.parse(row.scopes || "[]");
422
+ return Array.isArray(parsed) ? (parsed as string[]) : [];
423
+ } catch {
424
+ return [];
425
+ }
426
+ })(),
427
+ resourceUrl: typeof metadata.resourceUrl === "string" ? metadata.resourceUrl : "",
428
+ authorizationServerIssuer:
429
+ typeof metadata.authorizationServerIssuer === "string"
430
+ ? metadata.authorizationServerIssuer
431
+ : "",
432
+ registrationEndpoint:
433
+ typeof metadata.registrationEndpoint === "string" ? metadata.registrationEndpoint : null,
434
+ registeredScopes: Array.isArray(metadata.registeredScopes)
435
+ ? (metadata.registeredScopes as string[])
436
+ : null,
437
+ tokenEndpointAuthMethod:
438
+ typeof metadata.tokenEndpointAuthMethod === "string"
439
+ ? metadata.tokenEndpointAuthMethod
440
+ : null,
441
+ clientSource,
442
+ };
443
+ }
444
+
445
+ function rawPendingAppIdForUser(mcpServerId: string, userId: string | null): string | null {
446
+ const row = getDb()
447
+ .query(
448
+ `SELECT p.appId FROM oauth_pending p
449
+ JOIN oauth_apps a ON a.id = p.appId
450
+ WHERE p.flow = 'mcp' AND a.mcpServerId = ?
451
+ AND ${userId == null ? "p.userId IS NULL" : "p.userId = ?"}
452
+ ORDER BY p.createdAt DESC LIMIT 1`,
453
+ )
454
+ .get(...(userId == null ? [mcpServerId] : [mcpServerId, userId])) as { appId: string } | null;
455
+ return row?.appId ?? null;
456
+ }
457
+
458
+ /**
459
+ * Find a previously-registered (or manually-preregistered, excluded here)
460
+ * DCR client that can be reused for this connector+user instead of running a
461
+ * fresh RFC 7591 registration. Checks the existing token's app first (covers
462
+ * re-authorizing an already-connected DCR client), then the most recent
463
+ * still-live pending attempt's app (covers retrying an abandoned flow before
464
+ * the 10-minute GC sweep). Callers must still verify AS identity
465
+ * (issuer/registrationEndpoint/redirectUri) against fresh discovery before
466
+ * trusting the result — this only returns what's stored.
467
+ */
468
+ export function findReusableMcpOAuthClient(
469
+ mcpServerId: string,
470
+ userId: string | null = null,
471
+ ): StoredMcpOAuthClient | null {
472
+ const tokenRow = rawMcpToken(mcpServerId, userId);
473
+ if (tokenRow) {
474
+ const client = readMcpOAuthApp(tokenRow.appId);
475
+ if (client) return client;
476
+ }
477
+ const pendingAppId = rawPendingAppIdForUser(mcpServerId, userId);
478
+ if (pendingAppId) {
479
+ const client = readMcpOAuthApp(pendingAppId);
480
+ if (client) return client;
481
+ }
482
+ return null;
483
+ }
484
+
485
+ /**
486
+ * Mark an app's stored DCR client as invalid without deleting it (deleting
487
+ * would cascade and destroy authorization history). Idempotent per
488
+ * client_id: a client stays the same row's `clientId` until the next
489
+ * successful registration clears `invalidated` (see `upsertMcpApp`), so once
490
+ * this fires, later invalid_client errors against that SAME still-stored
491
+ * client — from the other call sites, or repeated automatic-refresh retries
492
+ * before the next /authorize re-registers — are no-ops instead of redundant
493
+ * writes.
494
+ */
495
+ function invalidateMcpOAuthApp(appId: string): void {
496
+ const row = getDb().query("SELECT metadata FROM oauth_apps WHERE id = ?").get(appId) as {
497
+ metadata: string;
498
+ } | null;
499
+ if (!row) return;
500
+
501
+ const meta = parseObject(row.metadata);
502
+ if (meta.invalidated === true) return;
503
+
504
+ const metadata = JSON.stringify({ ...meta, invalidated: true });
505
+ getDb()
506
+ .query(
507
+ "UPDATE oauth_apps SET metadata = ?, updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
508
+ )
509
+ .run(metadata, appId);
510
+ }
511
+
512
+ /**
513
+ * Mark the stored DCR client for this connector+user as invalid. The next
514
+ * /authorize call's findReusableMcpOAuthClient will skip it and register
515
+ * fresh exactly once.
516
+ */
517
+ export function invalidateMcpOAuthClient(mcpServerId: string, userId: string | null = null): void {
518
+ const tokenRow = rawMcpToken(mcpServerId, userId);
519
+ const appId = tokenRow?.appId ?? rawPendingAppIdForUser(mcpServerId, userId);
520
+ if (!appId) return;
521
+ invalidateMcpOAuthApp(appId);
522
+ }
523
+
315
524
  export interface UpsertMcpOAuthTokenInput {
316
525
  mcpServerId: string;
317
526
  userId?: string | null;
@@ -322,16 +531,23 @@ export interface UpsertMcpOAuthTokenInput {
322
531
  scope?: string | null;
323
532
  resourceUrl: string;
324
533
  authorizationServerIssuer: string;
534
+ registrationEndpoint?: string | null;
325
535
  authorizeUrl: string;
326
536
  tokenUrl: string;
327
537
  revocationUrl?: string | null;
538
+ // Pass the pending row's registeredScopes (undefined if it doesn't know
539
+ // one) — never pass an explicit null here, or a reuse flow that already
540
+ // has a correctly-tracked value on the still-live app row gets clobbered.
541
+ registeredScopes?: string[];
328
542
  dcrClientId?: string | null;
329
543
  dcrClientSecret?: string | null;
544
+ tokenEndpointAuthMethod?: string | null;
330
545
  clientSource: McpOAuthClientSource;
331
546
  status?: McpOAuthStatus;
332
547
  lastErrorMessage?: string | null;
333
548
  lastRefreshedAt?: string | null;
334
549
  connectedByUserId?: string | null;
550
+ redirectUri?: string;
335
551
  }
336
552
 
337
553
  export function upsertMcpOAuthToken(input: UpsertMcpOAuthTokenInput): void {
@@ -343,12 +559,16 @@ export function upsertMcpOAuthToken(input: UpsertMcpOAuthTokenInput): void {
343
559
  mcpServerId: input.mcpServerId,
344
560
  resourceUrl: input.resourceUrl,
345
561
  authorizationServerIssuer: input.authorizationServerIssuer,
562
+ registrationEndpoint: input.registrationEndpoint ?? null,
346
563
  authorizeUrl: input.authorizeUrl,
347
564
  tokenUrl: input.tokenUrl,
348
565
  revocationUrl: input.revocationUrl,
566
+ redirectUri: input.redirectUri,
349
567
  scopes: input.scope,
568
+ registeredScopes: input.registeredScopes,
350
569
  dcrClientId: input.dcrClientId,
351
570
  dcrClientSecret: input.dcrClientSecret,
571
+ tokenEndpointAuthMethod: input.tokenEndpointAuthMethod,
352
572
  clientSource: input.clientSource,
353
573
  });
354
574
  upsertAuthorization({
@@ -423,6 +643,13 @@ export function deleteMcpOAuthToken(mcpServerId: string, userId: string | null =
423
643
  WHERE id = ?`,
424
644
  )
425
645
  .run(encryptSecret("", getEncryptionKey()), existing.id);
646
+ if (result.changes === 1) {
647
+ // Disconnect is the canonical user recovery gesture. Clear the stored
648
+ // DCR client too, or Reconnect silently hands back the same client_id —
649
+ // rawMcpToken has no status filter, so findReusableMcpOAuthClient would
650
+ // otherwise still surface a "revoked" token's client as reusable.
651
+ invalidateMcpOAuthApp(existing.appId);
652
+ }
426
653
  return result.changes === 1;
427
654
  }
428
655
 
@@ -441,54 +668,91 @@ export interface InsertMcpOAuthPendingInput {
441
668
  nonce?: string | null;
442
669
  resourceUrl: string;
443
670
  authorizationServerIssuer: string;
671
+ registrationEndpoint?: string | null;
444
672
  authorizeUrl: string;
445
673
  tokenUrl: string;
446
674
  revocationUrl?: string | null;
447
675
  scopes?: string | null;
676
+ // Set only when this pending attempt just performed a fresh DCR
677
+ // registration (or is otherwise the authority on what the client was
678
+ // registered with) — see upsertMcpApp's `registeredScopes` doc.
679
+ registeredScopes?: string[];
448
680
  dcrClientId?: string | null;
449
681
  dcrClientSecret?: string | null;
682
+ tokenEndpointAuthMethod?: string | null;
450
683
  redirectUri: string;
451
684
  finalRedirect?: string | null;
452
685
  }
453
686
 
454
687
  export function insertMcpOAuthPending(input: InsertMcpOAuthPendingInput): void {
455
688
  getDb().transaction(() => {
456
- const existingToken = rawMcpToken(input.mcpServerId, input.userId ?? null);
689
+ const userId = input.userId ?? null;
690
+ const existingToken = rawMcpToken(input.mcpServerId, userId);
457
691
  const clientSource = existingToken
458
692
  ? decryptTokenRow(existingToken).clientSource
459
693
  : input.dcrClientId
460
694
  ? "dcr"
461
695
  : "preregistered";
462
- // A pending attempt must not mutate the connected app. If no authorization
463
- // exists yet, create a short-lived app solely to satisfy oauth_pending's FK;
464
- // consume/GC removes it when it remains orphaned.
696
+ // A pending attempt must not mutate a *valid* connected app — a different
697
+ // client context might be tried concurrently while the old one keeps
698
+ // working. But if the connected app was invalidated (provider rejected
699
+ // it — see invalidateMcpOAuthClient), this pending attempt IS the
700
+ // recovery registration for that exact appId, so it must update the row
701
+ // in place; otherwise the invalidated flag never clears and every future
702
+ // /authorize call re-registers again (an infinite-registration loop).
703
+ // If no authorization exists yet, reuse the most recent still-live
704
+ // pending attempt's app for this same connector+user (this is what lets
705
+ // a retried/abandoned authorize call share one DCR client instead of
706
+ // registering a fresh one — see findReusableMcpOAuthClient in the http
707
+ // layer) instead of always creating a new row; consume/GC removes it
708
+ // once it's orphaned.
709
+ const existingTokenAppValid = existingToken
710
+ ? readMcpOAuthApp(existingToken.appId) != null
711
+ : false;
712
+ const reuseAppId = existingToken
713
+ ? existingToken.appId
714
+ : rawPendingAppIdForUser(input.mcpServerId, userId);
465
715
  const appId =
466
- existingToken?.appId ??
467
- upsertMcpApp({
468
- mcpServerId: input.mcpServerId,
469
- resourceUrl: input.resourceUrl,
470
- authorizationServerIssuer: input.authorizationServerIssuer,
471
- authorizeUrl: input.authorizeUrl,
472
- tokenUrl: input.tokenUrl,
473
- revocationUrl: input.revocationUrl,
474
- scopes: input.scopes,
475
- dcrClientId: input.dcrClientId,
476
- dcrClientSecret: input.dcrClientSecret,
477
- clientSource,
478
- redirectUri: input.redirectUri,
479
- });
716
+ existingToken && existingTokenAppValid
717
+ ? existingToken.appId
718
+ : upsertMcpApp({
719
+ ...(reuseAppId ? { appId: reuseAppId } : {}),
720
+ mcpServerId: input.mcpServerId,
721
+ resourceUrl: input.resourceUrl,
722
+ authorizationServerIssuer: input.authorizationServerIssuer,
723
+ registrationEndpoint: input.registrationEndpoint ?? null,
724
+ authorizeUrl: input.authorizeUrl,
725
+ tokenUrl: input.tokenUrl,
726
+ revocationUrl: input.revocationUrl,
727
+ scopes: input.scopes,
728
+ registeredScopes: input.registeredScopes,
729
+ dcrClientId: input.dcrClientId,
730
+ dcrClientSecret: input.dcrClientSecret,
731
+ tokenEndpointAuthMethod: input.tokenEndpointAuthMethod,
732
+ clientSource,
733
+ redirectUri: input.redirectUri,
734
+ });
480
735
  const contextJson = JSON.stringify({
481
736
  resourceUrl: input.resourceUrl,
482
737
  authorizationServerIssuer: input.authorizationServerIssuer,
738
+ registrationEndpoint: input.registrationEndpoint ?? null,
483
739
  authorizeUrl: input.authorizeUrl,
484
740
  tokenUrl: input.tokenUrl,
485
741
  revocationUrl: input.revocationUrl ?? null,
486
742
  scopes: input.scopes ?? null,
743
+ // Carried through the pending row so a first-ever connect's callback
744
+ // can restore it: consumeMcpOAuthPending's deleteOrphanMcpApp deletes
745
+ // this app the moment the pending row is consumed (no authorization
746
+ // exists yet), so the metadata written by insertMcpOAuthPending's own
747
+ // upsertMcpApp call above does not survive to the following
748
+ // upsertMcpOAuthToken call — it re-creates the row from scratch.
749
+ registeredScopes: input.registeredScopes ?? null,
487
750
  dcrClientId: input.dcrClientId ?? null,
488
751
  dcrClientSecret:
489
752
  input.dcrClientSecret == null
490
753
  ? null
491
754
  : encryptSecret(input.dcrClientSecret, getEncryptionKey()),
755
+ tokenEndpointAuthMethod: input.tokenEndpointAuthMethod ?? null,
492
756
  clientSource,
493
757
  });
494
758
  getDb()
@@ -501,12 +765,12 @@ export function insertMcpOAuthPending(input: InsertMcpOAuthPendingInput): void {
501
765
  .run(
502
766
  input.state,
503
767
  appId,
504
- input.userId ? `user:${input.userId}` : "default",
768
+ userId ? `user:${userId}` : "default",
505
769
  encryptSecret(input.codeVerifier, getEncryptionKey()),
506
770
  input.nonce ?? null,
507
771
  input.redirectUri,
508
772
  input.finalRedirect ?? null,
509
- input.userId ?? null,
773
+ userId,
510
774
  contextJson,
511
775
  );
512
776
  })();
@@ -569,14 +833,23 @@ export function consumeMcpOAuthPending(state: string): McpOAuthPendingRow | null
569
833
  typeof context.authorizationServerIssuer === "string"
570
834
  ? context.authorizationServerIssuer
571
835
  : "",
836
+ registrationEndpoint:
837
+ typeof context.registrationEndpoint === "string" ? context.registrationEndpoint : null,
572
838
  authorizeUrl: typeof context.authorizeUrl === "string" ? context.authorizeUrl : "",
573
839
  tokenUrl: typeof context.tokenUrl === "string" ? context.tokenUrl : "",
574
840
  revocationUrl: typeof context.revocationUrl === "string" ? context.revocationUrl : null,
575
841
  scopes: typeof context.scopes === "string" ? context.scopes : null,
842
+ registeredScopes: Array.isArray(context.registeredScopes)
843
+ ? (context.registeredScopes as string[])
844
+ : null,
576
845
  dcrClientId: typeof context.dcrClientId === "string" ? context.dcrClientId : null,
577
846
  dcrClientSecret: encryptedClientSecret
578
847
  ? decryptSecret(encryptedClientSecret, getEncryptionKey())
579
848
  : null,
849
+ tokenEndpointAuthMethod:
850
+ typeof context.tokenEndpointAuthMethod === "string"
851
+ ? context.tokenEndpointAuthMethod
852
+ : null,
580
853
  redirectUri: row.redirectUri,
581
854
  finalRedirect: row.finalRedirect,
582
855
  createdAt: normalizeDateRequired(row.createdAt),
package/src/be/db.ts CHANGED
@@ -3936,6 +3936,22 @@ function assetSummaryQueries(types: Set<AssetEntityType>): string[] {
3936
3936
  FROM pages`,
3937
3937
  );
3938
3938
  }
3939
+ if (types.has("app")) {
3940
+ queries.push(
3941
+ `SELECT 'app' AS entityType, id, "key", name AS label,
3942
+ updated_at AS updatedAt, NULL AS providerId, NULL AS providerOrgId,
3943
+ NULL AS providerDriveId, NULL AS providerKey
3944
+ FROM apps`,
3945
+ );
3946
+ }
3947
+ if (types.has("script")) {
3948
+ queries.push(
3949
+ `SELECT 'script' AS entityType, id, "key", name AS label,
3950
+ updatedAt, NULL AS providerId, NULL AS providerOrgId,
3951
+ NULL AS providerDriveId, NULL AS providerKey
3952
+ FROM scripts`,
3953
+ );
3954
+ }
3939
3955
  if (types.has("file")) {
3940
3956
  queries.push(
3941
3957
  `SELECT 'file' AS entityType, id, "key", provider_key AS label,
@@ -3951,7 +3967,9 @@ function assetSummaryQueries(types: Set<AssetEntityType>): string[] {
3951
3967
 
3952
3968
  export function listAssetSummaries(filters?: AssetSummaryFilters): AssetSummary[] {
3953
3969
  const requestedTypes = new Set<AssetEntityType>(
3954
- filters?.types?.length ? filters.types : ["task", "workflow", "schedule", "page", "file"],
3970
+ filters?.types?.length
3971
+ ? filters.types
3972
+ : ["task", "workflow", "schedule", "page", "app", "script", "file"],
3955
3973
  );
3956
3974
  const queries = assetSummaryQueries(requestedTypes);
3957
3975
  if (queries.length === 0) return [];
@@ -4014,6 +4032,18 @@ function currentAssetKey(entityType: AssetEntityType, id: string): string | null
4014
4032
  .prepare<{ key: string }, [string]>('SELECT "key" AS key FROM pages WHERE id = ?')
4015
4033
  .get(id)?.key ?? null
4016
4034
  );
4035
+ case "app":
4036
+ return (
4037
+ getDb()
4038
+ .prepare<{ key: string }, [string]>('SELECT "key" AS key FROM apps WHERE id = ?')
4039
+ .get(id)?.key ?? null
4040
+ );
4041
+ case "script":
4042
+ return (
4043
+ getDb()
4044
+ .prepare<{ key: string }, [string]>('SELECT "key" AS key FROM scripts WHERE id = ?')
4045
+ .get(id)?.key ?? null
4046
+ );
4017
4047
  case "file":
4018
4048
  return getAssetKeyMapping(id)?.key ?? null;
4019
4049
  }
@@ -4109,6 +4139,17 @@ export function moveAssetKey(input: {
4109
4139
  input.id,
4110
4140
  ]);
4111
4141
  break;
4142
+ case "app":
4143
+ getDb().run('UPDATE apps SET "key" = ?, updated_at = ? WHERE id = ?', [key, now, input.id]);
4144
+ break;
4145
+ case "script":
4146
+ getDb().run('UPDATE scripts SET "key" = ?, updatedAt = ?, updated_by = ? WHERE id = ?', [
4147
+ key,
4148
+ now,
4149
+ input.changedBy ?? null,
4150
+ input.id,
4151
+ ]);
4152
+ break;
4112
4153
  case "file":
4113
4154
  getDb().run(
4114
4155
  'UPDATE asset_key_mappings SET "key" = ?, updated_at = ?, updated_by = ? WHERE id = ?',