@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.
- package/CHANGELOG.md +31 -0
- package/dist/env.d.ts +1 -0
- package/dist/env.js +1 -0
- package/dist/errors/errors.d.ts +14 -0
- package/dist/errors/errors.js +27 -0
- package/dist/function/function-runner.d.ts +3 -1
- package/dist/function/function-runner.js +5 -1
- package/dist/handle-error.js +2 -1
- package/dist/middleware/auth-bearer.js +10 -1
- package/dist/middleware/auth-cookie.js +34 -25
- package/dist/middleware/timeout.js +11 -5
- package/dist/pikku-state.js +1 -1
- package/dist/services/credential-service.d.ts +40 -0
- package/dist/services/credential-service.js +1 -0
- package/dist/services/credential-wire-service.d.ts +13 -0
- package/dist/services/credential-wire-service.js +31 -0
- package/dist/services/index.d.ts +5 -0
- package/dist/services/index.js +3 -0
- package/dist/services/local-content.d.ts +7 -4
- package/dist/services/local-content.js +42 -12
- package/dist/services/local-credential-service.d.ts +10 -0
- package/dist/services/local-credential-service.js +33 -0
- package/dist/services/local-secrets.js +2 -2
- package/dist/services/typed-credential-service.d.ts +27 -0
- package/dist/services/typed-credential-service.js +40 -0
- package/dist/testing/service-tests.d.ts +8 -0
- package/dist/testing/service-tests.js +107 -1
- package/dist/types/core.types.d.ts +7 -0
- package/dist/wirings/ai-agent/agent-dynamic-workflow.js +173 -53
- package/dist/wirings/ai-agent/ai-agent-prepare.js +2 -1
- package/dist/wirings/ai-agent/ai-agent-runner.js +2 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +2 -1
- package/dist/wirings/channel/local/local-channel-runner.js +11 -6
- package/dist/wirings/credential/credential.types.d.ts +24 -0
- package/dist/wirings/credential/credential.types.js +1 -0
- package/dist/wirings/credential/index.d.ts +3 -0
- package/dist/wirings/credential/index.js +2 -0
- package/dist/wirings/credential/validate-credential-definitions.d.ts +6 -0
- package/dist/wirings/credential/validate-credential-definitions.js +38 -0
- package/dist/wirings/credential/wire-credential.d.ts +48 -0
- package/dist/wirings/credential/wire-credential.js +47 -0
- package/dist/wirings/http/http-runner.js +8 -2
- package/dist/wirings/http/pikku-fetch-http-request.js +11 -1
- package/dist/wirings/oauth2/index.d.ts +2 -1
- package/dist/wirings/oauth2/index.js +1 -1
- package/dist/wirings/oauth2/oauth2-client.js +4 -8
- package/dist/wirings/oauth2/oauth2-routes.d.ts +35 -0
- package/dist/wirings/oauth2/oauth2-routes.js +146 -0
- package/dist/wirings/oauth2/oauth2.types.d.ts +0 -26
- package/dist/wirings/workflow/graph/graph-runner.d.ts +8 -1
- package/dist/wirings/workflow/graph/graph-runner.js +87 -7
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +5 -2
- package/dist/wirings/workflow/pikku-workflow-service.js +99 -24
- package/dist/wirings/workflow/workflow.types.d.ts +4 -0
- package/package.json +2 -1
- package/src/env.ts +1 -0
- package/src/errors/errors.ts +35 -0
- package/src/function/function-runner.ts +11 -0
- package/src/handle-error.ts +2 -1
- package/src/middleware/auth-bearer.ts +10 -1
- package/src/middleware/auth-cookie.ts +11 -4
- package/src/middleware/timeout.ts +14 -7
- package/src/pikku-state.ts +1 -1
- package/src/services/credential-service.ts +44 -0
- package/src/services/credential-wire-service.ts +44 -0
- package/src/services/index.ts +12 -0
- package/src/services/local-content.ts +61 -13
- package/src/services/local-credential-service.ts +41 -0
- package/src/services/local-secrets.test.ts +2 -2
- package/src/services/local-secrets.ts +2 -2
- package/src/services/typed-credential-service.ts +75 -0
- package/src/testing/service-tests.ts +140 -1
- package/src/types/core.types.ts +7 -0
- package/src/wirings/ai-agent/agent-dynamic-workflow.ts +378 -237
- package/src/wirings/ai-agent/ai-agent-prepare.ts +2 -1
- package/src/wirings/ai-agent/ai-agent-runner.test.ts +34 -0
- package/src/wirings/ai-agent/ai-agent-runner.ts +2 -1
- package/src/wirings/ai-agent/ai-agent-stream.ts +2 -1
- package/src/wirings/channel/local/local-channel-runner.ts +11 -6
- package/src/wirings/credential/credential.types.ts +28 -0
- package/src/wirings/credential/index.ts +8 -0
- package/src/wirings/credential/validate-credential-definitions.ts +64 -0
- package/src/wirings/credential/wire-credential.ts +49 -0
- package/src/wirings/http/http-runner.ts +11 -2
- package/src/wirings/http/pikku-fetch-http-request.ts +11 -1
- package/src/wirings/oauth2/index.ts +2 -1
- package/src/wirings/oauth2/oauth2-client.ts +4 -8
- package/src/wirings/oauth2/oauth2-routes.ts +234 -0
- package/src/wirings/oauth2/oauth2.types.ts +0 -27
- package/src/wirings/workflow/graph/graph-runner.test.ts +42 -0
- package/src/wirings/workflow/graph/graph-runner.ts +117 -8
- package/src/wirings/workflow/pikku-workflow-service.test.ts +109 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +150 -34
- package/src/wirings/workflow/workflow.types.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/wirings/oauth2/wire-oauth2-credential.d.ts +0 -20
- package/dist/wirings/oauth2/wire-oauth2-credential.js +0 -21
- 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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|