@pikku/core 0.12.8 → 0.12.10

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 (98) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/env.d.ts +1 -0
  3. package/dist/env.js +1 -0
  4. package/dist/errors/errors.d.ts +14 -0
  5. package/dist/errors/errors.js +27 -0
  6. package/dist/function/function-runner.d.ts +3 -1
  7. package/dist/function/function-runner.js +5 -1
  8. package/dist/handle-error.js +2 -1
  9. package/dist/middleware/auth-bearer.js +10 -1
  10. package/dist/middleware/auth-cookie.js +34 -25
  11. package/dist/middleware/timeout.js +11 -5
  12. package/dist/pikku-state.js +1 -1
  13. package/dist/services/credential-service.d.ts +40 -0
  14. package/dist/services/credential-service.js +1 -0
  15. package/dist/services/credential-wire-service.d.ts +13 -0
  16. package/dist/services/credential-wire-service.js +31 -0
  17. package/dist/services/index.d.ts +5 -0
  18. package/dist/services/index.js +3 -0
  19. package/dist/services/local-content.d.ts +7 -4
  20. package/dist/services/local-content.js +42 -12
  21. package/dist/services/local-credential-service.d.ts +10 -0
  22. package/dist/services/local-credential-service.js +33 -0
  23. package/dist/services/local-secrets.js +2 -2
  24. package/dist/services/typed-credential-service.d.ts +27 -0
  25. package/dist/services/typed-credential-service.js +40 -0
  26. package/dist/testing/service-tests.d.ts +8 -0
  27. package/dist/testing/service-tests.js +107 -1
  28. package/dist/types/core.types.d.ts +7 -0
  29. package/dist/wirings/ai-agent/agent-dynamic-workflow.js +173 -53
  30. package/dist/wirings/ai-agent/ai-agent-prepare.js +2 -1
  31. package/dist/wirings/ai-agent/ai-agent-runner.js +2 -1
  32. package/dist/wirings/ai-agent/ai-agent-stream.js +2 -1
  33. package/dist/wirings/channel/local/local-channel-runner.js +11 -6
  34. package/dist/wirings/credential/credential.types.d.ts +24 -0
  35. package/dist/wirings/credential/credential.types.js +1 -0
  36. package/dist/wirings/credential/index.d.ts +3 -0
  37. package/dist/wirings/credential/index.js +2 -0
  38. package/dist/wirings/credential/validate-credential-definitions.d.ts +6 -0
  39. package/dist/wirings/credential/validate-credential-definitions.js +38 -0
  40. package/dist/wirings/credential/wire-credential.d.ts +48 -0
  41. package/dist/wirings/credential/wire-credential.js +47 -0
  42. package/dist/wirings/http/http-runner.js +8 -2
  43. package/dist/wirings/http/pikku-fetch-http-request.js +11 -1
  44. package/dist/wirings/oauth2/index.d.ts +2 -1
  45. package/dist/wirings/oauth2/index.js +1 -1
  46. package/dist/wirings/oauth2/oauth2-client.js +4 -8
  47. package/dist/wirings/oauth2/oauth2-routes.d.ts +35 -0
  48. package/dist/wirings/oauth2/oauth2-routes.js +146 -0
  49. package/dist/wirings/oauth2/oauth2.types.d.ts +0 -26
  50. package/dist/wirings/workflow/graph/graph-runner.d.ts +8 -1
  51. package/dist/wirings/workflow/graph/graph-runner.js +87 -7
  52. package/dist/wirings/workflow/pikku-workflow-service.d.ts +5 -2
  53. package/dist/wirings/workflow/pikku-workflow-service.js +99 -24
  54. package/dist/wirings/workflow/workflow.types.d.ts +4 -0
  55. package/package.json +2 -1
  56. package/src/env.ts +1 -0
  57. package/src/errors/errors.ts +35 -0
  58. package/src/function/function-runner.ts +11 -0
  59. package/src/handle-error.ts +2 -1
  60. package/src/middleware/auth-bearer.ts +10 -1
  61. package/src/middleware/auth-cookie.ts +11 -4
  62. package/src/middleware/timeout.ts +14 -7
  63. package/src/pikku-state.ts +1 -1
  64. package/src/services/credential-service.ts +44 -0
  65. package/src/services/credential-wire-service.ts +44 -0
  66. package/src/services/index.ts +12 -0
  67. package/src/services/local-content.ts +61 -13
  68. package/src/services/local-credential-service.ts +41 -0
  69. package/src/services/local-secrets.test.ts +2 -2
  70. package/src/services/local-secrets.ts +2 -2
  71. package/src/services/typed-credential-service.ts +75 -0
  72. package/src/testing/service-tests.ts +140 -1
  73. package/src/types/core.types.ts +7 -0
  74. package/src/wirings/ai-agent/agent-dynamic-workflow.ts +378 -237
  75. package/src/wirings/ai-agent/ai-agent-prepare.ts +2 -1
  76. package/src/wirings/ai-agent/ai-agent-runner.test.ts +34 -0
  77. package/src/wirings/ai-agent/ai-agent-runner.ts +2 -1
  78. package/src/wirings/ai-agent/ai-agent-stream.ts +2 -1
  79. package/src/wirings/channel/local/local-channel-runner.ts +11 -6
  80. package/src/wirings/credential/credential.types.ts +28 -0
  81. package/src/wirings/credential/index.ts +8 -0
  82. package/src/wirings/credential/validate-credential-definitions.ts +64 -0
  83. package/src/wirings/credential/wire-credential.ts +49 -0
  84. package/src/wirings/http/http-runner.ts +11 -2
  85. package/src/wirings/http/pikku-fetch-http-request.ts +11 -1
  86. package/src/wirings/oauth2/index.ts +2 -1
  87. package/src/wirings/oauth2/oauth2-client.ts +4 -8
  88. package/src/wirings/oauth2/oauth2-routes.ts +234 -0
  89. package/src/wirings/oauth2/oauth2.types.ts +0 -27
  90. package/src/wirings/workflow/graph/graph-runner.test.ts +42 -0
  91. package/src/wirings/workflow/graph/graph-runner.ts +117 -8
  92. package/src/wirings/workflow/pikku-workflow-service.test.ts +109 -0
  93. package/src/wirings/workflow/pikku-workflow-service.ts +150 -34
  94. package/src/wirings/workflow/workflow.types.ts +4 -0
  95. package/tsconfig.tsbuildinfo +1 -1
  96. package/dist/wirings/oauth2/wire-oauth2-credential.d.ts +0 -20
  97. package/dist/wirings/oauth2/wire-oauth2-credential.js +0 -21
  98. package/src/wirings/oauth2/wire-oauth2-credential.ts +0 -23
@@ -11,6 +11,7 @@ import type {
11
11
  } from './ai-agent.types.js'
12
12
  import type { AIAgentRunnerParams } from '../../services/ai-agent-runner-service.js'
13
13
  import { PikkuError } from '../../errors/error-handler.js'
14
+ import { AIProviderNotConfiguredError } from '../../errors/errors.js'
14
15
  import { pikkuState, getSingletonServices } from '../../pikku-state.js'
15
16
  import { createMiddlewareSessionWireProps } from '../../services/user-session-service.js'
16
17
  import type { SessionService } from '../../services/user-session-service.js'
@@ -520,7 +521,7 @@ export async function prepareAgentRun(
520
521
 
521
522
  const agentRunner = singletonServices.aiAgentRunner
522
523
  if (!agentRunner) {
523
- throw new Error('AIAgentRunnerService not available in singletonServices')
524
+ throw new AIProviderNotConfiguredError()
524
525
  }
525
526
 
526
527
  if (agent.dynamicWorkflows && singletonServices.workflowService) {
@@ -5,6 +5,7 @@ import { resetPikkuState, pikkuState } from '../../pikku-state.js'
5
5
  import { runAIAgent } from './ai-agent-runner.js'
6
6
  import type { CoreAIAgent, PikkuAIMiddlewareHooks } from './ai-agent.types.js'
7
7
  import type { AIAgentStepResult } from '../../services/ai-agent-runner-service.js'
8
+ import { AIProviderNotConfiguredError } from '../../errors/errors.js'
8
9
 
9
10
  beforeEach(() => {
10
11
  resetPikkuState()
@@ -42,6 +43,39 @@ const makeStepResult = (
42
43
  })
43
44
 
44
45
  describe('runAIAgent', () => {
46
+ test('throws AIProviderNotConfiguredError when aiAgentRunner is missing', async () => {
47
+ addTestAgent('no-provider-agent')
48
+
49
+ const mockServices = {
50
+ logger: {
51
+ info: () => {},
52
+ warn: () => {},
53
+ error: () => {},
54
+ debug: () => {},
55
+ },
56
+ aiRunState: {
57
+ createRun: async () => 'run-no-provider',
58
+ updateRun: async () => {},
59
+ },
60
+ } as any
61
+
62
+ pikkuState(null, 'package', 'singletonServices', mockServices)
63
+
64
+ await assert.rejects(
65
+ () =>
66
+ runAIAgent(
67
+ 'no-provider-agent',
68
+ { message: 'hello', threadId: 't', resourceId: 'r' },
69
+ {}
70
+ ),
71
+ (error: unknown) => {
72
+ assert.ok(error instanceof AIProviderNotConfiguredError)
73
+ assert.match((error as Error).message, /AI provider/)
74
+ return true
75
+ }
76
+ )
77
+ })
78
+
45
79
  test('marks run as failed when runner throws', async () => {
46
80
  addTestAgent('failing-agent')
47
81
 
@@ -31,6 +31,7 @@ import {
31
31
  import { checkForApprovals, appendStepMessages } from './ai-agent-stream.js'
32
32
  import { pikkuState, getSingletonServices } from '../../pikku-state.js'
33
33
  import { resolveModelConfig } from './ai-agent-model-config.js'
34
+ import { AIProviderNotConfiguredError } from '../../errors/errors.js'
34
35
  import { randomUUID } from 'crypto'
35
36
 
36
37
  export async function runAIAgent(
@@ -367,7 +368,7 @@ export async function resumeAIAgentSync(
367
368
  const memoryConfig = agent.memory
368
369
  const agentRunner = singletonServices.aiAgentRunner
369
370
  if (!agentRunner) {
370
- throw new Error('AIAgentRunnerService not available')
371
+ throw new AIProviderNotConfiguredError()
371
372
  }
372
373
 
373
374
  const approvedIds = new Set(
@@ -10,6 +10,7 @@ import type {
10
10
  AIAgentMemoryConfig,
11
11
  } from './ai-agent.types.js'
12
12
  import { pikkuState, getSingletonServices } from '../../pikku-state.js'
13
+ import { AIProviderNotConfiguredError } from '../../errors/errors.js'
13
14
  import {
14
15
  combineChannelMiddleware,
15
16
  wrapChannelWithMiddleware,
@@ -748,7 +749,7 @@ export async function resumeAIAgent(
748
749
  const memoryConfig = agent.memory
749
750
  const agentRunner = singletonServices.aiAgentRunner
750
751
  if (!agentRunner) {
751
- throw new Error('AIAgentRunnerService not available in singletonServices')
752
+ throw new AIProviderNotConfiguredError()
752
753
  }
753
754
 
754
755
  if (!input.approved) {
@@ -5,6 +5,8 @@ import { processMessageHandlers } from '../channel-handler.js'
5
5
  import type { CoreChannel, RunChannelOptions } from '../channel.types.js'
6
6
  import { PikkuLocalChannelHandler } from './local-channel-handler.js'
7
7
  import type { PikkuWire, WireServices } from '../../../types/core.types.js'
8
+ import { isProduction } from '../../../env.js'
9
+ import { getErrorResponse } from '../../../errors/error-handler.js'
8
10
  import { handleHTTPError } from '../../../handle-error.js'
9
11
  import {
10
12
  PikkuSessionService,
@@ -121,9 +123,10 @@ export const runLocalChannel = async ({
121
123
  }
122
124
  } catch (e: any) {
123
125
  singletonServices.logger.error(`Error handling onConnect: ${e}`)
126
+ const errorResponse = getErrorResponse(e)
124
127
  channel.send({
125
- error: e.message || 'Unknown error',
126
- errorName: e.constructor?.name,
128
+ error: errorResponse?.message ?? (isProduction() ? 'Internal server error' : e.message),
129
+ ...(!isProduction() && { errorName: e.constructor?.name }),
127
130
  })
128
131
  }
129
132
  }
@@ -143,9 +146,10 @@ export const runLocalChannel = async ({
143
146
  })
144
147
  } catch (e: any) {
145
148
  singletonServices.logger.error(`Error handling onDisconnect: ${e}`)
149
+ const errorResponse = getErrorResponse(e)
146
150
  channel.send({
147
- error: e.message || 'Unknown error',
148
- errorName: e.constructor?.name,
151
+ error: errorResponse?.message ?? (isProduction() ? 'Internal server error' : e.message),
152
+ ...(!isProduction() && { errorName: e.constructor?.name }),
149
153
  })
150
154
  }
151
155
  }
@@ -165,9 +169,10 @@ export const runLocalChannel = async ({
165
169
  await channel.send(result)
166
170
  } catch (e: any) {
167
171
  singletonServices.logger.error(e)
172
+ const errorResponse = getErrorResponse(e)
168
173
  channel.send({
169
- error: e.message || 'Unknown error',
170
- errorName: e.constructor?.name,
174
+ error: errorResponse?.message ?? (isProduction() ? 'Internal server error' : e.message),
175
+ ...(!isProduction() && { errorName: e.constructor?.name }),
171
176
  })
172
177
  setTimeout(() => channel.close(), 200)
173
178
  }
@@ -0,0 +1,28 @@
1
+ import type { OAuth2CredentialConfig } from '../secret/secret.types.js'
2
+
3
+ export type CoreCredential<T = unknown> = {
4
+ name: string
5
+ displayName: string
6
+ description?: string
7
+ type: 'singleton' | 'wire'
8
+ schema: T
9
+ oauth2?: OAuth2CredentialConfig & {
10
+ appCredentialSecretId: string
11
+ }
12
+ }
13
+
14
+ export type CredentialDefinitionMeta = {
15
+ name: string
16
+ displayName: string
17
+ description?: string
18
+ type: 'singleton' | 'wire'
19
+ schema?: Record<string, unknown> | string
20
+ oauth2?: OAuth2CredentialConfig & {
21
+ appCredentialSecretId: string
22
+ }
23
+ sourceFile?: string
24
+ }
25
+
26
+ export type CredentialDefinitionsMeta = Record<string, CredentialDefinitionMeta>
27
+
28
+ export type CredentialDefinitions = CredentialDefinitionMeta[]
@@ -0,0 +1,8 @@
1
+ export { wireCredential } from './wire-credential.js'
2
+ export { validateAndBuildCredentialDefinitionsMeta } from './validate-credential-definitions.js'
3
+ export type {
4
+ CoreCredential,
5
+ CredentialDefinitionMeta,
6
+ CredentialDefinitionsMeta,
7
+ CredentialDefinitions,
8
+ } from './credential.types.js'
@@ -0,0 +1,64 @@
1
+ import type {
2
+ CredentialDefinitions,
3
+ CredentialDefinitionsMeta,
4
+ } from './credential.types.js'
5
+
6
+ export interface SchemaRefLike {
7
+ variableName: string
8
+ sourceFile: string
9
+ }
10
+
11
+ export function validateAndBuildCredentialDefinitionsMeta(
12
+ definitions: CredentialDefinitions,
13
+ schemaLookup: Map<string, SchemaRefLike>
14
+ ): CredentialDefinitionsMeta {
15
+ const meta: CredentialDefinitionsMeta = {}
16
+
17
+ for (const def of definitions) {
18
+ const existing = meta[def.name]
19
+
20
+ if (existing) {
21
+ if (def.schema && existing.schema) {
22
+ const defSchemaRef = schemaLookup.get(def.schema as string)
23
+ const existingSchemaRef = schemaLookup.get(existing.schema as string)
24
+
25
+ if (defSchemaRef && existingSchemaRef) {
26
+ if (
27
+ defSchemaRef.variableName !== existingSchemaRef.variableName ||
28
+ defSchemaRef.sourceFile !== existingSchemaRef.sourceFile
29
+ ) {
30
+ throw new Error(
31
+ `Credential '${def.name}' is defined with different schemas.\n` +
32
+ ` First definition: ${existing.sourceFile} (schema: ${existingSchemaRef.variableName})\n` +
33
+ ` Second definition: ${def.sourceFile} (schema: ${defSchemaRef.variableName})\n` +
34
+ `Credentials sharing a name must use the same schema.`
35
+ )
36
+ }
37
+ }
38
+ }
39
+
40
+ if (def.type !== existing.type) {
41
+ throw new Error(
42
+ `Credential '${def.name}' is defined with different types.\n` +
43
+ ` First definition: ${existing.sourceFile} (type: ${existing.type})\n` +
44
+ ` Second definition: ${def.sourceFile} (type: ${def.type})\n` +
45
+ `Credentials sharing a name must use the same type.`
46
+ )
47
+ }
48
+
49
+ continue
50
+ }
51
+
52
+ meta[def.name] = {
53
+ name: def.name,
54
+ displayName: def.displayName,
55
+ description: def.description,
56
+ type: def.type,
57
+ schema: def.schema,
58
+ oauth2: def.oauth2,
59
+ sourceFile: def.sourceFile,
60
+ }
61
+ }
62
+
63
+ return meta
64
+ }
@@ -0,0 +1,49 @@
1
+ import type { CoreCredential } from './credential.types.js'
2
+
3
+ /**
4
+ * No-op function for declaring credentials.
5
+ * This exists purely for TypeScript type checking and will be tree-shaken.
6
+ * The CLI extracts metadata via AST parsing.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * // Per-user API key
11
+ * wireCredential({
12
+ * name: 'stripe',
13
+ * displayName: 'Stripe API Key',
14
+ * type: 'wire',
15
+ * schema: z.object({ apiKey: z.string() }),
16
+ * })
17
+ *
18
+ * // Per-user OAuth
19
+ * wireCredential({
20
+ * name: 'google-sheets',
21
+ * displayName: 'Google Sheets',
22
+ * type: 'wire',
23
+ * schema: z.object({ accessToken: z.string(), refreshToken: z.string() }),
24
+ * oauth2: {
25
+ * appCredentialSecretId: 'GOOGLE_OAUTH_APP',
26
+ * authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
27
+ * tokenUrl: 'https://oauth2.googleapis.com/token',
28
+ * scopes: ['https://www.googleapis.com/auth/spreadsheets'],
29
+ * tokenSecretId: 'GOOGLE_OAUTH_TOKENS',
30
+ * }
31
+ * })
32
+ *
33
+ * // Platform-level OAuth (singleton)
34
+ * wireCredential({
35
+ * name: 'slack',
36
+ * displayName: 'Slack',
37
+ * type: 'singleton',
38
+ * schema: z.object({ accessToken: z.string(), refreshToken: z.string() }),
39
+ * oauth2: {
40
+ * appCredentialSecretId: 'SLACK_OAUTH_APP',
41
+ * authorizationUrl: 'https://slack.com/oauth/v2/authorize',
42
+ * tokenUrl: 'https://slack.com/api/oauth.v2.access',
43
+ * scopes: ['chat:write', 'channels:read'],
44
+ * tokenSecretId: 'SLACK_OAUTH_TOKENS',
45
+ * }
46
+ * })
47
+ * ```
48
+ */
49
+ export const wireCredential = <T>(_config: CoreCredential<T>): void => {}
@@ -32,6 +32,11 @@ import {
32
32
  getCreateWireServices,
33
33
  } from '../../pikku-state.js'
34
34
  import { PikkuSessionService } from '../../services/user-session-service.js'
35
+ import { getErrorResponse } from '../../errors/error-handler.js'
36
+ import {
37
+ PikkuCredentialWireService,
38
+ createMiddlewareCredentialWireProps,
39
+ } from '../../services/credential-wire-service.js'
35
40
  import { handleHTTPError } from '../../handle-error.js'
36
41
  import { pikkuState } from '../../pikku-state.js'
37
42
  import { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js'
@@ -298,6 +303,7 @@ const executeRoute = async (
298
303
  }
299
304
  ) => {
300
305
  const userSession = new PikkuSessionService<CoreUserSession>()
306
+ const credentialWire = new PikkuCredentialWireService()
301
307
  const { params, route, meta } = matchedRoute
302
308
  const { singletonServices, createWireServices, skipUserSession, requestId } =
303
309
  services
@@ -376,6 +382,7 @@ const executeRoute = async (
376
382
  setSession: (s: any) => userSession.setInitial(s),
377
383
  getSession: () => userSession.get(),
378
384
  hasSessionChanged: () => userSession.sessionChanged,
385
+ ...createMiddlewareCredentialWireProps(credentialWire),
379
386
  }
380
387
 
381
388
  result = await runPikkuFunc(
@@ -395,6 +402,7 @@ const executeRoute = async (
395
402
  tags: route.tags,
396
403
  wire,
397
404
  sessionService: userSession,
405
+ credentialWireService: credentialWire,
398
406
  packageName: meta.packageName,
399
407
  }
400
408
  )
@@ -530,7 +538,7 @@ export const fetchData = async <In, Out>(
530
538
  apiRoute,
531
539
  apiType,
532
540
  })
533
- throw new NotFoundError(`Route not found: ${apiRoute}`)
541
+ throw new NotFoundError()
534
542
  }
535
543
 
536
544
  // Execute the matched route along with its middleware and session management
@@ -552,10 +560,11 @@ export const fetchData = async <In, Out>(
552
560
  // For SSE routes, send error through the stream since the response is already in stream mode
553
561
  singletonServices.logger.error(e instanceof Error ? e.message : e)
554
562
  try {
563
+ const errorResponse = getErrorResponse(e)
555
564
  response.arrayBuffer(
556
565
  JSON.stringify({
557
566
  type: 'error',
558
- errorText: e instanceof Error ? e.message : String(e),
567
+ errorText: errorResponse?.message ?? 'Internal server error',
559
568
  })
560
569
  )
561
570
  response.arrayBuffer(JSON.stringify({ type: 'done' }))
@@ -105,6 +105,9 @@ export class PikkuFetchHTTPRequest<In = unknown>
105
105
  const merged: Record<string, unknown> = {}
106
106
  for (const part of parts) {
107
107
  for (const [key, value] of Object.entries(part)) {
108
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
109
+ continue
110
+ }
108
111
  if (key in merged && !valuesAreEquivalent(merged[key], value)) {
109
112
  throw new UnprocessableContentError(
110
113
  `Conflicting values for key "${key}": "${merged[key]}" vs "${value}"`
@@ -141,7 +144,14 @@ export class PikkuFetchHTTPRequest<In = unknown>
141
144
  body = { data: buffer }
142
145
  } else if (contentType === 'application/x-www-form-urlencoded') {
143
146
  const text = await this.request.text()
144
- body = Object.fromEntries(new URLSearchParams(text))
147
+ const params = new URLSearchParams(text)
148
+ let count = 0
149
+ for (const _ of params) {
150
+ if (++count > 256) {
151
+ throw new UnprocessableContentError('Too many form parameters')
152
+ }
153
+ }
154
+ body = Object.fromEntries(params)
145
155
  } else {
146
156
  throw new UnprocessableContentError(
147
157
  `Unsupported content type ${contentType}`
@@ -1,3 +1,4 @@
1
1
  export { OAuth2Client } from './oauth2-client.js'
2
- export { wireOAuth2Credential } from './wire-oauth2-credential.js'
2
+ export { createOAuth2Handler } from './oauth2-routes.js'
3
+ export type { CreateOAuth2HandlerOptions } from './oauth2-routes.js'
3
4
  export type { OAuth2AppCredential, OAuth2Token } from './oauth2.types.js'
@@ -30,7 +30,8 @@ import type { SecretService } from '../../services/secret-service.js'
30
30
  * })
31
31
  * ```
32
32
  */
33
- const DEFAULT_TIMEOUT_MS = 30000
33
+ const DEFAULT_TIMEOUT_MS = 30_000
34
+ const TOKEN_EXPIRY_BUFFER_MS = 60_000
34
35
 
35
36
  /**
36
37
  * Helper to fetch with a timeout using AbortController.
@@ -145,18 +146,15 @@ export class OAuth2Client {
145
146
  * Uses a promise lock to prevent concurrent refresh attempts.
146
147
  */
147
148
  private async refreshAndGetToken(): Promise<string> {
148
- // If already refreshing, wait for that to complete
149
149
  if (this.refreshPromise) {
150
150
  const token = await this.refreshPromise
151
151
  return token.accessToken
152
152
  }
153
153
 
154
- // Start refresh
155
154
  this.refreshPromise = this.doRefreshToken()
156
155
 
157
156
  try {
158
157
  const token = await this.refreshPromise
159
- this.cachedToken = token
160
158
  return token.accessToken
161
159
  } finally {
162
160
  this.refreshPromise = null
@@ -211,9 +209,8 @@ export class OAuth2Client {
211
209
  scope: data.scope,
212
210
  }
213
211
 
214
- // Cache and persist the refreshed token
215
- this.cachedToken = token
216
212
  await this.secrets.setSecretJSON(this.oauth2Config.tokenSecretId, token)
213
+ this.cachedToken = token
217
214
 
218
215
  return token
219
216
  }
@@ -225,8 +222,7 @@ export class OAuth2Client {
225
222
  if (!token.expiresAt) {
226
223
  return true // No expiry info, assume valid
227
224
  }
228
- // Add 60 second buffer to account for clock skew
229
- return token.expiresAt > Date.now() + 60000
225
+ return token.expiresAt > Date.now() + TOKEN_EXPIRY_BUFFER_MS
230
226
  }
231
227
 
232
228
  /**
@@ -0,0 +1,234 @@
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
+ }