@x12i/client-config
v1.2.0
Published
Framework-agnostic browser runtime config: typed schemas, validation, local persistence, sensitive-field handling, and optional encryption.
Downloads
190
Maintainers
Readme
@x12i/client-config
Client-side runtime configuration for browser apps (especially SPAs): typed schemas, validation, normalization, namespaced persistence, sensitive-field handling, optional encryption, migrations, import/export, .env-style text import, and configuration reporting helpers.
What it is
- A small, framework-agnostic library for operator-editable settings after deploy (API base URL, tenant id, feature flags, demo tokens, etc.).
- Schema-first: defaults, validation, and TypeScript inference from field definitions.
- Storage adapters for
localStorage,sessionStorage, and in-memory (tests, SSR fallbacks). - Envelope-based persistence (version, namespace, timestamps, encryption metadata) — not a raw JSON blob only.
- Hooks for load/save/reset/change/migration/validation errors.
- Pluggable encryption (Web Crypto AES-GCM helpers included) for stored bytes only — not a threat model against XSS.
What it is not
- Not secure secret storage, a vault, or a substitute for server-side secret management.
- Not a replacement for backend
.envor identity/auth. - Not a cloud config service (that can be a future adapter).
- Not React-specific — use a thin wrapper package later (e.g.
@x12i/client-config-react) for providers and hooks.
Real secrets that would cause serious harm if exposed should not rely on browser-only protection.
Installation
npm install @x12i/client-configQuick start
import {
createClientConfig,
createLocalStorageAdapter,
defineSchema,
field,
} from "@x12i/client-config";
const schema = defineSchema({
apiUrl: field.string({
label: "API base URL",
default: "",
required: true,
trim: true,
}),
tenantId: field.string({ default: "", label: "Tenant" }),
debug: field.boolean({ default: false }),
});
const store = createClientConfig({
namespace: "x12i.myapp.config",
schema,
storage: createLocalStorageAdapter({ fallbackToMemory: true }),
});
await store.load();
store.setField("apiUrl", "https://api.example.com");
await store.save();Schema definition
Use field.* helpers so TypeScript can infer store.get():
| Kind | Helper | Notes |
|------------|---------------|--------|
| string | field.string | trim, emptyAsUndefined, ttlMs, persist |
| number | field.number | coerceFromString (recommended for .env imports) |
| boolean | field.boolean | coerceFromString (recommended for .env imports) |
| enum | field.enum | values, caseInsensitive |
| string[] | field.stringArray | |
| json | field.json | arbitrary JSON-serializable default |
Common options on all fields: label, description, default, required, sensitive, persist (default true), validate, normalize, ttlMs, envKey (explicit env variable name for importEnv / analyzeEnv).
Invalid defaults throw at defineSchema time (INVALID_SCHEMA).
Storage adapters
createLocalStorageAdapter({ fallbackToMemory?: boolean })createSessionStorageAdapter({ fallbackToMemory?: boolean })createMemoryAdapter()
If storage is unavailable and you did not enable fallbackToMemory, operations that write will surface structured errors (STORAGE_UNAVAILABLE, STORAGE_QUOTA, etc.).
The storage key is buildStorageKey(namespace) (for example @x12i/client-config:my.app).
Sensitive fields
Mark fields with sensitive: true:
store.getRedacted()and safe / default export mask values for display and sharing.- Use full export with
{ mode: "full", includeSensitive: true }only when you explicitly want secrets in the JSON.
This reduces casual exposure; it does not make values secret in the browser.
Encryption caveats
- Encryption changes the stored representation (
fullencrypts the inner envelope JSON;fieldencrypts marked fields with a prefixed ciphertext string). - At runtime the app still holds decrypted values in memory.
- XSS, malicious extensions, or physical access defeat client-only encryption.
Provide an EncryptionProvider or use createAesGcmEncryptionProvider + importAesGcmKey / deriveAesGcmKeyFromPassphrase.
If decryption fails, you get DECRYPTION_FAILED (recoverable); clear() / reset() can recover.
Migration
Pass migrations: [{ toVersion, migrate(config, ctx) }] to createClientConfig. On load, stored schemaVersion is compared to the schema’s version; steps run in order until the config matches the current version.
hooks.onMigration reports success or failure.
Import / export
store.exportJson({ mode: "safe" | "full" | "partial", keys?, includeSensitive? })store.importJson(json, { mode: "replace" | "merge" | "mergeKnown", save?: boolean, source?: string })
Import accepts either a full export payload or a bare { config: { ... } } object.
.env-style text
Values in env files are always strings. Map them to schema fields with:
store.importEnv(text, options?)— same merge modes asimportJson(replace|merge|mergeKnown), plus optionalstrictParse(defaultfalse). Lenient parsing skips bad lines and still applies valid entries; strict mode rejects the whole import if any line fails to parse.store.validateEnv(text, options?)— same merge/parse options but does not mutate the store; returnsValidationResultplusparseErrorsandcollisionErrors.analyzeEnv(schema, text)— parse and describe mapping:matchedFields,missingInEnv,unknownEnvKeys,collisionErrors,previews(sensitive values masked), without a store.
Variable name resolution (first match wins per field): optional envKey on the field, then the schema key as-is, then a generated alias (apiUrl → API_URL via schemaKeyToEnvAlias).
Collisions: if one env variable would map to more than one schema field, import/validate fails with ENV_KEY_COLLISION.
Supported in the parser: KEY=value, optional export prefix, # comments, double/single-quoted values (with \\, \", \n, \r, \t in double quotes). Not supported: $VAR expansion, multiline values, line continuations.
Types: json fields accept a JSON object/array string (trimmed, starts with { or [). string[] fields accept comma-separated values.
Runtime reporting: store.getConfigurationReport() returns per-field required, valid, error, isEmpty, satisfied, atDefault for the current in-memory config (independent of .env). Lower-level: buildConfigurationReport(schema, config).
Standalone utilities (also used internally): parseDotenv, mapEnvEntriesToIncoming, mergeImportIncoming, schemaKeyToEnvAlias — useful for tests or custom pipelines without loading the store.
Common patterns
- Namespaced apps: one namespace per app or tenant slice (
x12i.reportix.local). - Diagnostics:
getClientConfigDiagnostics(adapter)for storage availability and Web Crypto. - Change tracking:
store.getStatus()exposesdirty,changedKeys,lastSavedAt,lastLoadedAt,lastError, etc. - Subscribe:
store.subscribe((status) => { ... })for UI updates. - Non-persisted fields:
persist: false— not written on save; after a full page reload they come from defaults unless you use session storage or another mechanism.
Security notes
- Anything the SPA can read, the user (or attacker with script access) can read.
- Do not describe this package as vault-grade or XSS-safe encryption.
- Prefer backend-held secrets, short-lived tokens, and proxies for high-value credentials.
- Use masking/redacted exports in logs and support tooling.
API overview
| Method | Purpose |
|--------|---------|
| load() | Read storage, migrate, normalize, validate |
| get() / getField / getMany | Read current config |
| setField / setMany / replace | Update (returns ValidationResult) |
| validate / validateField / validateImport | Validation only |
| save() | Persist (optional validateOnSave / per-call validate) |
| reset(fields?) | Revert to defaults |
| clear() | Remove storage key + defaults |
| exportJson / importJson | Portability |
| importEnv / validateEnv | .env text → config |
| getConfigurationReport | Per-field required/default/valid summary |
| getStatus / subscribe / getRedacted | Status and UI |
Constructor options include encryption, migrations, hooks, strictLoad, and validateOnSave.
Development and tests
npm run typecheck # TypeScript
npm test # Vitest (core store + `.env` / reporting)
npm run build # ESM + declarations in dist/npm publish runs prepublishOnly, which executes typecheck, tests, and build before the tarball is created.
License
MIT
