@objectstack/service-settings 7.5.0 → 7.7.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
@@ -32,10 +32,44 @@ true` and writes (service or REST) fail with HTTP 409.
32
32
 
33
33
  ## Encryption
34
34
 
35
- `Specifier.encrypted: true` (implicit for `password`) round-trips the
36
- value through a pluggable `CryptoAdapter`. The default
37
- `NoopCryptoAdapter` is a base64 wrapper production deployments must
38
- provide a real KMS adapter via plugin options.
35
+ `Specifier.encrypted: true` (implicit for `password`) routes the value
36
+ through a pluggable `ICryptoProvider` into `sys_secret`; only an opaque
37
+ handle id lands in `sys_setting.value_enc`. The same provider backs every
38
+ secret-at-rest in the platform: encrypted settings, ObjectQL `secret`
39
+ fields, and runtime datasource credentials.
40
+
41
+ ### Default provider: `LocalCryptoProvider`
42
+
43
+ The default is `LocalCryptoProvider` — AES-256-GCM keyed off a single
44
+ 32-byte data key. It resolves its key in order:
45
+
46
+ 1. **`OS_SECRET_KEY`** — the canonical production master key (32-byte hex
47
+ or base64). Set this in any container / multi-node deployment.
48
+ Generate one with `openssl rand -hex 32`. It **must be identical**
49
+ across every restart and every node, or previously-encrypted secrets
50
+ become undecryptable.
51
+ 2. `OS_DEV_CRYPTO_KEY` — dev convenience key (same format).
52
+ 3. A persisted file at `~/.objectstack/dev-crypto-key` (mode 0600). In
53
+ development this is auto-created so single-host dev loops survive
54
+ restarts; in production it is only *read*, never minted.
55
+
56
+ **Fail-loud in production.** When `NODE_ENV=production` and no stable key
57
+ source (env var or pre-existing file) is available, the provider refuses
58
+ to start with an actionable error instead of silently generating an
59
+ ephemeral key. This turns the old silent-data-loss footgun — every
60
+ `sys_secret` value becoming undecryptable after a container restart or on
61
+ a second node — into a config error at boot.
62
+
63
+ Secrets surviving a restart is **correctness, not a premium feature**, so
64
+ `LocalCryptoProvider` and the env-key path are open-source. KMS / Vault
65
+ providers (managed custody, per-tenant keys, automatic rotation) plug in
66
+ behind the same `ICryptoProvider` seam via `cryptoProvider` plugin option.
67
+
68
+ > `InMemoryCryptoProvider` is a deprecated alias for `LocalCryptoProvider`
69
+ > (the old name wrongly implied an ephemeral key).
70
+
71
+ The legacy `CryptoAdapter` / `NoopCryptoAdapter` (a base64 wrapper) remains
72
+ only as a pre-Phase-3 backward-compat path when no `cryptoProvider` is wired.
39
73
 
40
74
  ## Audit
41
75
 
package/dist/index.cjs CHANGED
@@ -31,6 +31,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  InMemoryCryptoProvider: () => InMemoryCryptoProvider,
34
+ LocalCryptoProvider: () => LocalCryptoProvider,
34
35
  NoopCryptoAdapter: () => NoopCryptoAdapter,
35
36
  SETTINGS_PLUGIN_ID: () => SETTINGS_PLUGIN_ID,
36
37
  SETTINGS_PLUGIN_VERSION: () => SETTINGS_PLUGIN_VERSION,
@@ -631,36 +632,25 @@ function coerceEnvValue(raw, hint) {
631
632
  return raw;
632
633
  }
633
634
 
634
- // src/in-memory-crypto-provider.ts
635
- var import_types = require("@objectstack/types");
635
+ // src/local-crypto-provider.ts
636
636
  var import_node_crypto = require("crypto");
637
637
  var import_node_fs = require("fs");
638
638
  var import_node_os = require("os");
639
639
  var import_node_path = require("path");
640
+ var SECRET_KEY_ENV = "OS_SECRET_KEY";
640
641
  var DEV_KEY_ENV = "OS_DEV_CRYPTO_KEY";
641
642
  var DEV_KEY_LEGACY_ENV = "OBJECTSTACK_DEV_CRYPTO_KEY";
642
- var devKeyFallbackPath = () => {
643
- const proc = globalThis?.process;
644
- const home = (0, import_types.readEnvWithDeprecation)("OS_HOME", "OBJECTSTACK_HOME") || (proc?.env?.HOME ? (0, import_node_path.join)(proc.env.HOME, ".objectstack") : void 0) || (0, import_node_path.join)((0, import_node_os.homedir)(), ".objectstack");
645
- return (0, import_node_path.join)(home, "dev-crypto-key");
643
+ var processEnv = () => globalThis.process?.env ?? {};
644
+ var detectMode = (env) => {
645
+ if (env.VITEST || env.NODE_ENV === "test") return "test";
646
+ if (env.NODE_ENV === "production") return "production";
647
+ return "development";
646
648
  };
647
- var loadOrCreateDevKey = () => {
648
- try {
649
- const path = devKeyFallbackPath();
650
- if ((0, import_node_fs.existsSync)(path)) {
651
- const raw = (0, import_node_fs.readFileSync)(path, "utf8").trim();
652
- const parsed = parseDevKey(raw);
653
- if (parsed) return { key: parsed, path, generated: false };
654
- }
655
- const key = (0, import_node_crypto.randomBytes)(32);
656
- (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(path), { recursive: true });
657
- (0, import_node_fs.writeFileSync)(path, key.toString("base64"), { mode: 384 });
658
- return { key, path, generated: true };
659
- } catch {
660
- return void 0;
661
- }
649
+ var keyFilePath = (env) => {
650
+ const home = env.OS_HOME || env.OBJECTSTACK_HOME || (env.HOME ? (0, import_node_path.join)(env.HOME, ".objectstack") : void 0) || (0, import_node_path.join)((0, import_node_os.homedir)(), ".objectstack");
651
+ return (0, import_node_path.join)(home, "dev-crypto-key");
662
652
  };
663
- var parseDevKey = (raw) => {
653
+ var parseKey = (raw) => {
664
654
  if (!raw) return void 0;
665
655
  const trimmed = raw.trim();
666
656
  if (!trimmed) return void 0;
@@ -671,11 +661,99 @@ var parseDevKey = (raw) => {
671
661
  if (buf.length === 32) return buf;
672
662
  } catch {
673
663
  }
674
- console.warn(
675
- `[InMemoryCryptoProvider] ${DEV_KEY_ENV} is set but is not 32 bytes (hex or base64). Ignoring and generating an ephemeral key.`
676
- );
677
664
  return void 0;
678
665
  };
666
+ var loadExistingKey = (path) => {
667
+ try {
668
+ if (!(0, import_node_fs.existsSync)(path)) return void 0;
669
+ return parseKey((0, import_node_fs.readFileSync)(path, "utf8").trim());
670
+ } catch {
671
+ return void 0;
672
+ }
673
+ };
674
+ var loadOrCreateKey = (path) => {
675
+ try {
676
+ const existing = loadExistingKey(path);
677
+ if (existing) return { key: existing, generated: false };
678
+ const key = (0, import_node_crypto.randomBytes)(32);
679
+ (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(path), { recursive: true });
680
+ (0, import_node_fs.writeFileSync)(path, key.toString("base64"), { mode: 384 });
681
+ return { key, generated: true };
682
+ } catch {
683
+ return void 0;
684
+ }
685
+ };
686
+ var INVALID_KEY_MSG = (name) => `[LocalCryptoProvider] ${name} is set but is not a 32-byte key (expected 64 hex chars or base64 of 32 bytes). Generate one with \`openssl rand -hex 32\`.`;
687
+ var MISSING_PROD_KEY_MSG = (path) => `[LocalCryptoProvider] Refusing to start in production without a stable encryption key.
688
+ No ${SECRET_KEY_ENV} (or ${DEV_KEY_ENV}) is set and no persisted key file was found at:
689
+ ${path}
690
+ Minting a key here would make every sys_secret value (encrypted settings, secret
691
+ fields, datasource credentials) undecryptable after the next restart or on another node.
692
+ Fix: generate a 32-byte key and set it in the environment (identical across every
693
+ restart and every node), e.g.
694
+ ${SECRET_KEY_ENV}=$(openssl rand -hex 32)`;
695
+ var warn = (msg) => {
696
+ try {
697
+ globalThis.console?.warn?.(msg);
698
+ } catch {
699
+ }
700
+ };
701
+ var legacyDeprecationWarned = { value: false };
702
+ function resolveDataKey(opts) {
703
+ if (opts.key) return { key: opts.key, source: "explicit" };
704
+ const env = opts.env ?? processEnv();
705
+ const mode = opts.mode ?? detectMode(env);
706
+ if (env[SECRET_KEY_ENV] !== void 0) {
707
+ const parsed = parseKey(env[SECRET_KEY_ENV]);
708
+ if (parsed) return { key: parsed, source: "env:OS_SECRET_KEY" };
709
+ throw new Error(INVALID_KEY_MSG(SECRET_KEY_ENV));
710
+ }
711
+ let devRaw = env[DEV_KEY_ENV];
712
+ if (devRaw === void 0 && env[DEV_KEY_LEGACY_ENV] !== void 0) {
713
+ devRaw = env[DEV_KEY_LEGACY_ENV];
714
+ if (!legacyDeprecationWarned.value) {
715
+ legacyDeprecationWarned.value = true;
716
+ warn(
717
+ `[ObjectStack] Env var \`${DEV_KEY_LEGACY_ENV}\` is deprecated; rename it to \`${DEV_KEY_ENV}\`.`
718
+ );
719
+ }
720
+ }
721
+ if (devRaw !== void 0) {
722
+ const parsed = parseKey(devRaw);
723
+ if (parsed) return { key: parsed, source: "env:OS_DEV_CRYPTO_KEY" };
724
+ if (mode === "production") throw new Error(INVALID_KEY_MSG(DEV_KEY_ENV));
725
+ warn(`${INVALID_KEY_MSG(DEV_KEY_ENV)} Ignoring and generating a local key.`);
726
+ }
727
+ if (mode === "test") {
728
+ return { key: (0, import_node_crypto.randomBytes)(32), source: "ephemeral" };
729
+ }
730
+ const path = keyFilePath(env);
731
+ if (mode === "production") {
732
+ const existing = loadExistingKey(path);
733
+ if (existing) {
734
+ warn(
735
+ `[LocalCryptoProvider] No ${SECRET_KEY_ENV} set \u2014 using the persisted key at ${path}. For containers / multi-node, prefer setting ${SECRET_KEY_ENV} so every node shares one key.`
736
+ );
737
+ return { key: existing, source: "file" };
738
+ }
739
+ throw new Error(MISSING_PROD_KEY_MSG(path));
740
+ }
741
+ const persisted = loadOrCreateKey(path);
742
+ if (persisted) {
743
+ if (persisted.generated) {
744
+ warn(
745
+ `[LocalCryptoProvider] No ${SECRET_KEY_ENV}/${DEV_KEY_ENV} set \u2014 generated a new AES-256-GCM key and persisted it to ${path} (mode 0600). Restarts on this host reuse it automatically. For containers, CI, or multi-node, set ${SECRET_KEY_ENV} explicitly so the key survives.`
746
+ );
747
+ }
748
+ return { key: persisted.key, source: persisted.generated ? "generated-file" : "file" };
749
+ }
750
+ const key = (0, import_node_crypto.randomBytes)(32);
751
+ warn(
752
+ `[LocalCryptoProvider] No ${SECRET_KEY_ENV} set and could not persist a fallback key at ${path} \u2014 generated an EPHEMERAL key. Existing encrypted settings/secrets will fail to decrypt after restart. Set ${SECRET_KEY_ENV} to a stable 32-byte key:
753
+ ${SECRET_KEY_ENV}=${key.toString("base64")}`
754
+ );
755
+ return { key, source: "ephemeral" };
756
+ }
679
757
  var isWebContainerRuntime = () => {
680
758
  const g = globalThis;
681
759
  return typeof g !== "undefined" && (Boolean(g.process?.versions?.webcontainer) || Boolean(g.process?.env?.SHELL?.includes?.("jsh")) || Boolean(g.process?.env?.STACKBLITZ));
@@ -688,8 +766,8 @@ var loadNobleGcm = () => {
688
766
  const mod = await import("@noble/ciphers/aes.js");
689
767
  return mod.gcm;
690
768
  } catch (err) {
691
- console.warn(
692
- `[InMemoryCryptoProvider] WebContainer detected but @noble/ciphers not installed: ${err?.message ?? err}. Falling back to node:crypto (will throw).`
769
+ warn(
770
+ `[LocalCryptoProvider] WebContainer detected but @noble/ciphers not installed: ${err?.message ?? err}. Falling back to node:crypto (will throw).`
693
771
  );
694
772
  return void 0;
695
773
  }
@@ -697,39 +775,11 @@ var loadNobleGcm = () => {
697
775
  }
698
776
  return nobleGcmPromise;
699
777
  };
700
- var InMemoryCryptoProvider = class {
778
+ var LocalCryptoProvider = class {
701
779
  constructor(opts = {}) {
702
- if (opts.key) {
703
- this.key = opts.key;
704
- } else {
705
- const fromEnv = parseDevKey(
706
- (0, import_types.readEnvWithDeprecation)(DEV_KEY_ENV, DEV_KEY_LEGACY_ENV)
707
- );
708
- if (fromEnv) {
709
- this.key = fromEnv;
710
- } else {
711
- const isTest = Boolean(
712
- globalThis?.process?.env?.VITEST || globalThis?.process?.env?.NODE_ENV === "test"
713
- );
714
- const persisted = isTest ? void 0 : loadOrCreateDevKey();
715
- if (persisted) {
716
- this.key = persisted.key;
717
- if (persisted.generated) {
718
- console.warn(
719
- `[InMemoryCryptoProvider] No ${DEV_KEY_ENV} set \u2014 generated a new AES-256-GCM key and persisted it to ${persisted.path} (mode 0600). Future restarts will reuse it automatically. For shared/CI environments, set ${DEV_KEY_ENV} explicitly in your environment.`
720
- );
721
- }
722
- } else {
723
- this.key = (0, import_node_crypto.randomBytes)(32);
724
- if (!isTest) {
725
- console.warn(
726
- `[InMemoryCryptoProvider] No ${DEV_KEY_ENV} set and could not persist a fallback key \u2014 generated an ephemeral AES-256-GCM key. Existing encrypted settings (e.g. AI API keys) will fail to decrypt on next restart. To make the key survive restarts, add this to your .env:
727
- ${DEV_KEY_ENV}=${this.key.toString("base64")}`
728
- );
729
- }
730
- }
731
- }
732
- }
780
+ const resolved = resolveDataKey(opts);
781
+ this.key = resolved.key;
782
+ this.keySource = resolved.source;
733
783
  this.useNoble = isWebContainerRuntime();
734
784
  }
735
785
  async encrypt(plain, ctx) {
@@ -753,7 +803,7 @@ var InMemoryCryptoProvider = class {
753
803
  }
754
804
  return {
755
805
  id: "sec_" + (0, import_node_crypto.randomBytes)(16).toString("hex"),
756
- kmsKeyId: "local:in-memory:v1",
806
+ kmsKeyId: "local:v1",
757
807
  alg: "aes-256-gcm",
758
808
  version: 1,
759
809
  ciphertext: blob
@@ -785,7 +835,7 @@ var InMemoryCryptoProvider = class {
785
835
  return {
786
836
  ...next,
787
837
  id: handle.id,
788
- kmsKeyId: `local:in-memory:v${handle.version + 1}`,
838
+ kmsKeyId: `local:v${handle.version + 1}`,
789
839
  version: handle.version + 1
790
840
  };
791
841
  }
@@ -803,6 +853,7 @@ var InMemoryCryptoProvider = class {
803
853
  return [ctx.namespace, ctx.key].join("|");
804
854
  }
805
855
  };
856
+ var InMemoryCryptoProvider = LocalCryptoProvider;
806
857
 
807
858
  // src/settings-routes.ts
808
859
  var defaultContext = (req) => {
@@ -3082,7 +3133,7 @@ var SettingsServicePlugin = class {
3082
3133
  {
3083
3134
  secretStore: this.buildSecretStore(engine),
3084
3135
  auditWriter: this.buildAuditWriter(ctx, engine),
3085
- cryptoProvider: this.opts.cryptoProvider ?? new InMemoryCryptoProvider()
3136
+ cryptoProvider: this.opts.cryptoProvider ?? new LocalCryptoProvider()
3086
3137
  }
3087
3138
  );
3088
3139
  }
@@ -3219,6 +3270,7 @@ function wrapEngineAsSettingsEngine(engine) {
3219
3270
  // Annotate the CommonJS export names for ESM import in node:
3220
3271
  0 && (module.exports = {
3221
3272
  InMemoryCryptoProvider,
3273
+ LocalCryptoProvider,
3222
3274
  NoopCryptoAdapter,
3223
3275
  SETTINGS_PLUGIN_ID,
3224
3276
  SETTINGS_PLUGIN_VERSION,