@pikku/core 0.12.7 → 0.12.9
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 +30 -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/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/local-content.d.ts +7 -4
- package/dist/services/local-content.js +42 -12
- package/dist/services/local-secrets.js +2 -2
- package/dist/testing/service-tests.js +1 -1
- 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/http/http-runner.js +4 -2
- package/dist/wirings/http/pikku-fetch-http-request.js +11 -1
- package/dist/wirings/http/web-request.js +3 -1
- package/dist/wirings/workflow/graph/graph-runner.d.ts +7 -0
- package/dist/wirings/workflow/graph/graph-runner.js +72 -7
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +3 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +59 -16
- package/dist/wirings/workflow/workflow.types.d.ts +4 -0
- package/package.json +1 -1
- package/src/env.ts +1 -0
- package/src/errors/errors.ts +35 -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/local-content.ts +61 -13
- package/src/services/local-secrets.test.ts +2 -2
- package/src/services/local-secrets.ts +2 -2
- package/src/testing/service-tests.ts +1 -1
- 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/http/http-runner.ts +4 -2
- package/src/wirings/http/pikku-fetch-http-request.ts +11 -1
- package/src/wirings/http/web-request.ts +3 -1
- package/src/wirings/workflow/graph/graph-runner.test.ts +42 -0
- package/src/wirings/workflow/graph/graph-runner.ts +84 -7
- package/src/wirings/workflow/pikku-workflow-service.test.ts +109 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +95 -25
- package/src/wirings/workflow/workflow.types.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
import { pikkuState } from '../../../pikku-state.js';
|
|
1
|
+
import { pikkuState, getSingletonServices } from '../../../pikku-state.js';
|
|
2
2
|
import { RPCNotFoundError } from '../../rpc/rpc-runner.js';
|
|
3
|
+
export class ChildWorkflowStartedException extends Error {
|
|
4
|
+
parentRunId;
|
|
5
|
+
stepId;
|
|
6
|
+
childRunId;
|
|
7
|
+
name = 'ChildWorkflowStartedException';
|
|
8
|
+
constructor(parentRunId, stepId, childRunId) {
|
|
9
|
+
super(`Child workflow started: ${childRunId}`);
|
|
10
|
+
this.parentRunId = parentRunId;
|
|
11
|
+
this.stepId = stepId;
|
|
12
|
+
this.childRunId = childRunId;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
3
15
|
function buildTemplateRegex(nodeId) {
|
|
4
16
|
if (!nodeId.includes('${'))
|
|
5
17
|
return null;
|
|
@@ -321,15 +333,46 @@ export async function executeGraphStep(workflowService, rpcService, runId, stepI
|
|
|
321
333
|
getState: () => workflowService.getRunState(runId),
|
|
322
334
|
};
|
|
323
335
|
try {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
336
|
+
let result;
|
|
337
|
+
const subWorkflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName];
|
|
338
|
+
if (subWorkflowMeta) {
|
|
339
|
+
const childWire = {
|
|
340
|
+
type: 'workflow',
|
|
341
|
+
id: rpcName,
|
|
342
|
+
parentRunId: runId,
|
|
343
|
+
parentStepId: stepId,
|
|
344
|
+
};
|
|
345
|
+
const shouldInline = subWorkflowMeta.inline || !getSingletonServices()?.queueService;
|
|
346
|
+
const { runId: childRunId } = await workflowService.startWorkflow(rpcName, data, childWire, rpcService, { inline: shouldInline });
|
|
347
|
+
await workflowService.setStepChildRunId(stepId, childRunId);
|
|
348
|
+
if (shouldInline) {
|
|
349
|
+
const childRun = await workflowService.getRun(childRunId);
|
|
350
|
+
if (childRun?.status === 'failed') {
|
|
351
|
+
throw new Error(childRun.error?.message || 'Sub-workflow failed');
|
|
352
|
+
}
|
|
353
|
+
if (childRun?.status === 'cancelled') {
|
|
354
|
+
throw new Error('Sub-workflow was cancelled');
|
|
355
|
+
}
|
|
356
|
+
result = childRun?.output;
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
throw new ChildWorkflowStartedException(runId, stepId, childRunId);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
result = await rpcService.rpcWithWire(rpcName, data, {
|
|
364
|
+
graph: graphWire,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
327
367
|
if (wireState.branchKey) {
|
|
328
368
|
await workflowService.setBranchTaken(stepId, wireState.branchKey);
|
|
329
369
|
}
|
|
330
370
|
return result;
|
|
331
371
|
}
|
|
332
372
|
catch (error) {
|
|
373
|
+
if (error instanceof ChildWorkflowStartedException) {
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
333
376
|
if (error instanceof RPCNotFoundError) {
|
|
334
377
|
await workflowService.updateRunStatus(runId, 'suspended', undefined, {
|
|
335
378
|
message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
|
|
@@ -381,9 +424,31 @@ async function executeGraphNodeInline(workflowService, rpcService, runId, graphN
|
|
|
381
424
|
getState: () => workflowService.getRunState(runId),
|
|
382
425
|
};
|
|
383
426
|
try {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
427
|
+
let result;
|
|
428
|
+
const subWorkflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName];
|
|
429
|
+
if (subWorkflowMeta) {
|
|
430
|
+
const childWire = {
|
|
431
|
+
type: 'workflow',
|
|
432
|
+
id: rpcName,
|
|
433
|
+
parentRunId: runId,
|
|
434
|
+
parentStepId: stepState.stepId,
|
|
435
|
+
};
|
|
436
|
+
const { runId: childRunId } = await workflowService.startWorkflow(rpcName, input, childWire, rpcService, { inline: true });
|
|
437
|
+
await workflowService.setStepChildRunId(stepState.stepId, childRunId);
|
|
438
|
+
const childRun = await workflowService.getRun(childRunId);
|
|
439
|
+
if (childRun?.status === 'failed') {
|
|
440
|
+
throw new Error(childRun.error?.message || 'Sub-workflow failed');
|
|
441
|
+
}
|
|
442
|
+
if (childRun?.status === 'cancelled') {
|
|
443
|
+
throw new Error('Sub-workflow was cancelled');
|
|
444
|
+
}
|
|
445
|
+
result = childRun?.output;
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
result = await rpcService.rpcWithWire(rpcName, input, {
|
|
449
|
+
graph: graphWire,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
387
452
|
if (wireState.branchKey) {
|
|
388
453
|
await workflowService.setBranchTaken(stepState.stepId, wireState.branchKey);
|
|
389
454
|
}
|
|
@@ -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)
|
|
@@ -4,6 +4,7 @@ import { getDurationInMilliseconds } from '../../time-utils.js';
|
|
|
4
4
|
import { continueGraph, executeGraphStep, runWorkflowGraph, runFromMeta, } from './graph/graph-runner.js';
|
|
5
5
|
import { PikkuError, addError } from '../../errors/error-handler.js';
|
|
6
6
|
import { RPCNotFoundError } from '../rpc/rpc-runner.js';
|
|
7
|
+
import { ChildWorkflowStartedException } from './graph/graph-runner.js';
|
|
7
8
|
/**
|
|
8
9
|
* Exception thrown when workflow needs to pause for async step
|
|
9
10
|
*/
|
|
@@ -84,6 +85,9 @@ const WORKFLOW_END_STATES = new Set([
|
|
|
84
85
|
*/
|
|
85
86
|
export class PikkuWorkflowService {
|
|
86
87
|
inlineRuns = new Set();
|
|
88
|
+
get logger() {
|
|
89
|
+
return getSingletonServices()?.logger;
|
|
90
|
+
}
|
|
87
91
|
constructor() { }
|
|
88
92
|
/**
|
|
89
93
|
* Check if a run is executing inline (without queues)
|
|
@@ -124,12 +128,12 @@ export class PikkuWorkflowService {
|
|
|
124
128
|
}
|
|
125
129
|
async queueStepWorker(runId, stepName, rpcName, data) {
|
|
126
130
|
const queueService = this.verifyQueueService();
|
|
127
|
-
await queueService.add(this.getConfig().stepWorkerQueueName, {
|
|
131
|
+
await queueService.add(this.getConfig().stepWorkerQueueName, JSON.parse(JSON.stringify({
|
|
128
132
|
runId,
|
|
129
133
|
stepName,
|
|
130
134
|
rpcName,
|
|
131
135
|
data,
|
|
132
|
-
});
|
|
136
|
+
})));
|
|
133
137
|
}
|
|
134
138
|
/**
|
|
135
139
|
* Execute a workflow sleep step completion
|
|
@@ -163,7 +167,9 @@ export class PikkuWorkflowService {
|
|
|
163
167
|
throw new WorkflowNotFoundError(name);
|
|
164
168
|
}
|
|
165
169
|
if (workflowMeta.source === 'graph' || workflowMeta.source === 'ai-agent') {
|
|
166
|
-
const shouldInline = options?.inline ||
|
|
170
|
+
const shouldInline = options?.inline ||
|
|
171
|
+
workflowMeta.inline ||
|
|
172
|
+
!getSingletonServices()?.queueService;
|
|
167
173
|
return runWorkflowGraph(this, name, input, rpcService, shouldInline, options?.startNode, wire);
|
|
168
174
|
}
|
|
169
175
|
// DSL workflow - check registration exists
|
|
@@ -175,17 +181,25 @@ export class PikkuWorkflowService {
|
|
|
175
181
|
if (!workflowMeta.graphHash) {
|
|
176
182
|
throw new Error(`Missing workflow graphHash for '${name}'`);
|
|
177
183
|
}
|
|
178
|
-
const shouldInline = options?.inline ||
|
|
184
|
+
const shouldInline = options?.inline ||
|
|
185
|
+
workflowMeta.inline ||
|
|
186
|
+
!getSingletonServices()?.queueService;
|
|
179
187
|
const runId = await this.createRun(name, input, shouldInline, workflowMeta.graphHash, wire);
|
|
180
188
|
if (shouldInline) {
|
|
181
189
|
this.inlineRuns.add(runId);
|
|
182
|
-
|
|
183
|
-
.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
.
|
|
190
|
+
try {
|
|
191
|
+
await this.runWorkflowJob(runId, rpcService);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (error.name !== 'WorkflowAsyncException' &&
|
|
195
|
+
error.name !== 'WorkflowCancelledException' &&
|
|
196
|
+
error.name !== 'WorkflowSuspendedException') {
|
|
197
|
+
getSingletonServices().logger.error(`Workflow ${name} (run ${runId}) failed:`, error);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
187
201
|
this.inlineRuns.delete(runId);
|
|
188
|
-
}
|
|
202
|
+
}
|
|
189
203
|
}
|
|
190
204
|
else {
|
|
191
205
|
await this.resumeWorkflow(runId);
|
|
@@ -227,6 +241,14 @@ export class PikkuWorkflowService {
|
|
|
227
241
|
}
|
|
228
242
|
if (workflowMeta?.source === 'graph') {
|
|
229
243
|
await continueGraph(this, runId, run.workflow);
|
|
244
|
+
const updatedRun = await this.getRun(runId);
|
|
245
|
+
if (updatedRun?.status === 'completed') {
|
|
246
|
+
await this.onChildWorkflowCompleted(updatedRun, updatedRun.output);
|
|
247
|
+
}
|
|
248
|
+
else if (updatedRun?.status === 'failed' ||
|
|
249
|
+
updatedRun?.status === 'cancelled') {
|
|
250
|
+
await this.onChildWorkflowFailed(updatedRun, new Error(updatedRun.error?.message || 'Child workflow failed'));
|
|
251
|
+
}
|
|
230
252
|
return;
|
|
231
253
|
}
|
|
232
254
|
const registrations = pikkuState(null, 'workflows', 'registrations');
|
|
@@ -249,6 +271,7 @@ export class PikkuWorkflowService {
|
|
|
249
271
|
data: () => run.input,
|
|
250
272
|
});
|
|
251
273
|
await this.updateRunStatus(runId, 'completed', result);
|
|
274
|
+
await this.onChildWorkflowCompleted(run, result);
|
|
252
275
|
}
|
|
253
276
|
catch (error) {
|
|
254
277
|
if (error instanceof WorkflowAsyncException) {
|
|
@@ -260,6 +283,7 @@ export class PikkuWorkflowService {
|
|
|
260
283
|
stack: '',
|
|
261
284
|
code: 'WORKFLOW_CANCELLED',
|
|
262
285
|
});
|
|
286
|
+
await this.onChildWorkflowFailed(run, error);
|
|
263
287
|
throw error;
|
|
264
288
|
}
|
|
265
289
|
if (error instanceof WorkflowSuspendedException) {
|
|
@@ -275,10 +299,27 @@ export class PikkuWorkflowService {
|
|
|
275
299
|
stack: error.stack,
|
|
276
300
|
code: error.code,
|
|
277
301
|
});
|
|
302
|
+
await this.onChildWorkflowFailed(run, error);
|
|
278
303
|
throw error;
|
|
279
304
|
}
|
|
280
305
|
});
|
|
281
306
|
}
|
|
307
|
+
async onChildWorkflowCompleted(childRun, result) {
|
|
308
|
+
const { parentRunId, parentStepId } = childRun.wire ?? {};
|
|
309
|
+
if (!parentRunId || !parentStepId)
|
|
310
|
+
return;
|
|
311
|
+
this.logger?.debug(`Child workflow ${childRun.id} completed, updating parent step ${parentStepId}`);
|
|
312
|
+
await this.setStepResult(parentStepId, result);
|
|
313
|
+
await this.resumeWorkflow(parentRunId);
|
|
314
|
+
}
|
|
315
|
+
async onChildWorkflowFailed(childRun, error) {
|
|
316
|
+
const { parentRunId, parentStepId } = childRun.wire ?? {};
|
|
317
|
+
if (!parentRunId || !parentStepId)
|
|
318
|
+
return;
|
|
319
|
+
this.logger?.debug(`Child workflow ${childRun.id} failed, updating parent step ${parentStepId}`);
|
|
320
|
+
await this.setStepError(parentStepId, error);
|
|
321
|
+
await this.resumeWorkflow(parentRunId);
|
|
322
|
+
}
|
|
282
323
|
async runVersionMismatchFallback(run, currentMeta, rpcService) {
|
|
283
324
|
const source = currentMeta.source;
|
|
284
325
|
if (source === 'complex') {
|
|
@@ -309,9 +350,8 @@ export class PikkuWorkflowService {
|
|
|
309
350
|
await this.withStepLock(runId, stepName, async () => {
|
|
310
351
|
// Get step state
|
|
311
352
|
let stepState = await this.getStepState(runId, stepName);
|
|
312
|
-
// Idempotency - if already succeeded,
|
|
353
|
+
// Idempotency - if already succeeded, nothing to do
|
|
313
354
|
if (stepState.status === 'succeeded') {
|
|
314
|
-
await this.resumeWorkflow(runId);
|
|
315
355
|
return;
|
|
316
356
|
}
|
|
317
357
|
// Log warning if already running (race condition)
|
|
@@ -322,8 +362,7 @@ export class PikkuWorkflowService {
|
|
|
322
362
|
if (stepState.status === 'failed') {
|
|
323
363
|
stepState = await this.createRetryAttempt(stepState.stepId, 'running');
|
|
324
364
|
}
|
|
325
|
-
if (stepState.status === 'pending') {
|
|
326
|
-
// Mark pending step as running before execution
|
|
365
|
+
if (stepState.status === 'pending' || stepState.status === 'scheduled') {
|
|
327
366
|
await this.setStepRunning(stepState.stepId);
|
|
328
367
|
}
|
|
329
368
|
try {
|
|
@@ -352,6 +391,10 @@ export class PikkuWorkflowService {
|
|
|
352
391
|
await this.resumeWorkflow(runId);
|
|
353
392
|
}
|
|
354
393
|
catch (error) {
|
|
394
|
+
if (error instanceof ChildWorkflowStartedException) {
|
|
395
|
+
this.logger?.debug(`Workflow step '${stepName}': child workflow ${error.childRunId} started, waiting for completion`);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
355
398
|
if (error instanceof RPCNotFoundError) {
|
|
356
399
|
await this.setStepError(stepState.stepId, error);
|
|
357
400
|
await this.updateRunStatus(runId, 'suspended', undefined, {
|
|
@@ -437,12 +480,12 @@ export class PikkuWorkflowService {
|
|
|
437
480
|
// Map step retry options to queue job options
|
|
438
481
|
const retries = stepOptions?.retries ?? 0;
|
|
439
482
|
const retryDelay = stepOptions?.retryDelay;
|
|
440
|
-
await getSingletonServices().queueService.add(this.getConfig().stepWorkerQueueName, {
|
|
483
|
+
await getSingletonServices().queueService.add(this.getConfig().stepWorkerQueueName, JSON.parse(JSON.stringify({
|
|
441
484
|
runId,
|
|
442
485
|
stepName,
|
|
443
486
|
rpcName,
|
|
444
487
|
data,
|
|
445
|
-
}, {
|
|
488
|
+
})), {
|
|
446
489
|
// attempts includes initial attempt, retries doesn't
|
|
447
490
|
attempts: retries + 1,
|
|
448
491
|
// Map retry delay to backoff
|
|
@@ -7,6 +7,7 @@ export interface WorkflowRunWire {
|
|
|
7
7
|
type: string;
|
|
8
8
|
id?: string;
|
|
9
9
|
parentRunId?: string;
|
|
10
|
+
parentStepId?: string;
|
|
10
11
|
}
|
|
11
12
|
export interface WorkflowServiceConfig {
|
|
12
13
|
retries: number;
|
|
@@ -166,6 +167,7 @@ export type WorkflowsMeta = Record<string, CommonWireMeta & {
|
|
|
166
167
|
steps: WorkflowStepMeta[];
|
|
167
168
|
context?: WorkflowContext;
|
|
168
169
|
dsl?: boolean;
|
|
170
|
+
inline?: boolean;
|
|
169
171
|
}>;
|
|
170
172
|
/**
|
|
171
173
|
* Unified workflow runtime meta (used by runtime to execute workflows)
|
|
@@ -183,6 +185,8 @@ export interface WorkflowRuntimeMeta {
|
|
|
183
185
|
description?: string;
|
|
184
186
|
/** Tags for organization */
|
|
185
187
|
tags?: string[];
|
|
188
|
+
/** If true, workflow always executes inline without queues */
|
|
189
|
+
inline?: boolean;
|
|
186
190
|
/** Serialized nodes */
|
|
187
191
|
nodes?: Record<string, any>;
|
|
188
192
|
/** Entry node IDs for graph workflows (computed at build time) */
|
package/package.json
CHANGED
package/src/env.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const isProduction = () => process.env.NODE_ENV === 'production'
|
package/src/errors/errors.ts
CHANGED
|
@@ -372,3 +372,38 @@ addError(MissingSchemaError, {
|
|
|
372
372
|
message:
|
|
373
373
|
'A required schema was not found. Ensure schema generation has been run.',
|
|
374
374
|
})
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* An AI provider has not been configured. Set up an AI provider (e.g. OpenAI, Anthropic) to use agent features.
|
|
378
|
+
* @group Error
|
|
379
|
+
*/
|
|
380
|
+
export class AIProviderNotConfiguredError extends PikkuError {
|
|
381
|
+
constructor() {
|
|
382
|
+
super(
|
|
383
|
+
'No AI provider configured. Please set up an AI provider (e.g. OpenAI, Anthropic) and provide a valid API key to use this agent.'
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
addError(AIProviderNotConfiguredError, {
|
|
388
|
+
status: 503,
|
|
389
|
+
message:
|
|
390
|
+
'No AI provider configured. Please set up an AI provider (e.g. OpenAI, Anthropic) and provide a valid API key to use this agent.',
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The AI provider API key is missing or invalid.
|
|
395
|
+
* @group Error
|
|
396
|
+
*/
|
|
397
|
+
export class AIProviderAuthError extends PikkuError {
|
|
398
|
+
constructor(message?: string) {
|
|
399
|
+
super(
|
|
400
|
+
message ||
|
|
401
|
+
'AI provider API key is missing or invalid. Please check your API key configuration.'
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
addError(AIProviderAuthError, {
|
|
406
|
+
status: 401,
|
|
407
|
+
message:
|
|
408
|
+
'AI provider API key is missing or invalid. Please check your API key configuration.',
|
|
409
|
+
})
|
package/src/handle-error.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isProduction } from './env.js'
|
|
1
2
|
import { getErrorResponse } from './errors/error-handler.js'
|
|
2
3
|
import { NotFoundError } from './errors/errors.js'
|
|
3
4
|
import type { Logger } from './services/logger.js'
|
|
@@ -55,7 +56,7 @@ export const handleHTTPError = (
|
|
|
55
56
|
if (trackerId) {
|
|
56
57
|
logger.warn(`Error id: ${trackerId}`)
|
|
57
58
|
const errorBody: Record<string, unknown> = { errorId: trackerId }
|
|
58
|
-
if (exposeErrors && e instanceof Error) {
|
|
59
|
+
if (exposeErrors && !isProduction() && e instanceof Error) {
|
|
59
60
|
errorBody.message = e.message
|
|
60
61
|
errorBody.stack = e.stack
|
|
61
62
|
}
|
|
@@ -2,6 +2,15 @@ import { InvalidSessionError } from '../errors/errors.js'
|
|
|
2
2
|
import type { CoreUserSession } from '../types/core.types.js'
|
|
3
3
|
import { pikkuMiddleware, pikkuMiddlewareFactory } from '../types/core.types.js'
|
|
4
4
|
|
|
5
|
+
const constantTimeEqual = (a: string, b: string): boolean => {
|
|
6
|
+
if (a.length !== b.length) return false
|
|
7
|
+
let result = 0
|
|
8
|
+
for (let i = 0; i < a.length; i++) {
|
|
9
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
|
10
|
+
}
|
|
11
|
+
return result === 0
|
|
12
|
+
}
|
|
13
|
+
|
|
5
14
|
/**
|
|
6
15
|
* Bearer token middleware that extracts and validates tokens from the Authorization header.
|
|
7
16
|
*
|
|
@@ -55,7 +64,7 @@ export const authBearer = pikkuMiddlewareFactory<{
|
|
|
55
64
|
|
|
56
65
|
let userSession: CoreUserSession | null = null
|
|
57
66
|
|
|
58
|
-
if (token && bearerToken
|
|
67
|
+
if (token && constantTimeEqual(bearerToken, token.value)) {
|
|
59
68
|
userSession = token.userSession
|
|
60
69
|
} else if (jwtService && !token) {
|
|
61
70
|
userSession = await jwtService.decode(bearerToken)
|
|
@@ -35,8 +35,15 @@ export const authCookie = pikkuMiddlewareFactory<{
|
|
|
35
35
|
name: string
|
|
36
36
|
options: SerializeOptions
|
|
37
37
|
expiresIn: RelativeTimeInput
|
|
38
|
-
}>(({ name, options, expiresIn }) =>
|
|
39
|
-
|
|
38
|
+
}>(({ name, options, expiresIn }) => {
|
|
39
|
+
const mergedOptions: SerializeOptions = {
|
|
40
|
+
httpOnly: true,
|
|
41
|
+
secure: true,
|
|
42
|
+
sameSite: 'lax',
|
|
43
|
+
path: '/',
|
|
44
|
+
...options,
|
|
45
|
+
}
|
|
46
|
+
return pikkuMiddleware(
|
|
40
47
|
async (
|
|
41
48
|
{ jwt: jwtService, logger },
|
|
42
49
|
{ http, setSession, getSession, session, hasSessionChanged },
|
|
@@ -67,7 +74,7 @@ export const authCookie = pikkuMiddlewareFactory<{
|
|
|
67
74
|
name,
|
|
68
75
|
await jwtService.encode(expiresIn, currentSession),
|
|
69
76
|
{
|
|
70
|
-
...
|
|
77
|
+
...mergedOptions,
|
|
71
78
|
expires: getRelativeTimeOffsetFromNow(expiresIn),
|
|
72
79
|
}
|
|
73
80
|
)
|
|
@@ -77,4 +84,4 @@ export const authCookie = pikkuMiddlewareFactory<{
|
|
|
77
84
|
}
|
|
78
85
|
}
|
|
79
86
|
)
|
|
80
|
-
)
|
|
87
|
+
})
|
|
@@ -4,12 +4,19 @@ import { pikkuMiddleware, pikkuMiddlewareFactory } from '../types/core.types.js'
|
|
|
4
4
|
export const timeout = () =>
|
|
5
5
|
pikkuMiddlewareFactory<{ timeout: number }>(({ timeout }) =>
|
|
6
6
|
pikkuMiddleware(async (_services, _wire, next) => {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
|
8
|
+
await Promise.race([
|
|
9
|
+
next(),
|
|
10
|
+
new Promise<never>((_resolve, reject) => {
|
|
11
|
+
timeoutId = setTimeout(
|
|
12
|
+
() => reject(new RequestTimeoutError()),
|
|
13
|
+
timeout
|
|
14
|
+
)
|
|
15
|
+
}),
|
|
16
|
+
]).finally(() => {
|
|
17
|
+
if (timeoutId !== undefined) {
|
|
18
|
+
clearTimeout(timeoutId)
|
|
19
|
+
}
|
|
20
|
+
})
|
|
14
21
|
})
|
|
15
22
|
)
|
package/src/pikku-state.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { GatewaysMeta } from './wirings/gateway/gateway.types.js'
|
|
|
14
14
|
import type { ScheduledTasksMeta } from './wirings/scheduler/scheduler.types.js'
|
|
15
15
|
import type { TriggerMeta } from './wirings/trigger/trigger.types.js'
|
|
16
16
|
|
|
17
|
-
const PIKKU_STATE_KEY = Symbol
|
|
17
|
+
const PIKKU_STATE_KEY = Symbol('@pikku/core/state')
|
|
18
18
|
|
|
19
19
|
export const getAllPackageStates = (): Map<string, PikkuPackageState> => {
|
|
20
20
|
if (!(globalThis as any)[PIKKU_STATE_KEY]) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createReadStream, createWriteStream, promises } from 'fs'
|
|
2
2
|
import { mkdir, readFile } from 'fs/promises'
|
|
3
|
-
import
|
|
3
|
+
import { resolve, normalize } from 'path'
|
|
4
|
+
import type { ContentService, JWTService, Logger } from '@pikku/core/services'
|
|
4
5
|
import { pipeline } from 'stream/promises'
|
|
5
6
|
import type { Readable } from 'stream'
|
|
6
7
|
|
|
@@ -15,19 +16,66 @@ export interface LocalContentConfig {
|
|
|
15
16
|
export class LocalContent implements ContentService {
|
|
16
17
|
constructor(
|
|
17
18
|
private config: LocalContentConfig,
|
|
18
|
-
private logger: Logger
|
|
19
|
+
private logger: Logger,
|
|
20
|
+
private jwt?: JWTService
|
|
19
21
|
) {}
|
|
20
22
|
|
|
23
|
+
private safePath(assetKey: string): string {
|
|
24
|
+
const base = resolve(this.config.localFileUploadPath)
|
|
25
|
+
const target = resolve(base, normalize(assetKey))
|
|
26
|
+
if (!target.startsWith(base + '/') && target !== base) {
|
|
27
|
+
throw new Error('Invalid asset key')
|
|
28
|
+
}
|
|
29
|
+
return target
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
public async init() {}
|
|
22
33
|
|
|
23
|
-
|
|
24
|
-
|
|
34
|
+
private async signParams(
|
|
35
|
+
dateLessThan: Date,
|
|
36
|
+
dateGreaterThan?: Date
|
|
37
|
+
): Promise<string> {
|
|
38
|
+
const signedAt = Date.now()
|
|
39
|
+
const expiresAt = dateLessThan.getTime()
|
|
40
|
+
const params = new URLSearchParams({
|
|
41
|
+
signedAt: String(signedAt),
|
|
42
|
+
expiresAt: String(expiresAt),
|
|
43
|
+
})
|
|
44
|
+
if (dateGreaterThan) {
|
|
45
|
+
params.set('notBefore', String(dateGreaterThan.getTime()))
|
|
46
|
+
}
|
|
47
|
+
if (this.jwt) {
|
|
48
|
+
const expiresInSeconds = Math.max(
|
|
49
|
+
1,
|
|
50
|
+
Math.floor((expiresAt - signedAt) / 1000)
|
|
51
|
+
)
|
|
52
|
+
const signature = await this.jwt.encode(
|
|
53
|
+
{ value: expiresInSeconds, unit: 'second' },
|
|
54
|
+
{ signedAt, expiresAt }
|
|
55
|
+
)
|
|
56
|
+
params.set('signature', signature)
|
|
57
|
+
}
|
|
58
|
+
return params.toString()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public async signURL(
|
|
62
|
+
url: string,
|
|
63
|
+
dateLessThan: Date,
|
|
64
|
+
dateGreaterThan?: Date
|
|
65
|
+
): Promise<string> {
|
|
66
|
+
const params = await this.signParams(dateLessThan, dateGreaterThan)
|
|
67
|
+
return `${url}?${params}`
|
|
25
68
|
}
|
|
26
69
|
|
|
27
|
-
public async signContentKey(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
70
|
+
public async signContentKey(
|
|
71
|
+
assetKey: string,
|
|
72
|
+
dateLessThan: Date,
|
|
73
|
+
dateGreaterThan?: Date
|
|
74
|
+
): Promise<string> {
|
|
75
|
+
const base = this.config.server
|
|
76
|
+
? `${this.config.server}${this.config.assetUrlPrefix}/${assetKey}`
|
|
77
|
+
: `${this.config.assetUrlPrefix}/${assetKey}`
|
|
78
|
+
return this.signURL(base, dateLessThan, dateGreaterThan)
|
|
31
79
|
}
|
|
32
80
|
|
|
33
81
|
public async getUploadURL(assetKey: string) {
|
|
@@ -44,7 +92,7 @@ export class LocalContent implements ContentService {
|
|
|
44
92
|
): Promise<boolean> {
|
|
45
93
|
this.logger.debug(`Writing file: ${assetKey}`)
|
|
46
94
|
|
|
47
|
-
const path =
|
|
95
|
+
const path = this.safePath(assetKey)
|
|
48
96
|
|
|
49
97
|
try {
|
|
50
98
|
await this.createDirectoryForFile(path)
|
|
@@ -65,7 +113,7 @@ export class LocalContent implements ContentService {
|
|
|
65
113
|
): Promise<boolean> {
|
|
66
114
|
this.logger.debug(`Writing file: ${assetKey}`)
|
|
67
115
|
try {
|
|
68
|
-
const path =
|
|
116
|
+
const path = this.safePath(assetKey)
|
|
69
117
|
await this.createDirectoryForFile(path)
|
|
70
118
|
await promises.copyFile(fromAbsolutePath, path)
|
|
71
119
|
} catch (e) {
|
|
@@ -80,7 +128,7 @@ export class LocalContent implements ContentService {
|
|
|
80
128
|
): Promise<ReadableStream | NodeJS.ReadableStream> {
|
|
81
129
|
this.logger.debug(`Getting key: ${assetKey}`)
|
|
82
130
|
|
|
83
|
-
const filePath =
|
|
131
|
+
const filePath = this.safePath(assetKey)
|
|
84
132
|
|
|
85
133
|
try {
|
|
86
134
|
const stream = createReadStream(filePath)
|
|
@@ -97,7 +145,7 @@ export class LocalContent implements ContentService {
|
|
|
97
145
|
}
|
|
98
146
|
|
|
99
147
|
public async readFileAsBuffer(assetKey: string): Promise<Buffer> {
|
|
100
|
-
const filePath =
|
|
148
|
+
const filePath = this.safePath(assetKey)
|
|
101
149
|
this.logger.debug(`Reading file as buffer: ${assetKey}`)
|
|
102
150
|
return readFile(filePath)
|
|
103
151
|
}
|
|
@@ -105,7 +153,7 @@ export class LocalContent implements ContentService {
|
|
|
105
153
|
public async deleteFile(assetKey: string): Promise<boolean> {
|
|
106
154
|
this.logger.debug(`deleting key: ${assetKey}`)
|
|
107
155
|
try {
|
|
108
|
-
await promises.unlink(
|
|
156
|
+
await promises.unlink(this.safePath(assetKey))
|
|
109
157
|
return true
|
|
110
158
|
} catch (e: any) {
|
|
111
159
|
this.logger.error(`Error deleting content ${assetKey}`, e)
|
|
@@ -22,7 +22,7 @@ describe('LocalSecretService', () => {
|
|
|
22
22
|
const vars = new LocalVariablesService({})
|
|
23
23
|
const service = new LocalSecretService(vars)
|
|
24
24
|
await assert.rejects(() => service.getSecret('MISSING'), {
|
|
25
|
-
message: '
|
|
25
|
+
message: 'Requested secret not found',
|
|
26
26
|
})
|
|
27
27
|
})
|
|
28
28
|
|
|
@@ -44,7 +44,7 @@ describe('LocalSecretService', () => {
|
|
|
44
44
|
const vars = new LocalVariablesService({})
|
|
45
45
|
const service = new LocalSecretService(vars)
|
|
46
46
|
await assert.rejects(() => service.getSecretJSON('MISSING'), {
|
|
47
|
-
message: '
|
|
47
|
+
message: 'Requested secret not found',
|
|
48
48
|
})
|
|
49
49
|
})
|
|
50
50
|
|
|
@@ -35,7 +35,7 @@ export class LocalSecretService implements SecretService {
|
|
|
35
35
|
if (value) {
|
|
36
36
|
return JSON.parse(value)
|
|
37
37
|
}
|
|
38
|
-
throw new Error(
|
|
38
|
+
throw new Error('Requested secret not found')
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
@@ -57,7 +57,7 @@ export class LocalSecretService implements SecretService {
|
|
|
57
57
|
if (value) {
|
|
58
58
|
return value
|
|
59
59
|
}
|
|
60
|
-
throw new Error(
|
|
60
|
+
throw new Error('Requested secret not found')
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/**
|
|
@@ -780,7 +780,7 @@ export function defineServiceTests(config: ServiceTestConfig): void {
|
|
|
780
780
|
test('getSecret throws for missing key', async () => {
|
|
781
781
|
const service = await factory({ key: kek })
|
|
782
782
|
await assert.rejects(() => service.getSecret('nonexistent'), {
|
|
783
|
-
message: '
|
|
783
|
+
message: 'Requested secret not found',
|
|
784
784
|
})
|
|
785
785
|
})
|
|
786
786
|
|