@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 +38 -4
- package/dist/index.cjs +114 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +40 -8
- package/dist/index.d.ts +40 -8
- package/dist/index.js +113 -62
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
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`)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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/
|
|
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
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
|
648
|
-
|
|
649
|
-
|
|
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
|
|
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
|
-
|
|
692
|
-
`[
|
|
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
|
|
778
|
+
var LocalCryptoProvider = class {
|
|
701
779
|
constructor(opts = {}) {
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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,
|