@pikku/core 0.12.31 → 0.12.33

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 (45) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/dist/function/function-runner.js +5 -0
  3. package/dist/middleware-runner.js +4 -1
  4. package/dist/pikku-state.js +1 -0
  5. package/dist/services/gopass-secrets.d.ts +1 -1
  6. package/dist/services/local-secrets.d.ts +1 -1
  7. package/dist/services/local-variables.d.ts +1 -0
  8. package/dist/services/local-variables.js +9 -0
  9. package/dist/services/scoped-secret-service.d.ts +1 -1
  10. package/dist/services/scoped-secret-service.js +2 -2
  11. package/dist/services/secret-service.d.ts +6 -2
  12. package/dist/services/temporary-file-service.d.ts +20 -0
  13. package/dist/services/temporary-file-service.js +63 -0
  14. package/dist/services/typed-secret-service.d.ts +1 -1
  15. package/dist/services/typed-variables-service.d.ts +1 -0
  16. package/dist/services/typed-variables-service.js +3 -0
  17. package/dist/services/variables-service.d.ts +9 -0
  18. package/dist/types/state.types.d.ts +3 -0
  19. package/dist/wirings/http/http-runner.js +4 -1
  20. package/dist/wirings/http/pikku-fetch-http-request.js +34 -20
  21. package/dist/wirings/http/web-request.js +6 -1
  22. package/dist/wirings/workflow/pikku-workflow-service.js +3 -0
  23. package/package.json +2 -1
  24. package/src/function/function-runner.ts +4 -0
  25. package/src/middleware-runner.test.ts +16 -1
  26. package/src/middleware-runner.ts +4 -1
  27. package/src/pikku-state.ts +1 -0
  28. package/src/services/gopass-secrets.ts +4 -2
  29. package/src/services/local-secrets.ts +4 -2
  30. package/src/services/local-variables.ts +11 -0
  31. package/src/services/scoped-secret-service.test.ts +20 -0
  32. package/src/services/scoped-secret-service.ts +5 -3
  33. package/src/services/secret-service.ts +8 -2
  34. package/src/services/temporary-file-service.ts +76 -0
  35. package/src/services/typed-secret-service.ts +4 -2
  36. package/src/services/typed-variables-service.ts +6 -0
  37. package/src/services/variables-service.ts +11 -0
  38. package/src/types/state.types.ts +5 -0
  39. package/src/wirings/http/http-runner.test.ts +13 -0
  40. package/src/wirings/http/http-runner.ts +4 -1
  41. package/src/wirings/http/pikku-fetch-http-request.ts +37 -20
  42. package/src/wirings/http/web-request.test.ts +2 -2
  43. package/src/wirings/http/web-request.ts +6 -1
  44. package/src/wirings/workflow/pikku-workflow-service.ts +3 -0
  45. package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,104 @@
1
+ ## 0.12.33
2
+
3
+ ### Patch Changes
4
+
5
+ - 5c67b7e: Add a dedicated `@pikku/core/services/temporary-file-service` export for the Node filesystem-backed temporary file service without routing it through the `services` barrel.
6
+ - 1b22977: fix(workflow): propagate pikkuUserId and session to child workflow wires
7
+
8
+ When a workflow calls `workflow.do()` on a sub-workflow, the child wire was created
9
+ without `pikkuUserId`. This meant that `pikkuFunc` steps inside the child workflow
10
+ could not resolve a session — `resolveSession` had nothing to look up, causing
11
+ `ForbiddenError` for authenticated steps.
12
+
13
+ Two fixes:
14
+ - `childWire` now copies `pikkuUserId` from the parent RPC service's wire, so remote
15
+ queue workers can re-hydrate the session from the session store.
16
+ - `orchestrateWorkflow` now propagates `session` from the parent RPC wire into the
17
+ child workflow's execution wire, so inline execution gets the session directly
18
+ without an extra session-store round-trip.
19
+
20
+ ## 0.12.32
21
+
22
+ ### Patch Changes
23
+
24
+ - a027a8e: feat: emit auth provider + plugin metadata as `auth-meta.gen.json` for the console SSO page
25
+
26
+ The enabled social providers and Better Auth plugins are now extracted statically
27
+ and written to a generated `auth-meta.gen.json`, replacing the runtime
28
+ `setAuthRegistry`/`getAuthRegistry` approach — so the console can show them without
29
+ evaluating the Better Auth factory.
30
+ - **inspector**: the `pikkuBetterAuth` inspector now reads the `plugins` array from
31
+ the `betterAuth({ ... })` config and records each plugin id (the callee name of
32
+ each `plugins: [organization(), bearer()]` entry) on the auth definition.
33
+ - **cli**: `pikku auth` (and `pikku all`) emit `auth/pikku-auth-meta.gen.json` (path
34
+ configurable via `authMetaJsonFile`) containing `basePath`, `hasCredentials`, the
35
+ enabled `providers` (`id` + `displayName` + `secretId`), and the enabled `plugins`
36
+ (`id` + `displayName`). The previous `setAuthRegistry(...)` runtime wiring is
37
+ removed from the generated `auth.gen.ts`.
38
+ - **better-auth**: exports a `PLUGIN_REGISTRY` and `pluginDisplayName(id)` helper so
39
+ plugin ids resolve to human-readable names.
40
+ - **core**: removes the unreleased `setAuthRegistry`/`getAuthRegistry` runtime auth
41
+ registry (now superseded by `auth-meta.gen.json`).
42
+ - **addon-console**: `getAuthProviders` reads `auth-meta.gen.json` and returns the
43
+ configured providers, plugins, and `hasCredentials` flag.
44
+ - **console**: the Auth Providers (SSO) page fetches `console:getAuthProviders` and
45
+ marks each provider configured/unconfigured, lists email+password credentials as a
46
+ provider, and shows the enabled Better Auth plugins.
47
+
48
+ - a027a8e: fix: address Better Auth review findings (secret/variable batch typing, auth init, guards)
49
+ - **core**: `SecretService.getSecrets` / `VariablesService.getVariables` (and the
50
+ Local/Typed/Scoped/AWS implementations) now return `Partial<T>`, honestly
51
+ reflecting that missing keys are omitted at runtime rather than typing partial
52
+ data as fully populated. `ScopedSecretService.getSecrets` now throws on a
53
+ disallowed key instead of silently filtering it out.
54
+ - **cli**: the generated `services.auth()` thunk clears its memoised promise on
55
+ rejection, so a transient Better Auth/Kysely startup failure no longer
56
+ permanently poisons auth for the process lifetime.
57
+ - **inspector**: the `pikkuBetterAuth` export guard now requires an exported
58
+ `const` (rejects `export let`/`export var`), matching its error message.
59
+ - **console**: the Microsoft auth provider's `callbackId` is `microsoft` (the
60
+ Better Auth provider id) rather than `microsoft-entra-id`.
61
+
62
+ - a027a8e: fix(core): compose repeated global middleware registrations instead of overwriting
63
+
64
+ `addHTTPMiddleware(pattern, …)` and `addTagMiddleware(tag, …)` stored the
65
+ middleware group with `groups[key] = middleware`, so a second registration for
66
+ the same pattern/tag silently replaced the first. With Better Auth, generated
67
+ `auth.gen.ts` registers `addHTTPMiddleware('*', [betterAuthSession()])`, which
68
+ clobbered an app's own `addHTTPMiddleware('*', [...])` global middleware (cors,
69
+ session, credential loading) and dropped it from every route.
70
+
71
+ Both now append to the existing group (matching `addGlobalMiddleware`, which
72
+ already appends), so generated auth middleware composes with user-registered
73
+ global middleware. The route meta lists each pattern once, so the combined
74
+ group is still applied a single time per request.
75
+
76
+ - a027a8e: feat(auth): migrate auth integration from Auth.js to Better Auth
77
+
78
+ The auth integration is now built on [Better Auth](https://better-auth.com)
79
+ and ships as a single package, `@pikku/better-auth` (replacing the former
80
+ `@pikku/auth-js`). There is exactly one auth package now.
81
+ - `pikkuBetterAuth(async ({ secrets, variables }) => betterAuth({ ... }))` is the new
82
+ single entry point. The CLI inspects the `betterAuth(...)` call and generates:
83
+ - `auth.gen.ts` — a catch-all `${basePath}{/*splat}` HTTP route per method and
84
+ a global `betterAuthSession({ auth })` middleware that bridges the Better
85
+ Auth session into the Pikku wire session.
86
+ - `auth-secrets.gen.ts` — `wireSecret(BETTER_AUTH_SECRET)` plus a
87
+ `<PROVIDER>_OAUTH` secret for each configured social provider, and
88
+ `wireVariable` for non-secret provider config (e.g. `MICROSOFT_TENANT_ID`,
89
+ `COGNITO_DOMAIN`/`REGION`/`USER_POOL_ID`).
90
+ - `auth.types.ts` — a typed `pikkuBetterAuth` re-export.
91
+ - `add-auth` (inspector) walks into the `betterAuth(...)` options to discover the
92
+ configured providers and required secrets/variables.
93
+ - The auth secret is now auto-wired by codegen from `BETTER_AUTH_SECRET` — it no
94
+ longer needs to be registered as a JWT signing key in `services.ts`.
95
+
96
+ CLI fix included: scaffold files generated outside `srcDirectories` (e.g. an
97
+ `auth.gen.ts` under a project's `pikku/` dir) are now added to the inspector's
98
+ wiring files, so their routes and secret metadata are picked up. The generated
99
+ wiring imports Pikku types via a resolved relative path instead of a hardcoded
100
+ `#pikku` specifier, so templates without a `#pikku` import map type-check.
101
+
1
102
  ## 0.12.31
2
103
 
3
104
  ### Patch Changes
@@ -29,6 +29,11 @@ async function resolveSession(wire, singletonServices, sessionService) {
29
29
  sessionService?.setInitial(stored);
30
30
  }
31
31
  }
32
+ else {
33
+ // Session already present on wire (e.g. propagated from parent workflow).
34
+ // Seed the sessionService so freezeInitial() returns it instead of undefined.
35
+ sessionService?.setInitial(wire.session);
36
+ }
32
37
  }
33
38
  /**
34
39
  * Get or create singleton services for an addon package.
@@ -69,7 +69,10 @@ const isSortedByPriority = (middlewares) => {
69
69
  */
70
70
  export const addTagMiddleware = (tag, middleware, packageName = null) => {
71
71
  const tagGroups = pikkuState(packageName, 'middleware', 'tagGroup');
72
- tagGroups[tag] = middleware;
72
+ const existing = tagGroups[tag];
73
+ tagGroups[tag] = existing
74
+ ? [...existing, ...middleware]
75
+ : middleware;
73
76
  return middleware;
74
77
  };
75
78
  /**
@@ -125,6 +125,7 @@ const createEmptyPackageState = () => ({
125
125
  package: {
126
126
  factories: null,
127
127
  singletonServices: null,
128
+ authFactory: null,
128
129
  credentialsMeta: null,
129
130
  requiredParentServices: null,
130
131
  },
@@ -11,5 +11,5 @@ export declare class GopassSecretService implements SecretService {
11
11
  hasSecret(key: string): Promise<boolean>;
12
12
  setSecret(key: string, value: unknown): Promise<void>;
13
13
  deleteSecret(key: string): Promise<void>;
14
- getSecrets(keys: string[]): Promise<Record<string, unknown>>;
14
+ getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<T>>;
15
15
  }
@@ -13,5 +13,5 @@ export declare class LocalSecretService implements SecretService {
13
13
  setSecret(key: string, value: unknown): Promise<void>;
14
14
  hasSecret(key: string): Promise<boolean>;
15
15
  deleteSecret(key: string): Promise<void>;
16
- getSecrets(keys: string[]): Promise<Record<string, unknown>>;
16
+ getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<T>>;
17
17
  }
@@ -3,6 +3,7 @@ export declare class LocalVariablesService implements VariablesService {
3
3
  private variables;
4
4
  constructor(variables?: Record<string, string | undefined>);
5
5
  getAll(): Record<string, string | undefined>;
6
+ getVariables<T extends Record<string, unknown> = Record<string, unknown>>(names: (keyof T & string)[]): Partial<T>;
6
7
  get<T = string>(name: string): T | undefined;
7
8
  set(name: string, value: unknown): void;
8
9
  has(name: string): boolean;
@@ -6,6 +6,15 @@ export class LocalVariablesService {
6
6
  getAll() {
7
7
  return this.variables || {};
8
8
  }
9
+ getVariables(names) {
10
+ const out = {};
11
+ for (const name of names) {
12
+ const value = this.get(name);
13
+ if (value !== undefined)
14
+ out[name] = value;
15
+ }
16
+ return out;
17
+ }
9
18
  get(name) {
10
19
  const raw = this.variables[name];
11
20
  if (raw === undefined)
@@ -12,5 +12,5 @@ export declare class ScopedSecretService implements SecretService {
12
12
  hasSecret(key: string): Promise<boolean>;
13
13
  setSecret(_key: string, _value: unknown): Promise<void>;
14
14
  deleteSecret(_key: string): Promise<void>;
15
- getSecrets(keys: string[]): Promise<Record<string, unknown>>;
15
+ getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<T>>;
16
16
  }
@@ -29,7 +29,7 @@ export class ScopedSecretService {
29
29
  throw new Error('deleteSecret is not allowed in scoped secret service');
30
30
  }
31
31
  async getSecrets(keys) {
32
- const allowed = keys.filter((k) => this.allowedKeys.has(k));
33
- return this.secrets.getSecrets(allowed);
32
+ keys.forEach((k) => this.assertAllowed(k));
33
+ return this.secrets.getSecrets(keys);
34
34
  }
35
35
  }
@@ -31,8 +31,12 @@ export interface SecretService {
31
31
  /**
32
32
  * Retrieves multiple secrets in a single batch operation.
33
33
  * Returns a map of key → value for successfully fetched secrets; missing
34
- * keys are omitted rather than throwing.
34
+ * keys are omitted rather than throwing, so the result is typed as
35
+ * `Partial<T>` and callers must handle keys that may be absent at runtime.
36
+ *
37
+ * Pass a shape as `T` to get a typed result without casting, e.g.
38
+ * `await secrets.getSecrets<{ FOO: string; BAR: { id: string } }>(['FOO', 'BAR'])`.
35
39
  * @param keys - The keys of the secrets to retrieve.
36
40
  */
37
- getSecrets(keys: string[]): Promise<Record<string, unknown>>;
41
+ getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<T>>;
38
42
  }
@@ -0,0 +1,20 @@
1
+ import type { Logger } from './logger.js';
2
+ export declare class TemporaryFileInstance {
3
+ private logger;
4
+ private tempDir;
5
+ private files;
6
+ constructor(logger: Logger, tempDir: string);
7
+ getFile(key: string): ReadableStream | NodeJS.ReadableStream;
8
+ writeFile(key: string, stream: ReadableStream | NodeJS.ReadableStream): Promise<void>;
9
+ deleteFile(key: string): Promise<void>;
10
+ hasFile(key: string): Promise<boolean>;
11
+ getTempFileAbsolutePath(key: string): Promise<string>;
12
+ private createDir;
13
+ cleanup(): Promise<void>;
14
+ }
15
+ export declare class TemporaryFileService {
16
+ private logger;
17
+ private tempDir;
18
+ constructor(logger: Logger, tempDir: string);
19
+ createInstance(): TemporaryFileInstance;
20
+ }
@@ -0,0 +1,63 @@
1
+ import { createReadStream, createWriteStream } from 'fs';
2
+ import { mkdir, rm, stat } from 'fs/promises';
3
+ import { resolve } from 'path';
4
+ import { pipeline } from 'stream/promises';
5
+ export class TemporaryFileInstance {
6
+ logger;
7
+ tempDir;
8
+ files = [];
9
+ constructor(logger, tempDir) {
10
+ this.logger = logger;
11
+ this.tempDir = tempDir;
12
+ this.tempDir = resolve(tempDir);
13
+ logger.debug(`Using temp dir ${this.tempDir}`);
14
+ }
15
+ getFile(key) {
16
+ return createReadStream(`${this.tempDir}/${key}`);
17
+ }
18
+ async writeFile(key, stream) {
19
+ await this.createDir(key);
20
+ const filePath = `${this.tempDir}/${key}`;
21
+ const fileStream = createWriteStream(filePath);
22
+ await pipeline(stream, fileStream);
23
+ this.files.push(filePath);
24
+ this.logger.debug(`Wrote file: ${filePath}`);
25
+ }
26
+ async deleteFile(key) {
27
+ await rm(`${this.tempDir}/${key}`);
28
+ this.logger.debug(`Deleted file ${this.tempDir}/${key}`);
29
+ }
30
+ async hasFile(key) {
31
+ try {
32
+ return !!(await stat(`${this.tempDir}/${key}`));
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ async getTempFileAbsolutePath(key) {
39
+ await this.createDir(key);
40
+ return `${this.tempDir}/${key}`;
41
+ }
42
+ async createDir(key) {
43
+ const dir = `${this.tempDir}/${key}`.split('/').slice(0, -1).join('/');
44
+ if (!(await this.hasFile(dir))) {
45
+ await mkdir(dir, { recursive: true });
46
+ }
47
+ }
48
+ async cleanup() {
49
+ await Promise.all(this.files.map((file) => rm(file)));
50
+ }
51
+ }
52
+ export class TemporaryFileService {
53
+ logger;
54
+ tempDir;
55
+ constructor(logger, tempDir) {
56
+ this.logger = logger;
57
+ this.tempDir = tempDir;
58
+ this.tempDir = resolve(tempDir);
59
+ }
60
+ createInstance() {
61
+ return new TemporaryFileInstance(this.logger, this.tempDir);
62
+ }
63
+ }
@@ -24,7 +24,7 @@ export declare class TypedSecretService<TMap = Record<string, unknown>> implemen
24
24
  hasSecret(key: string): Promise<boolean>;
25
25
  setSecret<K extends string>(key: K, value: K extends keyof TMap ? TMap[K] : unknown): Promise<void>;
26
26
  deleteSecret(key: string): Promise<void>;
27
- getSecrets(keys: string[]): Promise<Record<string, unknown>>;
27
+ getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<T>>;
28
28
  getAllStatus(): Promise<CredentialStatus[]>;
29
29
  getMissing(): Promise<CredentialStatus[]>;
30
30
  }
@@ -15,6 +15,7 @@ export declare class TypedVariablesService<TMap = Record<string, unknown>> imple
15
15
  constructor(variables: VariablesService, variablesMeta: Record<string, VariableMeta>);
16
16
  get<K extends keyof TMap & string>(name: K): Promise<TMap[K] | undefined> | TMap[K] | undefined;
17
17
  get<T = string>(name: string): Promise<T | undefined> | T | undefined;
18
+ getVariables<T extends Record<string, unknown> = Record<string, unknown>>(names: (keyof T & string)[]): Promise<Partial<T>> | Partial<T>;
18
19
  getAll(): Promise<Record<string, string | undefined>> | Record<string, string | undefined>;
19
20
  set(name: string, value: unknown): Promise<void> | void;
20
21
  has(name: string): Promise<boolean> | boolean;
@@ -8,6 +8,9 @@ export class TypedVariablesService {
8
8
  get(name) {
9
9
  return this.variables.get(name);
10
10
  }
11
+ getVariables(names) {
12
+ return this.variables.getVariables(names);
13
+ }
11
14
  getAll() {
12
15
  return this.variables.getAll();
13
16
  }
@@ -1,5 +1,14 @@
1
1
  export interface VariablesService {
2
2
  get<T = string>(name: string): Promise<T | undefined> | T | undefined;
3
+ /**
4
+ * Retrieves multiple variables in a single batch operation, mirroring
5
+ * `SecretService.getSecrets`. Missing variables are omitted rather than
6
+ * throwing, so the result is typed as `Partial<T>` and callers must handle
7
+ * keys that may be absent at runtime. Pass a shape as `T` to get a typed
8
+ * result without casting, e.g.
9
+ * `await variables.getVariables<{ FOO: string; BAR: string }>(['FOO', 'BAR'])`.
10
+ */
11
+ getVariables<T extends Record<string, unknown> = Record<string, unknown>>(names: (keyof T & string)[]): Promise<Partial<T>> | Partial<T>;
3
12
  getAll: () => Promise<Record<string, string | undefined>> | Record<string, string | undefined>;
4
13
  set: (name: string, value: unknown) => Promise<void> | void;
5
14
  has: (name: string) => Promise<boolean> | boolean;
@@ -114,6 +114,9 @@ export interface PikkuPackageState {
114
114
  } | null;
115
115
  /** Cached singleton services for this package */
116
116
  singletonServices: CoreSingletonServices | null;
117
+ /** The pikkuBetterAuth factory, self-registered when auth.ts is evaluated.
118
+ * pikkuServices reads it to build and inject the `auth` singleton. */
119
+ authFactory: ((services: CoreSingletonServices) => unknown | Promise<unknown>) | null;
117
120
  /** Credential metadata for this addon package */
118
121
  credentialsMeta: Record<string, {
119
122
  name: string;
@@ -56,7 +56,10 @@ function extractHeadersFromRequest(request, headerKeys) {
56
56
  */
57
57
  export const addHTTPMiddleware = (pattern, middleware, packageName = null) => {
58
58
  const httpGroups = pikkuState(packageName, 'middleware', 'httpGroup');
59
- httpGroups[pattern] = middleware;
59
+ const existing = httpGroups[pattern];
60
+ httpGroups[pattern] = existing
61
+ ? [...existing, ...middleware]
62
+ : middleware;
60
63
  return middleware;
61
64
  };
62
65
  /**
@@ -13,7 +13,7 @@ export class PikkuFetchHTTPRequest {
13
13
  #url;
14
14
  #rawBodyText;
15
15
  #rawBodyBuffer;
16
- #bodyRead = false;
16
+ #rawBufferPromise;
17
17
  constructor(request) {
18
18
  this.request = request;
19
19
  this.#url = new URL(request.url);
@@ -37,35 +37,49 @@ export class PikkuFetchHTTPRequest {
37
37
  * @returns A promise that resolves to the raw request body.
38
38
  */
39
39
  async arrayBuffer() {
40
+ return this.#readRawBuffer();
41
+ }
42
+ /**
43
+ * Reads the underlying single-use request body exactly once and memoises both
44
+ * the in-flight promise and the result. `json()`, `arrayBuffer()`, `body()`
45
+ * and `toWebRequest()` all funnel through here, so a request whose body is
46
+ * consumed in more than one place can no longer race on the underlying stream
47
+ * and fail with "Body has already been used".
48
+ *
49
+ * If a second consumer asks for the body while the first read is still in
50
+ * flight, that is the concurrent double-read that used to crash — now safe,
51
+ * but warned about so the redundant consumer is found and removed at the
52
+ * source rather than silently leaning on this cache.
53
+ */
54
+ async #readRawBuffer() {
40
55
  if (this.#rawBodyBuffer !== undefined) {
41
56
  return this.#rawBodyBuffer;
42
57
  }
43
- if (this.#bodyRead) {
44
- if (this.#rawBodyText !== undefined) {
45
- const buf = new TextEncoder().encode(this.#rawBodyText)
46
- .buffer;
47
- this.#rawBodyBuffer = buf;
48
- return buf;
49
- }
50
- return new ArrayBuffer(0);
58
+ if (this.#rawBodyText !== undefined) {
59
+ const buf = new TextEncoder().encode(this.#rawBodyText)
60
+ .buffer;
61
+ this.#rawBodyBuffer = buf;
62
+ return buf;
63
+ }
64
+ if (this.#rawBufferPromise !== undefined) {
65
+ console.warn(`[pikku] request body for ${this.method().toUpperCase()} ${this.path()} ` +
66
+ `was requested again before the first read resolved — the read is now ` +
67
+ `shared, but a duplicate body consumer (e.g. middleware calling ` +
68
+ `toWebRequest just for headers) should be removed.`);
69
+ return this.#rawBufferPromise;
51
70
  }
52
- const buf = await this.request.arrayBuffer();
53
- this.#rawBodyBuffer = buf;
54
- this.#bodyRead = true;
55
- return buf;
71
+ this.#rawBufferPromise = this.request.arrayBuffer().then((buf) => {
72
+ this.#rawBodyBuffer = buf;
73
+ return buf;
74
+ });
75
+ return this.#rawBufferPromise;
56
76
  }
57
77
  async #readRawText() {
58
78
  if (this.#rawBodyText !== undefined) {
59
79
  return this.#rawBodyText;
60
80
  }
61
- if (this.#rawBodyBuffer !== undefined) {
62
- const text = new TextDecoder().decode(this.#rawBodyBuffer);
63
- this.#rawBodyText = text;
64
- return text;
65
- }
66
- const text = await this.request.text();
81
+ const text = new TextDecoder().decode(await this.#readRawBuffer());
67
82
  this.#rawBodyText = text;
68
- this.#bodyRead = true;
69
83
  return text;
70
84
  }
71
85
  headers() {
@@ -21,8 +21,13 @@ export function toWebRequest(req, baseUrl) {
21
21
  return new Request(url, {
22
22
  method,
23
23
  headers,
24
+ // `pull` (lazy) rather than `start` (eager): the body is only read from the
25
+ // underlying request when this stream is actually consumed. A consumer that
26
+ // builds a web Request just to read its headers (e.g. a session middleware
27
+ // calling getSession({ headers })) never touches the body, so it does zero
28
+ // body I/O and cannot race the route handler's read of the same request.
24
29
  body: new ReadableStream({
25
- async start(controller) {
30
+ async pull(controller) {
26
31
  try {
27
32
  const buffer = await req.arrayBuffer();
28
33
  if (buffer.byteLength > 0) {
@@ -683,6 +683,7 @@ export class PikkuWorkflowService {
683
683
  const wire = {
684
684
  workflow: workflowWire,
685
685
  pikkuUserId: run.wire?.pikkuUserId,
686
+ session: rpcService.wire?.session,
686
687
  rpc: rpcService.wire?.rpc,
687
688
  };
688
689
  try {
@@ -812,6 +813,7 @@ export class PikkuWorkflowService {
812
813
  id: rpcName,
813
814
  parentRunId: runId,
814
815
  parentStepId: stepState.stepId,
816
+ pikkuUserId: rpcService.wire?.pikkuUserId,
815
817
  };
816
818
  const shouldInline = !getSingletonServices()?.queueService;
817
819
  const { runId: childRunId } = await this.startWorkflow(rpcName, data, childWire, rpcService, { inline: shouldInline });
@@ -952,6 +954,7 @@ export class PikkuWorkflowService {
952
954
  type: 'workflow',
953
955
  id: rpcName,
954
956
  parentRunId: runId,
957
+ pikkuUserId: rpcService.wire?.pikkuUserId,
955
958
  };
956
959
  const { runId: childRunId } = await this.startWorkflow(rpcName, data, childWire, rpcService, { inline: true });
957
960
  await this.setStepChildRunId(currentStepState.stepId, childRunId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.31",
3
+ "version": "0.12.33",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
@@ -44,6 +44,7 @@
44
44
  "./services": "./dist/services/index.js",
45
45
  "./services/local-meta": "./dist/services/meta-service.js",
46
46
  "./services/local-content": "./dist/services/local-content.js",
47
+ "./services/temporary-file-service": "./dist/services/temporary-file-service.js",
47
48
  "./services/gopass-secrets": "./dist/services/gopass-secrets.js",
48
49
  "./crypto-utils": "./dist/crypto-utils.js",
49
50
  "./internal": "./dist/internal.js",
@@ -58,6 +58,10 @@ async function resolveSession(
58
58
  wire.session = stored
59
59
  sessionService?.setInitial(stored)
60
60
  }
61
+ } else {
62
+ // Session already present on wire (e.g. propagated from parent workflow).
63
+ // Seed the sessionService so freezeInitial() returns it instead of undefined.
64
+ sessionService?.setInitial(wire.session as CoreUserSession)
61
65
  }
62
66
  }
63
67
 
@@ -5,7 +5,7 @@ import {
5
5
  addTagMiddleware,
6
6
  runMiddleware,
7
7
  } from './middleware-runner.js'
8
- import { resetPikkuState } from './pikku-state.js'
8
+ import { pikkuState, resetPikkuState } from './pikku-state.js'
9
9
  import type {
10
10
  CorePikkuMiddleware,
11
11
  MiddlewarePriority,
@@ -32,6 +32,21 @@ beforeEach(() => {
32
32
  resetPikkuState()
33
33
  })
34
34
 
35
+ describe('addTagMiddleware', () => {
36
+ test('composes repeated registrations for the same tag', () => {
37
+ const first: CorePikkuMiddleware = async (_s, _w, next) => next()
38
+ const second: CorePikkuMiddleware = async (_s, _w, next) => next()
39
+
40
+ addTagMiddleware('shared', [first])
41
+ addTagMiddleware('shared', [second])
42
+
43
+ assert.deepEqual(pikkuState(null, 'middleware', 'tagGroup')['shared'], [
44
+ first,
45
+ second,
46
+ ])
47
+ })
48
+ })
49
+
35
50
  describe('combineMiddleware', () => {
36
51
  test('should return empty array when no parameters provided', () => {
37
52
  const result = combineMiddleware('http', Math.random().toString())
@@ -106,7 +106,10 @@ export const addTagMiddleware = <PikkuMiddleware extends CorePikkuMiddleware>(
106
106
  packageName: string | null = null
107
107
  ): CorePikkuMiddlewareGroup => {
108
108
  const tagGroups = pikkuState(packageName, 'middleware', 'tagGroup')
109
- tagGroups[tag] = middleware
109
+ const existing = tagGroups[tag] as CorePikkuMiddleware[] | undefined
110
+ tagGroups[tag] = existing
111
+ ? [...existing, ...(middleware as CorePikkuMiddleware[])]
112
+ : middleware
110
113
  return middleware
111
114
  }
112
115
 
@@ -155,6 +155,7 @@ const createEmptyPackageState = (): PikkuPackageState => ({
155
155
  package: {
156
156
  factories: null,
157
157
  singletonServices: null,
158
+ authFactory: null,
158
159
  credentialsMeta: null,
159
160
  requiredParentServices: null,
160
161
  },
@@ -64,13 +64,15 @@ export class GopassSecretService implements SecretService {
64
64
  }
65
65
  }
66
66
 
67
- public async getSecrets(keys: string[]): Promise<Record<string, unknown>> {
67
+ public async getSecrets<
68
+ T extends Record<string, unknown> = Record<string, unknown>,
69
+ >(keys: (keyof T & string)[]): Promise<Partial<T>> {
68
70
  const results = await Promise.allSettled(keys.map((k) => this.getSecret(k)))
69
71
  const out: Record<string, unknown> = {}
70
72
  keys.forEach((key, i) => {
71
73
  if (results[i].status === 'fulfilled')
72
74
  out[key] = (results[i] as PromiseFulfilledResult<unknown>).value
73
75
  })
74
- return out
76
+ return out as Partial<T>
75
77
  }
76
78
  }
@@ -53,13 +53,15 @@ export class LocalSecretService implements SecretService {
53
53
  this.localSecrets.delete(key)
54
54
  }
55
55
 
56
- public async getSecrets(keys: string[]): Promise<Record<string, unknown>> {
56
+ public async getSecrets<
57
+ T extends Record<string, unknown> = Record<string, unknown>,
58
+ >(keys: (keyof T & string)[]): Promise<Partial<T>> {
57
59
  const results = await Promise.allSettled(keys.map((k) => this.getSecret(k)))
58
60
  const out: Record<string, unknown> = {}
59
61
  keys.forEach((key, i) => {
60
62
  if (results[i].status === 'fulfilled')
61
63
  out[key] = (results[i] as PromiseFulfilledResult<unknown>).value
62
64
  })
63
- return out
65
+ return out as Partial<T>
64
66
  }
65
67
  }
@@ -9,6 +9,17 @@ export class LocalVariablesService implements VariablesService {
9
9
  return this.variables || {}
10
10
  }
11
11
 
12
+ public getVariables<
13
+ T extends Record<string, unknown> = Record<string, unknown>,
14
+ >(names: (keyof T & string)[]): Partial<T> {
15
+ const out: Record<string, unknown> = {}
16
+ for (const name of names) {
17
+ const value = this.get(name)
18
+ if (value !== undefined) out[name] = value
19
+ }
20
+ return out as Partial<T>
21
+ }
22
+
12
23
  public get<T = string>(name: string): T | undefined {
13
24
  const raw = this.variables[name]
14
25
  if (raw === undefined) return undefined