@pikku/core 0.12.63 → 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 +147 -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 +9 -34
  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 +16 -39
  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,930 +0,0 @@
1
- import { describe, test, afterEach } from 'node:test'
2
-
3
- const mockFn = <T extends (...args: any[]) => any>(fn: T): T => fn
4
- import * as assert from 'node:assert/strict'
5
- import { OAuth2Client } from './oauth2-client.js'
6
- import type { OAuth2Token, OAuth2AppCredential } from './oauth2.types.js'
7
- import type { OAuth2CredentialConfig } from '../secret/secret.types.js'
8
- import type { SecretService } from '../../services/secret-service.js'
9
-
10
- // Store the original fetch for restoration
11
- const originalFetch = globalThis.fetch
12
-
13
- // Mock SecretService with in-memory store
14
- function createMockSecretService(
15
- initialSecrets: Record<string, unknown> = {}
16
- ): SecretService & { getStoredSecrets: () => Map<string, unknown> } {
17
- const secrets = new Map<string, unknown>(Object.entries(initialSecrets))
18
- return {
19
- getSecret: async <T = string>(key: string) => secrets.get(key) as T,
20
- setSecret: async (key: string, value: unknown) => {
21
- secrets.set(key, value)
22
- },
23
- deleteSecret: async (_key: string) => {
24
- // Not used in tests
25
- },
26
- hasSecret: async (key: string) => secrets.has(key),
27
- // Helper for tests to inspect stored values
28
- getStoredSecrets: () => secrets,
29
- }
30
- }
31
-
32
- // Helper to create a mock fetch response
33
- function mockFetchResponse(data: unknown, status = 200, ok = true): Response {
34
- return {
35
- ok,
36
- status,
37
- json: async () => data,
38
- text: async () => JSON.stringify(data),
39
- } as Response
40
- }
41
-
42
- // Default test configuration
43
- const defaultConfig: OAuth2CredentialConfig = {
44
- tokenSecretId: 'OAUTH_TOKENS',
45
- authorizationUrl: 'https://example.com/oauth/authorize',
46
- tokenUrl: 'https://example.com/oauth/token',
47
- scopes: ['read', 'write'],
48
- }
49
-
50
- const defaultAppCredential: OAuth2AppCredential = {
51
- clientId: 'test-client-id',
52
- clientSecret: 'test-client-secret',
53
- }
54
-
55
- describe('OAuth2Client', () => {
56
- afterEach(() => {
57
- // Restore original fetch after each test
58
- globalThis.fetch = originalFetch
59
- })
60
-
61
- describe('Token Management', () => {
62
- test('returns cached valid token', async () => {
63
- const validToken: OAuth2Token = {
64
- accessToken: 'valid-access-token',
65
- refreshToken: 'refresh-token',
66
- expiresAt: Date.now() + 3600000, // 1 hour from now
67
- tokenType: 'Bearer',
68
- }
69
-
70
- const secrets = createMockSecretService({
71
- OAUTH_TOKENS: validToken,
72
- APP_CREDS: defaultAppCredential,
73
- })
74
-
75
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
76
-
77
- // First call loads from secrets
78
- const token1 = await client.getAccessToken()
79
- assert.equal(token1, 'valid-access-token')
80
-
81
- // Second call should return cached token without hitting secrets
82
- const token2 = await client.getAccessToken()
83
- assert.equal(token2, 'valid-access-token')
84
- })
85
-
86
- test('refreshes expired token', async () => {
87
- const expiredToken: OAuth2Token = {
88
- accessToken: 'expired-access-token',
89
- refreshToken: 'refresh-token',
90
- expiresAt: Date.now() - 1000, // Expired
91
- tokenType: 'Bearer',
92
- }
93
-
94
- const secrets = createMockSecretService({
95
- OAUTH_TOKENS: expiredToken,
96
- APP_CREDS: defaultAppCredential,
97
- })
98
-
99
- globalThis.fetch = mockFn(async () =>
100
- mockFetchResponse({
101
- access_token: 'new-access-token',
102
- refresh_token: 'new-refresh-token',
103
- expires_in: 3600,
104
- token_type: 'Bearer',
105
- })
106
- )
107
-
108
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
109
- const token = await client.getAccessToken()
110
-
111
- assert.equal(token, 'new-access-token')
112
- })
113
-
114
- test('loads from secrets on first call', async () => {
115
- const storedToken: OAuth2Token = {
116
- accessToken: 'stored-access-token',
117
- refreshToken: 'stored-refresh-token',
118
- expiresAt: Date.now() + 3600000,
119
- tokenType: 'Bearer',
120
- }
121
-
122
- const secrets = createMockSecretService({
123
- OAUTH_TOKENS: storedToken,
124
- APP_CREDS: defaultAppCredential,
125
- })
126
-
127
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
128
- const token = await client.getAccessToken()
129
-
130
- assert.equal(token, 'stored-access-token')
131
- })
132
-
133
- test('refreshes expired token from secrets', async () => {
134
- const expiredToken: OAuth2Token = {
135
- accessToken: 'expired-token',
136
- refreshToken: 'valid-refresh-token',
137
- expiresAt: Date.now() - 1000, // Expired
138
- tokenType: 'Bearer',
139
- }
140
-
141
- const secrets = createMockSecretService({
142
- OAUTH_TOKENS: expiredToken,
143
- APP_CREDS: defaultAppCredential,
144
- })
145
-
146
- globalThis.fetch = mockFn(async () =>
147
- mockFetchResponse({
148
- access_token: 'refreshed-token',
149
- expires_in: 3600,
150
- token_type: 'Bearer',
151
- })
152
- )
153
-
154
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
155
- const token = await client.getAccessToken()
156
-
157
- assert.equal(token, 'refreshed-token')
158
- })
159
-
160
- test('throws when no token available', async () => {
161
- const secrets = createMockSecretService({
162
- APP_CREDS: defaultAppCredential,
163
- // No OAUTH_TOKENS
164
- })
165
-
166
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
167
-
168
- await assert.rejects(
169
- async () => await client.getAccessToken(),
170
- /undefined/
171
- )
172
- })
173
-
174
- test('uses 60-second expiry buffer', async () => {
175
- // Token expires in 30 seconds (within the 60-second buffer)
176
- const almostExpiredToken: OAuth2Token = {
177
- accessToken: 'almost-expired-token',
178
- refreshToken: 'refresh-token',
179
- expiresAt: Date.now() + 30000, // 30 seconds from now
180
- tokenType: 'Bearer',
181
- }
182
-
183
- const secrets = createMockSecretService({
184
- OAUTH_TOKENS: almostExpiredToken,
185
- APP_CREDS: defaultAppCredential,
186
- })
187
-
188
- globalThis.fetch = mockFn(async () =>
189
- mockFetchResponse({
190
- access_token: 'refreshed-due-to-buffer',
191
- expires_in: 3600,
192
- token_type: 'Bearer',
193
- })
194
- )
195
-
196
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
197
- const token = await client.getAccessToken()
198
-
199
- // Should refresh because within 60-second buffer
200
- assert.equal(token, 'refreshed-due-to-buffer')
201
- })
202
- })
203
-
204
- describe('Token Refresh', () => {
205
- test('makes correct HTTP request', async () => {
206
- const tokenWithRefresh: OAuth2Token = {
207
- accessToken: 'old-token',
208
- refreshToken: 'my-refresh-token',
209
- expiresAt: Date.now() - 1000,
210
- tokenType: 'Bearer',
211
- }
212
-
213
- const secrets = createMockSecretService({
214
- OAUTH_TOKENS: tokenWithRefresh,
215
- APP_CREDS: defaultAppCredential,
216
- })
217
-
218
- let capturedRequest: { url: string; options: RequestInit } | null = null
219
-
220
- globalThis.fetch = mockFn(
221
- async (url: RequestInfo | URL, options?: RequestInit) => {
222
- capturedRequest = { url: url.toString(), options: options! }
223
- return mockFetchResponse({
224
- access_token: 'new-token',
225
- expires_in: 3600,
226
- token_type: 'Bearer',
227
- })
228
- }
229
- ) as typeof fetch
230
-
231
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
232
- await client.getAccessToken()
233
-
234
- assert.ok(capturedRequest, 'Expected capturedRequest to be set')
235
- const req = capturedRequest as { url: string; options: RequestInit }
236
- assert.equal(req.url, 'https://example.com/oauth/token')
237
- assert.equal(req.options.method, 'POST')
238
- assert.equal(
239
- (req.options.headers as Record<string, string>)?.['Content-Type'],
240
- 'application/x-www-form-urlencoded'
241
- )
242
-
243
- const body = req.options.body as string
244
- assert.ok(body.includes('grant_type=refresh_token'))
245
- assert.ok(body.includes('refresh_token=my-refresh-token'))
246
- assert.ok(body.includes('client_id=test-client-id'))
247
- assert.ok(body.includes('client_secret=test-client-secret'))
248
- })
249
-
250
- test('updates cached token', async () => {
251
- const expiredToken: OAuth2Token = {
252
- accessToken: 'expired-token',
253
- refreshToken: 'refresh-token',
254
- expiresAt: Date.now() - 1000,
255
- tokenType: 'Bearer',
256
- }
257
-
258
- const secrets = createMockSecretService({
259
- OAUTH_TOKENS: expiredToken,
260
- APP_CREDS: defaultAppCredential,
261
- })
262
-
263
- let fetchCallCount = 0
264
- globalThis.fetch = mockFn(async () => {
265
- fetchCallCount++
266
- return mockFetchResponse({
267
- access_token: 'new-cached-token',
268
- expires_in: 3600,
269
- token_type: 'Bearer',
270
- })
271
- })
272
-
273
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
274
-
275
- // First call - triggers refresh
276
- const token1 = await client.getAccessToken()
277
- assert.equal(token1, 'new-cached-token')
278
-
279
- // Second call - should use cached token, no refresh
280
- const token2 = await client.getAccessToken()
281
- assert.equal(token2, 'new-cached-token')
282
- assert.equal(fetchCallCount, 1) // Only one refresh call
283
- })
284
-
285
- test('throws on HTTP error', async () => {
286
- const expiredToken: OAuth2Token = {
287
- accessToken: 'expired-token',
288
- refreshToken: 'refresh-token',
289
- expiresAt: Date.now() - 1000,
290
- tokenType: 'Bearer',
291
- }
292
-
293
- const secrets = createMockSecretService({
294
- OAUTH_TOKENS: expiredToken,
295
- APP_CREDS: defaultAppCredential,
296
- })
297
-
298
- globalThis.fetch = mockFn(async () =>
299
- mockFetchResponse({ error: 'invalid_grant' }, 400, false)
300
- )
301
-
302
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
303
-
304
- await assert.rejects(
305
- async () => await client.getAccessToken(),
306
- /Token refresh failed: 400/
307
- )
308
- })
309
-
310
- test('throws when no refresh token', async () => {
311
- const tokenWithoutRefresh: OAuth2Token = {
312
- accessToken: 'expired-token',
313
- // No refreshToken
314
- expiresAt: Date.now() - 1000,
315
- tokenType: 'Bearer',
316
- }
317
-
318
- const secrets = createMockSecretService({
319
- OAUTH_TOKENS: tokenWithoutRefresh,
320
- APP_CREDS: defaultAppCredential,
321
- })
322
-
323
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
324
-
325
- await assert.rejects(
326
- async () => await client.getAccessToken(),
327
- /OAuth2 token expired and no refresh token available/
328
- )
329
- })
330
-
331
- test('persists refreshed token to secrets', async () => {
332
- const expiredToken: OAuth2Token = {
333
- accessToken: 'expired-token',
334
- refreshToken: 'refresh-token',
335
- expiresAt: Date.now() - 1000,
336
- tokenType: 'Bearer',
337
- }
338
-
339
- const secrets = createMockSecretService({
340
- OAUTH_TOKENS: expiredToken,
341
- APP_CREDS: defaultAppCredential,
342
- })
343
-
344
- globalThis.fetch = mockFn(async () =>
345
- mockFetchResponse({
346
- access_token: 'new-persisted-token',
347
- refresh_token: 'new-refresh-token',
348
- expires_in: 3600,
349
- token_type: 'Bearer',
350
- })
351
- )
352
-
353
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
354
- await client.getAccessToken()
355
-
356
- // Verify the token was persisted to secrets
357
- const storedToken = secrets
358
- .getStoredSecrets()
359
- .get('OAUTH_TOKENS') as OAuth2Token
360
- assert.equal(storedToken.accessToken, 'new-persisted-token')
361
- assert.equal(storedToken.refreshToken, 'new-refresh-token')
362
- assert.ok(storedToken.expiresAt && storedToken.expiresAt > Date.now())
363
- })
364
-
365
- test('promise lock prevents concurrent refreshes', async () => {
366
- const expiredToken: OAuth2Token = {
367
- accessToken: 'expired-token',
368
- refreshToken: 'refresh-token',
369
- expiresAt: Date.now() - 1000,
370
- tokenType: 'Bearer',
371
- }
372
-
373
- const secrets = createMockSecretService({
374
- OAUTH_TOKENS: expiredToken,
375
- APP_CREDS: defaultAppCredential,
376
- })
377
-
378
- let fetchCallCount = 0
379
- globalThis.fetch = mockFn(async () => {
380
- fetchCallCount++
381
- // Simulate network delay
382
- await new Promise((resolve) => setTimeout(resolve, 50))
383
- return mockFetchResponse({
384
- access_token: 'concurrent-token',
385
- expires_in: 3600,
386
- token_type: 'Bearer',
387
- })
388
- })
389
-
390
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
391
-
392
- // Trigger concurrent refresh calls
393
- const [token1, token2, token3] = await Promise.all([
394
- client.getAccessToken(),
395
- client.getAccessToken(),
396
- client.getAccessToken(),
397
- ])
398
-
399
- // All should get the same token
400
- assert.equal(token1, 'concurrent-token')
401
- assert.equal(token2, 'concurrent-token')
402
- assert.equal(token3, 'concurrent-token')
403
-
404
- // Only one actual refresh should have happened
405
- assert.equal(fetchCallCount, 1)
406
- })
407
-
408
- test('preserves refresh token if not returned', async () => {
409
- const expiredToken: OAuth2Token = {
410
- accessToken: 'expired-token',
411
- refreshToken: 'original-refresh-token',
412
- expiresAt: Date.now() - 1000,
413
- tokenType: 'Bearer',
414
- }
415
-
416
- const secrets = createMockSecretService({
417
- OAUTH_TOKENS: expiredToken,
418
- APP_CREDS: defaultAppCredential,
419
- })
420
-
421
- // Response doesn't include refresh_token
422
- globalThis.fetch = mockFn(async () =>
423
- mockFetchResponse({
424
- access_token: 'new-token',
425
- expires_in: 3600,
426
- token_type: 'Bearer',
427
- // No refresh_token in response
428
- })
429
- )
430
-
431
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
432
- await client.getAccessToken()
433
-
434
- // Expire the new token to trigger another refresh
435
- // The client should still have the original refresh token
436
- // To verify this, we'd need to access internal state or trigger another refresh
437
- // For now, we verify the first refresh worked
438
- const token = await client.getAccessToken()
439
- assert.equal(token, 'new-token')
440
- })
441
- })
442
-
443
- describe('Request Method', () => {
444
- test('adds Authorization header', async () => {
445
- const validToken: OAuth2Token = {
446
- accessToken: 'bearer-token',
447
- expiresAt: Date.now() + 3600000,
448
- tokenType: 'Bearer',
449
- }
450
-
451
- const secrets = createMockSecretService({
452
- OAUTH_TOKENS: validToken,
453
- APP_CREDS: defaultAppCredential,
454
- })
455
-
456
- let capturedHeaders: Record<string, string> | null = null
457
-
458
- globalThis.fetch = mockFn(
459
- async (_url: RequestInfo | URL, options?: RequestInit) => {
460
- capturedHeaders = options?.headers as Record<string, string>
461
- return mockFetchResponse({ data: 'test' })
462
- }
463
- ) as typeof fetch
464
-
465
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
466
- await client.request('https://api.example.com/resource')
467
-
468
- assert.ok(capturedHeaders, 'Expected capturedHeaders to be set')
469
- const headers = capturedHeaders as Record<string, string>
470
- assert.equal(headers.Authorization, 'Bearer bearer-token')
471
- })
472
-
473
- test('retries on 401 with fresh token', async () => {
474
- const validToken: OAuth2Token = {
475
- accessToken: 'initial-token',
476
- refreshToken: 'refresh-token',
477
- expiresAt: Date.now() + 3600000,
478
- tokenType: 'Bearer',
479
- }
480
-
481
- const secrets = createMockSecretService({
482
- OAUTH_TOKENS: validToken,
483
- APP_CREDS: defaultAppCredential,
484
- })
485
-
486
- let requestCount = 0
487
- globalThis.fetch = mockFn(
488
- async (url: RequestInfo | URL, options?: RequestInit) => {
489
- requestCount++
490
- const urlStr = url.toString()
491
-
492
- // Token refresh endpoint
493
- if (urlStr === 'https://example.com/oauth/token') {
494
- return mockFetchResponse({
495
- access_token: 'refreshed-token',
496
- expires_in: 3600,
497
- token_type: 'Bearer',
498
- })
499
- }
500
-
501
- // API endpoint
502
- const headers = options?.headers as Record<string, string>
503
- if (headers?.Authorization === 'Bearer initial-token') {
504
- // First request returns 401
505
- return mockFetchResponse({}, 401, false)
506
- }
507
-
508
- // Retry with refreshed token succeeds
509
- return mockFetchResponse({ success: true })
510
- }
511
- ) as typeof fetch
512
-
513
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
514
- const response = await client.request('https://api.example.com/resource')
515
-
516
- assert.equal(response.status, 200)
517
- assert.equal(requestCount, 3) // Initial request + refresh + retry
518
- })
519
-
520
- test('no double retry on 401', async () => {
521
- const validToken: OAuth2Token = {
522
- accessToken: 'token',
523
- refreshToken: 'refresh-token',
524
- expiresAt: Date.now() + 3600000,
525
- tokenType: 'Bearer',
526
- }
527
-
528
- const secrets = createMockSecretService({
529
- OAUTH_TOKENS: validToken,
530
- APP_CREDS: defaultAppCredential,
531
- })
532
-
533
- let apiRequestCount = 0
534
- globalThis.fetch = mockFn(async (url: RequestInfo | URL) => {
535
- const urlStr = url.toString()
536
-
537
- if (urlStr === 'https://example.com/oauth/token') {
538
- return mockFetchResponse({
539
- access_token: 'new-token',
540
- expires_in: 3600,
541
- token_type: 'Bearer',
542
- })
543
- }
544
-
545
- apiRequestCount++
546
- // Always return 401
547
- return mockFetchResponse({}, 401, false)
548
- }) as typeof fetch
549
-
550
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
551
- const response = await client.request('https://api.example.com/resource')
552
-
553
- assert.equal(response.status, 401)
554
- // Should only be 2 API requests (initial + one retry)
555
- assert.equal(apiRequestCount, 2)
556
- })
557
-
558
- test('passes through other status codes', async () => {
559
- const validToken: OAuth2Token = {
560
- accessToken: 'token',
561
- expiresAt: Date.now() + 3600000,
562
- tokenType: 'Bearer',
563
- }
564
-
565
- const secrets = createMockSecretService({
566
- OAUTH_TOKENS: validToken,
567
- APP_CREDS: defaultAppCredential,
568
- })
569
-
570
- globalThis.fetch = mockFn(async () =>
571
- mockFetchResponse({ error: 'forbidden' }, 403, false)
572
- )
573
-
574
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
575
- const response = await client.request('https://api.example.com/resource')
576
-
577
- assert.equal(response.status, 403)
578
- })
579
-
580
- test('preserves user headers', async () => {
581
- const validToken: OAuth2Token = {
582
- accessToken: 'token',
583
- expiresAt: Date.now() + 3600000,
584
- tokenType: 'Bearer',
585
- }
586
-
587
- const secrets = createMockSecretService({
588
- OAUTH_TOKENS: validToken,
589
- APP_CREDS: defaultAppCredential,
590
- })
591
-
592
- let capturedHeaders: Record<string, string> | null = null
593
-
594
- globalThis.fetch = mockFn(
595
- async (_url: RequestInfo | URL, options?: RequestInit) => {
596
- capturedHeaders = options?.headers as Record<string, string>
597
- return mockFetchResponse({ data: 'test' })
598
- }
599
- ) as typeof fetch
600
-
601
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
602
- await client.request('https://api.example.com/resource', {
603
- headers: {
604
- 'Content-Type': 'application/json',
605
- 'X-Custom-Header': 'custom-value',
606
- },
607
- })
608
-
609
- assert.ok(capturedHeaders, 'Expected capturedHeaders to be set')
610
- const headers = capturedHeaders as Record<string, string>
611
- assert.equal(headers['Content-Type'], 'application/json')
612
- assert.equal(headers['X-Custom-Header'], 'custom-value')
613
- assert.equal(headers.Authorization, 'Bearer token')
614
- })
615
- })
616
-
617
- describe('Authorization URL', () => {
618
- test('includes required params', async () => {
619
- const secrets = createMockSecretService({
620
- APP_CREDS: defaultAppCredential,
621
- })
622
-
623
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
624
- const url = await client.getAuthorizationUrl(
625
- 'test-state',
626
- 'http://localhost:3000/callback'
627
- )
628
-
629
- const parsed = new URL(url)
630
- assert.equal(
631
- parsed.origin + parsed.pathname,
632
- 'https://example.com/oauth/authorize'
633
- )
634
- assert.equal(parsed.searchParams.get('response_type'), 'code')
635
- assert.equal(parsed.searchParams.get('client_id'), 'test-client-id')
636
- assert.equal(
637
- parsed.searchParams.get('redirect_uri'),
638
- 'http://localhost:3000/callback'
639
- )
640
- assert.equal(parsed.searchParams.get('scope'), 'read write')
641
- assert.equal(parsed.searchParams.get('state'), 'test-state')
642
- })
643
-
644
- test('includes additional params', async () => {
645
- const configWithParams: OAuth2CredentialConfig = {
646
- ...defaultConfig,
647
- additionalParams: {
648
- access_type: 'offline',
649
- prompt: 'consent',
650
- },
651
- }
652
-
653
- const secrets = createMockSecretService({
654
- APP_CREDS: defaultAppCredential,
655
- })
656
-
657
- const client = new OAuth2Client(configWithParams, 'APP_CREDS', secrets)
658
- const url = await client.getAuthorizationUrl(
659
- 'state',
660
- 'http://localhost/callback'
661
- )
662
-
663
- const parsed = new URL(url)
664
- assert.equal(parsed.searchParams.get('access_type'), 'offline')
665
- assert.equal(parsed.searchParams.get('prompt'), 'consent')
666
- })
667
-
668
- test('URL-encodes values', async () => {
669
- const configWithSpecialScopes: OAuth2CredentialConfig = {
670
- ...defaultConfig,
671
- scopes: ['read:user', 'write:data'],
672
- }
673
-
674
- const secrets = createMockSecretService({
675
- APP_CREDS: defaultAppCredential,
676
- })
677
-
678
- const client = new OAuth2Client(
679
- configWithSpecialScopes,
680
- 'APP_CREDS',
681
- secrets
682
- )
683
- const url = await client.getAuthorizationUrl(
684
- 'state with spaces',
685
- 'http://localhost/callback?param=value'
686
- )
687
-
688
- // URL should be properly encoded
689
- assert.ok(
690
- url.includes('state+with+spaces') ||
691
- url.includes('state%20with%20spaces')
692
- )
693
- assert.ok(
694
- url.includes(
695
- 'redirect_uri=http%3A%2F%2Flocalhost%2Fcallback%3Fparam%3Dvalue'
696
- )
697
- )
698
- })
699
- })
700
-
701
- describe('Code Exchange', () => {
702
- test('makes correct HTTP request', async () => {
703
- const secrets = createMockSecretService({
704
- APP_CREDS: defaultAppCredential,
705
- })
706
-
707
- let capturedRequest: { url: string; options: RequestInit } | null = null
708
-
709
- globalThis.fetch = mockFn(
710
- async (url: RequestInfo | URL, options?: RequestInit) => {
711
- capturedRequest = { url: url.toString(), options: options! }
712
- return mockFetchResponse({
713
- access_token: 'exchanged-token',
714
- refresh_token: 'refresh-token',
715
- expires_in: 3600,
716
- token_type: 'Bearer',
717
- })
718
- }
719
- ) as typeof fetch
720
-
721
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
722
- await client.exchangeCode('auth-code', 'http://localhost/callback')
723
-
724
- assert.ok(capturedRequest, 'Expected capturedRequest to be set')
725
- const req = capturedRequest as { url: string; options: RequestInit }
726
- assert.equal(req.url, 'https://example.com/oauth/token')
727
- assert.equal(req.options.method, 'POST')
728
-
729
- const body = req.options.body as string
730
- assert.ok(body.includes('grant_type=authorization_code'))
731
- assert.ok(body.includes('code=auth-code'))
732
- assert.ok(body.includes('redirect_uri=http%3A%2F%2Flocalhost%2Fcallback'))
733
- assert.ok(body.includes('client_id=test-client-id'))
734
- assert.ok(body.includes('client_secret=test-client-secret'))
735
- })
736
-
737
- test('caches resulting token', async () => {
738
- const secrets = createMockSecretService({
739
- APP_CREDS: defaultAppCredential,
740
- })
741
-
742
- let fetchCallCount = 0
743
- globalThis.fetch = mockFn(async () => {
744
- fetchCallCount++
745
- return mockFetchResponse({
746
- access_token: 'new-token',
747
- refresh_token: 'new-refresh',
748
- expires_in: 3600,
749
- token_type: 'Bearer',
750
- })
751
- })
752
-
753
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
754
- const token = await client.exchangeCode(
755
- 'code',
756
- 'http://localhost/callback'
757
- )
758
-
759
- assert.equal(token.accessToken, 'new-token')
760
-
761
- // Subsequent getAccessToken should use cached token
762
- const accessToken = await client.getAccessToken()
763
- assert.equal(accessToken, 'new-token')
764
- assert.equal(fetchCallCount, 1) // No additional fetch
765
- })
766
-
767
- test('throws on HTTP error', async () => {
768
- const secrets = createMockSecretService({
769
- APP_CREDS: defaultAppCredential,
770
- })
771
-
772
- globalThis.fetch = mockFn(async () =>
773
- mockFetchResponse({ error: 'invalid_code' }, 400, false)
774
- )
775
-
776
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
777
-
778
- await assert.rejects(
779
- async () =>
780
- await client.exchangeCode('bad-code', 'http://localhost/callback'),
781
- /Token exchange failed: 400/
782
- )
783
- })
784
-
785
- test('handles missing optional fields', async () => {
786
- const secrets = createMockSecretService({
787
- APP_CREDS: defaultAppCredential,
788
- })
789
-
790
- globalThis.fetch = mockFn(async () =>
791
- mockFetchResponse({
792
- access_token: 'token-only',
793
- // No refresh_token, expires_in, or scope
794
- })
795
- )
796
-
797
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
798
- const token = await client.exchangeCode(
799
- 'code',
800
- 'http://localhost/callback'
801
- )
802
-
803
- assert.equal(token.accessToken, 'token-only')
804
- assert.equal(token.refreshToken, undefined)
805
- assert.equal(token.expiresAt, undefined)
806
- assert.equal(token.tokenType, 'Bearer') // Default
807
- assert.equal(token.scope, undefined)
808
- })
809
- })
810
-
811
- describe('Security', () => {
812
- test('validates token response', async () => {
813
- const secrets = createMockSecretService({
814
- APP_CREDS: defaultAppCredential,
815
- })
816
-
817
- // Response without access_token
818
- globalThis.fetch = mockFn(async () =>
819
- mockFetchResponse({
820
- refresh_token: 'refresh-only',
821
- expires_in: 3600,
822
- })
823
- )
824
-
825
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
826
-
827
- await assert.rejects(
828
- async () =>
829
- await client.exchangeCode('code', 'http://localhost/callback'),
830
- /Invalid token response: missing access_token/
831
- )
832
- })
833
-
834
- test('validates access_token is a string', async () => {
835
- const secrets = createMockSecretService({
836
- APP_CREDS: defaultAppCredential,
837
- })
838
-
839
- // Response with non-string access_token
840
- globalThis.fetch = mockFn(async () =>
841
- mockFetchResponse({
842
- access_token: 12345, // Number instead of string
843
- expires_in: 3600,
844
- })
845
- )
846
-
847
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
848
-
849
- await assert.rejects(
850
- async () =>
851
- await client.exchangeCode('code', 'http://localhost/callback'),
852
- /Invalid token response: missing access_token/
853
- )
854
- })
855
-
856
- test('sanitizes error messages', async () => {
857
- const expiredToken: OAuth2Token = {
858
- accessToken: 'expired',
859
- refreshToken: 'refresh',
860
- expiresAt: Date.now() - 1000,
861
- tokenType: 'Bearer',
862
- }
863
-
864
- const secrets = createMockSecretService({
865
- OAUTH_TOKENS: expiredToken,
866
- APP_CREDS: defaultAppCredential,
867
- })
868
-
869
- // Error response with sensitive info
870
- globalThis.fetch = mockFn(async () =>
871
- mockFetchResponse(
872
- {
873
- error: 'invalid_grant',
874
- error_description: 'Refresh token expired for user@email.com',
875
- },
876
- 400,
877
- false
878
- )
879
- )
880
-
881
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
882
-
883
- await assert.rejects(
884
- async () => await client.getAccessToken(),
885
- (error: Error) => {
886
- // Should NOT contain the email or detailed error
887
- assert.ok(!error.message.includes('user@email.com'))
888
- assert.ok(!error.message.includes('Refresh token expired'))
889
- // Should contain generic error info
890
- assert.ok(error.message.includes('400'))
891
- return true
892
- }
893
- )
894
- })
895
-
896
- test('timeout works', async () => {
897
- const expiredToken: OAuth2Token = {
898
- accessToken: 'expired',
899
- refreshToken: 'refresh',
900
- expiresAt: Date.now() - 1000,
901
- tokenType: 'Bearer',
902
- }
903
-
904
- const secrets = createMockSecretService({
905
- OAUTH_TOKENS: expiredToken,
906
- APP_CREDS: defaultAppCredential,
907
- })
908
-
909
- const client = new OAuth2Client(defaultConfig, 'APP_CREDS', secrets)
910
-
911
- // The timeout is 30 seconds, which is too long for a test
912
- // Instead, we verify that the signal is being passed
913
- // by checking that an abort signal is present in the fetch call
914
- let signalPassed = false
915
- globalThis.fetch = mockFn(
916
- async (_url: RequestInfo | URL, options?: RequestInit) => {
917
- signalPassed = options?.signal instanceof AbortSignal
918
- return mockFetchResponse({
919
- access_token: 'token',
920
- expires_in: 3600,
921
- token_type: 'Bearer',
922
- })
923
- }
924
- ) as typeof fetch
925
-
926
- await client.getAccessToken()
927
- assert.equal(signalPassed, true)
928
- })
929
- })
930
- })