configre 2.1.0 → 2.1.4

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
@@ -124,7 +124,24 @@ const cfg = Configre(configPath);
124
124
 
125
125
  Secrets activate automatically when the base configuration or selected profile has a corresponding `.secret.cjs` file, or when `secrets.enc.json` already exists. No options are needed. Loading remains synchronous, and your application reads the result through ordinary properties such as `cfg.api.key`.
126
126
 
127
- > The demo reports `API key configured:` without printing the key. Avoid logging `cfg` in your application: it contains the decrypted secrets.
127
+ > The demo reports `API key configured:` without printing the key. Avoid logging `cfg` in your application: it contains the decrypted secrets. Use `cfg.print()` to log configuration with secret fields omitted.
128
+
129
+ ```javascript
130
+ const cfg = Configre(configPath);
131
+ cfg.print();
132
+ ```
133
+
134
+ `print()` calls Configre's `log.debug`; enable its output with `DEBUG=Configre:*`.
135
+ It omits fields supplied by the base and selected profile's `.secret.cjs` files,
136
+ including when loaded from the encrypted file. Public siblings in nested objects
137
+ remain visible; arrays supplied by secrets are omitted entirely. It does not
138
+ modify the configuration or log automatically on load. `cfg.print()` logs its
139
+ current values, including changes made after loading. The method is non-enumerable
140
+ and does not appear in `Object.keys(cfg)`, object spreads or JSON output.
141
+ `new Configre(configPath).print()` is also supported. If your configuration already
142
+ has a field named `print`, that value is preserved; use the constructor API to print it.
143
+ Sensitivity is determined by secret-file fields, not by names such as `password`
144
+ or `token`: keep sensitive values in `.secret.cjs` files, not in public settings.
128
145
 
129
146
  ### 1. Start the demo on the administrator machine
130
147
 
@@ -224,9 +241,11 @@ If this server is not authorized yet, Configre automatically:
224
241
  1. Creates its local identity, if needed.
225
242
  2. Writes `demo/secrets/config/recipients/truco.pub`.
226
243
  3. Creates and pushes a commit containing only that public-key file.
227
- 4. Stops startup with a message explaining that the administrator must publish an updated encrypted file.
244
+ 4. Logs a warning and continues with public configuration only until the administrator publishes an updated encrypted file.
245
+
246
+ Publishing a public key does not let the server decrypt the existing ciphertext. While authorization is pending, `cfg` preserves the public defaults and selected public profile; fields that exist only in the encrypted file are absent. Repeating the command does not publish another registration commit for the same key. If Git registration fails, Configre logs a warning and still returns public settings; the next Configre load retries registration.
228
247
 
229
- This first stop is expected: publishing a public key does not let the server decrypt the existing ciphertext. Repeating the command does not publish another registration commit for the same key.
248
+ Configre uses `log.info` when it creates identity files, writes Git exclusions, creates the recipients directory or a public-key file, updates the encrypted file, and successfully pushes the public key. Messages contain operation names and file paths, never secret values or key contents. These messages use the existing `Configre` LemonLog namespace; use `DEBUG=Configre:*` to display them. Unchanged files and previously published public keys do not generate another creation or publication log.
230
249
 
231
250
  ### 4. Include the server in the encrypted file
232
251
 
@@ -281,7 +300,7 @@ To revoke this server, remove `demo/secrets/config/recipients/truco.pub`, run th
281
300
  - Each OS user has one identity reused across projects; authorization is per project. A service running under another OS user needs its own registration. All authorized identities can decrypt all profiles in the project's encrypted file.
282
301
  - Public recipient files must contain a single RSA-3072 public key in PEM format, with exponent 65537, as generated by Configre. Other file extensions are ignored, and duplicate keys do not add recipients. The administrator is always included.
283
302
  - Registration uses an isolated Git index and publishes only the public-key file. It preserves unrelated staged and unstaged changes, runs no commit or pre-push hooks, and does not push local tags, merge, rebase or force-push. Failed registrations can be retried at the next startup; each Git command has a 30-second timeout. An authorized server loads secrets without Git commands or project writes.
284
- - Registration names must start with a letter or digit and contain only letters, digits, dots, underscores or hyphens. If another key already occupies `truco.pub`, Configre stops without overwriting it; use a distinct profile or resolve the key replacement explicitly.
303
+ - Registration names must start with a letter or digit and contain only letters, digits, dots, underscores or hyphens. If another key already occupies `truco.pub`, registration stops without overwriting it, and configuration loading continues with a warning and public settings only; use a distinct profile or resolve the key replacement explicitly.
285
304
  - Configre appends Git exclusions without removing existing rules and refuses to proceed on the administrator if editable secret modules are already tracked. It does not untrack files or rewrite history. It also prepares `.gitignore` when no repository exists yet.
286
305
  - The administrator's `.secret.cjs` files are the source of truth for values, and `recipients/` is the source of truth for authorization. Removing all recipient files revokes them on the next administrator reload; old ciphertext does not restore them. Recover accidentally deleted public-key files from Git before reloading. Missing Git exclusions and ciphertext are regenerated on the administrator, and existing secret modules are never overwritten.
287
306
  - Back up the administrator's editable secrets and private identity securely. Never share or commit `identity.pem`. On POSIX, its directory must have owner-only permissions (`0700`) and the private file must have owner-only permissions (`0600`); on Windows, protection depends on the user profile's filesystem permissions. Private identities and secret input files cannot be symlinks.
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
 
4
4
  const configPath = join(import.meta.dirname, "config");
5
5
  const cfg = Configre(configPath);
6
+ cfg.print();
6
7
  console.info("API key configured:", Boolean(cfg.api.key));
7
8
  console.info(`Set api.key in ${join(configPath, "index.secret.cjs")} and run this demo again.`);
8
9
  console.info(`New servers automatically publish their public key in ${join(configPath, "recipients")}. Pull and rerun here to authorize them.`);
package/index.js CHANGED
@@ -9,6 +9,28 @@ import loadSecrets from "./secrets/index.js";
9
9
  const requireConfig = createRequire(import.meta.url);
10
10
  const log = lemonlog("Configre");
11
11
 
12
+ function omitSecrets(settings, secrets) {
13
+ for (const [key, value] of Object.entries(secrets)) {
14
+ if (!Object.hasOwn(settings, key)) continue;
15
+ const current = settings[key];
16
+ if (value !== null && typeof value === "object" && !Array.isArray(value) &&
17
+ current !== null && typeof current === "object" && !Array.isArray(current)) {
18
+ omitSecrets(current, value);
19
+ if (Object.keys(current).length === 0) delete settings[key];
20
+ } else {
21
+ delete settings[key];
22
+ }
23
+ }
24
+ }
25
+
26
+ function printSettings(settings, secretSettings) {
27
+ const output = merge({}, settings);
28
+ for (const secrets of secretSettings) {
29
+ omitSecrets(output, secrets);
30
+ }
31
+ log.debug(output);
32
+ }
33
+
12
34
  class ConfigreClass {
13
35
  constructor(pathOrDir) {
14
36
  if (typeof pathOrDir !== "string" || pathOrDir.length === 0) {
@@ -83,6 +105,10 @@ class ConfigreClass {
83
105
  get() {
84
106
  return merge({}, this.defaultSettings, this.profileSettings, ...this.secretSettings);
85
107
  }
108
+
109
+ print() {
110
+ printSettings(this.get(), this.secretSettings);
111
+ }
86
112
  }
87
113
 
88
114
  // Wrapper function to support both constructor and function usage
@@ -90,7 +116,14 @@ function Configre(path) {
90
116
  if (this instanceof Configre) {
91
117
  return new ConfigreClass(path);
92
118
  } else {
93
- return new ConfigreClass(path).get();
119
+ const config = new ConfigreClass(path);
120
+ const settings = config.get();
121
+ if (!Object.hasOwn(settings, "print")) {
122
+ Object.defineProperty(settings, "print", {
123
+ value: () => printSettings(settings, config.secretSettings)
124
+ });
125
+ }
126
+ return settings;
94
127
  }
95
128
  }
96
129
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "configre",
3
- "version": "2.1.0",
3
+ "version": "2.1.4",
4
4
  "description": "🔧 Effortlessly Tailor Your Settings",
5
5
  "type": "module",
6
6
  "engines": {
@@ -3,12 +3,15 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { generatePrivateKey, parsePrivateKey, parsePublicKey } from "./crypto.js";
5
5
  import { stat, readText, withLock } from "./files.js";
6
+ import log from "./log.js";
6
7
 
7
8
  function loadIdentity() {
8
9
  const directory = path.join(os.homedir(), ".config", "configre");
9
10
  const privatePath = path.join(directory, "identity.pem");
10
11
  const publicPath = path.join(directory, "identity.pub");
11
- fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
12
+ if (fs.mkdirSync(directory, { recursive: true, mode: 0o700 })) {
13
+ log.info("Created identity directory", directory);
14
+ }
12
15
  const info = stat(directory);
13
16
  if (!info.isDirectory() || (process.platform !== "win32" && (info.mode & 0o077) !== 0)) {
14
17
  throw new Error(`Configre secrets: identity directory must be private and not a symlink at ${directory}`);
@@ -32,10 +35,12 @@ function loadIdentity() {
32
35
  throw new Error("Configre secrets: private identity is missing; restore it instead of replacing it");
33
36
  }
34
37
  fs.writeFileSync(privatePath, generatePrivateKey(), { flag: "wx", mode: 0o600 });
38
+ log.info("Created private identity file", privatePath);
35
39
  }
36
40
  const identity = parsePrivateKey(readText(privatePath, true));
37
41
  if (!stat(publicPath)) {
38
42
  fs.writeFileSync(publicPath, identity.publicKey, { flag: "wx", mode: 0o644 });
43
+ log.info("Created public identity file", publicPath);
39
44
  }
40
45
  return readIdentity();
41
46
  });
package/secrets/index.js CHANGED
@@ -5,6 +5,7 @@ import { isDeepStrictEqual } from "node:util";
5
5
  import { createRequire } from "node:module";
6
6
  import loadIdentity from "./identity.js";
7
7
  import registerRecipient from "./register.js";
8
+ import log from "./log.js";
8
9
  import { parsePublicKey, validateSecrets, encrypt, decrypt } from "./crypto.js";
9
10
  import { stat, readText, readJSON, withLock, writeEncrypted } from "./files.js";
10
11
 
@@ -63,11 +64,15 @@ function prepareIgnore(paths) {
63
64
  const lines = existing.split(/\r?\n/).filter(line => line && !line.startsWith("#"));
64
65
  if (!isDeepStrictEqual(lines.slice(-rules.length), rules)) {
65
66
  fs.appendFileSync(ignorePath, (existing && !existing.endsWith("\n") ? "\n" : "") + rules.join("\n") + "\n");
67
+ log.info("Wrote Git exclusions for secret files", ignorePath);
66
68
  }
67
69
  }
68
70
 
69
71
  function loadRecipients(directory, identity) {
70
- if (!stat(directory)) fs.mkdirSync(directory, { mode: 0o700 });
72
+ if (!stat(directory)) {
73
+ fs.mkdirSync(directory, { mode: 0o700 });
74
+ log.info("Created recipients directory", directory);
75
+ }
71
76
  if (!stat(directory).isDirectory()) {
72
77
  throw new Error("Configre secrets: recipients must be a directory, not a symlink");
73
78
  }
@@ -129,36 +134,45 @@ function loadSecrets(configPath, configFile, profile) {
129
134
  if (!hasLocal && !hasEncrypted) return [];
130
135
 
131
136
  const identity = loadIdentity();
132
- if (names.length === 0 && hasEncrypted) {
133
- try {
137
+ try {
138
+ if (names.length === 0 && hasEncrypted) {
134
139
  return selectSecrets(validateBundle(decrypt(readJSON(paths.encrypted), identity)), paths, profile);
135
- } catch (error) {
136
- if (error.code !== "CONFIGRE_NOT_AUTHORIZED") throw error;
137
- registerRecipient(paths, identity, profile);
138
- throw new Error("Configre secrets: not authorized yet; public key is published in Git. The administrator must pull, reload Configre and publish secrets.enc.json; then pull the updated encrypted file and restart this machine");
139
140
  }
140
- }
141
141
 
142
- const bundle = withLock(paths.encrypted, () => {
143
- const names = localFiles(paths);
144
- const hasEncrypted = !!stat(paths.encrypted);
145
- const envelope = hasEncrypted ? readJSON(paths.encrypted) : null;
146
- const previous = hasEncrypted ? validateBundle(decrypt(envelope, identity)) : null;
147
- if (names.length === 0 && hasEncrypted) return previous;
142
+ const bundle = withLock(paths.encrypted, () => {
143
+ const names = localFiles(paths);
144
+ const hasEncrypted = !!stat(paths.encrypted);
145
+ const envelope = hasEncrypted ? readJSON(paths.encrypted) : null;
146
+ const previous = hasEncrypted ? validateBundle(decrypt(envelope, identity)) : null;
147
+ if (names.length === 0 && hasEncrypted) return previous;
148
148
 
149
- prepareIgnore(paths);
150
- const settings = { files: Object.fromEntries(names.map(name => [name, readSecret(path.join(paths.parent, name))])) };
151
- const recipients = loadRecipients(paths.recipients, identity);
152
- if (hasEncrypted) {
153
- const previousRecipients = envelope.recipients.map(({ fingerprint, publicKey }) => ({ fingerprint, publicKey }));
154
- if (isDeepStrictEqual(settings, previous) && isDeepStrictEqual(recipients, previousRecipients)) {
155
- return settings;
149
+ prepareIgnore(paths);
150
+ const settings = { files: Object.fromEntries(names.map(name => [name, readSecret(path.join(paths.parent, name))])) };
151
+ const recipients = loadRecipients(paths.recipients, identity);
152
+ if (hasEncrypted) {
153
+ const previousRecipients = envelope.recipients.map(({ fingerprint, publicKey }) => ({ fingerprint, publicKey }));
154
+ if (isDeepStrictEqual(settings, previous) && isDeepStrictEqual(recipients, previousRecipients)) {
155
+ return settings;
156
+ }
156
157
  }
158
+ writeEncrypted(paths.encrypted, encrypt(settings, recipients));
159
+ log.info(hasEncrypted ? "Updated encrypted secrets file" : "Created encrypted secrets file", paths.encrypted);
160
+ return settings;
161
+ });
162
+ return selectSecrets(bundle, paths, profile);
163
+ } catch (error) {
164
+ if (error.code !== "CONFIGRE_NOT_AUTHORIZED") throw error;
165
+ let registration = "Public key is published in Git. The administrator must pull, reload Configre and publish secrets.enc.json; then pull the updated encrypted file and reload Configre.";
166
+ try {
167
+ registerRecipient(paths, identity, profile);
168
+ } catch (registrationError) {
169
+ registration = registrationError.code === "CONFIGRE_REGISTRATION_FAILED"
170
+ ? registrationError.message
171
+ : "Automatic public-key registration failed; check Git and file permissions, then reload Configre to retry.";
157
172
  }
158
- writeEncrypted(paths.encrypted, encrypt(settings, recipients));
159
- return settings;
160
- });
161
- return selectSecrets(bundle, paths, profile);
173
+ log.warn("Secrets not authorized; continuing with public settings only.", registration, "Public key:", identity.publicPath);
174
+ return [];
175
+ }
162
176
  }
163
177
 
164
178
  export default loadSecrets;
package/secrets/log.js ADDED
@@ -0,0 +1,3 @@
1
+ import lemonlog from "lemonlog";
2
+
3
+ export default lemonlog("Configre");
@@ -2,11 +2,14 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import config from "./config.js";
5
+ import log from "./log.js";
5
6
  import { parsePublicKey } from "./crypto.js";
6
7
  import { stat, readText, withLock } from "./files.js";
7
8
 
8
9
  function registrationError(message) {
9
- return new Error(`Configre secrets: not authorized; automatic registration ${message}`);
10
+ const error = new Error(`Configre secrets: not authorized; automatic registration ${message}`);
11
+ error.code = "CONFIGRE_REGISTRATION_FAILED";
12
+ return error;
10
13
  }
11
14
 
12
15
  function git(directory, args, operation, options = {}) {
@@ -84,9 +87,15 @@ function registerRecipient(paths, identity, profile) {
84
87
  git(root, ["update-index", "--add", "--cacheinfo", "100644", blob, relative], "adding the public key to the isolated index", { env });
85
88
  const tree = git(root, ["write-tree"], "building the registration tree", { env }).trim();
86
89
  const commit = git(root, ["commit-tree", tree, "-p", head, "-m", `Configre: register ${profile}`], "creating the public-key commit (Git author identity must be configured)").trim();
87
- fs.mkdirSync(directory, { recursive: true });
88
- if (!stat(filename)) fs.writeFileSync(filename, publicKey, { flag: "wx", mode: 0o644 });
90
+ if (fs.mkdirSync(directory, { recursive: true })) {
91
+ log.info("Created recipients directory", directory);
92
+ }
93
+ if (!stat(filename)) {
94
+ fs.writeFileSync(filename, publicKey, { flag: "wx", mode: 0o644 });
95
+ log.info("Created public-key registration file", filename);
96
+ }
89
97
  git(root, ["push", "--no-verify", "--no-follow-tags", "--", remote, `${commit}:${remoteBranch}`], "pushing the public-key commit");
98
+ log.info("Published public key to Git", filename);
90
99
  if (git(root, ["symbolic-ref", "--quiet", "HEAD"], "checking the current branch").trim() !== branch) {
91
100
  throw registrationError("published the public key, but the local branch changed concurrently; synchronize the checkout");
92
101
  }
@@ -7,6 +7,7 @@ import { spawnSync } from "node:child_process";
7
7
  import { pathToFileURL } from "node:url";
8
8
  import test from "node:test";
9
9
  import Configre from "../index.js";
10
+ import log from "../secrets/log.js";
10
11
  import { generatePrivateKey, parsePrivateKey, encrypt } from "../secrets/crypto.js";
11
12
 
12
13
  const sentinel = "synthetic-secret-for-configre-tests";
@@ -17,6 +18,9 @@ test.before(() => {
17
18
  });
18
19
 
19
20
  function fixture(t, { seed = true } = {}) {
21
+ const logs = { info: [], warn: [] };
22
+ t.mock.method(log, "info", (...args) => logs.info.push(args));
23
+ t.mock.method(log, "warn", (...args) => logs.warn.push(args));
20
24
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "configre-secrets-"));
21
25
  const homes = [0, 1, 2].map(index => path.join(root, `home-${index}`));
22
26
  let home = homes[0];
@@ -36,7 +40,7 @@ function fixture(t, { seed = true } = {}) {
36
40
  }
37
41
  t.after(() => fs.rmSync(root, { recursive: true, force: true }));
38
42
  return {
39
- root, config, homes,
43
+ root, config, homes, logs,
40
44
  local: path.join(config, "index.secret.cjs"),
41
45
  encrypted: path.join(config, "secrets.enc.json"),
42
46
  recipients: path.join(config, "recipients"),
@@ -54,6 +58,15 @@ function load(f) {
54
58
  return Configre(f.config);
55
59
  }
56
60
 
61
+ function assertPublicOnly(f, logs, warning) {
62
+ const count = logs.warn.length;
63
+ const config = load(f);
64
+ assert.equal(config.api.key, "");
65
+ assert.equal(logs.warn.length, count + 1);
66
+ assert.match(logs.warn.at(-1).join(" "), warning);
67
+ return config;
68
+ }
69
+
57
70
  function envelope(f) {
58
71
  return JSON.parse(fs.readFileSync(f.encrypted, "utf8"));
59
72
  }
@@ -67,6 +80,105 @@ function consumerCopy(f, name) {
67
80
  return { config };
68
81
  }
69
82
 
83
+ test("print logs public configuration without creating secret artifacts", t => {
84
+ const f = fixture(t, { seed: false });
85
+ const calls = [];
86
+ t.mock.method(Object.getPrototypeOf(log), "debug", (...args) => calls.push(args));
87
+ const config = new Configre(f.config);
88
+
89
+ assert.equal(calls.length, 0);
90
+ config.print();
91
+ assert.deepEqual(calls, [[config.get()]]);
92
+ assert.equal(fs.existsSync(f.homes[0]), false);
93
+ });
94
+
95
+ test("function results expose print without changing enumerable data and log current settings", t => {
96
+ const f = fixture(t, { seed: false });
97
+ const calls = [];
98
+ t.mock.method(Object.getPrototypeOf(log), "debug", (...args) => calls.push(args));
99
+ const cfg = Configre(f.config);
100
+ const expected = new Configre(f.config).get();
101
+
102
+ assert.equal(typeof cfg.print, "function");
103
+ assert.deepEqual(Object.keys(cfg), Object.keys(expected));
104
+ assert.deepEqual({ ...cfg }, expected);
105
+ assert.equal(JSON.stringify(cfg), JSON.stringify(expected));
106
+ cfg.api.host = "updated";
107
+ cfg.print();
108
+ assert.deepEqual(calls, [[{ ...expected, api: { key: "", host: "updated" } }]]);
109
+ assert.equal(cfg.api.host, "updated");
110
+ });
111
+
112
+ test("a public print field remains configuration data", t => {
113
+ const f = fixture(t, { seed: false });
114
+ fs.writeFileSync(path.join(f.config, "index.cjs"), 'module.exports = { print: false };');
115
+ assert.equal(Configre(f.config).print, false);
116
+ const calls = [];
117
+ t.mock.method(Object.getPrototypeOf(log), "debug", (...args) => calls.push(args));
118
+ new Configre(f.config).print();
119
+ assert.equal(calls[0][0].print, false);
120
+ });
121
+
122
+ test("print omits local and encrypted secret fields without changing effective settings", t => {
123
+ const f = fixture(t);
124
+ const calls = [];
125
+ t.mock.method(Object.getPrototypeOf(log), "debug", (...args) => calls.push(args));
126
+ writeSettings(f, {
127
+ api: { key: sentinel },
128
+ list: [sentinel],
129
+ privateGroup: { value: sentinel },
130
+ optional: null,
131
+ empty: "",
132
+ enabled: false,
133
+ count: 0
134
+ });
135
+ fs.writeFileSync(path.join(f.config, "testhost.secret.cjs"),
136
+ `module.exports = { api: { other: ${JSON.stringify(sentinel)} }, privateGroup: "public-looking" };`);
137
+
138
+ load(f);
139
+ for (const source of [f, consumerCopy(f, "debug-consumer")]) {
140
+ const config = new Configre(source.config);
141
+ const before = config.get();
142
+ const secretsBefore = structuredClone(config.secretSettings);
143
+ config.print();
144
+
145
+ assert.deepEqual(calls.at(-1), [{ api: { host: "profile" } }]);
146
+ assert.equal(JSON.stringify(calls).includes(sentinel), false);
147
+ assert.deepEqual(config.get(), before);
148
+ assert.deepEqual(config.secretSettings, secretsBefore);
149
+ assert.equal(before.api.key, sentinel);
150
+ assert.equal(before.list[0], sentinel);
151
+ assert.equal(before.list[1], 2);
152
+ const cfg = Configre(source.config);
153
+ cfg.api.key = sentinel + "-updated";
154
+ cfg.api.host = "updated";
155
+ cfg.print();
156
+ assert.deepEqual(calls.at(-1), [{ api: { host: "updated" } }]);
157
+ assert.equal(JSON.stringify(calls).includes(sentinel), false);
158
+ assert.equal(cfg.api.key, sentinel + "-updated");
159
+ }
160
+ const result = spawnSync(process.execPath, ["-e", `
161
+ const os = require('node:os');
162
+ os.homedir = () => process.env.CONFIGRE_TEST_HOME;
163
+ const Configre = require(process.env.CONFIGRE_TEST_MODULE);
164
+ Configre(process.env.CONFIGRE_TEST_PATH).print();
165
+ `, "--", "--config=testhost"], {
166
+ encoding: "utf8",
167
+ env: {
168
+ ...process.env, DEBUG: "Configre:*",
169
+ CONFIGRE_TEST_HOME: f.homes[0],
170
+ CONFIGRE_TEST_MODULE: path.join(import.meta.dirname, "..", "index.js"),
171
+ CONFIGRE_TEST_PATH: path.join(f.root, "debug-consumer")
172
+ }
173
+ });
174
+ assert.equal(result.status, 0, result.stderr);
175
+ const output = result.stdout + result.stderr;
176
+ assert.match(output, /Configre:debug/);
177
+ assert.match(output, /host: 'profile'/);
178
+ assert.equal(output.includes(sentinel), false);
179
+ assert.equal(output.includes("privateGroup"), false);
180
+ });
181
+
70
182
  function git(directory, args) {
71
183
  const result = spawnSync("git", ["-C", directory, ...args], { encoding: "utf8" });
72
184
  assert.equal(result.status, 0, result.stderr);
@@ -110,6 +222,45 @@ test("missing secret counterparts leave configuration and identity untouched", t
110
222
  assert.equal(new Configre(f.config).get().api.key, "");
111
223
  assert.deepEqual(fs.readdirSync(f.config), files);
112
224
  assert.equal(fs.existsSync(f.homes[0]), false);
225
+ assert.deepEqual(f.logs, { info: [], warn: [] });
226
+ });
227
+
228
+ test("an unauthorized machine keeps public settings and omits every encrypted field", t => {
229
+ const f = fixture(t);
230
+ writeSettings(f, { api: { key: sentinel }, encryptedOnly: { token: sentinel } });
231
+ load(f);
232
+ const consumer = consumerCopy(f, "unauthorized-consumer");
233
+ f.useHome(1);
234
+ const config = assertPublicOnly(consumer, f.logs, /not authorized.*public settings/i);
235
+ assert.deepEqual(config, { api: { key: "", host: "profile" }, list: [1, 2] });
236
+ assert.equal(Object.hasOwn(config, "encryptedOnly"), false);
237
+ assert.deepEqual(new Configre(consumer.config).get(), config);
238
+ assert.equal(JSON.stringify(f.logs).includes(sentinel), false);
239
+ const result = spawnSync(process.execPath, ["-e", `
240
+ const assert = require('node:assert/strict');
241
+ const os = require('node:os');
242
+ os.homedir = () => process.env.CONFIGRE_TEST_HOME;
243
+ const Configre = require(process.env.CONFIGRE_TEST_MODULE);
244
+ const cfg = Configre(process.env.CONFIGRE_TEST_PATH);
245
+ assert.equal(cfg.api.key, '');
246
+ assert.equal(Object.hasOwn(cfg, 'encryptedOnly'), false);
247
+ console.info('Application started');
248
+ `], {
249
+ encoding: "utf8",
250
+ env: {
251
+ ...process.env, DEBUG: "Configre:*",
252
+ CONFIGRE_TEST_HOME: f.homes[1],
253
+ CONFIGRE_TEST_MODULE: path.join(import.meta.dirname, "..", "index.js"),
254
+ CONFIGRE_TEST_PATH: consumer.config
255
+ }
256
+ });
257
+ assert.equal(result.status, 0, result.stderr);
258
+ const output = result.stdout + result.stderr;
259
+ assert.match(output, /Secrets not authorized; continuing with public settings only/);
260
+ assert.match(output, /Application started/);
261
+ assert.equal(output.includes(sentinel), false);
262
+ assert.equal(output.includes("BEGIN PRIVATE KEY"), false);
263
+ assert.equal(output.includes("BEGIN PUBLIC KEY"), false);
113
264
  });
114
265
 
115
266
  for (const profile of ["testhost", "testhost.dev", "forced"]) {
@@ -135,7 +286,12 @@ test("an existing empty secret module initializes secrets and reuses generated f
135
286
  const ignore = path.join(f.config, ".gitignore");
136
287
  fs.writeFileSync(ignore, "# existing rules\n*.log");
137
288
  const config = load(f);
289
+ const initialLogs = f.logs.info.length;
138
290
  assert.deepEqual(config, Configre(f.config));
291
+ assert.equal(f.logs.info.length, initialLogs);
292
+ for (const filename of [path.dirname(f.privatePath(0)), f.privatePath(0), f.publicPath(0), ignore, f.recipients, f.encrypted]) {
293
+ assert.ok(f.logs.info.some(([message, target]) => /Created|Wrote/.test(message) && target === filename));
294
+ }
139
295
  const original = fs.readFileSync(f.privatePath(0));
140
296
  const published = fs.readFileSync(f.publicPath(0), "utf8");
141
297
  assert.equal(parsePrivateKey(original).publicKey, published);
@@ -156,6 +312,9 @@ test("an existing empty secret module initializes secrets and reuses generated f
156
312
  assert.equal(fs.existsSync(f.encrypted + ".lock"), false);
157
313
  writeSettings(f);
158
314
  assert.equal(load(f).api.key, sentinel);
315
+ assert.deepEqual(f.logs.info.at(-1), ["Updated encrypted secrets file", f.encrypted]);
316
+ assert.equal(JSON.stringify(f.logs).includes(sentinel), false);
317
+ assert.equal(JSON.stringify(f.logs).includes("BEGIN PRIVATE KEY"), false);
159
318
  });
160
319
 
161
320
  test("missing administrator files are recreated without losing values or authorized recipients", t => {
@@ -198,7 +357,7 @@ test("removing every recipient revokes access without restoring keys from old ci
198
357
  assert.notDeepEqual(fs.readFileSync(f.encrypted), encrypted);
199
358
  const consumer = consumerCopy(f, "revoked-consumer");
200
359
  f.useHome(1);
201
- assert.throws(() => load(consumer), /not authorized/);
360
+ assertPublicOnly(consumer, f.logs, /not authorized/);
202
361
  });
203
362
 
204
363
  test("secrets merge last with profiles, arrays, JSON values and the constructor API", t => {
@@ -337,7 +496,7 @@ test("grant and revoke work across isolated machines without consumer project wr
337
496
  const after = fs.readdirSync(consumer.config).map(name => [name, fs.readFileSync(path.join(consumer.config, name))]);
338
497
  assert.deepEqual(after, before);
339
498
  f.useHome(2);
340
- assert.throws(() => load(consumer), /not authorized/);
499
+ assertPublicOnly(consumer, f.logs, /not authorized/);
341
500
  f.useHome(0);
342
501
  fs.unlinkSync(path.join(f.recipients, "developer.pub"));
343
502
  load(f);
@@ -346,9 +505,9 @@ test("grant and revoke work across isolated machines without consumer project wr
346
505
  assert.notEqual(revoked.iv, shared.iv);
347
506
  fs.copyFileSync(f.encrypted, path.join(consumer.config, "secrets.enc.json"));
348
507
  f.useHome(1);
349
- assert.throws(() => load(consumer), /not authorized/);
508
+ assertPublicOnly(consumer, f.logs, /not authorized/);
350
509
  writeSettings({ local: path.join(consumer.config, "index.secret.cjs") }, { api: { key: "replacement" } });
351
- assert.throws(() => load(consumer), /not authorized/);
510
+ assertPublicOnly(consumer, f.logs, /not authorized/);
352
511
  assert.deepEqual(JSON.parse(fs.readFileSync(path.join(consumer.config, "secrets.enc.json"))), revoked);
353
512
  fs.writeFileSync(path.join(consumer.config, "secrets.enc.json"), JSON.stringify(shared));
354
513
  fs.unlinkSync(path.join(consumer.config, "index.secret.cjs"));
@@ -633,7 +792,11 @@ test("a server publishes only its public key once and receives automatic authori
633
792
  fs.writeFileSync(untracked, sentinel);
634
793
  const index = git(f.checkout, ["ls-files", "--stage", "-z"]);
635
794
  f.useHome(1);
636
- assert.throws(() => load(f.consumer), /public key is published in Git/);
795
+ assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
796
+ const registeredPath = fs.realpathSync(f.registration);
797
+ assert.ok(f.logs.info.some(([message, filename]) => message === "Created public-key registration file" && filename === registeredPath));
798
+ assert.deepEqual(f.logs.info.at(-1), ["Published public key to Git", registeredPath]);
799
+ const registrationLogs = f.logs.info.length;
637
800
  const published = git(f.remote, ["rev-parse", "main"]).trim();
638
801
  assert.equal(git(f.checkout, ["rev-parse", "HEAD"]).trim(), published);
639
802
  assert.equal(git(f.remote, ["diff-tree", "--no-commit-id", "--name-only", "-r", "main"]).trim(), "config/recipients/testhost.pub");
@@ -645,7 +808,8 @@ test("a server publishes only its public key once and receives automatic authori
645
808
  assert.equal(fs.readFileSync(untracked, "utf8"), sentinel);
646
809
  assert.equal(git(f.remote, ["show", "main:tracked.txt"]), "initial\n");
647
810
  assert.equal(git(f.remote, ["tag", "--list"]), "");
648
- assert.throws(() => load(f.consumer), /public key is published in Git/);
811
+ assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
812
+ assert.equal(f.logs.info.length, registrationLogs);
649
813
  assert.equal(git(f.remote, ["rev-parse", "main"]).trim(), published);
650
814
 
651
815
  f.useHome(0);
@@ -657,7 +821,9 @@ test("a server publishes only its public key once and receives automatic authori
657
821
  git(f.root, ["push", "--quiet"]);
658
822
  git(f.checkout, ["pull", "--quiet", "--ff-only"]);
659
823
  f.useHome(1);
824
+ const warnings = f.logs.warn.length;
660
825
  assert.equal(load(f.consumer).api.key, sentinel);
826
+ assert.equal(f.logs.warn.length, warnings);
661
827
  f.useHome(0);
662
828
  writeSettings(f, { api: { key: sentinel + "-updated" } });
663
829
  load(f);
@@ -679,18 +845,16 @@ test("a rejected registration push preserves the branch and staging area and can
679
845
  const head = git(f.checkout, ["rev-parse", "HEAD"]);
680
846
  const index = fs.readFileSync(path.join(f.checkout, ".git", "index"));
681
847
  f.useHome(1);
682
- assert.throws(() => load(f.consumer), error => {
683
- assert.match(error.message, /failed while pushing the public-key commit/);
684
- assert.equal(error.stack.includes(sentinel), false);
685
- return true;
686
- });
848
+ assertPublicOnly(f.consumer, f.logs, /failed while pushing the public-key commit/);
849
+ assert.equal(JSON.stringify(f.logs).includes(sentinel), false);
850
+ assert.equal(f.logs.info.some(([message]) => message === "Published public key to Git"), false);
687
851
  assert.equal(git(f.checkout, ["rev-parse", "HEAD"]), head);
688
852
  assert.equal(git(f.remote, ["rev-parse", "main"]), head);
689
853
  assert.deepEqual(fs.readFileSync(path.join(f.checkout, ".git", "index")), index);
690
854
  assert.equal(fs.existsSync(path.join(f.checkout, ".git", "configre-registration.lock")), false);
691
855
  assert.equal(fs.readdirSync(path.join(f.checkout, ".git")).some(name => name.startsWith("configre-registration-")), false);
692
856
  fs.unlinkSync(hook);
693
- assert.throws(() => load(f.consumer), /public key is published in Git/);
857
+ assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
694
858
  assert.equal(git(f.remote, ["rev-list", "--count", "main"]).trim(), "2");
695
859
  });
696
860
 
@@ -706,9 +870,9 @@ test("registration recovers when publication succeeded before the local branch a
706
870
  git(interrupted, ["add", "tracked.txt"]);
707
871
  fs.writeFileSync(tracked, "unstaged work\n");
708
872
  f.useHome(1);
709
- assert.throws(() => load(f.consumer), /public key is published in Git/);
873
+ assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
710
874
  const published = git(f.remote, ["rev-parse", "main"]);
711
- assert.throws(() => load({ config: path.join(interrupted, "config") }), /public key is published in Git/);
875
+ assertPublicOnly({ config: path.join(interrupted, "config") }, f.logs, /public key is published in Git/i);
712
876
  git(interrupted, ["pull", "--quiet", "--ff-only"]);
713
877
  assert.equal(git(interrupted, ["rev-parse", "HEAD"]), published);
714
878
  assert.equal(git(f.remote, ["rev-parse", "main"]), published);
@@ -721,16 +885,16 @@ test("registration refuses to publish unrelated local commits or guess a branch"
721
885
  const remoteHead = git(f.remote, ["rev-parse", "main"]);
722
886
  git(f.checkout, ["checkout", "--quiet", "--detach"]);
723
887
  f.useHome(1);
724
- assert.throws(() => load(f.consumer), /detached HEAD/);
888
+ assertPublicOnly(f.consumer, f.logs, /detached HEAD/);
725
889
  git(f.checkout, ["checkout", "--quiet", "main"]);
726
890
  git(f.checkout, ["branch", "--unset-upstream"]);
727
- assert.throws(() => load(f.consumer), /upstream remote/);
891
+ assertPublicOnly(f.consumer, f.logs, /upstream remote/);
728
892
  git(f.checkout, ["branch", "--set-upstream-to=origin/main"]);
729
893
  fs.writeFileSync(path.join(f.checkout, "tracked.txt"), "unpublished work\n");
730
894
  git(f.checkout, ["add", "tracked.txt"]);
731
895
  git(f.checkout, ["commit", "--quiet", "-m", "Unpublished work"]);
732
896
  const localHead = git(f.checkout, ["rev-parse", "HEAD"]);
733
- assert.throws(() => load(f.consumer), /local branch to match its upstream/);
897
+ assertPublicOnly(f.consumer, f.logs, /local branch to match its upstream/);
734
898
  assert.equal(git(f.checkout, ["rev-parse", "HEAD"]), localHead);
735
899
  assert.equal(git(f.remote, ["rev-parse", "main"]), remoteHead);
736
900
  assert.equal(fs.existsSync(f.registration), false);
@@ -742,11 +906,11 @@ test("registration rejects profile collisions, unsafe names and damaged encrypte
742
906
  const previousArgs = process.argv;
743
907
  process.argv = [...previousArgs.filter(arg => !arg.startsWith("--config=")), "--config=../escape"];
744
908
  t.after(() => { process.argv = previousArgs; });
745
- assert.throws(() => load(f.consumer), /requires a profile/);
909
+ assertPublicOnly(f.consumer, f.logs, /requires a profile/);
746
910
  process.argv = previousArgs;
747
911
  fs.mkdirSync(path.dirname(f.registration));
748
912
  fs.copyFileSync(f.publicPath(2), f.registration);
749
- assert.throws(() => load(f.consumer), /different key for this profile/);
913
+ assertPublicOnly(f.consumer, f.logs, /different key for this profile/);
750
914
  assert.equal(fs.readFileSync(f.registration, "utf8"), fs.readFileSync(f.publicPath(2), "utf8"));
751
915
  fs.unlinkSync(f.registration);
752
916
  fs.copyFileSync(f.publicPath(2), path.join(f.recipients, "testhost.pub"));
@@ -754,7 +918,7 @@ test("registration rejects profile collisions, unsafe names and damaged encrypte
754
918
  git(f.root, ["commit", "--quiet", "-m", "Existing server identity"]);
755
919
  git(f.root, ["push", "--quiet"]);
756
920
  const remoteHead = git(f.remote, ["rev-parse", "main"]);
757
- assert.throws(() => load(f.consumer), /different published key/);
921
+ assertPublicOnly(f.consumer, f.logs, /different published key/);
758
922
  const damaged = envelope(f);
759
923
  damaged.extra = sentinel;
760
924
  fs.writeFileSync(path.join(f.consumer.config, "secrets.enc.json"), JSON.stringify(damaged));