@fjall/components-infrastructure 25.0.0 → 26.0.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/lib/app.js CHANGED
@@ -13,6 +13,7 @@ import { BuildkiteFactory } from "./patterns/aws/buildkite.js";
13
13
  import { DevSubstrate } from "./patterns/aws/devSubstrate.js";
14
14
  import { StandardTagsAspect } from "./utils/standardTagsAspect.js";
15
15
  import { emitAppLevelAlbAliasExports } from "./utils/albAliasTargetRegistry.js";
16
+ import { emitAppLevelCdnAliasExports } from "./utils/cdnAliasTargetRegistry.js";
16
17
  import { ensureFunctionLogGroups } from "./resources/aws/logging/logGroupHygiene.js";
17
18
  import { BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP } from "./utils/backupTierMapping.js";
18
19
  import { randomBytes } from "crypto";
@@ -843,8 +844,9 @@ export class App extends CdkApp {
843
844
  // they synthesise untagged. Idempotent via the aspectApplied guard.
844
845
  this.applyTagsAspect();
845
846
  // Must precede super.synth(): the tree is still mutable here, and only
846
- // now is the ALB count per app final.
847
+ // now is the ALB / CloudFront count per app final.
847
848
  emitAppLevelAlbAliasExports(this);
849
+ emitAppLevelCdnAliasExports(this);
848
850
  // Also pre-super, and before aspects run inside it, so the log groups
849
851
  // this creates are still visited by the app-wide tag aspects.
850
852
  ensureFunctionLogGroups(this);
@@ -20,6 +20,12 @@ export type FjallTarget = {
20
20
  } | {
21
21
  readonly kind: "cdn";
22
22
  readonly appName: string;
23
+ /**
24
+ * Names one CloudFront distribution when the app fronts several. Absent
25
+ * means the app's sole CDN, which only exists as an export when the app
26
+ * has exactly one (lib/utils/cdnAliasTargetRegistry.ts).
27
+ */
28
+ readonly cdnName?: string;
23
29
  } | {
24
30
  readonly kind: "bucket";
25
31
  readonly appName: string;
@@ -17,6 +17,7 @@
17
17
  import { type ICertificate } from "aws-cdk-lib/aws-certificatemanager";
18
18
  import { type Duration } from "aws-cdk-lib";
19
19
  import { type Construct } from "constructs";
20
+ import type { HstsConfig } from "../../../resources/aws/cdn/responseHeadersPolicy.js";
20
21
  import { type RelationalDatabase, type DynamoDBDatabase, type ProxyConfig, type ReadReplicaConfig, type CredentialsConfig, type EncryptionConfig, type AuroraEncryptionConfig, type AuroraWriterConfig, type AuroraReadersConfig, type DatabaseInsightsConfig } from "../database.js";
21
22
  import { type LambdaCompute } from "../compute.js";
22
23
  import { type Storage } from "../storage.js";
@@ -385,10 +386,25 @@ export interface StaticSiteBuildConfig {
385
386
  export interface StaticSiteSecurityConfig {
386
387
  /** Ship the four safe headers (HSTS/nosniff/frame/referrer). */
387
388
  headers?: boolean;
389
+ /**
390
+ * HSTS tuning. Absent ⇒ the header ships scoped to this host only, for the
391
+ * standard max-age: a site that did not ask for HSTS does not get its
392
+ * subdomains bound or a preload-list entry. Supply the members to widen it,
393
+ * or `false` to drop the header entirely.
394
+ */
395
+ hsts?: false | StaticSiteHstsConfig;
388
396
  /** Opt-in Content-Security-Policy. Absent ⇒ no CSP header (a wrong policy
389
397
  * silently breaks inline hydration, so it is never a shipped default). */
390
398
  contentSecurityPolicy?: string;
391
399
  }
400
+ /**
401
+ * HSTS configuration for a static site.
402
+ *
403
+ * Aliased from the response-headers wrapper rather than redeclared: the two
404
+ * describe one shape, and a second declaration is a silent-drift seam — a
405
+ * member added to one would compile clean against the other.
406
+ */
407
+ export type StaticSiteHstsConfig = HstsConfig;
392
408
  /**
393
409
  * Contact-form configuration for a static site. Requires a custom `domain`: SES
394
410
  * sends only from a verified identity, and the site's domain is the identity
@@ -50,6 +50,29 @@ export declare class StaticSite extends Construct implements IStaticSite {
50
50
  private validateProps;
51
51
  private createBucket;
52
52
  private deploySiteAssets;
53
+ /**
54
+ * Refuse to synthesise a site whose build output directory is not there.
55
+ *
56
+ * Ordered before the emptiness gate because that gate READS the directory:
57
+ * without this, `readdirSync` throws a bare `ENOENT` naming an absolute
58
+ * path, which is the least useful form of exactly the diagnosis its sibling
59
+ * spells out. A mis-set `build.outputDir` is the common cause and the one
60
+ * the message names.
61
+ */
62
+ private assertSourceExists;
63
+ /**
64
+ * Refuse to synthesise a pruning upload from an empty build output.
65
+ *
66
+ * The upload prunes, so an empty source does not deploy nothing — it
67
+ * deletes every object already in the bucket, taking the live site down.
68
+ * A build that creates its output directory and then fails to write into
69
+ * it exits 0 and looks identical to a successful one, so presence alone is
70
+ * not proof.
71
+ *
72
+ * `staticSiteBuilder` makes the same check at deploy time through the same
73
+ * helper, so the two verdicts cannot drift.
74
+ */
75
+ private assertSourceHasContent;
53
76
  private createFormsEndpoint;
54
77
  private createCdn;
55
78
  private buildBehaviours;
@@ -27,12 +27,14 @@
27
27
  */
28
28
  import { Construct } from "constructs";
29
29
  import { CfnOutput, Fn, Stack } from "aws-cdk-lib";
30
+ import { existsSync } from "node:fs";
30
31
  import { dirname, resolve } from "node:path";
31
32
  import { fileURLToPath } from "node:url";
32
33
  import { Code, Runtime, Architecture, FunctionUrlAuthType, HttpMethod } from "aws-cdk-lib/aws-lambda";
33
34
  import { PolicyStatement, Effect } from "aws-cdk-lib/aws-iam";
34
35
  import { CloudFrontTarget } from "aws-cdk-lib/aws-route53-targets";
35
36
  import { DNS_APEX, defaultFormsCorsOrigin, defaultFormsFromAddress, isAddressAtDomain } from "@fjall/util";
37
+ import { hasDeployableContent } from "@fjall/util/staticSiteOutput";
36
38
  import { StorageFactory } from "./storage.js";
37
39
  import { CdnFactory } from "./cdn.js";
38
40
  import { SecurityHeadersPolicy } from "../../resources/aws/cdn/index.js";
@@ -49,6 +51,22 @@ const FORMS_DEFAULTS = {
49
51
  MEMORY_MB: 256,
50
52
  MAX_CONCURRENCY: 5
51
53
  };
54
+ /**
55
+ * HSTS for a site that never asked for it.
56
+ *
57
+ * `SecurityHeadersPolicy` defaults to includeSubdomains + preload, which is
58
+ * the right posture for an operator who chose it and the wrong one to apply
59
+ * on the user's behalf: both directives have a blast radius beyond this
60
+ * distribution. includeSubdomains binds every subdomain of the apex — a
61
+ * sibling host on plain HTTP becomes unreachable — and preload is effectively
62
+ * one-way, since removal from the browser preload list ships on a release
63
+ * train measured in months. The max-age is unchanged; only the two
64
+ * out-of-scope directives are withheld until asked for.
65
+ */
66
+ const UNREQUESTED_HSTS = {
67
+ includeSubdomains: false,
68
+ preload: false
69
+ };
52
70
  /** Record label relative to the zone — DNS_APEX for the apex. */
53
71
  function recordLabelFor(domain, zoneName) {
54
72
  if (domain === zoneName)
@@ -124,8 +142,48 @@ export class StaticSite extends Construct {
124
142
  deploySiteAssets() {
125
143
  const deploymentSource = resolve(this.props.source, this.props.build.outputDir);
126
144
  const invalidate = this.props.cdn?.invalidateOnDeploy ?? true;
145
+ this.assertSourceExists(deploymentSource);
146
+ this.assertSourceHasContent(deploymentSource);
127
147
  this._bucket.deployAssets({ source: deploymentSource, prune: true }, invalidate ? this._cdn.getDistribution() : undefined);
128
148
  }
149
+ /**
150
+ * Refuse to synthesise a site whose build output directory is not there.
151
+ *
152
+ * Ordered before the emptiness gate because that gate READS the directory:
153
+ * without this, `readdirSync` throws a bare `ENOENT` naming an absolute
154
+ * path, which is the least useful form of exactly the diagnosis its sibling
155
+ * spells out. A mis-set `build.outputDir` is the common cause and the one
156
+ * the message names.
157
+ */
158
+ assertSourceExists(deploymentSource) {
159
+ if (existsSync(deploymentSource))
160
+ return;
161
+ throw new Error(`Static site '${this.props.name}' has nothing to deploy: the build ` +
162
+ `output directory '${deploymentSource}' does not exist. Run the ` +
163
+ "build before deploying, or check that 'build.outputDir' names the " +
164
+ "directory the build actually writes to.");
165
+ }
166
+ /**
167
+ * Refuse to synthesise a pruning upload from an empty build output.
168
+ *
169
+ * The upload prunes, so an empty source does not deploy nothing — it
170
+ * deletes every object already in the bucket, taking the live site down.
171
+ * A build that creates its output directory and then fails to write into
172
+ * it exits 0 and looks identical to a successful one, so presence alone is
173
+ * not proof.
174
+ *
175
+ * `staticSiteBuilder` makes the same check at deploy time through the same
176
+ * helper, so the two verdicts cannot drift.
177
+ */
178
+ assertSourceHasContent(deploymentSource) {
179
+ if (hasDeployableContent(deploymentSource))
180
+ return;
181
+ throw new Error(`Static site '${this.props.name}' has nothing to deploy: the build ` +
182
+ `output directory '${deploymentSource}' is empty. The upload prunes, ` +
183
+ "so deploying it would delete the site's current contents. Run the " +
184
+ "build before deploying, or check that 'build.outputDir' names the " +
185
+ "directory the build actually writes to.");
186
+ }
129
187
  createFormsEndpoint() {
130
188
  const forms = this.props.forms;
131
189
  if (!forms)
@@ -178,6 +236,10 @@ export class StaticSite extends Construct {
178
236
  if (this.props.security?.headers === true) {
179
237
  this._headersPolicy = new SecurityHeadersPolicy(this, `${this.pascalName}Headers`, {
180
238
  comment: `Security headers for ${this.props.name}`,
239
+ // `??`, not a conditional spread: an omitted `hsts` must resolve to
240
+ // the scoped default, and spreading would hand the decision back to
241
+ // the wrapper's aggressive one.
242
+ hsts: this.props.security.hsts ?? UNREQUESTED_HSTS,
181
243
  ...(this.props.security.contentSecurityPolicy !== undefined && {
182
244
  contentSecurityPolicy: this.props.security.contentSecurityPolicy
183
245
  })
@@ -23,12 +23,18 @@ export declare function fjallApp(appName: string, computeName?: string): FjallAl
23
23
  /**
24
24
  * Target helper for a Fjall CloudFront-fronted app.
25
25
  *
26
- * Requires the CDN stack to publish export `${safeApp}CdnDistributionDomainName`
27
- * — emitted by every `CloudFrontDistribution` given an `appName` (the
28
- * `CdnFactory.build` path fills it from the App). Naming an app with no such
29
- * export fails at CloudFormation execution, exactly as `fjallApp` documents.
26
+ * With no `cdnName`, requires the CDN stack to publish export
27
+ * `${safeApp}CdnDistributionDomainName` which `App.synth()` emits only for
28
+ * an app fronting exactly ONE distribution, since "the app's CDN" names
29
+ * nothing otherwise. Pass `cdnName` for an app with several (one per
30
+ * static-site pattern, plus any added by `fjall add cdn`); that name
31
+ * (`${safeApp}-${safeCdn}-CdnDistributionDomainName`) is emitted
32
+ * unconditionally per distribution.
33
+ *
34
+ * Naming an app that has no matching export fails at CloudFormation execution
35
+ * with "No export named … found", after synth and every unit test pass.
30
36
  */
31
- export declare function fjallCdn(appName: string): FjallAliasTarget;
37
+ export declare function fjallCdn(appName: string, cdnName?: string): FjallAliasTarget;
32
38
  /**
33
39
  * Target helper for a Fjall S3 static-site bucket.
34
40
  *
@@ -25,16 +25,23 @@ export function fjallApp(appName, computeName) {
25
25
  /**
26
26
  * Target helper for a Fjall CloudFront-fronted app.
27
27
  *
28
- * Requires the CDN stack to publish export `${safeApp}CdnDistributionDomainName`
29
- * — emitted by every `CloudFrontDistribution` given an `appName` (the
30
- * `CdnFactory.build` path fills it from the App). Naming an app with no such
31
- * export fails at CloudFormation execution, exactly as `fjallApp` documents.
28
+ * With no `cdnName`, requires the CDN stack to publish export
29
+ * `${safeApp}CdnDistributionDomainName` which `App.synth()` emits only for
30
+ * an app fronting exactly ONE distribution, since "the app's CDN" names
31
+ * nothing otherwise. Pass `cdnName` for an app with several (one per
32
+ * static-site pattern, plus any added by `fjall add cdn`); that name
33
+ * (`${safeApp}-${safeCdn}-CdnDistributionDomainName`) is emitted
34
+ * unconditionally per distribution.
35
+ *
36
+ * Naming an app that has no matching export fails at CloudFormation execution
37
+ * with "No export named … found", after synth and every unit test pass.
32
38
  */
33
- export function fjallCdn(appName) {
34
- const resolved = resolveCdnTarget(appName);
39
+ export function fjallCdn(appName, cdnName) {
40
+ const resolved = resolveCdnTarget(appName, cdnName);
35
41
  return {
36
42
  kind: "cdn",
37
43
  appName,
44
+ ...(cdnName !== undefined && { cdnName }),
38
45
  bind(record, zone) {
39
46
  return resolved.bind(record, zone);
40
47
  }
@@ -46,7 +46,7 @@ export declare function resolveEcsTarget(appName: string, computeName?: string):
46
46
  * defaults that to `<App>Cdn`). The CloudFront hosted-zone id is the globally
47
47
  * fixed `Z2FDTNDATAQYW2`.
48
48
  */
49
- export declare function resolveCdnTarget(appName: string): IAliasRecordTarget;
49
+ export declare function resolveCdnTarget(appName: string, cdnName?: string): IAliasRecordTarget;
50
50
  /**
51
51
  * Resolve a Fjall S3 static-site bucket to an `IAliasRecordTarget`.
52
52
  *
@@ -62,8 +62,8 @@ export function resolveEcsTarget(appName, computeName) {
62
62
  * defaults that to `<App>Cdn`). The CloudFront hosted-zone id is the globally
63
63
  * fixed `Z2FDTNDATAQYW2`.
64
64
  */
65
- export function resolveCdnTarget(appName) {
66
- const dnsName = Fn.importValue(cdnDomainExportName(appName));
65
+ export function resolveCdnTarget(appName, cdnName) {
66
+ const dnsName = Fn.importValue(cdnDomainExportName(appName, cdnName));
67
67
  return buildAliasTarget(dnsName, CLOUDFRONT_HOSTED_ZONE_ID);
68
68
  }
69
69
  /**
@@ -1,6 +1,7 @@
1
1
  import { Construct } from "constructs";
2
2
  import { CfnOutput, Names, Stack } from "aws-cdk-lib";
3
3
  import { toPascalCase } from "../../../utils/capitaliseString.js";
4
+ import { registerCdnAliasTarget } from "../../../utils/cdnAliasTargetRegistry.js";
4
5
  import { cdnDomainExportName } from "@fjall/util";
5
6
  import { Distribution, PriceClass, ViewerProtocolPolicy, CachePolicy, OriginRequestPolicy, AllowedMethods, OriginProtocolPolicy, Function as CloudFrontFunction, FunctionCode, FunctionRuntime, FunctionEventType, OriginAccessIdentity, S3OriginAccessControl } from "aws-cdk-lib/aws-cloudfront";
6
7
  import { S3BucketOrigin, LoadBalancerV2Origin, HttpOrigin } from "aws-cdk-lib/aws-cloudfront-origins";
@@ -111,12 +112,23 @@ export class CloudFrontDistribution extends Construct {
111
112
  logBucket: props.logBucket,
112
113
  priceClass: this.resolvePriceClass(props.priceClass)
113
114
  });
115
+ // The CDN-qualified export is unconditional and always unique, so an app
116
+ // fronting several distributions stays deployable. The unqualified
117
+ // app-level export is emitted once from `App.synth()`, and only when the
118
+ // app has exactly one CDN — see `cdnAliasTargetRegistry`.
119
+ const aliasAppName = props.appName ?? id;
114
120
  new CfnOutput(this, `${outputName}DistributionDomainName`, {
115
121
  key: `${outputName}DistributionDomainName`,
116
- exportName: cdnDomainExportName(props.appName ?? id),
122
+ exportName: cdnDomainExportName(aliasAppName, id),
117
123
  value: this.distribution.distributionDomainName,
118
124
  description: `CloudFront distribution domain name for ${id}`
119
125
  });
126
+ registerCdnAliasTarget({
127
+ appName: aliasAppName,
128
+ cdnName: id,
129
+ scope: this,
130
+ domainName: this.distribution.distributionDomainName
131
+ });
120
132
  new CfnOutput(this, `${outputName}DistributionId`, {
121
133
  key: `${outputName}DistributionId`,
122
134
  value: this.distribution.distributionId,
@@ -0,0 +1,48 @@
1
+ import type { Construct } from "constructs";
2
+ /**
3
+ * The app-level half of the `Domain` CloudFront alias-target contract.
4
+ *
5
+ * Sibling of {@link ./albAliasTargetRegistry.ts}, and deliberately the same
6
+ * shape: `fjallCdn(appName)` resolves `<App>CdnDistributionDomainName` from a
7
+ * *different* stack, so the name has to be derivable from the app name alone —
8
+ * the Domain stack cannot see how many distributions the app has. But an app
9
+ * may legitimately front several: one per static-site pattern, plus any CDN
10
+ * added directly.
11
+ *
12
+ * Emitting the app-keyed export from each of them produces the identical
13
+ * `Outputs` entry N times and CloudFormation rejects the entire template ("The
14
+ * Outputs section contains duplicate Export names") — the same failure the ALB
15
+ * family hit on 2026-08-09, and the reason two static sites could not live in
16
+ * one app until this registry existed.
17
+ *
18
+ * So the two halves are split by who can answer the question:
19
+ *
20
+ * - Each distribution emits its own `<App>-<Cdn>-CdnDistributionDomainName`
21
+ * unconditionally. Always unique, always present, addressable as
22
+ * `fjallCdn(app, cdn)`.
23
+ * - The App emits the unqualified `<App>CdnDistributionDomainName` at synth
24
+ * time, and ONLY when the app registered exactly one distribution — the
25
+ * sole case where "the app's CDN" names one thing.
26
+ *
27
+ * Two or more and no app-level export is written. A `Domain` using the bare
28
+ * `fjallCdn(app)` then fails at CloudFormation with "No export named
29
+ * <App>CdnDistributionDomainName found", which names the missing export and is
30
+ * fixed by naming the distribution. Picking one of N silently would deploy a
31
+ * domain pointing at an arbitrary distribution, and would keep working right
32
+ * up until the registration order changed.
33
+ */
34
+ export interface CdnAliasTargetRegistration {
35
+ readonly appName: string;
36
+ readonly cdnName: string;
37
+ /** Stack-attached scope the app-level `CfnOutput` is emitted into. */
38
+ readonly scope: Construct;
39
+ readonly domainName: string;
40
+ }
41
+ export declare function registerCdnAliasTarget(registration: CdnAliasTargetRegistration): void;
42
+ /**
43
+ * Emit the unqualified app-level alias export for every app in this tree that
44
+ * registered exactly one distribution. Call once, from `App.synth()`, before
45
+ * `super.synth()` — the construct tree is still mutable there, and that is the
46
+ * same window `applyTagsAspect()` and `emitAppLevelAlbAliasExports()` use.
47
+ */
48
+ export declare function emitAppLevelCdnAliasExports(root: Construct): void;
@@ -0,0 +1,63 @@
1
+ import { CfnOutput } from "aws-cdk-lib";
2
+ import { cdnDomainExportName } from "@fjall/util";
3
+ import { toPascalCase } from "./capitaliseString.js";
4
+ import { FjallLogger } from "./validationLogger.js";
5
+ /**
6
+ * Keyed on the construct-tree root (the `App`) so two apps synthesised in one
7
+ * process — the platform and organisation stacks do this — never see each
8
+ * other's registrations. Weak so a discarded tree does not leak.
9
+ */
10
+ const registryByRoot = new WeakMap();
11
+ export function registerCdnAliasTarget(registration) {
12
+ const root = registration.scope.node.root;
13
+ const existing = registryByRoot.get(root);
14
+ if (existing === undefined) {
15
+ registryByRoot.set(root, [registration]);
16
+ return;
17
+ }
18
+ existing.push(registration);
19
+ }
20
+ /**
21
+ * Emit the unqualified app-level alias export for every app in this tree that
22
+ * registered exactly one distribution. Call once, from `App.synth()`, before
23
+ * `super.synth()` — the construct tree is still mutable there, and that is the
24
+ * same window `applyTagsAspect()` and `emitAppLevelAlbAliasExports()` use.
25
+ */
26
+ export function emitAppLevelCdnAliasExports(root) {
27
+ const registrations = registryByRoot.get(root);
28
+ if (registrations === undefined)
29
+ return;
30
+ // `App.synth()` is re-entrant (CDK caches the assembly and returns it), and
31
+ // a second emission would collide on the output's construct id. Draining
32
+ // makes the call idempotent.
33
+ registryByRoot.delete(root);
34
+ const byApp = new Map();
35
+ for (const registration of registrations) {
36
+ const forApp = byApp.get(registration.appName);
37
+ if (forApp === undefined) {
38
+ byApp.set(registration.appName, [registration]);
39
+ }
40
+ else {
41
+ forApp.push(registration);
42
+ }
43
+ }
44
+ for (const [appName, forApp] of byApp) {
45
+ const only = forApp.length === 1 ? forApp[0] : undefined;
46
+ if (only === undefined) {
47
+ const cdnNames = forApp.map((r) => r.cdnName).join(", ");
48
+ FjallLogger.warn(`App '${appName}' fronts ${String(forApp.length)} CloudFront distributions (${cdnNames}), ` +
49
+ `so no app-level '${cdnDomainExportName(appName)}' export was emitted — ` +
50
+ `'the app's CDN' would be ambiguous. Target a specific one with ` +
51
+ `fjallCdn('${appName}', '<cdn>') in your Domain records.`);
52
+ continue;
53
+ }
54
+ // Output logical ids are alphanumeric-only; the app name is not.
55
+ const logicalId = `${toPascalCase(appName)}AppCdn`;
56
+ new CfnOutput(only.scope, `${logicalId}DistributionDomainName`, {
57
+ key: `${logicalId}DistributionDomainName`,
58
+ exportName: cdnDomainExportName(appName),
59
+ value: only.domainName,
60
+ description: `CloudFront distribution domain name for ${appName} (sole CDN; consumed by fjallCdn('${appName}'))`
61
+ });
62
+ }
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "25.0.0",
3
+ "version": "26.0.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",
@@ -68,20 +68,18 @@
68
68
  "@aws-sdk/client-identitystore": "^3.1098.0",
69
69
  "@aws-sdk/client-sesv2": "^3.1098.0",
70
70
  "@peculiar/x509": "2.0.0",
71
- "@types/aws-lambda": "^8.10.162",
72
71
  "@types/node": "^26.1.2",
73
- "@typescript-eslint/eslint-plugin": "^8.65.0",
74
- "@typescript-eslint/parser": "^8.65.0",
75
72
  "eslint": "^10.8.0",
76
73
  "prettier": "^3.9.6",
77
74
  "reflect-metadata": "^0.2.2",
75
+ "tsx": "^4.23.1",
78
76
  "typescript": "^6.0.3",
79
77
  "vitest": "^4.1.10"
80
78
  },
81
79
  "dependencies": {
82
80
  "@aws-sdk/client-organizations": "^3.1098.0",
83
- "@fjall/generator": "^25.0.0",
84
- "@fjall/util": "^25.0.0",
81
+ "@fjall/generator": "^26.0.0",
82
+ "@fjall/util": "^26.0.0",
85
83
  "constructs": "^10.7.2",
86
84
  "zod": "^4.4.3"
87
85
  },