@pikku/core 0.12.148 → 0.12.151

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 (139) hide show
  1. package/dist/function/function-runner.js +23 -0
  2. package/dist/function/trace-queue-service.d.ts +26 -0
  3. package/dist/function/trace-queue-service.js +48 -0
  4. package/dist/mesh.d.ts +2 -2
  5. package/dist/mesh.js +3 -3
  6. package/dist/middleware/index.d.ts +1 -1
  7. package/dist/middleware/index.js +1 -1
  8. package/dist/middleware/mesh-auth.d.ts +3 -2
  9. package/dist/middleware/mesh-auth.js +7 -2
  10. package/dist/middleware/remote-jobs-secret.d.ts +1 -1
  11. package/dist/middleware/require-origin.d.ts +19 -0
  12. package/dist/middleware/require-origin.js +36 -2
  13. package/dist/services/deployment-service.d.ts +1 -1
  14. package/dist/services/incoming-webhook-service.d.ts +4 -2
  15. package/dist/services/incoming-webhook-service.js +4 -3
  16. package/dist/services/queue-webhook-service.js +7 -1
  17. package/dist/services/remote-jobs.d.ts +6 -1
  18. package/dist/services/scheduler-service.d.ts +3 -1
  19. package/dist/services/webhook-service.d.ts +2 -0
  20. package/dist/services/workflow-service.d.ts +4 -1
  21. package/dist/testing/index.d.ts +4 -0
  22. package/dist/testing/index.js +2 -0
  23. package/dist/testing/queue-identity-transport-tests.d.ts +18 -0
  24. package/dist/testing/queue-identity-transport-tests.js +162 -0
  25. package/dist/testing/queue-trace-conformance.d.ts +26 -0
  26. package/dist/testing/queue-trace-conformance.js +259 -0
  27. package/dist/testing/service-tests/channel-store-tests.js +11 -0
  28. package/dist/testing/service-tests/queued-workflow-harness.d.ts +18 -1
  29. package/dist/testing/service-tests/queued-workflow-harness.js +19 -3
  30. package/dist/testing/service-tests/workflow-service-tests.js +12 -0
  31. package/dist/testing/trace-matrix/assertions.d.ts +3 -0
  32. package/dist/testing/trace-matrix/assertions.js +190 -0
  33. package/dist/testing/trace-matrix/chain-harness.d.ts +119 -0
  34. package/dist/testing/trace-matrix/chain-harness.js +777 -0
  35. package/dist/testing/trace-matrix/dimensions.d.ts +55 -0
  36. package/dist/testing/trace-matrix/dimensions.js +305 -0
  37. package/dist/testing/trace-matrix/pairwise.d.ts +20 -0
  38. package/dist/testing/trace-matrix/pairwise.js +75 -0
  39. package/dist/testing/trace-matrix/report.d.ts +1 -0
  40. package/dist/testing/trace-matrix/report.js +16 -0
  41. package/dist/testing/trace-matrix/transports.d.ts +19 -0
  42. package/dist/testing/trace-matrix/transports.js +27 -0
  43. package/dist/testing/trace-matrix/workflow-cases.d.ts +24 -0
  44. package/dist/testing/trace-matrix/workflow-cases.js +147 -0
  45. package/dist/trace.d.ts +66 -0
  46. package/dist/trace.js +97 -0
  47. package/dist/types/core.types.d.ts +6 -2
  48. package/dist/wirings/addon/addon-reach.d.ts +3 -0
  49. package/dist/wirings/addon/addon-reach.js +9 -0
  50. package/dist/wirings/addon/define-plugin.d.ts +16 -0
  51. package/dist/wirings/addon/wire-addon.js +5 -0
  52. package/dist/wirings/addon/wire-remote-addon.d.ts +11 -0
  53. package/dist/wirings/agent/agent-finalize.d.ts +2 -0
  54. package/dist/wirings/agent/agent-finalize.js +1 -1
  55. package/dist/wirings/agent/agent-prepare.d.ts +12 -2
  56. package/dist/wirings/agent/agent-prepare.js +38 -14
  57. package/dist/wirings/agent/agent-rpc.js +2 -1
  58. package/dist/wirings/agent/agent-runner.js +5 -6
  59. package/dist/wirings/agent/agent-stream.js +6 -6
  60. package/dist/wirings/agent/voice-input.d.ts +1 -1
  61. package/dist/wirings/agent/voice-output.d.ts +1 -1
  62. package/dist/wirings/agent-scorer/agent-scorer-live.d.ts +4 -2
  63. package/dist/wirings/agent-scorer/agent-scorer-live.js +2 -2
  64. package/dist/wirings/channel/channel-common.d.ts +4 -1
  65. package/dist/wirings/channel/channel-common.js +3 -1
  66. package/dist/wirings/channel/channel-handler.js +2 -0
  67. package/dist/wirings/channel/channel-host-rpc.d.ts +1 -1
  68. package/dist/wirings/channel/channel-host-rpc.js +2 -2
  69. package/dist/wirings/channel/channel-rpc-responder.d.ts +7 -1
  70. package/dist/wirings/channel/channel-rpc-responder.js +2 -2
  71. package/dist/wirings/channel/channel-rpc-service.d.ts +1 -1
  72. package/dist/wirings/channel/channel-rpc-service.js +2 -2
  73. package/dist/wirings/channel/channel-rpc.types.d.ts +5 -2
  74. package/dist/wirings/channel/channel-rpc.types.js +1 -1
  75. package/dist/wirings/channel/channel-store.d.ts +4 -2
  76. package/dist/wirings/channel/channel-store.js +2 -2
  77. package/dist/wirings/channel/channel.types.d.ts +3 -0
  78. package/dist/wirings/channel/local/local-channel-runner.js +16 -5
  79. package/dist/wirings/channel/pikku-abstract-channel-handler.d.ts +2 -0
  80. package/dist/wirings/channel/pikku-abstract-channel-handler.js +3 -1
  81. package/dist/wirings/channel/serverless/serverless-channel-runner.js +27 -4
  82. package/dist/wirings/gateway/gateway-runner.js +3 -1
  83. package/dist/wirings/http/http-runner.d.ts +4 -4
  84. package/dist/wirings/http/http-runner.js +14 -18
  85. package/dist/wirings/http/http.types.d.ts +3 -3
  86. package/dist/wirings/mcp/mcp-runner.js +15 -5
  87. package/dist/wirings/queue/in-memory-queue-service.d.ts +1 -1
  88. package/dist/wirings/queue/in-memory-queue-service.js +8 -2
  89. package/dist/wirings/queue/index.d.ts +1 -0
  90. package/dist/wirings/queue/index.js +1 -0
  91. package/dist/wirings/queue/queue-runner.d.ts +3 -4
  92. package/dist/wirings/queue/queue-runner.js +8 -7
  93. package/dist/wirings/queue/queue-trace.d.ts +18 -0
  94. package/dist/wirings/queue/queue-trace.js +42 -0
  95. package/dist/wirings/queue/queue.types.d.ts +7 -0
  96. package/dist/wirings/queue/remote-queue-job.d.ts +1 -1
  97. package/dist/wirings/queue/remote-queue-job.js +5 -3
  98. package/dist/wirings/rpc/rpc-runner.d.ts +7 -1
  99. package/dist/wirings/rpc/rpc-runner.js +43 -21
  100. package/dist/wirings/rpc/rpc-types.d.ts +2 -0
  101. package/dist/wirings/scheduler/remote-scheduled-job.d.ts +1 -1
  102. package/dist/wirings/scheduler/remote-scheduled-job.js +2 -2
  103. package/dist/wirings/scheduler/scheduler-runner.d.ts +4 -4
  104. package/dist/wirings/scheduler/scheduler-runner.js +7 -6
  105. package/dist/wirings/trigger/pikku-trigger-service.js +7 -4
  106. package/dist/wirings/trigger/webhook-source-runner.d.ts +7 -1
  107. package/dist/wirings/trigger/webhook-source-runner.js +11 -3
  108. package/dist/wirings/workflow/graph/graph-runner.d.ts +1 -1
  109. package/dist/wirings/workflow/graph/graph-runner.js +49 -30
  110. package/dist/wirings/workflow/index.d.ts +3 -1
  111. package/dist/wirings/workflow/index.js +1 -0
  112. package/dist/wirings/workflow/pikku-scenario-service.js +2 -0
  113. package/dist/wirings/workflow/pikku-workflow-service.d.ts +37 -40
  114. package/dist/wirings/workflow/pikku-workflow-service.js +122 -133
  115. package/dist/wirings/workflow/workflow-child-step.d.ts +2 -2
  116. package/dist/wirings/workflow/workflow-child-step.js +11 -4
  117. package/dist/wirings/workflow/workflow-compensation.d.ts +6 -0
  118. package/dist/wirings/workflow/workflow-compensation.js +15 -1
  119. package/dist/wirings/workflow/workflow-constants.d.ts +13 -0
  120. package/dist/wirings/workflow/workflow-constants.js +13 -0
  121. package/dist/wirings/workflow/workflow-dsl-pass.d.ts +2 -0
  122. package/dist/wirings/workflow/workflow-dsl-pass.js +27 -11
  123. package/dist/wirings/workflow/workflow-recovery.d.ts +131 -3
  124. package/dist/wirings/workflow/workflow-recovery.js +233 -7
  125. package/dist/wirings/workflow/workflow-run-engine.types.d.ts +1 -0
  126. package/dist/wirings/workflow/workflow-run-mirror.d.ts +4 -0
  127. package/dist/wirings/workflow/workflow-run-mirror.js +16 -0
  128. package/dist/wirings/workflow/workflow-run-status.d.ts +2 -0
  129. package/dist/wirings/workflow/workflow-run-status.js +13 -0
  130. package/dist/wirings/workflow/workflow-run-trace.d.ts +22 -0
  131. package/dist/wirings/workflow/workflow-run-trace.js +24 -0
  132. package/dist/wirings/workflow/workflow-step-claim.d.ts +18 -0
  133. package/dist/wirings/workflow/workflow-step-claim.js +47 -0
  134. package/dist/wirings/workflow/workflow-step-retry.d.ts +6 -2
  135. package/dist/wirings/workflow/workflow-step-retry.js +8 -3
  136. package/dist/wirings/workflow/workflow-wire.d.ts +12 -0
  137. package/dist/wirings/workflow/workflow-wire.js +33 -0
  138. package/dist/wirings/workflow/workflow.types.d.ts +16 -0
  139. package/package.json +2 -1
@@ -4,6 +4,8 @@ import { runMiddleware, combineMiddleware } from '../middleware-runner.js';
4
4
  import { combineChannelMiddleware, wrapChannelWithMiddleware, } from '../channel-middleware-runner.js';
5
5
  import { runPermissions } from '../permissions.js';
6
6
  import { withoutSecrets } from '../services/secretless.js';
7
+ import { traceQueueService, traceSchedulerService, traceWebhookService, } from './trace-queue-service.js';
8
+ import { createTrace } from '../trace.js';
7
9
  import { getCreateWireServices, pikkuState } from '../pikku-state.js';
8
10
  import { applyDefaultsFromSchema, coerceTopLevelDataFromSchema, validateSchema, } from '../schema.js';
9
11
  import { parseVersionedId } from '../version.js';
@@ -228,6 +230,12 @@ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServic
228
230
  }
229
231
  const resolvedAuditConfig = resolveAuditConfig(funcConfig.audit);
230
232
  const invocationWire = resolvedWire;
233
+ // A wire no runner gave a trace is the root of one.
234
+ const rootTrace = !invocationWire.trace;
235
+ if (rootTrace) {
236
+ invocationWire.trace = createTrace(invocationWire.traceId);
237
+ invocationWire.traceId = invocationWire.trace.traceId;
238
+ }
231
239
  const previousFunctionId = invocationWire.functionId;
232
240
  const previousAudit = invocationWire.audit;
233
241
  const previousAddonNamespace = invocationWire.addonNamespace;
@@ -344,6 +352,21 @@ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServic
344
352
  invocationAuditLog = createInvocationAudit(resolvedSingletonServices.audit, invocationWire, resolvedSingletonServices.logger);
345
353
  services = { ...services, auditLog: invocationAuditLog };
346
354
  }
355
+ services = {
356
+ ...services,
357
+ ...(rootTrace && {
358
+ logger: services.logger.scope?.(invocationWire.traceId) ?? services.logger,
359
+ }),
360
+ ...(services.queueService && {
361
+ queueService: traceQueueService(services.queueService, invocationWire),
362
+ }),
363
+ ...(services.schedulerService && {
364
+ schedulerService: traceSchedulerService(services.schedulerService, invocationWire),
365
+ }),
366
+ ...(services.webhookService && {
367
+ webhookService: traceWebhookService(services.webhookService, invocationWire),
368
+ }),
369
+ };
347
370
  // Unconditional, unlike the audit — every function can record. Built on
348
371
  // first access, since most invocations record nothing.
349
372
  if (!services.analytics) {
@@ -0,0 +1,26 @@
1
+ import { type TraceContext } from '../trace.js';
2
+ import type { SchedulerService } from '../services/scheduler-service.js';
3
+ import type { WebhookService } from '../services/webhook-service.js';
4
+ import type { QueueService } from '../wirings/queue/queue.types.js';
5
+ /**
6
+ * Stamps every job a function enqueues with the function's trace, so neither
7
+ * the call site nor the generated `PikkuQueue` has to pass it. Read at `add`
8
+ * time because an authenticated caller can adopt a trace mid-invocation. A
9
+ * `traceparent` the caller set wins.
10
+ *
11
+ * knowledge: decisions/internals/a-job-inherits-the-trace-of-the-function-that-enqueued-it.md
12
+ */
13
+ export declare const traceQueueService: <Service extends QueueService>(queueService: Service, wire: {
14
+ trace?: TraceContext;
15
+ }) => Service;
16
+ /** {@link traceQueueService} for one-off delayed RPCs. */
17
+ export declare const traceSchedulerService: <Service extends SchedulerService>(schedulerService: Service, wire: {
18
+ trace?: TraceContext;
19
+ }) => Service;
20
+ /**
21
+ * {@link traceQueueService} for outgoing webhooks. The trace rides on the
22
+ * delivery job and stays out of the request: the receiver is a third party.
23
+ */
24
+ export declare const traceWebhookService: <Service extends WebhookService>(webhookService: Service, wire: {
25
+ trace?: TraceContext;
26
+ }) => Service;
@@ -0,0 +1,48 @@
1
+ import { outgoingTraceparent } from '../trace.js';
2
+ const passThrough = (target, property) => {
3
+ const value = Reflect.get(target, property, target);
4
+ return typeof value === 'function' ? value.bind(target) : value;
5
+ };
6
+ /**
7
+ * Stamps every job a function enqueues with the function's trace, so neither
8
+ * the call site nor the generated `PikkuQueue` has to pass it. Read at `add`
9
+ * time because an authenticated caller can adopt a trace mid-invocation. A
10
+ * `traceparent` the caller set wins.
11
+ *
12
+ * knowledge: decisions/internals/a-job-inherits-the-trace-of-the-function-that-enqueued-it.md
13
+ */
14
+ export const traceQueueService = (queueService, wire) => new Proxy(queueService, {
15
+ get(target, property) {
16
+ if (property === 'add') {
17
+ return (queueName, data, options) => {
18
+ const traceparent = options?.traceparent ?? outgoingTraceparent(wire);
19
+ return target.add(queueName, data, traceparent ? { ...options, traceparent } : options);
20
+ };
21
+ }
22
+ return passThrough(target, property);
23
+ },
24
+ });
25
+ /** {@link traceQueueService} for one-off delayed RPCs. */
26
+ export const traceSchedulerService = (schedulerService, wire) => new Proxy(schedulerService, {
27
+ get(target, property) {
28
+ if (property === 'scheduleRPC') {
29
+ return (delay, rpcName, data, session, traceparent) => target.scheduleRPC(delay, rpcName, data, session, traceparent ?? outgoingTraceparent(wire));
30
+ }
31
+ return passThrough(target, property);
32
+ },
33
+ });
34
+ /**
35
+ * {@link traceQueueService} for outgoing webhooks. The trace rides on the
36
+ * delivery job and stays out of the request: the receiver is a third party.
37
+ */
38
+ export const traceWebhookService = (webhookService, wire) => new Proxy(webhookService, {
39
+ get(target, property) {
40
+ if (property === 'send') {
41
+ return (input) => target.send({
42
+ ...input,
43
+ traceparent: input.traceparent ?? outgoingTraceparent(wire),
44
+ });
45
+ }
46
+ return passThrough(target, property);
47
+ },
48
+ });
package/dist/mesh.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { JWTService } from './services/jwt-service.js';
2
2
  import type { SecretService } from './services/secret-service.js';
3
3
  /**
4
- * Authorization headers carrying a JWT-signed session and traceId for
4
+ * Authorization headers carrying a JWT-signed session and `traceparent` for
5
5
  * `pikkuMeshAuthMiddleware` on the receiving end. Used by every deployment
6
6
  * service regardless of transport (HTTP, Lambda Invoke, service bindings).
7
7
  */
8
- export declare function buildMeshHeaders(jwt: JWTService | undefined, secrets: SecretService | undefined, funcName: string, session?: unknown, traceId?: string): Promise<Record<string, string>>;
8
+ export declare function buildMeshHeaders(jwt: JWTService | undefined, secrets: SecretService | undefined, funcName: string, session?: unknown, traceparent?: string): Promise<Record<string, string>>;
package/dist/mesh.js CHANGED
@@ -19,14 +19,14 @@ const readMeshSecret = async (secrets) => {
19
19
  }
20
20
  };
21
21
  /**
22
- * Authorization headers carrying a JWT-signed session and traceId for
22
+ * Authorization headers carrying a JWT-signed session and `traceparent` for
23
23
  * `pikkuMeshAuthMiddleware` on the receiving end. Used by every deployment
24
24
  * service regardless of transport (HTTP, Lambda Invoke, service bindings).
25
25
  */
26
- export async function buildMeshHeaders(jwt, secrets, funcName, session, traceId) {
26
+ export async function buildMeshHeaders(jwt, secrets, funcName, session, traceparent) {
27
27
  const headers = {
28
28
  'content-type': 'application/json',
29
- ...(traceId && { 'x-request-id': traceId }),
29
+ ...(traceparent && { traceparent }),
30
30
  };
31
31
  const secret = await readMeshSecret(secrets);
32
32
  if (secret && jwt) {
@@ -5,7 +5,7 @@ export { addonScopeCap, ADDON_HEADER } from './addon-scope-cap.js';
5
5
  export { remoteJobsSecret } from './remote-jobs-secret.js';
6
6
  export { pikkuMeshAuthMiddleware } from './mesh-auth.js';
7
7
  export { cors } from './cors.js';
8
- export { requireOrigin, isAllowedOrigin, toOrigin } from './require-origin.js';
8
+ export { requireOrigin, isAllowedOrigin, toOrigin, hostOriginOf, allowedOriginsFromVariables, } from './require-origin.js';
9
9
  export { telemetryOuter, telemetryInner } from './telemetry.js';
10
10
  export { addTagMiddleware, addGlobalMiddleware, runMiddleware, } from '../middleware-runner.js';
11
11
  export { addGlobalPermission } from '../permissions.js';
@@ -5,7 +5,7 @@ export { addonScopeCap, ADDON_HEADER } from './addon-scope-cap.js';
5
5
  export { remoteJobsSecret } from './remote-jobs-secret.js';
6
6
  export { pikkuMeshAuthMiddleware } from './mesh-auth.js';
7
7
  export { cors } from './cors.js';
8
- export { requireOrigin, isAllowedOrigin, toOrigin } from './require-origin.js';
8
+ export { requireOrigin, isAllowedOrigin, toOrigin, hostOriginOf, allowedOriginsFromVariables, } from './require-origin.js';
9
9
  export { telemetryOuter, telemetryInner } from './telemetry.js';
10
10
  export { addTagMiddleware, addGlobalMiddleware, runMiddleware, } from '../middleware-runner.js';
11
11
  export { addGlobalPermission } from '../permissions.js';
@@ -3,7 +3,8 @@
3
3
  * mesh-RPC route. Once `PIKKU_MESH_SECRET` is set it requires a
4
4
  * `Bearer` JWT with audience `pikku-mesh`; a token bound to a function (`fn`)
5
5
  * is only accepted on that function's `/mesh/rpc/<fn>` path, and any session
6
- * it carries is decrypted with the secret and set on the wire.
6
+ * it carries is decrypted with the secret and set on the wire. A verified caller's
7
+ * `traceparent` is continued, so the trace survives the hop.
7
8
  *
8
9
  * Without the secret it lets non-mesh paths through and refuses
9
10
  * `/mesh/rpc/*`, so the mesh surface fails closed.
@@ -18,7 +19,7 @@ export declare const pikkuMeshAuthMiddleware: import("./middleware.types.js").Co
18
19
  secrets?: {
19
20
  requireAllowedHosts?: boolean;
20
21
  };
21
- workflow?: import("../wirings/workflow/workflow.types.js").WorkflowServiceConfig;
22
+ workflow?: Partial<import("../wirings/workflow/workflow.types.js").WorkflowServiceConfig>;
22
23
  webhook?: import("../services/webhook-service.js").WebhookServiceConfig;
23
24
  postgres?: import("../types/core.types.js").PostgresConfig;
24
25
  }>, import("../types/core.types.js").CoreUserSession>;
@@ -1,5 +1,6 @@
1
1
  import { UnauthorizedError } from '../errors/errors.js';
2
2
  import { pikkuMiddleware } from './middleware-factories.js';
3
+ import { adoptTraceparent } from '../trace.js';
3
4
  import { assertStrongKeyMaterial, decryptWithKeyMaterial, MESH_SESSION_INFO, } from '../crypto-utils.js';
4
5
  const MESH_RPC_PREFIX = '/mesh/rpc/';
5
6
  /**
@@ -15,7 +16,8 @@ const isMeshRpcPath = (path) => path.toLowerCase().startsWith(MESH_RPC_PREFIX);
15
16
  * mesh-RPC route. Once `PIKKU_MESH_SECRET` is set it requires a
16
17
  * `Bearer` JWT with audience `pikku-mesh`; a token bound to a function (`fn`)
17
18
  * is only accepted on that function's `/mesh/rpc/<fn>` path, and any session
18
- * it carries is decrypted with the secret and set on the wire.
19
+ * it carries is decrypted with the secret and set on the wire. A verified caller's
20
+ * `traceparent` is continued, so the trace survives the hop.
19
21
  *
20
22
  * Without the secret it lets non-mesh paths through and refuses
21
23
  * `/mesh/rpc/*`, so the mesh surface fails closed.
@@ -25,7 +27,8 @@ const isMeshRpcPath = (path) => path.toLowerCase().startsWith(MESH_RPC_PREFIX);
25
27
  * @throws {WeakKeyMaterialError} When the secret is too short to use.
26
28
  * @throws {Error} When the secret is set but no JWT service is configured.
27
29
  */
28
- export const pikkuMeshAuthMiddleware = pikkuMiddleware(async ({ secrets, jwt }, { http, setInitialSession }, next) => {
30
+ export const pikkuMeshAuthMiddleware = pikkuMiddleware(async ({ secrets, jwt }, wire, next) => {
31
+ const { http, setInitialSession } = wire;
29
32
  if (!http?.request || !secrets) {
30
33
  return next();
31
34
  }
@@ -81,5 +84,7 @@ export const pikkuMeshAuthMiddleware = pikkuMiddleware(async ({ secrets, jwt },
81
84
  throw new UnauthorizedError();
82
85
  }
83
86
  }
87
+ // knowledge: decisions/security/inbound-traceparent-is-ignored-until-the-caller-is-authenticated.md
88
+ adoptTraceparent(wire, http.request.header('traceparent'));
84
89
  return next();
85
90
  });
@@ -8,7 +8,7 @@ export declare const remoteJobsSecret: import("./middleware.types.js").CorePikku
8
8
  secrets?: {
9
9
  requireAllowedHosts?: boolean;
10
10
  };
11
- workflow?: import("../wirings/workflow/workflow.types.js").WorkflowServiceConfig;
11
+ workflow?: Partial<import("../wirings/workflow/workflow.types.js").WorkflowServiceConfig>;
12
12
  webhook?: import("../services/webhook-service.js").WebhookServiceConfig;
13
13
  postgres?: import("../types/core.types.js").PostgresConfig;
14
14
  }>, import("../types/core.types.js").CoreUserSession>;
@@ -1,6 +1,25 @@
1
1
  import type { CoreSingletonServices } from '../types/core.types.js';
2
2
  /** Scheme + host + port, or null for anything unparseable including the literal `"null"` origin. */
3
3
  export declare const toOrigin: (value: string | null | undefined) => string | null;
4
+ /**
5
+ * The origin a request was addressed to, from its `Host` and `X-Forwarded-Proto`.
6
+ *
7
+ * With no proxy header the scheme is `http` only when the host is loopback, because a
8
+ * local dev server speaks plain http and no proxy sits in front of it. Every other host
9
+ * still defaults to `https`, so a missing header on a real host fails closed.
10
+ */
11
+ export declare const hostOriginOf: (host: string | null | undefined, forwardedProto?: string | null) => string | null;
12
+ /**
13
+ * The browser origins an app allows, read from the `variables` service (never
14
+ * `process.env`, which a Worker does not have): `CORS_ORIGINS` as a comma-separated
15
+ * list, else `FRONTEND_URL`. Unset means no extra origins, so only the request's own
16
+ * host is accepted.
17
+ */
18
+ export declare const allowedOriginsFromVariables: (services: {
19
+ variables?: {
20
+ get<T = string>(name: string): Promise<T | undefined> | T | undefined;
21
+ };
22
+ }) => Promise<string[]>;
4
23
  /**
5
24
  * Whether a request origin may post to an origin-locked route.
6
25
  *
@@ -12,6 +12,41 @@ export const toOrigin = (value) => {
12
12
  return null;
13
13
  }
14
14
  };
15
+ /** `localhost`, `127.0.0.1` or `[::1]`, with an optional port. Nothing else counts as loopback. */
16
+ const LOOPBACK_HOST = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
17
+ /**
18
+ * The origin a request was addressed to, from its `Host` and `X-Forwarded-Proto`.
19
+ *
20
+ * With no proxy header the scheme is `http` only when the host is loopback, because a
21
+ * local dev server speaks plain http and no proxy sits in front of it. Every other host
22
+ * still defaults to `https`, so a missing header on a real host fails closed.
23
+ */
24
+ export const hostOriginOf = (host, forwardedProto) => {
25
+ if (!host)
26
+ return null;
27
+ const proto = forwardedProto || (LOOPBACK_HOST.test(host) ? 'http' : 'https');
28
+ return toOrigin(`${proto}://${host}`);
29
+ };
30
+ /**
31
+ * The browser origins an app allows, read from the `variables` service (never
32
+ * `process.env`, which a Worker does not have): `CORS_ORIGINS` as a comma-separated
33
+ * list, else `FRONTEND_URL`. Unset means no extra origins, so only the request's own
34
+ * host is accepted.
35
+ */
36
+ export const allowedOriginsFromVariables = async (services) => {
37
+ const variables = services.variables;
38
+ if (!variables)
39
+ return [];
40
+ const configured = await variables.get('CORS_ORIGINS');
41
+ if (configured) {
42
+ return configured
43
+ .split(',')
44
+ .map((origin) => origin.trim())
45
+ .filter(Boolean);
46
+ }
47
+ const frontendUrl = await variables.get('FRONTEND_URL');
48
+ return frontendUrl ? [frontendUrl] : [];
49
+ };
15
50
  /**
16
51
  * Whether a request origin may post to an origin-locked route.
17
52
  *
@@ -46,8 +81,7 @@ export const requireOrigin = pikkuMiddlewareFactory(({ origins = [] } = {}) => p
46
81
  const requestOrigin = toOrigin(request.header('origin')) ??
47
82
  toOrigin(request.header('referer'));
48
83
  const host = request.header('host');
49
- const proto = request.header('x-forwarded-proto') ?? 'https';
50
- const hostOrigin = host ? toOrigin(`${proto}://${host}`) : null;
84
+ const hostOrigin = hostOriginOf(host, request.header('x-forwarded-proto'));
51
85
  const configured = typeof origins === 'function'
52
86
  ? await origins(services)
53
87
  : origins;
@@ -34,5 +34,5 @@ export interface DeploymentService {
34
34
  * (endpoint, service binding), propagating the session (JWT signing,
35
35
  * headers) and making the call. Callers supply no transport detail.
36
36
  */
37
- invoke(funcName: string, data: unknown, session?: unknown, traceId?: string): Promise<unknown>;
37
+ invoke(funcName: string, data: unknown, session?: unknown, traceparent?: string): Promise<unknown>;
38
38
  }
@@ -39,13 +39,15 @@ export declare class IncomingWebhookService {
39
39
  /** How often a failing trigger is retried before the event is given up on. */
40
40
  retries?: number);
41
41
  /** Returns how many events were queued. A store-backed service drops ones it has already seen. */
42
- accept({ source, events, }: {
42
+ accept({ source, events, traceparent, }: {
43
43
  source: string;
44
44
  request: WebhookRequest;
45
45
  events: TriggerEvent[];
46
+ /** The trace of the request that delivered the events. */
47
+ traceparent?: string;
46
48
  }): Promise<number>;
47
49
  /** The job id is the provider's event id under its event name, which de-duplicates on queues that honour job ids. */
48
- protected enqueue(job: WebhookSourceJob): Promise<string>;
50
+ protected enqueue(job: WebhookSourceJob, traceparent?: string): Promise<string>;
49
51
  /** Keeps nothing by default; a store-backed service records each dispatch. */
50
52
  recordAttempt(_receiptId: string, _attempt: IncomingWebhookAttempt): Promise<void>;
51
53
  /** Most recent first. Empty when nothing is stored. */
@@ -18,14 +18,14 @@ export class IncomingWebhookService {
18
18
  this.retries = retries;
19
19
  }
20
20
  /** Returns how many events were queued. A store-backed service drops ones it has already seen. */
21
- async accept({ source, events, }) {
21
+ async accept({ source, events, traceparent, }) {
22
22
  for (const event of events) {
23
- await this.enqueue({ source, event });
23
+ await this.enqueue({ source, event }, traceparent);
24
24
  }
25
25
  return events.length;
26
26
  }
27
27
  /** The job id is the provider's event id under its event name, which de-duplicates on queues that honour job ids. */
28
- async enqueue(job) {
28
+ async enqueue(job, traceparent) {
29
29
  const jobId = job.receiptId ??
30
30
  (job.event.id
31
31
  ? `${job.source}:${job.event.name}:${job.event.id}`
@@ -35,6 +35,7 @@ export class IncomingWebhookService {
35
35
  attempts: this.retries + 1,
36
36
  ...(this.retries > 0 ? { backoff: 'exponential' } : {}),
37
37
  ...(jobId ? { jobId } : {}),
38
+ ...(traceparent ? { traceparent } : {}),
38
39
  });
39
40
  }
40
41
  /** Keeps nothing by default; a store-backed service records each dispatch. */
@@ -57,7 +57,13 @@ export class QueueWebhookService extends WebhookService {
57
57
  body,
58
58
  headers,
59
59
  };
60
- return { jobData, options: this.resolveJobOptions(input) };
60
+ return {
61
+ jobData,
62
+ options: {
63
+ ...this.resolveJobOptions(input),
64
+ ...(input.traceparent ? { traceparent: input.traceparent } : {}),
65
+ },
66
+ };
61
67
  }
62
68
  // knowledge: decisions/internals/queue-jobs-always-carry-an-explicit-attempts-count.md
63
69
  resolveJobOptions(input) {
@@ -11,7 +11,10 @@ export interface RemoteQueueJobData {
11
11
  queueName: string;
12
12
  data?: unknown;
13
13
  jobId?: string;
14
- traceId?: string;
14
+ /** The W3C trace to continue. Invalid or absent starts a new trace. */
15
+ traceparent?: string;
16
+ /** The signed identity claim. {@link runQueueJob} verifies it or drops it. */
17
+ pikkuUserId?: string;
15
18
  }
16
19
  /**
17
20
  * A scheduled task run delivered over HTTP to {@link
@@ -19,4 +22,6 @@ export interface RemoteQueueJobData {
19
22
  */
20
23
  export interface RemoteScheduledJobData {
21
24
  taskName: string;
25
+ /** The W3C trace to continue. Invalid or absent starts a new trace. */
26
+ traceparent?: string;
22
27
  }
@@ -26,7 +26,9 @@ export interface ScheduledTaskInfo extends ScheduledTaskSummary {
26
26
  export declare abstract class SchedulerService {
27
27
  abstract init(): Promise<void>;
28
28
  /** `delay` is milliseconds as a number, or a duration string such as `"5h"` / `"30m"`. */
29
- abstract scheduleRPC(delay: number | string, rpcName: string, data?: any, session?: CoreUserSession): Promise<string>;
29
+ abstract scheduleRPC(delay: number | string, rpcName: string, data?: any, session?: CoreUserSession,
30
+ /** The W3C trace the RPC continues when it fires. */
31
+ traceparent?: string): Promise<string>;
30
32
  abstract unschedule(taskId: string): Promise<boolean>;
31
33
  abstract getTask(taskId: string): Promise<ScheduledTaskInfo | null>;
32
34
  abstract getAllTasks(): Promise<ScheduledTaskSummary[]>;
@@ -14,6 +14,8 @@ export interface SendWebhookInput {
14
14
  retryDelay?: string | number;
15
15
  /** Persisted only by store-backed implementations; the queue-only default ignores it. */
16
16
  organizationId?: string;
17
+ /** The trace the delivery job runs in. A function's calls are stamped for it. */
18
+ traceparent?: string;
17
19
  }
18
20
  /** The queue job that will deliver the webhook. */
19
21
  export interface SendWebhookResult {
@@ -1,6 +1,6 @@
1
1
  import type { CoreUserSession } from '../types/core.types.js';
2
2
  import type { SerializedError } from '../errors/serialized-error.js';
3
- import type { WorkflowRun, WorkflowPlannedStep, WorkflowRunWire, WorkflowRunStatus, StepState, WorkflowStatus, WorkflowVersionStatus } from '../wirings/workflow/workflow.types.js';
3
+ import type { WorkflowRun, WorkflowPlannedStep, WorkflowRunWire, WorkflowRunStatus, StepState, WorkflowRecoveryOptions, WorkflowStatus, WorkflowVersionStatus } from '../wirings/workflow/workflow.types.js';
4
4
  import type { RunTimeline, ReconstructedRunState } from '../wirings/workflow/run-timeline.js';
5
5
  /**
6
6
  * Stores and drives workflow runs, provided as the `workflowService` singleton
@@ -10,6 +10,9 @@ import type { RunTimeline, ReconstructedRunState } from '../wirings/workflow/run
10
10
  */
11
11
  export interface WorkflowService {
12
12
  wireQueueWorkers?(): void;
13
+ /** Sweep for work a dead process left behind, now and on an interval. */
14
+ startRecovery?(options?: WorkflowRecoveryOptions | false): void;
15
+ stopRecovery?(): void;
13
16
  createRun(workflowName: string, input: any, inline: boolean, graphHash: string, wire: WorkflowRunWire, options?: {
14
17
  deterministic?: boolean;
15
18
  plannedSteps?: WorkflowPlannedStep[];
@@ -1,4 +1,8 @@
1
1
  export { defineServiceTests } from './service-tests.js';
2
2
  export type { ServiceTestConfig } from './service-tests.js';
3
3
  export type { WorkflowFencingHarness } from './service-tests/workflow-fencing-tests.js';
4
+ export { defineQueueIdentityTransportTests } from './queue-identity-transport-tests.js';
5
+ export type { QueueIdentityTransport } from './queue-identity-transport-tests.js';
4
6
  export { clearPikkuRuntimeState } from '../test-utils.js';
7
+ export { defineQueueTraceConformance } from './queue-trace-conformance.js';
8
+ export type { QueueTraceAdapter } from './queue-trace-conformance.js';
@@ -1,2 +1,4 @@
1
1
  export { defineServiceTests } from './service-tests.js';
2
+ export { defineQueueIdentityTransportTests } from './queue-identity-transport-tests.js';
2
3
  export { clearPikkuRuntimeState } from '../test-utils.js';
4
+ export { defineQueueTraceConformance } from './queue-trace-conformance.js';
@@ -0,0 +1,18 @@
1
+ import type { CoreSingletonServices } from '../types/core.types.js';
2
+ import type { QueueService } from '../wirings/queue/queue.types.js';
3
+ /** One adapter, cut at the broker: what `queueService` sent, `deliver` consumes. */
4
+ export interface QueueIdentityTransport {
5
+ queueService: QueueService;
6
+ /** Runs everything sent so far through the adapter's real consumer path. */
7
+ deliver: () => Promise<void>;
8
+ }
9
+ /**
10
+ * Conformance for carrying a job's identity claim through an adapter: a signed
11
+ * claim reaches the worker with the user's session, and an unsigned, forged or
12
+ * misbound one leaves the job running as nobody.
13
+ *
14
+ * @param name - The adapter, for the test names.
15
+ * @param create - Builds the adapter. Gets the singleton services its consumer
16
+ * has to run under.
17
+ */
18
+ export declare const defineQueueIdentityTransportTests: (name: string, create: (services: CoreSingletonServices) => QueueIdentityTransport) => void;
@@ -0,0 +1,162 @@
1
+ import assert from 'node:assert/strict';
2
+ import { before, beforeEach, describe, test } from 'node:test';
3
+ import { createSecretValue } from '../classification/secret-value.js';
4
+ import { pikkuState } from '../pikku-state.js';
5
+ import { QUEUE_IDENTITY_SECRET_NAME, signQueueIdentity, } from '../wirings/queue/queue-identity.js';
6
+ import { wireQueueWorker } from '../wirings/queue/queue-runner.js';
7
+ import { SignedQueueService } from '../wirings/queue/signed-queue-service.js';
8
+ const QUEUE_NAME = 'identity-transport';
9
+ const SECRET = 'a'.repeat(48);
10
+ const OTHER_SECRET = 'b'.repeat(48);
11
+ const USER = 'user-1';
12
+ const SESSION = { userId: USER };
13
+ const DATA = { message: 'hello', nested: { n: 1 } };
14
+ const TRACE_ID = 'a'.repeat(32);
15
+ const TRACEPARENT = `00-${TRACE_ID}-${'b'.repeat(16)}-01`;
16
+ /**
17
+ * Conformance for carrying a job's identity claim through an adapter: a signed
18
+ * claim reaches the worker with the user's session, and an unsigned, forged or
19
+ * misbound one leaves the job running as nobody.
20
+ *
21
+ * @param name - The adapter, for the test names.
22
+ * @param create - Builds the adapter. Gets the singleton services its consumer
23
+ * has to run under.
24
+ */
25
+ export const defineQueueIdentityTransportTests = (name, create) => {
26
+ describe(`queue identity transport [${name}]`, () => {
27
+ const runs = [];
28
+ const logger = {
29
+ info: () => { },
30
+ warn: () => { },
31
+ error: () => { },
32
+ debug: () => { },
33
+ };
34
+ const secrets = {
35
+ getSecret: async () => createSecretValue(SECRET),
36
+ hasSecret: async (key) => key === QUEUE_IDENTITY_SECRET_NAME,
37
+ };
38
+ const services = {
39
+ logger,
40
+ secrets,
41
+ sessionStore: {
42
+ get: async (id) => (id === USER ? SESSION : undefined),
43
+ },
44
+ };
45
+ let transport;
46
+ before(() => {
47
+ pikkuState(null, 'queue', 'meta')[QUEUE_NAME] = {
48
+ pikkuFuncId: `queue_${QUEUE_NAME}`,
49
+ name: QUEUE_NAME,
50
+ };
51
+ pikkuState(null, 'function', 'meta')[`queue_${QUEUE_NAME}`] = {
52
+ pikkuFuncId: `queue_${QUEUE_NAME}`,
53
+ inputSchemaName: null,
54
+ outputSchemaName: null,
55
+ sessionless: true,
56
+ };
57
+ wireQueueWorker({
58
+ name: QUEUE_NAME,
59
+ func: {
60
+ auth: false,
61
+ func: async (_services, data, wire) => {
62
+ runs.push({
63
+ data,
64
+ pikkuUserId: wire.pikkuUserId,
65
+ session: wire.session,
66
+ traceId: wire.traceId,
67
+ });
68
+ },
69
+ },
70
+ });
71
+ transport = create(services);
72
+ });
73
+ beforeEach(() => {
74
+ runs.length = 0;
75
+ pikkuState(null, 'package', 'singletonServices', services);
76
+ });
77
+ const deliverOne = async () => {
78
+ await transport.deliver();
79
+ assert.equal(runs.length, 1, 'the job ran once');
80
+ return runs[0];
81
+ };
82
+ const assertRanAsNobody = async () => {
83
+ const run = await deliverOne();
84
+ assert.equal(run.pikkuUserId, undefined);
85
+ assert.equal(run.session, undefined);
86
+ return run;
87
+ };
88
+ test('a signed claim reaches the worker with the session', async () => {
89
+ await new SignedQueueService(transport.queueService, secrets, logger).add(QUEUE_NAME, DATA, { pikkuUserId: USER });
90
+ const run = await deliverOne();
91
+ assert.equal(run.pikkuUserId, USER);
92
+ assert.deepEqual(run.session, SESSION);
93
+ assert.deepEqual(run.data, DATA);
94
+ });
95
+ test('the identity and the trace travel together', async () => {
96
+ await new SignedQueueService(transport.queueService, secrets, logger).add(QUEUE_NAME, DATA, { pikkuUserId: USER, traceparent: TRACEPARENT });
97
+ const run = await deliverOne();
98
+ assert.equal(run.pikkuUserId, USER);
99
+ assert.equal(run.traceId, TRACE_ID);
100
+ assert.deepEqual(run.data, DATA);
101
+ });
102
+ test('a job with no claim runs as nobody', async () => {
103
+ await transport.queueService.add(QUEUE_NAME, DATA);
104
+ const run = await assertRanAsNobody();
105
+ assert.deepEqual(run.data, DATA);
106
+ });
107
+ test('an unsigned claim is dropped', async () => {
108
+ await transport.queueService.add(QUEUE_NAME, DATA, { pikkuUserId: USER });
109
+ const run = await assertRanAsNobody();
110
+ assert.deepEqual(run.data, DATA);
111
+ });
112
+ test('a claim signed with another secret is dropped', async () => {
113
+ await transport.queueService.add(QUEUE_NAME, DATA, {
114
+ pikkuUserId: await signQueueIdentity(OTHER_SECRET, USER, {
115
+ queueName: QUEUE_NAME,
116
+ data: DATA,
117
+ }),
118
+ });
119
+ await assertRanAsNobody();
120
+ });
121
+ test('a tampered claim is dropped', async () => {
122
+ const [version, , signature] = (await signQueueIdentity(SECRET, 'someone-else', {
123
+ queueName: QUEUE_NAME,
124
+ data: DATA,
125
+ })).split('.');
126
+ const [, claim] = (await signQueueIdentity(SECRET, USER, {
127
+ queueName: QUEUE_NAME,
128
+ data: DATA,
129
+ })).split('.');
130
+ await transport.queueService.add(QUEUE_NAME, DATA, {
131
+ pikkuUserId: `${version}.${claim}.${signature}`,
132
+ });
133
+ await assertRanAsNobody();
134
+ });
135
+ test('a claim signed for another payload is dropped', async () => {
136
+ await transport.queueService.add(QUEUE_NAME, DATA, {
137
+ pikkuUserId: await signQueueIdentity(SECRET, USER, {
138
+ queueName: QUEUE_NAME,
139
+ data: { message: 'other' },
140
+ }),
141
+ });
142
+ await assertRanAsNobody();
143
+ });
144
+ test('a claim signed for another queue is dropped', async () => {
145
+ await transport.queueService.add(QUEUE_NAME, DATA, {
146
+ pikkuUserId: await signQueueIdentity(SECRET, USER, {
147
+ queueName: 'another-queue',
148
+ data: DATA,
149
+ }),
150
+ });
151
+ await assertRanAsNobody();
152
+ });
153
+ test('identity fields inside the payload grant nothing', async () => {
154
+ await transport.queueService.add(QUEUE_NAME, {
155
+ pikkuUserId: USER,
156
+ __pikkuUserId: USER,
157
+ data: DATA,
158
+ });
159
+ await assertRanAsNobody();
160
+ });
161
+ });
162
+ };