@desplega.ai/agent-swarm 1.119.6 → 1.121.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 (207) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-qe5zjcbr.js → actions-mwshtkf7.js} +6 -6
  3. package/dist/{anthropic-messages-qjxzsabf.js → anthropic-messages-4gn944cn.js} +29 -13
  4. package/dist/{app-31x7k3vj.js → app-skbty91x.js} +4 -4
  5. package/dist/{assistant-4crt9dve.js → assistant-tg7dhfj8.js} +13 -13
  6. package/dist/{azure-openai-responses-ntfd05zd.js → azure-openai-responses-79zc0wyy.js} +28 -13
  7. package/dist/{boot-reembed-8g69ttz4.js → boot-reembed-ntjmeyh2.js} +4 -4
  8. package/dist/{boot-reembed-q648j733.js → boot-reembed-q5x0xpcp.js} +5 -5
  9. package/dist/{boot-scrub-logs-sfmzd2hj.js → boot-scrub-logs-5gznsfbe.js} +4 -4
  10. package/dist/{claude-adapter-9n3nzt65.js → claude-adapter-vxvcym3r.js} +3 -3
  11. package/dist/{claude-managed-adapter-pv2nbqy4.js → claude-managed-adapter-g807894s.js} +2 -2
  12. package/dist/{cli-bpvgs2y6.js → cli-072t49hf.js} +2123 -1533
  13. package/dist/{cli-g6qz2kts.js → cli-0df2g86x.js} +336 -18
  14. package/dist/cli-1728qh70.js +791 -0
  15. package/dist/{cli-vg1jxfqz.js → cli-3b1ayz83.js} +5 -5
  16. package/dist/{cli-r93nz80j.js → cli-3fxqwfh3.js} +1 -1
  17. package/dist/{cli-w6rhc30s.js → cli-3jrdwnn9.js} +20 -5
  18. package/dist/{cli-pasyk3vc.js → cli-46etyprn.js} +10 -5
  19. package/dist/{cli-433c0f4b.js → cli-4pfweq4x.js} +8 -8
  20. package/dist/{cli-2r7s8jhp.js → cli-5c4327dx.js} +7 -1
  21. package/dist/{cli-4s84mw72.js → cli-5hpvd15x.js} +2 -2
  22. package/dist/{cli-branks1w.js → cli-6s3ytc5x.js} +1 -1
  23. package/dist/{cli-da3x3btq.js → cli-7pt9sc83.js} +2 -2
  24. package/dist/{cli-2gv8ygwr.js → cli-7pzae66m.js} +2 -2
  25. package/dist/{cli-qs7nb3zn.js → cli-8wrympd7.js} +27 -1
  26. package/dist/{cli-qw1ccvxm.js → cli-95rbe11h.js} +70 -51
  27. package/dist/{cli-6rvm3jkk.js → cli-agdrxyex.js} +1 -1
  28. package/dist/{cli-wr706gez.js → cli-av2s7my4.js} +167 -66
  29. package/dist/{cli-59jy217p.js → cli-avw9ev3x.js} +55 -21
  30. package/dist/{cli-fkxxh5d4.js → cli-b2mqkg1h.js} +2 -2
  31. package/dist/{cli-1arw00dp.js → cli-ccjwgarc.js} +12 -3
  32. package/dist/{cli-pscrc6tw.js → cli-ch7t5v7n.js} +5 -0
  33. package/dist/{cli-tghre7g6.js → cli-cw70hymf.js} +1 -0
  34. package/dist/{cli-n61x2aph.js → cli-dmzv0986.js} +816 -1335
  35. package/dist/{cli-d1reabed.js → cli-g9vva2qh.js} +2 -2
  36. package/dist/{cli-ner6af7m.js → cli-gdpx70nc.js} +24048 -23715
  37. package/dist/{cli-96feg24n.js → cli-ghq3v4c9.js} +2 -2
  38. package/dist/{cli-wxbnjgce.js → cli-ky8esb8t.js} +3 -3
  39. package/dist/{cli-gdqdkr3j.js → cli-nf7sadev.js} +2 -2
  40. package/dist/{cli-h6cf69x5.js → cli-p9w7kb3h.js} +1 -1
  41. package/dist/{cli-xeatwfbe.js → cli-ry656xem.js} +24 -17
  42. package/dist/{cli-q8dway4w.js → cli-s8bdcke2.js} +1 -1
  43. package/dist/cli-s8s499xy.js +81 -0
  44. package/dist/{cli-sbkwvn5e.js → cli-srjqn7fd.js} +1 -1
  45. package/dist/cli-tv7vekye.js +367 -0
  46. package/dist/{cli-gg5bd32n.js → cli-vg3zrw6h.js} +4 -4
  47. package/dist/{cli-vx0y68jv.js → cli-vp9r4ap4.js} +5 -4
  48. package/dist/{cli-mg280wsv.js → cli-we156bmz.js} +1 -1
  49. package/dist/{cli-86fdnerz.js → cli-wwsaj97q.js} +9 -4
  50. package/dist/{cli-e0y603nn.js → cli-x0e0yvnq.js} +101 -1
  51. package/dist/{cli-mw3xpgwb.js → cli-x4apc9t3.js} +4 -4
  52. package/dist/cli.js +26 -21
  53. package/dist/{codex-adapter-6j2b38tg.js → codex-adapter-mmy741zd.js} +8 -9
  54. package/dist/{codex-session-runner-vz215wgw.js → codex-session-runner-sazmx9zt.js} +8 -9
  55. package/dist/{commands-6xr20jbe.js → commands-cxxye5e4.js} +3 -3
  56. package/dist/{db-1mmvk1z4.js → db-ab1954b7.js} +5 -3
  57. package/dist/{google-generative-ai-n1d3xt4n.js → google-generative-ai-wwa2ked5.js} +14 -18
  58. package/dist/{google-vertex-mxst23gy.js → google-vertex-71xmdd7n.js} +14 -18
  59. package/dist/{handlers-xvrknk8w.js → handlers-fmfe2343.js} +13 -13
  60. package/dist/{hook-qm7he8wn.js → hook-ygnw3cvt.js} +7 -8
  61. package/dist/{http-hj08zq67.js → http-kactcc0z.js} +1128 -536
  62. package/dist/{index-688jph6a.js → index-6erq0wvk.js} +11 -11
  63. package/dist/{index-gze4fg2w.js → index-g86j2yt5.js} +9 -9
  64. package/dist/{index-etz7d8md.js → index-gmzs6a2q.js} +10 -10
  65. package/dist/{index-wj8dfpwh.js → index-hmp82wn0.js} +12 -12
  66. package/dist/{keepalive-94wn9yxw.js → keepalive-3cvt26dw.js} +18 -14
  67. package/dist/{lead-rvcqkp11.js → lead-ysf26cjr.js} +31 -32
  68. package/dist/{maintenance-qhfc4dnp.js → maintenance-9npwptbb.js} +5 -5
  69. package/dist/{mistral-conversations-ykcg66za.js → mistral-conversations-26ya4yah.js} +17 -13
  70. package/dist/{oauth-refresh-sweep-rj8wgvsw.js → oauth-refresh-sweep-0r2v76vw.js} +23 -17
  71. package/dist/{onboard-8bv8q1wc.js → onboard-ddb1674a.js} +2 -2
  72. package/dist/{openai-codex-responses-zy0907j9.js → openai-codex-responses-6rr2ye0s.js} +78 -38
  73. package/dist/{openai-completions-xk4p56n9.js → openai-completions-zfa9nkvt.js} +130 -33
  74. package/dist/{openai-responses-4gv3ca5d.js → openai-responses-9wmdks0g.js} +38 -14
  75. package/dist/{opencode-adapter-5bwzvbwj.js → opencode-adapter-h6rkxwrk.js} +2 -2
  76. package/dist/{openrouter-images-4fsrrzes.js → openrouter-images-dgq98729.js} +10 -3
  77. package/dist/{otel-impl-1kf6fr2m.js → otel-impl-b43bkq8e.js} +4 -4
  78. package/dist/{pi-messages-zb8v76wx.js → pi-messages-nzjbbz47.js} +4 -6
  79. package/dist/{pi-mono-adapter-9sg69kg3.js → pi-mono-adapter-ke789738.js} +2328 -1281
  80. package/dist/{pricing-refresh-e84tar2s.js → pricing-refresh-2rqf114z.js} +5 -5
  81. package/dist/{rbac-roles-atytq5ch.js → rbac-roles-5wkhc8xv.js} +5 -5
  82. package/dist/{rbac-roles-pg0zvnwn.js → rbac-roles-6vtafvzb.js} +4 -4
  83. package/dist/{seed-pricing-xd6nxq7k.js → seed-pricing-23wrb873.js} +4 -4
  84. package/dist/{setup-4z389yk0.js → setup-6rny4fk6.js} +2 -2
  85. package/dist/{worker-m61r0t7m.js → worker-yapewe14.js} +31 -32
  86. package/openapi.json +2671 -1811
  87. package/package.json +10 -5
  88. package/plugin/commands/swarm-chat.md +2 -0
  89. package/plugin/pi-skills/swarm-chat/SKILL.md +2 -0
  90. package/src/be/connection-bindings-blob-migration.ts +123 -0
  91. package/src/be/db-queries/mcp-oauth.ts +409 -247
  92. package/src/be/db-queries/oauth.ts +813 -131
  93. package/src/be/db.ts +97 -6
  94. package/src/be/migrations/117_unified_oauth.sql +624 -0
  95. package/src/be/migrations/119_agent_avatar.sql +7 -0
  96. package/src/be/migrations/120_task_title.sql +3 -0
  97. package/src/be/oauth-credential-bindings.ts +48 -14
  98. package/src/be/oauth-encryption-backfill.ts +84 -0
  99. package/src/be/oauth-refresh-sweep.ts +38 -23
  100. package/src/be/script-connections.ts +1130 -163
  101. package/src/be/script-credential-broker.ts +37 -27
  102. package/src/be/scripts/typecheck.ts +3 -2
  103. package/src/be/seed-capabilities.ts +62 -0
  104. package/src/be/seed-pricing.ts +1 -1
  105. package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +1 -1
  106. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +1 -1
  107. package/src/be/vendored-openapi.ts +98 -0
  108. package/src/commands/runner.ts +69 -6
  109. package/src/http/agents.ts +23 -5
  110. package/src/http/all-routes.ts +1 -0
  111. package/src/http/core.ts +8 -5
  112. package/src/http/index.ts +18 -5
  113. package/src/http/mcp-bridge.ts +5 -2
  114. package/src/http/mcp-oauth.ts +103 -101
  115. package/src/http/oauth-callback.ts +332 -0
  116. package/src/http/oauth-generic.ts +14 -54
  117. package/src/http/poll.ts +5 -1
  118. package/src/http/script-connections.ts +651 -170
  119. package/src/http/scripts.ts +4 -2
  120. package/src/http/sessions.ts +1 -1
  121. package/src/http/tasks.ts +35 -0
  122. package/src/http/trackers/jira.ts +22 -34
  123. package/src/http/trackers/linear.ts +21 -36
  124. package/src/http/x.ts +6 -2
  125. package/src/jira/app.ts +12 -6
  126. package/src/jira/oauth.ts +48 -48
  127. package/src/linear/app.ts +44 -2
  128. package/src/linear/oauth.ts +12 -37
  129. package/src/oauth/app-validation.ts +27 -9
  130. package/src/oauth/ensure-mcp-token.ts +1 -0
  131. package/src/oauth/ensure-token.ts +331 -108
  132. package/src/oauth/identity-capture.ts +103 -0
  133. package/src/oauth/keepalive.ts +14 -9
  134. package/src/oauth/presets.ts +242 -0
  135. package/src/oauth/wrapper.ts +197 -76
  136. package/src/prompts/base-prompt.ts +48 -4
  137. package/src/prompts/session-templates.ts +13 -1
  138. package/src/providers/claude-managed-models.ts +8 -0
  139. package/src/rbac/legacy-policy.ts +2 -1
  140. package/src/rbac/permissions.ts +7 -3
  141. package/src/scripts-runtime/credential-broker/broker.ts +51 -8
  142. package/src/scripts-runtime/credential-broker/fetch-patch.ts +30 -3
  143. package/src/scripts-runtime/credential-broker/index.ts +1 -1
  144. package/src/scripts-runtime/credential-broker/types.ts +55 -8
  145. package/src/scripts-runtime/egress-secrets.ts +6 -2
  146. package/src/scripts-runtime/eval-harness.ts +2 -2
  147. package/src/scripts-runtime/executors/types.ts +2 -1
  148. package/src/scripts-runtime/loader.ts +3 -0
  149. package/src/scripts-runtime/types/stdlib.d.ts +11 -0
  150. package/src/server.ts +230 -126
  151. package/src/tests/agent-avatar-resolution.test.ts +123 -0
  152. package/src/tests/agents-list-model-display.test.ts +10 -0
  153. package/src/tests/base-prompt.test.ts +72 -0
  154. package/src/tests/claude-managed-adapter.test.ts +6 -0
  155. package/src/tests/connection-embedded-auth.test.ts +709 -0
  156. package/src/tests/context-window.test.ts +1 -0
  157. package/src/tests/credential-broker.test.ts +123 -125
  158. package/src/tests/db-queries-oauth.test.ts +126 -1
  159. package/src/tests/ensure-token.test.ts +44 -2
  160. package/src/tests/list-endpoint-slimming.test.ts +45 -0
  161. package/src/tests/mcp-oauth-manual-client.test.ts +20 -1
  162. package/src/tests/mcp-oauth-queries.test.ts +154 -8
  163. package/src/tests/oauth-access-token-tool.test.ts +37 -1
  164. package/src/tests/oauth-callback-flow.test.ts +534 -0
  165. package/src/tests/oauth-credential-bindings.test.ts +50 -22
  166. package/src/tests/oauth-presets.test.ts +275 -0
  167. package/src/tests/oauth-refresh-failure.test.ts +263 -0
  168. package/src/tests/oauth-refresh-sweep.test.ts +83 -3
  169. package/src/tests/oauth-wrapper.test.ts +77 -55
  170. package/src/tests/prompt-template-session.test.ts +6 -5
  171. package/src/tests/rbac-charact-misc-tools.test.ts +60 -0
  172. package/src/tests/rbac-engine.test.ts +2 -1
  173. package/src/tests/script-connections-http.test.ts +356 -11
  174. package/src/tests/script-connections.test.ts +102 -0
  175. package/src/tests/sdk-allowlist.test.ts +4 -1
  176. package/src/tests/seed-capabilities.test.ts +92 -0
  177. package/src/tests/session-costs-model-key-normalize.test.ts +1 -0
  178. package/src/tests/sessions.test.ts +46 -0
  179. package/src/tests/spec-base-url.test.ts +266 -0
  180. package/src/tests/status.test.ts +3 -4
  181. package/src/tests/swarm-config-reserved-keys.test.ts +4 -2
  182. package/src/tests/task-title-route.test.ts +106 -0
  183. package/src/tests/tool-annotations.test.ts +6 -3
  184. package/src/tests/tracker-fold.test.ts +412 -0
  185. package/src/tests/unified-oauth-migration.test.ts +455 -0
  186. package/src/tests/vendored-openapi.test.ts +263 -0
  187. package/src/tools/credential-bindings/tool.ts +232 -77
  188. package/src/tools/oauth-access-token.ts +55 -6
  189. package/src/tools/script-connections/tool.ts +73 -53
  190. package/src/tools/swarm-config/set-config.ts +24 -50
  191. package/src/tools/update-profile.ts +15 -5
  192. package/src/tracker/types.ts +12 -0
  193. package/src/types.ts +29 -0
  194. package/src/utils/context-window.ts +1 -0
  195. package/src/workflows/executors/swarm-script.ts +6 -2
  196. package/vendored-openapi/README.md +22 -0
  197. package/vendored-openapi/github.json +7759 -0
  198. package/vendored-openapi/gmail.json +393 -0
  199. package/vendored-openapi/jira.json +3396 -0
  200. package/vendored-openapi/linear.json +252 -0
  201. package/vendored-openapi/manifest.json +128 -0
  202. package/vendored-openapi/slack.json +2895 -0
  203. package/dist/cli-0pygq9r1.js +0 -429
  204. package/dist/cli-8hhms4hp.js +0 -393
  205. package/dist/cli-z9gzq07x.js +0 -27
  206. package/src/scripts-runtime/credential-broker/store.ts +0 -72
  207. package/dist/{otel-g299k41b.js → otel-63jr5v38.js} +1 -1
@@ -1,107 +1,706 @@
1
1
  import type { OAuthApp, OAuthTokens } from "../../tracker/types";
2
+ import { decryptSecret, encryptSecret, getEncryptionKey } from "../crypto";
2
3
  import { normalizeDateRequired } from "../date-utils";
3
4
  import { getDb } from "../db";
4
5
 
5
- // ── OAuth Apps ──
6
+ type OAuthAppRow = Omit<
7
+ OAuthApp,
8
+ "clientSecretEncrypted" | "requiresRefreshTokenRotation" | "scopes"
9
+ > & {
10
+ clientSecret: string | null;
11
+ clientSecretEncrypted: number;
12
+ requiresRefreshTokenRotation: number;
13
+ scopes: string;
14
+ };
15
+
16
+ export type OAuthAuthorizationStatus = "active" | "refresh-failed" | "expired" | "revoked";
17
+
18
+ type OAuthAuthorizationRow = {
19
+ id: string;
20
+ appId: string;
21
+ label: string;
22
+ userId: string | null;
23
+ accountEmail: string | null;
24
+ identityJson: string | null;
25
+ accessToken: string;
26
+ refreshToken: string | null;
27
+ tokenType: string;
28
+ expiresAt: string | null;
29
+ scope: string | null;
30
+ tokensEncrypted: number;
31
+ tokenVersion: number;
32
+ status: OAuthAuthorizationStatus;
33
+ lastErrorMessage: string | null;
34
+ lastRefreshedAt: string | null;
35
+ connectedByUserId: string | null;
36
+ createdAt: string;
37
+ updatedAt: string;
38
+ };
39
+
40
+ export type OAuthAuthorization = Omit<OAuthAuthorizationRow, "tokensEncrypted"> & {
41
+ tokensEncrypted: boolean;
42
+ };
43
+
44
+ function parseScopeList(value: string): string[] {
45
+ try {
46
+ const parsed = JSON.parse(value);
47
+ if (Array.isArray(parsed)) {
48
+ return parsed.filter((item): item is string => typeof item === "string");
49
+ }
50
+ } catch {
51
+ // Legacy callers still pass comma-delimited scope strings.
52
+ }
53
+ return value
54
+ .split(",")
55
+ .map((scope) => scope.trim())
56
+ .filter(Boolean);
57
+ }
58
+
59
+ function storeScopeList(value: string | string[]): string {
60
+ return JSON.stringify(Array.isArray(value) ? value : parseScopeList(value));
61
+ }
62
+
63
+ function parseObject(value: string | null | undefined): Record<string, unknown> {
64
+ try {
65
+ const parsed = JSON.parse(value || "{}");
66
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
67
+ ? (parsed as Record<string, unknown>)
68
+ : {};
69
+ } catch {
70
+ return {};
71
+ }
72
+ }
73
+
74
+ function storageMetadata(metadata: string): {
75
+ metadata: string;
76
+ extraParamsJson: string | null;
77
+ tokenAuthStyle: "body" | "basic";
78
+ tokenBodyFormat: "form" | "json";
79
+ revocationUrl: string | null;
80
+ } {
81
+ const parsed = parseObject(metadata);
82
+ const extraParams =
83
+ parsed.extraParams &&
84
+ typeof parsed.extraParams === "object" &&
85
+ !Array.isArray(parsed.extraParams)
86
+ ? parsed.extraParams
87
+ : null;
88
+ const tokenAuthStyle = parsed.tokenAuthStyle === "basic" ? "basic" : "body";
89
+ const tokenBodyFormat = parsed.tokenBodyFormat === "json" ? "json" : "form";
90
+ const revocationUrl = typeof parsed.revocationUrl === "string" ? parsed.revocationUrl : null;
91
+ const hasLiftedFields = [
92
+ "extraParams",
93
+ "tokenAuthStyle",
94
+ "tokenBodyFormat",
95
+ "revocationUrl",
96
+ ].some((key) => Object.hasOwn(parsed, key));
97
+ if (hasLiftedFields) {
98
+ delete parsed.extraParams;
99
+ delete parsed.tokenAuthStyle;
100
+ delete parsed.tokenBodyFormat;
101
+ delete parsed.revocationUrl;
102
+ }
103
+ return {
104
+ metadata: hasLiftedFields ? JSON.stringify(parsed) : metadata,
105
+ extraParamsJson: extraParams ? JSON.stringify(extraParams) : null,
106
+ tokenAuthStyle,
107
+ tokenBodyFormat,
108
+ revocationUrl,
109
+ };
110
+ }
6
111
 
7
- function normalizeOAuthApp(row: OAuthApp): OAuthApp {
112
+ function normalizeOAuthApp(row: OAuthAppRow): OAuthApp {
113
+ const encrypted = row.clientSecretEncrypted === 1;
8
114
  return {
9
115
  ...row,
116
+ clientSecret:
117
+ row.clientSecret == null
118
+ ? ""
119
+ : encrypted
120
+ ? decryptSecret(row.clientSecret, getEncryptionKey())
121
+ : row.clientSecret,
122
+ clientSecretEncrypted: encrypted,
123
+ scopes: parseScopeList(row.scopes).join(","),
124
+ requiresRefreshTokenRotation: row.requiresRefreshTokenRotation === 1,
10
125
  createdAt: normalizeDateRequired(row.createdAt),
11
126
  updatedAt: normalizeDateRequired(row.updatedAt),
12
127
  };
13
128
  }
14
129
 
15
- function normalizeOAuthTokens(row: OAuthTokens): OAuthTokens {
130
+ function normalizeAuthorization(row: OAuthAuthorizationRow): OAuthAuthorization {
131
+ const encrypted = row.tokensEncrypted === 1;
132
+ const key = encrypted ? getEncryptionKey() : null;
16
133
  return {
17
134
  ...row,
135
+ accessToken: key ? decryptSecret(row.accessToken, key) : row.accessToken,
136
+ refreshToken:
137
+ row.refreshToken == null
138
+ ? null
139
+ : key
140
+ ? decryptSecret(row.refreshToken, key)
141
+ : row.refreshToken,
142
+ tokensEncrypted: encrypted,
18
143
  createdAt: normalizeDateRequired(row.createdAt),
19
144
  updatedAt: normalizeDateRequired(row.updatedAt),
20
145
  };
21
146
  }
22
147
 
148
+ function rawOAuthAppByProvider(provider: string): OAuthAppRow | null {
149
+ return getDb()
150
+ .query(
151
+ `SELECT * FROM oauth_apps
152
+ WHERE provider = ? AND mcpServerId IS NULL
153
+ ORDER BY createdAt ASC, id ASC
154
+ LIMIT 1`,
155
+ )
156
+ .get(provider) as OAuthAppRow | null;
157
+ }
158
+
159
+ /** Exact (non-MCP) app lookup by id — used to target a specific row when the
160
+ * provider is ambiguous (N apps per provider are allowed). */
161
+ function rawOAuthAppById(id: string): OAuthAppRow | null {
162
+ return getDb()
163
+ .query("SELECT * FROM oauth_apps WHERE id = ? AND mcpServerId IS NULL")
164
+ .get(id) as OAuthAppRow | null;
165
+ }
166
+
167
+ function rawDefaultAuthorizationForApp(appId: string): OAuthAuthorizationRow | null {
168
+ return getDb()
169
+ .query("SELECT * FROM oauth_authorizations WHERE appId = ? AND label = 'default'")
170
+ .get(appId) as OAuthAuthorizationRow | null;
171
+ }
172
+
173
+ function rawDefaultAuthorizationForProvider(provider: string): OAuthAuthorizationRow | null {
174
+ return getDb()
175
+ .query(
176
+ `SELECT z.*
177
+ FROM oauth_authorizations z
178
+ JOIN oauth_apps a ON a.id = z.appId
179
+ WHERE a.provider = ? AND a.mcpServerId IS NULL AND z.label = 'default'
180
+ ORDER BY a.createdAt ASC, a.id ASC
181
+ LIMIT 1`,
182
+ )
183
+ .get(provider) as OAuthAuthorizationRow | null;
184
+ }
185
+
186
+ export function getDefaultAuthorizationIdForProvider(provider: string): string | null {
187
+ return rawDefaultAuthorizationForProvider(provider)?.id ?? null;
188
+ }
189
+
190
+ // ── OAuth Apps ──
191
+
23
192
  export function getOAuthApp(provider: string): OAuthApp | null {
24
- const row = getDb()
25
- .query("SELECT * FROM oauth_apps WHERE provider = ?")
26
- .get(provider) as OAuthApp | null;
193
+ const row = rawOAuthAppByProvider(provider);
194
+ return row ? normalizeOAuthApp(row) : null;
195
+ }
196
+
197
+ export function getOAuthAppById(id: string): OAuthApp | null {
198
+ const row = getDb().query("SELECT * FROM oauth_apps WHERE id = ?").get(id) as OAuthAppRow | null;
27
199
  return row ? normalizeOAuthApp(row) : null;
28
200
  }
29
201
 
30
- export function upsertOAuthApp(
202
+ /** Resolve the (non-MCP) app id for a provider slug, or null if none. */
203
+ export function getOAuthAppIdByProvider(provider: string): string | null {
204
+ return rawOAuthAppByProvider(provider)?.id ?? null;
205
+ }
206
+
207
+ /** Shared write payload for the OAuth-app create / update / provider-upsert
208
+ * paths. The row-resolution strategy is chosen by the calling function — never
209
+ * by this payload — so `id` is intentionally absent here. */
210
+ type OAuthAppWriteData = {
211
+ clientId: string;
212
+ clientSecret: string;
213
+ authorizeUrl: string;
214
+ tokenUrl: string;
215
+ redirectUri: string;
216
+ scopes: string;
217
+ metadata?: string;
218
+ displayName?: string | null;
219
+ revocationUrl?: string | null;
220
+ userinfoUrl?: string | null;
221
+ scopeSeparator?: string;
222
+ tokenAuthStyle?: "body" | "basic";
223
+ tokenBodyFormat?: "form" | "json";
224
+ requiresRefreshTokenRotation?: boolean;
225
+ extraParams?: Record<string, string> | null;
226
+ source?: "manual" | "dcr" | "curated-prefill";
227
+ };
228
+
229
+ /** Insert a new row (when `existing` is null) or update the given row in place.
230
+ * Returns the row id either way. Callers pick the row-resolution strategy up
231
+ * front — this helper never resolves by provider on its own. */
232
+ function writeOAuthApp(
233
+ existing: OAuthAppRow | null,
31
234
  provider: string,
32
- data: {
33
- clientId: string;
34
- clientSecret: string;
35
- authorizeUrl: string;
36
- tokenUrl: string;
37
- redirectUri: string;
38
- scopes: string;
39
- metadata?: string;
40
- },
41
- ): void {
42
- // metadata is treated as a runtime-owned column (cloudId, webhookIds, etc.
43
- // are written by OAuth callback + webhook-register flows). On INSERT we
44
- // seed it with whatever the caller passed (or "{}"); on UPDATE we ONLY
45
- // overwrite when the caller explicitly provided one — otherwise the
46
- // existing value is preserved across server restarts.
235
+ data: OAuthAppWriteData,
236
+ ): string {
47
237
  const metadataProvided = data.metadata !== undefined;
48
- const sql = metadataProvided
49
- ? `INSERT INTO oauth_apps (provider, clientId, clientSecret, authorizeUrl, tokenUrl, redirectUri, scopes, metadata)
50
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
51
- ON CONFLICT(provider) DO UPDATE SET
52
- clientId = excluded.clientId,
53
- clientSecret = excluded.clientSecret,
54
- authorizeUrl = excluded.authorizeUrl,
55
- tokenUrl = excluded.tokenUrl,
56
- redirectUri = excluded.redirectUri,
57
- scopes = excluded.scopes,
58
- metadata = excluded.metadata,
59
- updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
60
- : `INSERT INTO oauth_apps (provider, clientId, clientSecret, authorizeUrl, tokenUrl, redirectUri, scopes, metadata)
61
- VALUES (?, ?, ?, ?, ?, ?, ?, '{}')
62
- ON CONFLICT(provider) DO UPDATE SET
63
- clientId = excluded.clientId,
64
- clientSecret = excluded.clientSecret,
65
- authorizeUrl = excluded.authorizeUrl,
66
- tokenUrl = excluded.tokenUrl,
67
- redirectUri = excluded.redirectUri,
68
- scopes = excluded.scopes,
69
- updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`;
70
- if (metadataProvided) {
238
+ const lifted = metadataProvided ? storageMetadata(data.metadata as string) : null;
239
+ const encryptedSecret = encryptSecret(data.clientSecret, getEncryptionKey());
240
+ const scopeSeparator =
241
+ data.scopeSeparator ?? existing?.scopeSeparator ?? (provider === "linear" ? "," : " ");
242
+ const rotation =
243
+ data.requiresRefreshTokenRotation ??
244
+ (existing ? existing.requiresRefreshTokenRotation === 1 : provider === "jira");
245
+ const tokenAuthStyle =
246
+ data.tokenAuthStyle ?? lifted?.tokenAuthStyle ?? existing?.tokenAuthStyle ?? "body";
247
+ const tokenBodyFormat =
248
+ data.tokenBodyFormat ?? lifted?.tokenBodyFormat ?? existing?.tokenBodyFormat ?? "form";
249
+ const extraParamsJson =
250
+ data.extraParams !== undefined
251
+ ? data.extraParams == null
252
+ ? null
253
+ : JSON.stringify(data.extraParams)
254
+ : lifted
255
+ ? lifted.extraParamsJson
256
+ : (existing?.extraParamsJson ?? null);
257
+ const revocationUrl =
258
+ data.revocationUrl !== undefined
259
+ ? data.revocationUrl
260
+ : lifted
261
+ ? lifted.revocationUrl
262
+ : (existing?.revocationUrl ?? null);
263
+
264
+ if (existing) {
71
265
  getDb()
72
- .query(sql)
266
+ .query(
267
+ `UPDATE oauth_apps SET
268
+ displayName = ?, clientId = ?, clientSecret = ?, clientSecretEncrypted = 1,
269
+ authorizeUrl = ?, tokenUrl = ?, revocationUrl = ?, userinfoUrl = ?,
270
+ redirectUri = ?, scopes = ?, scopeSeparator = ?, tokenAuthStyle = ?,
271
+ tokenBodyFormat = ?, requiresRefreshTokenRotation = ?, extraParamsJson = ?,
272
+ source = ?, metadata = ?, updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
273
+ WHERE id = ?`,
274
+ )
73
275
  .run(
74
- provider,
276
+ data.displayName !== undefined ? data.displayName : existing.displayName,
75
277
  data.clientId,
76
- data.clientSecret,
278
+ encryptedSecret,
77
279
  data.authorizeUrl,
78
280
  data.tokenUrl,
281
+ revocationUrl,
282
+ data.userinfoUrl !== undefined ? data.userinfoUrl : existing.userinfoUrl,
79
283
  data.redirectUri,
80
- data.scopes,
81
- data.metadata as string,
284
+ storeScopeList(data.scopes),
285
+ scopeSeparator,
286
+ tokenAuthStyle,
287
+ tokenBodyFormat,
288
+ rotation ? 1 : 0,
289
+ extraParamsJson,
290
+ data.source ?? existing.source,
291
+ lifted?.metadata ?? existing.metadata,
292
+ existing.id,
82
293
  );
83
- } else {
294
+ return existing.id;
295
+ }
296
+
297
+ const inserted = getDb()
298
+ .query(
299
+ `INSERT INTO oauth_apps (
300
+ provider, displayName, clientId, clientSecret, clientSecretEncrypted,
301
+ authorizeUrl, tokenUrl, revocationUrl, userinfoUrl, redirectUri, scopes,
302
+ scopeSeparator, tokenAuthStyle, tokenBodyFormat,
303
+ requiresRefreshTokenRotation, extraParamsJson, source, metadata
304
+ ) VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
305
+ RETURNING id`,
306
+ )
307
+ .get(
308
+ provider,
309
+ data.displayName ?? null,
310
+ data.clientId,
311
+ encryptedSecret,
312
+ data.authorizeUrl,
313
+ data.tokenUrl,
314
+ revocationUrl,
315
+ data.userinfoUrl ?? null,
316
+ data.redirectUri,
317
+ storeScopeList(data.scopes),
318
+ scopeSeparator,
319
+ tokenAuthStyle,
320
+ tokenBodyFormat,
321
+ rotation ? 1 : 0,
322
+ extraParamsJson,
323
+ data.source ?? "manual",
324
+ lifted?.metadata ?? "{}",
325
+ ) as { id: string };
326
+ return inserted.id;
327
+ }
328
+
329
+ /** Create a brand-new OAuth app row and return its id. ALWAYS inserts — it never
330
+ * resolves by provider, so a second app for the same provider can never clobber
331
+ * the first. User-facing create paths (HTTP `POST /api/oauth-apps` without an
332
+ * id, the MCP `oauth-app-upsert` action) MUST use this. */
333
+ export function createOAuthApp(provider: string, data: OAuthAppWriteData): string {
334
+ return writeOAuthApp(null, provider, data);
335
+ }
336
+
337
+ /** Update exactly one existing (non-MCP) app by id. Throws when the id is
338
+ * unknown or refers to an MCP-managed app. Provider is immutable on edit. */
339
+ export function updateOAuthAppById(id: string, data: OAuthAppWriteData): void {
340
+ const existing = rawOAuthAppById(id);
341
+ if (!existing) {
342
+ throw new Error(`OAuth app ${id} not found.`);
343
+ }
344
+ writeOAuthApp(existing, existing.provider, data);
345
+ }
346
+
347
+ /** Provider-keyed upsert — reserved for BOOT reconciliation (initLinear /
348
+ * initJira) and test seeding. Updates the oldest row for the provider, or
349
+ * inserts when none exists. Do NOT use for user-facing create/edit: with N apps
350
+ * per provider this silently clobbers a sibling row — the create path must use
351
+ * createOAuthApp and the edit path updateOAuthAppById. */
352
+ export function upsertOAuthApp(provider: string, data: OAuthAppWriteData): void {
353
+ writeOAuthApp(rawOAuthAppByProvider(provider), provider, data);
354
+ }
355
+
356
+ // ── OAuth Authorizations ──
357
+
358
+ export function listAuthorizationsForApp(appId: string): OAuthAuthorization[] {
359
+ const rows = getDb()
360
+ .query("SELECT * FROM oauth_authorizations WHERE appId = ? ORDER BY createdAt ASC, id ASC")
361
+ .all(appId) as OAuthAuthorizationRow[];
362
+ return rows.map(normalizeAuthorization);
363
+ }
364
+
365
+ export function getAuthorizationById(id: string): OAuthAuthorization | null {
366
+ const row = getDb()
367
+ .query("SELECT * FROM oauth_authorizations WHERE id = ?")
368
+ .get(id) as OAuthAuthorizationRow | null;
369
+ return row ? normalizeAuthorization(row) : null;
370
+ }
371
+
372
+ export function upsertAuthorization(data: {
373
+ id?: string;
374
+ appId: string;
375
+ label?: string;
376
+ userId?: string | null;
377
+ accountEmail?: string | null;
378
+ identityJson?: string | null;
379
+ accessToken: string;
380
+ refreshToken?: string | null;
381
+ tokenType?: string;
382
+ expiresAt?: string | null;
383
+ scope?: string | null;
384
+ status?: OAuthAuthorizationStatus;
385
+ lastErrorMessage?: string | null;
386
+ lastRefreshedAt?: string | null;
387
+ connectedByUserId?: string | null;
388
+ }): OAuthAuthorization {
389
+ const label = data.label ?? "default";
390
+ const existing = data.id
391
+ ? getAuthorizationById(data.id)
392
+ : listAuthorizationsForApp(data.appId).find((row) => row.label === label);
393
+ const key = getEncryptionKey();
394
+ const accessToken = encryptSecret(data.accessToken, key);
395
+ const refreshToken =
396
+ data.refreshToken === undefined
397
+ ? undefined
398
+ : data.refreshToken == null
399
+ ? null
400
+ : encryptSecret(data.refreshToken, key);
401
+
402
+ if (existing) {
84
403
  getDb()
85
- .query(sql)
404
+ .query(
405
+ `UPDATE oauth_authorizations SET
406
+ userId = ?, accountEmail = ?, identityJson = ?, accessToken = ?,
407
+ refreshToken = ?, tokenType = ?, expiresAt = ?, scope = ?,
408
+ tokensEncrypted = 1, tokenVersion = tokenVersion + 1, status = ?,
409
+ lastErrorMessage = ?, lastRefreshedAt = ?, connectedByUserId = ?,
410
+ updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
411
+ WHERE id = ?`,
412
+ )
86
413
  .run(
87
- provider,
88
- data.clientId,
89
- data.clientSecret,
90
- data.authorizeUrl,
91
- data.tokenUrl,
92
- data.redirectUri,
93
- data.scopes,
414
+ data.userId !== undefined ? data.userId : existing.userId,
415
+ data.accountEmail !== undefined ? data.accountEmail : existing.accountEmail,
416
+ data.identityJson !== undefined ? data.identityJson : existing.identityJson,
417
+ accessToken,
418
+ refreshToken === undefined
419
+ ? existing.refreshToken == null
420
+ ? null
421
+ : encryptSecret(existing.refreshToken, key)
422
+ : refreshToken,
423
+ data.tokenType ?? existing.tokenType,
424
+ data.expiresAt !== undefined ? data.expiresAt : existing.expiresAt,
425
+ data.scope !== undefined ? data.scope : existing.scope,
426
+ data.status ?? existing.status,
427
+ data.lastErrorMessage !== undefined ? data.lastErrorMessage : existing.lastErrorMessage,
428
+ data.lastRefreshedAt !== undefined ? data.lastRefreshedAt : existing.lastRefreshedAt,
429
+ data.connectedByUserId !== undefined ? data.connectedByUserId : existing.connectedByUserId,
430
+ existing.id,
94
431
  );
432
+ return getAuthorizationById(existing.id) as OAuthAuthorization;
95
433
  }
434
+
435
+ const id = data.id ?? crypto.randomUUID();
436
+ getDb()
437
+ .query(
438
+ `INSERT INTO oauth_authorizations (
439
+ id, appId, label, userId, accountEmail, identityJson,
440
+ accessToken, refreshToken, tokenType, expiresAt, scope,
441
+ tokensEncrypted, tokenVersion, status, lastErrorMessage,
442
+ lastRefreshedAt, connectedByUserId
443
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?)`,
444
+ )
445
+ .run(
446
+ id,
447
+ data.appId,
448
+ label,
449
+ data.userId ?? null,
450
+ data.accountEmail ?? null,
451
+ data.identityJson ?? null,
452
+ accessToken,
453
+ refreshToken ?? null,
454
+ data.tokenType ?? "Bearer",
455
+ data.expiresAt ?? null,
456
+ data.scope ?? null,
457
+ data.status ?? "active",
458
+ data.lastErrorMessage ?? null,
459
+ data.lastRefreshedAt ?? null,
460
+ data.connectedByUserId ?? null,
461
+ );
462
+ return getAuthorizationById(id) as OAuthAuthorization;
96
463
  }
97
464
 
98
- // ── OAuth Tokens ──
465
+ export function updateAuthorizationTokens(
466
+ id: string,
467
+ data: {
468
+ accessToken: string;
469
+ refreshToken?: string | null;
470
+ expiresAt?: string | null;
471
+ scope?: string | null;
472
+ expectedTokenVersion?: number;
473
+ },
474
+ ): OAuthAuthorization | null {
475
+ const existing = getAuthorizationById(id);
476
+ if (!existing) return null;
477
+ const expectedVersion = data.expectedTokenVersion ?? existing.tokenVersion;
478
+ const key = getEncryptionKey();
479
+ const encryptedRefresh =
480
+ data.refreshToken === undefined
481
+ ? existing.refreshToken == null
482
+ ? null
483
+ : encryptSecret(existing.refreshToken, key)
484
+ : data.refreshToken == null
485
+ ? null
486
+ : encryptSecret(data.refreshToken, key);
487
+ const result = getDb()
488
+ .query(
489
+ `UPDATE oauth_authorizations SET
490
+ accessToken = ?, refreshToken = ?, expiresAt = ?, scope = ?,
491
+ tokensEncrypted = 1, tokenVersion = tokenVersion + 1,
492
+ status = 'active', lastErrorMessage = NULL,
493
+ lastRefreshedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
494
+ updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
495
+ WHERE id = ? AND tokenVersion = ?`,
496
+ )
497
+ .run(
498
+ encryptSecret(data.accessToken, key),
499
+ encryptedRefresh,
500
+ data.expiresAt !== undefined ? data.expiresAt : existing.expiresAt,
501
+ data.scope !== undefined ? data.scope : existing.scope,
502
+ id,
503
+ expectedVersion,
504
+ );
505
+ return result.changes === 1 ? getAuthorizationById(id) : null;
506
+ }
507
+
508
+ /**
509
+ * Persist best-effort account identity (email + raw identity claims) captured
510
+ * after a successful token exchange. Never touches token material or
511
+ * tokenVersion — display-only metadata.
512
+ */
513
+ export function updateAuthorizationIdentity(
514
+ id: string,
515
+ data: { accountEmail?: string | null; identityJson?: string | null },
516
+ ): void {
517
+ getDb()
518
+ .query(
519
+ `UPDATE oauth_authorizations SET
520
+ accountEmail = ?, identityJson = ?,
521
+ updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
522
+ WHERE id = ?`,
523
+ )
524
+ .run(data.accountEmail ?? null, data.identityJson ?? null, id);
525
+ }
526
+
527
+ /**
528
+ * Hard-delete a single authorization row (used by the multi-authorization
529
+ * DELETE endpoint after best-effort remote revocation). Bindings referencing
530
+ * it are detached via `ON DELETE SET NULL`.
531
+ */
532
+ export function deleteAuthorizationById(id: string): boolean {
533
+ const result = getDb().query("DELETE FROM oauth_authorizations WHERE id = ?").run(id);
534
+ return result.changes > 0;
535
+ }
536
+
537
+ /**
538
+ * Delete every authorization owned by a SPECIFIC app id. Scoped by app id (not
539
+ * provider) so deleting one of several same-provider apps never touches a
540
+ * sibling app's authorizations. Bindings referencing the rows detach via
541
+ * `ON DELETE SET NULL`.
542
+ */
543
+ export function deleteAuthorizationsForApp(appId: string): number {
544
+ return getDb().query("DELETE FROM oauth_authorizations WHERE appId = ?").run(appId).changes;
545
+ }
546
+
547
+ // ── OAuth pending (DB-backed PKCE state for generic/tracker flows) ──
548
+
549
+ /** Pending PKCE sessions are valid for 10 minutes (matches the GC window). */
550
+ export const OAUTH_PENDING_TTL_MS = 10 * 60 * 1000;
551
+
552
+ export type OAuthPendingFlow = "generic" | "tracker";
553
+
554
+ export interface OAuthPendingRecord {
555
+ state: string;
556
+ appId: string;
557
+ label: string;
558
+ flow: OAuthPendingFlow;
559
+ /** Decrypted PKCE code verifier. */
560
+ codeVerifier: string;
561
+ nonce: string | null;
562
+ redirectUri: string;
563
+ finalRedirect: string | null;
564
+ userId: string | null;
565
+ contextJson: string;
566
+ createdAt: string;
567
+ }
568
+
569
+ type OAuthPendingRow = {
570
+ state: string;
571
+ appId: string;
572
+ label: string;
573
+ flow: OAuthPendingFlow;
574
+ codeVerifier: string;
575
+ nonce: string | null;
576
+ redirectUri: string;
577
+ finalRedirect: string | null;
578
+ userId: string | null;
579
+ contextJson: string;
580
+ createdAt: string;
581
+ };
582
+
583
+ /** Persist a pending PKCE session for a generic/tracker OAuth flow. */
584
+ export function createOAuthPending(input: {
585
+ state: string;
586
+ appId: string;
587
+ label?: string;
588
+ flow?: OAuthPendingFlow;
589
+ /** Plaintext PKCE code verifier — encrypted at rest. */
590
+ codeVerifier: string;
591
+ nonce?: string | null;
592
+ redirectUri: string;
593
+ finalRedirect?: string | null;
594
+ userId?: string | null;
595
+ contextJson?: string;
596
+ }): void {
597
+ getDb()
598
+ .query(
599
+ `INSERT INTO oauth_pending (
600
+ state, appId, label, flow, codeVerifier, nonce,
601
+ redirectUri, finalRedirect, userId, contextJson
602
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
603
+ )
604
+ .run(
605
+ input.state,
606
+ input.appId,
607
+ input.label ?? "default",
608
+ input.flow ?? "generic",
609
+ encryptSecret(input.codeVerifier, getEncryptionKey()),
610
+ input.nonce ?? null,
611
+ input.redirectUri,
612
+ input.finalRedirect ?? null,
613
+ input.userId ?? null,
614
+ input.contextJson ?? "{}",
615
+ );
616
+ }
617
+
618
+ /**
619
+ * Single-use consume of a generic/tracker pending row by `state`. Returns null
620
+ * for unknown states and for `mcp`-flow rows (those are owned by the MCP
621
+ * adapter), leaving the row untouched so the caller can fall through.
622
+ *
623
+ * Enforces the 10-minute TTL at consume time: an expired row is deleted and
624
+ * treated as invalid, so a stalled/failed GC timer cannot widen the validity
625
+ * window.
626
+ */
627
+ export function consumeOAuthPending(state: string): OAuthPendingRecord | null {
628
+ return getDb().transaction(() => {
629
+ const row = getDb()
630
+ .query(
631
+ `SELECT state, appId, label, flow, codeVerifier, nonce, redirectUri,
632
+ finalRedirect, userId, contextJson, createdAt
633
+ FROM oauth_pending
634
+ WHERE state = ? AND flow IN ('generic', 'tracker')`,
635
+ )
636
+ .get(state) as OAuthPendingRow | null;
637
+ if (!row) return null;
638
+ // Single-use: always delete, whether valid or expired.
639
+ getDb().query("DELETE FROM oauth_pending WHERE state = ?").run(state);
640
+ const createdAt = normalizeDateRequired(row.createdAt);
641
+ const createdMs = new Date(createdAt).getTime();
642
+ if (Number.isNaN(createdMs) || Date.now() - createdMs > OAUTH_PENDING_TTL_MS) {
643
+ return null;
644
+ }
645
+ return {
646
+ ...row,
647
+ codeVerifier: decryptSecret(row.codeVerifier, getEncryptionKey()),
648
+ createdAt,
649
+ };
650
+ })();
651
+ }
652
+
653
+ /** GC expired generic/tracker pending rows. MCP rows are GC'd separately. */
654
+ export function gcOAuthPending(olderThanMs = 10 * 60 * 1000): number {
655
+ const cutoff = new Date(Date.now() - olderThanMs).toISOString();
656
+ return getDb()
657
+ .query("DELETE FROM oauth_pending WHERE flow IN ('generic', 'tracker') AND createdAt < ?")
658
+ .run(cutoff).changes;
659
+ }
660
+
661
+ /**
662
+ * Persist a `refresh-failed` status + scrubbed error message on an
663
+ * authorization whose refresh just failed. Never clobbers a `revoked` row (a
664
+ * disconnected authorization is terminal, not "failing"). Best-effort by id —
665
+ * no optimistic-concurrency guard, since a successful refresh always bumps
666
+ * status back to `active` and clears the message anyway.
667
+ */
668
+ export function markAuthorizationRefreshFailed(
669
+ id: string,
670
+ message: string | null,
671
+ ): OAuthAuthorization | null {
672
+ const result = getDb()
673
+ .query(
674
+ `UPDATE oauth_authorizations SET
675
+ status = 'refresh-failed', lastErrorMessage = ?,
676
+ updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
677
+ WHERE id = ? AND status != 'revoked'`,
678
+ )
679
+ .run(message, id);
680
+ return result.changes === 1 ? getAuthorizationById(id) : null;
681
+ }
682
+
683
+ // ── Provider-string compatibility adapters ──
99
684
 
100
685
  export function getOAuthTokens(provider: string): OAuthTokens | null {
101
- const row = getDb()
102
- .query("SELECT * FROM oauth_tokens WHERE provider = ?")
103
- .get(provider) as OAuthTokens | null;
104
- return row ? normalizeOAuthTokens(row) : null;
686
+ const row = rawDefaultAuthorizationForProvider(provider);
687
+ if (!row) return null;
688
+ const authorization = normalizeAuthorization(row);
689
+ // A revoked authorization is a disconnected connection: the row is kept for
690
+ // referential continuity (script_credential_bindings.oauth_authorization_id,
691
+ // ON DELETE SET NULL) but must read as "no tokens" to provider-string callers.
692
+ if (authorization.status === "revoked") return null;
693
+ return {
694
+ id: authorization.id,
695
+ provider,
696
+ accessToken: authorization.accessToken,
697
+ refreshToken: authorization.refreshToken,
698
+ expiresAt: authorization.expiresAt ?? "",
699
+ scope: authorization.scope,
700
+ tokenVersion: authorization.tokenVersion,
701
+ createdAt: authorization.createdAt,
702
+ updatedAt: authorization.updatedAt,
703
+ };
105
704
  }
106
705
 
107
706
  export function storeOAuthTokens(
@@ -109,23 +708,25 @@ export function storeOAuthTokens(
109
708
  data: {
110
709
  accessToken: string;
111
710
  refreshToken?: string | null;
112
- expiresAt: string;
711
+ expiresAt: string | null;
113
712
  scope?: string | null;
114
713
  },
115
714
  ): void {
116
- // TODO(secrets-cipher): encrypt OAuth tokens at rest with src/be/crypto/secrets-cipher.ts.
117
- getDb()
118
- .query(
119
- `INSERT INTO oauth_tokens (provider, accessToken, refreshToken, expiresAt, scope)
120
- VALUES (?, ?, ?, ?, ?)
121
- ON CONFLICT(provider) DO UPDATE SET
122
- accessToken = excluded.accessToken,
123
- refreshToken = COALESCE(excluded.refreshToken, oauth_tokens.refreshToken),
124
- expiresAt = excluded.expiresAt,
125
- scope = COALESCE(excluded.scope, oauth_tokens.scope),
126
- updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`,
127
- )
128
- .run(provider, data.accessToken, data.refreshToken ?? null, data.expiresAt, data.scope ?? null);
715
+ const app = rawOAuthAppByProvider(provider);
716
+ if (!app) throw new Error(`OAuth app ${provider} is not configured`);
717
+ const existing = rawDefaultAuthorizationForApp(app.id);
718
+ upsertAuthorization({
719
+ ...(existing ? { id: existing.id } : {}),
720
+ appId: app.id,
721
+ label: "default",
722
+ accessToken: data.accessToken,
723
+ ...(data.refreshToken != null && data.refreshToken !== ""
724
+ ? { refreshToken: data.refreshToken }
725
+ : {}),
726
+ expiresAt: data.expiresAt,
727
+ ...(data.scope != null ? { scope: data.scope } : {}),
728
+ status: "active",
729
+ });
129
730
  }
130
731
 
131
732
  export function updateOAuthTokensAfterRefresh(
@@ -134,98 +735,179 @@ export function updateOAuthTokensAfterRefresh(
134
735
  data: {
135
736
  accessToken: string;
136
737
  refreshToken: string;
137
- expiresAt: string;
738
+ expiresAt: string | null;
138
739
  scope?: string | null;
740
+ expectedTokenVersion?: number;
139
741
  },
140
742
  ): void {
141
- const result = getDb()
142
- .query(
143
- `UPDATE oauth_tokens
144
- SET accessToken = ?,
145
- refreshToken = ?,
146
- expiresAt = ?,
147
- scope = COALESCE(?, scope),
148
- updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
149
- WHERE provider = ? AND refreshToken = ?`,
150
- )
151
- .run(
152
- data.accessToken,
153
- data.refreshToken,
154
- data.expiresAt,
155
- data.scope ?? null,
156
- provider,
157
- expectedRefreshToken,
158
- );
159
-
160
- if (result.changes === 1) return;
161
-
162
- const current = getOAuthTokens(provider);
743
+ const current = rawDefaultAuthorizationForProvider(provider);
163
744
  if (!current) {
164
745
  throw new Error(`OAuth token refresh persistence failed for ${provider}: token row missing`);
165
746
  }
166
- if (current.refreshToken !== expectedRefreshToken) {
747
+ const normalized = normalizeAuthorization(current);
748
+ if (normalized.refreshToken !== expectedRefreshToken) {
167
749
  throw new Error(
168
750
  `OAuth token refresh persistence failed for ${provider}: stored refresh token changed during refresh`,
169
751
  );
170
752
  }
171
- throw new Error(`OAuth token refresh persistence failed for ${provider}: no rows updated`);
753
+ const updated = updateAuthorizationTokens(current.id, {
754
+ accessToken: data.accessToken,
755
+ refreshToken: data.refreshToken,
756
+ expiresAt: data.expiresAt,
757
+ ...(data.scope != null ? { scope: data.scope } : {}),
758
+ expectedTokenVersion: data.expectedTokenVersion ?? current.tokenVersion,
759
+ });
760
+ if (updated) return;
761
+
762
+ const latest = getAuthorizationById(current.id);
763
+ if (!latest) {
764
+ throw new Error(`OAuth token refresh persistence failed for ${provider}: token row missing`);
765
+ }
766
+ if (latest.refreshToken === expectedRefreshToken) {
767
+ throw new Error(`OAuth token refresh persistence failed for ${provider}: no rows updated`);
768
+ }
769
+ throw new Error(
770
+ `OAuth token refresh persistence failed for ${provider}: stored refresh token changed during refresh`,
771
+ );
172
772
  }
173
773
 
174
- /**
175
- * Presence-flags-only projection of oauth_tokens for the background refresh
176
- * sweep. Deliberately excludes token values so sweep code can never leak
177
- * them into logs.
178
- */
179
- export type OAuthTokenSweepRow = {
774
+ export type AuthorizationSweepRow = {
775
+ authorizationId: string;
776
+ appId: string;
180
777
  provider: string;
181
- hasApp: boolean;
778
+ label: string;
779
+ status: OAuthAuthorizationStatus;
182
780
  hasRefreshToken: boolean;
183
781
  expiresAt: string;
184
782
  updatedAt: string;
185
783
  };
186
784
 
187
- export function listOAuthTokenSweepRows(): OAuthTokenSweepRow[] {
785
+ /**
786
+ * Every provider-facing authorization (any label; DCR/MCP apps excluded) the
787
+ * background sweep should consider. Keyed by authorization id — the sweep skips
788
+ * `revoked` rows and refreshes `refresh-failed` ones each pass so transient
789
+ * provider outages self-heal.
790
+ */
791
+ export function listAuthorizationSweepRows(): AuthorizationSweepRow[] {
188
792
  const rows = getDb()
189
793
  .query(
190
- `SELECT t.provider,
191
- CASE WHEN a.provider IS NOT NULL THEN 1 ELSE 0 END AS hasApp,
192
- CASE WHEN t.refreshToken IS NOT NULL AND t.refreshToken != '' THEN 1 ELSE 0 END AS hasRefreshToken,
193
- t.expiresAt,
194
- t.updatedAt
195
- FROM oauth_tokens t
196
- LEFT JOIN oauth_apps a ON a.provider = t.provider
197
- ORDER BY t.provider ASC`,
794
+ `SELECT z.id AS authorizationId,
795
+ z.appId AS appId,
796
+ a.provider AS provider,
797
+ z.label AS label,
798
+ z.status AS status,
799
+ CASE WHEN z.refreshToken IS NOT NULL AND z.refreshToken != '' THEN 1 ELSE 0 END AS hasRefreshToken,
800
+ COALESCE(z.expiresAt, '') AS expiresAt,
801
+ z.updatedAt AS updatedAt
802
+ FROM oauth_authorizations z
803
+ JOIN oauth_apps a ON a.id = z.appId
804
+ WHERE a.mcpServerId IS NULL
805
+ ORDER BY a.provider ASC, z.label ASC`,
198
806
  )
199
807
  .all() as Array<{
808
+ authorizationId: string;
809
+ appId: string;
200
810
  provider: string;
201
- hasApp: number;
811
+ label: string;
812
+ status: OAuthAuthorizationStatus;
202
813
  hasRefreshToken: number;
203
814
  expiresAt: string;
204
815
  updatedAt: string;
205
816
  }>;
206
817
  return rows.map((row) => ({
818
+ authorizationId: row.authorizationId,
819
+ appId: row.appId,
207
820
  provider: row.provider,
208
- hasApp: row.hasApp === 1,
821
+ label: row.label,
822
+ status: row.status,
209
823
  hasRefreshToken: row.hasRefreshToken === 1,
210
824
  expiresAt: normalizeDateRequired(row.expiresAt),
211
825
  updatedAt: normalizeDateRequired(row.updatedAt),
212
826
  }));
213
827
  }
214
828
 
829
+ export type KeepAliveAuthorization = {
830
+ authorizationId: string;
831
+ appId: string;
832
+ provider: string;
833
+ label: string;
834
+ displayName: string | null;
835
+ };
836
+
837
+ /**
838
+ * Active, refreshable authorizations whose app opts into keep-alive: either it
839
+ * rotates refresh tokens (`requiresRefreshTokenRotation=1`) or sets the
840
+ * `keepAlive` metadata flag. Replaces the hardcoded `["linear","jira"]` list;
841
+ * migrated tracker rows qualify automatically (jira via rotation, linear via
842
+ * the metadata backfill).
843
+ */
844
+ export function listKeepAliveAuthorizations(): KeepAliveAuthorization[] {
845
+ const rows = getDb()
846
+ .query(
847
+ `SELECT z.id AS authorizationId,
848
+ z.appId AS appId,
849
+ a.provider AS provider,
850
+ z.label AS label,
851
+ a.displayName AS displayName
852
+ FROM oauth_authorizations z
853
+ JOIN oauth_apps a ON a.id = z.appId
854
+ WHERE a.mcpServerId IS NULL
855
+ AND z.status = 'active'
856
+ AND z.refreshToken IS NOT NULL AND z.refreshToken != ''
857
+ AND (
858
+ a.requiresRefreshTokenRotation = 1
859
+ OR (json_valid(a.metadata) = 1 AND json_extract(a.metadata, '$.keepAlive') IN (1, 'true'))
860
+ )
861
+ ORDER BY a.provider ASC, z.label ASC`,
862
+ )
863
+ .all() as Array<{
864
+ authorizationId: string;
865
+ appId: string;
866
+ provider: string;
867
+ label: string;
868
+ displayName: string | null;
869
+ }>;
870
+ return rows.map((row) => ({
871
+ authorizationId: row.authorizationId,
872
+ appId: row.appId,
873
+ provider: row.provider,
874
+ label: row.label,
875
+ displayName: row.displayName,
876
+ }));
877
+ }
878
+
215
879
  export function deleteOAuthTokens(provider: string): void {
216
- getDb().query("DELETE FROM oauth_tokens WHERE provider = ?").run(provider);
880
+ const app = rawOAuthAppByProvider(provider);
881
+ if (!app) return;
882
+ const existing = rawDefaultAuthorizationForApp(app.id);
883
+ if (!existing) return;
884
+ // Disconnect revokes in place — clear token fields and mark revoked but KEEP
885
+ // the row so script_credential_bindings.oauth_authorization_id survives and a
886
+ // later reconnect (upsert by appId+label='default') reuses the same id. Real
887
+ // deletion of an authorization only happens via oauth_apps CASCADE.
888
+ getDb()
889
+ .query(
890
+ `UPDATE oauth_authorizations SET
891
+ accessToken = ?, refreshToken = NULL, expiresAt = NULL, scope = NULL,
892
+ tokensEncrypted = 1, tokenVersion = tokenVersion + 1,
893
+ status = 'revoked', lastErrorMessage = NULL, lastRefreshedAt = NULL,
894
+ updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
895
+ WHERE id = ?`,
896
+ )
897
+ .run(encryptSecret("", getEncryptionKey()), existing.id);
217
898
  }
218
899
 
219
900
  export function isTokenExpiringSoon(provider: string, bufferMs = 5 * 60 * 1000): boolean {
220
901
  const tokens = getOAuthTokens(provider);
221
902
  if (!tokens) return true;
222
903
  const expiresAt = new Date(tokens.expiresAt).getTime();
904
+ if (Number.isNaN(expiresAt)) return true;
223
905
  return expiresAt - Date.now() < bufferMs;
224
906
  }
225
907
 
226
908
  // ── OAuth Refresh Locks ──
227
909
 
228
- export function acquireOAuthRefreshLock(provider: string, ttlMs: number): string | null {
910
+ export function acquireOAuthRefreshLock(lockKey: string, ttlMs: number): string | null {
229
911
  const owner = crypto.randomUUID();
230
912
  const now = Date.now();
231
913
  const expiresAt = new Date(now + ttlMs).toISOString();
@@ -233,25 +915,25 @@ export function acquireOAuthRefreshLock(provider: string, ttlMs: number): string
233
915
 
234
916
  getDb()
235
917
  .query(
236
- `INSERT INTO oauth_refresh_locks (provider, owner, expiresAt, createdAt, updatedAt)
918
+ `INSERT INTO oauth_refresh_locks (lockKey, owner, expiresAt, createdAt, updatedAt)
237
919
  VALUES (?, ?, ?, ?, ?)
238
- ON CONFLICT(provider) DO UPDATE SET
920
+ ON CONFLICT(lockKey) DO UPDATE SET
239
921
  owner = excluded.owner,
240
922
  expiresAt = excluded.expiresAt,
241
923
  updatedAt = excluded.updatedAt
242
924
  WHERE oauth_refresh_locks.expiresAt <= ?`,
243
925
  )
244
- .run(provider, owner, expiresAt, nowIso, nowIso, nowIso);
926
+ .run(lockKey, owner, expiresAt, nowIso, nowIso, nowIso);
245
927
 
246
928
  const row = getDb()
247
- .query("SELECT owner FROM oauth_refresh_locks WHERE provider = ?")
248
- .get(provider) as { owner: string } | null;
929
+ .query("SELECT owner FROM oauth_refresh_locks WHERE lockKey = ?")
930
+ .get(lockKey) as { owner: string } | null;
249
931
 
250
932
  return row?.owner === owner ? owner : null;
251
933
  }
252
934
 
253
- export function releaseOAuthRefreshLock(provider: string, owner: string): void {
935
+ export function releaseOAuthRefreshLock(lockKey: string, owner: string): void {
254
936
  getDb()
255
- .query("DELETE FROM oauth_refresh_locks WHERE provider = ? AND owner = ?")
256
- .run(provider, owner);
937
+ .query("DELETE FROM oauth_refresh_locks WHERE lockKey = ? AND owner = ?")
938
+ .run(lockKey, owner);
257
939
  }