configre 2.0.0 → 2.1.2

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
@@ -119,10 +119,10 @@ import Configre from "../../index.js";
119
119
  import { join } from "node:path";
120
120
 
121
121
  const configPath = join(import.meta.dirname, "config");
122
- const cfg = Configre(configPath, { secrets: true });
122
+ const cfg = Configre(configPath);
123
123
  ```
124
124
 
125
- `{ secrets: true }` enables this workflow. Loading remains synchronous, and your application reads the result through ordinary properties such as `cfg.api.key`.
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
127
  > The demo reports `API key configured:` without printing the key. Avoid logging `cfg` in your application: it contains the decrypted secrets.
128
128
 
@@ -138,18 +138,24 @@ module.exports = {
138
138
  };
139
139
  ```
140
140
 
141
+ Create `demo/secrets/config/index.secret.cjs` yourself to enable secrets, initially with:
142
+
143
+ ```javascript
144
+ module.exports = {};
145
+ ```
146
+
141
147
  Then run:
142
148
 
143
149
  ```bash
144
150
  node demo/secrets/demo.js
145
151
  ```
146
152
 
147
- For a new setup, Configre creates the following files and directory:
153
+ For a new setup, Configre generates the encrypted file, recipients directory and Git exclusions alongside your existing configuration files:
148
154
 
149
155
  ```text
150
156
  demo/secrets/config/
151
157
  index.cjs # Public configuration
152
- index.secret.cjs # Editable secrets; starts with module.exports = {};
158
+ index.secret.cjs # Editable secrets you create yourself
153
159
  recipients/ # Public keys of servers that register
154
160
  secrets.enc.json # Generated encrypted values
155
161
  .gitignore # Keeps .secret.cjs files out of Git
@@ -163,7 +169,7 @@ With the public placeholder empty and no secrets added, look for:
163
169
  API key configured: false
164
170
  ```
165
171
 
166
- **`index.secret.cjs` starts empty on purpose.** Configre does not copy values from `index.cjs` into it. It contains only the fields you choose to override with secrets; public settings continue to come from `index.cjs`.
172
+ **Configre never creates `.secret.cjs` files.** Without a secret counterpart for the base configuration or selected profile, and without an encrypted file, it loads only public settings and creates no identity or secret artifacts. A secret module contains only the fields you choose to override; public settings continue to come from `index.cjs`.
167
173
 
168
174
  These initialization steps describe a new setup. If `secrets.enc.json` already exists, Configre uses that encrypted file instead of resetting it. A checkout containing the encrypted file but no `.secret.cjs` files is treated as a server checkout.
169
175
 
@@ -218,9 +224,11 @@ If this server is not authorized yet, Configre automatically:
218
224
  1. Creates its local identity, if needed.
219
225
  2. Writes `demo/secrets/config/recipients/truco.pub`.
220
226
  3. Creates and pushes a commit containing only that public-key file.
221
- 4. Stops startup with a message explaining that the administrator must publish an updated encrypted file.
227
+ 4. Logs a warning and continues with public configuration only until the administrator publishes an updated encrypted file.
228
+
229
+ 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.
222
230
 
223
- 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.
231
+ 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.
224
232
 
225
233
  ### 4. Include the server in the encrypted file
226
234
 
@@ -254,7 +262,7 @@ node demo/secrets/demo.js
254
262
 
255
263
  Commit and push the updated `secrets.enc.json`, then pull and restart the demo on the server. **You do not need to copy or register `truco.pub` again.** Its existing authorization also covers new keys you add later.
256
264
 
257
- For settings specific to `truco`, add `demo/secrets/config/truco.cjs`. The next administrator run creates an empty `truco.secret.cjs` counterpart, which you can fill with that profile's secrets. All profile secret files, including inactive ones, are encrypted together when the administrator runs the demo.
265
+ For settings specific to `truco`, add `demo/secrets/config/truco.cjs` and create `truco.secret.cjs` yourself with that profile's secrets. The secret file activates secrets when `truco` is selected, even without `index.secret.cjs`. A public profile alone never creates a secret counterpart. Once secrets are active, all profile secret files, including inactive ones, are encrypted together when the administrator runs the demo.
258
266
 
259
267
  For `--config=truco`, the merge order is:
260
268
 
@@ -271,11 +279,11 @@ To revoke this server, remove `demo/secrets/config/recipients/truco.pub`, run th
271
279
 
272
280
  - Secret `.cjs` modules execute on the administrator and are reloaded on each Configre call. They must export plain objects containing JSON-compatible objects, arrays, strings, finite numbers, booleans and `null`. Functions, `undefined`, accessors, symbols, custom objects, circular references, and properties named `__proto__`, `constructor` or `prototype` are rejected. Consumers decrypt data without executing these modules.
273
281
  - Empty strings stay empty strings. Configre does not fill them from environment variables or populate `process.env`. The existing deep-merge behavior, including array merging, also applies to secrets.
274
- - Without `{ secrets: true }`, Configre does not access identities or secret files. The option also works with `new Configre(configPath, { secrets: true }).get()` and CommonJS consumers.
282
+ - Activation depends only on the existing files; there is no `secrets` option. The same behavior applies to `new Configre(configPath).get()` and CommonJS consumers.
275
283
  - 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.
276
284
  - 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.
277
285
  - 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.
278
- - 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.
286
+ - 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.
279
287
  - 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.
280
288
  - 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.
281
289
  - 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.
@@ -2,7 +2,7 @@ import Configre from "../../index.js";
2
2
  import { join } from "node:path";
3
3
 
4
4
  const configPath = join(import.meta.dirname, "config");
5
- const cfg = Configre(configPath, { secrets: true });
5
+ const cfg = Configre(configPath);
6
6
  console.info("API key configured:", Boolean(cfg.api.key));
7
7
  console.info(`Set api.key in ${join(configPath, "index.secret.cjs")} and run this demo again.`);
8
8
  console.info(`New servers automatically publish their public key in ${join(configPath, "recipients")}. Pull and rerun here to authorize them.`);
package/index.js CHANGED
@@ -10,15 +10,10 @@ const requireConfig = createRequire(import.meta.url);
10
10
  const log = lemonlog("Configre");
11
11
 
12
12
  class ConfigreClass {
13
- constructor(pathOrDir, options = {}) {
13
+ constructor(pathOrDir) {
14
14
  if (typeof pathOrDir !== "string" || pathOrDir.length === 0) {
15
15
  throw new TypeError("Configre path must be a non-empty string");
16
16
  }
17
- if (options === null || typeof options !== "object" || Array.isArray(options) ||
18
- (options.secrets !== undefined && typeof options.secrets !== "boolean")) {
19
- throw new TypeError("Configre options must be an object with an optional boolean secrets property");
20
- }
21
-
22
17
  const dir = path.join(pathOrDir);
23
18
  const isNested = (ConfigreClass._nesting || 0) > 0;
24
19
  ConfigreClass._nesting = (ConfigreClass._nesting || 0) + 1;
@@ -35,9 +30,7 @@ class ConfigreClass {
35
30
  const configArg = process.argv.find(arg => arg.startsWith('--config='));
36
31
  this.profile = configArg ? configArg.slice('--config='.length) : os.hostname();
37
32
  this.profileSettings = this.loadProfileSettings();
38
- this.secretSettings = options.secrets === true
39
- ? loadSecrets(pathOrDir, this.configFile, this.profile)
40
- : [];
33
+ this.secretSettings = loadSecrets(pathOrDir, this.configFile, this.profile);
41
34
  } finally {
42
35
  ConfigreClass._nesting -= 1;
43
36
  }
@@ -93,11 +86,11 @@ class ConfigreClass {
93
86
  }
94
87
 
95
88
  // Wrapper function to support both constructor and function usage
96
- function Configre(path, options) {
89
+ function Configre(path) {
97
90
  if (this instanceof Configre) {
98
- return new ConfigreClass(path, options);
91
+ return new ConfigreClass(path);
99
92
  } else {
100
- return new ConfigreClass(path, options).get();
93
+ return new ConfigreClass(path).get();
101
94
  }
102
95
  }
103
96
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "configre",
3
- "version": "2.0.0",
3
+ "version": "2.1.2",
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
  }
@@ -122,51 +127,52 @@ function selectSecrets(bundle, paths, profile) {
122
127
 
123
128
  function loadSecrets(configPath, configFile, profile) {
124
129
  const paths = secretPaths(configPath, configFile);
130
+ const names = localFiles(paths);
131
+ const hasEncrypted = !!stat(paths.encrypted);
132
+ const hasLocal = names.includes(path.basename(paths.local)) || (paths.directory &&
133
+ (names.includes(profile + ".dev.secret.cjs") || names.includes(profile + ".secret.cjs")));
134
+ if (!hasLocal && !hasEncrypted) return [];
135
+
125
136
  const identity = loadIdentity();
126
- if (localFiles(paths).length === 0 && stat(paths.encrypted)) {
127
- try {
137
+ try {
138
+ if (names.length === 0 && hasEncrypted) {
128
139
  return selectSecrets(validateBundle(decrypt(readJSON(paths.encrypted), identity)), paths, profile);
129
- } catch (error) {
130
- if (error.code !== "CONFIGRE_NOT_AUTHORIZED") throw error;
131
- registerRecipient(paths, identity, profile);
132
- 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");
133
140
  }
134
- }
135
141
 
136
- const bundle = withLock(paths.encrypted, () => {
137
- const names = localFiles(paths);
138
- const hasEncrypted = !!stat(paths.encrypted);
139
- const envelope = hasEncrypted ? readJSON(paths.encrypted) : null;
140
- const previous = hasEncrypted ? validateBundle(decrypt(envelope, identity)) : null;
141
- 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;
142
148
 
143
- prepareIgnore(paths);
144
- const settings = { files: Object.fromEntries(names.map(name => [name, readSecret(path.join(paths.parent, name))])) };
145
- const recipients = loadRecipients(paths.recipients, identity);
146
- const templates = new Set([path.basename(paths.local)]);
147
- if (paths.directory) {
148
- for (const name of fs.readdirSync(paths.parent)) {
149
- if (name.endsWith(".cjs") && !name.endsWith(".secret.cjs") && stat(path.join(paths.parent, name)).isFile()) {
150
- templates.add(name.slice(0, -4) + ".secret.cjs");
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;
151
156
  }
152
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.";
153
172
  }
154
- for (const name of templates) {
155
- if (!Object.hasOwn(settings.files, name)) {
156
- fs.writeFileSync(path.join(paths.parent, name), "module.exports = {};\n", { flag: "wx", mode: 0o600 });
157
- settings.files[name] = {};
158
- }
159
- }
160
- if (hasEncrypted) {
161
- const previousRecipients = envelope.recipients.map(({ fingerprint, publicKey }) => ({ fingerprint, publicKey }));
162
- if (isDeepStrictEqual(settings, previous) && isDeepStrictEqual(recipients, previousRecipients)) {
163
- return settings;
164
- }
165
- }
166
- writeEncrypted(paths.encrypted, encrypt(settings, recipients));
167
- return settings;
168
- });
169
- return selectSecrets(bundle, paths, profile);
173
+ log.warn("Secrets not authorized; continuing with public settings only.", registration, "Public key:", identity.publicPath);
174
+ return [];
175
+ }
170
176
  }
171
177
 
172
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"),
@@ -51,7 +55,16 @@ function writeSettings(f, settings = { api: { key: sentinel } }) {
51
55
  }
52
56
 
53
57
  function load(f) {
54
- return Configre(f.config, { secrets: true });
58
+ return Configre(f.config);
59
+ }
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;
55
68
  }
56
69
 
57
70
  function envelope(f) {
@@ -98,27 +111,88 @@ function gitFixture(t) {
98
111
  };
99
112
  }
100
113
 
101
- test("secrets stay opt-in and invalid options fail before creating an identity", t => {
114
+ test("missing secret counterparts leave configuration and identity untouched", t => {
102
115
  const f = fixture(t, { seed: false });
103
- fs.writeFileSync(f.local, "invalid-json");
116
+ const publicFiles = fs.readdirSync(f.config);
104
117
  t.mock.method(os, "homedir", () => { throw new Error("must not access the identity"); });
105
118
  assert.equal(Configre(f.config).api.key, "");
106
- assert.equal(Configre(f.config, { secrets: false }).api.key, "");
119
+ assert.deepEqual(fs.readdirSync(f.config), publicFiles);
120
+ fs.writeFileSync(path.join(f.config, "other.secret.cjs"), "invalid-json");
121
+ const files = fs.readdirSync(f.config);
122
+ assert.equal(Configre(f.config).api.key, "");
107
123
  assert.equal(new Configre(f.config).get().api.key, "");
108
- for (const options of [null, true, [], { secrets: "true" }]) {
109
- assert.throws(() => Configre(f.config, options), /options must be an object/);
110
- }
111
- assert.equal(fs.existsSync(f.encrypted), false);
112
- assert.equal(fs.existsSync(path.join(f.config, ".gitignore")), false);
124
+ assert.deepEqual(fs.readdirSync(f.config), files);
113
125
  assert.equal(fs.existsSync(f.homes[0]), false);
126
+ assert.deepEqual(f.logs, { info: [], warn: [] });
127
+ });
128
+
129
+ test("an unauthorized machine keeps public settings and omits every encrypted field", t => {
130
+ const f = fixture(t);
131
+ writeSettings(f, { api: { key: sentinel }, encryptedOnly: { token: sentinel } });
132
+ load(f);
133
+ const consumer = consumerCopy(f, "unauthorized-consumer");
134
+ f.useHome(1);
135
+ const config = assertPublicOnly(consumer, f.logs, /not authorized.*public settings/i);
136
+ assert.deepEqual(config, { api: { key: "", host: "profile" }, list: [1, 2] });
137
+ assert.equal(Object.hasOwn(config, "encryptedOnly"), false);
138
+ assert.deepEqual(new Configre(consumer.config).get(), config);
139
+ assert.equal(JSON.stringify(f.logs).includes(sentinel), false);
140
+ const result = spawnSync(process.execPath, ["-e", `
141
+ const assert = require('node:assert/strict');
142
+ const os = require('node:os');
143
+ os.homedir = () => process.env.CONFIGRE_TEST_HOME;
144
+ const Configre = require(process.env.CONFIGRE_TEST_MODULE);
145
+ const cfg = Configre(process.env.CONFIGRE_TEST_PATH);
146
+ assert.equal(cfg.api.key, '');
147
+ assert.equal(Object.hasOwn(cfg, 'encryptedOnly'), false);
148
+ console.info('Application started');
149
+ `], {
150
+ encoding: "utf8",
151
+ env: {
152
+ ...process.env, DEBUG: "Configre:*",
153
+ CONFIGRE_TEST_HOME: f.homes[1],
154
+ CONFIGRE_TEST_MODULE: path.join(import.meta.dirname, "..", "index.js"),
155
+ CONFIGRE_TEST_PATH: consumer.config
156
+ }
157
+ });
158
+ assert.equal(result.status, 0, result.stderr);
159
+ const output = result.stdout + result.stderr;
160
+ assert.match(output, /Secrets not authorized; continuing with public settings only/);
161
+ assert.match(output, /Application started/);
162
+ assert.equal(output.includes(sentinel), false);
163
+ assert.equal(output.includes("BEGIN PRIVATE KEY"), false);
164
+ assert.equal(output.includes("BEGIN PUBLIC KEY"), false);
114
165
  });
115
166
 
116
- test("first use initializes empty secrets and reuses all generated files", t => {
167
+ for (const profile of ["testhost", "testhost.dev", "forced"]) {
168
+ test(`a ${profile} secret activates secrets without a base secret module`, t => {
169
+ const f = fixture(t);
170
+ if (profile === "forced") {
171
+ const previousArgs = process.argv;
172
+ process.argv = [...previousArgs.filter(arg => !arg.startsWith("--config=")), "--config=forced"];
173
+ t.after(() => { process.argv = previousArgs; });
174
+ }
175
+ const local = path.join(f.config, profile + ".secret.cjs");
176
+ writeSettings({ local });
177
+ assert.equal(load(f).api.key, sentinel);
178
+ assert.equal(fs.existsSync(f.local), false);
179
+ assert.deepEqual(fs.readdirSync(f.config).filter(name => name.endsWith(".secret.cjs")), [path.basename(local)]);
180
+ assert.equal(load(consumerCopy(f, "profile-only-consumer")).api.key, sentinel);
181
+ });
182
+ }
183
+
184
+ test("an existing empty secret module initializes secrets and reuses generated files", t => {
117
185
  const f = fixture(t, { seed: false });
186
+ fs.writeFileSync(f.local, "module.exports = {};\n", { mode: 0o600 });
118
187
  const ignore = path.join(f.config, ".gitignore");
119
188
  fs.writeFileSync(ignore, "# existing rules\n*.log");
120
189
  const config = load(f);
190
+ const initialLogs = f.logs.info.length;
121
191
  assert.deepEqual(config, Configre(f.config));
192
+ assert.equal(f.logs.info.length, initialLogs);
193
+ for (const filename of [path.dirname(f.privatePath(0)), f.privatePath(0), f.publicPath(0), ignore, f.recipients, f.encrypted]) {
194
+ assert.ok(f.logs.info.some(([message, target]) => /Created|Wrote/.test(message) && target === filename));
195
+ }
122
196
  const original = fs.readFileSync(f.privatePath(0));
123
197
  const published = fs.readFileSync(f.publicPath(0), "utf8");
124
198
  assert.equal(parsePrivateKey(original).publicKey, published);
@@ -139,6 +213,9 @@ test("first use initializes empty secrets and reuses all generated files", t =>
139
213
  assert.equal(fs.existsSync(f.encrypted + ".lock"), false);
140
214
  writeSettings(f);
141
215
  assert.equal(load(f).api.key, sentinel);
216
+ assert.deepEqual(f.logs.info.at(-1), ["Updated encrypted secrets file", f.encrypted]);
217
+ assert.equal(JSON.stringify(f.logs).includes(sentinel), false);
218
+ assert.equal(JSON.stringify(f.logs).includes("BEGIN PRIVATE KEY"), false);
142
219
  });
143
220
 
144
221
  test("missing administrator files are recreated without losing values or authorized recipients", t => {
@@ -181,7 +258,7 @@ test("removing every recipient revokes access without restoring keys from old ci
181
258
  assert.notDeepEqual(fs.readFileSync(f.encrypted), encrypted);
182
259
  const consumer = consumerCopy(f, "revoked-consumer");
183
260
  f.useHome(1);
184
- assert.throws(() => load(consumer), /not authorized/);
261
+ assertPublicOnly(consumer, f.logs, /not authorized/);
185
262
  });
186
263
 
187
264
  test("secrets merge last with profiles, arrays, JSON values and the constructor API", t => {
@@ -198,7 +275,7 @@ test("secrets merge last with profiles, arrays, JSON values and the constructor
198
275
  assert.deepEqual(config.api, settings.api);
199
276
  assert.deepEqual(config.list, [9, 2]);
200
277
  assert.deepEqual(config.json, settings.json);
201
- assert.deepEqual(new Configre(f.config, { secrets: true }).get(), config);
278
+ assert.deepEqual(new Configre(f.config).get(), config);
202
279
  assert.equal(fs.existsSync(f.recipients), true);
203
280
  assert.equal(fs.readFileSync(f.encrypted, "utf8").includes(sentinel), false);
204
281
  assert.equal(envelope(f).recipients.length, 1);
@@ -277,19 +354,19 @@ test("base and host secrets select dev and forced profiles identically on consum
277
354
  assert.deepEqual(fs.readdirSync(consumer.config), consumerFiles);
278
355
  });
279
356
 
280
- test("administrator scaffolds empty counterparts without overwriting existing host secrets", t => {
357
+ test("administrator leaves missing counterparts absent and preserves existing host secrets", t => {
281
358
  const f = fixture(t);
282
359
  writeSettings(f);
283
360
  load(f);
284
361
  const host = { local: path.join(f.config, "testhost.secret.cjs") };
285
- assert.equal(fs.readFileSync(host.local, "utf8"), "module.exports = {};\n");
362
+ assert.equal(fs.existsSync(host.local), false);
286
363
  writeSettings(host, { api: { key: "host-secret" } });
287
364
  const original = fs.readFileSync(host.local);
288
365
  fs.writeFileSync(path.join(f.config, "newhost.cjs"), "module.exports = { public: true };\n");
289
366
  load(f);
290
367
  assert.deepEqual(fs.readFileSync(host.local), original);
291
- assert.equal(fs.readFileSync(path.join(f.config, "newhost.secret.cjs"), "utf8"), "module.exports = {};\n");
292
- const consumer = consumerCopy(f, "scaffold-consumer");
368
+ assert.equal(fs.existsSync(path.join(f.config, "newhost.secret.cjs")), false);
369
+ const consumer = consumerCopy(f, "optional-profile-consumer");
293
370
  fs.writeFileSync(path.join(consumer.config, "newhost.cjs"), "module.exports = {};\n");
294
371
  assert.equal(load(consumer).api.key, "host-secret");
295
372
  assert.equal(fs.existsSync(path.join(consumer.config, "newhost.secret.cjs")), false);
@@ -320,7 +397,7 @@ test("grant and revoke work across isolated machines without consumer project wr
320
397
  const after = fs.readdirSync(consumer.config).map(name => [name, fs.readFileSync(path.join(consumer.config, name))]);
321
398
  assert.deepEqual(after, before);
322
399
  f.useHome(2);
323
- assert.throws(() => load(consumer), /not authorized/);
400
+ assertPublicOnly(consumer, f.logs, /not authorized/);
324
401
  f.useHome(0);
325
402
  fs.unlinkSync(path.join(f.recipients, "developer.pub"));
326
403
  load(f);
@@ -329,9 +406,9 @@ test("grant and revoke work across isolated machines without consumer project wr
329
406
  assert.notEqual(revoked.iv, shared.iv);
330
407
  fs.copyFileSync(f.encrypted, path.join(consumer.config, "secrets.enc.json"));
331
408
  f.useHome(1);
332
- assert.throws(() => load(consumer), /not authorized/);
409
+ assertPublicOnly(consumer, f.logs, /not authorized/);
333
410
  writeSettings({ local: path.join(consumer.config, "index.secret.cjs") }, { api: { key: "replacement" } });
334
- assert.throws(() => load(consumer), /not authorized/);
411
+ assertPublicOnly(consumer, f.logs, /not authorized/);
335
412
  assert.deepEqual(JSON.parse(fs.readFileSync(path.join(consumer.config, "secrets.enc.json"))), revoked);
336
413
  fs.writeFileSync(path.join(consumer.config, "secrets.enc.json"), JSON.stringify(shared));
337
414
  fs.unlinkSync(path.join(consumer.config, "index.secret.cjs"));
@@ -467,20 +544,33 @@ test("private permissions and symlinks are rejected", { skip: process.platform =
467
544
  assert.throws(() => load(f), /regular file/);
468
545
  });
469
546
 
547
+ test("explicit config files without their own secret sidecar leave identities and files untouched", t => {
548
+ const f = fixture(t, { seed: false });
549
+ const filename = path.join(f.root, "settings.cjs");
550
+ fs.writeFileSync(filename, 'module.exports = { public: true };');
551
+ fs.writeFileSync(path.join(f.root, "other.secret.cjs"), "invalid-json");
552
+ const files = fs.readdirSync(f.root);
553
+ t.mock.method(os, "homedir", () => { throw new Error("must not access the identity"); });
554
+ assert.deepEqual(Configre(filename), { public: true });
555
+ assert.deepEqual(Configre(filename.slice(0, -4)), { public: true });
556
+ assert.deepEqual(fs.readdirSync(f.root), files);
557
+ assert.equal(fs.existsSync(f.homes[0]), false);
558
+ });
559
+
470
560
  test("explicit config files and extensionless paths use sidecars beside the resolved file", t => {
471
561
  const f = fixture(t);
472
562
  const filename = path.join(f.root, "settings.cjs");
473
563
  fs.writeFileSync(filename, 'module.exports = { api: { key: "", host: "file" } };');
474
564
  const local = filename.slice(0, -4) + ".secret.cjs";
475
565
  writeSettings({ local });
476
- assert.equal(Configre(filename, { secrets: true }).api.key, sentinel);
477
- assert.equal(Configre(filename.slice(0, -4), { secrets: true }).api.key, sentinel);
566
+ assert.equal(Configre(filename).api.key, sentinel);
567
+ assert.equal(Configre(filename.slice(0, -4)).api.key, sentinel);
478
568
  assert.equal(fs.existsSync(filename + ".secrets.enc.json"), true);
479
569
  assert.equal(fs.existsSync(filename + ".recipients"), true);
480
570
  assert.equal(fs.existsSync(path.join(f.root, "secrets.enc.json")), false);
481
571
  const relative = path.relative(process.cwd(), f.config);
482
572
  writeSettings(f);
483
- assert.equal(Configre(relative, { secrets: true }).api.key, sentinel);
573
+ assert.equal(Configre(relative).api.key, sentinel);
484
574
  });
485
575
 
486
576
  test("ESM callers can use the same synchronous API", t => {
@@ -494,7 +584,7 @@ test("ESM callers can use the same synchronous API", t => {
494
584
  import os from 'node:os';
495
585
  import Configre from ${JSON.stringify(moduleURL)};
496
586
  os.homedir = () => process.env.CONFIGRE_TEST_HOME;
497
- const cfg = Configre(process.env.CONFIGRE_TEST_PATH, { secrets: true });
587
+ const cfg = Configre(process.env.CONFIGRE_TEST_PATH);
498
588
  assert.equal(typeof cfg.then, 'undefined');
499
589
  assert.equal(cfg.api.key, ${JSON.stringify(sentinel)});
500
590
  `);
@@ -511,7 +601,7 @@ test("symlinked configuration directories keep directory sidecar names", { skip:
511
601
  writeSettings(f);
512
602
  const linked = path.join(f.root, "linked-config");
513
603
  fs.symlinkSync(f.config, linked);
514
- assert.equal(Configre(linked, { secrets: true }).api.key, sentinel);
604
+ assert.equal(Configre(linked).api.key, sentinel);
515
605
  assert.equal(fs.existsSync(f.encrypted), true);
516
606
  assert.equal(fs.existsSync(path.join(f.config, "index.cjs.secrets.enc.json")), false);
517
607
  });
@@ -520,13 +610,12 @@ test("error output never includes local secret values or private key contents",
520
610
  const f = fixture(t);
521
611
  fs.writeFileSync(f.local, `module.exports = {secret: "${sentinel}", bad`);
522
612
  assert.throws(() => load(f), /invalid secret module/);
523
- assert.equal(new Configre(f.config)._isNested, false);
524
613
  const script = `
525
614
  const os = require('node:os');
526
615
  os.homedir = () => process.env.CONFIGRE_TEST_HOME;
527
616
  const Configre = require(process.env.CONFIGRE_TEST_MODULE);
528
617
  try {
529
- Configre(process.env.CONFIGRE_TEST_PATH, { secrets: true });
618
+ Configre(process.env.CONFIGRE_TEST_PATH);
530
619
  } catch (error) {
531
620
  console.error(error.stack);
532
621
  process.exitCode = 1;
@@ -545,6 +634,8 @@ test("error output never includes local secret values or private key contents",
545
634
  assert.match(result.stderr, /invalid secret module/);
546
635
  assert.equal((result.stdout + result.stderr).includes(sentinel), false);
547
636
  assert.equal((result.stdout + result.stderr).includes("BEGIN PRIVATE KEY"), false);
637
+ writeSettings(f);
638
+ assert.equal(new Configre(f.config)._isNested, false);
548
639
  });
549
640
 
550
641
  test("Git exclusions preserve existing rules and never hide already tracked local files", t => {
@@ -568,6 +659,7 @@ test("Git exclusions preserve existing rules and never hide already tracked loca
568
659
  assert.deepEqual(fs.readFileSync(f.encrypted), original);
569
660
  assert.ok(git(f.root, ["ls-files"]).includes("config/index.secret.cjs"));
570
661
  git(f.root, ["rm", "--cached", "config/index.secret.cjs"]);
662
+ writeSettings({ local: path.join(f.config, "testhost.secret.cjs") }, {});
571
663
  git(f.root, ["add", "-f", "config/testhost.secret.cjs"]);
572
664
  assert.throws(() => load(f), /tracked by Git/);
573
665
  });
@@ -579,13 +671,13 @@ test("recipient files can be tracked and sidecar names are escaped literally in
579
671
  fs.writeFileSync(filename, "module.exports = {};");
580
672
  const local = filename.slice(0, -4) + ".secret.cjs";
581
673
  writeSettings({ local });
582
- Configre(filename, { secrets: true });
674
+ Configre(filename);
583
675
  assert.ok(git(f.root, ["check-ignore", path.basename(local)]).includes(path.basename(local)));
584
676
  const recipient = filename + ".recipients/developer.pub";
585
677
  fs.copyFileSync(f.publicPath(1), recipient);
586
678
  git(f.root, ["add", "-f", path.relative(f.root, recipient)]);
587
679
  const original = fs.readFileSync(filename + ".secrets.enc.json");
588
- assert.equal(Configre(filename, { secrets: true }).api.key, sentinel);
680
+ assert.equal(Configre(filename).api.key, sentinel);
589
681
  assert.notDeepEqual(fs.readFileSync(filename + ".secrets.enc.json"), original);
590
682
  });
591
683
 
@@ -601,7 +693,11 @@ test("a server publishes only its public key once and receives automatic authori
601
693
  fs.writeFileSync(untracked, sentinel);
602
694
  const index = git(f.checkout, ["ls-files", "--stage", "-z"]);
603
695
  f.useHome(1);
604
- assert.throws(() => load(f.consumer), /public key is published in Git/);
696
+ assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
697
+ const registeredPath = fs.realpathSync(f.registration);
698
+ assert.ok(f.logs.info.some(([message, filename]) => message === "Created public-key registration file" && filename === registeredPath));
699
+ assert.deepEqual(f.logs.info.at(-1), ["Published public key to Git", registeredPath]);
700
+ const registrationLogs = f.logs.info.length;
605
701
  const published = git(f.remote, ["rev-parse", "main"]).trim();
606
702
  assert.equal(git(f.checkout, ["rev-parse", "HEAD"]).trim(), published);
607
703
  assert.equal(git(f.remote, ["diff-tree", "--no-commit-id", "--name-only", "-r", "main"]).trim(), "config/recipients/testhost.pub");
@@ -613,7 +709,8 @@ test("a server publishes only its public key once and receives automatic authori
613
709
  assert.equal(fs.readFileSync(untracked, "utf8"), sentinel);
614
710
  assert.equal(git(f.remote, ["show", "main:tracked.txt"]), "initial\n");
615
711
  assert.equal(git(f.remote, ["tag", "--list"]), "");
616
- assert.throws(() => load(f.consumer), /public key is published in Git/);
712
+ assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
713
+ assert.equal(f.logs.info.length, registrationLogs);
617
714
  assert.equal(git(f.remote, ["rev-parse", "main"]).trim(), published);
618
715
 
619
716
  f.useHome(0);
@@ -625,7 +722,9 @@ test("a server publishes only its public key once and receives automatic authori
625
722
  git(f.root, ["push", "--quiet"]);
626
723
  git(f.checkout, ["pull", "--quiet", "--ff-only"]);
627
724
  f.useHome(1);
725
+ const warnings = f.logs.warn.length;
628
726
  assert.equal(load(f.consumer).api.key, sentinel);
727
+ assert.equal(f.logs.warn.length, warnings);
629
728
  f.useHome(0);
630
729
  writeSettings(f, { api: { key: sentinel + "-updated" } });
631
730
  load(f);
@@ -647,18 +746,16 @@ test("a rejected registration push preserves the branch and staging area and can
647
746
  const head = git(f.checkout, ["rev-parse", "HEAD"]);
648
747
  const index = fs.readFileSync(path.join(f.checkout, ".git", "index"));
649
748
  f.useHome(1);
650
- assert.throws(() => load(f.consumer), error => {
651
- assert.match(error.message, /failed while pushing the public-key commit/);
652
- assert.equal(error.stack.includes(sentinel), false);
653
- return true;
654
- });
749
+ assertPublicOnly(f.consumer, f.logs, /failed while pushing the public-key commit/);
750
+ assert.equal(JSON.stringify(f.logs).includes(sentinel), false);
751
+ assert.equal(f.logs.info.some(([message]) => message === "Published public key to Git"), false);
655
752
  assert.equal(git(f.checkout, ["rev-parse", "HEAD"]), head);
656
753
  assert.equal(git(f.remote, ["rev-parse", "main"]), head);
657
754
  assert.deepEqual(fs.readFileSync(path.join(f.checkout, ".git", "index")), index);
658
755
  assert.equal(fs.existsSync(path.join(f.checkout, ".git", "configre-registration.lock")), false);
659
756
  assert.equal(fs.readdirSync(path.join(f.checkout, ".git")).some(name => name.startsWith("configre-registration-")), false);
660
757
  fs.unlinkSync(hook);
661
- assert.throws(() => load(f.consumer), /public key is published in Git/);
758
+ assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
662
759
  assert.equal(git(f.remote, ["rev-list", "--count", "main"]).trim(), "2");
663
760
  });
664
761
 
@@ -674,9 +771,9 @@ test("registration recovers when publication succeeded before the local branch a
674
771
  git(interrupted, ["add", "tracked.txt"]);
675
772
  fs.writeFileSync(tracked, "unstaged work\n");
676
773
  f.useHome(1);
677
- assert.throws(() => load(f.consumer), /public key is published in Git/);
774
+ assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
678
775
  const published = git(f.remote, ["rev-parse", "main"]);
679
- assert.throws(() => load({ config: path.join(interrupted, "config") }), /public key is published in Git/);
776
+ assertPublicOnly({ config: path.join(interrupted, "config") }, f.logs, /public key is published in Git/i);
680
777
  git(interrupted, ["pull", "--quiet", "--ff-only"]);
681
778
  assert.equal(git(interrupted, ["rev-parse", "HEAD"]), published);
682
779
  assert.equal(git(f.remote, ["rev-parse", "main"]), published);
@@ -689,16 +786,16 @@ test("registration refuses to publish unrelated local commits or guess a branch"
689
786
  const remoteHead = git(f.remote, ["rev-parse", "main"]);
690
787
  git(f.checkout, ["checkout", "--quiet", "--detach"]);
691
788
  f.useHome(1);
692
- assert.throws(() => load(f.consumer), /detached HEAD/);
789
+ assertPublicOnly(f.consumer, f.logs, /detached HEAD/);
693
790
  git(f.checkout, ["checkout", "--quiet", "main"]);
694
791
  git(f.checkout, ["branch", "--unset-upstream"]);
695
- assert.throws(() => load(f.consumer), /upstream remote/);
792
+ assertPublicOnly(f.consumer, f.logs, /upstream remote/);
696
793
  git(f.checkout, ["branch", "--set-upstream-to=origin/main"]);
697
794
  fs.writeFileSync(path.join(f.checkout, "tracked.txt"), "unpublished work\n");
698
795
  git(f.checkout, ["add", "tracked.txt"]);
699
796
  git(f.checkout, ["commit", "--quiet", "-m", "Unpublished work"]);
700
797
  const localHead = git(f.checkout, ["rev-parse", "HEAD"]);
701
- assert.throws(() => load(f.consumer), /local branch to match its upstream/);
798
+ assertPublicOnly(f.consumer, f.logs, /local branch to match its upstream/);
702
799
  assert.equal(git(f.checkout, ["rev-parse", "HEAD"]), localHead);
703
800
  assert.equal(git(f.remote, ["rev-parse", "main"]), remoteHead);
704
801
  assert.equal(fs.existsSync(f.registration), false);
@@ -710,11 +807,11 @@ test("registration rejects profile collisions, unsafe names and damaged encrypte
710
807
  const previousArgs = process.argv;
711
808
  process.argv = [...previousArgs.filter(arg => !arg.startsWith("--config=")), "--config=../escape"];
712
809
  t.after(() => { process.argv = previousArgs; });
713
- assert.throws(() => load(f.consumer), /requires a profile/);
810
+ assertPublicOnly(f.consumer, f.logs, /requires a profile/);
714
811
  process.argv = previousArgs;
715
812
  fs.mkdirSync(path.dirname(f.registration));
716
813
  fs.copyFileSync(f.publicPath(2), f.registration);
717
- assert.throws(() => load(f.consumer), /different key for this profile/);
814
+ assertPublicOnly(f.consumer, f.logs, /different key for this profile/);
718
815
  assert.equal(fs.readFileSync(f.registration, "utf8"), fs.readFileSync(f.publicPath(2), "utf8"));
719
816
  fs.unlinkSync(f.registration);
720
817
  fs.copyFileSync(f.publicPath(2), path.join(f.recipients, "testhost.pub"));
@@ -722,7 +819,7 @@ test("registration rejects profile collisions, unsafe names and damaged encrypte
722
819
  git(f.root, ["commit", "--quiet", "-m", "Existing server identity"]);
723
820
  git(f.root, ["push", "--quiet"]);
724
821
  const remoteHead = git(f.remote, ["rev-parse", "main"]);
725
- assert.throws(() => load(f.consumer), /different published key/);
822
+ assertPublicOnly(f.consumer, f.logs, /different published key/);
726
823
  const damaged = envelope(f);
727
824
  damaged.extra = sentinel;
728
825
  fs.writeFileSync(path.join(f.consumer.config, "secrets.enc.json"), JSON.stringify(damaged));
@@ -773,7 +870,7 @@ test("a second process cannot write while an encrypted replacement is pending",
773
870
  const os = require('node:os');
774
871
  os.homedir = () => process.env.CONFIGRE_TEST_HOME;
775
872
  const Configre = require(process.env.CONFIGRE_TEST_MODULE);
776
- assert.throws(() => Configre(process.env.CONFIGRE_TEST_PATH, { secrets: true }), /another writer/);
873
+ assert.throws(() => Configre(process.env.CONFIGRE_TEST_PATH), /another writer/);
777
874
  `], {
778
875
  encoding: "utf8",
779
876
  env: {
@@ -792,6 +889,5 @@ test("a second process cannot write while an encrypted replacement is pending",
792
889
  assert.equal(load(f).api.key, sentinel + "-new");
793
890
  assert.equal(contested, true);
794
891
  fs.unlinkSync(f.local);
795
- fs.unlinkSync(path.join(f.config, "testhost.secret.cjs"));
796
892
  assert.equal(load(f).api.key, sentinel + "-new");
797
893
  });