@anchor-secrets/sdk 0.4.0 → 0.5.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/README.md +7 -3
- package/dist/cache.d.cts +9 -1
- package/dist/cache.d.ts +9 -1
- package/dist/folder.d.cts +2 -0
- package/dist/folder.d.ts +2 -0
- package/dist/index.cjs +59 -21
- package/dist/index.js +59 -21
- package/dist/transport.d.cts +9 -3
- package/dist/transport.d.ts +9 -3
- package/dist/types.d.cts +2 -0
- package/dist/types.d.ts +2 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -35,8 +35,11 @@ folder.get<string>("DATABASE_URL"); // synchronous, never waits for the network
|
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
- `client.folder(query)` loads once and returns an `AnchorFolder`: `get`, `getOrThrow`, `has`,
|
|
38
|
-
`keys`, `toObject` (frozen) and `refresh()`. A read of values older than the
|
|
39
|
-
them at once and starts one background refresh.
|
|
38
|
+
`keys`, `toObject` (frozen all the way down) and `refresh()`. A read of values older than the
|
|
39
|
+
cache ttl returns them at once and starts one background refresh; there is no timer.
|
|
40
|
+
- A refresh sends the folder's last `ETag` as `If-None-Match`. When nothing changed, Anchor
|
|
41
|
+
answers `304` with no body and the cached values are kept. `cache: false` sends no
|
|
42
|
+
`If-None-Match`.
|
|
40
43
|
- `loadSecrets(query)` returns `{ KEY: value }` with typed values decoded: `number` and `boolean`
|
|
41
44
|
become numbers and booleans, `json` and `list` are parsed, `string` and `pem` stay strings.
|
|
42
45
|
`listSecrets(query)` returns the secrets with their metadata (`key`, `path`, `type`,
|
|
@@ -74,7 +77,8 @@ Every failure is an `AnchorError` with `status` (0 when there was no response),
|
|
|
74
77
|
`aborted`, `stale`, or the server's error code such as `unauthenticated`, `forbidden` or `not_found`.
|
|
75
78
|
Messages and events never contain tokens or values.
|
|
76
79
|
|
|
77
|
-
Every
|
|
80
|
+
Every request that returns values is recorded in the project's audit log under the token's
|
|
81
|
+
name. A `304` returns no values and is not recorded.
|
|
78
82
|
|
|
79
83
|
## License
|
|
80
84
|
|
package/dist/cache.d.cts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { AnchorEvent, AnchorSecret, CacheEntry, CacheOptions, CacheStore, ListQuery } from "./types.cjs";
|
|
2
2
|
export type { CacheEntry, CacheOptions, CacheStore } from "./types.cjs";
|
|
3
3
|
export type CachePolicy = Required<CacheOptions>;
|
|
4
|
+
/** What a fetch got from Anchor: new secrets and their tag, or a 304 for the tag it sent. */
|
|
5
|
+
export type Fetched = {
|
|
6
|
+
secrets: AnchorSecret[];
|
|
7
|
+
etag?: string;
|
|
8
|
+
} | "unchanged";
|
|
9
|
+
type FetchFn = (signal: AbortSignal, etag: string | undefined) => Promise<Fetched>;
|
|
4
10
|
export declare function memoryStore(): CacheStore;
|
|
5
11
|
/**
|
|
6
12
|
* The cache key of one folder as one token sees it. The token is reduced to a SHA-256
|
|
@@ -19,12 +25,14 @@ export declare class SecretCache {
|
|
|
19
25
|
private readonly inflight;
|
|
20
26
|
/** When the last fetch of a key failed; the stale entry is served without asking for one ttl. */
|
|
21
27
|
private readonly failedAt;
|
|
28
|
+
/** The newest entry this instance fetched per key, in case the store returns an older one. */
|
|
29
|
+
private readonly latest;
|
|
22
30
|
constructor(policy: CachePolicy, emit: (event: AnchorEvent) => void, now?: () => number);
|
|
23
31
|
/**
|
|
24
32
|
* The folder's entry. With `strict`, a failed fetch rejects instead of falling back, so a
|
|
25
33
|
* caller that asked for new values learns it did not get them.
|
|
26
34
|
*/
|
|
27
|
-
load(key: string, fetch:
|
|
35
|
+
load(key: string, fetch: FetchFn, opts?: {
|
|
28
36
|
fresh?: boolean;
|
|
29
37
|
signal?: AbortSignal;
|
|
30
38
|
strict?: boolean;
|
package/dist/cache.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { AnchorEvent, AnchorSecret, CacheEntry, CacheOptions, CacheStore, ListQuery } from "./types.js";
|
|
2
2
|
export type { CacheEntry, CacheOptions, CacheStore } from "./types.js";
|
|
3
3
|
export type CachePolicy = Required<CacheOptions>;
|
|
4
|
+
/** What a fetch got from Anchor: new secrets and their tag, or a 304 for the tag it sent. */
|
|
5
|
+
export type Fetched = {
|
|
6
|
+
secrets: AnchorSecret[];
|
|
7
|
+
etag?: string;
|
|
8
|
+
} | "unchanged";
|
|
9
|
+
type FetchFn = (signal: AbortSignal, etag: string | undefined) => Promise<Fetched>;
|
|
4
10
|
export declare function memoryStore(): CacheStore;
|
|
5
11
|
/**
|
|
6
12
|
* The cache key of one folder as one token sees it. The token is reduced to a SHA-256
|
|
@@ -19,12 +25,14 @@ export declare class SecretCache {
|
|
|
19
25
|
private readonly inflight;
|
|
20
26
|
/** When the last fetch of a key failed; the stale entry is served without asking for one ttl. */
|
|
21
27
|
private readonly failedAt;
|
|
28
|
+
/** The newest entry this instance fetched per key, in case the store returns an older one. */
|
|
29
|
+
private readonly latest;
|
|
22
30
|
constructor(policy: CachePolicy, emit: (event: AnchorEvent) => void, now?: () => number);
|
|
23
31
|
/**
|
|
24
32
|
* The folder's entry. With `strict`, a failed fetch rejects instead of falling back, so a
|
|
25
33
|
* caller that asked for new values learns it did not get them.
|
|
26
34
|
*/
|
|
27
|
-
load(key: string, fetch:
|
|
35
|
+
load(key: string, fetch: FetchFn, opts?: {
|
|
28
36
|
fresh?: boolean;
|
|
29
37
|
signal?: AbortSignal;
|
|
30
38
|
strict?: boolean;
|
package/dist/folder.d.cts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ClientCore } from "./client.cjs";
|
|
2
2
|
import type { AnchorFolder, ListQuery } from "./types.cjs";
|
|
3
|
+
/** Freezes plain objects and arrays all the way down: one folder's values are shared by every reader. */
|
|
4
|
+
export declare function deepFreeze<T>(value: T): T;
|
|
3
5
|
export declare function openFolder(core: ClientCore, query: ListQuery, options?: {
|
|
4
6
|
signal?: AbortSignal;
|
|
5
7
|
}): Promise<AnchorFolder>;
|
package/dist/folder.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ClientCore } from "./client.js";
|
|
2
2
|
import type { AnchorFolder, ListQuery } from "./types.js";
|
|
3
|
+
/** Freezes plain objects and arrays all the way down: one folder's values are shared by every reader. */
|
|
4
|
+
export declare function deepFreeze<T>(value: T): T;
|
|
3
5
|
export declare function openFolder(core: ClientCore, query: ListQuery, options?: {
|
|
4
6
|
signal?: AbortSignal;
|
|
5
7
|
}): Promise<AnchorFolder>;
|
package/dist/index.cjs
CHANGED
|
@@ -70,6 +70,7 @@ function cacheKey(origin, token, q) {
|
|
|
70
70
|
q.includeImports
|
|
71
71
|
]);
|
|
72
72
|
}
|
|
73
|
+
var newer = (a, b) => a && b ? b.fetchedAt > a.fetchedAt ? b : a : a ?? b;
|
|
73
74
|
function untilAborted(promise, signal) {
|
|
74
75
|
if (!signal) return promise;
|
|
75
76
|
if (signal.aborted) return Promise.reject(abortedError());
|
|
@@ -91,15 +92,20 @@ var SecretCache = class {
|
|
|
91
92
|
inflight = /* @__PURE__ */ new Map();
|
|
92
93
|
/** When the last fetch of a key failed; the stale entry is served without asking for one ttl. */
|
|
93
94
|
failedAt = /* @__PURE__ */ new Map();
|
|
95
|
+
/** The newest entry this instance fetched per key, in case the store returns an older one. */
|
|
96
|
+
latest = /* @__PURE__ */ new Map();
|
|
94
97
|
/**
|
|
95
98
|
* The folder's entry. With `strict`, a failed fetch rejects instead of falling back, so a
|
|
96
99
|
* caller that asked for new values learns it did not get them.
|
|
97
100
|
*/
|
|
98
101
|
async load(key, fetch, opts = {}) {
|
|
99
102
|
if (opts.signal?.aborted) throw abortedError();
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
+
let flight = opts.fresh ? void 0 : this.inflight.get(key);
|
|
104
|
+
if (!flight) {
|
|
105
|
+
const entry = newer(await this.read(key), this.latest.get(key));
|
|
106
|
+
if (entry && !opts.fresh && this.servable(key, entry)) return entry;
|
|
107
|
+
flight = this.inflight.get(key) ?? this.start(key, fetch, entry);
|
|
108
|
+
}
|
|
103
109
|
flight.waiters++;
|
|
104
110
|
let outcome;
|
|
105
111
|
try {
|
|
@@ -118,7 +124,7 @@ var SecretCache = class {
|
|
|
118
124
|
const controller = new AbortController();
|
|
119
125
|
const promise = this.fetch(
|
|
120
126
|
key,
|
|
121
|
-
() => fetch(controller.signal),
|
|
127
|
+
() => fetch(controller.signal, stale?.etag),
|
|
122
128
|
stale,
|
|
123
129
|
controller.signal
|
|
124
130
|
).finally(() => {
|
|
@@ -155,7 +161,16 @@ var SecretCache = class {
|
|
|
155
161
|
}
|
|
156
162
|
async fetch(key, fetch, stale, signal) {
|
|
157
163
|
try {
|
|
158
|
-
const
|
|
164
|
+
const fetched = await fetch();
|
|
165
|
+
if (fetched === "unchanged" && !stale) {
|
|
166
|
+
throw new AnchorError(
|
|
167
|
+
304,
|
|
168
|
+
"bad_response",
|
|
169
|
+
"Anchor answered 304 to a request without a tag"
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
const entry = fetched === "unchanged" ? { secrets: stale.secrets, fetchedAt: this.now(), etag: stale?.etag } : { secrets: fetched.secrets, fetchedAt: this.now(), etag: fetched.etag };
|
|
173
|
+
this.latest.set(key, entry);
|
|
159
174
|
await this.write(key, entry);
|
|
160
175
|
this.failedAt.delete(key);
|
|
161
176
|
return { entry };
|
|
@@ -180,6 +195,7 @@ function delayFor(error, attempt, policy) {
|
|
|
180
195
|
}
|
|
181
196
|
function wait(ms, signal) {
|
|
182
197
|
return new Promise((resolve, reject) => {
|
|
198
|
+
if (signal?.aborted) return reject(abortedError());
|
|
183
199
|
const onAbort = () => {
|
|
184
200
|
clearTimeout(timer);
|
|
185
201
|
reject(abortedError());
|
|
@@ -354,18 +370,27 @@ function decodeAll(secrets) {
|
|
|
354
370
|
}
|
|
355
371
|
|
|
356
372
|
// src/folder.ts
|
|
373
|
+
function deepFreeze(value) {
|
|
374
|
+
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
|
375
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
376
|
+
Object.freeze(value);
|
|
377
|
+
}
|
|
378
|
+
return value;
|
|
379
|
+
}
|
|
357
380
|
var snapshotOf = (entry) => ({
|
|
358
381
|
entry,
|
|
359
|
-
values:
|
|
382
|
+
values: deepFreeze(decodeAll(entry.secrets))
|
|
360
383
|
});
|
|
361
384
|
async function openFolder(core, query, options = {}) {
|
|
362
385
|
let snapshot = snapshotOf(await core.loadEntry(query, { signal: options.signal }));
|
|
363
|
-
let checkedAt =
|
|
386
|
+
let checkedAt = snapshot.entry.fetchedAt;
|
|
364
387
|
let failed = false;
|
|
365
388
|
let pending;
|
|
366
389
|
const accept = (entry) => {
|
|
367
390
|
failed = false;
|
|
368
|
-
if (entry
|
|
391
|
+
if (entry.fetchedAt < snapshot.entry.fetchedAt) return;
|
|
392
|
+
if (entry.secrets === snapshot.entry.secrets) snapshot = { entry, values: snapshot.values };
|
|
393
|
+
else snapshot = snapshotOf(entry);
|
|
369
394
|
};
|
|
370
395
|
const refreshInBackground = () => {
|
|
371
396
|
pending = core.loadEntry(query).then(accept, (error) => {
|
|
@@ -435,7 +460,7 @@ async function errorFromResponse(res) {
|
|
|
435
460
|
}
|
|
436
461
|
return new AnchorError(res.status, "http_error", `Anchor answered HTTP ${res.status}`, options);
|
|
437
462
|
}
|
|
438
|
-
async function fetchJson(cfg, url, signal) {
|
|
463
|
+
async function fetchJson(cfg, url, signal, ifNoneMatch) {
|
|
439
464
|
if (signal?.aborted) throw abortedError();
|
|
440
465
|
const controller = new AbortController();
|
|
441
466
|
let timedOut = false;
|
|
@@ -446,14 +471,17 @@ async function fetchJson(cfg, url, signal) {
|
|
|
446
471
|
const onAbort = () => controller.abort();
|
|
447
472
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
448
473
|
try {
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
474
|
+
const headers = {
|
|
475
|
+
authorization: `Bearer ${cfg.token}`,
|
|
476
|
+
accept: "application/json"
|
|
477
|
+
};
|
|
478
|
+
if (ifNoneMatch) headers["if-none-match"] = ifNoneMatch;
|
|
479
|
+
const res = await cfg.fetch(url, { method: "GET", headers, signal: controller.signal });
|
|
480
|
+
const etag = res.headers.get("etag") ?? void 0;
|
|
481
|
+
if (res.status === 304) return { status: 304, etag, body: void 0 };
|
|
454
482
|
if (!res.ok) throw await errorFromResponse(res);
|
|
455
483
|
try {
|
|
456
|
-
return await res.json();
|
|
484
|
+
return { status: res.status, etag, body: await res.json() };
|
|
457
485
|
} catch (cause) {
|
|
458
486
|
if (controller.signal.aborted) throw cause;
|
|
459
487
|
throw new AnchorError(
|
|
@@ -494,7 +522,7 @@ function normalize(q) {
|
|
|
494
522
|
includeImports: q.includeImports ?? true
|
|
495
523
|
};
|
|
496
524
|
}
|
|
497
|
-
async function fetchFolder(cfg, q, emit, signal) {
|
|
525
|
+
async function fetchFolder(cfg, q, emit, signal, etag) {
|
|
498
526
|
const params = new URLSearchParams({
|
|
499
527
|
path: q.path,
|
|
500
528
|
reveal: "true",
|
|
@@ -502,14 +530,16 @@ async function fetchFolder(cfg, q, emit, signal) {
|
|
|
502
530
|
includeImports: String(q.includeImports)
|
|
503
531
|
});
|
|
504
532
|
const url = `${cfg.base}/api/v1/projects/${encodeURIComponent(q.project)}/environments/${encodeURIComponent(q.environment)}/secrets?${params}`;
|
|
505
|
-
const
|
|
533
|
+
const res = await withRetry(() => fetchJson(cfg, url, signal, etag), cfg.retry, {
|
|
506
534
|
signal,
|
|
507
535
|
emit
|
|
508
536
|
});
|
|
537
|
+
if (res.status === 304) return "unchanged";
|
|
538
|
+
const body = res.body;
|
|
509
539
|
if (!body || !Array.isArray(body.secrets)) {
|
|
510
|
-
throw new AnchorError(
|
|
540
|
+
throw new AnchorError(res.status, "bad_response", "Anchor returned an unexpected response");
|
|
511
541
|
}
|
|
512
|
-
return body.secrets.map(toSecret);
|
|
542
|
+
return { secrets: body.secrets.map(toSecret), etag: res.etag };
|
|
513
543
|
}
|
|
514
544
|
var copy = (s) => s.importedFrom ? { ...s, importedFrom: { ...s.importedFrom } } : { ...s };
|
|
515
545
|
function createClientCore(options) {
|
|
@@ -525,11 +555,19 @@ function createClientCore(options) {
|
|
|
525
555
|
const cfg = resolveConfig(options);
|
|
526
556
|
const query = normalize(q);
|
|
527
557
|
if (!cache) {
|
|
528
|
-
|
|
558
|
+
const fetched = await fetchFolder(cfg, query, emit, o.signal);
|
|
559
|
+
if (fetched === "unchanged") {
|
|
560
|
+
throw new AnchorError(
|
|
561
|
+
304,
|
|
562
|
+
"bad_response",
|
|
563
|
+
"Anchor answered 304 to a request without a tag"
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
return { secrets: fetched.secrets, fetchedAt: Date.now() };
|
|
529
567
|
}
|
|
530
568
|
return cache.load(
|
|
531
569
|
cacheKey(cfg.origin, cfg.token, query),
|
|
532
|
-
(signal) => fetchFolder(cfg, query, emit, signal),
|
|
570
|
+
(signal, etag) => fetchFolder(cfg, query, emit, signal, etag),
|
|
533
571
|
o
|
|
534
572
|
);
|
|
535
573
|
}
|
package/dist/index.js
CHANGED
|
@@ -42,6 +42,7 @@ function cacheKey(origin, token, q) {
|
|
|
42
42
|
q.includeImports
|
|
43
43
|
]);
|
|
44
44
|
}
|
|
45
|
+
var newer = (a, b) => a && b ? b.fetchedAt > a.fetchedAt ? b : a : a ?? b;
|
|
45
46
|
function untilAborted(promise, signal) {
|
|
46
47
|
if (!signal) return promise;
|
|
47
48
|
if (signal.aborted) return Promise.reject(abortedError());
|
|
@@ -63,15 +64,20 @@ var SecretCache = class {
|
|
|
63
64
|
inflight = /* @__PURE__ */ new Map();
|
|
64
65
|
/** When the last fetch of a key failed; the stale entry is served without asking for one ttl. */
|
|
65
66
|
failedAt = /* @__PURE__ */ new Map();
|
|
67
|
+
/** The newest entry this instance fetched per key, in case the store returns an older one. */
|
|
68
|
+
latest = /* @__PURE__ */ new Map();
|
|
66
69
|
/**
|
|
67
70
|
* The folder's entry. With `strict`, a failed fetch rejects instead of falling back, so a
|
|
68
71
|
* caller that asked for new values learns it did not get them.
|
|
69
72
|
*/
|
|
70
73
|
async load(key, fetch, opts = {}) {
|
|
71
74
|
if (opts.signal?.aborted) throw abortedError();
|
|
72
|
-
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
+
let flight = opts.fresh ? void 0 : this.inflight.get(key);
|
|
76
|
+
if (!flight) {
|
|
77
|
+
const entry = newer(await this.read(key), this.latest.get(key));
|
|
78
|
+
if (entry && !opts.fresh && this.servable(key, entry)) return entry;
|
|
79
|
+
flight = this.inflight.get(key) ?? this.start(key, fetch, entry);
|
|
80
|
+
}
|
|
75
81
|
flight.waiters++;
|
|
76
82
|
let outcome;
|
|
77
83
|
try {
|
|
@@ -90,7 +96,7 @@ var SecretCache = class {
|
|
|
90
96
|
const controller = new AbortController();
|
|
91
97
|
const promise = this.fetch(
|
|
92
98
|
key,
|
|
93
|
-
() => fetch(controller.signal),
|
|
99
|
+
() => fetch(controller.signal, stale?.etag),
|
|
94
100
|
stale,
|
|
95
101
|
controller.signal
|
|
96
102
|
).finally(() => {
|
|
@@ -127,7 +133,16 @@ var SecretCache = class {
|
|
|
127
133
|
}
|
|
128
134
|
async fetch(key, fetch, stale, signal) {
|
|
129
135
|
try {
|
|
130
|
-
const
|
|
136
|
+
const fetched = await fetch();
|
|
137
|
+
if (fetched === "unchanged" && !stale) {
|
|
138
|
+
throw new AnchorError(
|
|
139
|
+
304,
|
|
140
|
+
"bad_response",
|
|
141
|
+
"Anchor answered 304 to a request without a tag"
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const entry = fetched === "unchanged" ? { secrets: stale.secrets, fetchedAt: this.now(), etag: stale?.etag } : { secrets: fetched.secrets, fetchedAt: this.now(), etag: fetched.etag };
|
|
145
|
+
this.latest.set(key, entry);
|
|
131
146
|
await this.write(key, entry);
|
|
132
147
|
this.failedAt.delete(key);
|
|
133
148
|
return { entry };
|
|
@@ -152,6 +167,7 @@ function delayFor(error, attempt, policy) {
|
|
|
152
167
|
}
|
|
153
168
|
function wait(ms, signal) {
|
|
154
169
|
return new Promise((resolve, reject) => {
|
|
170
|
+
if (signal?.aborted) return reject(abortedError());
|
|
155
171
|
const onAbort = () => {
|
|
156
172
|
clearTimeout(timer);
|
|
157
173
|
reject(abortedError());
|
|
@@ -326,18 +342,27 @@ function decodeAll(secrets) {
|
|
|
326
342
|
}
|
|
327
343
|
|
|
328
344
|
// src/folder.ts
|
|
345
|
+
function deepFreeze(value) {
|
|
346
|
+
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
|
347
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
348
|
+
Object.freeze(value);
|
|
349
|
+
}
|
|
350
|
+
return value;
|
|
351
|
+
}
|
|
329
352
|
var snapshotOf = (entry) => ({
|
|
330
353
|
entry,
|
|
331
|
-
values:
|
|
354
|
+
values: deepFreeze(decodeAll(entry.secrets))
|
|
332
355
|
});
|
|
333
356
|
async function openFolder(core, query, options = {}) {
|
|
334
357
|
let snapshot = snapshotOf(await core.loadEntry(query, { signal: options.signal }));
|
|
335
|
-
let checkedAt =
|
|
358
|
+
let checkedAt = snapshot.entry.fetchedAt;
|
|
336
359
|
let failed = false;
|
|
337
360
|
let pending;
|
|
338
361
|
const accept = (entry) => {
|
|
339
362
|
failed = false;
|
|
340
|
-
if (entry
|
|
363
|
+
if (entry.fetchedAt < snapshot.entry.fetchedAt) return;
|
|
364
|
+
if (entry.secrets === snapshot.entry.secrets) snapshot = { entry, values: snapshot.values };
|
|
365
|
+
else snapshot = snapshotOf(entry);
|
|
341
366
|
};
|
|
342
367
|
const refreshInBackground = () => {
|
|
343
368
|
pending = core.loadEntry(query).then(accept, (error) => {
|
|
@@ -407,7 +432,7 @@ async function errorFromResponse(res) {
|
|
|
407
432
|
}
|
|
408
433
|
return new AnchorError(res.status, "http_error", `Anchor answered HTTP ${res.status}`, options);
|
|
409
434
|
}
|
|
410
|
-
async function fetchJson(cfg, url, signal) {
|
|
435
|
+
async function fetchJson(cfg, url, signal, ifNoneMatch) {
|
|
411
436
|
if (signal?.aborted) throw abortedError();
|
|
412
437
|
const controller = new AbortController();
|
|
413
438
|
let timedOut = false;
|
|
@@ -418,14 +443,17 @@ async function fetchJson(cfg, url, signal) {
|
|
|
418
443
|
const onAbort = () => controller.abort();
|
|
419
444
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
420
445
|
try {
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
446
|
+
const headers = {
|
|
447
|
+
authorization: `Bearer ${cfg.token}`,
|
|
448
|
+
accept: "application/json"
|
|
449
|
+
};
|
|
450
|
+
if (ifNoneMatch) headers["if-none-match"] = ifNoneMatch;
|
|
451
|
+
const res = await cfg.fetch(url, { method: "GET", headers, signal: controller.signal });
|
|
452
|
+
const etag = res.headers.get("etag") ?? void 0;
|
|
453
|
+
if (res.status === 304) return { status: 304, etag, body: void 0 };
|
|
426
454
|
if (!res.ok) throw await errorFromResponse(res);
|
|
427
455
|
try {
|
|
428
|
-
return await res.json();
|
|
456
|
+
return { status: res.status, etag, body: await res.json() };
|
|
429
457
|
} catch (cause) {
|
|
430
458
|
if (controller.signal.aborted) throw cause;
|
|
431
459
|
throw new AnchorError(
|
|
@@ -466,7 +494,7 @@ function normalize(q) {
|
|
|
466
494
|
includeImports: q.includeImports ?? true
|
|
467
495
|
};
|
|
468
496
|
}
|
|
469
|
-
async function fetchFolder(cfg, q, emit, signal) {
|
|
497
|
+
async function fetchFolder(cfg, q, emit, signal, etag) {
|
|
470
498
|
const params = new URLSearchParams({
|
|
471
499
|
path: q.path,
|
|
472
500
|
reveal: "true",
|
|
@@ -474,14 +502,16 @@ async function fetchFolder(cfg, q, emit, signal) {
|
|
|
474
502
|
includeImports: String(q.includeImports)
|
|
475
503
|
});
|
|
476
504
|
const url = `${cfg.base}/api/v1/projects/${encodeURIComponent(q.project)}/environments/${encodeURIComponent(q.environment)}/secrets?${params}`;
|
|
477
|
-
const
|
|
505
|
+
const res = await withRetry(() => fetchJson(cfg, url, signal, etag), cfg.retry, {
|
|
478
506
|
signal,
|
|
479
507
|
emit
|
|
480
508
|
});
|
|
509
|
+
if (res.status === 304) return "unchanged";
|
|
510
|
+
const body = res.body;
|
|
481
511
|
if (!body || !Array.isArray(body.secrets)) {
|
|
482
|
-
throw new AnchorError(
|
|
512
|
+
throw new AnchorError(res.status, "bad_response", "Anchor returned an unexpected response");
|
|
483
513
|
}
|
|
484
|
-
return body.secrets.map(toSecret);
|
|
514
|
+
return { secrets: body.secrets.map(toSecret), etag: res.etag };
|
|
485
515
|
}
|
|
486
516
|
var copy = (s) => s.importedFrom ? { ...s, importedFrom: { ...s.importedFrom } } : { ...s };
|
|
487
517
|
function createClientCore(options) {
|
|
@@ -497,11 +527,19 @@ function createClientCore(options) {
|
|
|
497
527
|
const cfg = resolveConfig(options);
|
|
498
528
|
const query = normalize(q);
|
|
499
529
|
if (!cache) {
|
|
500
|
-
|
|
530
|
+
const fetched = await fetchFolder(cfg, query, emit, o.signal);
|
|
531
|
+
if (fetched === "unchanged") {
|
|
532
|
+
throw new AnchorError(
|
|
533
|
+
304,
|
|
534
|
+
"bad_response",
|
|
535
|
+
"Anchor answered 304 to a request without a tag"
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
return { secrets: fetched.secrets, fetchedAt: Date.now() };
|
|
501
539
|
}
|
|
502
540
|
return cache.load(
|
|
503
541
|
cacheKey(cfg.origin, cfg.token, query),
|
|
504
|
-
(signal) => fetchFolder(cfg, query, emit, signal),
|
|
542
|
+
(signal, etag) => fetchFolder(cfg, query, emit, signal, etag),
|
|
505
543
|
o
|
|
506
544
|
);
|
|
507
545
|
}
|
package/dist/transport.d.cts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { Config } from "./config.cjs";
|
|
2
|
+
export type JsonResponse = {
|
|
3
|
+
status: number;
|
|
4
|
+
etag: string | undefined;
|
|
5
|
+
body: unknown;
|
|
6
|
+
};
|
|
2
7
|
/**
|
|
3
|
-
* One GET: the parsed body of a 2xx
|
|
4
|
-
* attempt may succeed. Aborts after
|
|
8
|
+
* One GET: the parsed body of a 2xx (none for a 304 answering `ifNoneMatch`), or an
|
|
9
|
+
* `AnchorError` whose `retryable` says whether another attempt may succeed. Aborts after
|
|
10
|
+
* `cfg.timeoutMs`, or as soon as `signal` aborts.
|
|
5
11
|
*/
|
|
6
|
-
export declare function fetchJson(cfg: Config, url: string, signal?: AbortSignal): Promise<
|
|
12
|
+
export declare function fetchJson(cfg: Config, url: string, signal?: AbortSignal, ifNoneMatch?: string): Promise<JsonResponse>;
|
package/dist/transport.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { Config } from "./config.js";
|
|
2
|
+
export type JsonResponse = {
|
|
3
|
+
status: number;
|
|
4
|
+
etag: string | undefined;
|
|
5
|
+
body: unknown;
|
|
6
|
+
};
|
|
2
7
|
/**
|
|
3
|
-
* One GET: the parsed body of a 2xx
|
|
4
|
-
* attempt may succeed. Aborts after
|
|
8
|
+
* One GET: the parsed body of a 2xx (none for a 304 answering `ifNoneMatch`), or an
|
|
9
|
+
* `AnchorError` whose `retryable` says whether another attempt may succeed. Aborts after
|
|
10
|
+
* `cfg.timeoutMs`, or as soon as `signal` aborts.
|
|
5
11
|
*/
|
|
6
|
-
export declare function fetchJson(cfg: Config, url: string, signal?: AbortSignal): Promise<
|
|
12
|
+
export declare function fetchJson(cfg: Config, url: string, signal?: AbortSignal, ifNoneMatch?: string): Promise<JsonResponse>;
|
package/dist/types.d.cts
CHANGED
|
@@ -109,6 +109,8 @@ export interface AnchorFolder {
|
|
|
109
109
|
export type CacheEntry = {
|
|
110
110
|
secrets: AnchorSecret[];
|
|
111
111
|
fetchedAt: number;
|
|
112
|
+
/** The server's tag for these secrets, sent back as `If-None-Match`; absent before 0.5.0. */
|
|
113
|
+
etag?: string;
|
|
112
114
|
};
|
|
113
115
|
/**
|
|
114
116
|
* Where cached folders live; the default keeps them in memory. A custom store (a file, Redis)
|
package/dist/types.d.ts
CHANGED
|
@@ -109,6 +109,8 @@ export interface AnchorFolder {
|
|
|
109
109
|
export type CacheEntry = {
|
|
110
110
|
secrets: AnchorSecret[];
|
|
111
111
|
fetchedAt: number;
|
|
112
|
+
/** The server's tag for these secrets, sent back as `If-None-Match`; absent before 0.5.0. */
|
|
113
|
+
etag?: string;
|
|
112
114
|
};
|
|
113
115
|
/**
|
|
114
116
|
* Where cached folders live; the default keeps them in memory. A custom store (a file, Redis)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchor-secrets/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Node client for Anchor, a self-hosted secrets manager on Cloudflare Workers",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"secrets",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"node": ">=20"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@anchor/shared": "0.
|
|
50
|
+
"@anchor/shared": "0.5.0",
|
|
51
51
|
"@arethetypeswrong/cli": "^0.18.5",
|
|
52
52
|
"@types/node": "^22.20.5",
|
|
53
53
|
"esbuild": "^0.28.2",
|