@pikku/core 0.12.9 → 0.12.11
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 +13 -0
- package/dist/function/function-runner.d.ts +3 -1
- package/dist/function/function-runner.js +5 -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-credential-service.d.ts +10 -0
- package/dist/services/local-credential-service.js +33 -0
- 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 +106 -0
- package/dist/types/core.types.d.ts +7 -0
- package/dist/wirings/ai-agent/agent-dynamic-workflow.js +173 -53
- package/dist/wirings/cli/channel/cli-raw-channel-runner.d.ts +15 -0
- package/dist/wirings/cli/channel/cli-raw-channel-runner.js +58 -0
- package/dist/wirings/cli/channel/index.d.ts +1 -0
- package/dist/wirings/cli/channel/index.js +1 -0
- 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 +4 -0
- 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 +1 -1
- package/dist/wirings/workflow/graph/graph-runner.js +15 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +2 -2
- package/dist/wirings/workflow/pikku-workflow-service.js +40 -8
- package/package.json +2 -1
- package/src/function/function-runner.ts +11 -0
- 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-credential-service.ts +41 -0
- package/src/services/typed-credential-service.ts +75 -0
- package/src/testing/service-tests.ts +139 -0
- package/src/types/core.types.ts +7 -0
- package/src/wirings/ai-agent/agent-dynamic-workflow.ts +378 -237
- package/src/wirings/cli/channel/cli-raw-channel-runner.ts +89 -0
- package/src/wirings/cli/channel/index.ts +1 -0
- 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 +7 -0
- 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.ts +33 -1
- package/src/wirings/workflow/pikku-workflow-service.ts +55 -9
- 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
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { pikkuState } from '../../../pikku-state.js'
|
|
2
|
+
import type { CLIMeta } from '../cli.types.js'
|
|
3
|
+
import { parseCLIArguments, generateCommandHelp } from '../command-parser.js'
|
|
4
|
+
import { runCLICommand } from '../cli-runner.js'
|
|
5
|
+
import type { CoreSingletonServices, CreateWireServices } from '../../../types/core.types.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Handles raw CLI input from a WebSocket channel.
|
|
9
|
+
* Parses the args, resolves the command, and either returns help or executes it.
|
|
10
|
+
*/
|
|
11
|
+
export async function handleRawCLI({
|
|
12
|
+
programName,
|
|
13
|
+
args,
|
|
14
|
+
singletonServices,
|
|
15
|
+
createWireServices,
|
|
16
|
+
}: {
|
|
17
|
+
programName: string
|
|
18
|
+
args: string[]
|
|
19
|
+
singletonServices: CoreSingletonServices
|
|
20
|
+
createWireServices?: CreateWireServices
|
|
21
|
+
}): Promise<{ help?: string; result?: unknown; error?: string }> {
|
|
22
|
+
const allCLIMeta = pikkuState(null, 'cli', 'meta') as CLIMeta | undefined
|
|
23
|
+
if (!allCLIMeta) {
|
|
24
|
+
return { error: 'CLI metadata not found' }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const programMeta = allCLIMeta.programs[programName]
|
|
28
|
+
if (!programMeta) {
|
|
29
|
+
return { error: `Program "${programName}" not found` }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Handle empty input or explicit help
|
|
33
|
+
if (
|
|
34
|
+
args.length === 0 ||
|
|
35
|
+
args.includes('--help') ||
|
|
36
|
+
args.includes('-h') ||
|
|
37
|
+
args[0] === 'help'
|
|
38
|
+
) {
|
|
39
|
+
const helpArgs = args.filter(
|
|
40
|
+
(a) => a !== 'help' && a !== '--help' && a !== '-h'
|
|
41
|
+
)
|
|
42
|
+
const helpText = generateCommandHelp(programName, allCLIMeta, helpArgs)
|
|
43
|
+
return { help: helpText }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Parse the args
|
|
47
|
+
const parsed = parseCLIArguments(args, programName, allCLIMeta)
|
|
48
|
+
|
|
49
|
+
// If there are errors or the command resolves to a group (no function), show help
|
|
50
|
+
if (parsed.errors.length > 0 || parsed.commandPath.length === 0) {
|
|
51
|
+
const helpText = generateCommandHelp(
|
|
52
|
+
programName,
|
|
53
|
+
allCLIMeta,
|
|
54
|
+
parsed.commandPath
|
|
55
|
+
)
|
|
56
|
+
return { help: helpText }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Check if the resolved command has a pikkuFuncId (is executable)
|
|
60
|
+
let current: { pikkuFuncId?: string; subcommands?: Record<string, any> } | undefined = programMeta.commands[parsed.commandPath[0]]
|
|
61
|
+
for (let i = 1; i < parsed.commandPath.length; i++) {
|
|
62
|
+
current = current?.subcommands?.[parsed.commandPath[i]]
|
|
63
|
+
}
|
|
64
|
+
if (!current?.pikkuFuncId) {
|
|
65
|
+
// It's a group command — show help scoped to that group
|
|
66
|
+
const helpText = generateCommandHelp(
|
|
67
|
+
programName,
|
|
68
|
+
allCLIMeta,
|
|
69
|
+
parsed.commandPath
|
|
70
|
+
)
|
|
71
|
+
return { help: helpText }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Execute the command
|
|
75
|
+
const data = { ...parsed.positionals, ...parsed.options }
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const result = await runCLICommand({
|
|
79
|
+
program: programName,
|
|
80
|
+
commandPath: parsed.commandPath,
|
|
81
|
+
data,
|
|
82
|
+
singletonServices,
|
|
83
|
+
createWireServices,
|
|
84
|
+
})
|
|
85
|
+
return { result }
|
|
86
|
+
} catch (e: unknown) {
|
|
87
|
+
return { error: (e as Error).message }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -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 => {}
|
|
@@ -33,6 +33,10 @@ import {
|
|
|
33
33
|
} from '../../pikku-state.js'
|
|
34
34
|
import { PikkuSessionService } from '../../services/user-session-service.js'
|
|
35
35
|
import { getErrorResponse } from '../../errors/error-handler.js'
|
|
36
|
+
import {
|
|
37
|
+
PikkuCredentialWireService,
|
|
38
|
+
createMiddlewareCredentialWireProps,
|
|
39
|
+
} from '../../services/credential-wire-service.js'
|
|
36
40
|
import { handleHTTPError } from '../../handle-error.js'
|
|
37
41
|
import { pikkuState } from '../../pikku-state.js'
|
|
38
42
|
import { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js'
|
|
@@ -299,6 +303,7 @@ const executeRoute = async (
|
|
|
299
303
|
}
|
|
300
304
|
) => {
|
|
301
305
|
const userSession = new PikkuSessionService<CoreUserSession>()
|
|
306
|
+
const credentialWire = new PikkuCredentialWireService()
|
|
302
307
|
const { params, route, meta } = matchedRoute
|
|
303
308
|
const { singletonServices, createWireServices, skipUserSession, requestId } =
|
|
304
309
|
services
|
|
@@ -377,6 +382,7 @@ const executeRoute = async (
|
|
|
377
382
|
setSession: (s: any) => userSession.setInitial(s),
|
|
378
383
|
getSession: () => userSession.get(),
|
|
379
384
|
hasSessionChanged: () => userSession.sessionChanged,
|
|
385
|
+
...createMiddlewareCredentialWireProps(credentialWire),
|
|
380
386
|
}
|
|
381
387
|
|
|
382
388
|
result = await runPikkuFunc(
|
|
@@ -396,6 +402,7 @@ const executeRoute = async (
|
|
|
396
402
|
tags: route.tags,
|
|
397
403
|
wire,
|
|
398
404
|
sessionService: userSession,
|
|
405
|
+
credentialWireService: credentialWire,
|
|
399
406
|
packageName: meta.packageName,
|
|
400
407
|
}
|
|
401
408
|
)
|
|
@@ -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
|
+
}
|
|
@@ -40,30 +40,3 @@ export type OAuth2Config = {
|
|
|
40
40
|
/** Additional query parameters for authorization URL */
|
|
41
41
|
additionalParams?: Record<string, string>
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Configuration for wireOAuth2Credential.
|
|
46
|
-
* Combines standard credential fields with OAuth2-specific config.
|
|
47
|
-
*/
|
|
48
|
-
export type CoreOAuth2Credential = {
|
|
49
|
-
/** Unique identifier for this credential */
|
|
50
|
-
name: string
|
|
51
|
-
/** Human-readable name for UI display */
|
|
52
|
-
displayName: string
|
|
53
|
-
/** Optional description for UI */
|
|
54
|
-
description?: string
|
|
55
|
-
/** Key used with SecretService to retrieve app credentials (clientId/clientSecret) */
|
|
56
|
-
secretId: string
|
|
57
|
-
/** Where access/refresh tokens are stored */
|
|
58
|
-
tokenSecretId: string
|
|
59
|
-
/** OAuth2 authorization URL */
|
|
60
|
-
authorizationUrl: string
|
|
61
|
-
/** OAuth2 token exchange URL */
|
|
62
|
-
tokenUrl: string
|
|
63
|
-
/** Required scopes */
|
|
64
|
-
scopes: string[]
|
|
65
|
-
/** Use PKCE flow */
|
|
66
|
-
pkce?: boolean
|
|
67
|
-
/** Additional query parameters for authorization URL */
|
|
68
|
-
additionalParams?: Record<string, string>
|
|
69
|
-
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
type PikkuWorkflowService,
|
|
3
|
+
WorkflowAsyncException,
|
|
4
|
+
WorkflowSuspendedException,
|
|
5
|
+
} from '../pikku-workflow-service.js'
|
|
2
6
|
import type { GraphWireState, PikkuGraphWire } from './workflow-graph.types.js'
|
|
3
7
|
import { pikkuState, getSingletonServices } from '../../../pikku-state.js'
|
|
4
8
|
import type { WorkflowRuntimeMeta, WorkflowRunWire } from '../workflow.types.js'
|
|
@@ -489,6 +493,11 @@ export async function executeGraphStep(
|
|
|
489
493
|
} else {
|
|
490
494
|
result = await rpcService.rpcWithWire(rpcName, data, {
|
|
491
495
|
graph: graphWire,
|
|
496
|
+
workflow: workflowService.createWorkflowWire(
|
|
497
|
+
graphName,
|
|
498
|
+
runId,
|
|
499
|
+
rpcService
|
|
500
|
+
),
|
|
492
501
|
})
|
|
493
502
|
}
|
|
494
503
|
|
|
@@ -498,6 +507,12 @@ export async function executeGraphStep(
|
|
|
498
507
|
|
|
499
508
|
return result
|
|
500
509
|
} catch (error) {
|
|
510
|
+
if (
|
|
511
|
+
error instanceof WorkflowAsyncException ||
|
|
512
|
+
error instanceof WorkflowSuspendedException
|
|
513
|
+
) {
|
|
514
|
+
throw error
|
|
515
|
+
}
|
|
501
516
|
if (error instanceof ChildWorkflowStartedException) {
|
|
502
517
|
throw error
|
|
503
518
|
}
|
|
@@ -619,6 +634,11 @@ async function executeGraphNodeInline(
|
|
|
619
634
|
} else {
|
|
620
635
|
result = await rpcService.rpcWithWire(rpcName, input, {
|
|
621
636
|
graph: graphWire,
|
|
637
|
+
workflow: workflowService.createWorkflowWire(
|
|
638
|
+
graphName,
|
|
639
|
+
runId,
|
|
640
|
+
rpcService
|
|
641
|
+
),
|
|
622
642
|
})
|
|
623
643
|
}
|
|
624
644
|
|
|
@@ -631,6 +651,12 @@ async function executeGraphNodeInline(
|
|
|
631
651
|
|
|
632
652
|
await workflowService.setStepResult(stepState.stepId, result)
|
|
633
653
|
} catch (error) {
|
|
654
|
+
if (
|
|
655
|
+
error instanceof WorkflowAsyncException ||
|
|
656
|
+
error instanceof WorkflowSuspendedException
|
|
657
|
+
) {
|
|
658
|
+
throw error
|
|
659
|
+
}
|
|
634
660
|
if (error instanceof RPCNotFoundError) {
|
|
635
661
|
await workflowService.setStepError(stepState.stepId, error as Error)
|
|
636
662
|
await workflowService.updateRunStatus(runId, 'suspended', undefined, {
|
|
@@ -855,6 +881,12 @@ export async function runWorkflowGraph(
|
|
|
855
881
|
entryNodes
|
|
856
882
|
)
|
|
857
883
|
} catch (error) {
|
|
884
|
+
if (
|
|
885
|
+
error instanceof WorkflowAsyncException ||
|
|
886
|
+
error instanceof WorkflowSuspendedException
|
|
887
|
+
) {
|
|
888
|
+
return
|
|
889
|
+
}
|
|
858
890
|
await workflowService.updateRunStatus(runId, 'failed', undefined, {
|
|
859
891
|
message: (error as Error).message,
|
|
860
892
|
stack: (error as Error).stack || '',
|