@pikku/core 0.12.137 → 0.12.139

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 (78) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/dev/hot-reload.js +5 -2
  3. package/dist/env.d.ts +18 -0
  4. package/dist/env.js +60 -1
  5. package/dist/function/abort-scope.d.ts +6 -5
  6. package/dist/function/abort-scope.js +6 -17
  7. package/dist/function/function-runner.js +2 -1
  8. package/dist/middleware/addon-scope-cap.d.ts +19 -0
  9. package/dist/middleware/addon-scope-cap.js +34 -0
  10. package/dist/middleware/auth-apikey.js +1 -1
  11. package/dist/middleware/auth-bearer.js +1 -1
  12. package/dist/middleware/auth-cookie.js +1 -1
  13. package/dist/middleware/index.d.ts +1 -0
  14. package/dist/middleware/index.js +1 -0
  15. package/dist/schema.js +3 -1
  16. package/dist/scopes.d.ts +8 -0
  17. package/dist/scopes.js +23 -0
  18. package/dist/services/content-service.d.ts +13 -1
  19. package/dist/services/in-memory-queue-service.js +4 -2
  20. package/dist/services/in-memory-workflow-service.js +7 -6
  21. package/dist/services/index.d.ts +2 -2
  22. package/dist/services/index.js +1 -1
  23. package/dist/services/local-content-request-handler.js +21 -60
  24. package/dist/services/local-content.d.ts +45 -1
  25. package/dist/services/local-content.js +202 -18
  26. package/dist/services/local-variables.d.ts +5 -0
  27. package/dist/services/local-variables.js +7 -2
  28. package/dist/services/queue-webhook-service.js +1 -1
  29. package/dist/services/scoped-content-service.d.ts +35 -0
  30. package/dist/services/scoped-content-service.js +171 -0
  31. package/dist/services/webhook-service.d.ts +2 -2
  32. package/dist/services/webhook-service.js +6 -4
  33. package/dist/types/core.types.d.ts +10 -1
  34. package/dist/types/state.types.d.ts +8 -0
  35. package/dist/utils/hmac.d.ts +25 -10
  36. package/dist/utils/hmac.js +202 -24
  37. package/dist/wirings/addon/addon-reach.d.ts +3 -0
  38. package/dist/wirings/addon/addon-reach.js +21 -0
  39. package/dist/wirings/addon/addon-runner.d.ts +6 -0
  40. package/dist/wirings/addon/addon-runner.js +13 -0
  41. package/dist/wirings/addon/define-screens.d.ts +32 -0
  42. package/dist/wirings/addon/define-screens.js +5 -0
  43. package/dist/wirings/addon/index.d.ts +2 -0
  44. package/dist/wirings/addon/index.js +1 -0
  45. package/dist/wirings/addon/wire-addon.d.ts +27 -0
  46. package/dist/wirings/addon/wire-addon.js +4 -0
  47. package/dist/wirings/agent/agent-agui.js +65 -62
  48. package/dist/wirings/agent/agent-interrupt.d.ts +4 -1
  49. package/dist/wirings/agent/agent-interrupt.js +3 -3
  50. package/dist/wirings/agent/agent-memory.js +5 -6
  51. package/dist/wirings/agent/agent-model-config.js +2 -1
  52. package/dist/wirings/agent/agent-prepare.js +24 -13
  53. package/dist/wirings/agent/agent-stream.js +38 -34
  54. package/dist/wirings/agent/agent.types.d.ts +8 -1
  55. package/dist/wirings/channel/channel-handler.js +1 -1
  56. package/dist/wirings/channel/local/local-channel-handler.js +5 -1
  57. package/dist/wirings/channel/local/local-channel-runner.js +6 -4
  58. package/dist/wirings/channel/local/local-eventhub-service.d.ts +1 -0
  59. package/dist/wirings/channel/local/local-eventhub-service.js +4 -1
  60. package/dist/wirings/cli/channel/cli-channel-runner.js +7 -2
  61. package/dist/wirings/cli/channel/cli-raw-client-runner.js +8 -4
  62. package/dist/wirings/cli/cli-runner.js +1 -1
  63. package/dist/wirings/gateway/gateway-runner.js +3 -3
  64. package/dist/wirings/http/http-runner.js +6 -6
  65. package/dist/wirings/rpc/rpc-runner.d.ts +4 -0
  66. package/dist/wirings/rpc/rpc-runner.js +41 -2
  67. package/dist/wirings/rpc/rpc-types.d.ts +6 -0
  68. package/dist/wirings/scheduler/scheduler-runner.js +1 -1
  69. package/dist/wirings/scope/define-scope.d.ts +19 -0
  70. package/dist/wirings/scope/define-scope.js +19 -0
  71. package/dist/wirings/scope/index.d.ts +2 -2
  72. package/dist/wirings/scope/index.js +2 -2
  73. package/dist/wirings/trigger/trigger-runner.js +3 -1
  74. package/dist/wirings/trigger/webhook-source-runner.js +5 -2
  75. package/dist/wirings/virtual-user/virtual-user-scaffold.js +3 -2
  76. package/dist/wirings/workflow/workflow-invocation-id.js +85 -8
  77. package/package.json +3 -1
  78. package/src/public-surface.json +65 -18
package/CHANGELOG.md CHANGED
@@ -1,3 +1,43 @@
1
+ ## 0.12.139
2
+
3
+ ### Patch Changes
4
+
5
+ - a196a21: A call over HTTP that names an add-on in `x-pikku-addon` can only reach that add-on's own functions and the add-ons it lists in `uses`. `rpc.exposed` now refuses anything else, including host functions and an add-on nobody wired, and `AddonNotUsedError` answers 403.
6
+ - 53ac40d: An addon can ship screens: `wireAddon({ ui: true })` mounts what the package declares with `defineScreens`, the inspector reads them, `pikku all` emits the screens manifest and the installed-addons registry, and `x-pikku-addon` narrows a session to the addon's role. `pikku new addon --ui` scaffolds one; its tables must be prefixed `<name>_`.
7
+ - 443fd40: Local content uploads stream to disk instead of being buffered in memory, and a rejected or aborted upload leaves no file behind. A 300 MB upload and read no longer grows the server's memory with the file.
8
+ - 56c8047: Content services support `visibility: 'private' | 'public'` (default private). `ContentService` gains `getDownloadURL`, `deleteByPrefix` and `listFilesByPrefix`. `LocalContent` stores under `private/` and `public/` and serves public files unsigned at `<assetUrlPrefix>/_public/<bucket>/<key>`; unprefixed files from before still read as private, and a private bucket may not be named `_public`. S3 makes public objects `public-read` per object (the bucket must allow ACLs), Backblaze uses `publicBucketId` when set, and the scoped addon content service passes visibility through and keeps the new methods inside the addon's folder.
9
+
10
+ ## 0.12.138
11
+
12
+ ### Patch Changes
13
+
14
+ - 6f11bb5: A deployed Cloudflare Worker is always production. The adapter now pins `process.env.NODE_ENV` to `"production"` in the bundle, and core's `isProduction()` reads the literal `process.env.NODE_ENV` so that pin takes effect. Before, a Worker had no `NODE_ENV`, so `isProduction()` was false and every 5xx response returned the error message and stack trace to the client. `process.env.NODE_ENV` now wins over a variables service when it is set.
15
+ - 0c67e33: `@pikku/core` no longer imports `node:async_hooks`, so it is edge-clean end to end. The function abort scope that `beginChanges()` observes is now passed explicitly (agent tool `execute(input, { abortScope })` onto `wire.abortScope`, carried through nested `rpc` calls) instead of living in `AsyncLocalStorage`, which also means concurrent runs in one process or isolate cannot see each other's scope. `AgentToolDef.execute` gains an optional second argument; nothing else in the public API changes.
16
+ - 15c4968: The default runtime path no longer needs `node:*`, `Buffer` or an unguarded `process`, so it loads on edge runtimes (Web APIs only).
17
+
18
+ **Breaking:** `FileScenarioRunStore`, `scenarioArtifactContentType` and `scenarioRunSummary` leave the `@pikku/core/services` barrel because they use `node:fs`. Import them from `@pikku/core/services/file-scenario-run-store`. Pre-0.13 breaking changes still ship as a patch.
19
+
20
+ **Breaking:** the HMAC helpers in `@pikku/core/hmac` run on Web Crypto, which is async. `hmacSha256Hex`, `hmacDigest`, `verifyHmacSignature` and `verifyPublicKeySignature` now return a Promise; `timingSafeStringEqual` stays synchronous. `WebhookService.sign` and `WebhookService.verify` return a Promise, and so does `verifySlackSignature` in `@pikku/gateway-slack`: `await` them. A function-style `verify` on a webhook source may return a Promise as well. `verifyPublicKeySignature` accepts RSA and EC (P-256, P-384, P-521) PEM keys; Ed25519 and RSA-PSS keys verify as false.
21
+
22
+ `isProduction()` (and the `PIKKU_MODEL_ALIASES` and `PIKKU_ENV` reads) now ask the registered singleton services' `variables` service first, then `process.env` when `process` exists. With neither, `isProduction()` is true, so an edge deployment never exposes error details by accident. `LocalVariablesService` resolves its `process.env` default when constructed and falls back to an empty store without `process`.
23
+
24
+ Workflow invocation ids use a built-in SHA-1 and stay byte-identical. `@pikku/kysely` and the Cloudflare hibernation server use `globalThis.crypto.randomUUID()` and no `Buffer`; `@pikku/kysely` is marked `sideEffects: false`. `@pikku/ai-deepinfra` reads `DEEPINFRA_API_KEY` only when `process` exists and decodes base64 audio without `Buffer`.
25
+
26
+ Runtime tiers. A package declares where it can run in `package.json`: `"pikku": { "runtime": "edge" | "serverless" | "server" }`, with optional per-subpath overrides (`"exports": { "./dev": "server" }`). `server` is the default. `@pikku/core` and the default services are `edge` (core's Node-only subpaths are `server`), `@pikku/better-auth`, `@pikku/addon-admin` and `@pikku/cloudflare` are `serverless` (better-auth statically imports `node:crypto` scrypt), and `@pikku/addon-console` is `server` and must never land in a deployed unit.
27
+
28
+ `pikku deploy` now verifies each deployed unit before bundling it. The unit is bundled with esbuild (neutral platform, `workerd`/`worker`/`browser` conditions) under its tier's profile, ahead of the adapter's aliases and stubs so they cannot hide anything, and the build fails when the bundle holds a package declared above the unit's tier or a Node built-in the runtime does not provide. The error names the unit, package, import chain and the fix. A built-in the provider stubs is a warning. On an edge unit, a bundled `InMemory*` class also fails the build. The unit's tier is its `runtime`, else the project's own `pikku.runtime`, else the provider default (`serverless` on Cloudflare). Packages that declare nothing are judged by their imports only.
29
+
30
+ `ProviderAdapter.getRuntimeProfile(tier?)` returns the compat date, compat flags, the built-ins the runtime provides (keyed by compat date), the stubbed ones and the externals. The Cloudflare adapter derives `wrangler.toml`, the upload metadata and the bundle externals from it; `node:os` and `node:fs` are not provided before compat date 2025-09-15. The default is `nodejs_compat_v2` with compat date 2024-12-18, which is what `wrangler.toml` and Fabric's live workers already use, so Fabric deploys see no runtime change. Only the direct-API upload in `@pikku/deploy-cloudflare` (`createWorker`, used by its own `deploy()`) moves: it sent `nodejs_compat` with 2024-01-01 before. A newer date or flag is opt-in through the adapter option `new CloudflareProviderAdapter({ compatDate })` or `getCloudflareRuntimeProfile(tier, compatDate)`. From 2025-08-15 `node:http` and `node:https` exist, and from 2025-09-15 `node:os`, `node:fs` (stubbed by the build regardless) and `node:perf_hooks`; the verifier's allowed built-ins follow the effective date. A newer date changes the runtime of every worker deployed with it.
31
+
32
+ `declareScopes(['pikku:console'])` (from `@pikku/core/scope`) declares scopes by id without wiring anything. `wireAddon({ scopes })` only requires a scope of an addon's functions; the tree arrives with the addon's metadata, so a role granting `pikku:console` used to fail PKU124 unless the addon was wired, and a wired `@pikku/addon-console` lands in every unit carrying the `/rpc` catch-all. An app that grants the scope without the console now calls `declareScopes` and no longer needs the scaffold or the addon. When the addon is wired its tree and descriptions win and the bare id adds nothing.
33
+
34
+ More packages declare a tier after bundling each with the edge profile. `edge`: `@pikku/addon-graph`, `@pikku/pino`, `@pikku/jose`, `@pikku/ai-vercel`, `@pikku/ai-voice`. `serverless`: `@pikku/kysely-postgres` (postgres.js needs `node:buffer`, `stream`, `events`), `@pikku/backblaze` (`copyFile` reads a local file, which the build stubs), `@pikku/gateway-slack` (`@slack/web-api` imports `node:os` and `zlib`). `server`: `@pikku/schedule` (`cron` imports `child_process`), `@pikku/aws-services` (the AWS SDK's Node handler needs `node:http2`), `@pikku/browser` (puppeteer), and `@pikku/modelcontextprotocol` except its `./fetch` subpath, which is `edge`.
35
+
36
+ `@pikku/addon-graph` no longer needs `node:crypto` or `Buffer`: the Crypto function hashes and HMACs on Web Crypto (MD5 is built in, since Web Crypto has none) and encodes with `TextEncoder`/`atob`/`btoa`, with output unchanged.
37
+
38
+ - d75a23d: Await previously un-awaited promises (session setters, channel sends, schema compile, trigger and CLI renderer calls) and handle rejections in fire-and-forget timers and signal handlers, found by type-aware `no-floating-promises` / `no-misused-promises`.
39
+ - 9b12c8a: Harden webhook signature verification. An empty or missing secret now fails closed at every call site (`verifyHmacSignature`, webhook source verification, `WebhookService.verify`, `verifySlackSignature`), including a signature computed over an empty key. HMAC verification now decodes the signature to bytes and compares with `crypto.subtle.verify('HMAC', ...)` instead of a pure-JS string compare, and a malformed hex signature is rejected. `verifyPublicKeySignature` accepts Ed25519 SPKI keys again.
40
+
1
41
  ## 0.12.137
2
42
 
3
43
  ### Patch Changes
@@ -105,14 +105,17 @@ export async function pikkuDevReloader(options) {
105
105
  pendingChanges.add(filePath);
106
106
  if (debounceTimer)
107
107
  clearTimeout(debounceTimer);
108
- debounceTimer = setTimeout(async () => {
108
+ const flush = async () => {
109
109
  const files = [...pendingChanges];
110
110
  pendingChanges.clear();
111
111
  for (const file of files) {
112
112
  postCodegenQueue.add(file);
113
113
  await safeHandleFileChange(file);
114
114
  }
115
- }, 50);
115
+ };
116
+ // Fire-and-forget timer callback: safeHandleFileChange catches and logs
117
+ // every error, so nothing can reject here.
118
+ debounceTimer = setTimeout(() => void flush(), 50);
116
119
  };
117
120
  for (const srcDir of absSrcDirs) {
118
121
  try {
package/dist/env.d.ts CHANGED
@@ -1 +1,19 @@
1
+ /**
2
+ * Reads one variable without `process`, which edge runtimes do not have.
3
+ *
4
+ * Looks in the registered singleton services' `variables` service first, then
5
+ * in `process.env` when there is one. Never throws: services that were never
6
+ * registered (a unit test, an early boot path) simply fall through, which is
7
+ * why this reads the state directly instead of calling
8
+ * `getSingletonServices()`.
9
+ *
10
+ * The read is synchronous, so a `VariablesService` that answers with a Promise
11
+ * is ignored and the lookup falls through to `process.env`.
12
+ */
13
+ export declare const readEnvVariable: (name: string) => string | undefined;
14
+ /**
15
+ * `NODE_ENV` decides whether error details are exposed. With neither a
16
+ * variables service nor `process` to say, this is production: failing closed
17
+ * means an edge deployment never leaks error details by accident.
18
+ */
1
19
  export declare const isProduction: () => boolean;
package/dist/env.js CHANGED
@@ -1 +1,60 @@
1
- export const isProduction = () => process.env.NODE_ENV === 'production';
1
+ import { pikkuState } from './pikku-state.js';
2
+ /**
3
+ * Reads one variable without `process`, which edge runtimes do not have.
4
+ *
5
+ * Looks in the registered singleton services' `variables` service first, then
6
+ * in `process.env` when there is one. Never throws: services that were never
7
+ * registered (a unit test, an early boot path) simply fall through, which is
8
+ * why this reads the state directly instead of calling
9
+ * `getSingletonServices()`.
10
+ *
11
+ * The read is synchronous, so a `VariablesService` that answers with a Promise
12
+ * is ignored and the lookup falls through to `process.env`.
13
+ */
14
+ export const readEnvVariable = (name) => {
15
+ try {
16
+ const variables = pikkuState(null, 'package', 'singletonServices')?.variables;
17
+ if (variables) {
18
+ const value = variables.get(name);
19
+ if (value instanceof Promise) {
20
+ value.catch(() => { });
21
+ }
22
+ else if (typeof value === 'string' ||
23
+ typeof value === 'number' ||
24
+ typeof value === 'boolean') {
25
+ return String(value);
26
+ }
27
+ }
28
+ }
29
+ catch {
30
+ // A faulty variables service must not take error handling down with it.
31
+ }
32
+ return hasProcessEnv() ? globalThis.process.env[name] : undefined;
33
+ };
34
+ const hasProcessEnv = () => typeof globalThis.process?.env === 'object';
35
+ /**
36
+ * The literal `process.env.NODE_ENV`, kept as a literal on purpose: a deploy
37
+ * adapter pins it with a bundler `define` (a Cloudflare Worker is always
38
+ * production), and `define` only rewrites this exact expression, not
39
+ * `process.env[name]`. Without that define it reads the real value, and on a
40
+ * runtime with no `process` the ReferenceError means "unknown".
41
+ */
42
+ const buildTimeNodeEnv = () => {
43
+ try {
44
+ return process.env.NODE_ENV;
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ };
50
+ /**
51
+ * `NODE_ENV` decides whether error details are exposed. With neither a
52
+ * variables service nor `process` to say, this is production: failing closed
53
+ * means an edge deployment never leaks error details by accident.
54
+ */
55
+ export const isProduction = () => {
56
+ const nodeEnv = buildTimeNodeEnv() ?? readEnvVariable('NODE_ENV');
57
+ if (nodeEnv !== undefined)
58
+ return nodeEnv === 'production';
59
+ return !hasProcessEnv();
60
+ };
@@ -14,10 +14,6 @@ export interface AbortScope {
14
14
  /** Called by `beginChanges()` once a function commits to mutating. */
15
15
  onBeginChanges?: () => void;
16
16
  }
17
- /** Run `fn` with an abort scope that `beginChanges()` will observe. */
18
- export declare const runInAbortScope: <T>(scope: AbortScope, fn: () => T) => T;
19
- /** The current scope, if the caller is running inside one. */
20
- export declare const getAbortScope: () => AbortScope | undefined;
21
17
  /**
22
18
  * Declare that everything after this line changes something.
23
19
  *
@@ -38,10 +34,15 @@ export declare const getAbortScope: () => AbortScope | undefined;
38
34
  * ignoring the answer, and the failure mode of ignoring it is a mutation nobody
39
35
  * asked for.
40
36
  *
37
+ * The scope is passed explicitly — the function runner binds it from
38
+ * `wire.abortScope` — rather than looked up ambiently, so concurrent runs in
39
+ * one process (or one edge isolate) can never see each other's scope and core
40
+ * needs no `AsyncLocalStorage`.
41
+ *
41
42
  * Entirely cooperative, and safe to call anywhere — outside a scope it is a
42
43
  * no-op, so a function does not need to know how it was invoked. A function
43
44
  * that never calls it keeps the conservative default: if it is not marked
44
45
  * `readonly`, an interrupt assumes it changed something and says so. The tool
45
46
  * that forgot to call this is exactly the one that cannot be assumed harmless.
46
47
  */
47
- export declare const beginChanges: () => Promise<void>;
48
+ export declare const beginChanges: (scope?: AbortScope) => Promise<void>;
@@ -1,4 +1,3 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks';
2
1
  /**
3
2
  * Raised by {@link beginChanges} when the caller has gone away before anything
4
3
  * was changed. Distinct from a failure: nothing happened, so there is nothing
@@ -12,20 +11,6 @@ export class AbandonedError extends Error {
12
11
  this.name = 'AbandonedError';
13
12
  }
14
13
  }
15
- /**
16
- * The ambient "is my caller still there?" signal.
17
- *
18
- * Ambient rather than a parameter because the question is the same one for
19
- * every wiring — an agent run that was interrupted, an HTTP request whose
20
- * client disconnected, a cancelled workflow — and threading it through every
21
- * signature would mean changing every function that merely sits between the
22
- * wiring and the mutation.
23
- */
24
- const scopeStorage = new AsyncLocalStorage();
25
- /** Run `fn` with an abort scope that `beginChanges()` will observe. */
26
- export const runInAbortScope = (scope, fn) => scopeStorage.run(scope, fn);
27
- /** The current scope, if the caller is running inside one. */
28
- export const getAbortScope = () => scopeStorage.getStore();
29
14
  /**
30
15
  * Declare that everything after this line changes something.
31
16
  *
@@ -46,14 +31,18 @@ export const getAbortScope = () => scopeStorage.getStore();
46
31
  * ignoring the answer, and the failure mode of ignoring it is a mutation nobody
47
32
  * asked for.
48
33
  *
34
+ * The scope is passed explicitly — the function runner binds it from
35
+ * `wire.abortScope` — rather than looked up ambiently, so concurrent runs in
36
+ * one process (or one edge isolate) can never see each other's scope and core
37
+ * needs no `AsyncLocalStorage`.
38
+ *
49
39
  * Entirely cooperative, and safe to call anywhere — outside a scope it is a
50
40
  * no-op, so a function does not need to know how it was invoked. A function
51
41
  * that never calls it keeps the conservative default: if it is not marked
52
42
  * `readonly`, an interrupt assumes it changed something and says so. The tool
53
43
  * that forgot to call this is exactly the one that cannot be assumed harmless.
54
44
  */
55
- export const beginChanges = async () => {
56
- const scope = scopeStorage.getStore();
45
+ export const beginChanges = async (scope) => {
57
46
  if (!scope)
58
47
  return;
59
48
  if (scope.abandoned) {
@@ -235,7 +235,8 @@ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServic
235
235
  // and offering it there would invite the contradiction of a `readonly`
236
236
  // function announcing where its changes begin.
237
237
  if (!funcMeta.readonly) {
238
- invocationWire.beginChanges = beginChanges;
238
+ const abortScope = resolvedWire.abortScope;
239
+ invocationWire.beginChanges = () => beginChanges(abortScope);
239
240
  }
240
241
  // knowledge: decisions/security/addon-scopes-are-resolved-where-the-function-runs.md
241
242
  const addonScopes = resolveAddonScopes(packageName, addonInstance?.namespace);
@@ -0,0 +1,19 @@
1
+ export declare const ADDON_HEADER = "x-pikku-addon";
2
+ /**
3
+ * Narrows the session to what an addon is allowed to do. A call that names an
4
+ * addon in `x-pikku-addon` keeps only the scopes both the viewer and the
5
+ * addon's role hold, so an addon's screens can never act beyond what it
6
+ * declared, whoever is looking at them.
7
+ *
8
+ * The header is a selector, not a credential: naming an addon can only
9
+ * reduce a session, so a caller who sends it by hand has capped themselves. An
10
+ * addon the host does not know is refused, because ignoring it would let a
11
+ * misspelt name run uncapped.
12
+ *
13
+ * Register it after the session middleware: it narrows a session that is
14
+ * already resolved, and a call with no session has nothing to narrow.
15
+ */
16
+ export declare const addonScopeCap: import("./middleware.types.js").CorePikkuMiddlewareFactory<{
17
+ /** The scopes of the addon's role, or `undefined` when no such addon is installed. */
18
+ resolve: (name: string) => readonly string[] | undefined | Promise<readonly string[] | undefined>;
19
+ }>;
@@ -0,0 +1,34 @@
1
+ import { ForbiddenError } from '../errors/errors.js';
2
+ import { intersectScopes } from '../scopes.js';
3
+ import { pikkuMiddleware, pikkuMiddlewareFactory, } from './middleware-factories.js';
4
+ export const ADDON_HEADER = 'x-pikku-addon';
5
+ /**
6
+ * Narrows the session to what an addon is allowed to do. A call that names an
7
+ * addon in `x-pikku-addon` keeps only the scopes both the viewer and the
8
+ * addon's role hold, so an addon's screens can never act beyond what it
9
+ * declared, whoever is looking at them.
10
+ *
11
+ * The header is a selector, not a credential: naming an addon can only
12
+ * reduce a session, so a caller who sends it by hand has capped themselves. An
13
+ * addon the host does not know is refused, because ignoring it would let a
14
+ * misspelt name run uncapped.
15
+ *
16
+ * Register it after the session middleware: it narrows a session that is
17
+ * already resolved, and a call with no session has nothing to narrow.
18
+ */
19
+ export const addonScopeCap = pikkuMiddlewareFactory(({ resolve }) => pikkuMiddleware(async (_services, { http, setSession, getSession }, next) => {
20
+ const name = http?.request?.header(ADDON_HEADER);
21
+ const session = await getSession?.();
22
+ if (!name || !setSession || !session) {
23
+ return next();
24
+ }
25
+ const role = await resolve(name);
26
+ if (!role) {
27
+ throw new ForbiddenError(`Unknown addon '${name}'`);
28
+ }
29
+ await setSession({
30
+ ...session,
31
+ scopes: intersectScopes(session.scopes, role),
32
+ });
33
+ return next();
34
+ }));
@@ -19,7 +19,7 @@ export const authAPIKey = pikkuMiddlewareFactory(({ source }) => pikkuMiddleware
19
19
  if (apiKey && jwtService) {
20
20
  const userSession = await jwtService.decode(apiKey);
21
21
  if (userSession) {
22
- setSession?.(userSession);
22
+ await setSession?.(userSession);
23
23
  }
24
24
  }
25
25
  return next();
@@ -48,7 +48,7 @@ export const authBearer = pikkuMiddlewareFactory(({ token } = {}) => pikkuMiddle
48
48
  userSession = await jwtService.decode(bearerToken);
49
49
  }
50
50
  if (userSession) {
51
- setSession?.(userSession);
51
+ await setSession?.(userSession);
52
52
  }
53
53
  }
54
54
  return next();
@@ -47,7 +47,7 @@ export const authCookie = pikkuMiddlewareFactory(({ name, options, expiresIn })
47
47
  // standard JWT timestamp, so an identity-less token is treated as no
48
48
  // session rather than as an authenticated one.
49
49
  if (userSession && hasSessionIdentity(userSession)) {
50
- setSession?.(userSession);
50
+ await setSession?.(userSession);
51
51
  }
52
52
  }
53
53
  await next();
@@ -1,6 +1,7 @@
1
1
  export { authAPIKey } from './auth-apikey.js';
2
2
  export { authCookie } from './auth-cookie.js';
3
3
  export { authBearer } from './auth-bearer.js';
4
+ export { addonScopeCap, ADDON_HEADER, } from './addon-scope-cap.js';
4
5
  export { remoteJobsSecret } from './remote-jobs-secret.js';
5
6
  export { pikkuRemoteAuthMiddleware } from './remote-auth.js';
6
7
  export { cors } from './cors.js';
@@ -1,6 +1,7 @@
1
1
  export { authAPIKey } from './auth-apikey.js';
2
2
  export { authCookie } from './auth-cookie.js';
3
3
  export { authBearer } from './auth-bearer.js';
4
+ export { addonScopeCap, ADDON_HEADER, } from './addon-scope-cap.js';
4
5
  export { remoteJobsSecret } from './remote-jobs-secret.js';
5
6
  export { pikkuRemoteAuthMiddleware } from './remote-auth.js';
6
7
  export { cors } from './cors.js';
package/dist/schema.js CHANGED
@@ -18,7 +18,9 @@ export const compileAllSchemas = (logger, schemaService) => {
18
18
  for (const [pkgName, packageState] of getAllPackageStates()) {
19
19
  const resolvedPkgName = pkgName === '__main__' ? null : pkgName;
20
20
  for (const [name, schema] of packageState.misc.schemas) {
21
- schemaService.compileSchema(schemaKey(name, resolvedPkgName), schema);
21
+ // compileSchema may be async; this function is synchronous (public
22
+ // signature), so a rejection is logged instead of left unhandled.
23
+ Promise.resolve(schemaService.compileSchema(schemaKey(name, resolvedPkgName), schema)).catch((e) => logger.error(`Failed to compile schema '${name}': ${e?.message ?? e}`));
22
24
  }
23
25
  }
24
26
  validateAllSchemasLoaded(logger, schemaService);
package/dist/scopes.d.ts CHANGED
@@ -21,3 +21,11 @@ export declare const hasAnyScope: (candidates: readonly string[] | undefined, he
21
21
  * nothing.
22
22
  */
23
23
  export declare const verifyScopes: (required: readonly string[] | undefined, session: CoreUserSession | undefined) => void;
24
+ /**
25
+ * The scopes both `held` and `cap` grant, by the same parent and wildcard rules
26
+ * as {@link hasScopes}. Where one side is broader, the narrower grant is kept:
27
+ * a holder of `invoices:*` capped to `invoices:read` ends with `invoices:read`,
28
+ * and a holder of `invoices:read` capped to `invoices:*` keeps `invoices:read`.
29
+ * Fails closed: nothing held, or an empty cap, yields nothing.
30
+ */
31
+ export declare const intersectScopes: (held: Iterable<string> | undefined, cap: Iterable<string> | undefined) => string[];
package/dist/scopes.js CHANGED
@@ -64,3 +64,26 @@ export const verifyScopes = (required, session) => {
64
64
  throw new MissingScopeError(missing);
65
65
  }
66
66
  };
67
+ /**
68
+ * The scopes both `held` and `cap` grant, by the same parent and wildcard rules
69
+ * as {@link hasScopes}. Where one side is broader, the narrower grant is kept:
70
+ * a holder of `invoices:*` capped to `invoices:read` ends with `invoices:read`,
71
+ * and a holder of `invoices:read` capped to `invoices:*` keeps `invoices:read`.
72
+ * Fails closed: nothing held, or an empty cap, yields nothing.
73
+ */
74
+ export const intersectScopes = (held, cap) => {
75
+ const heldSet = new Set(held ?? []);
76
+ const capSet = new Set(cap ?? []);
77
+ const result = new Set();
78
+ for (const scope of heldSet) {
79
+ if (holds(capSet, scope)) {
80
+ result.add(scope);
81
+ }
82
+ }
83
+ for (const scope of capSet) {
84
+ if (holds(heldSet, scope)) {
85
+ result.add(scope);
86
+ }
87
+ }
88
+ return [...result];
89
+ };
@@ -1,6 +1,8 @@
1
+ export type ContentVisibility = 'private' | 'public';
1
2
  export interface SignContentKeyArgs<TBucket extends string = string> {
2
3
  bucket: TBucket;
3
4
  contentKey: string;
5
+ visibility?: ContentVisibility;
4
6
  dateLessThan: Date;
5
7
  dateGreaterThan?: Date;
6
8
  }
@@ -14,7 +16,7 @@ export interface GetUploadURLArgs<TBucket extends string = string> {
14
16
  fileKey: string;
15
17
  contentType: string;
16
18
  size?: number;
17
- visibility?: 'private' | 'public';
19
+ visibility?: ContentVisibility;
18
20
  }
19
21
  export interface UploadURLResult {
20
22
  uploadUrl: string;
@@ -25,6 +27,10 @@ export interface UploadURLResult {
25
27
  export interface BucketKeyArgs<TBucket extends string = string> {
26
28
  bucket: TBucket;
27
29
  key: string;
30
+ visibility?: ContentVisibility;
31
+ }
32
+ export interface GetDownloadURLArgs<TBucket extends string = string> extends BucketKeyArgs<TBucket> {
33
+ expiresInSeconds?: number;
28
34
  }
29
35
  export interface WriteFileArgs<TBucket extends string = string> extends BucketKeyArgs<TBucket> {
30
36
  stream: ReadableStream | NodeJS.ReadableStream;
@@ -42,4 +48,10 @@ export interface ContentService<TBucket extends string = string> {
42
48
  copyFile(args: CopyFileArgs<TBucket>): Promise<boolean>;
43
49
  readFile(args: BucketKeyArgs<TBucket>): Promise<ReadableStream | NodeJS.ReadableStream>;
44
50
  readFileAsBuffer(args: BucketKeyArgs<TBucket>): Promise<Buffer>;
51
+ /** A signed URL for private content, the plain URL for public content. */
52
+ getDownloadURL(args: GetDownloadURLArgs<TBucket>): Promise<string>;
53
+ /** Returns how many files were deleted. */
54
+ deleteByPrefix(bucket: TBucket, prefix: string, visibility?: ContentVisibility): Promise<number>;
55
+ /** Returns the keys, relative to the bucket, that start with the prefix. */
56
+ listFilesByPrefix(bucket: TBucket, prefix: string, visibility?: ContentVisibility): Promise<string[]>;
45
57
  }
@@ -24,7 +24,8 @@ export class InMemoryQueueService {
24
24
  }
25
25
  catch (e) {
26
26
  if (attemptsMade < maxAttempts) {
27
- setTimeout(runAttempt, this.backoffDelay(options?.backoff, attemptsMade));
27
+ // Fire-and-forget retry: runAttempt catches every error itself.
28
+ setTimeout(() => void runAttempt(), this.backoffDelay(options?.backoff, attemptsMade));
28
29
  }
29
30
  else {
30
31
  console.error(`[InMemoryQueue] Job ${jobId} on ${queueName} failed after ${attemptsMade} attempt(s):`, e.message);
@@ -32,7 +33,8 @@ export class InMemoryQueueService {
32
33
  }
33
34
  };
34
35
  const delay = options?.delay ?? 100 + Math.floor(Math.random() * 201);
35
- setTimeout(runAttempt, delay);
36
+ // Fire-and-forget: runAttempt catches every error itself.
37
+ setTimeout(() => void runAttempt(), delay);
36
38
  return jobId;
37
39
  }
38
40
  backoffDelay(backoff, attemptsMade) {
@@ -1,4 +1,3 @@
1
- import { randomUUID } from 'crypto';
2
1
  import { PikkuWorkflowService } from '../wirings/workflow/pikku-workflow-service.js';
3
2
  import { isExpectedError } from '../errors/error-handler.js';
4
3
  import { isStepLeaseLive } from '../wirings/workflow/workflow-constants.js';
@@ -17,7 +16,7 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
17
16
  branchKeys = new Map(); // keyed by stepId
18
17
  workflowVersions = new Map(); // keyed by `${name}:${graphHash}`
19
18
  async createRunImpl(workflowName, input, inline, graphHash, wire, options) {
20
- const runId = randomUUID();
19
+ const runId = globalThis.crypto.randomUUID();
21
20
  const now = new Date();
22
21
  const run = {
23
22
  id: runId,
@@ -41,7 +40,7 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
41
40
  return runId;
42
41
  }
43
42
  async scheduleSleep(runId, stepId, duration) {
44
- const timer = setTimeout(async () => {
43
+ const resume = async () => {
45
44
  this.sleepTimers.delete(timer);
46
45
  try {
47
46
  await this.executeWorkflowSleepCompleted(runId, stepId);
@@ -49,7 +48,9 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
49
48
  catch (error) {
50
49
  this.logger?.error(`Failed to resume workflow sleep for runId ${runId}: ${error?.message ?? error}`);
51
50
  }
52
- }, duration);
51
+ };
52
+ // Fire-and-forget timer callback: resume catches and logs every error.
53
+ const timer = setTimeout(() => void resume(), duration);
53
54
  this.sleepTimers.add(timer);
54
55
  return true;
55
56
  }
@@ -71,7 +72,7 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
71
72
  }
72
73
  }
73
74
  async insertStepStateImpl(runId, stepName, rpcName, data, stepOptions, fromStepName) {
74
- const stepId = randomUUID();
75
+ const stepId = globalThis.crypto.randomUUID();
75
76
  const now = new Date();
76
77
  const step = {
77
78
  stepId,
@@ -202,7 +203,7 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
202
203
  throw new Error(`Step not found: ${failedStepId}`);
203
204
  }
204
205
  const failedStepData = this.stepData.get(failedStepId);
205
- const newStepId = randomUUID();
206
+ const newStepId = globalThis.crypto.randomUUID();
206
207
  const now = new Date();
207
208
  const newStep = {
208
209
  stepId: newStepId,
@@ -1,6 +1,7 @@
1
1
  export { LogLevel } from './logger.js';
2
2
  export { ScopedSecretService } from './scoped-secret-service.js';
3
3
  export { ScopedCredentialService } from './scoped-credential-service.js';
4
+ export { ScopedContentService } from './scoped-content-service.js';
4
5
  export { PikkuSessionService, createMiddlewareSessionWireProps, } from './user-session-service.js';
5
6
  export { TypedSecretService } from './typed-secret-service.js';
6
7
  export { PikkuCredentialWireService } from './credential-wire-service.js';
@@ -21,8 +22,7 @@ export { LeaseLostError, LeaseTakenError, holdLease, type Lease, type LeaseServi
21
22
  export { InMemoryLeaseService } from './in-memory-lease-service.js';
22
23
  export { InMemoryAgentRunStateService } from './in-memory-agent-run-state-service.js';
23
24
  export { LocalGatewayService } from './local-gateway-service.js';
24
- export { FileScenarioRunStore, scenarioArtifactContentType, scenarioRunSummary, type FileScenarioRunStoreOptions, } from './file-scenario-run-store.js';
25
- export type { ContentService, SignContentKeyArgs, SignURLArgs, GetUploadURLArgs, UploadURLResult, BucketKeyArgs, WriteFileArgs, CopyFileArgs, } from './content-service.js';
25
+ export type { ContentService, ContentVisibility, GetDownloadURLArgs, SignContentKeyArgs, SignURLArgs, GetUploadURLArgs, UploadURLResult, BucketKeyArgs, WriteFileArgs, CopyFileArgs, } from './content-service.js';
26
26
  export type { ScenarioPersona, ResolvedPersona, ScenarioPersonas, } from './personas-service.js';
27
27
  export type { JWTService } from './jwt-service.js';
28
28
  export type { EmailAttachment, EmailService, SendEmailInput, SendEmailResult, SendHTMLEmailInput, SendTemplateEmailInput, SendTextEmailInput, } from './email-service.js';
@@ -1,6 +1,7 @@
1
1
  export { LogLevel } from './logger.js';
2
2
  export { ScopedSecretService } from './scoped-secret-service.js';
3
3
  export { ScopedCredentialService } from './scoped-credential-service.js';
4
+ export { ScopedContentService } from './scoped-content-service.js';
4
5
  export { PikkuSessionService, createMiddlewareSessionWireProps, } from './user-session-service.js';
5
6
  export { TypedSecretService } from './typed-secret-service.js';
6
7
  export { PikkuCredentialWireService } from './credential-wire-service.js';
@@ -21,7 +22,6 @@ export { LeaseLostError, LeaseTakenError, holdLease, } from './lease-service.js'
21
22
  export { InMemoryLeaseService } from './in-memory-lease-service.js';
22
23
  export { InMemoryAgentRunStateService } from './in-memory-agent-run-state-service.js';
23
24
  export { LocalGatewayService } from './local-gateway-service.js';
24
- export { FileScenarioRunStore, scenarioArtifactContentType, scenarioRunSummary, } from './file-scenario-run-store.js';
25
25
  export { renderEmail, } from './email-template.js';
26
26
  export { DEFAULT_WEBHOOK_RETRIES, PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME, WebhookService, } from './webhook-service.js';
27
27
  export { ACTOR_ROOT_SECRET_MIN_LENGTH, ACTOR_SECRET_INFO, ACTOR_SECRET_NAME, actorSecretSubject, deriveActorSecret, verifyActorSecret, } from './persona-actor-secret.js';