@rezwanul7/settings 1.0.0 → 1.1.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 CHANGED
@@ -307,15 +307,17 @@ again later. `reset()` deletes without a version check. `MemoryStore` keeps an O
307
307
 
308
308
  ## Secret settings
309
309
 
310
- The package doesn't encrypt anything. Keep a secret setting, such as an SMTP password, sealed with
311
- [`@rezwanul7/secret-vault`](../secret-vault/README.md): seal it before `set()`, store the Sealed value as a string,
312
- and open it after `get()`. Give the Context the Owner, so a Sealed value copied to another Owner doesn't open.
310
+ Keep a secret setting, such as an SMTP password, sealed by wrapping your Store in `SealedStore` with a
311
+ [`@rezwanul7/secret-vault`](../secret-vault/README.md) `Vault`. List the paths to seal; you call `set` and `get` with
312
+ plain values, the schema validates the Plaintext, and the wrapped Store only ever holds a Sealed value there. This
313
+ package doesn't depend on Secret Vault: `vault` only needs `encryptJson`, `decryptJson` and, optionally, `isSealed`
314
+ (without it, a string starting with `vault:` counts as sealed, which is what a `Vault` writes).
313
315
 
314
316
  ```ts
315
317
  import {
316
318
  createSettings,
317
319
  defineSettings,
318
- type Owner,
320
+ SealedStore,
319
321
  } from "@rezwanul7/settings";
320
322
  import { PrismaStore } from "@rezwanul7/settings/prisma";
321
323
  import { z } from "zod";
@@ -326,8 +328,7 @@ const organization = defineSettings({
326
328
  schema: z.object({
327
329
  smtp: z.object({
328
330
  host: z.string(),
329
- // A Sealed value made by the Vault, never the password itself.
330
- password: z.string().startsWith("vault:").nullable(),
331
+ password: z.string().min(1).nullable(),
331
332
  }),
332
333
  }),
333
334
  defaults: { smtp: { host: "localhost", password: null } },
@@ -335,30 +336,74 @@ const organization = defineSettings({
335
336
 
336
337
  const settings = createSettings({
337
338
  definitions: { organization },
338
- store: new PrismaStore({ delegate: prisma.setting }),
339
+ store: new SealedStore({
340
+ store: new PrismaStore({ delegate: prisma.setting }),
341
+ vault,
342
+ paths: ["smtp.password"],
343
+ }),
339
344
  });
340
345
 
341
- function smtpPasswordContext(owner: Owner): string {
342
- return `settings:smtp.password:${owner.scope}:${owner.id}`;
343
- }
346
+ await settings.scope("organization", orgId).set("smtp.password", "hunter2");
347
+ const password = await settings
348
+ .scope("organization", orgId)
349
+ .get("smtp.password"); // "hunter2"
350
+ ```
344
351
 
345
- export async function saveSmtpPassword(orgId: string, password: string) {
346
- const org = settings.scope("organization", orgId);
347
- const sealed = await vault.encrypt(password, smtpPasswordContext(org.owner));
348
- await org.set("smtp.password", sealed);
349
- }
352
+ - Each Sealed value is bound to its Owner and path (Context `settings:` plus `JSON.stringify([scope, id, path])`), so
353
+ one copied to another Owner or path doesn't open. A script that moves a Sealed setting must open it and seal it
354
+ again.
355
+ - A path may name an object, which is sealed whole as JSON. No path may be inside another.
356
+ - A Sealed value that doesn't open, such as after a wrong Vault secret, makes the read **throw**. It is never ignored
357
+ as a stale Override, because the next write would replace the secret with its default.
358
+ - Every write seals again with the Vault's current secret, so a Rotation finishes for an Owner the next time it is
359
+ written.
360
+ - Adding a path to `paths` on a deployment that already has plain values there is safe: they read as before and are
361
+ sealed on each Owner's next write. Values at unlisted paths are never opened.
362
+ - The Effective settings now hold the **Plaintext**. Don't log them or pass them to the client; read the secret on the
363
+ server, only where it is used.
364
+ - Switching from values you sealed by hand: their Context differs, so open each with your old Context once and write
365
+ the Plaintext back through the settings with a script; `SealedStore` then seals it under its own Context.
366
+
367
+ ## Caching reads
368
+
369
+ `CachedStore` keeps what the Store it wraps returns for `ttl` seconds, so reading one Owner's settings in several
370
+ requests doesn't query the database each time. An Owner with nothing stored is cached too. Every write, conflict and
371
+ delete evicts the Owner, so a write on this instance is read back at once and two concurrent writes both take effect.
350
372
 
351
- export async function readSmtpPassword(orgId: string): Promise<string | null> {
352
- const org = settings.scope("organization", orgId);
353
- const sealed = await org.get("smtp.password");
354
- return sealed === null
355
- ? null
356
- : vault.decrypt(sealed, smtpPasswordContext(org.owner));
357
- }
373
+ ```ts
374
+ import { CachedStore, MemoryCache, SealedStore } from "@rezwanul7/settings";
375
+
376
+ const store = new SealedStore({
377
+ store: new CachedStore({
378
+ store: new PrismaStore({ delegate: prisma.setting }),
379
+ cache: new MemoryCache({ maxEntries: 5000 }),
380
+ ttl: 60,
381
+ }),
382
+ vault,
383
+ paths: ["smtp.password"],
384
+ });
358
385
  ```
359
386
 
360
- The Effective settings then hold only the Sealed value, so they can be logged or passed around without exposing the
361
- secret; open it on the server, only where it is used.
387
+ Nest `SealedStore` outermost so the cache only holds Sealed values. A cache that throws is passed to the optional
388
+ `onError` and the Store answers instead; a failing `delete` leaves the entry to expire with its TTL.
389
+
390
+ `MemoryCache` is per process. With several instances, another instance can serve Overrides up to `ttl` seconds old
391
+ after a write elsewhere. Share a cache to avoid that; any object with three string methods fits `SettingsCache`:
392
+
393
+ ```ts
394
+ import type { SettingsCache } from "@rezwanul7/settings";
395
+ import { redis } from "./redis";
396
+
397
+ const redisCache: SettingsCache = {
398
+ get: async (key) => (await redis.get(key)) ?? undefined,
399
+ set: async (key, value, ttlSeconds) => {
400
+ await redis.set(key, value, "EX", Math.ceil(ttlSeconds));
401
+ },
402
+ delete: async (key) => {
403
+ await redis.del(key);
404
+ },
405
+ };
406
+ ```
362
407
 
363
408
  ## Errors
364
409
 
package/dist/index.cjs CHANGED
@@ -469,7 +469,193 @@ function asJson(overrides) {
469
469
  return JSON.parse(JSON.stringify(overrides));
470
470
  }
471
471
 
472
+ // src/sealed-store.ts
473
+ function defaultIsSealed(value) {
474
+ return typeof value === "string" && value.startsWith("vault:");
475
+ }
476
+ function contextOf(owner, path) {
477
+ return `settings:${JSON.stringify([owner.scope, owner.id, path])}`;
478
+ }
479
+ var SealedStore = class {
480
+ #store;
481
+ #vault;
482
+ #paths;
483
+ /**
484
+ * @param options - The Store to wrap, the Vault and the sealed paths.
485
+ * @throws `TypeError` when a path is empty or inside another path.
486
+ */
487
+ constructor(options) {
488
+ this.#store = options.store;
489
+ this.#vault = options.vault;
490
+ this.#paths = [...new Set(options.paths)];
491
+ for (const path of this.#paths) {
492
+ if (path === "") throw new TypeError("SealedStore: a path is empty.");
493
+ const inner = this.#paths.find((p) => p.startsWith(`${path}.`));
494
+ if (inner !== void 0) {
495
+ throw new TypeError(
496
+ `SealedStore: path "${inner}" is inside path "${path}".`
497
+ );
498
+ }
499
+ }
500
+ }
501
+ /** {@inheritDoc SettingsStore.read} */
502
+ async read(owner) {
503
+ const stored = await this.#store.read(owner);
504
+ if (stored === null) return null;
505
+ const isSealed = this.#vault.isSealed?.bind(this.#vault) ?? defaultIsSealed;
506
+ let overrides = stored.overrides;
507
+ for (const path of this.#paths) {
508
+ const value = getAt(overrides, path);
509
+ if (!isSealed(value)) continue;
510
+ const plaintext = await this.#vault.decryptJson(
511
+ value,
512
+ contextOf(owner, path)
513
+ );
514
+ overrides = setAt(overrides, path, plaintext);
515
+ }
516
+ return { overrides, version: stored.version };
517
+ }
518
+ /** {@inheritDoc SettingsStore.write} */
519
+ async write(owner, overrides, expectedVersion) {
520
+ let sealed = overrides;
521
+ for (const path of this.#paths) {
522
+ if (!hasAt(sealed, path)) continue;
523
+ const value = getAt(sealed, path);
524
+ if (value === void 0) continue;
525
+ sealed = setAt(
526
+ sealed,
527
+ path,
528
+ await this.#vault.encryptJson(value, contextOf(owner, path))
529
+ );
530
+ }
531
+ return this.#store.write(owner, sealed, expectedVersion);
532
+ }
533
+ /** {@inheritDoc SettingsStore.delete} */
534
+ async delete(owner) {
535
+ await this.#store.delete(owner);
536
+ }
537
+ };
538
+
539
+ // src/cached-store.ts
540
+ var CachedStore = class {
541
+ #store;
542
+ #cache;
543
+ #ttl;
544
+ #prefix;
545
+ #onError;
546
+ /**
547
+ * @param options - The Store to wrap, the cache and the TTL.
548
+ * @throws `RangeError` when `ttl` isn't a positive number.
549
+ */
550
+ constructor(options) {
551
+ if (!(options.ttl > 0) || !Number.isFinite(options.ttl)) {
552
+ throw new RangeError("CachedStore: ttl must be a positive number.");
553
+ }
554
+ this.#store = options.store;
555
+ this.#cache = options.cache;
556
+ this.#ttl = options.ttl;
557
+ this.#prefix = options.prefix ?? "settings";
558
+ this.#onError = options.onError;
559
+ }
560
+ /** {@inheritDoc SettingsStore.read} */
561
+ async read(owner) {
562
+ const key2 = this.#key(owner);
563
+ try {
564
+ const cached = await this.#cache.get(key2);
565
+ if (cached !== void 0) {
566
+ return JSON.parse(cached);
567
+ }
568
+ } catch (error) {
569
+ this.#report(error);
570
+ }
571
+ const stored = await this.#store.read(owner);
572
+ try {
573
+ await this.#cache.set(key2, JSON.stringify(stored), this.#ttl);
574
+ } catch (error) {
575
+ this.#report(error);
576
+ }
577
+ return stored;
578
+ }
579
+ /** {@inheritDoc SettingsStore.write} */
580
+ async write(owner, overrides, expectedVersion) {
581
+ try {
582
+ return await this.#store.write(owner, overrides, expectedVersion);
583
+ } finally {
584
+ await this.#evict(owner);
585
+ }
586
+ }
587
+ /** {@inheritDoc SettingsStore.delete} */
588
+ async delete(owner) {
589
+ try {
590
+ await this.#store.delete(owner);
591
+ } finally {
592
+ await this.#evict(owner);
593
+ }
594
+ }
595
+ async #evict(owner) {
596
+ try {
597
+ await this.#cache.delete(this.#key(owner));
598
+ } catch (error) {
599
+ this.#report(error);
600
+ }
601
+ }
602
+ #key(owner) {
603
+ return `${this.#prefix}:${JSON.stringify([owner.scope, owner.id])}`;
604
+ }
605
+ #report(error) {
606
+ this.#onError?.(error);
607
+ }
608
+ };
609
+
610
+ // src/memory-cache.ts
611
+ var MemoryCache = class {
612
+ #entries = /* @__PURE__ */ new Map();
613
+ #maxEntries;
614
+ /**
615
+ * @param options - The size limit.
616
+ * @throws `RangeError` when `maxEntries` isn't a positive integer.
617
+ */
618
+ constructor(options = {}) {
619
+ const { maxEntries = 1e3 } = options;
620
+ if (!Number.isInteger(maxEntries) || maxEntries < 1) {
621
+ throw new RangeError(
622
+ "MemoryCache: maxEntries must be a positive integer."
623
+ );
624
+ }
625
+ this.#maxEntries = maxEntries;
626
+ }
627
+ /** {@inheritDoc SettingsCache.get} */
628
+ async get(key2) {
629
+ const entry = this.#entries.get(key2);
630
+ if (entry === void 0) return void 0;
631
+ if (entry.expiresAt <= Date.now()) {
632
+ this.#entries.delete(key2);
633
+ return void 0;
634
+ }
635
+ return entry.value;
636
+ }
637
+ /** {@inheritDoc SettingsCache.set} */
638
+ async set(key2, value, ttlSeconds) {
639
+ this.#entries.delete(key2);
640
+ this.#entries.set(key2, {
641
+ value,
642
+ expiresAt: Date.now() + ttlSeconds * 1e3
643
+ });
644
+ while (this.#entries.size > this.#maxEntries) {
645
+ const oldest = this.#entries.keys().next().value;
646
+ this.#entries.delete(oldest);
647
+ }
648
+ }
649
+ /** {@inheritDoc SettingsCache.delete} */
650
+ async delete(key2) {
651
+ this.#entries.delete(key2);
652
+ }
653
+ };
654
+
655
+ exports.CachedStore = CachedStore;
656
+ exports.MemoryCache = MemoryCache;
472
657
  exports.MemoryStore = MemoryStore;
658
+ exports.SealedStore = SealedStore;
473
659
  exports.SettingsConflictError = SettingsConflictError;
474
660
  exports.SettingsDefinitionError = SettingsDefinitionError;
475
661
  exports.SettingsValidationError = SettingsValidationError;
package/dist/index.d.cts CHANGED
@@ -314,6 +314,175 @@ declare class MemoryStore implements SettingsStore {
314
314
  delete(owner: Owner): Promise<void>;
315
315
  }
316
316
 
317
+ /**
318
+ * The part of a Secret Vault `Vault` that {@link SealedStore} uses, so this package doesn't depend on Secret Vault.
319
+ * A `Vault` fits.
320
+ */
321
+ interface SealingVault {
322
+ /**
323
+ * Seals a JSON value.
324
+ *
325
+ * @param value - The value to seal.
326
+ * @param context - Where the Sealed value is stored.
327
+ * @returns The Sealed value.
328
+ */
329
+ encryptJson(value: unknown, context: string): Promise<string>;
330
+ /**
331
+ * Opens a Sealed value made by `encryptJson`; rejects when it doesn't open.
332
+ *
333
+ * @param sealed - The Sealed value.
334
+ * @param context - The Context it was sealed under.
335
+ * @returns The Plaintext value.
336
+ */
337
+ decryptJson(sealed: string, context: string): Promise<unknown>;
338
+ /**
339
+ * Whether a stored value is a Sealed value rather than plain text still stored at a listed path. Optional: a Secret
340
+ * Vault `Vault` has `isSealed` only as a static method, so without it `SealedStore` treats a string starting with
341
+ * `vault:` as sealed.
342
+ *
343
+ * @param value - A stored value.
344
+ */
345
+ isSealed?(value: unknown): boolean;
346
+ }
347
+ /** Options of {@link SealedStore}. */
348
+ interface SealedStoreOptions {
349
+ /** The Store that keeps the Sealed values. */
350
+ readonly store: SettingsStore;
351
+ /** Seals and opens values; a Secret Vault `Vault` fits. */
352
+ readonly vault: SealingVault;
353
+ /**
354
+ * Dot paths into the settings to keep sealed, such as `"smtp.password"`. A path may name an object, which is sealed
355
+ * whole as JSON. No path may be inside another.
356
+ */
357
+ readonly paths: readonly string[];
358
+ }
359
+ /**
360
+ * A Store that keeps the Overrides at the listed paths as Sealed values in the Store it wraps, and hands the core
361
+ * their Plaintext. The schema validates the Plaintext, and the app calls `set` and `get` with plain values.
362
+ *
363
+ * Every write seals again with the Vault's current secret, which finishes a Rotation for that Owner. A Sealed value
364
+ * that doesn't open makes the read throw: it is never ignored as a stale Override, because the next write would
365
+ * replace the secret with its default. A plain value at a listed path reads as it is and is sealed on the Owner's next
366
+ * write. Values at unlisted paths are never opened.
367
+ *
368
+ * Nest it outermost, around a {@link CachedStore}, so the cache holds only Sealed values.
369
+ *
370
+ * @example
371
+ * const store = new SealedStore({
372
+ * store: new PrismaStore({ delegate: prisma.setting }),
373
+ * vault,
374
+ * paths: ["smtp.password"],
375
+ * });
376
+ */
377
+ declare class SealedStore implements SettingsStore {
378
+ #private;
379
+ /**
380
+ * @param options - The Store to wrap, the Vault and the sealed paths.
381
+ * @throws `TypeError` when a path is empty or inside another path.
382
+ */
383
+ constructor(options: SealedStoreOptions);
384
+ /** {@inheritDoc SettingsStore.read} */
385
+ read(owner: Owner): Promise<StoredOverrides | null>;
386
+ /** {@inheritDoc SettingsStore.write} */
387
+ write(owner: Owner, overrides: Record<string, unknown>, expectedVersion: number | null): Promise<number | null>;
388
+ /** {@inheritDoc SettingsStore.delete} */
389
+ delete(owner: Owner): Promise<void>;
390
+ }
391
+
392
+ /**
393
+ * A cache of strings with a time to live, which {@link CachedStore} keeps Overrides in. {@link MemoryCache} fits;
394
+ * so does a thin wrapper around Redis or Keyv.
395
+ */
396
+ interface SettingsCache {
397
+ /**
398
+ * Reads an entry.
399
+ *
400
+ * @param key - The cache key.
401
+ * @returns The value, or `undefined` when there is none or it expired.
402
+ */
403
+ get(key: string): Promise<string | undefined>;
404
+ /**
405
+ * Stores an entry, replacing any under the key.
406
+ *
407
+ * @param key - The cache key.
408
+ * @param value - The string to keep.
409
+ * @param ttlSeconds - Seconds until the entry expires.
410
+ */
411
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
412
+ /**
413
+ * Removes an entry; removing a missing one does nothing.
414
+ *
415
+ * @param key - The cache key.
416
+ */
417
+ delete(key: string): Promise<void>;
418
+ }
419
+ /** Options of {@link CachedStore}. */
420
+ interface CachedStoreOptions {
421
+ /** The Store to read through. */
422
+ readonly store: SettingsStore;
423
+ /** Where to keep what the Store returns. */
424
+ readonly cache: SettingsCache;
425
+ /** Seconds an entry stays cached. */
426
+ readonly ttl: number;
427
+ /** Starts every cache key. Defaults to `"settings"`. */
428
+ readonly prefix?: string;
429
+ /** Called with an error a cache method threw; the Store then answers instead. */
430
+ readonly onError?: (error: unknown) => void;
431
+ }
432
+ /**
433
+ * A Store that keeps what the Store it wraps returns in a cache for `ttl` seconds, an Owner with nothing stored
434
+ * included. Every write, conflict and delete evicts the Owner, so a write on this instance is read back at once and a
435
+ * conflicting write retries against fresh Overrides. A cache that throws is reported to `onError` and the Store
436
+ * answers instead.
437
+ *
438
+ * With a per-process cache and several instances, another instance can read Overrides up to `ttl` seconds old. Share
439
+ * a cache, such as Redis, to avoid it.
440
+ *
441
+ * @example
442
+ * const store = new CachedStore({ store: prismaStore, cache: new MemoryCache(), ttl: 60 });
443
+ */
444
+ declare class CachedStore implements SettingsStore {
445
+ #private;
446
+ /**
447
+ * @param options - The Store to wrap, the cache and the TTL.
448
+ * @throws `RangeError` when `ttl` isn't a positive number.
449
+ */
450
+ constructor(options: CachedStoreOptions);
451
+ /** {@inheritDoc SettingsStore.read} */
452
+ read(owner: Owner): Promise<StoredOverrides | null>;
453
+ /** {@inheritDoc SettingsStore.write} */
454
+ write(owner: Owner, overrides: Record<string, unknown>, expectedVersion: number | null): Promise<number | null>;
455
+ /** {@inheritDoc SettingsStore.delete} */
456
+ delete(owner: Owner): Promise<void>;
457
+ }
458
+
459
+ /** Options of {@link MemoryCache}. */
460
+ interface MemoryCacheOptions {
461
+ /** Most entries kept; the oldest are evicted past it. Defaults to 1000. */
462
+ readonly maxEntries?: number;
463
+ }
464
+ /**
465
+ * A {@link SettingsCache} in process memory. Entries expire after their TTL and the oldest are evicted past
466
+ * `maxEntries`. It is per process: instances don't share it.
467
+ *
468
+ * @example
469
+ * const cache = new MemoryCache({ maxEntries: 5000 });
470
+ */
471
+ declare class MemoryCache implements SettingsCache {
472
+ #private;
473
+ /**
474
+ * @param options - The size limit.
475
+ * @throws `RangeError` when `maxEntries` isn't a positive integer.
476
+ */
477
+ constructor(options?: MemoryCacheOptions);
478
+ /** {@inheritDoc SettingsCache.get} */
479
+ get(key: string): Promise<string | undefined>;
480
+ /** {@inheritDoc SettingsCache.set} */
481
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
482
+ /** {@inheritDoc SettingsCache.delete} */
483
+ delete(key: string): Promise<void>;
484
+ }
485
+
317
486
  /**
318
487
  * A write would make an Owner's Effective settings fail its Scope's schema, so nothing was stored. `issues` are the
319
488
  * Standard Schema issues, with paths into the Effective settings, for showing next to a form field.
@@ -358,4 +527,4 @@ declare class SettingsDefinitionError extends Error {
358
527
  constructor(message: string, scope: string, issues?: ReadonlyArray<StandardSchemaV1.Issue>);
359
528
  }
360
529
 
361
- export { MemoryStore, Owner, type Path, type PathValue, type ScopedSettings, SettingsConflictError, type SettingsDefinition, SettingsDefinitionError, type SettingsOptions, SettingsStore, SettingsValidationError, StoredOverrides, createSettings, defineSettings };
530
+ export { CachedStore, type CachedStoreOptions, MemoryCache, type MemoryCacheOptions, MemoryStore, Owner, type Path, type PathValue, type ScopedSettings, SealedStore, type SealedStoreOptions, type SealingVault, type SettingsCache, SettingsConflictError, type SettingsDefinition, SettingsDefinitionError, type SettingsOptions, SettingsStore, SettingsValidationError, StoredOverrides, createSettings, defineSettings };
package/dist/index.d.ts CHANGED
@@ -314,6 +314,175 @@ declare class MemoryStore implements SettingsStore {
314
314
  delete(owner: Owner): Promise<void>;
315
315
  }
316
316
 
317
+ /**
318
+ * The part of a Secret Vault `Vault` that {@link SealedStore} uses, so this package doesn't depend on Secret Vault.
319
+ * A `Vault` fits.
320
+ */
321
+ interface SealingVault {
322
+ /**
323
+ * Seals a JSON value.
324
+ *
325
+ * @param value - The value to seal.
326
+ * @param context - Where the Sealed value is stored.
327
+ * @returns The Sealed value.
328
+ */
329
+ encryptJson(value: unknown, context: string): Promise<string>;
330
+ /**
331
+ * Opens a Sealed value made by `encryptJson`; rejects when it doesn't open.
332
+ *
333
+ * @param sealed - The Sealed value.
334
+ * @param context - The Context it was sealed under.
335
+ * @returns The Plaintext value.
336
+ */
337
+ decryptJson(sealed: string, context: string): Promise<unknown>;
338
+ /**
339
+ * Whether a stored value is a Sealed value rather than plain text still stored at a listed path. Optional: a Secret
340
+ * Vault `Vault` has `isSealed` only as a static method, so without it `SealedStore` treats a string starting with
341
+ * `vault:` as sealed.
342
+ *
343
+ * @param value - A stored value.
344
+ */
345
+ isSealed?(value: unknown): boolean;
346
+ }
347
+ /** Options of {@link SealedStore}. */
348
+ interface SealedStoreOptions {
349
+ /** The Store that keeps the Sealed values. */
350
+ readonly store: SettingsStore;
351
+ /** Seals and opens values; a Secret Vault `Vault` fits. */
352
+ readonly vault: SealingVault;
353
+ /**
354
+ * Dot paths into the settings to keep sealed, such as `"smtp.password"`. A path may name an object, which is sealed
355
+ * whole as JSON. No path may be inside another.
356
+ */
357
+ readonly paths: readonly string[];
358
+ }
359
+ /**
360
+ * A Store that keeps the Overrides at the listed paths as Sealed values in the Store it wraps, and hands the core
361
+ * their Plaintext. The schema validates the Plaintext, and the app calls `set` and `get` with plain values.
362
+ *
363
+ * Every write seals again with the Vault's current secret, which finishes a Rotation for that Owner. A Sealed value
364
+ * that doesn't open makes the read throw: it is never ignored as a stale Override, because the next write would
365
+ * replace the secret with its default. A plain value at a listed path reads as it is and is sealed on the Owner's next
366
+ * write. Values at unlisted paths are never opened.
367
+ *
368
+ * Nest it outermost, around a {@link CachedStore}, so the cache holds only Sealed values.
369
+ *
370
+ * @example
371
+ * const store = new SealedStore({
372
+ * store: new PrismaStore({ delegate: prisma.setting }),
373
+ * vault,
374
+ * paths: ["smtp.password"],
375
+ * });
376
+ */
377
+ declare class SealedStore implements SettingsStore {
378
+ #private;
379
+ /**
380
+ * @param options - The Store to wrap, the Vault and the sealed paths.
381
+ * @throws `TypeError` when a path is empty or inside another path.
382
+ */
383
+ constructor(options: SealedStoreOptions);
384
+ /** {@inheritDoc SettingsStore.read} */
385
+ read(owner: Owner): Promise<StoredOverrides | null>;
386
+ /** {@inheritDoc SettingsStore.write} */
387
+ write(owner: Owner, overrides: Record<string, unknown>, expectedVersion: number | null): Promise<number | null>;
388
+ /** {@inheritDoc SettingsStore.delete} */
389
+ delete(owner: Owner): Promise<void>;
390
+ }
391
+
392
+ /**
393
+ * A cache of strings with a time to live, which {@link CachedStore} keeps Overrides in. {@link MemoryCache} fits;
394
+ * so does a thin wrapper around Redis or Keyv.
395
+ */
396
+ interface SettingsCache {
397
+ /**
398
+ * Reads an entry.
399
+ *
400
+ * @param key - The cache key.
401
+ * @returns The value, or `undefined` when there is none or it expired.
402
+ */
403
+ get(key: string): Promise<string | undefined>;
404
+ /**
405
+ * Stores an entry, replacing any under the key.
406
+ *
407
+ * @param key - The cache key.
408
+ * @param value - The string to keep.
409
+ * @param ttlSeconds - Seconds until the entry expires.
410
+ */
411
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
412
+ /**
413
+ * Removes an entry; removing a missing one does nothing.
414
+ *
415
+ * @param key - The cache key.
416
+ */
417
+ delete(key: string): Promise<void>;
418
+ }
419
+ /** Options of {@link CachedStore}. */
420
+ interface CachedStoreOptions {
421
+ /** The Store to read through. */
422
+ readonly store: SettingsStore;
423
+ /** Where to keep what the Store returns. */
424
+ readonly cache: SettingsCache;
425
+ /** Seconds an entry stays cached. */
426
+ readonly ttl: number;
427
+ /** Starts every cache key. Defaults to `"settings"`. */
428
+ readonly prefix?: string;
429
+ /** Called with an error a cache method threw; the Store then answers instead. */
430
+ readonly onError?: (error: unknown) => void;
431
+ }
432
+ /**
433
+ * A Store that keeps what the Store it wraps returns in a cache for `ttl` seconds, an Owner with nothing stored
434
+ * included. Every write, conflict and delete evicts the Owner, so a write on this instance is read back at once and a
435
+ * conflicting write retries against fresh Overrides. A cache that throws is reported to `onError` and the Store
436
+ * answers instead.
437
+ *
438
+ * With a per-process cache and several instances, another instance can read Overrides up to `ttl` seconds old. Share
439
+ * a cache, such as Redis, to avoid it.
440
+ *
441
+ * @example
442
+ * const store = new CachedStore({ store: prismaStore, cache: new MemoryCache(), ttl: 60 });
443
+ */
444
+ declare class CachedStore implements SettingsStore {
445
+ #private;
446
+ /**
447
+ * @param options - The Store to wrap, the cache and the TTL.
448
+ * @throws `RangeError` when `ttl` isn't a positive number.
449
+ */
450
+ constructor(options: CachedStoreOptions);
451
+ /** {@inheritDoc SettingsStore.read} */
452
+ read(owner: Owner): Promise<StoredOverrides | null>;
453
+ /** {@inheritDoc SettingsStore.write} */
454
+ write(owner: Owner, overrides: Record<string, unknown>, expectedVersion: number | null): Promise<number | null>;
455
+ /** {@inheritDoc SettingsStore.delete} */
456
+ delete(owner: Owner): Promise<void>;
457
+ }
458
+
459
+ /** Options of {@link MemoryCache}. */
460
+ interface MemoryCacheOptions {
461
+ /** Most entries kept; the oldest are evicted past it. Defaults to 1000. */
462
+ readonly maxEntries?: number;
463
+ }
464
+ /**
465
+ * A {@link SettingsCache} in process memory. Entries expire after their TTL and the oldest are evicted past
466
+ * `maxEntries`. It is per process: instances don't share it.
467
+ *
468
+ * @example
469
+ * const cache = new MemoryCache({ maxEntries: 5000 });
470
+ */
471
+ declare class MemoryCache implements SettingsCache {
472
+ #private;
473
+ /**
474
+ * @param options - The size limit.
475
+ * @throws `RangeError` when `maxEntries` isn't a positive integer.
476
+ */
477
+ constructor(options?: MemoryCacheOptions);
478
+ /** {@inheritDoc SettingsCache.get} */
479
+ get(key: string): Promise<string | undefined>;
480
+ /** {@inheritDoc SettingsCache.set} */
481
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
482
+ /** {@inheritDoc SettingsCache.delete} */
483
+ delete(key: string): Promise<void>;
484
+ }
485
+
317
486
  /**
318
487
  * A write would make an Owner's Effective settings fail its Scope's schema, so nothing was stored. `issues` are the
319
488
  * Standard Schema issues, with paths into the Effective settings, for showing next to a form field.
@@ -358,4 +527,4 @@ declare class SettingsDefinitionError extends Error {
358
527
  constructor(message: string, scope: string, issues?: ReadonlyArray<StandardSchemaV1.Issue>);
359
528
  }
360
529
 
361
- export { MemoryStore, Owner, type Path, type PathValue, type ScopedSettings, SettingsConflictError, type SettingsDefinition, SettingsDefinitionError, type SettingsOptions, SettingsStore, SettingsValidationError, StoredOverrides, createSettings, defineSettings };
530
+ export { CachedStore, type CachedStoreOptions, MemoryCache, type MemoryCacheOptions, MemoryStore, Owner, type Path, type PathValue, type ScopedSettings, SealedStore, type SealedStoreOptions, type SealingVault, type SettingsCache, SettingsConflictError, type SettingsDefinition, SettingsDefinitionError, type SettingsOptions, SettingsStore, SettingsValidationError, StoredOverrides, createSettings, defineSettings };
package/dist/index.js CHANGED
@@ -467,4 +467,187 @@ function asJson(overrides) {
467
467
  return JSON.parse(JSON.stringify(overrides));
468
468
  }
469
469
 
470
- export { MemoryStore, SettingsConflictError, SettingsDefinitionError, SettingsValidationError, createSettings, defineSettings };
470
+ // src/sealed-store.ts
471
+ function defaultIsSealed(value) {
472
+ return typeof value === "string" && value.startsWith("vault:");
473
+ }
474
+ function contextOf(owner, path) {
475
+ return `settings:${JSON.stringify([owner.scope, owner.id, path])}`;
476
+ }
477
+ var SealedStore = class {
478
+ #store;
479
+ #vault;
480
+ #paths;
481
+ /**
482
+ * @param options - The Store to wrap, the Vault and the sealed paths.
483
+ * @throws `TypeError` when a path is empty or inside another path.
484
+ */
485
+ constructor(options) {
486
+ this.#store = options.store;
487
+ this.#vault = options.vault;
488
+ this.#paths = [...new Set(options.paths)];
489
+ for (const path of this.#paths) {
490
+ if (path === "") throw new TypeError("SealedStore: a path is empty.");
491
+ const inner = this.#paths.find((p) => p.startsWith(`${path}.`));
492
+ if (inner !== void 0) {
493
+ throw new TypeError(
494
+ `SealedStore: path "${inner}" is inside path "${path}".`
495
+ );
496
+ }
497
+ }
498
+ }
499
+ /** {@inheritDoc SettingsStore.read} */
500
+ async read(owner) {
501
+ const stored = await this.#store.read(owner);
502
+ if (stored === null) return null;
503
+ const isSealed = this.#vault.isSealed?.bind(this.#vault) ?? defaultIsSealed;
504
+ let overrides = stored.overrides;
505
+ for (const path of this.#paths) {
506
+ const value = getAt(overrides, path);
507
+ if (!isSealed(value)) continue;
508
+ const plaintext = await this.#vault.decryptJson(
509
+ value,
510
+ contextOf(owner, path)
511
+ );
512
+ overrides = setAt(overrides, path, plaintext);
513
+ }
514
+ return { overrides, version: stored.version };
515
+ }
516
+ /** {@inheritDoc SettingsStore.write} */
517
+ async write(owner, overrides, expectedVersion) {
518
+ let sealed = overrides;
519
+ for (const path of this.#paths) {
520
+ if (!hasAt(sealed, path)) continue;
521
+ const value = getAt(sealed, path);
522
+ if (value === void 0) continue;
523
+ sealed = setAt(
524
+ sealed,
525
+ path,
526
+ await this.#vault.encryptJson(value, contextOf(owner, path))
527
+ );
528
+ }
529
+ return this.#store.write(owner, sealed, expectedVersion);
530
+ }
531
+ /** {@inheritDoc SettingsStore.delete} */
532
+ async delete(owner) {
533
+ await this.#store.delete(owner);
534
+ }
535
+ };
536
+
537
+ // src/cached-store.ts
538
+ var CachedStore = class {
539
+ #store;
540
+ #cache;
541
+ #ttl;
542
+ #prefix;
543
+ #onError;
544
+ /**
545
+ * @param options - The Store to wrap, the cache and the TTL.
546
+ * @throws `RangeError` when `ttl` isn't a positive number.
547
+ */
548
+ constructor(options) {
549
+ if (!(options.ttl > 0) || !Number.isFinite(options.ttl)) {
550
+ throw new RangeError("CachedStore: ttl must be a positive number.");
551
+ }
552
+ this.#store = options.store;
553
+ this.#cache = options.cache;
554
+ this.#ttl = options.ttl;
555
+ this.#prefix = options.prefix ?? "settings";
556
+ this.#onError = options.onError;
557
+ }
558
+ /** {@inheritDoc SettingsStore.read} */
559
+ async read(owner) {
560
+ const key2 = this.#key(owner);
561
+ try {
562
+ const cached = await this.#cache.get(key2);
563
+ if (cached !== void 0) {
564
+ return JSON.parse(cached);
565
+ }
566
+ } catch (error) {
567
+ this.#report(error);
568
+ }
569
+ const stored = await this.#store.read(owner);
570
+ try {
571
+ await this.#cache.set(key2, JSON.stringify(stored), this.#ttl);
572
+ } catch (error) {
573
+ this.#report(error);
574
+ }
575
+ return stored;
576
+ }
577
+ /** {@inheritDoc SettingsStore.write} */
578
+ async write(owner, overrides, expectedVersion) {
579
+ try {
580
+ return await this.#store.write(owner, overrides, expectedVersion);
581
+ } finally {
582
+ await this.#evict(owner);
583
+ }
584
+ }
585
+ /** {@inheritDoc SettingsStore.delete} */
586
+ async delete(owner) {
587
+ try {
588
+ await this.#store.delete(owner);
589
+ } finally {
590
+ await this.#evict(owner);
591
+ }
592
+ }
593
+ async #evict(owner) {
594
+ try {
595
+ await this.#cache.delete(this.#key(owner));
596
+ } catch (error) {
597
+ this.#report(error);
598
+ }
599
+ }
600
+ #key(owner) {
601
+ return `${this.#prefix}:${JSON.stringify([owner.scope, owner.id])}`;
602
+ }
603
+ #report(error) {
604
+ this.#onError?.(error);
605
+ }
606
+ };
607
+
608
+ // src/memory-cache.ts
609
+ var MemoryCache = class {
610
+ #entries = /* @__PURE__ */ new Map();
611
+ #maxEntries;
612
+ /**
613
+ * @param options - The size limit.
614
+ * @throws `RangeError` when `maxEntries` isn't a positive integer.
615
+ */
616
+ constructor(options = {}) {
617
+ const { maxEntries = 1e3 } = options;
618
+ if (!Number.isInteger(maxEntries) || maxEntries < 1) {
619
+ throw new RangeError(
620
+ "MemoryCache: maxEntries must be a positive integer."
621
+ );
622
+ }
623
+ this.#maxEntries = maxEntries;
624
+ }
625
+ /** {@inheritDoc SettingsCache.get} */
626
+ async get(key2) {
627
+ const entry = this.#entries.get(key2);
628
+ if (entry === void 0) return void 0;
629
+ if (entry.expiresAt <= Date.now()) {
630
+ this.#entries.delete(key2);
631
+ return void 0;
632
+ }
633
+ return entry.value;
634
+ }
635
+ /** {@inheritDoc SettingsCache.set} */
636
+ async set(key2, value, ttlSeconds) {
637
+ this.#entries.delete(key2);
638
+ this.#entries.set(key2, {
639
+ value,
640
+ expiresAt: Date.now() + ttlSeconds * 1e3
641
+ });
642
+ while (this.#entries.size > this.#maxEntries) {
643
+ const oldest = this.#entries.keys().next().value;
644
+ this.#entries.delete(oldest);
645
+ }
646
+ }
647
+ /** {@inheritDoc SettingsCache.delete} */
648
+ async delete(key2) {
649
+ this.#entries.delete(key2);
650
+ }
651
+ };
652
+
653
+ export { CachedStore, MemoryCache, MemoryStore, SealedStore, SettingsConflictError, SettingsDefinitionError, SettingsValidationError, createSettings, defineSettings };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rezwanul7/settings",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -45,6 +45,7 @@
45
45
  },
46
46
  "devDependencies": {
47
47
  "@repo/eslint-config": "0.0.0",
48
+ "@rezwanul7/secret-vault": "0.1.0",
48
49
  "@repo/typescript-config": "0.0.0",
49
50
  "@types/node": "^22.15.3",
50
51
  "@vitest/coverage-v8": "4.0.18",