@fjall/util 3.8.1 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/.minified CHANGED
@@ -1 +1 @@
1
- 89 files minified at 2026-07-20T11:32:46.338Z
1
+ 89 files minified at 2026-07-22T07:34:05.207Z
package/dist/config.d.ts CHANGED
@@ -144,6 +144,7 @@ export declare const DomainConfigSchema: z.ZodObject<{
144
144
  }>;
145
145
  parentDomain: z.ZodOptional<z.ZodString>;
146
146
  account: z.ZodOptional<z.ZodString>;
147
+ region: z.ZodOptional<z.ZodString>;
147
148
  }, z.core.$strict>;
148
149
  export type DomainConfig = z.infer<typeof DomainConfigSchema>;
149
150
  export declare const RootConfigSchema: z.ZodObject<{
@@ -156,6 +157,7 @@ export declare const RootConfigSchema: z.ZodObject<{
156
157
  }>;
157
158
  parentDomain: z.ZodOptional<z.ZodString>;
158
159
  account: z.ZodOptional<z.ZodString>;
160
+ region: z.ZodOptional<z.ZodString>;
159
161
  }, z.core.$strict>>>;
160
162
  }, z.core.$strict>;
161
163
  export type RootConfig = z.infer<typeof RootConfigSchema>;
@@ -180,17 +182,21 @@ export declare const RootConfigReadSchema: z.ZodObject<{
180
182
  }>;
181
183
  parentDomain: z.ZodOptional<z.ZodString>;
182
184
  account: z.ZodOptional<z.ZodString>;
185
+ region: z.ZodOptional<z.ZodString>;
183
186
  }, z.core.$strict>>>;
184
187
  }, z.core.$strip>;
185
188
  export type RootConfigRead = z.infer<typeof RootConfigReadSchema>;
186
189
  /**
187
- * Canonical serialiser for the root fjall-config.json the single source of
190
+ * Canonical serialiser for the root fjall-config.json - the single source of
188
191
  * truth for the file's on-disk shape. Both `Config.saveConfig` and the webapp
189
192
  * scaffold MUST route through this rather than hand-rolling the JSON, so the
190
193
  * two can never drift (a scaffold emitting a key the loader later rejects is
191
- * exactly the bug this prevents). The `RootConfig` input type makes an unknown
192
- * key unrepresentable at compile time; the round-trip test asserts the emitted
193
- * default validates against RootConfigSchema.
194
+ * exactly the bug this prevents). The `RootConfig` input type keeps OUR keys
195
+ * honest at compile time, but the emitted JSON is deliberately NOT
196
+ * strict-validated: `mergeWithDisk` routes top-level keys another fjall
197
+ * version wrote through the save verbatim (see its doc), so a merged save may
198
+ * carry keys RootConfigSchema does not recognise. The round-trip test asserts
199
+ * only that the emitted DEFAULT validates against RootConfigSchema.
194
200
  */
195
201
  export declare function serialiseRootConfig(config?: RootConfig): string;
196
202
  /**
@@ -208,12 +214,32 @@ export declare class Config {
208
214
  * included the real file's contents, so writing would clobber them.
209
215
  */
210
216
  private loadFailed;
217
+ /**
218
+ * True when the file's JSON parsed but failed even the tolerant
219
+ * RootConfigReadSchema (a recognised key such as `domains` is malformed),
220
+ * so the WHOLE config fell back to empty and any recorded domain pins
221
+ * vanished from memory.
222
+ */
223
+ private parseDegraded;
211
224
  /**
212
225
  * Top-level keys explicitly cleared this session (clearActiveTarget).
213
226
  * The disk-preserving merge in saveConfig would otherwise resurrect them
214
227
  * from the on-disk copy.
215
228
  */
216
229
  private readonly clearedKeys;
230
+ /**
231
+ * Deep copy of the state this instance LOADED from disk (empty for a
232
+ * programmatically-constructed Config, whose entire state is session
233
+ * intent). saveConfig diffs the live state against this snapshot so it
234
+ * only re-asserts what this session actually changed - an untouched key
235
+ * (or an untouched domains[] entry) keeps whatever a concurrent process
236
+ * wrote to disk between load and save. This is the structural fix for
237
+ * the activeTarget lost-update incident: previously every save re-wrote
238
+ * the whole load-time snapshot at top-level-key granularity, clobbering
239
+ * a concurrent `fjall target set` and erasing concurrently-registered
240
+ * sibling domain entries.
241
+ */
242
+ private loadedSnapshot;
217
243
  constructor(rootConfig?: RootConfig, configPath?: string);
218
244
  /**
219
245
  * Find the config directory by walking up the directory tree.
@@ -236,14 +262,32 @@ export declare class Config {
236
262
  */
237
263
  private static assertWritable;
238
264
  /**
239
- * Disk-preserving merge: re-reads the on-disk config immediately before
240
- * writing so sibling top-level keys written by a concurrent process between
241
- * this session's load and save survive. In-memory keys win at top-level-key
242
- * granularity; keys explicitly cleared this session are removed even when
243
- * the disk copy still carries them. Unreadable or invalid disk state falls
244
- * back to the in-memory state with a warning rather than blocking the save.
265
+ * Session-intent merge: re-reads the on-disk config immediately before
266
+ * writing and asserts ONLY what this session actually changed relative to
267
+ * its load-time snapshot (see loadedSnapshot). An untouched key keeps the
268
+ * disk's current value, the `domains` key merges PER ENTRY (keyed by name)
269
+ * so concurrently-registered siblings survive, keys this version does not
270
+ * recognise ride through verbatim, and keys explicitly cleared this
271
+ * session are removed even when the disk copy still carries them.
272
+ * Unreadable or invalid disk state falls back to the in-memory state with
273
+ * a warning rather than blocking the save.
245
274
  */
246
275
  private mergeWithDisk;
276
+ /**
277
+ * Did this session change `key` relative to its load-time snapshot?
278
+ * JSON comparison is exact for today's scalar keys; a future nested
279
+ * top-level key needs its own per-entry merge (as `domains` has).
280
+ */
281
+ private sessionChangedKey;
282
+ /**
283
+ * Per-entry merge of the `domains` key. Disk order is the base: an entry
284
+ * this session changed is asserted, an entry this session removed (it was
285
+ * in the load snapshot, it is gone from live state) drops out, and any
286
+ * entry this session never touched keeps the DISK version - including
287
+ * entries a concurrent process registered after this session loaded.
288
+ * Session additions append after the disk entries.
289
+ */
290
+ private mergeDomains;
247
291
  private static readDiskConfigForMerge;
248
292
  static getConfigDirectory(startDir?: string): string | null;
249
293
  /**
@@ -252,6 +296,16 @@ export declare class Config {
252
296
  * signal error paths branch on.
253
297
  */
254
298
  getConfigPath(): string | null;
299
+ /**
300
+ * True when a fjall-config.json EXISTS on disk but its contents are not
301
+ * represented in this instance: the file could not be read (loadFailed),
302
+ * or its recognised keys were malformed and the tolerant read fell back
303
+ * to empty (parseDegraded). Callers that enforce recorded truth - the
304
+ * deploy account pin - must fail closed in this state rather than treat
305
+ * a missing entry as the absence of a pin. False for a config that simply
306
+ * does not exist.
307
+ */
308
+ isContentUnavailable(): boolean;
255
309
  getActiveTarget(): string | undefined;
256
310
  setActiveTarget(name: string): void;
257
311
  clearActiveTarget(): void;
package/dist/config.js CHANGED
@@ -1 +1 @@
1
- var S=Object.defineProperty;var h=(d,e)=>S(d,"name",{value:e,configurable:!0});import*as i from"fs";import*as f from"path";import{z as s}from"zod";import{failure as g,success as w}from"./docker/result.js";import{getErrorMessage as C}from"./errorUtils.js";import{logger as y}from"./logger.js";import{maskSensitiveOutput as u}from"./securityHelpers.js";const v=10,m="fjall-config.json",M=["compliance","governance","none"],N=["enforced","off"],b=["centralised","off"],k=["account","draining","org"],I="managementEvents",L="organisationManagementEvents",U=["active","draining","removed"],J="FjallTrailBucketName",P="FjallTrailKeyArn",G="OrganisationTrailBucketName",E=s.object({name:s.string(),type:s.enum(["apex","delegated"]),parentDomain:s.string().optional(),account:s.string().optional()}).strict(),T=s.object({activeTarget:s.string().optional(),domains:s.array(E).optional()}).strict(),O=s.object({activeTarget:s.string().optional(),domains:s.array(E).optional()});function A(d={}){return JSON.stringify(d,null,2)}h(A,"serialiseRootConfig");const _=T.keyof().options;function x(d,e,t){const n=e[t];n!==void 0&&(d[t]=n)}h(x,"copyDefinedKey");class a{static{h(this,"Config")}rootConfig;configPath=null;loadFailed=!1;clearedKeys=new Set;constructor(e,t){this.rootConfig=e??{},this.configPath=t??null}static findConfigDirectory(e){let t=e!==void 0&&e!==""?e:process.cwd();for(let n=0;n<v;n++){const r=f.join(t,"fjall"),c=f.join(r,m);if(i.existsSync(c))return r;const o=f.join(t,m);if(i.existsSync(o))return t;const l=f.dirname(t);if(l===t)break;t=l}return null}static loadConfigFile(e){try{return i.accessSync(e,i.constants.R_OK),i.readFileSync(e,{encoding:"utf8"})}catch(t){return y.warn("Config",`Config file at ${e} could not be read; using defaults`,{file:e,error:u(C(t))}),null}}static loadConfig(e){const t=a.findConfigDirectory(e);if(!t)return new a;const n=f.join(t,m),r=a.loadConfigFile(n);if(r===null){const o=new a(void 0,n);return o.loadFailed=!0,o}let c;if(r!==""){let o;try{o=JSON.parse(r)}catch(p){throw a.formatZodError(p,m)}const l=T.safeParse(o);if(l.success)c=l.data;else{const p=O.safeParse(o);c=p.success?p.data:{},y.warn("Config","fjall-config.json contains keys this version does not recognise; they were ignored (only activeTarget and domains are read). If this is an old config, regenerate it with `fjall create ...` or re-run `fjall connect`.",{file:n})}}return new a(c,n)}static formatZodError(e,t){if(e instanceof s.ZodError&&e.issues.length>0){const c=e.issues.map(o=>`${o.path.join(".")}: ${o.message}`).join("; ");return new Error(`Failed to parse ${t}: ${c}`)}const r=(e instanceof Error?e.message:String(e)).replace(/\n/g," ").substring(0,500);return new Error(`Failed to parse ${t}: ${r}`)}saveConfig(){let e=this.configPath;if(!e){const o=a.findConfigDirectory()||f.join(process.cwd(),"fjall");e=f.join(o,m)}if(this.loadFailed)return g(new Error(`Refusing to save ${e}: the file exists but could not be read when this config loaded, so saving would replace its contents with state that never included them. Fix the file permissions (e.g. chmod u+rw ${e}) and retry.`));const t=f.dirname(e);try{i.mkdirSync(t,{recursive:!0})}catch(o){return g(new Error(`Cannot create config directory ${t}: ${u(C(o))}`))}const n=a.assertWritable(e,t);if(!n.success)return n;const r=A(this.mergeWithDisk(e)),c=`${e}.tmp-${process.pid}`;try{i.writeFileSync(c,r,{mode:384}),i.renameSync(c,e)}catch(o){return g(new Error(`Failed to save ${e}: ${u(C(o))}`))}return w(void 0)}static assertWritable(e,t){if(i.existsSync(e))try{i.accessSync(e,i.constants.W_OK)}catch{return g(new Error(`Cannot save ${e}: the file is read-only. Make it writable (e.g. chmod u+w ${e}) and retry.`))}try{i.accessSync(t,i.constants.W_OK)}catch{return g(new Error(`Cannot save ${e}: the directory ${t} is not writable. Make it writable (e.g. chmod u+w ${t}) and retry.`))}return w(void 0)}mergeWithDisk(e){const t=a.readDiskConfigForMerge(e);if(t===void 0)return this.rootConfig;const n={...t};for(const r of _)x(n,this.rootConfig,r);for(const r of this.clearedKeys)delete n[r];return n}static readDiskConfigForMerge(e){if(!i.existsSync(e))return;let t;try{t=JSON.parse(i.readFileSync(e,{encoding:"utf8"}))}catch(r){y.warn("Config",`Could not re-read ${e} before saving; writing in-memory state without merging`,{file:e,error:u(C(r))});return}const n=T.safeParse(t);if(!n.success){y.warn("Config",`On-disk ${e} failed validation before saving; writing in-memory state without merging`,{file:e,error:u(n.error.message)});return}return n.data}static getConfigDirectory(e){return a.findConfigDirectory(e)}getConfigPath(){return this.configPath}getActiveTarget(){return this.rootConfig.activeTarget}setActiveTarget(e){this.rootConfig.activeTarget=e,this.clearedKeys.delete("activeTarget")}clearActiveTarget(){this.rootConfig.activeTarget=void 0,this.clearedKeys.add("activeTarget")}getDomains(){return this.rootConfig.domains??[]}setDomains(e){this.rootConfig.domains=e}addDomain(e){this.rootConfig.domains||(this.rootConfig.domains=[]),this.rootConfig.domains.push(e)}getDomain(e){return this.rootConfig.domains?.find(t=>t.name.toLowerCase()===e.toLowerCase())}removeDomain(e){if(!this.rootConfig.domains)return!1;const t=this.rootConfig.domains.findIndex(n=>n.name.toLowerCase()===e.toLowerCase());return t===-1?!1:(this.rootConfig.domains.splice(t,1),!0)}}export{I as ACCOUNT_TRAIL_NAME,U as ACCOUNT_TRAIL_STATES,a as Config,E as DomainConfigSchema,L as ORGANISATION_TRAIL_NAME,G as ORG_TRAIL_BUCKET_OUTPUT_KEY,b as ROOT_ACCESS_MANAGEMENT_MODES,m as ROOT_CONFIG_FILENAME,O as RootConfigReadSchema,T as RootConfigSchema,N as S3_BPA_MODES,J as TRAIL_BUCKET_OUTPUT_KEY,P as TRAIL_KEY_ARN_OUTPUT_KEY,k as TRAIL_LIFECYCLE_STATES,M as VAULT_LOCK_MODES,A as serialiseRootConfig};
1
+ var k=Object.defineProperty;var h=(g,e)=>k(g,"name",{value:e,configurable:!0});import*as a from"fs";import*as u from"path";import{z as c}from"zod";import{failure as y,success as O}from"./docker/result.js";import{getErrorMessage as T}from"./errorUtils.js";import{logger as C}from"./logger.js";import{maskSensitiveOutput as w}from"./securityHelpers.js";const F=10,E="fjall-config.json",U=["compliance","governance","none"],J=["enforced","off"],B=["centralised","off"],P=["account","draining","org"],G="managementEvents",W="organisationManagementEvents",Y=["active","draining","removed"],Z="FjallTrailBucketName",H="FjallTrailKeyArn",q="OrganisationTrailBucketName",D=c.object({name:c.string(),type:c.enum(["apex","delegated"]),parentDomain:c.string().optional(),account:c.string().optional(),region:c.string().optional()}).strict(),j=c.object({activeTarget:c.string().optional(),domains:c.array(D).optional()}).strict(),A=c.object({activeTarget:c.string().optional(),domains:c.array(D).optional()});function $(g={}){return JSON.stringify(g,null,2)}h($,"serialiseRootConfig");const x=j.keyof().options;function N(g,e,n){const t=e[n];t!==void 0&&(g[n]=t)}h(N,"copyDefinedKey");function _(g,e){if(e===void 0)return!1;const n=new Set([...Object.keys(g),...Object.keys(e)]);for(const t of n)if(g[t]!==e[t])return!1;return!0}h(_,"domainEntriesEqual");class l{static{h(this,"Config")}rootConfig;configPath=null;loadFailed=!1;parseDegraded=!1;clearedKeys=new Set;loadedSnapshot={};constructor(e,n){this.rootConfig=e??{},this.configPath=n??null}static findConfigDirectory(e){let n=e!==void 0&&e!==""?e:process.cwd();for(let t=0;t<F;t++){const r=u.join(n,"fjall"),o=u.join(r,E);if(a.existsSync(o))return r;const s=u.join(n,E);if(a.existsSync(s))return n;const f=u.dirname(n);if(f===n)break;n=f}return null}static loadConfigFile(e){try{return a.accessSync(e,a.constants.R_OK),a.readFileSync(e,{encoding:"utf8"})}catch(n){return C.warn("Config",`Config file at ${e} could not be read; using defaults`,{file:e,error:w(T(n))}),null}}static loadConfig(e){const n=l.findConfigDirectory(e);if(!n)return new l;const t=u.join(n,E),r=l.loadConfigFile(t);if(r===null){const d=new l(void 0,t);return d.loadFailed=!0,d}let o,s=!1;if(r!==""){let d;try{d=JSON.parse(r)}catch(i){throw l.formatZodError(i,E)}const p=j.safeParse(d);if(p.success)o=p.data;else{const i=A.safeParse(d);o=i.success?i.data:{},s=!i.success,i.success?C.warn("Config","fjall-config.json contains keys this version does not recognise; they were ignored (only activeTarget and domains are read). If this is an old config, regenerate it with `fjall create ...` or re-run `fjall connect`.",{file:t}):C.warn("Config","fjall-config.json has a malformed activeTarget or domains value, so its contents were ignored for this run. Fix the file (or regenerate it with `fjall create ...` / `fjall connect`).",{file:t})}}const f=new l(o,t);return f.parseDegraded=s,f.loadedSnapshot=structuredClone(f.rootConfig),f}static formatZodError(e,n){if(e instanceof c.ZodError&&e.issues.length>0){const o=e.issues.map(s=>`${s.path.join(".")}: ${s.message}`).join("; ");return new Error(`Failed to parse ${n}: ${o}`)}const r=(e instanceof Error?e.message:String(e)).replace(/\n/g," ").substring(0,500);return new Error(`Failed to parse ${n}: ${r}`)}saveConfig(){let e=this.configPath;if(!e){const s=l.findConfigDirectory()||u.join(process.cwd(),"fjall");e=u.join(s,E)}if(this.loadFailed)return y(new Error(`Refusing to save ${e}: the file exists but could not be read when this config loaded, so saving would replace its contents with state that never included them. Fix the file permissions (e.g. chmod u+rw ${e}) and retry.`));const n=u.dirname(e);try{a.mkdirSync(n,{recursive:!0})}catch(s){return y(new Error(`Cannot create config directory ${n}: ${w(T(s))}`))}const t=l.assertWritable(e,n);if(!t.success)return t;const r=$(this.mergeWithDisk(e)),o=`${e}.tmp-${process.pid}`;try{a.writeFileSync(o,r,{mode:384}),a.renameSync(o,e)}catch(s){return y(new Error(`Failed to save ${e}: ${w(T(s))}`))}return O(void 0)}static assertWritable(e,n){if(a.existsSync(e))try{a.accessSync(e,a.constants.W_OK)}catch{return y(new Error(`Cannot save ${e}: the file is read-only. Make it writable (e.g. chmod u+w ${e}) and retry.`))}try{a.accessSync(n,a.constants.W_OK)}catch{return y(new Error(`Cannot save ${e}: the directory ${n} is not writable. Make it writable (e.g. chmod u+w ${n}) and retry.`))}return O(void 0)}mergeWithDisk(e){const n=l.readDiskConfigForMerge(e);if(n===void 0)return this.rootConfig;const t={...n.foreign,...n.known};for(const o of x)o!=="domains"&&this.sessionChangedKey(o)&&(this.rootConfig[o]===void 0?delete t[o]:N(t,this.rootConfig,o));const r=this.mergeDomains(n.known.domains);r!==void 0?t.domains=r:delete t.domains;for(const o of this.clearedKeys)delete t[o];return t}sessionChangedKey(e){return JSON.stringify(this.rootConfig[e])!==JSON.stringify(this.loadedSnapshot[e])}mergeDomains(e){const n=this.rootConfig.domains,t=this.loadedSnapshot.domains??[];if(n===void 0&&t.length===0)return e;const r=h(i=>i.toLowerCase(),"norm"),o=n??[],s=new Map(t.map(i=>[r(i.name),i])),f=new Map(o.map(i=>[r(i.name),i])),d=[],p=new Set;for(const i of e??[]){const m=r(i.name);p.add(m);const S=f.get(m),v=s.get(m);if(S!==void 0&&!_(S,v)){d.push(S);continue}S===void 0&&v!==void 0||d.push(i)}for(const i of o){const m=r(i.name);p.has(m)||_(i,s.get(m))||d.push(i)}return d}static readDiskConfigForMerge(e){if(!a.existsSync(e))return;let n;try{n=JSON.parse(a.readFileSync(e,{encoding:"utf8"}))}catch(o){C.warn("Config",`Could not re-read ${e} before saving; writing in-memory state without merging`,{file:e,error:w(T(o))});return}const t=A.safeParse(n);if(!t.success){C.warn("Config",`On-disk ${e} failed validation before saving; writing in-memory state without merging`,{file:e,error:w(t.error.message)});return}const r={};if(typeof n=="object"&&n!==null){const o=x;for(const[s,f]of Object.entries(n))o.includes(s)||(r[s]=f)}return{known:t.data,foreign:r}}static getConfigDirectory(e){return l.findConfigDirectory(e)}getConfigPath(){return this.configPath}isContentUnavailable(){return this.loadFailed||this.parseDegraded}getActiveTarget(){return this.rootConfig.activeTarget}setActiveTarget(e){this.rootConfig.activeTarget=e,this.clearedKeys.delete("activeTarget")}clearActiveTarget(){this.rootConfig.activeTarget=void 0,this.clearedKeys.add("activeTarget")}getDomains(){return this.rootConfig.domains??[]}setDomains(e){this.rootConfig.domains=e}addDomain(e){this.rootConfig.domains||(this.rootConfig.domains=[]),this.rootConfig.domains.push(e)}getDomain(e){return this.rootConfig.domains?.find(n=>n.name.toLowerCase()===e.toLowerCase())}removeDomain(e){if(!this.rootConfig.domains)return!1;const n=this.rootConfig.domains.findIndex(t=>t.name.toLowerCase()===e.toLowerCase());return n===-1?!1:(this.rootConfig.domains.splice(n,1),!0)}}export{G as ACCOUNT_TRAIL_NAME,Y as ACCOUNT_TRAIL_STATES,l as Config,D as DomainConfigSchema,W as ORGANISATION_TRAIL_NAME,q as ORG_TRAIL_BUCKET_OUTPUT_KEY,B as ROOT_ACCESS_MANAGEMENT_MODES,E as ROOT_CONFIG_FILENAME,A as RootConfigReadSchema,j as RootConfigSchema,J as S3_BPA_MODES,Z as TRAIL_BUCKET_OUTPUT_KEY,H as TRAIL_KEY_ARN_OUTPUT_KEY,P as TRAIL_LIFECYCLE_STATES,U as VAULT_LOCK_MODES,$ as serialiseRootConfig};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/util",
3
- "version": "3.8.1",
3
+ "version": "3.10.0",
4
4
  "description": "Common utility methods",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -134,5 +134,5 @@
134
134
  "engines": {
135
135
  "node": ">=22.0.0"
136
136
  },
137
- "gitHead": "c15e42f3d2047acbbe086518450c77ae68839e1d"
137
+ "gitHead": "ce4e8471194f7e3e5e7df66844bf9387503983f6"
138
138
  }