@anchor-secrets/sdk 0.0.0-stage → 0.4.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/dist/index.js ADDED
@@ -0,0 +1,526 @@
1
+ // src/cache.ts
2
+ import { createHash } from "node:crypto";
3
+
4
+ // src/errors.ts
5
+ var AnchorError = class extends Error {
6
+ status;
7
+ code;
8
+ /** Whether repeating the request may succeed (network, timeout, 429, 502, 503, 504). */
9
+ retryable;
10
+ /** The server's `Retry-After`, in milliseconds, when it sent one. */
11
+ retryAfterMs;
12
+ constructor(status, code, message, options = {}) {
13
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
14
+ this.name = "AnchorError";
15
+ this.status = status;
16
+ this.code = code;
17
+ this.retryable = options.retryable ?? false;
18
+ if (options.retryAfterMs !== void 0) this.retryAfterMs = options.retryAfterMs;
19
+ }
20
+ };
21
+ var abortedError = () => new AnchorError(0, "aborted", "The request to Anchor was cancelled");
22
+
23
+ // src/cache.ts
24
+ function memoryStore() {
25
+ const entries = /* @__PURE__ */ new Map();
26
+ return {
27
+ get: (key) => entries.get(key),
28
+ set: (key, entry) => {
29
+ entries.set(key, entry);
30
+ }
31
+ };
32
+ }
33
+ function cacheKey(origin, token, q) {
34
+ const fingerprint = createHash("sha256").update(token).digest("hex").slice(0, 16);
35
+ return JSON.stringify([
36
+ origin,
37
+ fingerprint,
38
+ q.project,
39
+ q.environment,
40
+ q.path,
41
+ q.expand,
42
+ q.includeImports
43
+ ]);
44
+ }
45
+ function untilAborted(promise, signal) {
46
+ if (!signal) return promise;
47
+ if (signal.aborted) return Promise.reject(abortedError());
48
+ return new Promise((resolve, reject) => {
49
+ const onAbort = () => reject(abortedError());
50
+ signal.addEventListener("abort", onAbort, { once: true });
51
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
52
+ });
53
+ }
54
+ var SecretCache = class {
55
+ constructor(policy, emit, now = () => Date.now()) {
56
+ this.policy = policy;
57
+ this.emit = emit;
58
+ this.now = now;
59
+ }
60
+ policy;
61
+ emit;
62
+ now;
63
+ inflight = /* @__PURE__ */ new Map();
64
+ /** When the last fetch of a key failed; the stale entry is served without asking for one ttl. */
65
+ failedAt = /* @__PURE__ */ new Map();
66
+ /**
67
+ * The folder's entry. With `strict`, a failed fetch rejects instead of falling back, so a
68
+ * caller that asked for new values learns it did not get them.
69
+ */
70
+ async load(key, fetch, opts = {}) {
71
+ if (opts.signal?.aborted) throw abortedError();
72
+ const entry = await this.read(key);
73
+ if (entry && !opts.fresh && this.servable(key, entry)) return entry;
74
+ const flight = this.inflight.get(key) ?? this.start(key, fetch, entry);
75
+ flight.waiters++;
76
+ let outcome;
77
+ try {
78
+ outcome = await untilAborted(flight.promise, opts.signal);
79
+ } catch (error) {
80
+ if (opts.signal?.aborted && --flight.waiters === 0) {
81
+ flight.controller.abort();
82
+ if (this.inflight.get(key) === flight) this.inflight.delete(key);
83
+ }
84
+ throw error;
85
+ }
86
+ if (opts.strict && outcome.error) throw outcome.error;
87
+ return outcome.entry;
88
+ }
89
+ start(key, fetch, stale) {
90
+ const controller = new AbortController();
91
+ const promise = this.fetch(
92
+ key,
93
+ () => fetch(controller.signal),
94
+ stale,
95
+ controller.signal
96
+ ).finally(() => {
97
+ if (this.inflight.get(key)?.promise === promise) this.inflight.delete(key);
98
+ });
99
+ const flight = { promise, controller, waiters: 0 };
100
+ this.inflight.set(key, flight);
101
+ return flight;
102
+ }
103
+ /** A store that fails is a miss: Anchor stays reachable when Redis (or a file) is not. */
104
+ async read(key) {
105
+ try {
106
+ return await this.policy.store.get(key);
107
+ } catch (error) {
108
+ this.emit({ type: "store_failed", operation: "get", error });
109
+ return void 0;
110
+ }
111
+ }
112
+ async write(key, entry) {
113
+ try {
114
+ await this.policy.store.set(key, entry);
115
+ } catch (error) {
116
+ this.emit({ type: "store_failed", operation: "set", error });
117
+ }
118
+ }
119
+ servable(key, entry) {
120
+ const now = this.now();
121
+ if (now - entry.fetchedAt < this.policy.ttlMs) return true;
122
+ const failed = this.failedAt.get(key);
123
+ return failed !== void 0 && now - failed < this.policy.ttlMs && this.usable(entry, now);
124
+ }
125
+ usable(entry, now) {
126
+ return now - entry.fetchedAt <= this.policy.ttlMs + this.policy.maxStaleMs;
127
+ }
128
+ async fetch(key, fetch, stale, signal) {
129
+ try {
130
+ const entry = { secrets: await fetch(), fetchedAt: this.now() };
131
+ await this.write(key, entry);
132
+ this.failedAt.delete(key);
133
+ return { entry };
134
+ } catch (error) {
135
+ if (signal.aborted) throw error;
136
+ const now = this.now();
137
+ if (!(error instanceof AnchorError) || !stale || !this.usable(stale, now)) throw error;
138
+ this.failedAt.set(key, now);
139
+ const staleForMs = Math.max(0, now - stale.fetchedAt - this.policy.ttlMs);
140
+ this.emit({ type: "refresh_failed", error, staleForMs });
141
+ return { entry: stale, error };
142
+ }
143
+ }
144
+ };
145
+
146
+ // src/retry.ts
147
+ var DEFAULT_RETRY = { attempts: 3, baseDelayMs: 250, maxDelayMs: 5e3 };
148
+ function delayFor(error, attempt, policy) {
149
+ if (error.retryAfterMs !== void 0) return Math.min(error.retryAfterMs, policy.maxDelayMs);
150
+ const cap = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** (attempt - 1));
151
+ return Math.floor(Math.random() * cap);
152
+ }
153
+ function wait(ms, signal) {
154
+ return new Promise((resolve, reject) => {
155
+ const onAbort = () => {
156
+ clearTimeout(timer);
157
+ reject(abortedError());
158
+ };
159
+ const timer = setTimeout(() => {
160
+ signal?.removeEventListener("abort", onAbort);
161
+ resolve();
162
+ }, ms);
163
+ signal?.addEventListener("abort", onAbort, { once: true });
164
+ });
165
+ }
166
+ async function withRetry(run, policy, opts) {
167
+ const attempts = policy ? Math.max(1, policy.attempts) : 1;
168
+ for (let attempt = 1; ; attempt++) {
169
+ if (opts.signal?.aborted) throw abortedError();
170
+ try {
171
+ return await run();
172
+ } catch (error) {
173
+ if (!policy || !(error instanceof AnchorError) || !error.retryable || attempt >= attempts) {
174
+ throw error;
175
+ }
176
+ const delayMs = delayFor(error, attempt, policy);
177
+ opts.emit({ type: "retry", attempt, delayMs, error });
178
+ await wait(delayMs, opts.signal);
179
+ }
180
+ }
181
+ }
182
+
183
+ // src/config.ts
184
+ var DEFAULT_TIMEOUT_MS = 1e4;
185
+ function fromEnv(name) {
186
+ const value = typeof process === "undefined" ? void 0 : process.env[name];
187
+ return value === "" ? void 0 : value;
188
+ }
189
+ function resolveConfig(opts) {
190
+ const apiUrl = opts.apiUrl || fromEnv("ANCHOR_API_URL");
191
+ if (!apiUrl) {
192
+ throw new AnchorError(
193
+ 0,
194
+ "config",
195
+ "Anchor API URL is not set: pass apiUrl or set ANCHOR_API_URL"
196
+ );
197
+ }
198
+ let origin;
199
+ try {
200
+ origin = new URL(apiUrl).origin;
201
+ } catch {
202
+ throw new AnchorError(
203
+ 0,
204
+ "config",
205
+ "Anchor API URL is not a valid URL (apiUrl or ANCHOR_API_URL)"
206
+ );
207
+ }
208
+ const token = opts.token || fromEnv("ANCHOR_TOKEN");
209
+ if (!token) {
210
+ throw new AnchorError(0, "config", "Anchor token is not set: pass token or set ANCHOR_TOKEN");
211
+ }
212
+ const f = opts.fetch ?? globalThis.fetch;
213
+ if (typeof f !== "function") {
214
+ throw new AnchorError(
215
+ 0,
216
+ "config",
217
+ "No fetch implementation: use Node 20 or later, or pass fetch"
218
+ );
219
+ }
220
+ return {
221
+ base: apiUrl.replace(/\/+$/, ""),
222
+ origin,
223
+ token,
224
+ fetch: f,
225
+ timeoutMs: timeoutOption(opts.timeoutMs),
226
+ retry: retryPolicy(opts.retry),
227
+ onEvent: opts.onEvent
228
+ };
229
+ }
230
+ function numberOption(name, value, fallback, rule) {
231
+ if (value === void 0) return fallback;
232
+ const valid = typeof value === "number" && !Number.isNaN(value) && (rule.infinite || Number.isFinite(value)) && value >= rule.min && (!rule.integer || Number.isInteger(value));
233
+ if (!valid) {
234
+ const kind = rule.integer ? "an integer" : "a number";
235
+ throw new AnchorError(
236
+ 0,
237
+ "config",
238
+ `Anchor option ${name} must be ${kind} of at least ${rule.min}`
239
+ );
240
+ }
241
+ return value;
242
+ }
243
+ var timeoutOption = (value) => numberOption("timeoutMs", value, DEFAULT_TIMEOUT_MS, { min: 1 });
244
+ function retryPolicy(retry) {
245
+ if (retry === false) return false;
246
+ return {
247
+ attempts: numberOption("retry.attempts", retry?.attempts, DEFAULT_RETRY.attempts, {
248
+ min: 1,
249
+ integer: true
250
+ }),
251
+ baseDelayMs: numberOption("retry.baseDelayMs", retry?.baseDelayMs, DEFAULT_RETRY.baseDelayMs, {
252
+ min: 0
253
+ }),
254
+ maxDelayMs: numberOption("retry.maxDelayMs", retry?.maxDelayMs, DEFAULT_RETRY.maxDelayMs, {
255
+ min: 0
256
+ })
257
+ };
258
+ }
259
+ function validateOptions(opts) {
260
+ timeoutOption(opts.timeoutMs);
261
+ retryPolicy(opts.retry);
262
+ }
263
+ function emitSafely(onEvent, event) {
264
+ try {
265
+ onEvent?.(event);
266
+ } catch {
267
+ }
268
+ }
269
+ var DEFAULT_TTL_MS = 6e4;
270
+ function cachePolicy(cache) {
271
+ if (cache === false) return false;
272
+ return {
273
+ ttlMs: numberOption("cache.ttlMs", cache?.ttlMs, DEFAULT_TTL_MS, { min: 0 }),
274
+ maxStaleMs: numberOption("cache.maxStaleMs", cache?.maxStaleMs, Number.POSITIVE_INFINITY, {
275
+ min: 0,
276
+ infinite: true
277
+ }),
278
+ store: cache?.store ?? memoryStore()
279
+ };
280
+ }
281
+
282
+ // ../shared/src/value-decode.ts
283
+ function decodeTypedValue(type, value) {
284
+ switch (type) {
285
+ case "json":
286
+ case "list":
287
+ return JSON.parse(value);
288
+ case "number":
289
+ return Number(value);
290
+ case "boolean":
291
+ return value === "true";
292
+ default:
293
+ return value;
294
+ }
295
+ }
296
+
297
+ // src/decode.ts
298
+ function toSecret(s) {
299
+ if (typeof s.value !== "string") {
300
+ throw new AnchorError(200, "bad_response", `Anchor returned ${s.key} without a value`);
301
+ }
302
+ const out = {
303
+ key: s.key,
304
+ path: s.path,
305
+ type: s.type,
306
+ sensitive: s.sensitive,
307
+ version: s.version,
308
+ value: s.value
309
+ };
310
+ if (s.importedFrom) out.importedFrom = { ...s.importedFrom };
311
+ return out;
312
+ }
313
+ function decode(s) {
314
+ try {
315
+ return decodeTypedValue(s.type, s.value);
316
+ } catch {
317
+ throw new AnchorError(200, "bad_response", `Secret ${s.key} is not valid ${s.type}`);
318
+ }
319
+ }
320
+ function decodeAll(secrets) {
321
+ const entries = /* @__PURE__ */ new Map();
322
+ for (const s of secrets) {
323
+ if (!entries.has(s.key)) entries.set(s.key, decode(s));
324
+ }
325
+ return Object.fromEntries(entries);
326
+ }
327
+
328
+ // src/folder.ts
329
+ var snapshotOf = (entry) => ({
330
+ entry,
331
+ values: Object.freeze(decodeAll(entry.secrets))
332
+ });
333
+ async function openFolder(core, query, options = {}) {
334
+ let snapshot = snapshotOf(await core.loadEntry(query, { signal: options.signal }));
335
+ let checkedAt = Date.now();
336
+ let failed = false;
337
+ let pending;
338
+ const accept = (entry) => {
339
+ failed = false;
340
+ if (entry !== snapshot.entry) snapshot = snapshotOf(entry);
341
+ };
342
+ const refreshInBackground = () => {
343
+ pending = core.loadEntry(query).then(accept, (error) => {
344
+ failed = true;
345
+ if (error instanceof AnchorError) {
346
+ const staleForMs = Math.max(0, Date.now() - snapshot.entry.fetchedAt - core.ttlMs);
347
+ core.emit({ type: "refresh_failed", error, staleForMs });
348
+ }
349
+ }).finally(() => {
350
+ checkedAt = Date.now();
351
+ pending = void 0;
352
+ });
353
+ };
354
+ const values = () => {
355
+ if (!pending && Date.now() - checkedAt >= core.ttlMs) refreshInBackground();
356
+ if (failed && Date.now() - snapshot.entry.fetchedAt > core.ttlMs + core.maxStaleMs) {
357
+ throw new AnchorError(
358
+ 0,
359
+ "stale",
360
+ "Anchor values are older than ttlMs + maxStaleMs and refreshing them failed"
361
+ );
362
+ }
363
+ return snapshot.values;
364
+ };
365
+ const has = (key) => Object.hasOwn(values(), key);
366
+ return {
367
+ get: (key) => has(key) ? snapshot.values[key] : void 0,
368
+ getOrThrow(key) {
369
+ if (!has(key)) throw new Error(`Anchor secret ${key} is not set`);
370
+ return snapshot.values[key];
371
+ },
372
+ has,
373
+ keys: () => Object.keys(values()),
374
+ toObject: values,
375
+ async refresh() {
376
+ try {
377
+ accept(await core.loadEntry(query, { fresh: true, strict: true }));
378
+ } finally {
379
+ checkedAt = Date.now();
380
+ }
381
+ }
382
+ };
383
+ }
384
+
385
+ // src/transport.ts
386
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 502, 503, 504]);
387
+ function retryAfterMs(res) {
388
+ if (res.status !== 429 && res.status !== 503) return void 0;
389
+ const header = res.headers.get("retry-after")?.trim();
390
+ if (!header || !/^\d+$/.test(header)) return void 0;
391
+ return Number(header) * 1e3;
392
+ }
393
+ async function errorFromResponse(res) {
394
+ let body;
395
+ try {
396
+ body = await res.json();
397
+ } catch {
398
+ body = void 0;
399
+ }
400
+ const options = {
401
+ retryable: RETRYABLE_STATUSES.has(res.status),
402
+ retryAfterMs: retryAfterMs(res)
403
+ };
404
+ const error = body?.error;
405
+ if (typeof error?.code === "string" && typeof error.message === "string") {
406
+ return new AnchorError(res.status, error.code, error.message, options);
407
+ }
408
+ return new AnchorError(res.status, "http_error", `Anchor answered HTTP ${res.status}`, options);
409
+ }
410
+ async function fetchJson(cfg, url, signal) {
411
+ if (signal?.aborted) throw abortedError();
412
+ const controller = new AbortController();
413
+ let timedOut = false;
414
+ const timer = setTimeout(() => {
415
+ timedOut = true;
416
+ controller.abort();
417
+ }, cfg.timeoutMs);
418
+ const onAbort = () => controller.abort();
419
+ signal?.addEventListener("abort", onAbort, { once: true });
420
+ try {
421
+ const res = await cfg.fetch(url, {
422
+ method: "GET",
423
+ headers: { authorization: `Bearer ${cfg.token}`, accept: "application/json" },
424
+ signal: controller.signal
425
+ });
426
+ if (!res.ok) throw await errorFromResponse(res);
427
+ try {
428
+ return await res.json();
429
+ } catch (cause) {
430
+ if (controller.signal.aborted) throw cause;
431
+ throw new AnchorError(
432
+ res.status,
433
+ "bad_response",
434
+ "Anchor returned a response that is not JSON"
435
+ );
436
+ }
437
+ } catch (cause) {
438
+ if (cause instanceof AnchorError) throw cause;
439
+ if (timedOut) {
440
+ throw new AnchorError(
441
+ 0,
442
+ "timeout",
443
+ `Anchor at ${cfg.origin} did not answer within ${cfg.timeoutMs} ms`,
444
+ { retryable: true }
445
+ );
446
+ }
447
+ if (signal?.aborted) throw abortedError();
448
+ const reason = cause instanceof Error ? cause.message : "request failed";
449
+ throw new AnchorError(0, "network", `Could not reach Anchor at ${cfg.origin}: ${reason}`, {
450
+ cause,
451
+ retryable: true
452
+ });
453
+ } finally {
454
+ clearTimeout(timer);
455
+ signal?.removeEventListener("abort", onAbort);
456
+ }
457
+ }
458
+
459
+ // src/client.ts
460
+ function normalize(q) {
461
+ return {
462
+ project: q.project,
463
+ environment: q.environment,
464
+ path: q.path ?? "/",
465
+ expand: q.expand ?? true,
466
+ includeImports: q.includeImports ?? true
467
+ };
468
+ }
469
+ async function fetchFolder(cfg, q, emit, signal) {
470
+ const params = new URLSearchParams({
471
+ path: q.path,
472
+ reveal: "true",
473
+ expand: String(q.expand),
474
+ includeImports: String(q.includeImports)
475
+ });
476
+ const url = `${cfg.base}/api/v1/projects/${encodeURIComponent(q.project)}/environments/${encodeURIComponent(q.environment)}/secrets?${params}`;
477
+ const body = await withRetry(() => fetchJson(cfg, url, signal), cfg.retry, {
478
+ signal,
479
+ emit
480
+ });
481
+ if (!body || !Array.isArray(body.secrets)) {
482
+ throw new AnchorError(200, "bad_response", "Anchor returned an unexpected response");
483
+ }
484
+ return body.secrets.map(toSecret);
485
+ }
486
+ var copy = (s) => s.importedFrom ? { ...s, importedFrom: { ...s.importedFrom } } : { ...s };
487
+ function createClientCore(options) {
488
+ validateOptions(options);
489
+ const emit = (event) => emitSafely(options.onEvent, event);
490
+ const policy = cachePolicy(options.cache);
491
+ const cache = policy ? new SecretCache(policy, emit) : void 0;
492
+ return {
493
+ ttlMs: policy ? policy.ttlMs : DEFAULT_TTL_MS,
494
+ maxStaleMs: policy ? policy.maxStaleMs : Number.POSITIVE_INFINITY,
495
+ emit,
496
+ async loadEntry(q, o = {}) {
497
+ const cfg = resolveConfig(options);
498
+ const query = normalize(q);
499
+ if (!cache) {
500
+ return { secrets: await fetchFolder(cfg, query, emit, o.signal), fetchedAt: Date.now() };
501
+ }
502
+ return cache.load(
503
+ cacheKey(cfg.origin, cfg.token, query),
504
+ (signal) => fetchFolder(cfg, query, emit, signal),
505
+ o
506
+ );
507
+ }
508
+ };
509
+ }
510
+ function createAnchorClient(options = {}) {
511
+ const core = createClientCore(options);
512
+ return {
513
+ async listSecrets(q, o) {
514
+ return (await core.loadEntry(q, o)).secrets.map(copy);
515
+ },
516
+ async loadSecrets(q, o) {
517
+ return decodeAll((await core.loadEntry(q, o)).secrets);
518
+ },
519
+ folder: (q, o) => openFolder(core, q, o)
520
+ };
521
+ }
522
+ export {
523
+ AnchorError,
524
+ createAnchorClient,
525
+ memoryStore
526
+ };
@@ -0,0 +1,8 @@
1
+ import type { AnchorEvent, RetryOptions } from "./types.cjs";
2
+ export type RetryPolicy = Required<RetryOptions>;
3
+ export declare const DEFAULT_RETRY: RetryPolicy;
4
+ /** Runs `run` until it succeeds, fails with a non-retryable error, or runs out of attempts. */
5
+ export declare function withRetry<T>(run: () => Promise<T>, policy: RetryPolicy | false, opts: {
6
+ signal?: AbortSignal;
7
+ emit: (event: AnchorEvent) => void;
8
+ }): Promise<T>;
@@ -0,0 +1,8 @@
1
+ import type { AnchorEvent, RetryOptions } from "./types.js";
2
+ export type RetryPolicy = Required<RetryOptions>;
3
+ export declare const DEFAULT_RETRY: RetryPolicy;
4
+ /** Runs `run` until it succeeds, fails with a non-retryable error, or runs out of attempts. */
5
+ export declare function withRetry<T>(run: () => Promise<T>, policy: RetryPolicy | false, opts: {
6
+ signal?: AbortSignal;
7
+ emit: (event: AnchorEvent) => void;
8
+ }): Promise<T>;
@@ -0,0 +1,6 @@
1
+ import type { Config } from "./config.cjs";
2
+ /**
3
+ * One GET: the parsed body of a 2xx, or an `AnchorError` whose `retryable` says whether another
4
+ * attempt may succeed. Aborts after `cfg.timeoutMs`, or as soon as `signal` aborts.
5
+ */
6
+ export declare function fetchJson(cfg: Config, url: string, signal?: AbortSignal): Promise<unknown>;
@@ -0,0 +1,6 @@
1
+ import type { Config } from "./config.js";
2
+ /**
3
+ * One GET: the parsed body of a 2xx, or an `AnchorError` whose `retryable` says whether another
4
+ * attempt may succeed. Aborts after `cfg.timeoutMs`, or as soon as `signal` aborts.
5
+ */
6
+ export declare function fetchJson(cfg: Config, url: string, signal?: AbortSignal): Promise<unknown>;
@@ -0,0 +1,128 @@
1
+ /** Public types of the SDK. They never come from `@anchor/shared`, which is bundled. */
2
+ import type { AnchorError } from "./errors.cjs";
3
+ /** The value type a secret declares. */
4
+ export type AnchorValueType = "string" | "json" | "pem" | "number" | "boolean" | "list";
5
+ /** A decoded secret value, as `loadSecrets` returns it. */
6
+ export type AnchorValue = string | number | boolean | unknown[] | Record<string, unknown>;
7
+ /** One secret, with its value as the server returned it (a string). */
8
+ export type AnchorSecret = {
9
+ key: string;
10
+ path: string;
11
+ type: AnchorValueType;
12
+ sensitive: boolean;
13
+ version: number;
14
+ value: string;
15
+ /** Set when the secret comes from a folder this folder imports. */
16
+ importedFrom?: {
17
+ environment: string;
18
+ path: string;
19
+ };
20
+ };
21
+ export type ClientOptions = {
22
+ /** Base URL of the Anchor server. Defaults to `ANCHOR_API_URL`. */
23
+ apiUrl?: string;
24
+ /** A service token or another bearer Anchor accepts. Defaults to `ANCHOR_TOKEN`. */
25
+ token?: string;
26
+ /** Replaces the global `fetch`, for proxies and tests. */
27
+ fetch?: typeof fetch;
28
+ /** Per attempt; default 10 000 ms. */
29
+ timeoutMs?: number;
30
+ /** Retries of network errors, timeouts and HTTP 429/502/503/504; `false` turns them off. */
31
+ retry?: RetryOptions | false;
32
+ /** Caching of fetched folders; `false` turns it off. */
33
+ cache?: CacheOptions | false;
34
+ /** Receives retries and failed refreshes. Exceptions it throws are ignored. */
35
+ onEvent?: (event: AnchorEvent) => void;
36
+ };
37
+ export type RetryOptions = {
38
+ /** Attempts in total, including the first; default 3. */
39
+ attempts?: number;
40
+ /** Upper bound of the first wait; it doubles per attempt. Default 250 ms. */
41
+ baseDelayMs?: number;
42
+ /** Upper bound of any wait, including `Retry-After`; default 5 000 ms. */
43
+ maxDelayMs?: number;
44
+ };
45
+ /** What the client reports through `onEvent`. Never carries a token or a value. */
46
+ export type AnchorEvent = {
47
+ type: "retry";
48
+ attempt: number;
49
+ delayMs: number;
50
+ error: AnchorError;
51
+ } | {
52
+ type: "refresh_failed";
53
+ error: AnchorError;
54
+ staleForMs: number;
55
+ }
56
+ /** A custom `CacheStore` threw; the read went to Anchor, or the fetched values were not kept. */
57
+ | {
58
+ type: "store_failed";
59
+ operation: "get" | "set";
60
+ error: unknown;
61
+ };
62
+ export type ListQuery = {
63
+ /** Project slug. */
64
+ project: string;
65
+ /** Environment slug. */
66
+ environment: string;
67
+ /** Folder path; default `/`. */
68
+ path?: string;
69
+ /** Expand `${...}` references; default `true`, like `anchor run`. */
70
+ expand?: boolean;
71
+ /** Add the secrets of the folder's imports; default `true`, like `anchor run`. */
72
+ includeImports?: boolean;
73
+ };
74
+ export type RequestOptions = {
75
+ /** Cancels the call; it rejects with code `aborted`. */
76
+ signal?: AbortSignal;
77
+ /** Ask the server even when the cached folder is fresh; still falls back to it on failure. */
78
+ fresh?: boolean;
79
+ };
80
+ export interface AnchorClient {
81
+ /** The secrets of one folder, with values. */
82
+ listSecrets(query: ListQuery, options?: RequestOptions): Promise<AnchorSecret[]>;
83
+ /** The same secrets as `{ KEY: value }`, with typed values decoded. */
84
+ loadSecrets(query: ListQuery, options?: RequestOptions): Promise<Record<string, AnchorValue>>;
85
+ /** Loads the folder once, then serves it synchronously and refreshes it in the background. */
86
+ folder(query: ListQuery, options?: {
87
+ signal?: AbortSignal;
88
+ }): Promise<AnchorFolder>;
89
+ }
90
+ /**
91
+ * One folder's decoded secrets. Reads are synchronous and never wait for the network: a read of
92
+ * values older than the cache ttl starts one background refresh and returns the current values.
93
+ * Once a refresh has failed past `ttlMs + maxStaleMs`, reads throw `AnchorError` code `stale`
94
+ * until Anchor answers again.
95
+ */
96
+ export interface AnchorFolder {
97
+ /** The value of `key`, or `undefined` when the folder has no such secret. */
98
+ get<T = AnchorValue>(key: string): T | undefined;
99
+ /** The value of `key`; throws `Anchor secret <KEY> is not set` when it is missing. */
100
+ getOrThrow<T = AnchorValue>(key: string): T;
101
+ has(key: string): boolean;
102
+ keys(): string[];
103
+ /** Every value, frozen; the same object until new values arrive. */
104
+ toObject(): Readonly<Record<string, AnchorValue>>;
105
+ /** Fetches now; rejects when that fails, keeping the current values. */
106
+ refresh(): Promise<void>;
107
+ }
108
+ /** What the cache keeps per folder. `secrets` hold plaintext values. */
109
+ export type CacheEntry = {
110
+ secrets: AnchorSecret[];
111
+ fetchedAt: number;
112
+ };
113
+ /**
114
+ * Where cached folders live; the default keeps them in memory. A custom store (a file, Redis)
115
+ * receives plaintext values and must protect them, for example by encrypting them.
116
+ */
117
+ export interface CacheStore {
118
+ get(key: string): CacheEntry | undefined | Promise<CacheEntry | undefined>;
119
+ set(key: string, entry: CacheEntry): void | Promise<void>;
120
+ }
121
+ export type CacheOptions = {
122
+ /** How long a fetched folder is served without asking the server; default 60 000 ms. */
123
+ ttlMs?: number;
124
+ /** How long past `ttlMs` a folder may still be served when the server fails; default no limit. */
125
+ maxStaleMs?: number;
126
+ /** Default: an in-memory store per client. */
127
+ store?: CacheStore;
128
+ };