tauri-plugin-configurate-api 0.1.0 → 0.2.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
@@ -1,351 +1,410 @@
1
- # tauri-plugin-configurate
2
-
3
- A Tauri v2 plugin for type-safe application configuration management.
4
-
5
- Define your config schema once in TypeScript and get full type inference for reads and writes. Supports JSON, YAML, and encrypted binary formats, with first-class OS keyring integration for storing secrets securely off disk.
6
-
7
- ## Features
8
-
9
- - **Type-safe schema** — define your config shape with `defineConfig()` and get compile-time checked reads/writes
10
- - **OS keyring support** — mark fields with `keyring()` to store secrets in the native credential store (Keychain / Credential Manager / libsecret) and keep them off disk
11
- - **Multiple formats** — JSON (human-readable), YAML (human-readable), binary (compact), or encrypted binary (XChaCha20-Poly1305)
12
- - **Minimal IPC** — every operation (file read + keyring fetch) is batched into a single IPC round-trip
13
- - **Multiple config files** — use `ConfigurateFactory` to manage multiple files with different schemas from one place
14
- - **Path traversal protection** — config identifiers and sub-directory paths are validated before use as file names; `/`, `\`, `:`, `*`, `?`, `"`, `<`, `>`, `|`, `.`, `..`, and null bytes are all rejected
15
-
16
- ## Installation
17
-
18
- ### Rust
19
-
20
- Add the plugin to `src-tauri/Cargo.toml`:
21
-
22
- ```toml
23
- [dependencies]
24
- tauri-plugin-configurate = { path = "/path/to/tauri-plugin-configurate" }
25
- ```
26
-
27
- Register it in `src-tauri/src/lib.rs`:
28
-
29
- ```rust
30
- pub fn run() {
31
- tauri::Builder::default()
32
- .plugin(tauri_plugin_configurate::init())
33
- .run(tauri::generate_context!())
34
- .expect("error while running tauri application");
35
- }
36
- ```
37
-
38
- ### JavaScript / TypeScript
39
-
40
- Install the guest bindings:
41
-
42
- ```sh
43
- # npm
44
- npm install tauri-plugin-configurate-api
45
-
46
- # pnpm
47
- pnpm add tauri-plugin-configurate-api
48
-
49
- # bun
50
- bun add tauri-plugin-configurate-api
51
- ```
52
-
53
- ### Capabilities (permissions)
54
-
55
- Add the following to your capability file (e.g. `src-tauri/capabilities/default.json`):
56
-
57
- ```json
58
- {
59
- "permissions": ["configurate:default"]
60
- }
61
- ```
62
-
63
- `configurate:default` grants access to all plugin commands. You can also allow them individually:
64
-
65
- | Permission | Description |
66
- | -------------------------- | ------------------------------------------ |
67
- | `configurate:allow-create` | Allow creating a new config file |
68
- | `configurate:allow-load` | Allow loading a config file |
69
- | `configurate:allow-save` | Allow saving (overwriting) a config file |
70
- | `configurate:allow-delete` | Allow deleting a config file |
71
- | `configurate:allow-unlock` | Allow fetching secrets from the OS keyring |
72
-
73
- ## Usage
74
-
75
- ### 1. Define a schema
76
-
77
- Use `defineConfig()` to declare the shape of your config. Primitive fields use constructor values (`String`, `Number`, `Boolean`). Nested objects are supported. Fields that should be stored in the OS keyring are wrapped with `keyring()`.
78
-
79
- ```ts
80
- import {
81
- defineConfig,
82
- keyring,
83
- ConfigurateFactory,
84
- BaseDirectory,
85
- } from "tauri-plugin-configurate-api";
86
-
87
- const appSchema = defineConfig({
88
- theme: String,
89
- language: String,
90
- fontSize: Number,
91
- notifications: Boolean,
92
- database: {
93
- host: String,
94
- port: Number,
95
- // stored in the OS keyring — never written to disk
96
- password: keyring(String, { id: "db-password" }),
97
- },
98
- });
99
- ```
100
-
101
- `keyring()` IDs must be unique within a schema. Duplicates are caught at both compile time and runtime.
102
-
103
- ### 2. Create a factory
104
-
105
- `ConfigurateFactory` holds shared options (`dir`, `format`, optional `subDir`, optional `encryptionKey`) and produces `Configurate` instances — one per config file.
106
-
107
- ```ts
108
- const factory = new ConfigurateFactory({
109
- dir: BaseDirectory.AppConfig,
110
- format: "json",
111
- // Optional: store all files under <AppConfig>/my-app/
112
- // subDir: "my-app",
113
- });
114
- ```
115
-
116
- > **`subDir`** — a forward-slash-separated relative path (e.g. `"my-app"` or `"my-app/config"`) appended between the base directory and the config file name. Each path component must not be empty, `.`, `..`, or contain Windows-forbidden characters. When omitted, files are written directly into `dir`.
117
-
118
- ### 3. Build a `Configurate` instance
119
-
120
- ```ts
121
- const appConfig = factory.build(appSchema, "app"); // → app.json
122
-
123
- // Override the factory-level subDir for one specific file:
124
- const specialConfig = factory.build(specialSchema, "special", "other-dir"); // → <AppConfig>/other-dir/special.json
125
- ```
126
-
127
- Each call to `build()` can use a different schema, `id`, and/or `subDir`.
128
-
129
- ### 4. Create, load, save, delete
130
-
131
- All file operations return a `LazyConfigEntry` that you execute with `.run()` or `.unlock()`.
132
-
133
- #### Create
134
-
135
- ```ts
136
- await appConfig
137
- .create({
138
- theme: "dark",
139
- language: "en",
140
- fontSize: 14,
141
- notifications: true,
142
- database: { host: "localhost", port: 5432, password: "s3cr3t" },
143
- })
144
- .lock({ service: "my-app", account: "default" }) // write password to keyring
145
- .run();
146
- ```
147
-
148
- #### Load (secrets remain `null`)
149
-
150
- ```ts
151
- const locked = await appConfig.load().run();
152
-
153
- locked.data.theme; // "dark"
154
- locked.data.database.password; // null ← secret is not in memory
155
- ```
156
-
157
- #### Load and unlock in one IPC call
158
-
159
- ```ts
160
- const unlocked = await appConfig.load().unlock({ service: "my-app", account: "default" });
161
-
162
- unlocked.data.database.password; // "s3cr3t"
163
- ```
164
-
165
- #### Unlock a `LockedConfig` later (no file re-read)
166
-
167
- ```ts
168
- const locked = await appConfig.load().run();
169
- // ... pass locked.data to the UI without secrets ...
170
- const unlocked = await locked.unlock({ service: "my-app", account: "default" });
171
- ```
172
-
173
- `locked.unlock()` issues a single IPC call that reads only from the OS keyring — the file is not read again.
174
-
175
- #### Save
176
-
177
- ```ts
178
- await appConfig
179
- .save({
180
- theme: "light",
181
- language: "ja",
182
- fontSize: 16,
183
- notifications: false,
184
- database: { host: "db.example.com", port: 5432, password: "newpass" },
185
- })
186
- .lock({ service: "my-app", account: "default" })
187
- .run();
188
- ```
189
-
190
- #### Delete
191
-
192
- ```ts
193
- // Pass keyring options to wipe secrets from the OS keyring as well.
194
- await appConfig.delete({ service: "my-app", account: "default" });
195
-
196
- // Omit keyring options when the schema has no keyring fields.
197
- await appConfig.delete();
198
- ```
199
-
200
- ---
201
-
202
- ## Multiple config files
203
-
204
- Use `ConfigurateFactory` to manage several config files each can have a different schema, id, or format.
205
-
206
- ```ts
207
- const appSchema = defineConfig({ theme: String, language: String });
208
- const cacheSchema = defineConfig({ lastSync: Number });
209
- const secretSchema = defineConfig({
210
- token: keyring(String, { id: "api-token" }),
211
- });
212
-
213
- const factory = new ConfigurateFactory({
214
- dir: BaseDirectory.AppConfig,
215
- format: "json",
216
- subDir: "my-app", // all files stored under <AppConfig>/my-app/
217
- });
218
-
219
- const appConfig = factory.build(appSchema, "app"); // → my-app/app.json
220
- const cacheConfig = factory.build(cacheSchema, "cache"); // → my-app/cache.json
221
- const secretConfig = factory.build(secretSchema, "secrets"); // → my-app/secrets.json
222
-
223
- // Override subDir per-file when needed:
224
- const legacyConfig = factory.build(legacySchema, "legacy", "old-dir"); // old-dir/legacy.json
225
-
226
- // Each instance is a full Configurate — all operations are available
227
- const app = await appConfig.load().run();
228
- const cache = await cacheConfig.load().run();
229
- ```
230
-
231
- ## Encrypted binary format
232
-
233
- Set `format: "binary"` and provide an `encryptionKey` to store config files encrypted with **XChaCha20-Poly1305**. The 32-byte cipher key is derived internally via SHA-256, so any high-entropy string is suitable — a random key stored in the OS keyring is ideal.
234
-
235
- Encrypted files use the **`.binc`** extension (plain binary files use `.bin`). Never mix backends: opening a `.binc` file with the wrong or missing key returns an error; opening a plain `.bin` file with an `encryptionKey` also returns a decryption error.
236
-
237
- ```ts
238
- const encKey = await getEncryptionKeyFromKeyring(); // your own retrieval logic
239
-
240
- const factory = new ConfigurateFactory({
241
- dir: BaseDirectory.AppConfig,
242
- format: "binary",
243
- encryptionKey: encKey,
244
- });
245
-
246
- const config = factory.build(appSchema, "app"); // → app.binc (encrypted)
247
-
248
- await config.create({ theme: "dark", language: "en" /* ... */ }).run();
249
- const locked = await config.load().run();
250
- ```
251
-
252
- On-disk format: `[24-byte random nonce][ciphertext + 16-byte Poly1305 tag]`.
253
-
254
- > **Note**`encryptionKey` is only valid with `format: "binary"`. Providing it with `"json"` or `"yaml"` throws an error at construction time.
255
-
256
- ## API reference
257
-
258
- ### `defineConfig(schema)`
259
-
260
- Validates the schema for duplicate keyring IDs and returns it typed as `S`. Throws at runtime if a duplicate ID is found.
261
-
262
- ```ts
263
- const schema = defineConfig({ name: String, port: Number });
264
- ```
265
-
266
- ### `keyring(type, { id })`
267
-
268
- Marks a schema field as keyring-protected. The field is stored in the OS keyring and appears as `null` in the on-disk file and in `LockedConfig.data`.
269
-
270
- ```ts
271
- keyring(String, { id: "my-secret" });
272
- ```
273
-
274
- ### `ConfigurateFactory`
275
-
276
- ```ts
277
- new ConfigurateFactory(baseOpts: ConfigurateBaseOptions)
278
- ```
279
-
280
- `ConfigurateBaseOptions` is `ConfigurateOptions` without `id`:
281
-
282
- | Field | Type | Description |
283
- | --------------- | --------------- | --------------------------------------------------- |
284
- | `dir` | `BaseDirectory` | Base directory for all files |
285
- | `subDir` | `string?` | Sub-directory path relative to `dir` (optional) |
286
- | `format` | `StorageFormat` | `"json"`, `"yaml"`, or `"binary"` |
287
- | `encryptionKey` | `string?` | Encryption key (binary format only, yields `.binc`) |
288
-
289
- #### `factory.build(schema, id, subDir?)`
290
-
291
- Returns a `Configurate<S>` for the given schema and file stem. The optional `subDir` argument overrides the factory-level `subDir` for this specific instance.
292
-
293
- ```ts
294
- factory.build(schema, "app") // → <dir>/app.json
295
- factory.build(schema, "app", "my-app") // → <dir>/my-app/app.json
296
- ```
297
-
298
- ### `Configurate<S>`
299
-
300
- | Method | Returns | Description |
301
- | ---------------- | -------------------- | ---------------------------------------- |
302
- | `.create(data)` | `LazyConfigEntry<S>` | Write a new config file |
303
- | `.load()` | `LazyConfigEntry<S>` | Read an existing config file |
304
- | `.save(data)` | `LazyConfigEntry<S>` | Overwrite an existing config file |
305
- | `.delete(opts?)` | `Promise<void>` | Delete the file and wipe keyring entries |
306
-
307
- ### `LazyConfigEntry<S>`
308
-
309
- | Method | Returns | Description |
310
- | --------------- | ---------------------------- | ----------------------------------------------------- |
311
- | `.lock(opts)` | `this` | Attach keyring options (chainable, before run/unlock) |
312
- | `.run()` | `Promise<LockedConfig<S>>` | Execute — secrets are `null` |
313
- | `.unlock(opts)` | `Promise<UnlockedConfig<S>>` | Execute — secrets are inlined (single IPC call) |
314
-
315
- ### `LockedConfig<S>`
316
-
317
- | Member | Type | Description |
318
- | --------------- | ---------------------------- | ----------------------------------------- |
319
- | `.data` | `InferLocked<S>` | Config data with keyring fields as `null` |
320
- | `.unlock(opts)` | `Promise<UnlockedConfig<S>>` | Fetch secrets without re-reading the file |
321
-
322
- ### `UnlockedConfig<S>`
323
-
324
- | Member | Type | Description |
325
- | --------- | ------------------ | ------------------------------------ |
326
- | `.data` | `InferUnlocked<S>` | Config data with all secrets inlined |
327
- | `.lock()` | `void` | Drop in-memory secrets (GC-assisted) |
328
-
329
- ## IPC call count
330
-
331
- | Operation | IPC calls |
332
- | ------------------------------------------- | --------- |
333
- | `create` / `save` (with or without keyring) | 1 |
334
- | `load` (no keyring) | 1 |
335
- | `load().unlock(opts)` | 1 |
336
- | `load().run()` then `locked.unlock(opts)` | 2 |
337
- | `delete` | 1 |
338
-
339
- ## Security considerations
340
-
341
- - **Secrets off disk** keyring fields are set to `null` before the file is written; the plaintext never touches the filesystem.
342
- - **Path traversal protection** config IDs and `subDir` components containing `/`, `\`, `:`, `*`, `?`, `"`, `<`, `>`, `|`, bare `.` or `..`, and null bytes are rejected with an `invalid payload` error.
343
- - **Encrypted binary (`.binc`)** XChaCha20-Poly1305 provides authenticated encryption; any tampering with the ciphertext is detected at read time and returns an error. Encrypted files are distinguished from plain binary (`.bin`) by their extension.
344
- - **Binary ≠ encrypted** — `format: "binary"` without `encryptionKey` stores data as plain bincode-encoded JSON (`.bin`). Use `encryptionKey` when confidentiality is required.
345
- - **Key entropy** — when using `encryptionKey`, provide a high-entropy value (≥ 128 bits of randomness). A randomly generated key stored in the OS keyring is recommended.
346
- - **Keyring availability** — the OS keyring may not be available in all environments (e.g. headless CI). Handle `keyring error` responses gracefully in those cases.
347
- - **In-memory secrets** — `UnlockedConfig.data` holds plaintext values in the JS heap until GC collection. JavaScript provides no guaranteed way to zero-out memory, so avoid keeping `UnlockedConfig` objects alive longer than necessary.
348
-
349
- ## License
350
-
351
- MIT
1
+ # tauri-plugin-configurate
2
+
3
+ A Tauri v2 plugin for type-safe application configuration management.
4
+
5
+ Define your config schema once in TypeScript and get full type inference for reads and writes. Supports JSON, YAML, and encrypted binary formats, with first-class OS keyring integration for storing secrets securely off disk.
6
+
7
+ ## Features
8
+
9
+ - 🛡️ **Type-safe schema** — define your config shape with `defineConfig()` and get compile-time checked reads/writes
10
+ - 🔑 **OS keyring support** — mark fields with `keyring()` to store secrets in the native credential store (Keychain / Credential Manager / libsecret) and keep them off disk
11
+ - 💾 **Multiple formats** — JSON (human-readable), YAML (human-readable), binary (compact), or encrypted binary (XChaCha20-Poly1305)
12
+ - **Minimal IPC** — every operation (file read + keyring fetch) is batched into a single IPC round-trip
13
+ - 🗂️ **Multiple config files** — use `ConfigurateFactory` to manage multiple files with different schemas from one place
14
+ - 🛤️ **Flexible path control** — `dirName` to replace the app identifier, `path` to add sub-directories, custom extensions via the `name` field
15
+ - 🚧 **Path traversal protection** — `..`, bare `.`, empty segments, and Windows-forbidden characters (`/ \ : * ? " < > |` and null bytes) are rejected with an `invalid payload` error
16
+
17
+ ## Installation
18
+
19
+ ### Rust
20
+
21
+ Add the plugin to `src-tauri/Cargo.toml`:
22
+
23
+ ```toml
24
+ [dependencies]
25
+ tauri-plugin-configurate = "0.1.0"
26
+ ```
27
+
28
+ Register it in `src-tauri/src/lib.rs`:
29
+
30
+ ```rust
31
+ pub fn run() {
32
+ tauri::Builder::default()
33
+ .plugin(tauri_plugin_configurate::init())
34
+ .run(tauri::generate_context!())
35
+ .expect("error while running tauri application");
36
+ }
37
+ ```
38
+
39
+ ### JavaScript / TypeScript
40
+
41
+ Install the guest bindings:
42
+
43
+ ```sh
44
+ # npm
45
+ npm install tauri-plugin-configurate-api
46
+
47
+ # pnpm
48
+ pnpm add tauri-plugin-configurate-api
49
+
50
+ # bun
51
+ bun add tauri-plugin-configurate-api
52
+ ```
53
+
54
+ ### Capabilities (permissions)
55
+
56
+ Add the following to your capability file (e.g. `src-tauri/capabilities/default.json`):
57
+
58
+ ```json
59
+ {
60
+ "permissions": ["configurate:default"]
61
+ }
62
+ ```
63
+
64
+ `configurate:default` grants access to all plugin commands. You can also allow them individually:
65
+
66
+ | Permission | Description |
67
+ | -------------------------- | ------------------------------------------ |
68
+ | `configurate:allow-create` | Allow creating a new config file |
69
+ | `configurate:allow-load` | Allow loading a config file |
70
+ | `configurate:allow-save` | Allow saving (overwriting) a config file |
71
+ | `configurate:allow-delete` | Allow deleting a config file |
72
+ | `configurate:allow-unlock` | Allow fetching secrets from the OS keyring |
73
+
74
+ ## Usage
75
+
76
+ ### 1. Define a schema
77
+
78
+ Use `defineConfig()` to declare the shape of your config. Primitive fields use constructor values (`String`, `Number`, `Boolean`). Nested objects are supported. Fields that should be stored in the OS keyring are wrapped with `keyring()`.
79
+
80
+ ```ts
81
+ import {
82
+ defineConfig,
83
+ keyring,
84
+ ConfigurateFactory,
85
+ BaseDirectory,
86
+ } from "tauri-plugin-configurate-api";
87
+
88
+ const appSchema = defineConfig({
89
+ theme: String,
90
+ language: String,
91
+ fontSize: Number,
92
+ notifications: Boolean,
93
+ database: {
94
+ host: String,
95
+ port: Number,
96
+ // stored in the OS keyring never written to disk
97
+ password: keyring(String, { id: "db-password" }),
98
+ },
99
+ });
100
+ ```
101
+
102
+ `keyring()` IDs must be unique within a schema. Duplicates are caught at both compile time and runtime.
103
+
104
+ ### 2. Create a factory
105
+
106
+ `ConfigurateFactory` holds shared options (`dir`, `format`, optional `dirName`, optional `path`, optional `encryptionKey`) and produces `Configurate` instances — one per config file.
107
+
108
+ ```ts
109
+ const factory = new ConfigurateFactory({
110
+ dir: BaseDirectory.AppConfig,
111
+ format: "json",
112
+ // dirName: "my-app", // replaces the identifier: %APPDATA%/my-app/
113
+ // path: "config", // sub-directory within the root: <root>/config/
114
+ // encryptionKey: key, // enables encrypted binary (.binc), requires format: "binary"
115
+ });
116
+ ```
117
+
118
+ ### 3. Build a `Configurate` instance
119
+
120
+ `factory.build()` accepts either a plain filename string or an object for full control.
121
+
122
+ ```ts
123
+ // Plain string filename as-is (include extension)
124
+ const appConfig = factory.build(appSchema, "app.json");
125
+
126
+ // Object form — sub-directory within the root
127
+ const nestedConfig = factory.build(appSchema, { name: "app.json", path: "config/v2" });
128
+
129
+ // Object form replace the app identifier directory
130
+ const movedConfig = factory.build(appSchema, { name: "app.json", dirName: "my-app" });
131
+
132
+ // Object form — both dirName and path
133
+ const fullConfig = factory.build(appSchema, { name: "app.json", dirName: "my-app", path: "config" });
134
+
135
+ // Third-argument shorthand — overrides factory-level dirName (string form only)
136
+ const specialConfig = factory.build(appSchema, "special.json", "other-dir");
137
+ ```
138
+
139
+ #### Path layout
140
+
141
+ With `BaseDirectory.AppConfig` on Windows (identifier `com.example.app`):
142
+
143
+ | `name` | `dirName` | `path` | Resolved path |
144
+ | ----------- | ----------- | ----------- | --------------------------------------------------- |
145
+ | `app.json` | _(omitted)_ | _(omitted)_ | `%APPDATA%\com.example.app\app.json` |
146
+ | `app.json` | `my-app` | _(omitted)_ | `%APPDATA%\my-app\app.json` |
147
+ | `app.json` | _(omitted)_ | `cfg/v2` | `%APPDATA%\com.example.app\cfg\v2\app.json` |
148
+ | `app.json` | `my-app` | `cfg/v2` | `%APPDATA%\my-app\cfg\v2\app.json` |
149
+ | `.env` | _(omitted)_ | _(omitted)_ | `%APPDATA%\com.example.app\.env` |
150
+ | `data.yaml` | `my-app` | `profiles` | `%APPDATA%\my-app\profiles\data.yaml` |
151
+
152
+ > **`name`** — full filename including extension (e.g. `"app.json"`, `"data.yaml"`, `".env"`). Must be a single component — path separators are rejected. No extension is appended automatically.
153
+ >
154
+ > **`dirName`** — replaces the identifier component of the base path (`com.example.app` → your value). For base directories without an identifier (e.g. `Desktop`, `Home`), `dirName` is appended as a sub-directory instead. Each segment is validated; `..` and Windows-forbidden characters are rejected.
155
+ >
156
+ > **`path`** — adds a sub-directory within the root (after `dirName` / identifier). Use forward slashes for nesting (e.g. `"profiles/v2"`). Each segment is validated the same way.
157
+
158
+ Each call to `build()` can use a different schema, `name`, `dirName`, and/or `path`.
159
+
160
+ ### 4. Create, load, save, delete
161
+
162
+ All file operations return a `LazyConfigEntry` that you execute with `.run()` or `.unlock()`.
163
+
164
+ #### Create
165
+
166
+ ```ts
167
+ await appConfig
168
+ .create({
169
+ theme: "dark",
170
+ language: "en",
171
+ fontSize: 14,
172
+ notifications: true,
173
+ database: { host: "localhost", port: 5432, password: "s3cr3t" },
174
+ })
175
+ .lock({ service: "my-app", account: "default" }) // write password to keyring
176
+ .run();
177
+ ```
178
+
179
+ #### Load (secrets remain `null`)
180
+
181
+ ```ts
182
+ const locked = await appConfig.load().run();
183
+
184
+ locked.data.theme; // "dark"
185
+ locked.data.database.password; // null ← secret is not in memory
186
+ ```
187
+
188
+ #### Load and unlock in one IPC call
189
+
190
+ ```ts
191
+ const unlocked = await appConfig.load().unlock({ service: "my-app", account: "default" });
192
+
193
+ unlocked.data.database.password; // "s3cr3t"
194
+ ```
195
+
196
+ #### Unlock a `LockedConfig` later (no file re-read)
197
+
198
+ ```ts
199
+ const locked = await appConfig.load().run();
200
+ // ... pass locked.data to the UI without secrets ...
201
+ const unlocked = await locked.unlock({ service: "my-app", account: "default" });
202
+ ```
203
+
204
+ `locked.unlock()` issues a single IPC call that reads only from the OS keyring the file is not read again.
205
+
206
+ #### Save
207
+
208
+ ```ts
209
+ await appConfig
210
+ .save({
211
+ theme: "light",
212
+ language: "ja",
213
+ fontSize: 16,
214
+ notifications: false,
215
+ database: { host: "db.example.com", port: 5432, password: "newpass" },
216
+ })
217
+ .lock({ service: "my-app", account: "default" })
218
+ .run();
219
+ ```
220
+
221
+ #### Delete
222
+
223
+ ```ts
224
+ // Pass keyring options to wipe secrets from the OS keyring as well.
225
+ await appConfig.delete({ service: "my-app", account: "default" });
226
+
227
+ // Omit keyring options when the schema has no keyring fields.
228
+ await appConfig.delete();
229
+ ```
230
+
231
+ ---
232
+
233
+ ## Multiple config files
234
+
235
+ Use `ConfigurateFactory` to manage several config files each can have a different schema, name, or format.
236
+
237
+ ```ts
238
+ const appSchema = defineConfig({ theme: String, language: String });
239
+ const cacheSchema = defineConfig({ lastSync: Number });
240
+ const secretSchema = defineConfig({
241
+ token: keyring(String, { id: "api-token" }),
242
+ });
243
+
244
+ const factory = new ConfigurateFactory({
245
+ dir: BaseDirectory.AppConfig,
246
+ format: "json",
247
+ dirName: "my-app", // → %APPDATA%/my-app/ (replaces identifier)
248
+ });
249
+
250
+ const appConfig = factory.build(appSchema, "app.json"); // → %APPDATA%/my-app/app.json
251
+ const cacheConfig = factory.build(cacheSchema, "cache.json"); // → %APPDATA%/my-app/cache.json
252
+ const secretConfig = factory.build(secretSchema, "secrets.json"); // → %APPDATA%/my-app/secrets.json
253
+
254
+ // Object form sub-directory within the root
255
+ const v2Config = factory.build(appSchema, { name: "app.json", path: "v2" }); // → %APPDATA%/my-app/v2/app.json
256
+ const deepConfig = factory.build(cacheSchema, { name: "cache.json", path: "archive/2025" }); // → %APPDATA%/my-app/archive/2025/cache.json
257
+
258
+ // Object form — override dirName per instance
259
+ const otherConfig = factory.build(appSchema, { name: "app.json", dirName: "other-app" }); // → %APPDATA%/other-app/app.json
260
+
261
+ // Third-argument shorthand (string form only)
262
+ const legacyConfig = factory.build(appSchema, "legacy.json", "old-app"); // → %APPDATA%/old-app/legacy.json
263
+
264
+ // Each instance is a full Configurate — all operations are available
265
+ const app = await appConfig.load().run();
266
+ const cache = await cacheConfig.load().run();
267
+ ```
268
+
269
+ ## Encrypted binary format
270
+
271
+ Set `format: "binary"` and provide an `encryptionKey` to store config files encrypted with **XChaCha20-Poly1305**. The 32-byte cipher key is derived internally via SHA-256, so any high-entropy string is suitable — a random key stored in the OS keyring is ideal.
272
+
273
+ Encrypted files use the **`.binc`** extension (plain binary files use `.bin`). Since `name` is the full filename, you must specify the correct extension yourself (e.g. `"app.binc"` for encrypted, `"app.bin"` for plain binary, `"app.json"` for JSON, `"app.yaml"` for YAML). No extension is appended automatically — a mismatch between `format` and the file extension will not be caught at construction time. Never mix backends: opening a `.binc` file with the wrong or missing key returns an error; opening a plain `.bin` file with an `encryptionKey` also returns a decryption error.
274
+
275
+ ```ts
276
+ const encKey = await getEncryptionKeyFromKeyring(); // your own retrieval logic
277
+
278
+ const factory = new ConfigurateFactory({
279
+ dir: BaseDirectory.AppConfig,
280
+ format: "binary",
281
+ encryptionKey: encKey,
282
+ });
283
+
284
+ const config = factory.build(appSchema, "app.binc"); // encrypted binary
285
+
286
+ await config.create({ theme: "dark", language: "en" /* ... */ }).run();
287
+ const locked = await config.load().run();
288
+ ```
289
+
290
+ On-disk format: `[24-byte random nonce][ciphertext + 16-byte Poly1305 tag]`.
291
+
292
+ > **Note** — `encryptionKey` is only valid with `format: "binary"`. Providing it with `"json"` or `"yaml"` throws an error at construction time.
293
+
294
+ ## API reference
295
+
296
+ ### `defineConfig(schema)`
297
+
298
+ Validates the schema for duplicate keyring IDs and returns it typed as `S`. Throws at runtime if a duplicate ID is found.
299
+
300
+ ```ts
301
+ const schema = defineConfig({ name: String, port: Number });
302
+ ```
303
+
304
+ ### `keyring(type, { id })`
305
+
306
+ Marks a schema field as keyring-protected. The field is stored in the OS keyring and appears as `null` in the on-disk file and in `LockedConfig.data`.
307
+
308
+ ```ts
309
+ keyring(String, { id: "my-secret" });
310
+ ```
311
+
312
+ ### `ConfigurateFactory`
313
+
314
+ ```ts
315
+ new ConfigurateFactory(baseOpts: ConfigurateBaseOptions)
316
+ ```
317
+
318
+ `ConfigurateBaseOptions` is `ConfigurateOptions` without `name`:
319
+
320
+ | Field | Type | Description |
321
+ | --------------- | --------------- | -------------------------------------------------------------------- |
322
+ | `dir` | `BaseDirectory` | Base directory for all files |
323
+ | `dirName` | `string?` | Replaces the app identifier component of the base path |
324
+ | `path` | `string?` | Sub-directory within the root (after `dirName` / identifier) |
325
+ | `format` | `StorageFormat` | `"json"`, `"yaml"`, or `"binary"` |
326
+ | `encryptionKey` | `string?` | Encryption key (binary format only) |
327
+
328
+ #### `factory.build(schema, name, dirName?)` / `factory.build(schema, config)`
329
+
330
+ Returns a `Configurate<S>` for the given schema. The second argument is either:
331
+ - a plain `string` — the full filename including extension (e.g. `"app.json"`, `".env"`)
332
+ - `{ name: string; path?: string | null; dirName?: string | null }` — explicit filename, optional sub-directory, optional identifier replacement
333
+
334
+ When using the string form, the optional third `dirName` argument overrides the factory-level `dirName` for this instance.
335
+
336
+ In the object form, passing `null` for `dirName` or `path` explicitly disables the factory-level value. Omitting the field (or passing `undefined`) falls back to the factory-level value.
337
+
338
+ ```ts
339
+ factory.build(schema, "app.json") // → <root>/app.json
340
+ factory.build(schema, "app.json", "my-app") // → %APPDATA%/my-app/app.json
341
+ factory.build(schema, { name: "app.json", path: "config" }) // <root>/config/app.json
342
+ factory.build(schema, { name: "app.json", dirName: "my-app" }) // %APPDATA%/my-app/app.json
343
+ factory.build(schema, { name: "cfg.json", dirName: "my-app", path: "a/b" }) // %APPDATA%/my-app/a/b/cfg.json
344
+ ```
345
+
346
+ ### `ConfigurateOptions`
347
+
348
+ | Field | Type | Description |
349
+ | --------------- | --------------- | ---------------------------------------------------------------------------- |
350
+ | `name` | `string` | Full filename including extension (`"app.json"`, `".env"`). No path separators (`/` or `\`) allowed. |
351
+ | `dir` | `BaseDirectory` | Base directory |
352
+ | `dirName` | `string?` | Replaces the identifier component of the base path |
353
+ | `path` | `string?` | Sub-directory within the root. Forward-slash separated (e.g. `"cfg/v2"`) |
354
+ | `format` | `StorageFormat` | `"json"`, `"yaml"`, or `"binary"` |
355
+ | `encryptionKey` | `string?` | Encryption key (binary format only) |
356
+
357
+ ### `Configurate<S>`
358
+
359
+ | Method | Returns | Description |
360
+ | ---------------- | -------------------- | ---------------------------------------- |
361
+ | `.create(data)` | `LazyConfigEntry<S>` | Write a new config file |
362
+ | `.load()` | `LazyConfigEntry<S>` | Read an existing config file |
363
+ | `.save(data)` | `LazyConfigEntry<S>` | Overwrite an existing config file |
364
+ | `.delete(opts?)` | `Promise<void>` | Delete the file and wipe keyring entries |
365
+
366
+ ### `LazyConfigEntry<S>`
367
+
368
+ | Method | Returns | Description |
369
+ | --------------- | ---------------------------- | ----------------------------------------------------- |
370
+ | `.lock(opts)` | `this` | Attach keyring options (chainable, before run/unlock) |
371
+ | `.run()` | `Promise<LockedConfig<S>>` | Execute — secrets are `null` |
372
+ | `.unlock(opts)` | `Promise<UnlockedConfig<S>>` | Execute — secrets are inlined (single IPC call) |
373
+
374
+ ### `LockedConfig<S>`
375
+
376
+ | Member | Type | Description |
377
+ | --------------- | ---------------------------- | ----------------------------------------- |
378
+ | `.data` | `InferLocked<S>` | Config data with keyring fields as `null` |
379
+ | `.unlock(opts)` | `Promise<UnlockedConfig<S>>` | Fetch secrets without re-reading the file |
380
+
381
+ ### `UnlockedConfig<S>`
382
+
383
+ | Member | Type | Description |
384
+ | --------- | ------------------ | ------------------------------------ |
385
+ | `.data` | `InferUnlocked<S>` | Config data with all secrets inlined |
386
+ | `.lock()` | `void` | Drop in-memory secrets (GC-assisted) |
387
+
388
+ ## IPC call count
389
+
390
+ | Operation | IPC calls |
391
+ | ------------------------------------------- | --------- |
392
+ | `create` / `save` (with or without keyring) | 1 |
393
+ | `load` (no keyring) | 1 |
394
+ | `load().unlock(opts)` | 1 |
395
+ | `load().run()` then `locked.unlock(opts)` | 2 |
396
+ | `delete` | 1 |
397
+
398
+ ## Security considerations
399
+
400
+ - **Secrets off disk** — keyring fields are set to `null` before the file is written; the plaintext never touches the filesystem.
401
+ - **Path traversal protection** — `name`, `dirName`, and `path` components containing `..`, bare `.`, empty segments, and Windows-forbidden characters (`/ \ : * ? " < > |` and null bytes) are rejected with an `invalid payload` error.
402
+ - **Authenticated encryption** — XChaCha20-Poly1305 provides authenticated encryption; any tampering with the ciphertext is detected at read time and returns an error.
403
+ - **Binary ≠ encrypted** — `format: "binary"` without `encryptionKey` stores data as plain bincode-encoded JSON. Use `encryptionKey` when confidentiality is required.
404
+ - **Key entropy** — when using `encryptionKey`, provide a high-entropy value (≥ 128 bits of randomness). A randomly generated key stored in the OS keyring is recommended.
405
+ - **Keyring availability** — the OS keyring may not be available in all environments (e.g. headless CI). Handle `keyring error` responses gracefully in those cases.
406
+ - **In-memory secrets** — `UnlockedConfig.data` holds plaintext values in the JS heap until GC collection. JavaScript provides no guaranteed way to zero-out memory, so avoid keeping `UnlockedConfig` objects alive longer than necessary.
407
+
408
+ ## License
409
+
410
+ MIT