@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +30 -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/handle-error.js +2 -1
  7. package/dist/middleware/auth-bearer.js +10 -1
  8. package/dist/middleware/auth-cookie.js +34 -25
  9. package/dist/middleware/timeout.js +11 -5
  10. package/dist/pikku-state.js +1 -1
  11. package/dist/services/local-content.d.ts +7 -4
  12. package/dist/services/local-content.js +42 -12
  13. package/dist/services/local-secrets.js +2 -2
  14. package/dist/testing/service-tests.js +1 -1
  15. package/dist/wirings/ai-agent/ai-agent-prepare.js +2 -1
  16. package/dist/wirings/ai-agent/ai-agent-runner.js +2 -1
  17. package/dist/wirings/ai-agent/ai-agent-stream.js +2 -1
  18. package/dist/wirings/channel/local/local-channel-runner.js +11 -6
  19. package/dist/wirings/http/http-runner.js +4 -2
  20. package/dist/wirings/http/pikku-fetch-http-request.js +11 -1
  21. package/dist/wirings/http/web-request.js +3 -1
  22. package/dist/wirings/workflow/graph/graph-runner.d.ts +7 -0
  23. package/dist/wirings/workflow/graph/graph-runner.js +72 -7
  24. package/dist/wirings/workflow/pikku-workflow-service.d.ts +3 -0
  25. package/dist/wirings/workflow/pikku-workflow-service.js +59 -16
  26. package/dist/wirings/workflow/workflow.types.d.ts +4 -0
  27. package/package.json +1 -1
  28. package/src/env.ts +1 -0
  29. package/src/errors/errors.ts +35 -0
  30. package/src/handle-error.ts +2 -1
  31. package/src/middleware/auth-bearer.ts +10 -1
  32. package/src/middleware/auth-cookie.ts +11 -4
  33. package/src/middleware/timeout.ts +14 -7
  34. package/src/pikku-state.ts +1 -1
  35. package/src/services/local-content.ts +61 -13
  36. package/src/services/local-secrets.test.ts +2 -2
  37. package/src/services/local-secrets.ts +2 -2
  38. package/src/testing/service-tests.ts +1 -1
  39. package/src/wirings/ai-agent/ai-agent-prepare.ts +2 -1
  40. package/src/wirings/ai-agent/ai-agent-runner.test.ts +34 -0
  41. package/src/wirings/ai-agent/ai-agent-runner.ts +2 -1
  42. package/src/wirings/ai-agent/ai-agent-stream.ts +2 -1
  43. package/src/wirings/channel/local/local-channel-runner.ts +11 -6
  44. package/src/wirings/http/http-runner.ts +4 -2
  45. package/src/wirings/http/pikku-fetch-http-request.ts +11 -1
  46. package/src/wirings/http/web-request.ts +3 -1
  47. package/src/wirings/workflow/graph/graph-runner.test.ts +42 -0
  48. package/src/wirings/workflow/graph/graph-runner.ts +84 -7
  49. package/src/wirings/workflow/pikku-workflow-service.test.ts +109 -0
  50. package/src/wirings/workflow/pikku-workflow-service.ts +95 -25
  51. package/src/wirings/workflow/workflow.types.ts +4 -0
  52. 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
- const result = await rpcService.rpcWithWire(rpcName, data, {
325
- graph: graphWire,
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
- const result = await rpcService.rpcWithWire(rpcName, input, {
385
- graph: graphWire,
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 || !getSingletonServices()?.queueService;
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 || !getSingletonServices()?.queueService;
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
- this.runWorkflowJob(runId, rpcService)
183
- .catch((err) => {
184
- getSingletonServices().logger.error(`Workflow ${name} (run ${runId}) failed:`, err);
185
- })
186
- .finally(() => {
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, resume orchestrator and return
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.7",
3
+ "version": "0.12.9",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
package/src/env.ts ADDED
@@ -0,0 +1 @@
1
+ export const isProduction = () => process.env.NODE_ENV === 'production'
@@ -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
+ })
@@ -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 === token.value) {
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
- pikkuMiddleware(
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
- ...options,
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
- const timeoutId = setTimeout(() => {
8
- throw new RequestTimeoutError()
9
- }, timeout)
10
-
11
- await next()
12
-
13
- clearTimeout(timeoutId)
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
  )
@@ -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.for('@pikku/core/state')
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 type { ContentService, Logger } from '@pikku/core/services'
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
- public async signURL(url: string): Promise<string> {
24
- return `${url}?signed=true`
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(assetKey: string): Promise<string> {
28
- return this.config.server
29
- ? `${this.config.server}${this.config.assetUrlPrefix}/${assetKey}?signed=true`
30
- : `${this.config.assetUrlPrefix}/${assetKey}?signed=true`
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 = `${this.config.localFileUploadPath}/${assetKey}`
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 = `${this.config.localFileUploadPath}/${assetKey}`
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 = `${this.config.localFileUploadPath}/${assetKey}`
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 = `${this.config.localFileUploadPath}/${assetKey}`
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(`${this.config.localFileUploadPath}/${assetKey}`)
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: 'Secret Not Found: MISSING',
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: 'Secret Not Found: MISSING',
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(`Secret Not Found: ${key}`)
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(`Secret Not Found: ${key}`)
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: 'Secret not found: nonexistent',
783
+ message: 'Requested secret not found',
784
784
  })
785
785
  })
786
786