@pikku/core 0.12.8 → 0.12.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/env.d.ts +1 -0
  3. package/dist/env.js +1 -0
  4. package/dist/errors/errors.d.ts +14 -0
  5. package/dist/errors/errors.js +27 -0
  6. package/dist/function/function-runner.d.ts +3 -1
  7. package/dist/function/function-runner.js +5 -1
  8. package/dist/handle-error.js +2 -1
  9. package/dist/middleware/auth-bearer.js +10 -1
  10. package/dist/middleware/auth-cookie.js +34 -25
  11. package/dist/middleware/timeout.js +11 -5
  12. package/dist/pikku-state.js +1 -1
  13. package/dist/services/credential-service.d.ts +40 -0
  14. package/dist/services/credential-service.js +1 -0
  15. package/dist/services/credential-wire-service.d.ts +13 -0
  16. package/dist/services/credential-wire-service.js +31 -0
  17. package/dist/services/index.d.ts +5 -0
  18. package/dist/services/index.js +3 -0
  19. package/dist/services/local-content.d.ts +7 -4
  20. package/dist/services/local-content.js +42 -12
  21. package/dist/services/local-credential-service.d.ts +10 -0
  22. package/dist/services/local-credential-service.js +33 -0
  23. package/dist/services/local-secrets.js +2 -2
  24. package/dist/services/typed-credential-service.d.ts +27 -0
  25. package/dist/services/typed-credential-service.js +40 -0
  26. package/dist/testing/service-tests.d.ts +8 -0
  27. package/dist/testing/service-tests.js +107 -1
  28. package/dist/types/core.types.d.ts +7 -0
  29. package/dist/wirings/ai-agent/agent-dynamic-workflow.js +173 -53
  30. package/dist/wirings/ai-agent/ai-agent-prepare.js +2 -1
  31. package/dist/wirings/ai-agent/ai-agent-runner.js +2 -1
  32. package/dist/wirings/ai-agent/ai-agent-stream.js +2 -1
  33. package/dist/wirings/channel/local/local-channel-runner.js +11 -6
  34. package/dist/wirings/credential/credential.types.d.ts +24 -0
  35. package/dist/wirings/credential/credential.types.js +1 -0
  36. package/dist/wirings/credential/index.d.ts +3 -0
  37. package/dist/wirings/credential/index.js +2 -0
  38. package/dist/wirings/credential/validate-credential-definitions.d.ts +6 -0
  39. package/dist/wirings/credential/validate-credential-definitions.js +38 -0
  40. package/dist/wirings/credential/wire-credential.d.ts +48 -0
  41. package/dist/wirings/credential/wire-credential.js +47 -0
  42. package/dist/wirings/http/http-runner.js +8 -2
  43. package/dist/wirings/http/pikku-fetch-http-request.js +11 -1
  44. package/dist/wirings/oauth2/index.d.ts +2 -1
  45. package/dist/wirings/oauth2/index.js +1 -1
  46. package/dist/wirings/oauth2/oauth2-client.js +4 -8
  47. package/dist/wirings/oauth2/oauth2-routes.d.ts +35 -0
  48. package/dist/wirings/oauth2/oauth2-routes.js +146 -0
  49. package/dist/wirings/oauth2/oauth2.types.d.ts +0 -26
  50. package/dist/wirings/workflow/graph/graph-runner.d.ts +8 -1
  51. package/dist/wirings/workflow/graph/graph-runner.js +87 -7
  52. package/dist/wirings/workflow/pikku-workflow-service.d.ts +5 -2
  53. package/dist/wirings/workflow/pikku-workflow-service.js +99 -24
  54. package/dist/wirings/workflow/workflow.types.d.ts +4 -0
  55. package/package.json +2 -1
  56. package/src/env.ts +1 -0
  57. package/src/errors/errors.ts +35 -0
  58. package/src/function/function-runner.ts +11 -0
  59. package/src/handle-error.ts +2 -1
  60. package/src/middleware/auth-bearer.ts +10 -1
  61. package/src/middleware/auth-cookie.ts +11 -4
  62. package/src/middleware/timeout.ts +14 -7
  63. package/src/pikku-state.ts +1 -1
  64. package/src/services/credential-service.ts +44 -0
  65. package/src/services/credential-wire-service.ts +44 -0
  66. package/src/services/index.ts +12 -0
  67. package/src/services/local-content.ts +61 -13
  68. package/src/services/local-credential-service.ts +41 -0
  69. package/src/services/local-secrets.test.ts +2 -2
  70. package/src/services/local-secrets.ts +2 -2
  71. package/src/services/typed-credential-service.ts +75 -0
  72. package/src/testing/service-tests.ts +140 -1
  73. package/src/types/core.types.ts +7 -0
  74. package/src/wirings/ai-agent/agent-dynamic-workflow.ts +378 -237
  75. package/src/wirings/ai-agent/ai-agent-prepare.ts +2 -1
  76. package/src/wirings/ai-agent/ai-agent-runner.test.ts +34 -0
  77. package/src/wirings/ai-agent/ai-agent-runner.ts +2 -1
  78. package/src/wirings/ai-agent/ai-agent-stream.ts +2 -1
  79. package/src/wirings/channel/local/local-channel-runner.ts +11 -6
  80. package/src/wirings/credential/credential.types.ts +28 -0
  81. package/src/wirings/credential/index.ts +8 -0
  82. package/src/wirings/credential/validate-credential-definitions.ts +64 -0
  83. package/src/wirings/credential/wire-credential.ts +49 -0
  84. package/src/wirings/http/http-runner.ts +11 -2
  85. package/src/wirings/http/pikku-fetch-http-request.ts +11 -1
  86. package/src/wirings/oauth2/index.ts +2 -1
  87. package/src/wirings/oauth2/oauth2-client.ts +4 -8
  88. package/src/wirings/oauth2/oauth2-routes.ts +234 -0
  89. package/src/wirings/oauth2/oauth2.types.ts +0 -27
  90. package/src/wirings/workflow/graph/graph-runner.test.ts +42 -0
  91. package/src/wirings/workflow/graph/graph-runner.ts +117 -8
  92. package/src/wirings/workflow/pikku-workflow-service.test.ts +109 -0
  93. package/src/wirings/workflow/pikku-workflow-service.ts +150 -34
  94. package/src/wirings/workflow/workflow.types.ts +4 -0
  95. package/tsconfig.tsbuildinfo +1 -1
  96. package/dist/wirings/oauth2/wire-oauth2-credential.d.ts +0 -20
  97. package/dist/wirings/oauth2/wire-oauth2-credential.js +0 -21
  98. package/src/wirings/oauth2/wire-oauth2-credential.ts +0 -23
@@ -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(`Route not found: ${apiRoute}`);
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: e instanceof Error ? e.message : String(e),
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
- body = Object.fromEntries(new URLSearchParams(text));
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 { wireOAuth2Credential } from './wire-oauth2-credential.js';
2
+ export { createOAuth2Handler } from './oauth2-routes.js';
3
+ export type { CreateOAuth2HandlerOptions } from './oauth2-routes.js';
3
4
  export type { OAuth2AppCredential, OAuth2Token } from './oauth2.types.js';
@@ -1,2 +1,2 @@
1
1
  export { OAuth2Client } from './oauth2-client.js';
2
- export { wireOAuth2Credential } from './wire-oauth2-credential.js';
2
+ export { createOAuth2Handler } from './oauth2-routes.js';
@@ -26,7 +26,8 @@
26
26
  * })
27
27
  * ```
28
28
  */
29
- const DEFAULT_TIMEOUT_MS = 30000;
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
- // Add 60 second buffer to account for clock skew
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 { PikkuWorkflowService } from '../pikku-workflow-service.js';
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 { pikkuState } from '../../../pikku-state.js';
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
- const result = await rpcService.rpcWithWire(rpcName, data, {
325
- graph: graphWire,
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
- const result = await rpcService.rpcWithWire(rpcName, input, {
385
- graph: graphWire,
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
- private createWorkflowWire;
272
+ createWorkflowWire(name: string, runId: string, rpcService: any): PikkuWorkflowWire;
270
273
  private verifyStepName;
271
274
  private getConfig;
272
275
  }