@pikku/core 0.12.31 → 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.
- package/CHANGELOG.md +82 -0
- package/dist/middleware-runner.js +4 -1
- package/dist/pikku-state.js +1 -0
- package/dist/services/gopass-secrets.d.ts +1 -1
- package/dist/services/local-secrets.d.ts +1 -1
- package/dist/services/local-variables.d.ts +1 -0
- package/dist/services/local-variables.js +9 -0
- package/dist/services/scoped-secret-service.d.ts +1 -1
- package/dist/services/scoped-secret-service.js +2 -2
- package/dist/services/secret-service.d.ts +6 -2
- package/dist/services/typed-secret-service.d.ts +1 -1
- package/dist/services/typed-variables-service.d.ts +1 -0
- package/dist/services/typed-variables-service.js +3 -0
- package/dist/services/variables-service.d.ts +9 -0
- package/dist/types/state.types.d.ts +3 -0
- package/dist/wirings/http/http-runner.js +4 -1
- package/dist/wirings/http/pikku-fetch-http-request.js +34 -20
- package/dist/wirings/http/web-request.js +6 -1
- package/package.json +1 -1
- package/src/middleware-runner.test.ts +16 -1
- package/src/middleware-runner.ts +4 -1
- package/src/pikku-state.ts +1 -0
- package/src/services/gopass-secrets.ts +4 -2
- package/src/services/local-secrets.ts +4 -2
- package/src/services/local-variables.ts +11 -0
- package/src/services/scoped-secret-service.test.ts +20 -0
- package/src/services/scoped-secret-service.ts +5 -3
- package/src/services/secret-service.ts +8 -2
- package/src/services/typed-secret-service.ts +4 -2
- package/src/services/typed-variables-service.ts +6 -0
- package/src/services/variables-service.ts +11 -0
- package/src/types/state.types.ts +5 -0
- package/src/wirings/http/http-runner.test.ts +13 -0
- package/src/wirings/http/http-runner.ts +4 -1
- package/src/wirings/http/pikku-fetch-http-request.ts +37 -20
- package/src/wirings/http/web-request.test.ts +2 -2
- package/src/wirings/http/web-request.ts +6 -1
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,85 @@
|
|
|
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
|
+
|
|
1
83
|
## 0.12.31
|
|
2
84
|
|
|
3
85
|
### Patch Changes
|
|
@@ -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]
|
|
72
|
+
const existing = tagGroups[tag];
|
|
73
|
+
tagGroups[tag] = existing
|
|
74
|
+
? [...existing, ...middleware]
|
|
75
|
+
: middleware;
|
|
73
76
|
return middleware;
|
|
74
77
|
};
|
|
75
78
|
/**
|
package/dist/pikku-state.js
CHANGED
|
@@ -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<
|
|
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<
|
|
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<
|
|
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
|
-
|
|
33
|
-
return this.secrets.getSecrets(
|
|
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<
|
|
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<
|
|
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;
|
|
@@ -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]
|
|
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
|
-
#
|
|
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.#
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
30
|
+
async pull(controller) {
|
|
26
31
|
try {
|
|
27
32
|
const buffer = await req.arrayBuffer();
|
|
28
33
|
if (buffer.byteLength > 0) {
|
package/package.json
CHANGED
|
@@ -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())
|
package/src/middleware-runner.ts
CHANGED
|
@@ -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]
|
|
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
|
|
package/src/pikku-state.ts
CHANGED
|
@@ -64,13 +64,15 @@ export class GopassSecretService implements SecretService {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
public async getSecrets
|
|
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
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
|
47
|
-
|
|
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>
|
package/src/types/state.types.ts
CHANGED
|
@@ -171,6 +171,11 @@ export interface PikkuPackageState {
|
|
|
171
171
|
} | null
|
|
172
172
|
/** Cached singleton services for this package */
|
|
173
173
|
singletonServices: CoreSingletonServices | null
|
|
174
|
+
/** The pikkuBetterAuth factory, self-registered when auth.ts is evaluated.
|
|
175
|
+
* pikkuServices reads it to build and inject the `auth` singleton. */
|
|
176
|
+
authFactory:
|
|
177
|
+
| ((services: CoreSingletonServices) => unknown | Promise<unknown>)
|
|
178
|
+
| null
|
|
174
179
|
/** Credential metadata for this addon package */
|
|
175
180
|
credentialsMeta: Record<
|
|
176
181
|
string,
|
|
@@ -272,6 +272,19 @@ describe('http-runner helpers', () => {
|
|
|
272
272
|
)
|
|
273
273
|
})
|
|
274
274
|
|
|
275
|
+
test('addHTTPMiddleware composes repeated registrations for the same pattern', () => {
|
|
276
|
+
const first: CorePikkuMiddleware = async (_s, _w, next) => next()
|
|
277
|
+
const second: CorePikkuMiddleware = async (_s, _w, next) => next()
|
|
278
|
+
|
|
279
|
+
addHTTPMiddleware('*', [first])
|
|
280
|
+
addHTTPMiddleware('*', [second])
|
|
281
|
+
|
|
282
|
+
assert.deepEqual(pikkuState(null, 'middleware', 'httpGroup')['*'], [
|
|
283
|
+
first,
|
|
284
|
+
second,
|
|
285
|
+
])
|
|
286
|
+
})
|
|
287
|
+
|
|
275
288
|
test('createHTTPWire combines request and response when present', () => {
|
|
276
289
|
const req = new TestRequest('/x', 'get')
|
|
277
290
|
const res = new TestResponse()
|
|
@@ -96,7 +96,10 @@ export const addHTTPMiddleware = <PikkuMiddleware extends CorePikkuMiddleware>(
|
|
|
96
96
|
packageName: string | null = null
|
|
97
97
|
): CorePikkuMiddlewareGroup => {
|
|
98
98
|
const httpGroups = pikkuState(packageName, 'middleware', 'httpGroup')
|
|
99
|
-
httpGroups[pattern]
|
|
99
|
+
const existing = httpGroups[pattern] as CorePikkuMiddleware[] | undefined
|
|
100
|
+
httpGroups[pattern] = existing
|
|
101
|
+
? [...existing, ...(middleware as CorePikkuMiddleware[])]
|
|
102
|
+
: middleware
|
|
100
103
|
return middleware
|
|
101
104
|
}
|
|
102
105
|
|