@pikku/core 0.12.62 → 0.12.64

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 (198) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/dist/dev/hot-reload.js +13 -55
  3. package/dist/dev/module-runner.d.ts +14 -0
  4. package/dist/dev/module-runner.js +59 -0
  5. package/dist/dev/reload-meta.d.ts +13 -0
  6. package/dist/dev/reload-meta.js +22 -0
  7. package/dist/errors/errors.d.ts +12 -0
  8. package/dist/errors/errors.js +19 -0
  9. package/dist/function/function-runner.d.ts +5 -5
  10. package/dist/function/function-runner.js +39 -111
  11. package/dist/function/functions.types.d.ts +17 -7
  12. package/dist/index.d.ts +5 -1
  13. package/dist/index.js +3 -1
  14. package/dist/middleware/auth-apikey.d.ts +1 -0
  15. package/dist/middleware/auth-bearer.d.ts +1 -0
  16. package/dist/middleware/auth-cookie.d.ts +1 -0
  17. package/dist/middleware/cors.d.ts +1 -0
  18. package/dist/middleware/index.d.ts +1 -1
  19. package/dist/middleware/index.js +1 -1
  20. package/dist/middleware/remote-auth.d.ts +1 -0
  21. package/dist/middleware/telemetry.d.ts +2 -0
  22. package/dist/middleware/timeout.d.ts +1 -0
  23. package/dist/permissions.d.ts +32 -25
  24. package/dist/permissions.js +104 -185
  25. package/dist/pikku-state.js +0 -2
  26. package/dist/scopes.d.ts +17 -0
  27. package/dist/scopes.js +59 -0
  28. package/dist/services/ai-embedding-service.d.ts +31 -0
  29. package/dist/services/ai-embedding-service.js +1 -0
  30. package/dist/services/credential-wire-service.d.ts +3 -1
  31. package/dist/services/credential-wire-service.js +10 -4
  32. package/dist/services/index.d.ts +4 -0
  33. package/dist/services/index.js +2 -0
  34. package/dist/services/meta-service.d.ts +15 -2
  35. package/dist/services/meta-service.js +51 -1
  36. package/dist/services/queue-webhook-service.d.ts +55 -0
  37. package/dist/services/queue-webhook-service.js +136 -0
  38. package/dist/services/scope-service.d.ts +67 -0
  39. package/dist/services/scope-service.js +1 -0
  40. package/dist/services/typed-secret-service.d.ts +8 -0
  41. package/dist/services/typed-secret-service.js +39 -4
  42. package/dist/services/webhook-service.d.ts +140 -0
  43. package/dist/services/webhook-service.js +44 -0
  44. package/dist/services/workflow-service.d.ts +7 -0
  45. package/dist/types/core.types.d.ts +34 -6
  46. package/dist/types/state.types.d.ts +20 -2
  47. package/dist/utils/hmac.d.ts +16 -0
  48. package/dist/utils/hmac.js +26 -0
  49. package/dist/utils/safe-fetch.d.ts +51 -0
  50. package/dist/utils/safe-fetch.js +192 -0
  51. package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
  52. package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
  53. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +41 -1
  54. package/dist/wirings/ai-agent/ai-agent-prepare.js +146 -6
  55. package/dist/wirings/ai-agent/ai-agent-runner.js +6 -1
  56. package/dist/wirings/ai-agent/ai-agent-stream.js +61 -10
  57. package/dist/wirings/ai-agent/ai-agent.types.d.ts +27 -1
  58. package/dist/wirings/ai-agent/index.d.ts +1 -0
  59. package/dist/wirings/ai-agent/index.js +1 -0
  60. package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
  61. package/dist/wirings/ai-agent/voice-input.js +2 -38
  62. package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
  63. package/dist/wirings/channel/channel-common.js +0 -1
  64. package/dist/wirings/channel/channel-handler.js +1 -4
  65. package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
  66. package/dist/wirings/channel/channel-middleware-runner.js +34 -19
  67. package/dist/wirings/channel/channel.types.d.ts +2 -6
  68. package/dist/wirings/cli/cli-runner.js +0 -2
  69. package/dist/wirings/cli/cli.types.d.ts +1 -2
  70. package/dist/wirings/http/http-routes.js +0 -3
  71. package/dist/wirings/http/http-runner.d.ts +9 -29
  72. package/dist/wirings/http/http-runner.js +13 -35
  73. package/dist/wirings/http/http.types.d.ts +1 -9
  74. package/dist/wirings/mcp/mcp-runner.js +0 -2
  75. package/dist/wirings/mcp/mcp.types.d.ts +5 -11
  76. package/dist/wirings/oauth2/index.d.ts +0 -3
  77. package/dist/wirings/oauth2/index.js +1 -2
  78. package/dist/wirings/rpc/addon-runner.d.ts +28 -0
  79. package/dist/wirings/rpc/addon-runner.js +173 -0
  80. package/dist/wirings/rpc/index.d.ts +5 -1
  81. package/dist/wirings/rpc/index.js +3 -1
  82. package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
  83. package/dist/wirings/rpc/remote-addon-auth.js +43 -0
  84. package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
  85. package/dist/wirings/rpc/rpc-runner.js +109 -3
  86. package/dist/wirings/rpc/rpc-types.d.ts +15 -1
  87. package/dist/wirings/rpc/wire-addon.js +9 -0
  88. package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
  89. package/dist/wirings/rpc/wire-remote-addon.js +19 -0
  90. package/dist/wirings/scope/index.d.ts +3 -0
  91. package/dist/wirings/scope/index.js +2 -0
  92. package/dist/wirings/scope/scope.types.d.ts +39 -0
  93. package/dist/wirings/scope/scope.types.js +1 -0
  94. package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
  95. package/dist/wirings/scope/validate-scope-definitions.js +76 -0
  96. package/dist/wirings/scope/wire-scope.d.ts +33 -0
  97. package/dist/wirings/scope/wire-scope.js +32 -0
  98. package/dist/wirings/workflow/dsl/index.d.ts +1 -1
  99. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +53 -1
  100. package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
  101. package/dist/wirings/workflow/graph/graph-runner.js +3 -0
  102. package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
  103. package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
  104. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
  105. package/dist/wirings/workflow/index.d.ts +2 -2
  106. package/dist/wirings/workflow/index.js +1 -1
  107. package/dist/wirings/workflow/pikku-workflow-service.d.ts +51 -1
  108. package/dist/wirings/workflow/pikku-workflow-service.js +171 -0
  109. package/dist/wirings/workflow/workflow.types.d.ts +1 -1
  110. package/package.json +5 -1
  111. package/run-tests.sh +1 -0
  112. package/src/dev/hot-reload.ts +13 -68
  113. package/src/dev/module-runner.test.ts +169 -0
  114. package/src/dev/module-runner.ts +103 -0
  115. package/src/dev/reload-meta.test.ts +31 -2
  116. package/src/dev/reload-meta.ts +26 -0
  117. package/src/errors/errors.ts +24 -0
  118. package/src/function/function-runner.test.ts +276 -142
  119. package/src/function/function-runner.ts +52 -145
  120. package/src/function/functions.types.ts +28 -38
  121. package/src/index.ts +8 -6
  122. package/src/middleware/index.ts +1 -5
  123. package/src/permissions.test.ts +153 -372
  124. package/src/permissions.ts +139 -259
  125. package/src/pikku-state.ts +0 -2
  126. package/src/scopes.test.ts +167 -0
  127. package/src/scopes.ts +69 -0
  128. package/src/services/ai-embedding-service.ts +31 -0
  129. package/src/services/credential-wire-service.ts +10 -4
  130. package/src/services/index.ts +20 -0
  131. package/src/services/meta-service.ts +68 -3
  132. package/src/services/queue-webhook-service.test.ts +408 -0
  133. package/src/services/queue-webhook-service.ts +182 -0
  134. package/src/services/scope-service.ts +75 -0
  135. package/src/services/typed-secret-service.test.ts +35 -0
  136. package/src/services/typed-secret-service.ts +39 -4
  137. package/src/services/webhook-service.ts +180 -0
  138. package/src/services/workflow-service.ts +7 -0
  139. package/src/types/core.types.ts +42 -14
  140. package/src/types/state.types.ts +23 -3
  141. package/src/utils/hmac.ts +27 -0
  142. package/src/utils/safe-fetch.test.ts +373 -0
  143. package/src/utils/safe-fetch.ts +213 -0
  144. package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
  145. package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
  146. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +308 -0
  147. package/src/wirings/ai-agent/ai-agent-prepare.ts +192 -8
  148. package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
  149. package/src/wirings/ai-agent/ai-agent-runner.ts +21 -0
  150. package/src/wirings/ai-agent/ai-agent-stream.test.ts +198 -10
  151. package/src/wirings/ai-agent/ai-agent-stream.ts +84 -10
  152. package/src/wirings/ai-agent/ai-agent.types.ts +28 -0
  153. package/src/wirings/ai-agent/index.ts +1 -0
  154. package/src/wirings/ai-agent/voice-input.ts +6 -35
  155. package/src/wirings/channel/channel-common.ts +0 -1
  156. package/src/wirings/channel/channel-handler.ts +1 -9
  157. package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
  158. package/src/wirings/channel/channel-middleware-runner.ts +39 -27
  159. package/src/wirings/channel/channel.types.ts +0 -6
  160. package/src/wirings/cli/cli-runner.ts +0 -2
  161. package/src/wirings/cli/cli.types.ts +0 -2
  162. package/src/wirings/http/http-routes.ts +0 -3
  163. package/src/wirings/http/http-runner.test.ts +1 -11
  164. package/src/wirings/http/http-runner.ts +19 -40
  165. package/src/wirings/http/http.types.ts +1 -10
  166. package/src/wirings/mcp/mcp-runner.ts +0 -2
  167. package/src/wirings/mcp/mcp.types.ts +3 -11
  168. package/src/wirings/oauth2/index.ts +0 -3
  169. package/src/wirings/rpc/addon-runner.ts +233 -0
  170. package/src/wirings/rpc/index.ts +17 -1
  171. package/src/wirings/rpc/remote-addon-auth.ts +69 -0
  172. package/src/wirings/rpc/rpc-runner.test.ts +319 -16
  173. package/src/wirings/rpc/rpc-runner.ts +155 -3
  174. package/src/wirings/rpc/rpc-types.ts +16 -5
  175. package/src/wirings/rpc/wire-addon.test.ts +6 -3
  176. package/src/wirings/rpc/wire-addon.ts +9 -0
  177. package/src/wirings/rpc/wire-remote-addon.ts +57 -0
  178. package/src/wirings/scope/index.ts +14 -0
  179. package/src/wirings/scope/scope.test.ts +135 -0
  180. package/src/wirings/scope/scope.types.ts +44 -0
  181. package/src/wirings/scope/validate-scope-definitions.ts +110 -0
  182. package/src/wirings/scope/wire-scope.ts +34 -0
  183. package/src/wirings/workflow/dsl/index.ts +4 -0
  184. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +61 -0
  185. package/src/wirings/workflow/graph/graph-node.ts +2 -0
  186. package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
  187. package/src/wirings/workflow/graph/graph-runner.ts +3 -0
  188. package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
  189. package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
  190. package/src/wirings/workflow/index.ts +5 -0
  191. package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
  192. package/src/wirings/workflow/pikku-workflow-service.ts +236 -0
  193. package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
  194. package/src/wirings/workflow/workflow.types.ts +4 -0
  195. package/tsconfig.tsbuildinfo +1 -1
  196. package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
  197. package/src/wirings/oauth2/oauth2-client.ts +0 -331
  198. package/src/wirings/oauth2/oauth2-routes.ts +0 -234
@@ -1,331 +0,0 @@
1
- import type { OAuth2Token, OAuth2AppCredential } from './oauth2.types.js'
2
- import type { OAuth2CredentialConfig } from '../secret/secret.types.js'
3
- import type { SecretService } from '../../services/secret-service.js'
4
-
5
- /**
6
- * OAuth2 client that acts as a service.
7
- * Created once in createSingletonServices, handles token caching and refresh internally.
8
- * Functions use it directly via oauth.request().
9
- *
10
- * @example
11
- * ```typescript
12
- * // In services.ts
13
- * export const createSingletonServices = async (config, { secrets }) => {
14
- * return {
15
- * slackOAuth: new OAuth2Client(
16
- * { tokenSecretId: 'SLACK_TOKENS', authorizationUrl: '...', tokenUrl: '...', scopes: [] },
17
- * 'SLACK_APP_CREDS',
18
- * secrets
19
- * ),
20
- * }
21
- * }
22
- *
23
- * // In function
24
- * export const postMessage = pikkuFunc(async ({ slackOAuth }, { channel, text }) => {
25
- * const response = await slackOAuth.request('https://slack.com/api/chat.postMessage', {
26
- * method: 'POST',
27
- * body: JSON.stringify({ channel, text }),
28
- * })
29
- * return response.json()
30
- * })
31
- * ```
32
- */
33
- const DEFAULT_TIMEOUT_MS = 30_000
34
- const TOKEN_EXPIRY_BUFFER_MS = 60_000
35
-
36
- /**
37
- * Helper to fetch with a timeout using AbortController.
38
- */
39
- async function fetchWithTimeout(
40
- url: string,
41
- options: RequestInit,
42
- timeoutMs: number = DEFAULT_TIMEOUT_MS
43
- ): Promise<Response> {
44
- const controller = new AbortController()
45
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
46
-
47
- try {
48
- return await fetch(url, {
49
- ...options,
50
- signal: controller.signal,
51
- })
52
- } finally {
53
- clearTimeout(timeoutId)
54
- }
55
- }
56
-
57
- export class OAuth2Client {
58
- private cachedToken: OAuth2Token | null = null
59
- private cachedAppCredential: OAuth2AppCredential | null = null
60
- private refreshPromise: Promise<OAuth2Token> | null = null
61
-
62
- constructor(
63
- private oauth2Config: OAuth2CredentialConfig,
64
- private appCredentialSecretId: string,
65
- private secrets: SecretService
66
- ) {}
67
-
68
- /**
69
- * Make an authenticated request. Handles token caching and 401 refresh.
70
- */
71
- async request(
72
- url: string,
73
- options?: RequestInit,
74
- timeoutMs: number = DEFAULT_TIMEOUT_MS
75
- ): Promise<Response> {
76
- const token = await this.getAccessToken()
77
-
78
- const response = await fetchWithTimeout(
79
- url,
80
- {
81
- ...options,
82
- headers: {
83
- ...options?.headers,
84
- Authorization: `Bearer ${token}`,
85
- },
86
- },
87
- timeoutMs
88
- )
89
-
90
- // Auto-retry on 401 after refresh
91
- if (response.status === 401) {
92
- const newToken = await this.refreshAndGetToken()
93
- return fetchWithTimeout(
94
- url,
95
- {
96
- ...options,
97
- headers: {
98
- ...options?.headers,
99
- Authorization: `Bearer ${newToken}`,
100
- },
101
- },
102
- timeoutMs
103
- )
104
- }
105
-
106
- return response
107
- }
108
-
109
- /**
110
- * Get cached token or load from secrets.
111
- */
112
- async getAccessToken(): Promise<string> {
113
- // Return cached token if valid
114
- if (this.cachedToken) {
115
- if (this.isTokenValid(this.cachedToken)) {
116
- return this.cachedToken.accessToken
117
- }
118
- // Token expired, try to refresh
119
- if (this.cachedToken.refreshToken) {
120
- return this.refreshAndGetToken()
121
- }
122
- // Token expired and no refresh token available
123
- throw new Error('OAuth2 token expired and no refresh token available')
124
- }
125
-
126
- // Load from secrets
127
- const token = await this.secrets.getSecret<OAuth2Token>(
128
- this.oauth2Config.tokenSecretId
129
- )
130
- this.cachedToken = token
131
-
132
- // Check if loaded token is expired and needs refresh
133
- if (!this.isTokenValid(token)) {
134
- if (token.refreshToken) {
135
- return this.refreshAndGetToken()
136
- }
137
- // Token expired and no refresh token available
138
- throw new Error('OAuth2 token expired and no refresh token available')
139
- }
140
-
141
- return token.accessToken
142
- }
143
-
144
- /**
145
- * Refresh token and update cache.
146
- * Uses a promise lock to prevent concurrent refresh attempts.
147
- */
148
- private async refreshAndGetToken(): Promise<string> {
149
- if (this.refreshPromise) {
150
- const token = await this.refreshPromise
151
- return token.accessToken
152
- }
153
-
154
- this.refreshPromise = this.doRefreshToken()
155
-
156
- try {
157
- const token = await this.refreshPromise
158
- return token.accessToken
159
- } finally {
160
- this.refreshPromise = null
161
- }
162
- }
163
-
164
- /**
165
- * Perform the actual token refresh.
166
- */
167
- private async doRefreshToken(): Promise<OAuth2Token> {
168
- if (!this.cachedToken?.refreshToken) {
169
- throw new Error('No refresh token available')
170
- }
171
-
172
- const appCredential = await this.getAppCredential()
173
-
174
- const params = new URLSearchParams({
175
- grant_type: 'refresh_token',
176
- refresh_token: this.cachedToken.refreshToken,
177
- client_id: appCredential.clientId,
178
- })
179
-
180
- if (appCredential.clientSecret) {
181
- params.set('client_secret', appCredential.clientSecret)
182
- }
183
-
184
- const response = await fetchWithTimeout(this.oauth2Config.tokenUrl, {
185
- method: 'POST',
186
- headers: {
187
- 'Content-Type': 'application/x-www-form-urlencoded',
188
- },
189
- body: params.toString(),
190
- })
191
-
192
- if (!response.ok) {
193
- throw new Error(`Token refresh failed: ${response.status}`)
194
- }
195
-
196
- const data = await response.json()
197
-
198
- if (!data.access_token || typeof data.access_token !== 'string') {
199
- throw new Error('Invalid token response: missing access_token')
200
- }
201
-
202
- const token: OAuth2Token = {
203
- accessToken: data.access_token,
204
- refreshToken: data.refresh_token || this.cachedToken.refreshToken,
205
- expiresAt: data.expires_in
206
- ? Date.now() + data.expires_in * 1000
207
- : undefined,
208
- tokenType: data.token_type || 'Bearer',
209
- scope: data.scope,
210
- }
211
-
212
- await this.secrets.setSecret(this.oauth2Config.tokenSecretId, token)
213
- this.cachedToken = token
214
-
215
- return token
216
- }
217
-
218
- /**
219
- * Check if token is valid (not expired).
220
- */
221
- private isTokenValid(token: OAuth2Token): boolean {
222
- if (!token.expiresAt) {
223
- return true // No expiry info, assume valid
224
- }
225
- return token.expiresAt > Date.now() + TOKEN_EXPIRY_BUFFER_MS
226
- }
227
-
228
- /**
229
- * Get app credentials (cached).
230
- */
231
- private async getAppCredential(): Promise<OAuth2AppCredential> {
232
- if (this.cachedAppCredential) {
233
- return this.cachedAppCredential
234
- }
235
- this.cachedAppCredential =
236
- await this.secrets.getSecret<OAuth2AppCredential>(
237
- this.appCredentialSecretId
238
- )
239
- return this.cachedAppCredential
240
- }
241
-
242
- // ========================================
243
- // CLI-only methods (for pikku oauth connect)
244
- // ========================================
245
-
246
- /**
247
- * Generate OAuth2 authorization URL.
248
- * Used by CLI during `pikku oauth connect`.
249
- */
250
- async getAuthorizationUrl(
251
- state: string,
252
- redirectUri: string
253
- ): Promise<string> {
254
- const appCredential = await this.getAppCredential()
255
-
256
- const params = new URLSearchParams({
257
- response_type: 'code',
258
- client_id: appCredential.clientId,
259
- redirect_uri: redirectUri,
260
- scope: this.oauth2Config.scopes.join(' '),
261
- state,
262
- })
263
-
264
- // Add PKCE if enabled
265
- // Note: code_verifier should be stored and passed to exchangeCode
266
- // For now, PKCE implementation is simplified
267
-
268
- // Add any additional params
269
- if (this.oauth2Config.additionalParams) {
270
- for (const [key, value] of Object.entries(
271
- this.oauth2Config.additionalParams
272
- )) {
273
- params.set(key, value)
274
- }
275
- }
276
-
277
- return `${this.oauth2Config.authorizationUrl}?${params.toString()}`
278
- }
279
-
280
- /**
281
- * Exchange authorization code for tokens.
282
- * Used by CLI during `pikku oauth connect`.
283
- */
284
- async exchangeCode(code: string, redirectUri: string): Promise<OAuth2Token> {
285
- const appCredential = await this.getAppCredential()
286
-
287
- const params = new URLSearchParams({
288
- grant_type: 'authorization_code',
289
- code,
290
- redirect_uri: redirectUri,
291
- client_id: appCredential.clientId,
292
- })
293
-
294
- if (appCredential.clientSecret) {
295
- params.set('client_secret', appCredential.clientSecret)
296
- }
297
-
298
- const response = await fetchWithTimeout(this.oauth2Config.tokenUrl, {
299
- method: 'POST',
300
- headers: {
301
- 'Content-Type': 'application/x-www-form-urlencoded',
302
- },
303
- body: params.toString(),
304
- })
305
-
306
- if (!response.ok) {
307
- throw new Error(`Token exchange failed: ${response.status}`)
308
- }
309
-
310
- const data = await response.json()
311
-
312
- if (!data.access_token || typeof data.access_token !== 'string') {
313
- throw new Error('Invalid token response: missing access_token')
314
- }
315
-
316
- const token: OAuth2Token = {
317
- accessToken: data.access_token,
318
- refreshToken: data.refresh_token,
319
- expiresAt: data.expires_in
320
- ? Date.now() + data.expires_in * 1000
321
- : undefined,
322
- tokenType: data.token_type || 'Bearer',
323
- scope: data.scope,
324
- }
325
-
326
- // Cache the token
327
- this.cachedToken = token
328
-
329
- return token
330
- }
331
- }
@@ -1,234 +0,0 @@
1
- import type { CredentialService } from '../../services/credential-service.js'
2
- import type { SecretService } from '../../services/secret-service.js'
3
- import type { JWTService } from '../../services/jwt-service.js'
4
- import type { CredentialDefinitionsMeta } from '../credential/credential.types.js'
5
- import type { OAuth2Token } from './oauth2.types.js'
6
- import { OAuth2Client } from './oauth2-client.js'
7
-
8
- const TOKEN_EXPIRY_BUFFER_MS = 60_000
9
-
10
- export type CreateOAuth2HandlerOptions = {
11
- credentialsMeta: CredentialDefinitionsMeta
12
- basePath?: string
13
- }
14
-
15
- type OAuth2RouteContext = {
16
- credentialsMeta: CredentialDefinitionsMeta
17
- basePath: string
18
- }
19
-
20
- function getCredentialMeta(ctx: OAuth2RouteContext, name: string) {
21
- const meta = ctx.credentialsMeta[name]
22
- if (!meta) {
23
- throw new Error(`Credential '${name}' not found`)
24
- }
25
- if (!meta.oauth2) {
26
- throw new Error(`Credential '${name}' is not an OAuth2 credential`)
27
- }
28
- return meta
29
- }
30
-
31
- /**
32
- * Creates OAuth2 route handlers for user credential management.
33
- *
34
- * Returns individual handler functions for connect/callback/disconnect/status
35
- * that handle the OAuth2 authorization code flow and store tokens in CredentialService.
36
- *
37
- * @example
38
- * ```typescript
39
- * const oauth2 = createOAuth2Handler({ credentialsMeta })
40
- *
41
- * const oauth2Routes = defineHTTPRoutes({
42
- * auth: true,
43
- * basePath: '/credentials',
44
- * routes: {
45
- * connect: { method: 'get', route: '/:name/connect', func: oauth2.connect },
46
- * callback: { method: 'get', route: '/:name/callback', func: oauth2.callback, auth: false },
47
- * disconnect: { method: 'delete', route: '/:name', func: oauth2.disconnect },
48
- * status: { method: 'get', route: '/:name/status', func: oauth2.status },
49
- * },
50
- * })
51
- *
52
- * wireHTTPRoutes({ routes: { credentials: oauth2Routes } })
53
- * ```
54
- */
55
- export const createOAuth2Handler = (options: CreateOAuth2HandlerOptions) => {
56
- const basePath = options.basePath ?? '/credentials'
57
- const ctx: OAuth2RouteContext = {
58
- credentialsMeta: options.credentialsMeta,
59
- basePath,
60
- }
61
-
62
- const connectHandler = async (
63
- services: {
64
- secrets: SecretService
65
- jwt: JWTService
66
- credentialService: CredentialService
67
- },
68
- _data: any,
69
- wire: any
70
- ) => {
71
- const { name } = wire.http.request.params()
72
- const meta = getCredentialMeta(ctx, name)
73
- const queryParams = wire.http.request.query()
74
- const redirectUrl = queryParams.redirect_url || queryParams.redirect
75
-
76
- const oauth2Client = new OAuth2Client(
77
- meta.oauth2!,
78
- meta.oauth2!.appCredentialSecretId,
79
- services.secrets
80
- )
81
-
82
- const userId = wire.session?.userId
83
- if (!userId) {
84
- throw new Error('Authentication required for OAuth2 connect')
85
- }
86
-
87
- const state = await services.jwt.encode(
88
- { value: 10, unit: 'minute' },
89
- {
90
- userId,
91
- credentialName: name,
92
- redirectUrl,
93
- }
94
- )
95
-
96
- let origin = wire.http.request.header('origin')
97
- if (!origin) {
98
- const host = wire.http.request.header('host')
99
- if (!host) {
100
- throw new Error(
101
- 'Unable to determine request origin for OAuth2 callback'
102
- )
103
- }
104
- const protocol = wire.http.request.header('x-forwarded-proto') || 'http'
105
- origin = `${protocol}://${host}`
106
- }
107
- const callbackUrl = `${origin}${basePath}/${name}/callback`
108
- const authUrl = await oauth2Client.getAuthorizationUrl(state, callbackUrl)
109
-
110
- return Response.redirect(authUrl, 302)
111
- }
112
-
113
- const callbackHandler = async (
114
- services: {
115
- secrets: SecretService
116
- jwt: JWTService
117
- credentialService: CredentialService
118
- logger: any
119
- },
120
- _data: any,
121
- wire: any
122
- ) => {
123
- const { name } = wire.http.request.params()
124
- const meta = getCredentialMeta(ctx, name)
125
- const queryParams = wire.http.request.query()
126
- const code = queryParams.code as string | undefined
127
- const state = queryParams.state as string | undefined
128
-
129
- if (!code || !state) {
130
- throw new Error('Missing code or state parameter')
131
- }
132
-
133
- let statePayload: {
134
- userId: string
135
- credentialName: string
136
- redirectUrl?: string
137
- }
138
- try {
139
- statePayload = await services.jwt.decode(state)
140
- } catch {
141
- throw new Error('Invalid or expired OAuth2 state')
142
- }
143
-
144
- if (statePayload.credentialName !== name) {
145
- throw new Error('Credential name mismatch in state')
146
- }
147
-
148
- const oauth2Client = new OAuth2Client(
149
- meta.oauth2!,
150
- meta.oauth2!.appCredentialSecretId,
151
- services.secrets
152
- )
153
-
154
- let origin = wire.http.request.header('origin')
155
- if (!origin) {
156
- const host = wire.http.request.header('host')
157
- if (!host) {
158
- throw new Error(
159
- 'Unable to determine request origin for OAuth2 callback'
160
- )
161
- }
162
- const protocol = wire.http.request.header('x-forwarded-proto') || 'http'
163
- origin = `${protocol}://${host}`
164
- }
165
- const callbackUrl = `${origin}${basePath}/${name}/callback`
166
- const tokens = await oauth2Client.exchangeCode(code, callbackUrl)
167
-
168
- await services.credentialService.set(name, tokens, statePayload.userId)
169
-
170
- services.logger.info(
171
- `OAuth2 tokens stored for user '${statePayload.userId}', credential '${name}'`
172
- )
173
-
174
- if (statePayload.redirectUrl) {
175
- return Response.redirect(statePayload.redirectUrl, 302)
176
- } else {
177
- return { success: true, credentialName: name }
178
- }
179
- }
180
-
181
- const disconnectHandler = async (
182
- services: { credentialService: CredentialService; logger: any },
183
- _data: any,
184
- wire: any
185
- ) => {
186
- const { name } = wire.http.request.params()
187
- const userId = wire.session?.userId
188
- if (!userId) {
189
- throw new Error('Authentication required')
190
- }
191
- await services.credentialService.delete(name, userId)
192
- services.logger.info(
193
- `OAuth2 credential '${name}' disconnected for user '${userId}'`
194
- )
195
- return { success: true }
196
- }
197
-
198
- const statusHandler = async (
199
- services: { credentialService: CredentialService },
200
- _data: any,
201
- wire: any
202
- ) => {
203
- const { name } = wire.http.request.params()
204
- const userId = wire.session?.userId
205
- if (!userId) {
206
- throw new Error('Authentication required')
207
- }
208
-
209
- const credential = await services.credentialService.get<OAuth2Token>(
210
- name,
211
- userId
212
- )
213
-
214
- if (!credential) {
215
- return { connected: false }
216
- }
217
-
218
- return {
219
- connected: true,
220
- hasRefreshToken: !!credential.refreshToken,
221
- expiresAt: credential.expiresAt,
222
- isExpired: credential.expiresAt
223
- ? credential.expiresAt < Date.now() + TOKEN_EXPIRY_BUFFER_MS
224
- : false,
225
- }
226
- }
227
-
228
- return {
229
- connect: { func: connectHandler } as any,
230
- callback: { func: callbackHandler } as any,
231
- disconnect: { func: disconnectHandler } as any,
232
- status: { func: statusHandler } as any,
233
- }
234
- }