@pikku/core 0.12.30 → 0.12.32

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 (40) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/dist/data-classification.d.ts +3 -3
  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/typed-secret-service.d.ts +1 -1
  13. package/dist/services/typed-variables-service.d.ts +1 -0
  14. package/dist/services/typed-variables-service.js +3 -0
  15. package/dist/services/variables-service.d.ts +9 -0
  16. package/dist/types/state.types.d.ts +3 -0
  17. package/dist/wirings/http/http-runner.js +4 -1
  18. package/dist/wirings/http/pikku-fetch-http-request.js +34 -20
  19. package/dist/wirings/http/web-request.js +6 -1
  20. package/package.json +1 -1
  21. package/src/data-classification.ts +10 -3
  22. package/src/middleware-runner.test.ts +16 -1
  23. package/src/middleware-runner.ts +4 -1
  24. package/src/pikku-state.ts +1 -0
  25. package/src/services/gopass-secrets.ts +4 -2
  26. package/src/services/local-secrets.ts +4 -2
  27. package/src/services/local-variables.ts +11 -0
  28. package/src/services/scoped-secret-service.test.ts +20 -0
  29. package/src/services/scoped-secret-service.ts +5 -3
  30. package/src/services/secret-service.ts +8 -2
  31. package/src/services/typed-secret-service.ts +4 -2
  32. package/src/services/typed-variables-service.ts +6 -0
  33. package/src/services/variables-service.ts +11 -0
  34. package/src/types/state.types.ts +5 -0
  35. package/src/wirings/http/http-runner.test.ts +13 -0
  36. package/src/wirings/http/http-runner.ts +4 -1
  37. package/src/wirings/http/pikku-fetch-http-request.ts +37 -20
  38. package/src/wirings/http/web-request.test.ts +2 -2
  39. package/src/wirings/http/web-request.ts +6 -1
  40. package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,108 @@
1
+ ## 0.12.32
2
+
3
+ ### Patch Changes
4
+
5
+ - a027a8e: feat: emit auth provider + plugin metadata as `auth-meta.gen.json` for the console SSO page
6
+
7
+ The enabled social providers and Better Auth plugins are now extracted statically
8
+ and written to a generated `auth-meta.gen.json`, replacing the runtime
9
+ `setAuthRegistry`/`getAuthRegistry` approach — so the console can show them without
10
+ evaluating the Better Auth factory.
11
+ - **inspector**: the `pikkuBetterAuth` inspector now reads the `plugins` array from
12
+ the `betterAuth({ ... })` config and records each plugin id (the callee name of
13
+ each `plugins: [organization(), bearer()]` entry) on the auth definition.
14
+ - **cli**: `pikku auth` (and `pikku all`) emit `auth/pikku-auth-meta.gen.json` (path
15
+ configurable via `authMetaJsonFile`) containing `basePath`, `hasCredentials`, the
16
+ enabled `providers` (`id` + `displayName` + `secretId`), and the enabled `plugins`
17
+ (`id` + `displayName`). The previous `setAuthRegistry(...)` runtime wiring is
18
+ removed from the generated `auth.gen.ts`.
19
+ - **better-auth**: exports a `PLUGIN_REGISTRY` and `pluginDisplayName(id)` helper so
20
+ plugin ids resolve to human-readable names.
21
+ - **core**: removes the unreleased `setAuthRegistry`/`getAuthRegistry` runtime auth
22
+ registry (now superseded by `auth-meta.gen.json`).
23
+ - **addon-console**: `getAuthProviders` reads `auth-meta.gen.json` and returns the
24
+ configured providers, plugins, and `hasCredentials` flag.
25
+ - **console**: the Auth Providers (SSO) page fetches `console:getAuthProviders` and
26
+ marks each provider configured/unconfigured, lists email+password credentials as a
27
+ provider, and shows the enabled Better Auth plugins.
28
+
29
+ - a027a8e: fix: address Better Auth review findings (secret/variable batch typing, auth init, guards)
30
+ - **core**: `SecretService.getSecrets` / `VariablesService.getVariables` (and the
31
+ Local/Typed/Scoped/AWS implementations) now return `Partial<T>`, honestly
32
+ reflecting that missing keys are omitted at runtime rather than typing partial
33
+ data as fully populated. `ScopedSecretService.getSecrets` now throws on a
34
+ disallowed key instead of silently filtering it out.
35
+ - **cli**: the generated `services.auth()` thunk clears its memoised promise on
36
+ rejection, so a transient Better Auth/Kysely startup failure no longer
37
+ permanently poisons auth for the process lifetime.
38
+ - **inspector**: the `pikkuBetterAuth` export guard now requires an exported
39
+ `const` (rejects `export let`/`export var`), matching its error message.
40
+ - **console**: the Microsoft auth provider's `callbackId` is `microsoft` (the
41
+ Better Auth provider id) rather than `microsoft-entra-id`.
42
+
43
+ - a027a8e: fix(core): compose repeated global middleware registrations instead of overwriting
44
+
45
+ `addHTTPMiddleware(pattern, …)` and `addTagMiddleware(tag, …)` stored the
46
+ middleware group with `groups[key] = middleware`, so a second registration for
47
+ the same pattern/tag silently replaced the first. With Better Auth, generated
48
+ `auth.gen.ts` registers `addHTTPMiddleware('*', [betterAuthSession()])`, which
49
+ clobbered an app's own `addHTTPMiddleware('*', [...])` global middleware (cors,
50
+ session, credential loading) and dropped it from every route.
51
+
52
+ Both now append to the existing group (matching `addGlobalMiddleware`, which
53
+ already appends), so generated auth middleware composes with user-registered
54
+ global middleware. The route meta lists each pattern once, so the combined
55
+ group is still applied a single time per request.
56
+
57
+ - a027a8e: feat(auth): migrate auth integration from Auth.js to Better Auth
58
+
59
+ The auth integration is now built on [Better Auth](https://better-auth.com)
60
+ and ships as a single package, `@pikku/better-auth` (replacing the former
61
+ `@pikku/auth-js`). There is exactly one auth package now.
62
+ - `pikkuBetterAuth(async ({ secrets, variables }) => betterAuth({ ... }))` is the new
63
+ single entry point. The CLI inspects the `betterAuth(...)` call and generates:
64
+ - `auth.gen.ts` — a catch-all `${basePath}{/*splat}` HTTP route per method and
65
+ a global `betterAuthSession({ auth })` middleware that bridges the Better
66
+ Auth session into the Pikku wire session.
67
+ - `auth-secrets.gen.ts` — `wireSecret(BETTER_AUTH_SECRET)` plus a
68
+ `<PROVIDER>_OAUTH` secret for each configured social provider, and
69
+ `wireVariable` for non-secret provider config (e.g. `MICROSOFT_TENANT_ID`,
70
+ `COGNITO_DOMAIN`/`REGION`/`USER_POOL_ID`).
71
+ - `auth.types.ts` — a typed `pikkuBetterAuth` re-export.
72
+ - `add-auth` (inspector) walks into the `betterAuth(...)` options to discover the
73
+ configured providers and required secrets/variables.
74
+ - The auth secret is now auto-wired by codegen from `BETTER_AUTH_SECRET` — it no
75
+ longer needs to be registered as a JWT signing key in `services.ts`.
76
+
77
+ CLI fix included: scaffold files generated outside `srcDirectories` (e.g. an
78
+ `auth.gen.ts` under a project's `pikku/` dir) are now added to the inspector's
79
+ wiring files, so their routes and secret metadata are picked up. The generated
80
+ wiring imports Pikku types via a resolved relative path instead of a hardcoded
81
+ `#pikku` specifier, so templates without a `#pikku` import map type-check.
82
+
83
+ ## 0.12.31
84
+
85
+ ### Patch Changes
86
+
87
+ - fe70fe0: fix(db): make classified columns usable in Kysely queries and emit real zod
88
+
89
+ Two fixes so data-classified DB columns (`@private`/`@pii`/`@secret`, default
90
+ `private`) are usable end-to-end instead of poisoning ordinary app code:
91
+ 1. **Brand marker is now optional** (`{ readonly __classification__?: ... }`)
92
+ in both `@pikku/core` and the `pikku db migrate` schema header. A required
93
+ marker made a plain value (e.g. `string`) unassignable to a branded column
94
+ (`Private<string>`), breaking every Kysely `where`/insert/`.set()` operand —
95
+ any project with classified columns failed to type-check. Optional keeps the
96
+ brand structurally present (so the inspector's PKU910 output check still
97
+ detects it) while letting plain values flow IN. The inspector's level read is
98
+ now union-aware (`'pii' | undefined`) so pii/secret no longer silently
99
+ downgrade to private.
100
+ 2. **Zod codegen resolves classified `ColumnType<>`** to proper scalars instead
101
+ of `z.unknown()`. `pikku db migrate` emits `<Table>Z`/`InsertZ`/`PatchZ` from
102
+ the Select slot, unwrapping the brand and honoring insert-optionality from the
103
+ Insert slot's `| undefined`. Public `Generated<T>`/bare/nested shapes are
104
+ unchanged.
105
+
1
106
  ## 0.12.30
2
107
 
3
108
  ### Patch Changes
@@ -1,11 +1,11 @@
1
1
  export type Private<T> = T & {
2
- readonly __classification__: 'private';
2
+ readonly __classification__?: 'private';
3
3
  };
4
4
  export type Pii<T> = T & {
5
- readonly __classification__: 'pii';
5
+ readonly __classification__?: 'pii';
6
6
  };
7
7
  export type Secret<T> = T & {
8
- readonly __classification__: 'secret';
8
+ readonly __classification__?: 'secret';
9
9
  };
10
10
  export type Classification = 'public' | 'private' | 'pii' | 'secret';
11
11
  export type AnonymizeStrategy = 'fake:email' | 'fake:name' | 'hash' | 'keep' | null;
@@ -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
  }
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.30",
3
+ "version": "0.12.32",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
@@ -1,6 +1,13 @@
1
- export type Private<T> = T & { readonly __classification__: 'private' }
2
- export type Pii<T> = T & { readonly __classification__: 'pii' }
3
- export type Secret<T> = T & { readonly __classification__: 'secret' }
1
+ // The `__classification__` marker is OPTIONAL on purpose. A required property
2
+ // would make a plain value (e.g. `string`) unassignable to a branded column
3
+ // (`Private<string>`), which breaks ordinary Kysely query operands
4
+ // `where('email', '=', someString)`, inserts, and `.set(...)`. Making it
5
+ // optional keeps the brand structurally present (so the inspector's PKU910
6
+ // output check still detects it) while allowing plain values to flow IN.
7
+ // See `findPiiPaths` in @pikku/inspector, which reads the level union-aware.
8
+ export type Private<T> = T & { readonly __classification__?: 'private' }
9
+ export type Pii<T> = T & { readonly __classification__?: 'pii' }
10
+ export type Secret<T> = T & { readonly __classification__?: 'secret' }
4
11
 
5
12
  export type Classification = 'public' | 'private' | 'pii' | 'secret'
6
13
  export type AnonymizeStrategy =
@@ -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
@@ -7,6 +7,8 @@ const createMockSecrets = () => ({
7
7
  hasSecret: async () => true,
8
8
  setSecret: async () => {},
9
9
  deleteSecret: async () => {},
10
+ getSecrets: async <T>(keys: string[]) =>
11
+ Object.fromEntries(keys.map((key) => [key, { key }])) as T,
10
12
  })
11
13
 
12
14
  describe('ScopedSecretService', () => {
@@ -55,6 +57,24 @@ describe('ScopedSecretService', () => {
55
57
  })
56
58
  })
57
59
 
60
+ test('should return batch secrets for allowed keys', async () => {
61
+ const mock = createMockSecrets()
62
+ const scoped = new ScopedSecretService(mock, new Set(['KEY1', 'KEY2']))
63
+ const result = await scoped.getSecrets(['KEY1', 'KEY2'])
64
+ assert.deepStrictEqual(result, {
65
+ KEY1: { key: 'KEY1' },
66
+ KEY2: { key: 'KEY2' },
67
+ })
68
+ })
69
+
70
+ test('should reject batch getSecrets when any key is not allowed', async () => {
71
+ const mock = createMockSecrets()
72
+ const scoped = new ScopedSecretService(mock, new Set(['KEY1']))
73
+ await assert.rejects(() => scoped.getSecrets(['KEY1', 'KEY2']), {
74
+ message: 'Access denied to secret key: KEY2',
75
+ })
76
+ })
77
+
58
78
  test('should always throw on setSecret', async () => {
59
79
  const mock = createMockSecrets()
60
80
  const scoped = new ScopedSecretService(mock, new Set(['KEY1']))
@@ -34,8 +34,10 @@ export class ScopedSecretService implements SecretService {
34
34
  throw new Error('deleteSecret is not allowed in scoped secret service')
35
35
  }
36
36
 
37
- async getSecrets(keys: string[]): Promise<Record<string, unknown>> {
38
- const allowed = keys.filter((k) => this.allowedKeys.has(k))
39
- return this.secrets.getSecrets(allowed)
37
+ async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
38
+ keys: (keyof T & string)[]
39
+ ): Promise<Partial<T>> {
40
+ keys.forEach((k) => this.assertAllowed(k))
41
+ return this.secrets.getSecrets<T>(keys)
40
42
  }
41
43
  }
@@ -31,8 +31,14 @@ 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>>(
42
+ keys: (keyof T & string)[]
43
+ ): Promise<Partial<T>>
38
44
  }
@@ -43,8 +43,10 @@ export class TypedSecretService<
43
43
  return this.secrets.deleteSecret(key)
44
44
  }
45
45
 
46
- async getSecrets(keys: string[]): Promise<Record<string, unknown>> {
47
- return this.secrets.getSecrets(keys)
46
+ async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
47
+ keys: (keyof T & string)[]
48
+ ): Promise<Partial<T>> {
49
+ return this.secrets.getSecrets<T>(keys)
48
50
  }
49
51
 
50
52
  async getAllStatus(): Promise<CredentialStatus[]> {
@@ -28,6 +28,12 @@ export class TypedVariablesService<
28
28
  return this.variables.get(name)
29
29
  }
30
30
 
31
+ getVariables<T extends Record<string, unknown> = Record<string, unknown>>(
32
+ names: (keyof T & string)[]
33
+ ): Promise<Partial<T>> | Partial<T> {
34
+ return this.variables.getVariables<T>(names)
35
+ }
36
+
31
37
  getAll():
32
38
  | Promise<Record<string, string | undefined>>
33
39
  | Record<string, string | undefined> {
@@ -1,5 +1,16 @@
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>>(
12
+ names: (keyof T & string)[]
13
+ ): Promise<Partial<T>> | Partial<T>
3
14
  getAll: () =>
4
15
  | Promise<Record<string, string | undefined>>
5
16
  | Record<string, string | undefined>