@terreno/api 0.18.0 → 0.20.0
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 +25 -0
- package/dist/api.test.js +18 -8
- package/dist/auth.d.ts +5 -5
- package/dist/auth.js +123 -131
- package/dist/configuration.test.js +289 -10
- package/dist/configurationApp.d.ts +72 -5
- package/dist/configurationApp.js +168 -48
- package/dist/configurationPlugin.d.ts +64 -7
- package/dist/configurationPlugin.js +161 -39
- package/dist/configurationPlugin.test.js +238 -1
- package/dist/expressServer.test.js +0 -1
- package/dist/openApi.d.ts +6 -6
- package/dist/openApi.js +21 -21
- package/dist/populate.test.js +23 -0
- package/dist/realtime/queryMatcher.js +0 -6
- package/dist/realtime/queryStore.js +3 -11
- package/dist/realtime/realtime.test.js +41 -34
- package/dist/secretProviders.d.ts +79 -2
- package/dist/secretProviders.js +177 -9
- package/dist/secretProviders.test.d.ts +1 -0
- package/dist/secretProviders.test.js +391 -0
- package/package.json +1 -1
- package/src/actions.openApi.test.ts +1 -1
- package/src/actions.ts +0 -1
- package/src/api.test.ts +10 -2
- package/src/auth.ts +19 -19
- package/src/configuration.test.ts +171 -7
- package/src/configurationApp.ts +213 -30
- package/src/configurationPlugin.test.ts +174 -2
- package/src/configurationPlugin.ts +157 -28
- package/src/expressServer.test.ts +0 -1
- package/src/openApi.ts +21 -21
- package/src/populate.test.ts +25 -0
- package/src/realtime/queryMatcher.ts +0 -6
- package/src/realtime/queryStore.ts +1 -10
- package/src/realtime/realtime.test.ts +24 -24
- package/src/realtime/realtimeApp.ts +0 -1
- package/src/realtime/registry.ts +0 -1
- package/src/realtime/types.ts +0 -4
- package/src/secretProviders.test.ts +186 -0
- package/src/secretProviders.ts +145 -5
|
@@ -1776,6 +1776,17 @@ describe("startChangeStreamWatcher", () => {
|
|
|
1776
1776
|
};
|
|
1777
1777
|
};
|
|
1778
1778
|
|
|
1779
|
+
const invokeRegisteredChangeHandler = async (
|
|
1780
|
+
mockStream: ReturnType<typeof createMockChangeStream>,
|
|
1781
|
+
event: Record<string, unknown>
|
|
1782
|
+
): Promise<void> => {
|
|
1783
|
+
const changeHandler = mockStream.listeners.get("change");
|
|
1784
|
+
if (!changeHandler) {
|
|
1785
|
+
throw new Error("expected change handler");
|
|
1786
|
+
}
|
|
1787
|
+
await changeHandler(event);
|
|
1788
|
+
};
|
|
1789
|
+
|
|
1779
1790
|
const createMockIo = () => {
|
|
1780
1791
|
const rooms = new Map<string, Set<string>>();
|
|
1781
1792
|
const sockets = new Map<string, any>();
|
|
@@ -1864,9 +1875,7 @@ describe("startChangeStreamWatcher", () => {
|
|
|
1864
1875
|
startChangeStreamWatcher(io, {}, true);
|
|
1865
1876
|
|
|
1866
1877
|
// Trigger an insert change event
|
|
1867
|
-
|
|
1868
|
-
expect(changeHandler).toBeDefined();
|
|
1869
|
-
await changeHandler!({
|
|
1878
|
+
await invokeRegisteredChangeHandler(mockStream, {
|
|
1870
1879
|
documentKey: {_id: "doc-1"},
|
|
1871
1880
|
fullDocument: {_id: "doc-1", name: "Test Todo"},
|
|
1872
1881
|
ns: {coll: "todos"},
|
|
@@ -1886,9 +1895,8 @@ describe("startChangeStreamWatcher", () => {
|
|
|
1886
1895
|
|
|
1887
1896
|
startChangeStreamWatcher(io, {}, true);
|
|
1888
1897
|
|
|
1889
|
-
const changeHandler = mockStream.listeners.get("change");
|
|
1890
1898
|
// Trigger for an unregistered collection — should not throw
|
|
1891
|
-
await
|
|
1899
|
+
await invokeRegisteredChangeHandler(mockStream, {
|
|
1892
1900
|
documentKey: {_id: "doc-1"},
|
|
1893
1901
|
fullDocument: {_id: "doc-1"},
|
|
1894
1902
|
ns: {coll: "unknown_collection"},
|
|
@@ -1927,9 +1935,8 @@ describe("startChangeStreamWatcher", () => {
|
|
|
1927
1935
|
|
|
1928
1936
|
startChangeStreamWatcher(io, {}, true);
|
|
1929
1937
|
|
|
1930
|
-
const changeHandler = mockStream.listeners.get("change");
|
|
1931
1938
|
// Update event should be skipped because "update" not in methods
|
|
1932
|
-
await
|
|
1939
|
+
await invokeRegisteredChangeHandler(mockStream, {
|
|
1933
1940
|
documentKey: {_id: "doc-1"},
|
|
1934
1941
|
fullDocument: {_id: "doc-1", name: "Updated"},
|
|
1935
1942
|
ns: {coll: "todos"},
|
|
@@ -1969,9 +1976,8 @@ describe("startChangeStreamWatcher", () => {
|
|
|
1969
1976
|
|
|
1970
1977
|
startChangeStreamWatcher(io, {}, true);
|
|
1971
1978
|
|
|
1972
|
-
const changeHandler = mockStream.listeners.get("change");
|
|
1973
1979
|
// Hard delete (no fullDocument)
|
|
1974
|
-
await
|
|
1980
|
+
await invokeRegisteredChangeHandler(mockStream, {
|
|
1975
1981
|
documentKey: {_id: "doc-1"},
|
|
1976
1982
|
ns: {coll: "todos"},
|
|
1977
1983
|
operationType: "delete",
|
|
@@ -2009,9 +2015,8 @@ describe("startChangeStreamWatcher", () => {
|
|
|
2009
2015
|
|
|
2010
2016
|
startChangeStreamWatcher(io, {}, true);
|
|
2011
2017
|
|
|
2012
|
-
const changeHandler = mockStream.listeners.get("change");
|
|
2013
2018
|
// Hard delete for broadcast strategy
|
|
2014
|
-
await
|
|
2019
|
+
await invokeRegisteredChangeHandler(mockStream, {
|
|
2015
2020
|
documentKey: {_id: "doc-1"},
|
|
2016
2021
|
ns: {coll: "broadcasts"},
|
|
2017
2022
|
operationType: "delete",
|
|
@@ -2049,8 +2054,7 @@ describe("startChangeStreamWatcher", () => {
|
|
|
2049
2054
|
|
|
2050
2055
|
startChangeStreamWatcher(io, {}, true);
|
|
2051
2056
|
|
|
2052
|
-
|
|
2053
|
-
await changeHandler!({
|
|
2057
|
+
await invokeRegisteredChangeHandler(mockStream, {
|
|
2054
2058
|
documentKey: {_id: "doc-1"},
|
|
2055
2059
|
fullDocument: {_id: "doc-1", name: "Updated", status: "done"},
|
|
2056
2060
|
ns: {coll: "todos"},
|
|
@@ -2110,9 +2114,8 @@ describe("startChangeStreamWatcher", () => {
|
|
|
2110
2114
|
|
|
2111
2115
|
startChangeStreamWatcher(io, {ignoredOperations: ["insert"]}, true);
|
|
2112
2116
|
|
|
2113
|
-
const changeHandler = mockStream.listeners.get("change");
|
|
2114
2117
|
// This insert should be skipped because "insert" is ignored
|
|
2115
|
-
await
|
|
2118
|
+
await invokeRegisteredChangeHandler(mockStream, {
|
|
2116
2119
|
documentKey: {_id: "doc-1"},
|
|
2117
2120
|
fullDocument: {_id: "doc-1"},
|
|
2118
2121
|
ns: {coll: "todos"},
|
|
@@ -2132,15 +2135,14 @@ describe("startChangeStreamWatcher", () => {
|
|
|
2132
2135
|
|
|
2133
2136
|
startChangeStreamWatcher(io, {}, true);
|
|
2134
2137
|
|
|
2135
|
-
const changeHandler = mockStream.listeners.get("change");
|
|
2136
2138
|
// Missing ns.coll
|
|
2137
|
-
await
|
|
2139
|
+
await invokeRegisteredChangeHandler(mockStream, {
|
|
2138
2140
|
documentKey: {_id: "doc-1"},
|
|
2139
2141
|
ns: {},
|
|
2140
2142
|
operationType: "insert",
|
|
2141
2143
|
});
|
|
2142
2144
|
// Missing documentKey
|
|
2143
|
-
await
|
|
2145
|
+
await invokeRegisteredChangeHandler(mockStream, {
|
|
2144
2146
|
documentKey: {},
|
|
2145
2147
|
ns: {coll: "todos"},
|
|
2146
2148
|
operationType: "insert",
|
|
@@ -2159,9 +2161,8 @@ describe("startChangeStreamWatcher", () => {
|
|
|
2159
2161
|
|
|
2160
2162
|
startChangeStreamWatcher(io, {}, true);
|
|
2161
2163
|
|
|
2162
|
-
const changeHandler = mockStream.listeners.get("change");
|
|
2163
2164
|
// "drop" is not in our pipeline filter, should be skipped
|
|
2164
|
-
await
|
|
2165
|
+
await invokeRegisteredChangeHandler(mockStream, {
|
|
2165
2166
|
operationType: "drop",
|
|
2166
2167
|
});
|
|
2167
2168
|
});
|
|
@@ -2257,9 +2258,8 @@ describe("startChangeStreamWatcher", () => {
|
|
|
2257
2258
|
|
|
2258
2259
|
startChangeStreamWatcher(io, {}, true);
|
|
2259
2260
|
|
|
2260
|
-
const changeHandler = mockStream.listeners.get("change");
|
|
2261
2261
|
// Should not throw even though permission check throws
|
|
2262
|
-
await
|
|
2262
|
+
await invokeRegisteredChangeHandler(mockStream, {
|
|
2263
2263
|
documentKey: {_id: "doc-1"},
|
|
2264
2264
|
fullDocument: {_id: "doc-1", name: "Test"},
|
|
2265
2265
|
ns: {coll: "todos"},
|
|
@@ -2317,7 +2317,7 @@ describe("RealtimeApp.onServerCreated", () => {
|
|
|
2317
2317
|
});
|
|
2318
2318
|
|
|
2319
2319
|
const makeServer = (): import("http").Server => {
|
|
2320
|
-
const http = require("http");
|
|
2320
|
+
const http = require("node:http");
|
|
2321
2321
|
const server = http.createServer();
|
|
2322
2322
|
servers.push(server);
|
|
2323
2323
|
return server;
|
|
@@ -2366,7 +2366,7 @@ describe("RealtimeApp.setupAdapter (private, via onServerCreated config)", () =>
|
|
|
2366
2366
|
});
|
|
2367
2367
|
|
|
2368
2368
|
const makeServer = (): import("http").Server => {
|
|
2369
|
-
const http = require("http");
|
|
2369
|
+
const http = require("node:http");
|
|
2370
2370
|
const server = http.createServer();
|
|
2371
2371
|
servers.push(server);
|
|
2372
2372
|
return server;
|
|
@@ -60,7 +60,6 @@ export interface RealtimeSocketLike extends SocketWithDecodedToken {
|
|
|
60
60
|
join: (room: string) => Promise<void> | void;
|
|
61
61
|
leave: (room: string) => Promise<void> | void;
|
|
62
62
|
emit: (event: string, payload: unknown) => void;
|
|
63
|
-
// biome-ignore lint/suspicious/noExplicitAny: Socket.io event handlers accept arbitrary argument shapes per event name
|
|
64
63
|
on: (event: string, handler: (...args: any[]) => any) => void;
|
|
65
64
|
}
|
|
66
65
|
|
package/src/realtime/registry.ts
CHANGED
|
@@ -17,7 +17,6 @@ export interface RealtimeRegistryEntry {
|
|
|
17
17
|
/**
|
|
18
18
|
* Full modelRouter options (for responseHandler, permissions, etc.).
|
|
19
19
|
*/
|
|
20
|
-
// biome-ignore lint/suspicious/noExplicitAny: registry stores heterogeneous models — narrowing the generic is not useful at the registry level
|
|
21
20
|
options: ModelRouterOptions<any>;
|
|
22
21
|
}
|
|
23
22
|
|
package/src/realtime/types.ts
CHANGED
|
@@ -19,10 +19,8 @@ export interface RealtimeConfig {
|
|
|
19
19
|
| "owner"
|
|
20
20
|
| "model"
|
|
21
21
|
| "broadcast"
|
|
22
|
-
// biome-ignore lint/suspicious/noExplicitAny: doc is an arbitrary Mongoose document; consumers cast to their model type
|
|
23
22
|
| ((doc: any, method: string, req: express.Request) => string[]);
|
|
24
23
|
/** Custom serializer for real-time events. Falls back to the modelRouter responseHandler. */
|
|
25
|
-
// biome-ignore lint/suspicious/noExplicitAny: doc shape and return value are consumer-defined per model
|
|
26
24
|
realtimeResponseHandler?: (doc: any, method: string) => any;
|
|
27
25
|
}
|
|
28
26
|
|
|
@@ -39,7 +37,6 @@ export interface RealtimeEvent {
|
|
|
39
37
|
/** Document ID */
|
|
40
38
|
id: string;
|
|
41
39
|
/** Serialized document data (omitted for hard deletes) */
|
|
42
|
-
// biome-ignore lint/suspicious/noExplicitAny: emitted document shape varies by model and serializer
|
|
43
40
|
data?: any;
|
|
44
41
|
/** Fields that were updated (for update events from change streams) */
|
|
45
42
|
updatedFields?: string[];
|
|
@@ -105,7 +102,6 @@ export interface QuerySubscription {
|
|
|
105
102
|
/** Collection tag (e.g. "todos") */
|
|
106
103
|
collection: string;
|
|
107
104
|
/** MongoDB-style query filter (e.g. {completed: false}) */
|
|
108
|
-
// biome-ignore lint/suspicious/noExplicitAny: MongoDB query filter values are arbitrary user-supplied JSON
|
|
109
105
|
query: Record<string, any>;
|
|
110
106
|
/** Client-provided queryId (ignored — server computes a canonical ID) */
|
|
111
107
|
queryId?: string;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import {beforeEach, describe, expect, it} from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type {SecretProvider} from "./configurationPlugin";
|
|
4
|
+
import {CachingSecretProvider, CompositeSecretProvider, EnvSecretProvider} from "./secretProviders";
|
|
5
|
+
|
|
6
|
+
describe("EnvSecretProvider", () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
delete process.env.MY_SECRET_KEY;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("resolves from a SCREAMING_SNAKE_CASE env var", async () => {
|
|
12
|
+
process.env.MY_SECRET_KEY = "from-env";
|
|
13
|
+
const provider = new EnvSecretProvider();
|
|
14
|
+
expect(await provider.getSecret("my-secret-key")).toBe("from-env");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns null when the env var is missing", async () => {
|
|
18
|
+
const provider = new EnvSecretProvider();
|
|
19
|
+
expect(await provider.getSecret("my-secret-key")).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("ignores the version parameter", async () => {
|
|
23
|
+
process.env.MY_SECRET_KEY = "value";
|
|
24
|
+
const provider = new EnvSecretProvider();
|
|
25
|
+
expect(await provider.getSecret("my-secret-key", "5")).toBe("value");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("CompositeSecretProvider", () => {
|
|
30
|
+
it("throws when constructed with no providers", () => {
|
|
31
|
+
expect(() => new CompositeSecretProvider([])).toThrow();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns the first non-null result", async () => {
|
|
35
|
+
const a: SecretProvider = {getSecret: async () => null, name: "a"};
|
|
36
|
+
const b: SecretProvider = {getSecret: async () => "from-b", name: "b"};
|
|
37
|
+
const c: SecretProvider = {getSecret: async () => "from-c", name: "c"};
|
|
38
|
+
const provider = new CompositeSecretProvider([a, b, c]);
|
|
39
|
+
expect(await provider.getSecret("x")).toBe("from-b");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("falls through to the next provider when one throws", async () => {
|
|
43
|
+
const failing: SecretProvider = {
|
|
44
|
+
getSecret: async () => {
|
|
45
|
+
throw new Error("provider down");
|
|
46
|
+
},
|
|
47
|
+
name: "failing",
|
|
48
|
+
};
|
|
49
|
+
const fallback: SecretProvider = {getSecret: async () => "from-fallback", name: "fallback"};
|
|
50
|
+
const provider = new CompositeSecretProvider([failing, fallback]);
|
|
51
|
+
expect(await provider.getSecret("x")).toBe("from-fallback");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns null when every provider yields null", async () => {
|
|
55
|
+
const a: SecretProvider = {getSecret: async () => null, name: "a"};
|
|
56
|
+
const b: SecretProvider = {getSecret: async () => null, name: "b"};
|
|
57
|
+
const provider = new CompositeSecretProvider([a, b]);
|
|
58
|
+
expect(await provider.getSecret("x")).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("forwards the version parameter to each provider", async () => {
|
|
62
|
+
const seen: Array<string | undefined> = [];
|
|
63
|
+
const a: SecretProvider = {
|
|
64
|
+
getSecret: async (_name, version) => {
|
|
65
|
+
seen.push(version);
|
|
66
|
+
return null;
|
|
67
|
+
},
|
|
68
|
+
name: "a",
|
|
69
|
+
};
|
|
70
|
+
const b: SecretProvider = {
|
|
71
|
+
getSecret: async (_name, version) => {
|
|
72
|
+
seen.push(version);
|
|
73
|
+
return "value";
|
|
74
|
+
},
|
|
75
|
+
name: "b",
|
|
76
|
+
};
|
|
77
|
+
const provider = new CompositeSecretProvider([a, b]);
|
|
78
|
+
await provider.getSecret("x", "7");
|
|
79
|
+
expect(seen).toEqual(["7", "7"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("builds a composite name from the underlying providers", () => {
|
|
83
|
+
const provider = new CompositeSecretProvider([
|
|
84
|
+
{getSecret: async () => null, name: "gcp"},
|
|
85
|
+
{getSecret: async () => null, name: "env"},
|
|
86
|
+
]);
|
|
87
|
+
expect(provider.name).toBe("composite(gcp,env)");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("CachingSecretProvider", () => {
|
|
92
|
+
it("memoizes a value within the TTL (single underlying call)", async () => {
|
|
93
|
+
let calls = 0;
|
|
94
|
+
const underlying: SecretProvider = {
|
|
95
|
+
getSecret: async () => {
|
|
96
|
+
calls++;
|
|
97
|
+
return "value";
|
|
98
|
+
},
|
|
99
|
+
name: "underlying",
|
|
100
|
+
};
|
|
101
|
+
const provider = new CachingSecretProvider(underlying, {ttlMs: 10_000});
|
|
102
|
+
expect(await provider.getSecret("x")).toBe("value");
|
|
103
|
+
expect(await provider.getSecret("x")).toBe("value");
|
|
104
|
+
expect(calls).toBe(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("re-fetches after clear()", async () => {
|
|
108
|
+
let calls = 0;
|
|
109
|
+
const underlying: SecretProvider = {
|
|
110
|
+
getSecret: async () => {
|
|
111
|
+
calls++;
|
|
112
|
+
return `value-${calls}`;
|
|
113
|
+
},
|
|
114
|
+
name: "underlying",
|
|
115
|
+
};
|
|
116
|
+
const provider = new CachingSecretProvider(underlying, {ttlMs: 10_000});
|
|
117
|
+
expect(await provider.getSecret("x")).toBe("value-1");
|
|
118
|
+
provider.clear();
|
|
119
|
+
expect(await provider.getSecret("x")).toBe("value-2");
|
|
120
|
+
expect(calls).toBe(2);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("re-fetches after the TTL expires", async () => {
|
|
124
|
+
let calls = 0;
|
|
125
|
+
const underlying: SecretProvider = {
|
|
126
|
+
getSecret: async () => {
|
|
127
|
+
calls++;
|
|
128
|
+
return `value-${calls}`;
|
|
129
|
+
},
|
|
130
|
+
name: "underlying",
|
|
131
|
+
};
|
|
132
|
+
const provider = new CachingSecretProvider(underlying, {ttlMs: 1});
|
|
133
|
+
expect(await provider.getSecret("x")).toBe("value-1");
|
|
134
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
135
|
+
expect(await provider.getSecret("x")).toBe("value-2");
|
|
136
|
+
expect(calls).toBe(2);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("caches different versions independently", async () => {
|
|
140
|
+
const seen: Array<string | undefined> = [];
|
|
141
|
+
const underlying: SecretProvider = {
|
|
142
|
+
getSecret: async (_name, version) => {
|
|
143
|
+
seen.push(version);
|
|
144
|
+
return `v-${version ?? "latest"}`;
|
|
145
|
+
},
|
|
146
|
+
name: "underlying",
|
|
147
|
+
};
|
|
148
|
+
const provider = new CachingSecretProvider(underlying, {ttlMs: 10_000});
|
|
149
|
+
expect(await provider.getSecret("x", "1")).toBe("v-1");
|
|
150
|
+
expect(await provider.getSecret("x", "2")).toBe("v-2");
|
|
151
|
+
// Cached hits, no additional underlying calls.
|
|
152
|
+
expect(await provider.getSecret("x", "1")).toBe("v-1");
|
|
153
|
+
expect(seen).toEqual(["1", "2"]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("clearKey invalidates a single secret", async () => {
|
|
157
|
+
let calls = 0;
|
|
158
|
+
const underlying: SecretProvider = {
|
|
159
|
+
getSecret: async () => {
|
|
160
|
+
calls++;
|
|
161
|
+
return `value-${calls}`;
|
|
162
|
+
},
|
|
163
|
+
name: "underlying",
|
|
164
|
+
};
|
|
165
|
+
const provider = new CachingSecretProvider(underlying, {ttlMs: 10_000});
|
|
166
|
+
await provider.getSecret("x");
|
|
167
|
+
provider.clearKey("x");
|
|
168
|
+
await provider.getSecret("x");
|
|
169
|
+
expect(calls).toBe(2);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("caches null results", async () => {
|
|
173
|
+
let calls = 0;
|
|
174
|
+
const underlying: SecretProvider = {
|
|
175
|
+
getSecret: async () => {
|
|
176
|
+
calls++;
|
|
177
|
+
return null;
|
|
178
|
+
},
|
|
179
|
+
name: "underlying",
|
|
180
|
+
};
|
|
181
|
+
const provider = new CachingSecretProvider(underlying, {ttlMs: 10_000});
|
|
182
|
+
expect(await provider.getSecret("missing")).toBeNull();
|
|
183
|
+
expect(await provider.getSecret("missing")).toBeNull();
|
|
184
|
+
expect(calls).toBe(1);
|
|
185
|
+
});
|
|
186
|
+
});
|
package/src/secretProviders.ts
CHANGED
|
@@ -28,7 +28,11 @@ interface SecretManagerModule {
|
|
|
28
28
|
export class EnvSecretProvider implements SecretProvider {
|
|
29
29
|
name = "env";
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a secret from an environment variable. Environment variables have no
|
|
33
|
+
* versions, so the optional `version` parameter is ignored.
|
|
34
|
+
*/
|
|
35
|
+
async getSecret(secretName: string, _version?: string): Promise<string | null> {
|
|
32
36
|
// Convert secret name to env var format: "openai-api-key" → "OPENAI_API_KEY"
|
|
33
37
|
const envKey = secretName.replace(/[-.]/g, "_").toUpperCase();
|
|
34
38
|
const value = process.env[envKey] ?? null;
|
|
@@ -96,16 +100,28 @@ export class GcpSecretProvider implements SecretProvider {
|
|
|
96
100
|
return this.client;
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Resolve a secret from Google Cloud Secret Manager.
|
|
105
|
+
*
|
|
106
|
+
* @param secretName - A short secret id (e.g. "openai-api-key") or a full
|
|
107
|
+
* resource path (e.g. "projects/p/secrets/s" or
|
|
108
|
+
* "projects/p/secrets/s/versions/3").
|
|
109
|
+
* @param version - Optional version to resolve when `secretName` is a short id
|
|
110
|
+
* (e.g. "3"). Defaults to "latest". Ignored when `secretName` already
|
|
111
|
+
* contains an explicit `/versions/...` suffix.
|
|
112
|
+
*/
|
|
113
|
+
async getSecret(secretName: string, version?: string): Promise<string | null> {
|
|
100
114
|
const client = await this.getClient();
|
|
101
115
|
|
|
116
|
+
const resolvedVersion = version ?? "latest";
|
|
102
117
|
let resourceName: string;
|
|
103
118
|
if (secretName.startsWith("projects/")) {
|
|
104
|
-
|
|
119
|
+
// Honor a full resource path. Only append a version when one is not present.
|
|
120
|
+
resourceName = secretName.includes("/versions/")
|
|
105
121
|
? secretName
|
|
106
|
-
: `${secretName}/versions
|
|
122
|
+
: `${secretName}/versions/${resolvedVersion}`;
|
|
107
123
|
} else {
|
|
108
|
-
resourceName = `projects/${this.projectId}/secrets/${secretName}/versions
|
|
124
|
+
resourceName = `projects/${this.projectId}/secrets/${secretName}/versions/${resolvedVersion}`;
|
|
109
125
|
}
|
|
110
126
|
|
|
111
127
|
try {
|
|
@@ -126,3 +142,127 @@ export class GcpSecretProvider implements SecretProvider {
|
|
|
126
142
|
}
|
|
127
143
|
}
|
|
128
144
|
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Secret provider that delegates to an ordered list of providers, returning the
|
|
148
|
+
* first non-null result.
|
|
149
|
+
*
|
|
150
|
+
* A provider that throws is warn-logged (secret name only — never the value) and
|
|
151
|
+
* resolution falls through to the next provider. This makes it easy to compose a
|
|
152
|
+
* primary provider with a fallback, e.g. GCP with an environment-variable
|
|
153
|
+
* fallback:
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```typescript
|
|
157
|
+
* const provider = new CompositeSecretProvider([
|
|
158
|
+
* new GcpSecretProvider({projectId: "my-project"}),
|
|
159
|
+
* new EnvSecretProvider(),
|
|
160
|
+
* ]);
|
|
161
|
+
* const key = await provider.getSecret("openai-api-key");
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
export class CompositeSecretProvider implements SecretProvider {
|
|
165
|
+
name: string;
|
|
166
|
+
private providers: SecretProvider[];
|
|
167
|
+
|
|
168
|
+
constructor(providers: SecretProvider[]) {
|
|
169
|
+
if (!providers || providers.length === 0) {
|
|
170
|
+
throw new APIError({
|
|
171
|
+
status: 500,
|
|
172
|
+
title: "CompositeSecretProvider requires at least one provider",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
this.providers = providers;
|
|
176
|
+
this.name = `composite(${providers.map((p) => p.name).join(",")})`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async getSecret(secretName: string, version?: string): Promise<string | null> {
|
|
180
|
+
for (const provider of this.providers) {
|
|
181
|
+
try {
|
|
182
|
+
const value = await provider.getSecret(secretName, version);
|
|
183
|
+
if (value !== null) {
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
} catch (error: unknown) {
|
|
187
|
+
// Never log the secret value — only the name and which provider failed.
|
|
188
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
189
|
+
logger.warn(
|
|
190
|
+
`CompositeSecretProvider: provider ${provider.name} failed for secret ${secretName}: ${message}`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Options for CachingSecretProvider.
|
|
200
|
+
*/
|
|
201
|
+
export interface CachingSecretProviderOptions {
|
|
202
|
+
/** Time-to-live for cached values, in milliseconds. Defaults to 60_000 (1 minute). */
|
|
203
|
+
ttlMs?: number;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
interface CacheEntry {
|
|
207
|
+
value: string | null;
|
|
208
|
+
expiresAt: number;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Secret provider that wraps any provider with an in-memory TTL cache.
|
|
213
|
+
*
|
|
214
|
+
* Cache entries are keyed by `secretName@version` so that pinned versions are
|
|
215
|
+
* cached independently. `null` results (secret not found) are cached too, to
|
|
216
|
+
* avoid hammering the underlying provider for missing secrets. Secret values are
|
|
217
|
+
* never logged.
|
|
218
|
+
*
|
|
219
|
+
* Use `clear()` to drop the entire cache (e.g. on rotation) or `clearKey()` to
|
|
220
|
+
* invalidate a single secret.
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* ```typescript
|
|
224
|
+
* const provider = new CachingSecretProvider(
|
|
225
|
+
* new CompositeSecretProvider([gcp, env]),
|
|
226
|
+
* {ttlMs: 30_000}
|
|
227
|
+
* );
|
|
228
|
+
* ```
|
|
229
|
+
*/
|
|
230
|
+
export class CachingSecretProvider implements SecretProvider {
|
|
231
|
+
name: string;
|
|
232
|
+
private provider: SecretProvider;
|
|
233
|
+
private ttlMs: number;
|
|
234
|
+
private cache = new Map<string, CacheEntry>();
|
|
235
|
+
|
|
236
|
+
constructor(provider: SecretProvider, options?: CachingSecretProviderOptions) {
|
|
237
|
+
this.provider = provider;
|
|
238
|
+
this.ttlMs = options?.ttlMs ?? 60_000;
|
|
239
|
+
this.name = `caching(${provider.name})`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private cacheKey(secretName: string, version?: string): string {
|
|
243
|
+
return `${secretName}@${version ?? "latest"}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async getSecret(secretName: string, version?: string): Promise<string | null> {
|
|
247
|
+
const key = this.cacheKey(secretName, version);
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
const cached = this.cache.get(key);
|
|
250
|
+
if (cached && cached.expiresAt > now) {
|
|
251
|
+
return cached.value;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const value = await this.provider.getSecret(secretName, version);
|
|
255
|
+
this.cache.set(key, {expiresAt: now + this.ttlMs, value});
|
|
256
|
+
return value;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Clears the entire cache. Useful on secret rotation and in tests. */
|
|
260
|
+
clear(): void {
|
|
261
|
+
this.cache.clear();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Invalidates a single cached secret by name (and optional version). */
|
|
265
|
+
clearKey(secretName: string, version?: string): void {
|
|
266
|
+
this.cache.delete(this.cacheKey(secretName, version));
|
|
267
|
+
}
|
|
268
|
+
}
|