@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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function validateAndBuildCredentialDefinitionsMeta(definitions, schemaLookup) {
|
|
2
|
+
const meta = {};
|
|
3
|
+
for (const def of definitions) {
|
|
4
|
+
const existing = meta[def.name];
|
|
5
|
+
if (existing) {
|
|
6
|
+
if (def.schema && existing.schema) {
|
|
7
|
+
const defSchemaRef = schemaLookup.get(def.schema);
|
|
8
|
+
const existingSchemaRef = schemaLookup.get(existing.schema);
|
|
9
|
+
if (defSchemaRef && existingSchemaRef) {
|
|
10
|
+
if (defSchemaRef.variableName !== existingSchemaRef.variableName ||
|
|
11
|
+
defSchemaRef.sourceFile !== existingSchemaRef.sourceFile) {
|
|
12
|
+
throw new Error(`Credential '${def.name}' is defined with different schemas.\n` +
|
|
13
|
+
` First definition: ${existing.sourceFile} (schema: ${existingSchemaRef.variableName})\n` +
|
|
14
|
+
` Second definition: ${def.sourceFile} (schema: ${defSchemaRef.variableName})\n` +
|
|
15
|
+
`Credentials sharing a name must use the same schema.`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (def.type !== existing.type) {
|
|
20
|
+
throw new Error(`Credential '${def.name}' is defined with different types.\n` +
|
|
21
|
+
` First definition: ${existing.sourceFile} (type: ${existing.type})\n` +
|
|
22
|
+
` Second definition: ${def.sourceFile} (type: ${def.type})\n` +
|
|
23
|
+
`Credentials sharing a name must use the same type.`);
|
|
24
|
+
}
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
meta[def.name] = {
|
|
28
|
+
name: def.name,
|
|
29
|
+
displayName: def.displayName,
|
|
30
|
+
description: def.description,
|
|
31
|
+
type: def.type,
|
|
32
|
+
schema: def.schema,
|
|
33
|
+
oauth2: def.oauth2,
|
|
34
|
+
sourceFile: def.sourceFile,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return meta;
|
|
38
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CoreCredential } from './credential.types.js';
|
|
2
|
+
/**
|
|
3
|
+
* No-op function for declaring credentials.
|
|
4
|
+
* This exists purely for TypeScript type checking and will be tree-shaken.
|
|
5
|
+
* The CLI extracts metadata via AST parsing.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* // Per-user API key
|
|
10
|
+
* wireCredential({
|
|
11
|
+
* name: 'stripe',
|
|
12
|
+
* displayName: 'Stripe API Key',
|
|
13
|
+
* type: 'wire',
|
|
14
|
+
* schema: z.object({ apiKey: z.string() }),
|
|
15
|
+
* })
|
|
16
|
+
*
|
|
17
|
+
* // Per-user OAuth
|
|
18
|
+
* wireCredential({
|
|
19
|
+
* name: 'google-sheets',
|
|
20
|
+
* displayName: 'Google Sheets',
|
|
21
|
+
* type: 'wire',
|
|
22
|
+
* schema: z.object({ accessToken: z.string(), refreshToken: z.string() }),
|
|
23
|
+
* oauth2: {
|
|
24
|
+
* appCredentialSecretId: 'GOOGLE_OAUTH_APP',
|
|
25
|
+
* authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
26
|
+
* tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
27
|
+
* scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
|
28
|
+
* tokenSecretId: 'GOOGLE_OAUTH_TOKENS',
|
|
29
|
+
* }
|
|
30
|
+
* })
|
|
31
|
+
*
|
|
32
|
+
* // Platform-level OAuth (singleton)
|
|
33
|
+
* wireCredential({
|
|
34
|
+
* name: 'slack',
|
|
35
|
+
* displayName: 'Slack',
|
|
36
|
+
* type: 'singleton',
|
|
37
|
+
* schema: z.object({ accessToken: z.string(), refreshToken: z.string() }),
|
|
38
|
+
* oauth2: {
|
|
39
|
+
* appCredentialSecretId: 'SLACK_OAUTH_APP',
|
|
40
|
+
* authorizationUrl: 'https://slack.com/oauth/v2/authorize',
|
|
41
|
+
* tokenUrl: 'https://slack.com/api/oauth.v2.access',
|
|
42
|
+
* scopes: ['chat:write', 'channels:read'],
|
|
43
|
+
* tokenSecretId: 'SLACK_OAUTH_TOKENS',
|
|
44
|
+
* }
|
|
45
|
+
* })
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export declare const wireCredential: <T>(_config: CoreCredential<T>) => void;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* No-op function for declaring credentials.
|
|
3
|
+
* This exists purely for TypeScript type checking and will be tree-shaken.
|
|
4
|
+
* The CLI extracts metadata via AST parsing.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* // Per-user API key
|
|
9
|
+
* wireCredential({
|
|
10
|
+
* name: 'stripe',
|
|
11
|
+
* displayName: 'Stripe API Key',
|
|
12
|
+
* type: 'wire',
|
|
13
|
+
* schema: z.object({ apiKey: z.string() }),
|
|
14
|
+
* })
|
|
15
|
+
*
|
|
16
|
+
* // Per-user OAuth
|
|
17
|
+
* wireCredential({
|
|
18
|
+
* name: 'google-sheets',
|
|
19
|
+
* displayName: 'Google Sheets',
|
|
20
|
+
* type: 'wire',
|
|
21
|
+
* schema: z.object({ accessToken: z.string(), refreshToken: z.string() }),
|
|
22
|
+
* oauth2: {
|
|
23
|
+
* appCredentialSecretId: 'GOOGLE_OAUTH_APP',
|
|
24
|
+
* authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
25
|
+
* tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
26
|
+
* scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
|
27
|
+
* tokenSecretId: 'GOOGLE_OAUTH_TOKENS',
|
|
28
|
+
* }
|
|
29
|
+
* })
|
|
30
|
+
*
|
|
31
|
+
* // Platform-level OAuth (singleton)
|
|
32
|
+
* wireCredential({
|
|
33
|
+
* name: 'slack',
|
|
34
|
+
* displayName: 'Slack',
|
|
35
|
+
* type: 'singleton',
|
|
36
|
+
* schema: z.object({ accessToken: z.string(), refreshToken: z.string() }),
|
|
37
|
+
* oauth2: {
|
|
38
|
+
* appCredentialSecretId: 'SLACK_OAUTH_APP',
|
|
39
|
+
* authorizationUrl: 'https://slack.com/oauth/v2/authorize',
|
|
40
|
+
* tokenUrl: 'https://slack.com/api/oauth.v2.access',
|
|
41
|
+
* scopes: ['chat:write', 'channels:read'],
|
|
42
|
+
* tokenSecretId: 'SLACK_OAUTH_TOKENS',
|
|
43
|
+
* }
|
|
44
|
+
* })
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export const wireCredential = (_config) => { };
|
|
@@ -2,6 +2,8 @@ import { NotFoundError, PikkuMissingMetaError } from '../../errors/errors.js';
|
|
|
2
2
|
import { closeWireServices, createWeakUID, isSerializable, } from '../../utils.js';
|
|
3
3
|
import { getSingletonServices, getCreateWireServices, } from '../../pikku-state.js';
|
|
4
4
|
import { PikkuSessionService } from '../../services/user-session-service.js';
|
|
5
|
+
import { getErrorResponse } from '../../errors/error-handler.js';
|
|
6
|
+
import { PikkuCredentialWireService, createMiddlewareCredentialWireProps, } from '../../services/credential-wire-service.js';
|
|
5
7
|
import { handleHTTPError } from '../../handle-error.js';
|
|
6
8
|
import { pikkuState } from '../../pikku-state.js';
|
|
7
9
|
import { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js';
|
|
@@ -196,6 +198,7 @@ export const createHTTPWire = (request, response) => {
|
|
|
196
198
|
*/
|
|
197
199
|
const executeRoute = async (services, matchedRoute, http, options) => {
|
|
198
200
|
const userSession = new PikkuSessionService();
|
|
201
|
+
const credentialWire = new PikkuCredentialWireService();
|
|
199
202
|
const { params, route, meta } = matchedRoute;
|
|
200
203
|
const { singletonServices, createWireServices, skipUserSession, requestId } = services;
|
|
201
204
|
// Attach URL parameters to the request object
|
|
@@ -256,6 +259,7 @@ const executeRoute = async (services, matchedRoute, http, options) => {
|
|
|
256
259
|
setSession: (s) => userSession.setInitial(s),
|
|
257
260
|
getSession: () => userSession.get(),
|
|
258
261
|
hasSessionChanged: () => userSession.sessionChanged,
|
|
262
|
+
...createMiddlewareCredentialWireProps(credentialWire),
|
|
259
263
|
};
|
|
260
264
|
result = await runPikkuFunc('http', `${meta.method}:${meta.route}`, meta.pikkuFuncId, {
|
|
261
265
|
singletonServices,
|
|
@@ -270,6 +274,7 @@ const executeRoute = async (services, matchedRoute, http, options) => {
|
|
|
270
274
|
tags: route.tags,
|
|
271
275
|
wire,
|
|
272
276
|
sessionService: userSession,
|
|
277
|
+
credentialWireService: credentialWire,
|
|
273
278
|
packageName: meta.packageName,
|
|
274
279
|
});
|
|
275
280
|
if (!matchedRoute.route.sse) {
|
|
@@ -379,7 +384,7 @@ export const fetchData = async (request, response, { skipUserSession = false, re
|
|
|
379
384
|
apiRoute,
|
|
380
385
|
apiType,
|
|
381
386
|
});
|
|
382
|
-
throw new NotFoundError(
|
|
387
|
+
throw new NotFoundError();
|
|
383
388
|
}
|
|
384
389
|
// Execute the matched route along with its middleware and session management
|
|
385
390
|
;
|
|
@@ -396,9 +401,10 @@ export const fetchData = async (request, response, { skipUserSession = false, re
|
|
|
396
401
|
// For SSE routes, send error through the stream since the response is already in stream mode
|
|
397
402
|
singletonServices.logger.error(e instanceof Error ? e.message : e);
|
|
398
403
|
try {
|
|
404
|
+
const errorResponse = getErrorResponse(e);
|
|
399
405
|
response.arrayBuffer(JSON.stringify({
|
|
400
406
|
type: 'error',
|
|
401
|
-
errorText:
|
|
407
|
+
errorText: errorResponse?.message ?? 'Internal server error',
|
|
402
408
|
}));
|
|
403
409
|
response.arrayBuffer(JSON.stringify({ type: 'done' }));
|
|
404
410
|
}
|
|
@@ -89,6 +89,9 @@ export class PikkuFetchHTTPRequest {
|
|
|
89
89
|
const merged = {};
|
|
90
90
|
for (const part of parts) {
|
|
91
91
|
for (const [key, value] of Object.entries(part)) {
|
|
92
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
92
95
|
if (key in merged && !valuesAreEquivalent(merged[key], value)) {
|
|
93
96
|
throw new UnprocessableContentError(`Conflicting values for key "${key}": "${merged[key]}" vs "${value}"`);
|
|
94
97
|
}
|
|
@@ -124,7 +127,14 @@ export class PikkuFetchHTTPRequest {
|
|
|
124
127
|
}
|
|
125
128
|
else if (contentType === 'application/x-www-form-urlencoded') {
|
|
126
129
|
const text = await this.request.text();
|
|
127
|
-
|
|
130
|
+
const params = new URLSearchParams(text);
|
|
131
|
+
let count = 0;
|
|
132
|
+
for (const _ of params) {
|
|
133
|
+
if (++count > 256) {
|
|
134
|
+
throw new UnprocessableContentError('Too many form parameters');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
body = Object.fromEntries(params);
|
|
128
138
|
}
|
|
129
139
|
else {
|
|
130
140
|
throw new UnprocessableContentError(`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';
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { OAuth2Client } from './oauth2-client.js';
|
|
2
|
-
export {
|
|
2
|
+
export { createOAuth2Handler } from './oauth2-routes.js';
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
* })
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
|
-
const DEFAULT_TIMEOUT_MS =
|
|
29
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
30
|
+
const TOKEN_EXPIRY_BUFFER_MS = 60_000;
|
|
30
31
|
/**
|
|
31
32
|
* Helper to fetch with a timeout using AbortController.
|
|
32
33
|
*/
|
|
@@ -114,16 +115,13 @@ export class OAuth2Client {
|
|
|
114
115
|
* Uses a promise lock to prevent concurrent refresh attempts.
|
|
115
116
|
*/
|
|
116
117
|
async refreshAndGetToken() {
|
|
117
|
-
// If already refreshing, wait for that to complete
|
|
118
118
|
if (this.refreshPromise) {
|
|
119
119
|
const token = await this.refreshPromise;
|
|
120
120
|
return token.accessToken;
|
|
121
121
|
}
|
|
122
|
-
// Start refresh
|
|
123
122
|
this.refreshPromise = this.doRefreshToken();
|
|
124
123
|
try {
|
|
125
124
|
const token = await this.refreshPromise;
|
|
126
|
-
this.cachedToken = token;
|
|
127
125
|
return token.accessToken;
|
|
128
126
|
}
|
|
129
127
|
finally {
|
|
@@ -169,9 +167,8 @@ export class OAuth2Client {
|
|
|
169
167
|
tokenType: data.token_type || 'Bearer',
|
|
170
168
|
scope: data.scope,
|
|
171
169
|
};
|
|
172
|
-
// Cache and persist the refreshed token
|
|
173
|
-
this.cachedToken = token;
|
|
174
170
|
await this.secrets.setSecretJSON(this.oauth2Config.tokenSecretId, token);
|
|
171
|
+
this.cachedToken = token;
|
|
175
172
|
return token;
|
|
176
173
|
}
|
|
177
174
|
/**
|
|
@@ -181,8 +178,7 @@ export class OAuth2Client {
|
|
|
181
178
|
if (!token.expiresAt) {
|
|
182
179
|
return true; // No expiry info, assume valid
|
|
183
180
|
}
|
|
184
|
-
|
|
185
|
-
return token.expiresAt > Date.now() + 60000;
|
|
181
|
+
return token.expiresAt > Date.now() + TOKEN_EXPIRY_BUFFER_MS;
|
|
186
182
|
}
|
|
187
183
|
/**
|
|
188
184
|
* Get app credentials (cached).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CredentialDefinitionsMeta } from '../credential/credential.types.js';
|
|
2
|
+
export type CreateOAuth2HandlerOptions = {
|
|
3
|
+
credentialsMeta: CredentialDefinitionsMeta;
|
|
4
|
+
basePath?: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Creates OAuth2 route handlers for user credential management.
|
|
8
|
+
*
|
|
9
|
+
* Returns individual handler functions for connect/callback/disconnect/status
|
|
10
|
+
* that handle the OAuth2 authorization code flow and store tokens in CredentialService.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* const oauth2 = createOAuth2Handler({ credentialsMeta })
|
|
15
|
+
*
|
|
16
|
+
* const oauth2Routes = defineHTTPRoutes({
|
|
17
|
+
* auth: true,
|
|
18
|
+
* basePath: '/credentials',
|
|
19
|
+
* routes: {
|
|
20
|
+
* connect: { method: 'get', route: '/:name/connect', func: oauth2.connect },
|
|
21
|
+
* callback: { method: 'get', route: '/:name/callback', func: oauth2.callback, auth: false },
|
|
22
|
+
* disconnect: { method: 'delete', route: '/:name', func: oauth2.disconnect },
|
|
23
|
+
* status: { method: 'get', route: '/:name/status', func: oauth2.status },
|
|
24
|
+
* },
|
|
25
|
+
* })
|
|
26
|
+
*
|
|
27
|
+
* wireHTTPRoutes({ routes: { credentials: oauth2Routes } })
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare const createOAuth2Handler: (options: CreateOAuth2HandlerOptions) => {
|
|
31
|
+
connect: any;
|
|
32
|
+
callback: any;
|
|
33
|
+
disconnect: any;
|
|
34
|
+
status: any;
|
|
35
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { OAuth2Client } from './oauth2-client.js';
|
|
2
|
+
const TOKEN_EXPIRY_BUFFER_MS = 60_000;
|
|
3
|
+
function getCredentialMeta(ctx, name) {
|
|
4
|
+
const meta = ctx.credentialsMeta[name];
|
|
5
|
+
if (!meta) {
|
|
6
|
+
throw new Error(`Credential '${name}' not found`);
|
|
7
|
+
}
|
|
8
|
+
if (!meta.oauth2) {
|
|
9
|
+
throw new Error(`Credential '${name}' is not an OAuth2 credential`);
|
|
10
|
+
}
|
|
11
|
+
return meta;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Creates OAuth2 route handlers for user credential management.
|
|
15
|
+
*
|
|
16
|
+
* Returns individual handler functions for connect/callback/disconnect/status
|
|
17
|
+
* that handle the OAuth2 authorization code flow and store tokens in CredentialService.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* const oauth2 = createOAuth2Handler({ credentialsMeta })
|
|
22
|
+
*
|
|
23
|
+
* const oauth2Routes = defineHTTPRoutes({
|
|
24
|
+
* auth: true,
|
|
25
|
+
* basePath: '/credentials',
|
|
26
|
+
* routes: {
|
|
27
|
+
* connect: { method: 'get', route: '/:name/connect', func: oauth2.connect },
|
|
28
|
+
* callback: { method: 'get', route: '/:name/callback', func: oauth2.callback, auth: false },
|
|
29
|
+
* disconnect: { method: 'delete', route: '/:name', func: oauth2.disconnect },
|
|
30
|
+
* status: { method: 'get', route: '/:name/status', func: oauth2.status },
|
|
31
|
+
* },
|
|
32
|
+
* })
|
|
33
|
+
*
|
|
34
|
+
* wireHTTPRoutes({ routes: { credentials: oauth2Routes } })
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export const createOAuth2Handler = (options) => {
|
|
38
|
+
const basePath = options.basePath ?? '/credentials';
|
|
39
|
+
const ctx = {
|
|
40
|
+
credentialsMeta: options.credentialsMeta,
|
|
41
|
+
basePath,
|
|
42
|
+
};
|
|
43
|
+
const connectHandler = async (services, _data, wire) => {
|
|
44
|
+
const { name } = wire.http.request.params();
|
|
45
|
+
const meta = getCredentialMeta(ctx, name);
|
|
46
|
+
const queryParams = wire.http.request.query();
|
|
47
|
+
const redirectUrl = queryParams.redirect_url || queryParams.redirect;
|
|
48
|
+
const oauth2Client = new OAuth2Client(meta.oauth2, meta.oauth2.appCredentialSecretId, services.secrets);
|
|
49
|
+
const userId = wire.session?.userId;
|
|
50
|
+
if (!userId) {
|
|
51
|
+
throw new Error('Authentication required for OAuth2 connect');
|
|
52
|
+
}
|
|
53
|
+
const state = await services.jwt.encode({ value: 10, unit: 'minute' }, {
|
|
54
|
+
userId,
|
|
55
|
+
credentialName: name,
|
|
56
|
+
redirectUrl,
|
|
57
|
+
});
|
|
58
|
+
let origin = wire.http.request.header('origin');
|
|
59
|
+
if (!origin) {
|
|
60
|
+
const host = wire.http.request.header('host');
|
|
61
|
+
if (!host) {
|
|
62
|
+
throw new Error('Unable to determine request origin for OAuth2 callback');
|
|
63
|
+
}
|
|
64
|
+
const protocol = wire.http.request.header('x-forwarded-proto') || 'http';
|
|
65
|
+
origin = `${protocol}://${host}`;
|
|
66
|
+
}
|
|
67
|
+
const callbackUrl = `${origin}${basePath}/${name}/callback`;
|
|
68
|
+
const authUrl = await oauth2Client.getAuthorizationUrl(state, callbackUrl);
|
|
69
|
+
return Response.redirect(authUrl, 302);
|
|
70
|
+
};
|
|
71
|
+
const callbackHandler = async (services, _data, wire) => {
|
|
72
|
+
const { name } = wire.http.request.params();
|
|
73
|
+
const meta = getCredentialMeta(ctx, name);
|
|
74
|
+
const queryParams = wire.http.request.query();
|
|
75
|
+
const code = queryParams.code;
|
|
76
|
+
const state = queryParams.state;
|
|
77
|
+
if (!code || !state) {
|
|
78
|
+
throw new Error('Missing code or state parameter');
|
|
79
|
+
}
|
|
80
|
+
let statePayload;
|
|
81
|
+
try {
|
|
82
|
+
statePayload = await services.jwt.decode(state);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
throw new Error('Invalid or expired OAuth2 state');
|
|
86
|
+
}
|
|
87
|
+
if (statePayload.credentialName !== name) {
|
|
88
|
+
throw new Error('Credential name mismatch in state');
|
|
89
|
+
}
|
|
90
|
+
const oauth2Client = new OAuth2Client(meta.oauth2, meta.oauth2.appCredentialSecretId, services.secrets);
|
|
91
|
+
let origin = wire.http.request.header('origin');
|
|
92
|
+
if (!origin) {
|
|
93
|
+
const host = wire.http.request.header('host');
|
|
94
|
+
if (!host) {
|
|
95
|
+
throw new Error('Unable to determine request origin for OAuth2 callback');
|
|
96
|
+
}
|
|
97
|
+
const protocol = wire.http.request.header('x-forwarded-proto') || 'http';
|
|
98
|
+
origin = `${protocol}://${host}`;
|
|
99
|
+
}
|
|
100
|
+
const callbackUrl = `${origin}${basePath}/${name}/callback`;
|
|
101
|
+
const tokens = await oauth2Client.exchangeCode(code, callbackUrl);
|
|
102
|
+
await services.credentialService.set(name, tokens, statePayload.userId);
|
|
103
|
+
services.logger.info(`OAuth2 tokens stored for user '${statePayload.userId}', credential '${name}'`);
|
|
104
|
+
if (statePayload.redirectUrl) {
|
|
105
|
+
return Response.redirect(statePayload.redirectUrl, 302);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
return { success: true, credentialName: name };
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const disconnectHandler = async (services, _data, wire) => {
|
|
112
|
+
const { name } = wire.http.request.params();
|
|
113
|
+
const userId = wire.session?.userId;
|
|
114
|
+
if (!userId) {
|
|
115
|
+
throw new Error('Authentication required');
|
|
116
|
+
}
|
|
117
|
+
await services.credentialService.delete(name, userId);
|
|
118
|
+
services.logger.info(`OAuth2 credential '${name}' disconnected for user '${userId}'`);
|
|
119
|
+
return { success: true };
|
|
120
|
+
};
|
|
121
|
+
const statusHandler = async (services, _data, wire) => {
|
|
122
|
+
const { name } = wire.http.request.params();
|
|
123
|
+
const userId = wire.session?.userId;
|
|
124
|
+
if (!userId) {
|
|
125
|
+
throw new Error('Authentication required');
|
|
126
|
+
}
|
|
127
|
+
const credential = await services.credentialService.get(name, userId);
|
|
128
|
+
if (!credential) {
|
|
129
|
+
return { connected: false };
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
connected: true,
|
|
133
|
+
hasRefreshToken: !!credential.refreshToken,
|
|
134
|
+
expiresAt: credential.expiresAt,
|
|
135
|
+
isExpired: credential.expiresAt
|
|
136
|
+
? credential.expiresAt < Date.now() + TOKEN_EXPIRY_BUFFER_MS
|
|
137
|
+
: false,
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
return {
|
|
141
|
+
connect: { func: connectHandler },
|
|
142
|
+
callback: { func: callbackHandler },
|
|
143
|
+
disconnect: { func: disconnectHandler },
|
|
144
|
+
status: { func: statusHandler },
|
|
145
|
+
};
|
|
146
|
+
};
|
|
@@ -37,29 +37,3 @@ export type OAuth2Config = {
|
|
|
37
37
|
/** Additional query parameters for authorization URL */
|
|
38
38
|
additionalParams?: Record<string, string>;
|
|
39
39
|
};
|
|
40
|
-
/**
|
|
41
|
-
* Configuration for wireOAuth2Credential.
|
|
42
|
-
* Combines standard credential fields with OAuth2-specific config.
|
|
43
|
-
*/
|
|
44
|
-
export type CoreOAuth2Credential = {
|
|
45
|
-
/** Unique identifier for this credential */
|
|
46
|
-
name: string;
|
|
47
|
-
/** Human-readable name for UI display */
|
|
48
|
-
displayName: string;
|
|
49
|
-
/** Optional description for UI */
|
|
50
|
-
description?: string;
|
|
51
|
-
/** Key used with SecretService to retrieve app credentials (clientId/clientSecret) */
|
|
52
|
-
secretId: string;
|
|
53
|
-
/** Where access/refresh tokens are stored */
|
|
54
|
-
tokenSecretId: string;
|
|
55
|
-
/** OAuth2 authorization URL */
|
|
56
|
-
authorizationUrl: string;
|
|
57
|
-
/** OAuth2 token exchange URL */
|
|
58
|
-
tokenUrl: string;
|
|
59
|
-
/** Required scopes */
|
|
60
|
-
scopes: string[];
|
|
61
|
-
/** Use PKCE flow */
|
|
62
|
-
pkce?: boolean;
|
|
63
|
-
/** Additional query parameters for authorization URL */
|
|
64
|
-
additionalParams?: Record<string, string>;
|
|
65
|
-
};
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type PikkuWorkflowService } from '../pikku-workflow-service.js';
|
|
2
2
|
import type { WorkflowRuntimeMeta, WorkflowRunWire } from '../workflow.types.js';
|
|
3
|
+
export declare class ChildWorkflowStartedException extends Error {
|
|
4
|
+
parentRunId: string;
|
|
5
|
+
stepId: string;
|
|
6
|
+
childRunId: string;
|
|
7
|
+
name: string;
|
|
8
|
+
constructor(parentRunId: string, stepId: string, childRunId: string);
|
|
9
|
+
}
|
|
3
10
|
export declare function continueGraph(workflowService: PikkuWorkflowService, runId: string, graphName: string, overrideMeta?: WorkflowRuntimeMeta): Promise<void>;
|
|
4
11
|
export declare function executeGraphStep(workflowService: PikkuWorkflowService, rpcService: any, runId: string, stepId: string, nodeId: string, rpcName: string, data: any, graphName: string): Promise<any>;
|
|
5
12
|
export declare function onGraphNodeComplete(workflowService: PikkuWorkflowService, runId: string, graphName: string): Promise<void>;
|
|
@@ -1,5 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { WorkflowAsyncException, WorkflowSuspendedException, } from '../pikku-workflow-service.js';
|
|
2
|
+
import { pikkuState, getSingletonServices } from '../../../pikku-state.js';
|
|
2
3
|
import { RPCNotFoundError } from '../../rpc/rpc-runner.js';
|
|
4
|
+
export class ChildWorkflowStartedException extends Error {
|
|
5
|
+
parentRunId;
|
|
6
|
+
stepId;
|
|
7
|
+
childRunId;
|
|
8
|
+
name = 'ChildWorkflowStartedException';
|
|
9
|
+
constructor(parentRunId, stepId, childRunId) {
|
|
10
|
+
super(`Child workflow started: ${childRunId}`);
|
|
11
|
+
this.parentRunId = parentRunId;
|
|
12
|
+
this.stepId = stepId;
|
|
13
|
+
this.childRunId = childRunId;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
3
16
|
function buildTemplateRegex(nodeId) {
|
|
4
17
|
if (!nodeId.includes('${'))
|
|
5
18
|
return null;
|
|
@@ -321,15 +334,51 @@ export async function executeGraphStep(workflowService, rpcService, runId, stepI
|
|
|
321
334
|
getState: () => workflowService.getRunState(runId),
|
|
322
335
|
};
|
|
323
336
|
try {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
337
|
+
let result;
|
|
338
|
+
const subWorkflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName];
|
|
339
|
+
if (subWorkflowMeta) {
|
|
340
|
+
const childWire = {
|
|
341
|
+
type: 'workflow',
|
|
342
|
+
id: rpcName,
|
|
343
|
+
parentRunId: runId,
|
|
344
|
+
parentStepId: stepId,
|
|
345
|
+
};
|
|
346
|
+
const shouldInline = subWorkflowMeta.inline || !getSingletonServices()?.queueService;
|
|
347
|
+
const { runId: childRunId } = await workflowService.startWorkflow(rpcName, data, childWire, rpcService, { inline: shouldInline });
|
|
348
|
+
await workflowService.setStepChildRunId(stepId, childRunId);
|
|
349
|
+
if (shouldInline) {
|
|
350
|
+
const childRun = await workflowService.getRun(childRunId);
|
|
351
|
+
if (childRun?.status === 'failed') {
|
|
352
|
+
throw new Error(childRun.error?.message || 'Sub-workflow failed');
|
|
353
|
+
}
|
|
354
|
+
if (childRun?.status === 'cancelled') {
|
|
355
|
+
throw new Error('Sub-workflow was cancelled');
|
|
356
|
+
}
|
|
357
|
+
result = childRun?.output;
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
throw new ChildWorkflowStartedException(runId, stepId, childRunId);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
result = await rpcService.rpcWithWire(rpcName, data, {
|
|
365
|
+
graph: graphWire,
|
|
366
|
+
workflow: workflowService.createWorkflowWire(graphName, runId, rpcService),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
327
369
|
if (wireState.branchKey) {
|
|
328
370
|
await workflowService.setBranchTaken(stepId, wireState.branchKey);
|
|
329
371
|
}
|
|
330
372
|
return result;
|
|
331
373
|
}
|
|
332
374
|
catch (error) {
|
|
375
|
+
if (error instanceof WorkflowAsyncException ||
|
|
376
|
+
error instanceof WorkflowSuspendedException) {
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
if (error instanceof ChildWorkflowStartedException) {
|
|
380
|
+
throw error;
|
|
381
|
+
}
|
|
333
382
|
if (error instanceof RPCNotFoundError) {
|
|
334
383
|
await workflowService.updateRunStatus(runId, 'suspended', undefined, {
|
|
335
384
|
message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
|
|
@@ -381,15 +430,42 @@ async function executeGraphNodeInline(workflowService, rpcService, runId, graphN
|
|
|
381
430
|
getState: () => workflowService.getRunState(runId),
|
|
382
431
|
};
|
|
383
432
|
try {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
433
|
+
let result;
|
|
434
|
+
const subWorkflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName];
|
|
435
|
+
if (subWorkflowMeta) {
|
|
436
|
+
const childWire = {
|
|
437
|
+
type: 'workflow',
|
|
438
|
+
id: rpcName,
|
|
439
|
+
parentRunId: runId,
|
|
440
|
+
parentStepId: stepState.stepId,
|
|
441
|
+
};
|
|
442
|
+
const { runId: childRunId } = await workflowService.startWorkflow(rpcName, input, childWire, rpcService, { inline: true });
|
|
443
|
+
await workflowService.setStepChildRunId(stepState.stepId, childRunId);
|
|
444
|
+
const childRun = await workflowService.getRun(childRunId);
|
|
445
|
+
if (childRun?.status === 'failed') {
|
|
446
|
+
throw new Error(childRun.error?.message || 'Sub-workflow failed');
|
|
447
|
+
}
|
|
448
|
+
if (childRun?.status === 'cancelled') {
|
|
449
|
+
throw new Error('Sub-workflow was cancelled');
|
|
450
|
+
}
|
|
451
|
+
result = childRun?.output;
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
result = await rpcService.rpcWithWire(rpcName, input, {
|
|
455
|
+
graph: graphWire,
|
|
456
|
+
workflow: workflowService.createWorkflowWire(graphName, runId, rpcService),
|
|
457
|
+
});
|
|
458
|
+
}
|
|
387
459
|
if (wireState.branchKey) {
|
|
388
460
|
await workflowService.setBranchTaken(stepState.stepId, wireState.branchKey);
|
|
389
461
|
}
|
|
390
462
|
await workflowService.setStepResult(stepState.stepId, result);
|
|
391
463
|
}
|
|
392
464
|
catch (error) {
|
|
465
|
+
if (error instanceof WorkflowAsyncException ||
|
|
466
|
+
error instanceof WorkflowSuspendedException) {
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
393
469
|
if (error instanceof RPCNotFoundError) {
|
|
394
470
|
await workflowService.setStepError(stepState.stepId, error);
|
|
395
471
|
await workflowService.updateRunStatus(runId, 'suspended', undefined, {
|
|
@@ -510,6 +586,10 @@ export async function runWorkflowGraph(workflowService, graphName, triggerInput,
|
|
|
510
586
|
await continueGraphInline(workflowService, rpcService, runId, graphName, nodes, triggerInput, entryNodes);
|
|
511
587
|
}
|
|
512
588
|
catch (error) {
|
|
589
|
+
if (error instanceof WorkflowAsyncException ||
|
|
590
|
+
error instanceof WorkflowSuspendedException) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
513
593
|
await workflowService.updateRunStatus(runId, 'failed', undefined, {
|
|
514
594
|
message: error.message,
|
|
515
595
|
stack: error.stack || '',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SerializedError } from '../../types/core.types.js';
|
|
2
|
-
import type { StepState, WorkflowRun, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from './workflow.types.js';
|
|
2
|
+
import type { PikkuWorkflowWire, StepState, WorkflowRun, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from './workflow.types.js';
|
|
3
3
|
import type { WorkflowService } from '../../services/workflow-service.js';
|
|
4
4
|
import { PikkuError } from '../../errors/error-handler.js';
|
|
5
5
|
/**
|
|
@@ -47,6 +47,7 @@ export declare class WorkflowStepNameNotString extends Error {
|
|
|
47
47
|
*/
|
|
48
48
|
export declare abstract class PikkuWorkflowService implements WorkflowService {
|
|
49
49
|
private inlineRuns;
|
|
50
|
+
protected get logger(): import("../../services/logger.js").Logger;
|
|
50
51
|
constructor();
|
|
51
52
|
/**
|
|
52
53
|
* Check if a run is executing inline (without queues)
|
|
@@ -249,6 +250,8 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
249
250
|
wire?: WorkflowRunWire;
|
|
250
251
|
}): Promise<any>;
|
|
251
252
|
runWorkflowJob(runId: string, rpcService: any): Promise<void>;
|
|
253
|
+
private onChildWorkflowCompleted;
|
|
254
|
+
private onChildWorkflowFailed;
|
|
252
255
|
private runVersionMismatchFallback;
|
|
253
256
|
/**
|
|
254
257
|
* Execute a single workflow step (called by worker)
|
|
@@ -266,7 +269,7 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
266
269
|
private sleepStep;
|
|
267
270
|
private getSuspendStepName;
|
|
268
271
|
private suspendStep;
|
|
269
|
-
|
|
272
|
+
createWorkflowWire(name: string, runId: string, rpcService: any): PikkuWorkflowWire;
|
|
270
273
|
private verifyStepName;
|
|
271
274
|
private getConfig;
|
|
272
275
|
}
|