@pikku/core 0.12.79 → 0.12.80

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 (330) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/dist/dev/hot-reload.js +1 -6
  3. package/dist/ecosystem.d.ts +27 -0
  4. package/dist/ecosystem.js +26 -0
  5. package/dist/errors/error-handler.js +4 -2
  6. package/dist/function/function-runner.js +20 -42
  7. package/dist/function/functions.types.d.ts +9 -5
  8. package/dist/index.d.ts +4 -9
  9. package/dist/index.js +3 -8
  10. package/dist/middleware/auth-cookie.d.ts +0 -4
  11. package/dist/middleware/auth-cookie.js +38 -2
  12. package/dist/middleware/cors.js +1 -0
  13. package/dist/middleware/index.d.ts +1 -1
  14. package/dist/middleware/index.js +1 -1
  15. package/dist/middleware/remote-auth.js +14 -3
  16. package/dist/permissions.d.ts +8 -10
  17. package/dist/permissions.js +0 -11
  18. package/dist/pikku-state.js +12 -5
  19. package/dist/schema.js +35 -1
  20. package/dist/services/ai-run-state-service.d.ts +7 -1
  21. package/dist/services/in-memory-ai-run-state-service.d.ts +1 -1
  22. package/dist/services/in-memory-ai-run-state-service.js +2 -1
  23. package/dist/services/in-memory-workflow-service.js +2 -0
  24. package/dist/services/local-content-request-handler.d.ts +21 -0
  25. package/dist/services/local-content-request-handler.js +72 -53
  26. package/dist/services/local-content.d.ts +6 -0
  27. package/dist/services/local-content.js +14 -1
  28. package/dist/services/workflow-service.d.ts +4 -2
  29. package/dist/testing/service-tests/agent-run-service-tests.d.ts +10 -0
  30. package/dist/testing/service-tests/agent-run-service-tests.js +72 -0
  31. package/dist/testing/service-tests/ai-storage-service-tests.d.ts +3 -0
  32. package/dist/testing/service-tests/ai-storage-service-tests.js +226 -0
  33. package/dist/testing/service-tests/channel-store-tests.d.ts +3 -0
  34. package/dist/testing/service-tests/channel-store-tests.js +72 -0
  35. package/dist/testing/service-tests/credential-service-tests.d.ts +3 -0
  36. package/dist/testing/service-tests/credential-service-tests.js +109 -0
  37. package/dist/testing/service-tests/deployment-service-tests.d.ts +3 -0
  38. package/dist/testing/service-tests/deployment-service-tests.js +21 -0
  39. package/dist/testing/service-tests/event-hub-store-tests.d.ts +3 -0
  40. package/dist/testing/service-tests/event-hub-store-tests.js +34 -0
  41. package/dist/testing/service-tests/secret-service-tests.d.ts +3 -0
  42. package/dist/testing/service-tests/secret-service-tests.js +80 -0
  43. package/dist/testing/service-tests/session-store-tests.d.ts +3 -0
  44. package/dist/testing/service-tests/session-store-tests.js +43 -0
  45. package/dist/testing/service-tests/workflow-run-service-tests.d.ts +3 -0
  46. package/dist/testing/service-tests/workflow-run-service-tests.js +42 -0
  47. package/dist/testing/service-tests/workflow-service-tests.d.ts +3 -0
  48. package/dist/testing/service-tests/workflow-service-tests.js +150 -0
  49. package/dist/testing/service-tests.d.ts +6 -0
  50. package/dist/testing/service-tests.js +26 -791
  51. package/dist/types/core.types.d.ts +11 -1
  52. package/dist/types/state.types.d.ts +1 -1
  53. package/dist/wirings/actor-flow/run-conversation.js +1 -6
  54. package/dist/wirings/ai-agent/agent-rpc.d.ts +15 -0
  55. package/dist/wirings/ai-agent/agent-rpc.js +53 -0
  56. package/dist/wirings/ai-agent/ai-agent-agui.js +1 -5
  57. package/dist/wirings/ai-agent/ai-agent-memory.js +3 -1
  58. package/dist/wirings/ai-agent/ai-agent-prepare.js +3 -9
  59. package/dist/wirings/ai-agent/ai-agent-runner.js +28 -107
  60. package/dist/wirings/ai-agent/ai-agent-stream.js +20 -61
  61. package/dist/wirings/ai-agent/ai-agent-turn.d.ts +56 -0
  62. package/dist/wirings/ai-agent/ai-agent-turn.js +81 -0
  63. package/dist/wirings/ai-agent/ai-agent.types.d.ts +1 -1
  64. package/dist/wirings/ai-agent/voice-input.js +1 -6
  65. package/dist/wirings/ai-agent/voice-output.js +2 -12
  66. package/dist/wirings/channel/channel-common.js +1 -0
  67. package/dist/wirings/channel/channel-handler.js +3 -5
  68. package/dist/wirings/channel/channel-rpc-service.d.ts +0 -6
  69. package/dist/wirings/channel/channel-rpc-service.js +0 -8
  70. package/dist/wirings/channel/channel-rpc.types.d.ts +6 -0
  71. package/dist/wirings/channel/channel-rpc.types.js +8 -0
  72. package/dist/wirings/channel/channel-runner.d.ts +1 -3
  73. package/dist/wirings/channel/channel-runner.js +16 -8
  74. package/dist/wirings/channel/channel.types.d.ts +2 -0
  75. package/dist/wirings/channel/pikku-abstract-channel-handler.js +1 -0
  76. package/dist/wirings/channel/serverless/serverless-channel-runner.js +3 -0
  77. package/dist/wirings/cli/channel/cli-channel-runner.js +2 -0
  78. package/dist/wirings/cli/cli-runner.js +4 -2
  79. package/dist/wirings/cli/cli.types.d.ts +0 -8
  80. package/dist/wirings/cli/command-parser.js +13 -0
  81. package/dist/wirings/gateway/gateway-runner.js +9 -2
  82. package/dist/wirings/http/http-routes.js +2 -0
  83. package/dist/wirings/http/http-runner.d.ts +0 -10
  84. package/dist/wirings/http/http-runner.js +6 -13
  85. package/dist/wirings/http/http.types.d.ts +0 -10
  86. package/dist/wirings/http/index.d.ts +1 -1
  87. package/dist/wirings/http/index.js +1 -1
  88. package/dist/wirings/mcp/mcp-runner.d.ts +0 -7
  89. package/dist/wirings/mcp/mcp-runner.js +0 -6
  90. package/dist/wirings/rpc/rpc-runner.d.ts +8 -51
  91. package/dist/wirings/rpc/rpc-runner.js +53 -86
  92. package/dist/wirings/rpc/rpc-types.d.ts +3 -0
  93. package/dist/wirings/secret/validate-secret-definitions.js +2 -2
  94. package/dist/wirings/trigger/pikku-trigger-service.d.ts +0 -4
  95. package/dist/wirings/trigger/trigger-runner.js +1 -0
  96. package/dist/wirings/virtual-user/run-virtual-user.js +11 -11
  97. package/dist/wirings/virtual-user/virtual-user-derive.js +4 -25
  98. package/dist/wirings/virtual-user/virtual-user-dispositions.js +1 -4
  99. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +29 -2
  100. package/dist/wirings/workflow/feature.js +7 -4
  101. package/dist/wirings/workflow/graph/graph-runner.d.ts +1 -2
  102. package/dist/wirings/workflow/graph/graph-runner.js +8 -7
  103. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +0 -4
  104. package/dist/wirings/workflow/index.d.ts +7 -2
  105. package/dist/wirings/workflow/index.js +5 -1
  106. package/dist/wirings/workflow/pikku-scenario-service.d.ts +17 -3
  107. package/dist/wirings/workflow/pikku-scenario-service.js +38 -10
  108. package/dist/wirings/workflow/pikku-workflow-service.d.ts +28 -147
  109. package/dist/wirings/workflow/pikku-workflow-service.js +71 -493
  110. package/dist/wirings/workflow/workflow-approval.d.ts +39 -0
  111. package/dist/wirings/workflow/workflow-approval.js +114 -0
  112. package/dist/wirings/workflow/workflow-constants.d.ts +26 -0
  113. package/dist/wirings/workflow/workflow-constants.js +35 -0
  114. package/dist/wirings/workflow/workflow-errors.d.ts +58 -0
  115. package/dist/wirings/workflow/workflow-errors.js +112 -0
  116. package/dist/wirings/workflow/workflow-meta-resolver.d.ts +11 -0
  117. package/dist/wirings/workflow/workflow-meta-resolver.js +31 -0
  118. package/dist/wirings/workflow/workflow-queue-routing.d.ts +8 -0
  119. package/dist/wirings/workflow/workflow-queue-routing.js +38 -0
  120. package/dist/wirings/workflow/workflow-queue-wiring.d.ts +20 -0
  121. package/dist/wirings/workflow/workflow-queue-wiring.js +79 -0
  122. package/dist/wirings/workflow/workflow-recovery.d.ts +68 -0
  123. package/dist/wirings/workflow/workflow-recovery.js +101 -0
  124. package/dist/wirings/workflow/workflow-run-engine.types.d.ts +54 -0
  125. package/dist/wirings/workflow/workflow-run-engine.types.js +1 -0
  126. package/dist/wirings/workflow/workflow-run-ownership.d.ts +16 -0
  127. package/dist/wirings/workflow/workflow-run-ownership.js +29 -0
  128. package/dist/wirings/workflow/workflow-suspend.d.ts +12 -0
  129. package/dist/wirings/workflow/workflow-suspend.js +33 -0
  130. package/dist/wirings/workflow/workflow.types.d.ts +7 -0
  131. package/knowledge/decisions/internals/a-non-streaming-agent-run-registers-with-airunstate-too.md +22 -0
  132. package/knowledge/decisions/internals/a-resumed-agent-turn-is-as-interruptible-as-the-first.md +20 -0
  133. package/knowledge/decisions/internals/a-scenario-step-template-is-offered-unfilled.md +21 -0
  134. package/knowledge/decisions/internals/a-virtual-user-decides-whether-to-trust-memory-once-per-turn.md +21 -0
  135. package/knowledge/decisions/internals/a-wall-clock-threshold-is-a-load-test-in-disguise.md +46 -0
  136. package/knowledge/decisions/internals/a-workflow-wire-is-built-from-the-run-not-from-the-rpc-service.md +45 -0
  137. package/knowledge/decisions/internals/agent-context-waits-for-a-tool-result-still-being-written.md +21 -0
  138. package/knowledge/decisions/internals/agent-speech-travels-as-a-custom-agui-event.md +22 -0
  139. package/knowledge/decisions/internals/an-agent-interrupt-is-not-a-failure.md +23 -0
  140. package/knowledge/decisions/internals/an-agent-run-owned-by-another-instance-says-so.md +25 -0
  141. package/knowledge/decisions/internals/an-agent-stream-send-must-return-the-inner-sends-promise.md +22 -0
  142. package/knowledge/decisions/internals/an-empty-text-part-is-omitted-from-an-agent-message.md +20 -0
  143. package/knowledge/decisions/internals/an-empty-transcript-is-not-recorded.md +22 -0
  144. package/knowledge/decisions/internals/an-unref-d-timer-cannot-be-awaited-under-node-test.md +66 -0
  145. package/knowledge/decisions/internals/channel-state-accessors-are-unsound-generics-that-every-implementation-asserts.md +33 -0
  146. package/knowledge/decisions/internals/gateway-listener-middleware-runs-without-an-rpc-on-the-wire.md +43 -0
  147. package/knowledge/decisions/internals/hot-reload-writes-into-the-function-map-captured-at-startup.md +26 -0
  148. package/knowledge/decisions/internals/index.md +38 -26
  149. package/knowledge/decisions/internals/only-exposed-functions-enter-a-virtual-user-catalogue.md +21 -0
  150. package/knowledge/decisions/internals/scenario-given-and-when-are-sugar-but-then-is-not.md +24 -0
  151. package/knowledge/decisions/internals/side-effects-are-an-allowlist-not-a-boolean.md +34 -0
  152. package/knowledge/decisions/internals/speech-synthesis-picks-a-voice-per-sentence-and-warns-once.md +24 -0
  153. package/knowledge/decisions/internals/the-actor-prompt-says-json-because-of-json-object-mode.md +22 -0
  154. package/knowledge/decisions/internals/the-agent-done-event-goes-through-the-middleware-and-is-awaited.md +26 -0
  155. package/knowledge/decisions/internals/the-api-report-pins-members-not-just-names.md +43 -0
  156. package/knowledge/decisions/internals/the-ecosystem-entry-point-carries-the-adapter-surface.md +58 -0
  157. package/knowledge/decisions/internals/the-middleware-resolution-cache-is-deliberately-unbounded.md +40 -0
  158. package/knowledge/decisions/internals/the-per-invocation-rpc-view-is-a-class.md +40 -0
  159. package/knowledge/decisions/internals/the-persona-runtime-is-exported-from-the-persona-entry-point.md +28 -0
  160. package/knowledge/decisions/internals/the-transcript-event-is-sent-ahead-of-the-run.md +25 -0
  161. package/knowledge/decisions/internals/the-virtual-user-catalogue-is-the-only-gate-on-what-may-be-called.md +21 -0
  162. package/knowledge/decisions/internals/the-worker-disposition-is-the-one-that-is-not-testing.md +22 -0
  163. package/knowledge/decisions/internals/thread-history-records-the-transcript-not-the-audio.md +25 -0
  164. package/knowledge/decisions/internals/virtual-user-step-order-comes-from-insertion-order.md +21 -0
  165. package/knowledge/decisions/internals/voice-output-speaks-unless-voice-input-explicitly-says-otherwise.md +24 -0
  166. package/knowledge/decisions/internals/wiring-registries-erase-the-generics-their-wire-functions-capture.md +35 -0
  167. package/knowledge/decisions/security/a-dropped-audit-write-is-always-logged.md +4 -2
  168. package/knowledge/decisions/security/a-graph-run-starts-at-an-entry-node-the-graph-declared.md +33 -0
  169. package/knowledge/decisions/security/a-permission-gets-a-wire-it-cannot-reply-on.md +33 -0
  170. package/knowledge/decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md +37 -0
  171. package/knowledge/decisions/security/a-virtual-user-is-never-offered-a-step-that-would-forge-its-own-oracle.md +28 -0
  172. package/knowledge/decisions/security/a-workflow-run-is-read-and-approved-by-its-owner.md +34 -0
  173. package/knowledge/decisions/security/an-agent-approval-is-claimed-before-the-tool-runs.md +33 -0
  174. package/knowledge/decisions/security/an-upload-is-counted-as-it-arrives-not-buffered-then-measured.md +24 -0
  175. package/knowledge/decisions/security/index.md +7 -0
  176. package/knowledge/questions/channel-middleware-accepts-bare-factories-that-nothing-resolves.md +35 -0
  177. package/knowledge/questions/index.md +2 -1
  178. package/knowledge/questions/unauthorized-channel-replies-escape-the-declared-out-type.md +44 -0
  179. package/package.json +13 -3
  180. package/scripts/generate-api-report.d.mts +1 -0
  181. package/scripts/generate-api-report.mjs +89 -0
  182. package/scripts/generate-api-report.mts +218 -0
  183. package/src/api-report.test.ts +69 -0
  184. package/src/column-form.ts +1 -4
  185. package/src/crypto-utils.test.ts +15 -2
  186. package/src/dev/hot-reload.ts +2 -7
  187. package/src/ecosystem.ts +40 -0
  188. package/src/errors/error-handler.ts +5 -3
  189. package/src/errors/error.test.ts +4 -1
  190. package/src/function/function-runner.test.ts +1 -1
  191. package/src/function/function-runner.ts +28 -39
  192. package/src/function/functions.types.ts +15 -9
  193. package/src/handle-error.ts +1 -1
  194. package/src/index.ts +3 -22
  195. package/src/middleware/auth-cookie.test.ts +50 -0
  196. package/src/middleware/auth-cookie.ts +38 -2
  197. package/src/middleware/cors.ts +2 -1
  198. package/src/middleware/index.ts +1 -1
  199. package/src/middleware/remote-auth.test.ts +43 -0
  200. package/src/middleware/remote-auth.ts +17 -3
  201. package/src/middleware-runner.ts +4 -2
  202. package/src/no-any-casts.test.ts +57 -0
  203. package/src/permissions.test.ts +3 -1
  204. package/src/permissions.ts +22 -24
  205. package/src/pikku-state.ts +17 -6
  206. package/src/public-surface.json +578 -0
  207. package/src/public-surface.json.README +25 -0
  208. package/src/public-surface.test.ts +105 -0
  209. package/src/removed-legacy-exports.test.ts +62 -0
  210. package/src/schema.test.ts +78 -0
  211. package/src/schema.ts +36 -1
  212. package/src/services/ai-run-state-service.ts +7 -1
  213. package/src/services/audit-service.ts +2 -2
  214. package/src/services/in-memory-ai-run-state-service.ts +3 -2
  215. package/src/services/in-memory-workflow-service.ts +2 -0
  216. package/src/services/index.ts +1 -4
  217. package/src/services/local-content-request-handler.test.ts +43 -5
  218. package/src/services/local-content-request-handler.ts +103 -74
  219. package/src/services/local-content.ts +15 -1
  220. package/src/services/local-email-service.ts +5 -1
  221. package/src/services/system-role-guard.test.ts +4 -1
  222. package/src/services/workflow-service.ts +9 -2
  223. package/src/side-effects-are-declared.test.ts +84 -0
  224. package/src/source-files-stay-composable.test.ts +41 -0
  225. package/src/testing/service-tests/agent-run-service-tests.ts +98 -0
  226. package/src/testing/service-tests/ai-storage-service-tests.ts +286 -0
  227. package/src/testing/service-tests/channel-store-tests.ts +98 -0
  228. package/src/testing/service-tests/credential-service-tests.ts +143 -0
  229. package/src/testing/service-tests/deployment-service-tests.ts +33 -0
  230. package/src/testing/service-tests/event-hub-store-tests.ts +48 -0
  231. package/src/testing/service-tests/secret-service-tests.ts +105 -0
  232. package/src/testing/service-tests/session-store-tests.ts +62 -0
  233. package/src/testing/service-tests/workflow-run-service-tests.ts +59 -0
  234. package/src/testing/service-tests/workflow-service-tests.ts +308 -0
  235. package/src/testing/service-tests.ts +31 -1111
  236. package/src/types/core.types.ts +15 -1
  237. package/src/types/state.types.ts +1 -1
  238. package/src/wirings/actor-flow/run-conversation.ts +1 -6
  239. package/src/wirings/ai-agent/agent-rpc.ts +120 -0
  240. package/src/wirings/ai-agent/ai-agent-agui.ts +1 -5
  241. package/src/wirings/ai-agent/ai-agent-interrupt.test.ts +2 -1
  242. package/src/wirings/ai-agent/ai-agent-memory.ts +8 -1
  243. package/src/wirings/ai-agent/ai-agent-prepare.ts +10 -12
  244. package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +1 -0
  245. package/src/wirings/ai-agent/ai-agent-runner.test.ts +82 -2
  246. package/src/wirings/ai-agent/ai-agent-runner.ts +49 -120
  247. package/src/wirings/ai-agent/ai-agent-stream.test.ts +2 -0
  248. package/src/wirings/ai-agent/ai-agent-stream.ts +36 -63
  249. package/src/wirings/ai-agent/ai-agent-thread-ownership.test.ts +1 -1
  250. package/src/wirings/ai-agent/ai-agent-turn.ts +121 -0
  251. package/src/wirings/ai-agent/ai-agent.types.ts +1 -1
  252. package/src/wirings/ai-agent/voice-input.ts +1 -6
  253. package/src/wirings/ai-agent/voice-output.ts +2 -12
  254. package/src/wirings/channel/channel-common.ts +5 -2
  255. package/src/wirings/channel/channel-handler-shapes.test.ts +72 -0
  256. package/src/wirings/channel/channel-handler.ts +7 -7
  257. package/src/wirings/channel/channel-rpc-service.ts +0 -14
  258. package/src/wirings/channel/channel-rpc.test.ts +25 -4
  259. package/src/wirings/channel/channel-rpc.types.ts +14 -0
  260. package/src/wirings/channel/channel-runner.ts +33 -20
  261. package/src/wirings/channel/channel.types.ts +2 -0
  262. package/src/wirings/channel/local/local-channel-runner.ts +1 -1
  263. package/src/wirings/channel/pikku-abstract-channel-handler.ts +2 -1
  264. package/src/wirings/channel/serverless/serverless-channel-runner.ts +6 -3
  265. package/src/wirings/cli/channel/cli-channel-runner.ts +3 -1
  266. package/src/wirings/cli/channel/cli-raw-client-runner.ts +4 -1
  267. package/src/wirings/cli/cli-runner.ts +7 -4
  268. package/src/wirings/cli/cli.types.ts +0 -39
  269. package/src/wirings/cli/command-parser.test.ts +19 -0
  270. package/src/wirings/cli/command-parser.ts +17 -1
  271. package/src/wirings/gateway/gateway-channel-meta.test.ts +44 -0
  272. package/src/wirings/gateway/gateway-runner.ts +27 -19
  273. package/src/wirings/http/http-routes.ts +4 -1
  274. package/src/wirings/http/http-runner.ts +11 -26
  275. package/src/wirings/http/http.types.ts +0 -15
  276. package/src/wirings/http/index.ts +1 -7
  277. package/src/wirings/http/pikku-fetch-http-request.ts +2 -2
  278. package/src/wirings/http/web-request.ts +1 -1
  279. package/src/wirings/mcp/mcp-runner.ts +2 -16
  280. package/src/wirings/persona/persona-environments.test.ts +14 -3
  281. package/src/wirings/persona/persona.test.ts +13 -3
  282. package/src/wirings/persona/validate-personas.ts +5 -1
  283. package/src/wirings/queue/queue-runner.ts +1 -1
  284. package/src/wirings/rpc/addon-auth-tags.test.ts +1 -5
  285. package/src/wirings/rpc/rpc-runner.ts +58 -136
  286. package/src/wirings/rpc/rpc-types.ts +7 -0
  287. package/src/wirings/rpc/wire-addon.ts +3 -1
  288. package/src/wirings/secret/validate-secret-definitions.test.ts +22 -0
  289. package/src/wirings/secret/validate-secret-definitions.ts +2 -2
  290. package/src/wirings/trigger/pikku-trigger-service.ts +0 -5
  291. package/src/wirings/trigger/trigger-runner.ts +7 -5
  292. package/src/wirings/virtual-user/run-virtual-user.test.ts +18 -9
  293. package/src/wirings/virtual-user/run-virtual-user.ts +28 -15
  294. package/src/wirings/virtual-user/virtual-user-agents.test.ts +4 -1
  295. package/src/wirings/virtual-user/virtual-user-derive.ts +4 -25
  296. package/src/wirings/virtual-user/virtual-user-dispositions.test.ts +7 -2
  297. package/src/wirings/virtual-user/virtual-user-dispositions.ts +1 -4
  298. package/src/wirings/virtual-user/virtual-user-intents.test.ts +13 -3
  299. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +35 -2
  300. package/src/wirings/workflow/feature.ts +7 -4
  301. package/src/wirings/workflow/graph/graph-node.ts +1 -1
  302. package/src/wirings/workflow/graph/graph-runner.test.ts +4 -2
  303. package/src/wirings/workflow/graph/graph-runner.ts +13 -16
  304. package/src/wirings/workflow/graph/workflow-graph.types.ts +0 -5
  305. package/src/wirings/workflow/index.ts +12 -4
  306. package/src/wirings/workflow/pikku-scenario-service.ts +54 -25
  307. package/src/wirings/workflow/pikku-workflow-service.test.ts +2 -2
  308. package/src/wirings/workflow/pikku-workflow-service.ts +190 -743
  309. package/src/wirings/workflow/scenario-hooks.test.ts +51 -0
  310. package/src/wirings/workflow/workflow-approval.ts +185 -0
  311. package/src/wirings/workflow/workflow-child-run-session.test.ts +89 -0
  312. package/src/wirings/workflow/workflow-constants.ts +47 -0
  313. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +1 -1
  314. package/src/wirings/workflow/workflow-errors.ts +128 -0
  315. package/src/wirings/workflow/workflow-inline-authority.test.ts +12 -6
  316. package/src/wirings/workflow/workflow-meta-resolver.ts +45 -0
  317. package/src/wirings/workflow/workflow-missing-meta.test.ts +92 -0
  318. package/src/wirings/workflow/workflow-queue-routing.ts +85 -0
  319. package/src/wirings/workflow/workflow-queue-wiring.ts +149 -0
  320. package/src/wirings/workflow/workflow-recovery.ts +162 -0
  321. package/src/wirings/workflow/workflow-retry-policy.test.ts +1 -1
  322. package/src/wirings/workflow/workflow-run-authority.test.ts +215 -0
  323. package/src/wirings/workflow/workflow-run-engine.types.ts +91 -0
  324. package/src/wirings/workflow/workflow-run-ownership.ts +36 -0
  325. package/src/wirings/workflow/workflow-suspend.ts +61 -0
  326. package/src/wirings/workflow/workflow.types.ts +7 -0
  327. package/src/wirings-stay-decoupled.test.ts +123 -0
  328. package/tsconfig.json +1 -1
  329. package/tsconfig.tsbuildinfo +1 -1
  330. package/src/internal.ts +0 -10
@@ -0,0 +1,105 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFileSync } from 'node:fs'
4
+ import { fileURLToPath, pathToFileURL } from 'node:url'
5
+ import { dirname, join, resolve } from 'node:path'
6
+
7
+ const srcRoot = dirname(fileURLToPath(import.meta.url))
8
+ const packageRoot = join(srcRoot, '..')
9
+
10
+ const packageJson = JSON.parse(
11
+ readFileSync(join(packageRoot, 'package.json'), 'utf-8')
12
+ )
13
+ const expected: Record<string, string[]> = JSON.parse(
14
+ readFileSync(join(srcRoot, 'public-surface.json'), 'utf-8')
15
+ )
16
+
17
+ const subpaths = packageJson.exports as Record<string, string>
18
+
19
+ /**
20
+ * Every runtime export reachable through an entry point, keyed by subpath.
21
+ *
22
+ * Types are absent: they are erased before this can see them, so a type-only
23
+ * addition is not pinned here.
24
+ */
25
+ const actualSurface = async () => {
26
+ const surface: Record<string, string[]> = {}
27
+ for (const [subpath, dist] of Object.entries(subpaths)) {
28
+ const src = resolve(packageRoot, dist.replace('./dist/', './src/'))
29
+ const module = await import(pathToFileURL(src).href)
30
+ surface[subpath] = Object.keys(module).sort()
31
+ }
32
+ return surface
33
+ }
34
+
35
+ const REGENERATE =
36
+ 'Regenerate with the snippet in public-surface.json.README, and make sure ' +
37
+ 'every added name is one @pikku/core means to publish.'
38
+
39
+ describe('the published surface is the surface we meant to publish', () => {
40
+ test('every export subpath resolves to a source file', () => {
41
+ const missing = Object.entries(subpaths).filter(([, dist]) => {
42
+ try {
43
+ readFileSync(
44
+ resolve(packageRoot, dist.replace('./dist/', './src/')).replace(
45
+ /\.js$/,
46
+ '.ts'
47
+ )
48
+ )
49
+ return false
50
+ } catch {
51
+ return true
52
+ }
53
+ })
54
+
55
+ assert.deepEqual(
56
+ missing.map(([subpath]) => subpath),
57
+ [],
58
+ 'package.json exports point at files that do not exist in src'
59
+ )
60
+ })
61
+
62
+ test('no entry point gained or lost a runtime export', async () => {
63
+ const actual = await actualSurface()
64
+
65
+ assert.deepEqual(
66
+ Object.keys(actual).sort(),
67
+ Object.keys(expected).sort(),
68
+ `the set of export subpaths changed. ${REGENERATE}`
69
+ )
70
+
71
+ for (const subpath of Object.keys(expected)) {
72
+ const added = actual[subpath]!.filter(
73
+ (name) => !expected[subpath]!.includes(name)
74
+ )
75
+ const removed = expected[subpath]!.filter(
76
+ (name) => !actual[subpath]!.includes(name)
77
+ )
78
+
79
+ assert.deepEqual(
80
+ { added, removed },
81
+ { added: [], removed: [] },
82
+ `'${subpath}' changed its public exports.\n` +
83
+ (added.length ? ` added: ${added.join(', ')}\n` : '') +
84
+ (removed.length ? ` removed: ${removed.join(', ')}\n` : '') +
85
+ `${REGENERATE}`
86
+ )
87
+ }
88
+ })
89
+
90
+ test('the wildcard re-exports have not widened past their barrels', async () => {
91
+ // `errors/index.ts` and `dev/hot-reload.ts` re-export with `export *`, so a
92
+ // new export in the wrapped module is published without anyone editing an
93
+ // entry point. This pins the two that are reachable publicly.
94
+ const actual = await actualSurface()
95
+
96
+ assert.equal(
97
+ actual['./errors']!.every(
98
+ (name) => name.endsWith('Error') || name.startsWith('addError')
99
+ ),
100
+ true,
101
+ `'@pikku/core/errors' exports something that is not an error or an error ` +
102
+ `registrar: ${actual['./errors']!.filter((n) => !n.endsWith('Error') && !n.startsWith('addError')).join(', ')}`
103
+ )
104
+ })
105
+ })
@@ -0,0 +1,62 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFileSync, readdirSync } from 'node:fs'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { join, dirname, relative } from 'node:path'
6
+
7
+ const srcRoot = dirname(fileURLToPath(import.meta.url))
8
+ const thisFile = fileURLToPath(import.meta.url)
9
+
10
+ /**
11
+ * Names removed from the public surface, each with the entry point that must
12
+ * no longer carry it.
13
+ *
14
+ * They survived as throwing stubs only so the pinned bootstrap CLI could
15
+ * resolve the import; `@pikku/cli@0.12.96` emits none of them, so the stubs
16
+ * went too.
17
+ */
18
+ const REMOVED = [
19
+ { name: 'addTagPermission', entry: './middleware/index.js' },
20
+ { name: 'addHTTPPermission', entry: './wirings/http/index.js' },
21
+ { name: 'ZodLike', entry: './index.js' },
22
+ ]
23
+
24
+ const collectSourceFiles = (directory: string): string[] =>
25
+ readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
26
+ const entryPath = join(directory, entry.name)
27
+ if (entry.isDirectory()) {
28
+ return entry.name === 'dist' ? [] : collectSourceFiles(entryPath)
29
+ }
30
+ return /\.ts$/.test(entry.name) ? [entryPath] : []
31
+ })
32
+
33
+ describe('removed legacy exports stay removed', () => {
34
+ for (const { name, entry } of REMOVED) {
35
+ test(`${entry} no longer exports ${name}`, async () => {
36
+ const module = await import(entry)
37
+ assert.equal(
38
+ name in module,
39
+ false,
40
+ `${entry} exports ${name} again; it was removed in #972`
41
+ )
42
+ })
43
+ }
44
+
45
+ test('no source file mentions the removed permission wrappers', () => {
46
+ const offenders = collectSourceFiles(srcRoot)
47
+ .filter(
48
+ (file) =>
49
+ file !== thisFile &&
50
+ /\baddTagPermission\b|\baddHTTPPermission\b|\bZodLike\b/.test(
51
+ readFileSync(file, 'utf-8')
52
+ )
53
+ )
54
+ .map((file) => relative(srcRoot, file))
55
+
56
+ assert.deepEqual(
57
+ offenders,
58
+ [],
59
+ `removed names still referenced in:\n${offenders.join('\n')}`
60
+ )
61
+ })
62
+ })
@@ -265,6 +265,84 @@ describe('Schema', () => {
265
265
  await validateSchema(logger, schemaService, null, {})
266
266
  })
267
267
 
268
+ test('strips undefined-valued properties before validating', async () => {
269
+ addSchema('undefinedProps', {
270
+ properties: { name: { type: 'string' }, count: { type: 'number' } },
271
+ })
272
+ const logger = {
273
+ info: () => {},
274
+ warn: () => {},
275
+ error: () => {},
276
+ debug: () => {},
277
+ } as any
278
+ let seen: any
279
+ const schemaService = {
280
+ compileSchema: async () => {},
281
+ validateSchema: async (_name: string, data: any) => {
282
+ seen = data
283
+ },
284
+ getSchemaNames: () => new Set<string>(),
285
+ }
286
+ await validateSchema(logger, schemaService, 'undefinedProps', {
287
+ name: 'ada',
288
+ count: undefined,
289
+ })
290
+ assert.deepStrictEqual(seen, { name: 'ada' })
291
+ assert.ok(!('count' in seen))
292
+ })
293
+
294
+ test('strips undefined nested in objects and arrays', async () => {
295
+ addSchema('nestedUndefined', { properties: {} })
296
+ const logger = {
297
+ info: () => {},
298
+ warn: () => {},
299
+ error: () => {},
300
+ debug: () => {},
301
+ } as any
302
+ let seen: any
303
+ const schemaService = {
304
+ compileSchema: async () => {},
305
+ validateSchema: async (_name: string, data: any) => {
306
+ seen = data
307
+ },
308
+ getSchemaNames: () => new Set<string>(),
309
+ }
310
+ await validateSchema(logger, schemaService, 'nestedUndefined', {
311
+ nested: { keep: 1, drop: undefined },
312
+ list: [{ keep: 2, drop: undefined }],
313
+ })
314
+ assert.deepStrictEqual(seen, {
315
+ nested: { keep: 1 },
316
+ list: [{ keep: 2 }],
317
+ })
318
+ })
319
+
320
+ test('leaves Date instances intact while stripping', async () => {
321
+ addSchema('withDate', { properties: {} })
322
+ const logger = {
323
+ info: () => {},
324
+ warn: () => {},
325
+ error: () => {},
326
+ debug: () => {},
327
+ } as any
328
+ const when = new Date('2020-01-01T00:00:00.000Z')
329
+ let seen: any
330
+ const schemaService = {
331
+ compileSchema: async () => {},
332
+ validateSchema: async (_name: string, data: any) => {
333
+ seen = data
334
+ },
335
+ getSchemaNames: () => new Set<string>(),
336
+ }
337
+ await validateSchema(logger, schemaService, 'withDate', {
338
+ when,
339
+ drop: undefined,
340
+ })
341
+ assert.ok(seen.when instanceof Date)
342
+ assert.equal(seen.when.getTime(), when.getTime())
343
+ assert.ok(!('drop' in seen))
344
+ })
345
+
268
346
  test('should throw MissingSchemaError when schema not found', async () => {
269
347
  const logger = {
270
348
  info: () => {},
package/src/schema.ts CHANGED
@@ -135,6 +135,41 @@ export const coerceTopLevelDataFromSchema = (
135
135
  }
136
136
  }
137
137
 
138
+ /**
139
+ * Drops object properties whose value is literally `undefined`.
140
+ *
141
+ * JSON Schema has no way to describe such a property, and the validator refuses
142
+ * the whole instance rather than reporting it — `{ a: undefined }` throws
143
+ * "Instances of \"undefined\" type are not supported" instead of validating.
144
+ *
145
+ * That matters because whether a payload can contain one depends on how the
146
+ * call travelled. Over HTTP `JSON.stringify` drops these keys before the
147
+ * request is built, so they never arrive. Dispatched in-process — an inline
148
+ * workflow step calling an RPC — the object is handed over as-is, and
149
+ * `{ retries: data.maybeRetries }` with the field omitted by the caller is
150
+ * enough to kill the step. Normalising here makes the two paths agree, and an
151
+ * explicitly-undefined *required* field now reports as the missing property it
152
+ * is rather than as an internal error.
153
+ *
154
+ * Deliberately not a JSON round-trip: `coerceTopLevelDataFromSchema` puts real
155
+ * `Date` instances on the data first, and stringifying would flatten them back
156
+ * to strings the validator has never been given.
157
+ */
158
+ const stripUndefinedValues = <T>(value: T): T => {
159
+ if (Array.isArray(value)) return value.map(stripUndefinedValues) as T
160
+ if (value === null || typeof value !== 'object') return value
161
+ // Only plain objects: a Date, Map or class instance is data, not a bag of
162
+ // properties to be rewritten.
163
+ const prototype = Object.getPrototypeOf(value)
164
+ if (prototype !== Object.prototype && prototype !== null) return value
165
+
166
+ return Object.fromEntries(
167
+ Object.entries(value)
168
+ .filter(([, entry]) => entry !== undefined)
169
+ .map(([key, entry]) => [key, stripUndefinedValues(entry)])
170
+ ) as T
171
+ }
172
+
138
173
  export const validateSchema = async (
139
174
  logger: Logger,
140
175
  schemaService: SchemaService | undefined,
@@ -164,6 +199,6 @@ export const validateSchema = async (
164
199
  )
165
200
  }
166
201
  await schemaService.compileSchema(key, schema)
167
- await schemaService.validateSchema(key, data ?? {})
202
+ await schemaService.validateSchema(key, stripUndefinedValues(data ?? {}))
168
203
  }
169
204
  }
@@ -10,10 +10,16 @@ export interface AIRunStateService {
10
10
  updateRun(runId: string, updates: Partial<AgentRunState>): Promise<void>
11
11
  getRun(runId: string): Promise<AgentRunState | null>
12
12
  getRunsByThread(threadId: string): Promise<AgentRunState[]>
13
+ /**
14
+ * Claims an approval: removes it from the run's pending list and records the
15
+ * decision, as one atomic step. Returns whether THIS caller made the claim —
16
+ * concurrent approvals of the same tool call produce exactly one `true`, and
17
+ * only that caller may run the tool.
18
+ */
13
19
  resolveApproval(
14
20
  toolCallId: string,
15
21
  status: 'approved' | 'denied'
16
- ): Promise<void>
22
+ ): Promise<boolean>
17
23
  findRunByToolCallId(
18
24
  toolCallId: string
19
25
  ): Promise<{ run: AgentRunState; approval: PendingApproval } | null>
@@ -150,7 +150,7 @@ class DisabledInvocationAudit implements AuditLog {
150
150
  if (!this.warned) {
151
151
  this.warned = true
152
152
  // knowledge: decisions/security/a-dropped-audit-write-is-always-logged.md
153
- const logger = (this.wire as any).logger ?? this.logger
153
+ const logger = this.wire.logger ?? this.logger
154
154
  logger?.warn?.(
155
155
  `audit.write() dropped for '${this.wire.functionId ?? 'unknown function'}' — the function has no audit config (set audit: true on it)`
156
156
  )
@@ -207,7 +207,7 @@ class InvocationAuditLog implements AuditLog {
207
207
  await this.service.audit(event)
208
208
  }
209
209
  } catch (error) {
210
- const logger = (this.wire as any).logger ?? this.logger
210
+ const logger = this.wire.logger ?? this.logger
211
211
  logger?.warn?.('best-effort audit flush failed', error)
212
212
  }
213
213
  }
@@ -38,7 +38,7 @@ export class InMemoryAIRunStateService implements AIRunStateService {
38
38
  async resolveApproval(
39
39
  toolCallId: string,
40
40
  status: 'approved' | 'denied'
41
- ): Promise<void> {
41
+ ): Promise<boolean> {
42
42
  for (const run of this.runs.values()) {
43
43
  if (run.pendingApprovals) {
44
44
  const approval = run.pendingApprovals.find(
@@ -49,10 +49,11 @@ export class InMemoryAIRunStateService implements AIRunStateService {
49
49
  (a) => a.toolCallId !== toolCallId
50
50
  )
51
51
  run.updatedAt = new Date()
52
- return
52
+ return true
53
53
  }
54
54
  }
55
55
  }
56
+ return false
56
57
  }
57
58
 
58
59
  async findRunByToolCallId(
@@ -142,6 +142,7 @@ export class InMemoryWorkflowService
142
142
  const step: StepState & { stepName: string } = {
143
143
  stepId,
144
144
  status: 'pending',
145
+ rpcName,
145
146
  attemptCount: 1,
146
147
  retries: stepOptions?.retries,
147
148
  retryDelay: stepOptions?.retryDelay,
@@ -273,6 +274,7 @@ export class InMemoryWorkflowService
273
274
  const newStep: StepState & { stepName: string } = {
274
275
  stepId: newStepId,
275
276
  status,
277
+ rpcName: failedStep.rpcName,
276
278
  attemptCount: failedStep.attemptCount + 1,
277
279
  retries: failedStep.retries,
278
280
  retryDelay: failedStep.retryDelay,
@@ -45,10 +45,7 @@ export type {
45
45
  ScenarioRpcMap,
46
46
  ScenarioHttpResponse,
47
47
  } from './personas-service.js'
48
- // The persona runtime — `HttpPersona`, `createHttpPersonas`,
49
- // `readScenarioHttpResponse`, `postScenarioJson` — is exported from
50
- // `@pikku/core/persona`, not from here. Types are free; those are values, and
51
- // they reach the actor-flow and agent runners, which no production server runs.
48
+ // knowledge: decisions/internals/the-persona-runtime-is-exported-from-the-persona-entry-point.md
52
49
  export type { HttpPersonasConfig } from './http-personas.js'
53
50
  export type { JWTService } from './jwt-service.js'
54
51
  export type {
@@ -81,11 +81,17 @@ describe('createLocalContentRequestHandler', () => {
81
81
  test('refuses an upload whose key escapes the content directory', async () => {
82
82
  writeFileSync(join(tmpDir, '..', 'pikku-handler-secret.txt'), 'secret')
83
83
  try {
84
+ // Signed for the escaping path itself, so it clears the signature gate
85
+ // and the path-escape guard is what rejects it.
86
+ const signed = await content.signURL({
87
+ url: '/upload/bucket/..%2f..%2fpikku-handler-secret.txt',
88
+ dateLessThan: new Date(Date.now() + 60_000),
89
+ } as any)
84
90
  const response = await handler(
85
- new Request(
86
- 'http://localhost/upload/bucket/..%2f..%2fpikku-handler-secret.txt',
87
- { method: 'PUT', body: 'overwritten' }
88
- )
91
+ new Request(`http://localhost${signed}`, {
92
+ method: 'PUT',
93
+ body: 'overwritten',
94
+ })
89
95
  )
90
96
  assert.equal(response?.status, 400)
91
97
  assert.equal(
@@ -104,6 +110,33 @@ describe('createLocalContentRequestHandler', () => {
104
110
  assert.equal(response?.status, 400)
105
111
  })
106
112
 
113
+ test('refuses an unsigned PUT to the upload prefix', async () => {
114
+ const put = await handler(
115
+ new Request('http://localhost/upload/bucket/evil.bin', {
116
+ method: 'PUT',
117
+ body: 'attacker-controlled',
118
+ })
119
+ )
120
+ assert.equal(put?.status, 403, 'an upload URL must be signed like a read')
121
+ })
122
+
123
+ test('refuses a PUT whose signature was minted for a different key', async () => {
124
+ const { uploadUrl } = await content.getUploadURL({
125
+ bucket: 'bucket',
126
+ fileKey: 'legit/one.bin',
127
+ contentType: 'application/octet-stream',
128
+ } as any)
129
+ const query = uploadUrl.slice(uploadUrl.indexOf('?'))
130
+ // Same signature, a different target path.
131
+ const put = await handler(
132
+ new Request(`http://localhost/upload/bucket/other.bin${query}`, {
133
+ method: 'PUT',
134
+ body: 'payload',
135
+ })
136
+ )
137
+ assert.equal(put?.status, 403)
138
+ })
139
+
107
140
  test('round-trips an upload and a signed read', async () => {
108
141
  const { uploadUrl } = await content.getUploadURL({
109
142
  bucket: 'bucket',
@@ -188,8 +221,13 @@ describe('createLocalContentRequestHandler', () => {
188
221
  })
189
222
 
190
223
  test('rejects a body over the size limit without writing it', async () => {
224
+ const { uploadUrl } = await content.getUploadURL({
225
+ bucket: 'bucket',
226
+ fileKey: 'big.bin',
227
+ contentType: 'application/octet-stream',
228
+ } as any)
191
229
  const response = await handler(
192
- new Request('http://localhost/upload/bucket/big.bin', {
230
+ new Request(`http://localhost${uploadUrl}`, {
193
231
  method: 'PUT',
194
232
  body: Buffer.alloc(1024 * 1024 + 1),
195
233
  })
@@ -77,89 +77,122 @@ const text = (status: number, body: string) =>
77
77
  headers: { 'content-type': 'text/plain; charset=utf-8' },
78
78
  })
79
79
 
80
- export const createLocalContentRequestHandler = ({
81
- content,
82
- logger,
83
- getJWT,
84
- }: LocalContentRequestHandlerOptions): LocalContentRequestHandler => {
85
- // Logged at most once. An unverifiable request is attacker-triggerable, so
86
- // this reports a startup misconfiguration rather than per-request news.
87
- let loggedMissingJWT = false
80
+ export type SignedContentVerification =
81
+ | { ok: true }
82
+ | { ok: false; status: number; body: string }
88
83
 
89
- const validateSignedAssetRequest = async (
90
- requestUrl: URL
91
- ): Promise<{ ok: true } | { ok: false; status: number; body: string }> => {
92
- const signedAtValue = requestUrl.searchParams.get('signedAt')
93
- const expiresAtValue = requestUrl.searchParams.get('expiresAt')
94
- const notBeforeValue = requestUrl.searchParams.get('notBefore')
95
- const signature = requestUrl.searchParams.get('signature')
96
-
97
- if (!signedAtValue || !expiresAtValue) {
98
- return { ok: false, status: 403, body: 'Signed URL required' }
99
- }
84
+ /**
85
+ * Verifies a signed content URL — the one function that decides whether a
86
+ * signed read or a signed upload is genuine.
87
+ *
88
+ * Exported and shared so the runtimes that serve content (the core handler here,
89
+ * the node server, the express adapter) verify identically. They used to each
90
+ * carry their own copy, which is how the express upload path ended up with no
91
+ * check at all: one copy can rot or be forgotten in isolation.
92
+ *
93
+ * `onMissingJWT` is called at most where the caller wants to log the
94
+ * misconfiguration once; a signed request with no verifier is treated as
95
+ * invalid, never as trusted.
96
+ */
97
+ export const verifySignedContentRequest = async (
98
+ requestUrl: URL,
99
+ jwt: JWTService | undefined,
100
+ onMissingJWT?: () => void
101
+ ): Promise<SignedContentVerification> => {
102
+ const signedAtValue = requestUrl.searchParams.get('signedAt')
103
+ const expiresAtValue = requestUrl.searchParams.get('expiresAt')
104
+ const notBeforeValue = requestUrl.searchParams.get('notBefore')
105
+ const signature = requestUrl.searchParams.get('signature')
100
106
 
101
- const signedAt = Number(signedAtValue)
102
- const expiresAt = Number(expiresAtValue)
103
- const notBefore =
104
- notBeforeValue == null ? undefined : Number(notBeforeValue)
107
+ if (!signedAtValue || !expiresAtValue) {
108
+ return { ok: false, status: 403, body: 'Signed URL required' }
109
+ }
105
110
 
106
- if (
107
- !Number.isFinite(signedAt) ||
108
- !Number.isFinite(expiresAt) ||
109
- (notBefore != null && !Number.isFinite(notBefore))
110
- ) {
111
- return { ok: false, status: 403, body: 'Invalid signed URL' }
112
- }
111
+ const signedAt = Number(signedAtValue)
112
+ const expiresAt = Number(expiresAtValue)
113
+ const notBefore = notBeforeValue == null ? undefined : Number(notBeforeValue)
113
114
 
114
- const now = Date.now()
115
- if (now > expiresAt || (notBefore != null && now < notBefore)) {
116
- return { ok: false, status: 403, body: 'Signed URL expired' }
117
- }
115
+ if (
116
+ !Number.isFinite(signedAt) ||
117
+ !Number.isFinite(expiresAt) ||
118
+ (notBefore != null && !Number.isFinite(notBefore))
119
+ ) {
120
+ return { ok: false, status: 403, body: 'Invalid signed URL' }
121
+ }
118
122
 
119
- const jwt = getJWT()
120
- if (!jwt) {
121
- if (!loggedMissingJWT) {
122
- loggedMissingJWT = true
123
- logger.error(
124
- 'pikku: refusing signed asset reads — no JWTService is available to verify them. Pass `contentSigningJWT` (the same service LocalContent signs with) or expose it as `singletonServices.jwt`.'
125
- )
126
- }
127
- return { ok: false, status: 403, body: 'Invalid signed URL' }
128
- }
123
+ const now = Date.now()
124
+ if (now > expiresAt || (notBefore != null && now < notBefore)) {
125
+ return { ok: false, status: 403, body: 'Signed URL expired' }
126
+ }
129
127
 
130
- if (!signature) {
131
- return { ok: false, status: 403, body: 'Signed URL signature required' }
132
- }
128
+ if (!jwt) {
129
+ onMissingJWT?.()
130
+ return { ok: false, status: 403, body: 'Invalid signed URL' }
131
+ }
133
132
 
134
- try {
135
- const payload = await jwt.decode<{
136
- signedAt?: number
137
- expiresAt?: number
138
- notBefore?: number
139
- path?: string
140
- }>(signature)
141
-
142
- // Every claim is compared, the path included: without it a signature
143
- // minted for one asset would read any other.
144
- if (
145
- payload.signedAt !== signedAt ||
146
- payload.expiresAt !== expiresAt ||
147
- payload.notBefore !== notBefore ||
148
- payload.path !== signedContentPath(requestUrl.pathname)
149
- ) {
150
- return { ok: false, status: 403, body: 'Invalid signed URL' }
151
- }
152
- } catch {
133
+ if (!signature) {
134
+ return { ok: false, status: 403, body: 'Signed URL signature required' }
135
+ }
136
+
137
+ try {
138
+ const payload = await jwt.decode<{
139
+ signedAt?: number
140
+ expiresAt?: number
141
+ notBefore?: number
142
+ path?: string
143
+ }>(signature)
144
+
145
+ // Every claim is compared, the path included: without it a signature
146
+ // minted for one asset would read any other.
147
+ if (
148
+ payload.signedAt !== signedAt ||
149
+ payload.expiresAt !== expiresAt ||
150
+ payload.notBefore !== notBefore ||
151
+ payload.path !== signedContentPath(requestUrl.pathname)
152
+ ) {
153
153
  return { ok: false, status: 403, body: 'Invalid signed URL' }
154
154
  }
155
-
156
- return { ok: true }
155
+ } catch {
156
+ return { ok: false, status: 403, body: 'Invalid signed URL' }
157
157
  }
158
158
 
159
+ return { ok: true }
160
+ }
161
+
162
+ export const createLocalContentRequestHandler = ({
163
+ content,
164
+ logger,
165
+ getJWT,
166
+ }: LocalContentRequestHandlerOptions): LocalContentRequestHandler => {
167
+ // Logged at most once. An unverifiable request is attacker-triggerable, so
168
+ // this reports a startup misconfiguration rather than per-request news.
169
+ let loggedMissingJWT = false
170
+
171
+ const validateSignedAssetRequest = (
172
+ requestUrl: URL
173
+ ): Promise<SignedContentVerification> =>
174
+ verifySignedContentRequest(requestUrl, getJWT(), () => {
175
+ if (loggedMissingJWT) return
176
+ loggedMissingJWT = true
177
+ logger.error(
178
+ 'pikku: refusing signed asset reads — no JWTService is available to verify them. Pass `contentSigningJWT` (the same service LocalContent signs with) or expose it as `singletonServices.jwt`.'
179
+ )
180
+ })
181
+
159
182
  const handleUpload = async (
160
183
  request: Request,
184
+ requestUrl: URL,
161
185
  pathname: string
162
186
  ): Promise<Response> => {
187
+ // Uploads are presigned exactly like reads: getUploadURL mints a signed URL
188
+ // and this verifies it before writing. Without this an unauthenticated PUT
189
+ // could write arbitrary bytes to any key under the content root — the write
190
+ // side had none of the signature checking the read side has always had.
191
+ const signed = await validateSignedAssetRequest(requestUrl)
192
+ if (!signed.ok) {
193
+ return text(signed.status, signed.body)
194
+ }
195
+
163
196
  const key = contentKey(pathname, content.uploadUrlPrefix)
164
197
  const targetPath = toTargetPath(content.localFileUploadPath, key)
165
198
  if (!targetPath) {
@@ -168,11 +201,7 @@ export const createLocalContentRequestHandler = ({
168
201
 
169
202
  const maxBytes = parseSizeLimit(content.sizeLimit ?? '1mb')
170
203
 
171
- // Counted as it arrives and abandoned the moment it goes over, so an
172
- // oversized upload costs the limit rather than its own size — `arrayBuffer()`
173
- // would have to hold all of it first, which hands an unauthenticated caller
174
- // a way to spend the server's memory. Mirrors node-http-server's
175
- // `readRequestBody`, which aborts the same way.
204
+ // knowledge: decisions/security/an-upload-is-counted-as-it-arrives-not-buffered-then-measured.md
176
205
  const chunks: Buffer[] = []
177
206
  let bytesRead = 0
178
207
  const reader = request.body?.getReader()
@@ -252,7 +281,7 @@ export const createLocalContentRequestHandler = ({
252
281
  request.method === 'PUT' &&
253
282
  matchesPrefix(pathname, content.uploadUrlPrefix)
254
283
  ) {
255
- return handleUpload(request, pathname)
284
+ return handleUpload(request, requestUrl, pathname)
256
285
  }
257
286
 
258
287
  if (