@go-to-k/cdkd 0.124.0 → 0.126.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/cli.js CHANGED
@@ -19,9 +19,9 @@ import { CloudFrontClient, CreateCloudFrontOriginAccessIdentityCommand, CreateDi
19
19
  import { CloudWatchClient, DeleteAlarmsCommand, DescribeAlarmsCommand, ListTagsForResourceCommand as ListTagsForResourceCommand$4, PutMetricAlarmCommand, TagResourceCommand as TagResourceCommand$6, UntagResourceCommand as UntagResourceCommand$6 } from "@aws-sdk/client-cloudwatch";
20
20
  import { CloudWatchLogsClient, CreateLogGroupCommand, DeleteDataProtectionPolicyCommand, DeleteIndexPolicyCommand, DeleteLogGroupCommand, DeleteRetentionPolicyCommand, DescribeIndexPoliciesCommand, DescribeLogGroupsCommand, GetDataProtectionPolicyCommand, ListTagsForResourceCommand as ListTagsForResourceCommand$5, PutBearerTokenAuthenticationCommand, PutDataProtectionPolicyCommand, PutIndexPolicyCommand, PutLogGroupDeletionProtectionCommand, PutRetentionPolicyCommand, ResourceAlreadyExistsException, ResourceNotFoundException as ResourceNotFoundException$4, TagResourceCommand as TagResourceCommand$7, UntagResourceCommand as UntagResourceCommand$7 } from "@aws-sdk/client-cloudwatch-logs";
21
21
  import { BedrockAgentCoreControlClient, CreateAgentRuntimeCommand, DeleteAgentRuntimeCommand, GetAgentRuntimeCommand, ResourceNotFoundException as ResourceNotFoundException$5, UpdateAgentRuntimeCommand } from "@aws-sdk/client-bedrock-agentcore-control";
22
- import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
22
+ import { cpSync, createWriteStream, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
23
23
  import * as path from "node:path";
24
- import { dirname, isAbsolute, resolve } from "node:path";
24
+ import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
25
25
  import { execFile, spawn } from "node:child_process";
26
26
  import { tmpdir } from "node:os";
27
27
  import { AssociateVPCWithHostedZoneCommand, ChangeResourceRecordSetsCommand, ChangeTagsForResourceCommand, CreateHostedZoneCommand, CreateQueryLoggingConfigCommand, DeleteHostedZoneCommand, DeleteQueryLoggingConfigCommand, DisassociateVPCFromHostedZoneCommand, GetHostedZoneCommand, ListHostedZonesByNameCommand, ListHostedZonesCommand, ListQueryLoggingConfigsCommand, ListResourceRecordSetsCommand, ListTagsForResourceCommand as ListTagsForResourceCommand$6, Route53Client, UpdateHostedZoneCommentCommand } from "@aws-sdk/client-route-53";
@@ -57,11 +57,15 @@ import { CreateNamespaceCommand, CreateTableBucketCommand, CreateTableCommand as
57
57
  import { AttachTrafficSourcesCommand, AutoScalingClient, CreateAutoScalingGroupCommand, DeleteAutoScalingGroupCommand, DeleteLifecycleHookCommand, DeleteNotificationConfigurationCommand, DescribeAutoScalingGroupsCommand, DescribeLifecycleHooksCommand, DescribeNotificationConfigurationsCommand, DescribeTrafficSourcesCommand, DetachTrafficSourcesCommand, DisableMetricsCollectionCommand, EnableMetricsCollectionCommand, PutLifecycleHookCommand, PutNotificationConfigurationCommand, UpdateAutoScalingGroupCommand } from "@aws-sdk/client-auto-scaling";
58
58
  import * as readline from "node:readline/promises";
59
59
  import { Document, Pair, Scalar, YAMLMap, YAMLSeq, parse as parse$1, stringify } from "yaml";
60
+ import { mkdir, mkdtemp } from "node:fs/promises";
61
+ import { Readable } from "node:stream";
62
+ import { pipeline } from "node:stream/promises";
60
63
  import { createServer } from "node:net";
61
64
  import { promisify } from "node:util";
62
65
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
63
66
  import { readFile } from "fs/promises";
64
67
  import { createServer as createServer$1 } from "node:http";
68
+ import { createServer as createServer$2 } from "node:https";
65
69
  import * as chokidar from "chokidar";
66
70
 
67
71
  //#region src/cli/options.ts
@@ -36687,16 +36691,23 @@ function resolveAssetCodePath$1(stack, logicalId, resource) {
36687
36691
  * bind mounts at the same target so cdkd cannot rely on overlay
36688
36692
  * layering.
36689
36693
  *
36690
- * **Out of scope (hard-errors)**:
36694
+ * **Same-stack handling** (`{Ref: <Id>}` / `{Fn::GetAtt: [<Id>, 'Ref']}`):
36691
36695
  *
36692
- * - Literal ARN strings (`arn:aws:lambda:...`) these are external /
36693
- * pre-existing layers (no asset on disk to mount) including
36694
- * cross-account / cross-region.
36695
- * - Same-stack refs that don't point at an `AWS::Lambda::LayerVersion`
36696
- * resource almost always a typo'd logical ID.
36697
- * - Same-stack refs to a `LayerVersion` whose `Metadata['aws:asset:path']`
36698
- * is missing the layer's content is `S3Bucket` / `S3Key` from
36699
- * outside cdk.out and there's no local directory to bind-mount.
36696
+ * - Refs that don't point at an `AWS::Lambda::LayerVersion` resource
36697
+ * hard-error almost always a typo'd logical ID.
36698
+ * - Refs to a `LayerVersion` whose `Metadata['aws:asset:path']` is
36699
+ * missing hard-error the layer's content is `S3Bucket` / `S3Key`
36700
+ * from outside cdk.out and there's no local directory to bind-mount.
36701
+ *
36702
+ * **Literal-ARN handling** (issue #448): entries shaped like the string
36703
+ * `arn:aws:lambda:<region>:<account>:layer:<name>:<version>` are parsed
36704
+ * into a `{kind: 'arn', ...}` resolved layer. The actual
36705
+ * `lambda:GetLayerVersion` + presigned-URL download + unzip happens
36706
+ * later in the CLI (`materializeLayerFromArn(...)`), which can optionally
36707
+ * `sts:AssumeRole` into the layer's account when the dev's default
36708
+ * credentials cannot read it. Covers AWS-published public layers (Lambda
36709
+ * Powertools, Datadog Extension, etc.) and cross-account / cross-region
36710
+ * shared layers.
36700
36711
  */
36701
36712
  function resolveLambdaLayers(stack, logicalId, props) {
36702
36713
  const layers = props["Layers"];
@@ -36707,13 +36718,24 @@ function resolveLambdaLayers(stack, logicalId, props) {
36707
36718
  const out = [];
36708
36719
  for (let i = 0; i < layers.length; i++) {
36709
36720
  const entry = layers[i];
36721
+ if (typeof entry === "string") {
36722
+ const parsed = parseLayerVersionArn(entry);
36723
+ if (!parsed) throw new LocalInvokeResolutionError(`Lambda '${logicalId}' has a Layers entry [${i}] cdkd cannot resolve locally: literal string '${entry}'. Expected a same-stack Ref / Fn::GetAtt to an AWS::Lambda::LayerVersion OR a literal layer-version ARN of the form arn:aws:lambda:<region>:<account>:layer:<name>:<version>.`);
36724
+ out.push({
36725
+ kind: "arn",
36726
+ logicalId: parsed.arn,
36727
+ ...parsed
36728
+ });
36729
+ continue;
36730
+ }
36710
36731
  const layerLogicalId = pickLayerLogicalId(entry);
36711
- if (!layerLogicalId) throw new LocalInvokeResolutionError(`Lambda '${logicalId}' has a Layers entry [${i}] cdkd cannot resolve locally: ${describeLayerEntry(entry)}. Only same-stack Ref / Fn::GetAtt to an AWS::Lambda::LayerVersion are supported in v1; cross-account / cross-region / pre-existing-ARN layers are deferred to a follow-up PR.`);
36732
+ if (!layerLogicalId) throw new LocalInvokeResolutionError(`Lambda '${logicalId}' has a Layers entry [${i}] cdkd cannot resolve locally: ${describeLayerEntry(entry)}. Expected a same-stack Ref / Fn::GetAtt to an AWS::Lambda::LayerVersion OR a literal layer-version ARN of the form arn:aws:lambda:<region>:<account>:layer:<name>:<version>.`);
36712
36733
  const layerResource = resources[layerLogicalId];
36713
36734
  if (!layerResource) throw new LocalInvokeResolutionError(`Lambda '${logicalId}' Layers entry [${i}] references '${layerLogicalId}', but no resource with that logical ID exists in stack '${stack.stackName}'.`);
36714
36735
  if (layerResource.Type !== "AWS::Lambda::LayerVersion") throw new LocalInvokeResolutionError(`Lambda '${logicalId}' Layers entry [${i}] references '${layerLogicalId}' (${layerResource.Type}), which is not an AWS::Lambda::LayerVersion.`);
36715
36736
  const assetPath = resolveAssetCodePath$1(stack, layerLogicalId, layerResource);
36716
36737
  out.push({
36738
+ kind: "asset",
36717
36739
  logicalId: layerLogicalId,
36718
36740
  assetPath
36719
36741
  });
@@ -36721,6 +36743,29 @@ function resolveLambdaLayers(stack, logicalId, props) {
36721
36743
  return out;
36722
36744
  }
36723
36745
  /**
36746
+ * Parse a Lambda layer-version ARN string into its segments.
36747
+ *
36748
+ * Returns `undefined` for anything that does not match the strict
36749
+ * `arn:aws:lambda:<region>:<account>:layer:<name>:<version>` shape so
36750
+ * the caller can produce a clearer error than a silent
36751
+ * misinterpretation of hand-edited templates. The partition segment
36752
+ * accepts `aws` / `aws-cn` / `aws-us-gov` so GovCloud / China-region
36753
+ * ARNs work without code changes.
36754
+ *
36755
+ * Exported for unit testing.
36756
+ */
36757
+ function parseLayerVersionArn(input) {
36758
+ const m = /^arn:(aws|aws-cn|aws-us-gov):lambda:([a-z]{2}-(?:[a-z]+-){1,2}\d+):(\d{12}):layer:([A-Za-z0-9_-]+):(\d+)$/.exec(input);
36759
+ if (!m) return void 0;
36760
+ return {
36761
+ arn: input,
36762
+ region: m[2],
36763
+ accountId: m[3],
36764
+ name: m[4],
36765
+ version: m[5]
36766
+ };
36767
+ }
36768
+ /**
36724
36769
  * Walk a single Layers-array entry and return the referenced layer's
36725
36770
  * logical ID — or `undefined` for shapes we don't try to resolve in v1.
36726
36771
  *
@@ -36789,6 +36834,195 @@ function notFoundError$1(target, stack, resources) {
36789
36834
  return new LocalInvokeResolutionError(msg.trimEnd());
36790
36835
  }
36791
36836
 
36837
+ //#endregion
36838
+ //#region src/local/layer-arn-materializer.ts
36839
+ var LayerMaterializationError = class LayerMaterializationError extends Error {
36840
+ constructor(message) {
36841
+ super(message);
36842
+ this.name = "LayerMaterializationError";
36843
+ Object.setPrototypeOf(this, LayerMaterializationError.prototype);
36844
+ }
36845
+ };
36846
+ async function materializeLayerFromArn(layer, options = {}) {
36847
+ const logger = getLogger();
36848
+ let credentials;
36849
+ if (options.roleArn) try {
36850
+ credentials = await assumeRoleForLayer(options.roleArn, layer.region, options);
36851
+ logger.debug(`Layer ${layer.arn}: assumed role ${options.roleArn} for GetLayerVersion`);
36852
+ } catch (err) {
36853
+ throw new LayerMaterializationError(`Layer ${layer.arn}: STS AssumeRole(${options.roleArn}) failed: ${errMsg(err)}. Check the role trust policy permits your principal and sts:AssumeRole is allowed.`);
36854
+ }
36855
+ let presignedUrl;
36856
+ try {
36857
+ presignedUrl = await fetchLayerContentUrl(layer, credentials, options);
36858
+ } catch (err) {
36859
+ const hint = looksLikeAccessDenied(err) ? " GetLayerVersion access denied; check the credentials / role can read the layer (grant lambda:GetLayerVersion on the layer ARN, or pass --layer-role-arn <arn> to assume a role in the layer account)." : "";
36860
+ throw new LayerMaterializationError(`Layer ${layer.arn}: GetLayerVersion failed in region ${layer.region}: ${errMsg(err)}.${hint}`);
36861
+ }
36862
+ let zipBytes;
36863
+ try {
36864
+ zipBytes = await downloadPresignedZip(presignedUrl, options);
36865
+ } catch (err) {
36866
+ throw new LayerMaterializationError(`Layer ${layer.arn}: failed to download layer ZIP from the presigned URL: ${errMsg(err)}.`);
36867
+ }
36868
+ const dir = await mkdtemp(join(tmpdir(), `cdkd-local-arn-layer-${layer.name}-${layer.version}-`));
36869
+ try {
36870
+ await unzipBufferToDirectory(zipBytes, dir);
36871
+ } catch (err) {
36872
+ try {
36873
+ rmSync(dir, {
36874
+ recursive: true,
36875
+ force: true
36876
+ });
36877
+ } catch {}
36878
+ throw new LayerMaterializationError(`Layer ${layer.arn}: failed to unzip layer contents into '${dir}': ${errMsg(err)}.`);
36879
+ }
36880
+ return dir;
36881
+ }
36882
+ async function fetchLayerContentUrl(layer, credentials, options) {
36883
+ const client = (options.lambdaClientFactory ?? await defaultLambdaClientFactory())(layer.region, credentials);
36884
+ try {
36885
+ const command = await buildGetLayerVersionCommand(`arn:aws:lambda:${layer.region}:${layer.accountId}:layer:${layer.name}`, Number(layer.version));
36886
+ const url = (await client.send(command))?.Content?.Location;
36887
+ if (!url || typeof url !== "string") throw new Error("GetLayerVersion response did not include Content.Location (presigned ZIP URL)");
36888
+ return url;
36889
+ } finally {
36890
+ client.destroy?.();
36891
+ }
36892
+ }
36893
+ async function assumeRoleForLayer(roleArn, region, options) {
36894
+ const client = (options.stsClientFactory ?? await defaultStsClientFactory())(region);
36895
+ try {
36896
+ const command = await buildAssumeRoleCommand(roleArn);
36897
+ const creds = (await client.send(command))?.Credentials;
36898
+ if (!creds?.AccessKeyId || !creds.SecretAccessKey) throw new Error("AssumeRole returned no Credentials");
36899
+ return {
36900
+ accessKeyId: creds.AccessKeyId,
36901
+ secretAccessKey: creds.SecretAccessKey,
36902
+ ...creds.SessionToken !== void 0 && { sessionToken: creds.SessionToken }
36903
+ };
36904
+ } finally {
36905
+ client.destroy?.();
36906
+ }
36907
+ }
36908
+ async function defaultLambdaClientFactory() {
36909
+ const { LambdaClient } = await import("@aws-sdk/client-lambda");
36910
+ return (region, credentials) => new LambdaClient({
36911
+ region,
36912
+ ...credentials && { credentials: {
36913
+ accessKeyId: credentials.accessKeyId,
36914
+ secretAccessKey: credentials.secretAccessKey,
36915
+ ...credentials.sessionToken !== void 0 && { sessionToken: credentials.sessionToken }
36916
+ } }
36917
+ });
36918
+ }
36919
+ async function defaultStsClientFactory() {
36920
+ const { STSClient } = await import("@aws-sdk/client-sts");
36921
+ return (region) => new STSClient({ region });
36922
+ }
36923
+ async function buildGetLayerVersionCommand(layerArn, versionNumber) {
36924
+ const { GetLayerVersionCommand } = await import("@aws-sdk/client-lambda");
36925
+ return new GetLayerVersionCommand({
36926
+ LayerName: layerArn,
36927
+ VersionNumber: versionNumber
36928
+ });
36929
+ }
36930
+ async function buildAssumeRoleCommand(roleArn) {
36931
+ const { AssumeRoleCommand } = await import("@aws-sdk/client-sts");
36932
+ return new AssumeRoleCommand({
36933
+ RoleArn: roleArn,
36934
+ RoleSessionName: `cdkd-local-layer-${Date.now()}`,
36935
+ DurationSeconds: 3600
36936
+ });
36937
+ }
36938
+ async function downloadPresignedZip(presignedUrl, options) {
36939
+ if (options.fetchZip) return options.fetchZip(presignedUrl);
36940
+ const response = await fetch(presignedUrl);
36941
+ if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText} from layer Content.Location URL`);
36942
+ const buf = await response.arrayBuffer();
36943
+ return new Uint8Array(buf);
36944
+ }
36945
+ /**
36946
+ * Minimal ZIP unzipper that handles the subset of the ZIP format Lambda
36947
+ * layer ZIPs ever use (DEFLATE compression method 8, STORE method 0).
36948
+ * Avoids bringing in a heavyweight dep for a 50-line task.
36949
+ *
36950
+ * Path-traversal guard: every entry's relative path is `normalize()`d
36951
+ * and rejected if the resulting absolute path escapes `destDir` (the
36952
+ * "Zip Slip" CVE class). Symlinks inside the ZIP are also rejected for
36953
+ * the same reason — they could point at arbitrary host paths.
36954
+ */
36955
+ async function unzipBufferToDirectory(zipBytes, destDir) {
36956
+ const view = new DataView(zipBytes.buffer, zipBytes.byteOffset, zipBytes.byteLength);
36957
+ const eocdSig = 101010256;
36958
+ const minScan = Math.max(0, zipBytes.byteLength - 65535 - 22);
36959
+ let eocdOffset = -1;
36960
+ for (let i = zipBytes.byteLength - 22; i >= minScan; i--) if (view.getUint32(i, true) === eocdSig) {
36961
+ eocdOffset = i;
36962
+ break;
36963
+ }
36964
+ if (eocdOffset < 0) throw new Error("Not a ZIP file (no End of Central Directory record found)");
36965
+ const totalEntries = view.getUint16(eocdOffset + 10, true);
36966
+ const cdSize = view.getUint32(eocdOffset + 12, true);
36967
+ const cdOffset = view.getUint32(eocdOffset + 16, true);
36968
+ const destAbsolute = resolve(destDir);
36969
+ let cursor = cdOffset;
36970
+ const cdEnd = cdOffset + cdSize;
36971
+ let parsed = 0;
36972
+ while (cursor < cdEnd && parsed < totalEntries) {
36973
+ if (view.getUint32(cursor, true) !== 33639248) throw new Error(`Corrupt ZIP: missing Central Directory header at offset ${cursor}`);
36974
+ const compressionMethod = view.getUint16(cursor + 10, true);
36975
+ const compressedSize = view.getUint32(cursor + 20, true);
36976
+ const uncompressedSize = view.getUint32(cursor + 24, true);
36977
+ const fileNameLength = view.getUint16(cursor + 28, true);
36978
+ const extraFieldLength = view.getUint16(cursor + 30, true);
36979
+ const fileCommentLength = view.getUint16(cursor + 32, true);
36980
+ const externalAttrs = view.getUint32(cursor + 38, true);
36981
+ const localHeaderOffset = view.getUint32(cursor + 42, true);
36982
+ const fileName = new TextDecoder("utf-8").decode(zipBytes.subarray(cursor + 46, cursor + 46 + fileNameLength));
36983
+ cursor += 46 + fileNameLength + extraFieldLength + fileCommentLength;
36984
+ parsed++;
36985
+ const targetPath = resolve(destAbsolute, normalize(fileName));
36986
+ if (!targetPath.startsWith(destAbsolute + (destAbsolute.endsWith("/") ? "" : "/"))) throw new Error(`Refusing to extract entry '${fileName}' — path escapes the destination directory`);
36987
+ if ((externalAttrs >>> 16 & 61440) === 40960) throw new Error(`Refusing to extract symlink entry '${fileName}' from layer ZIP (security)`);
36988
+ if (fileName.endsWith("/")) {
36989
+ await mkdir(targetPath, { recursive: true });
36990
+ continue;
36991
+ }
36992
+ await mkdir(dirname(targetPath), { recursive: true });
36993
+ if (view.getUint32(localHeaderOffset, true) !== 67324752) throw new Error(`Corrupt ZIP: missing Local File Header for '${fileName}'`);
36994
+ const lfhFileNameLength = view.getUint16(localHeaderOffset + 26, true);
36995
+ const lfhExtraFieldLength = view.getUint16(localHeaderOffset + 28, true);
36996
+ const dataOffset = localHeaderOffset + 30 + lfhFileNameLength + lfhExtraFieldLength;
36997
+ const compressedData = zipBytes.subarray(dataOffset, dataOffset + compressedSize);
36998
+ let payload;
36999
+ if (compressionMethod === 0) payload = compressedData;
37000
+ else if (compressionMethod === 8) payload = await inflateRaw(compressedData);
37001
+ else throw new Error(`Unsupported ZIP compression method ${compressionMethod} for entry '${fileName}' (only STORE and DEFLATE supported)`);
37002
+ if (payload.length !== uncompressedSize && compressionMethod !== 0) throw new Error(`ZIP entry '${fileName}': inflate produced ${payload.length} bytes, expected ${uncompressedSize}`);
37003
+ await pipeline(Readable.from(payload), createWriteStream(targetPath));
37004
+ }
37005
+ }
37006
+ async function inflateRaw(data) {
37007
+ const { inflateRaw: inflate } = await import("node:zlib");
37008
+ return new Promise((resolveP, rejectP) => {
37009
+ inflate(data, (err, out) => {
37010
+ if (err) rejectP(err);
37011
+ else resolveP(out);
37012
+ });
37013
+ });
37014
+ }
37015
+ function errMsg(err) {
37016
+ return err instanceof Error ? err.message : String(err);
37017
+ }
37018
+ function looksLikeAccessDenied(err) {
37019
+ if (!(err instanceof Error)) return false;
37020
+ const name = err.name ?? "";
37021
+ const code = err.Code ?? "";
37022
+ const message = err.message ?? "";
37023
+ return name === "AccessDeniedException" || code === "AccessDeniedException" || /access denied/i.test(message) || /not authorized/i.test(message);
37024
+ }
37025
+
36792
37026
  //#endregion
36793
37027
  //#region src/local/env-resolver.ts
36794
37028
  /**
@@ -40329,7 +40563,7 @@ function buildHttpApiV2Event(req, ctx, opts = {}) {
40329
40563
  stage: ctx.route.stage,
40330
40564
  time: formatRequestTime(now),
40331
40565
  timeEpoch: now.getTime(),
40332
- authentication: null,
40566
+ authentication: req.clientCert ? { clientCert: req.clientCert } : null,
40333
40567
  authorizer: null
40334
40568
  },
40335
40569
  body,
@@ -40379,7 +40613,8 @@ function buildRestV1Event(req, ctx, opts = {}) {
40379
40613
  httpMethod: req.method.toUpperCase(),
40380
40614
  identity: {
40381
40615
  sourceIp: req.sourceIp ?? "127.0.0.1",
40382
- userAgent: headers["user-agent"] ?? ""
40616
+ userAgent: headers["user-agent"] ?? "",
40617
+ ...req.clientCert && { clientCert: req.clientCert }
40383
40618
  },
40384
40619
  path: `/${ctx.route.stage}${ctx.matchedPath}`,
40385
40620
  protocol: "HTTP/1.1",
@@ -42571,12 +42806,20 @@ function amzDateOutsideSkew(amzDate, now) {
42571
42806
  async function startApiServer(opts) {
42572
42807
  const logger = getLogger().child("start-api");
42573
42808
  let currentState = opts.state;
42574
- const server = createServer$1((req, res) => {
42809
+ const requestHandler = (req, res) => {
42575
42810
  handleRequest(req, res, currentState, opts).catch((err) => {
42576
42811
  logger.error(`Unhandled request error: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
42577
42812
  if (!res.headersSent) writeError(res, 502);
42578
42813
  });
42579
- });
42814
+ };
42815
+ const server = opts.mtls ? createServer$2({
42816
+ requestCert: true,
42817
+ rejectUnauthorized: true,
42818
+ ca: opts.mtls.caPem,
42819
+ cert: opts.mtls.certPem,
42820
+ key: opts.mtls.keyPem
42821
+ }, requestHandler) : createServer$1(requestHandler);
42822
+ const scheme = opts.mtls ? "https" : "http";
42580
42823
  server.on("connection", (socket) => {
42581
42824
  socket.setNoDelay(true);
42582
42825
  });
@@ -42598,6 +42841,7 @@ async function startApiServer(opts) {
42598
42841
  return {
42599
42842
  port: actualPort,
42600
42843
  host: actualHost,
42844
+ scheme,
42601
42845
  server,
42602
42846
  close: async () => {
42603
42847
  if (closed) return;
@@ -42653,12 +42897,14 @@ async function handleRequest(req, res, state, opts) {
42653
42897
  return;
42654
42898
  }
42655
42899
  const authorizer = state.routes.find((r) => r.route.declaredAt === match.route.declaredAt && r.route.method === match.route.method)?.authorizer;
42900
+ const clientCert = opts.mtls ? extractClientCert(req) : void 0;
42656
42901
  const snapshot = {
42657
42902
  method,
42658
42903
  rawUrl,
42659
42904
  headers: collectHeaders(req),
42660
42905
  body: bodyBuf,
42661
- ...req.socket.remoteAddress !== void 0 && { sourceIp: req.socket.remoteAddress }
42906
+ ...req.socket.remoteAddress !== void 0 && { sourceIp: req.socket.remoteAddress },
42907
+ ...clientCert && { clientCert }
42662
42908
  };
42663
42909
  const matchCtx = {
42664
42910
  route: match.route,
@@ -43124,6 +43370,135 @@ function writeMockCorsPreflight(res, preflight) {
43124
43370
  for (const [name, value] of Object.entries(preflight.headers)) res.setHeader(name, value);
43125
43371
  res.end();
43126
43372
  }
43373
+ /**
43374
+ * Extract the verified client certificate from a request's TLS socket.
43375
+ *
43376
+ * Pre-conditions (load-bearing — caller MUST gate on `opts.mtls`):
43377
+ * - The server was started with `https.createServer({requestCert: true,
43378
+ * rejectUnauthorized: true, ...})`, so the TLS handshake has
43379
+ * already rejected unknown-CA / self-signed / missing-cert clients
43380
+ * by the time `handleRequest` runs. Any peer cert we see here is
43381
+ * structurally valid against the supplied CA bundle — we do NOT
43382
+ * re-verify in code.
43383
+ *
43384
+ * Returns `undefined` when the request was not over a TLS socket (the
43385
+ * caller should NOT call this on plain-HTTP requests; the gate is the
43386
+ * `opts.mtls` check in `handleRequest`).
43387
+ *
43388
+ * The returned shape is the AWS-canonical
43389
+ * `requestContext.identity.clientCert` per
43390
+ * https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mutual-tls.html#api-gateway-mutual-tls-event-shape:
43391
+ *
43392
+ * {
43393
+ * clientCertPem: "-----BEGIN CERTIFICATE-----\n...",
43394
+ * subjectDN: "CN=client,O=example,C=US",
43395
+ * issuerDN: "CN=My CA,O=example,C=US",
43396
+ * serialNumber: "01:23:45:67:...",
43397
+ * validity: { notBefore: "May 22 03:30:00 2026 GMT",
43398
+ * notAfter: "May 22 03:30:00 2027 GMT" }
43399
+ * }
43400
+ *
43401
+ * Exported for unit testing — the helper is pure-functional given a
43402
+ * cert object and never touches the network.
43403
+ */
43404
+ function extractClientCert(req) {
43405
+ const socket = req.socket;
43406
+ if (typeof socket.getPeerCertificate !== "function") return void 0;
43407
+ return peerCertificateToAws(socket.getPeerCertificate(false));
43408
+ }
43409
+ /**
43410
+ * Convert Node's `PeerCertificate` object to the AWS-canonical
43411
+ * `clientCert` event shape. Exported separately from
43412
+ * {@link extractClientCert} so the conversion can be unit-tested
43413
+ * against a synthetic cert object without a real TLS socket.
43414
+ *
43415
+ * Returns `undefined` when the cert is empty (`getPeerCertificate`
43416
+ * returns `{}` when there is no peer cert). Otherwise emits every
43417
+ * field defined by the AWS shape, falling back to `''` for missing
43418
+ * subject / issuer DN segments so handlers do not need to null-check.
43419
+ */
43420
+ function peerCertificateToAws(cert) {
43421
+ if (!cert || typeof cert !== "object") return void 0;
43422
+ if (Object.keys(cert).length === 0) return void 0;
43423
+ const c = cert;
43424
+ const subject = c["subject"];
43425
+ const issuer = c["issuer"];
43426
+ const raw = c["raw"];
43427
+ const subjectDN = formatDN(subject);
43428
+ const issuerDN = formatDN(issuer);
43429
+ const serialNumber = typeof c["serialNumber"] === "string" ? c["serialNumber"] : "";
43430
+ const validity = {
43431
+ notBefore: typeof c["valid_from"] === "string" ? c["valid_from"] : "",
43432
+ notAfter: typeof c["valid_to"] === "string" ? c["valid_to"] : ""
43433
+ };
43434
+ return {
43435
+ clientCertPem: Buffer.isBuffer(raw) ? derBufferToPem(raw) : "",
43436
+ subjectDN,
43437
+ issuerDN,
43438
+ serialNumber,
43439
+ validity
43440
+ };
43441
+ }
43442
+ /**
43443
+ * Format a Node `subject` / `issuer` object (e.g.
43444
+ * `{C: 'US', O: 'example', CN: 'client'}`) as the canonical
43445
+ * comma-separated DN string AWS emits (`CN=client,O=example,C=US`).
43446
+ *
43447
+ * Ordering follows AWS / OpenSSL convention: CN first, then OU, O, L,
43448
+ * ST, C. Fields the cert does not declare are skipped silently.
43449
+ */
43450
+ function formatDN(dn) {
43451
+ if (!dn || typeof dn !== "object") return "";
43452
+ const obj = dn;
43453
+ const order = [
43454
+ "CN",
43455
+ "OU",
43456
+ "O",
43457
+ "L",
43458
+ "ST",
43459
+ "C"
43460
+ ];
43461
+ const parts = [];
43462
+ for (const key of order) {
43463
+ const v = obj[key];
43464
+ if (typeof v === "string" && v.length > 0) parts.push(`${key}=${v}`);
43465
+ }
43466
+ return parts.join(",");
43467
+ }
43468
+ /**
43469
+ * Encode a DER-encoded certificate Buffer as PEM. We wrap the base64
43470
+ * in 64-char-per-line segments the way `openssl x509` does so the
43471
+ * round-trip looks like what AWS API Gateway emits.
43472
+ */
43473
+ function derBufferToPem(der) {
43474
+ const b64 = der.toString("base64");
43475
+ const lines = [];
43476
+ for (let i = 0; i < b64.length; i += 64) lines.push(b64.slice(i, i + 64));
43477
+ return `-----BEGIN CERTIFICATE-----\n${lines.join("\n")}\n-----END CERTIFICATE-----\n`;
43478
+ }
43479
+ /**
43480
+ * Read mTLS materials from disk. Each path is a PEM file. The function
43481
+ * throws a wrapped error naming the offending path on `ENOENT` /
43482
+ * permission failures so the CLI surfaces a clear error before the
43483
+ * server starts.
43484
+ *
43485
+ * Exported for the CLI's resolve-then-construct flow + for unit tests.
43486
+ */
43487
+ function readMtlsMaterialsFromDisk(opts) {
43488
+ return {
43489
+ caPem: readPemOrThrow(opts.truststorePath, "--mtls-truststore"),
43490
+ certPem: readPemOrThrow(opts.certPath, "--mtls-cert"),
43491
+ keyPem: readPemOrThrow(opts.keyPath, "--mtls-key")
43492
+ };
43493
+ }
43494
+ function readPemOrThrow(path, flagName) {
43495
+ try {
43496
+ return readFileSync(path);
43497
+ } catch (err) {
43498
+ const msg = err instanceof Error ? err.message : String(err);
43499
+ throw new Error(`${flagName}: cannot read PEM file at '${path}': ${msg}`);
43500
+ }
43501
+ }
43127
43502
 
43128
43503
  //#endregion
43129
43504
  //#region src/local/api-server-grouping.ts
@@ -43667,7 +44042,8 @@ async function localStartApiCommand(target, options) {
43667
44042
  stsRegion: options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"],
43668
44043
  inlineTmpDirs,
43669
44044
  layerTmpDirs,
43670
- stateByStack
44045
+ stateByStack,
44046
+ ...options.layerRoleArn !== void 0 && { layerRoleArn: options.layerRoleArn }
43671
44047
  });
43672
44048
  specs.set(logicalId, spec);
43673
44049
  }
@@ -43721,6 +44097,8 @@ async function localStartApiCommand(target, options) {
43721
44097
  const rieTimeoutMs = Math.max(3e4, maxTimeoutSec * 2 * 1e3);
43722
44098
  const basePort = parseInt(options.port, 10);
43723
44099
  if (!Number.isFinite(basePort) || basePort < 0 || basePort > 65535) throw new Error(`--port must be 0..65535 (got ${options.port}).`);
44100
+ const mtlsConfig = resolveMtlsConfig(options);
44101
+ if (mtlsConfig) logger.info("mTLS enabled: client certificates required (chain check against --mtls-truststore at TLS handshake).");
43724
44102
  const initialGroups = groupRoutesByServer(initialMaterial.routes);
43725
44103
  const servers = [];
43726
44104
  let nextPort = basePort;
@@ -43742,6 +44120,7 @@ async function localStartApiCommand(target, options) {
43742
44120
  state: groupState,
43743
44121
  rieTimeoutMs,
43744
44122
  host: options.host,
44123
+ ...mtlsConfig && { mtls: mtlsConfig },
43745
44124
  port: basePort === 0 ? 0 : nextPort,
43746
44125
  authorizerCache,
43747
44126
  jwksCache,
@@ -43759,7 +44138,7 @@ async function localStartApiCommand(target, options) {
43759
44138
  printPerServerRouteTables(servers);
43760
44139
  warnUnsupportedRoutes(servers.flatMap((s) => s.group.routes.map((r) => r.route)), logger);
43761
44140
  logger.info(`Per-Lambda concurrency: ${perLambdaConcurrency} (override with --per-lambda-concurrency)`);
43762
- for (const { group, server } of servers) process.stdout.write(`Server listening on http://${server.host}:${server.port} (${group.displayName})\n`);
44141
+ for (const { group, server } of servers) process.stdout.write(`Server listening on ${server.scheme}://${server.host}:${server.port} (${group.displayName})\n`);
43763
44142
  process.stdout.write("^C to stop and clean up containers.\n");
43764
44143
  let watcher;
43765
44144
  let reloadChain = Promise.resolve();
@@ -43977,10 +44356,10 @@ function warnIamRoutes(routesWithAuth) {
43977
44356
  * missing, runtime not supported).
43978
44357
  */
43979
44358
  async function buildContainerSpec(args) {
43980
- const { logicalId, stacks, overrides, assumeRole, containerHost, debugPort, stsRegion, inlineTmpDirs, layerTmpDirs, stateByStack } = args;
44359
+ const { logicalId, stacks, overrides, assumeRole, containerHost, debugPort, stsRegion, inlineTmpDirs, layerTmpDirs, stateByStack, layerRoleArn } = args;
43981
44360
  const lambda = resolveLambdaByLogicalId(logicalId, stacks);
43982
44361
  const codeDir = lambda.codePath ?? materializeInlineCode$1(lambda.handler, lambda.inlineCode ?? "", resolveRuntimeFileExtension(lambda.runtime), inlineTmpDirs);
43983
- const optDir = materializeLambdaLayers$1(lambda.layers, layerTmpDirs);
44362
+ const optDir = await materializeLambdaLayers$1(lambda.layers, layerTmpDirs, layerRoleArn);
43984
44363
  let templateEnv = getTemplateEnv$1(lambda.resource);
43985
44364
  const stateBundle = stateByStack.get(lambda.stack.stackName);
43986
44365
  let stateAudit;
@@ -44039,22 +44418,46 @@ async function buildContainerSpec(args) {
44039
44418
  *
44040
44419
  * Three branches:
44041
44420
  * - 0 layers → `undefined` (no `/opt` mount).
44042
- * - 1 layer → bind-mount the layer's asset dir directly (no copy).
44421
+ * - 1 layer → bind-mount the layer's asset dir directly (no copy)
44422
+ * when the entry is a same-stack asset. Literal-ARN entries always
44423
+ * pre-materialize first.
44043
44424
  * - 2+ layers → copy each into a fresh tmpdir IN ORDER (later
44044
44425
  * layers overwrite earlier files via `cpSync({force: true})`),
44045
44426
  * bind-mount the tmpdir at `/opt`. Records the tmpdir in
44046
44427
  * `layerTmpDirs` so `shutdown(...)` removes it.
44047
44428
  *
44429
+ * Issue #448: literal-ARN entries (`{kind: 'arn', ...}`) are downloaded
44430
+ * + unzipped via `lambda:GetLayerVersion` BEFORE the cpSync-merge
44431
+ * branches run. Every per-ARN tmpdir is also recorded in `layerTmpDirs`
44432
+ * so the same shutdown path cleans it up — even for the single-layer
44433
+ * fast path that bind-mounts the dir directly.
44434
+ *
44048
44435
  * AWS Lambda's actual runtime extracts every layer ZIP into `/opt`
44049
44436
  * in template order — the merge mirrors that. Docker rejects multiple
44050
44437
  * `-v ...:/opt:ro` entries at the same target, so cdkd can't rely on
44051
44438
  * overlay layering and must produce a single merged dir on the host.
44052
44439
  */
44053
- function materializeLambdaLayers$1(layers, layerTmpDirs) {
44440
+ async function materializeLambdaLayers$1(layers, layerTmpDirs, layerRoleArn) {
44054
44441
  if (layers.length === 0) return void 0;
44055
- if (layers.length === 1) return layers[0].assetPath;
44442
+ const flat = [];
44443
+ for (const layer of layers) {
44444
+ if (layer.kind === "asset") {
44445
+ flat.push({
44446
+ logicalId: layer.logicalId,
44447
+ assetPath: layer.assetPath
44448
+ });
44449
+ continue;
44450
+ }
44451
+ const dir = await materializeLayerFromArn(layer, { ...layerRoleArn !== void 0 && { roleArn: layerRoleArn } });
44452
+ layerTmpDirs.add(dir);
44453
+ flat.push({
44454
+ logicalId: layer.arn,
44455
+ assetPath: dir
44456
+ });
44457
+ }
44458
+ if (flat.length === 1) return flat[0].assetPath;
44056
44459
  const dir = mkdtempSync(path.join(tmpdir(), "cdkd-local-start-api-layers-"));
44057
- for (const layer of layers) cpSync(layer.assetPath, dir, {
44460
+ for (const layer of flat) cpSync(layer.assetPath, dir, {
44058
44461
  recursive: true,
44059
44462
  force: true
44060
44463
  });
@@ -44456,10 +44859,36 @@ function parseDebugPort(raw) {
44456
44859
  return parsed;
44457
44860
  }
44458
44861
  /**
44862
+ * Resolve the mTLS configuration from CLI options. Returns `undefined`
44863
+ * when none of the three `--mtls-*` flags is set (the server stays
44864
+ * plain-HTTP). When any of the three is set, ALL THREE must be set —
44865
+ * partial configurations are rejected at parse time so the server
44866
+ * never boots in a half-configured state.
44867
+ *
44868
+ * Exported for unit testing.
44869
+ */
44870
+ function resolveMtlsConfig(options) {
44871
+ const present = [];
44872
+ const absent = [];
44873
+ if (options.mtlsTruststore !== void 0 && options.mtlsTruststore !== "") present.push("--mtls-truststore");
44874
+ else absent.push("--mtls-truststore");
44875
+ if (options.mtlsCert !== void 0 && options.mtlsCert !== "") present.push("--mtls-cert");
44876
+ else absent.push("--mtls-cert");
44877
+ if (options.mtlsKey !== void 0 && options.mtlsKey !== "") present.push("--mtls-key");
44878
+ else absent.push("--mtls-key");
44879
+ if (present.length === 0) return void 0;
44880
+ if (absent.length > 0) throw new Error(`mTLS configuration is incomplete: ${present.join(", ")} set but ${absent.join(", ")} missing. All three of --mtls-truststore, --mtls-cert, and --mtls-key must be set together to enable mTLS, or all three left unset for plain HTTP.`);
44881
+ return readMtlsMaterialsFromDisk({
44882
+ truststorePath: options.mtlsTruststore,
44883
+ certPath: options.mtlsCert,
44884
+ keyPath: options.mtlsKey
44885
+ });
44886
+ }
44887
+ /**
44459
44888
  * Builder for the `start-api` subcommand. Wired up by `local.ts`.
44460
44889
  */
44461
44890
  function createLocalStartApiCommand() {
44462
- const startApi = new Command("start-api").description("Run a long-running local HTTP server that maps API Gateway routes (REST v1, HTTP API, Function URL) to Lambda invocations against the AWS Lambda Runtime Interface Emulator (Docker required). Supports Lambda TOKEN/REQUEST authorizers, Cognito User Pool / HTTP v2 JWT authorizers, and REST v1 AWS_IAM (SigV4 signature verification only — IAM policy evaluation is NOT emulated; see docs/local-emulation.md). When JWKS is unreachable, JWT authorizers fall back to pass-through (every token accepted) with a warn line — local dev fallback. VPC-config Lambdas run locally and surface a warn line at startup; their containers do NOT get attached to the deployed VPC subnets, so calls to private RDS / ElastiCache will fail.").argument("[target]", "Optional API filter. Accepts the bare CDK logical id ('MyHttpApi'; single-stack apps only), stack-qualified logical id ('MyStack:MyHttpApi'), full CDK Construct path ('MyStack/MyHttpApi/Resource'), or an ancestor Construct path that prefix-matches ('MyStack/MyHttpApi'). When omitted, every discovered API gets its own server. Mirrors `cdkd local invoke` / `cdkd local run-task` target syntax.").addOption(new Option("--port <port>", "HTTP server port (default: auto-allocate)").default("0")).addOption(new Option("--host <host>", "Bind address").default("127.0.0.1")).addOption(new Option("--stack <name>", "Stack to start (single-stack apps auto-detect)")).addOption(new Option("--warm", "Pre-start one container per Lambda at server boot").default(false)).addOption(new Option("--per-lambda-concurrency <n>", "Pool size cap per Lambda (default 2, max 4)").default("2")).addOption(new Option("--no-pull", "Skip docker pull (cached image)")).addOption(new Option("--container-host <host>", "IP the host uses to bind/probe the RIE port (must be a numeric IP — `docker run -p <ip>:<port>:8080` rejects hostnames). Defaults to 127.0.0.1.").default("127.0.0.1")).addOption(new Option("--debug-port-base <port>", "Reserve a contiguous --debug-port range (one per Lambda)")).addOption(new Option("--env-vars <file>", "JSON env-var overrides (SAM-compatible: {\"LogicalId\":{\"KEY\":\"VALUE\"}, \"Parameters\": {...}})")).addOption(new Option("--assume-role <arn-or-pair>", "Assume the Lambda's execution role and forward STS-issued temp creds. Bare <arn> = global default; <LogicalId>=<arn> = per-Lambda override (repeatable). Per-Lambda > global > unset (developer creds passed through).").argParser((raw, prev) => parseAssumeRoleToken(raw, prev))).addOption(new Option("--watch", "Hot-reload: re-synth + re-discover routes when cdk.out/ or asset directories change. Off by default; the server keeps the previous version serving when synth fails mid-reload.").default(false)).addOption(new Option("--stage <name>", "Select an API Gateway Stage by its 'StageName'. Default: the first Stage attached to each API. Drives event.stageVariables for both REST v1 and HTTP API v2. NOTE: For HTTP API v2 routes, requestContext.stage is always '$default' regardless of this flag (AWS-side limitation — HTTP API only exposes one stage to the integration event); only event.stageVariables is affected for v2 routes. For REST v1 routes the selected StageName is also threaded into requestContext.stage.")).addOption(new Option("--api <id>", "DEPRECATED — use the positional <target> argument instead. Same accepted forms (bare logical id, stack-qualified, Construct path, ancestor prefix). Will be removed in a future major release.")).addOption(new Option("--from-state", "Read cdkd S3 state for every routed stack and substitute Ref / Fn::GetAtt / Fn::Sub / Fn::Join (and AWS pseudo parameters) in Lambda env vars with the deployed physical IDs / attributes. Off by default — pre-PR warn-and-drop semantics are preserved. Turn on for stacks already deployed via cdkd deploy. Mirrors `cdkd local invoke --from-state` / `cdkd local run-task --from-state`. Re-runs against fresh state on every hot-reload firing (--watch).").default(false)).addOption(new Option("--stack-region <region>", "Region of the cdkd state record to read (used with --from-state when the same stack name has state in multiple regions).")).addOption(new Option("--allow-unverified-sigv4", "Opt-in: allow AWS_IAM SigV4 requests that cannot be cryptographically verified (foreign access-key-id, OR no local AWS credentials configured) to pass through with a placeholder principalId. DEFAULT off — fail-closed so unauthenticated bypass is impossible against `event.requestContext.identity.accessKey`-trusting handler code. Use only in dev loops where you understand the risk.").default(false)).action(withErrorHandling(localStartApiCommand));
44891
+ const startApi = new Command("start-api").description("Run a long-running local HTTP server that maps API Gateway routes (REST v1, HTTP API, Function URL) to Lambda invocations against the AWS Lambda Runtime Interface Emulator (Docker required). Supports Lambda TOKEN/REQUEST authorizers, Cognito User Pool / HTTP v2 JWT authorizers, and REST v1 AWS_IAM (SigV4 signature verification only — IAM policy evaluation is NOT emulated; see docs/local-emulation.md). When JWKS is unreachable, JWT authorizers fall back to pass-through (every token accepted) with a warn line — local dev fallback. VPC-config Lambdas run locally and surface a warn line at startup; their containers do NOT get attached to the deployed VPC subnets, so calls to private RDS / ElastiCache will fail.").argument("[target]", "Optional API filter. Accepts the bare CDK logical id ('MyHttpApi'; single-stack apps only), stack-qualified logical id ('MyStack:MyHttpApi'), full CDK Construct path ('MyStack/MyHttpApi/Resource'), or an ancestor Construct path that prefix-matches ('MyStack/MyHttpApi'). When omitted, every discovered API gets its own server. Mirrors `cdkd local invoke` / `cdkd local run-task` target syntax.").addOption(new Option("--port <port>", "HTTP server port (default: auto-allocate)").default("0")).addOption(new Option("--host <host>", "Bind address").default("127.0.0.1")).addOption(new Option("--stack <name>", "Stack to start (single-stack apps auto-detect)")).addOption(new Option("--warm", "Pre-start one container per Lambda at server boot").default(false)).addOption(new Option("--per-lambda-concurrency <n>", "Pool size cap per Lambda (default 2, max 4)").default("2")).addOption(new Option("--no-pull", "Skip docker pull (cached image)")).addOption(new Option("--container-host <host>", "IP the host uses to bind/probe the RIE port (must be a numeric IP — `docker run -p <ip>:<port>:8080` rejects hostnames). Defaults to 127.0.0.1.").default("127.0.0.1")).addOption(new Option("--debug-port-base <port>", "Reserve a contiguous --debug-port range (one per Lambda)")).addOption(new Option("--env-vars <file>", "JSON env-var overrides (SAM-compatible: {\"LogicalId\":{\"KEY\":\"VALUE\"}, \"Parameters\": {...}})")).addOption(new Option("--assume-role <arn-or-pair>", "Assume the Lambda's execution role and forward STS-issued temp creds. Bare <arn> = global default; <LogicalId>=<arn> = per-Lambda override (repeatable). Per-Lambda > global > unset (developer creds passed through).").argParser((raw, prev) => parseAssumeRoleToken(raw, prev))).addOption(new Option("--watch", "Hot-reload: re-synth + re-discover routes when cdk.out/ or asset directories change. Off by default; the server keeps the previous version serving when synth fails mid-reload.").default(false)).addOption(new Option("--stage <name>", "Select an API Gateway Stage by its 'StageName'. Default: the first Stage attached to each API. Drives event.stageVariables for both REST v1 and HTTP API v2. NOTE: For HTTP API v2 routes, requestContext.stage is always '$default' regardless of this flag (AWS-side limitation — HTTP API only exposes one stage to the integration event); only event.stageVariables is affected for v2 routes. For REST v1 routes the selected StageName is also threaded into requestContext.stage.")).addOption(new Option("--api <id>", "DEPRECATED — use the positional <target> argument instead. Same accepted forms (bare logical id, stack-qualified, Construct path, ancestor prefix). Will be removed in a future major release.")).addOption(new Option("--layer-role-arn <arn>", "Role to sts:AssumeRole before calling lambda:GetLayerVersion on every literal-ARN entry in Properties.Layers (issue #448). Use only when the dev credentials cannot read the layer — typically cross-account layers. AWS-published public layers (e.g. Lambda Powertools) are readable from every account and need no role.")).addOption(new Option("--from-state", "Read cdkd S3 state for every routed stack and substitute Ref / Fn::GetAtt / Fn::Sub / Fn::Join (and AWS pseudo parameters) in Lambda env vars with the deployed physical IDs / attributes. Off by default — pre-PR warn-and-drop semantics are preserved. Turn on for stacks already deployed via cdkd deploy. Mirrors `cdkd local invoke --from-state` / `cdkd local run-task --from-state`. Re-runs against fresh state on every hot-reload firing (--watch).").default(false)).addOption(new Option("--stack-region <region>", "Region of the cdkd state record to read (used with --from-state when the same stack name has state in multiple regions).")).addOption(new Option("--mtls-truststore <path>", "PEM-encoded CA bundle for client-certificate verification (mutual TLS). When set, the local server switches from HTTP to HTTPS and the TLS handshake rejects clients whose certificate doesn't chain to one of these CAs. Verified certs are surfaced on the Lambda event under requestContext.identity.clientCert (REST v1) / requestContext.authentication.clientCert (HTTP API v2). Must be set together with --mtls-cert + --mtls-key; partial flag sets are rejected. Generate a CA + server + client cert for local dev: openssl req -x509 -newkey rsa:2048 -nodes -keyout ca-key.pem -out ca.pem -subj \"/CN=cdkd-local-ca\" -days 365; openssl req -newkey rsa:2048 -nodes -keyout server-key.pem -out server-csr.pem -subj \"/CN=localhost\"; openssl x509 -req -in server-csr.pem -CA ca.pem -CAkey ca-key.pem -CAcreateserial -out server-cert.pem -days 365; openssl req -newkey rsa:2048 -nodes -keyout client-key.pem -out client-csr.pem -subj \"/CN=client\"; openssl x509 -req -in client-csr.pem -CA ca.pem -CAkey ca-key.pem -CAcreateserial -out client-cert.pem -days 365; curl --cacert ca.pem --cert client-cert.pem --key client-key.pem https://localhost:<port>/...")).addOption(new Option("--mtls-cert <path>", "PEM-encoded server certificate for mutual TLS. Self-signed is fine for local dev. Must be set together with --mtls-truststore + --mtls-key.")).addOption(new Option("--mtls-key <path>", "PEM-encoded server private key matching --mtls-cert. Must be set together with --mtls-truststore + --mtls-cert.")).addOption(new Option("--allow-unverified-sigv4", "Opt-in: allow AWS_IAM SigV4 requests that cannot be cryptographically verified (foreign access-key-id, OR no local AWS credentials configured) to pass through with a placeholder principalId. DEFAULT off — fail-closed so unauthenticated bypass is impossible against `event.requestContext.identity.accessKey`-trusting handler code. Use only in dev loops where you understand the risk.").default(false)).action(withErrorHandling(localStartApiCommand));
44463
44892
  [
44464
44893
  ...commonOptions,
44465
44894
  ...appOptions,
@@ -45562,6 +45991,14 @@ async function localInvokeCommand(target, options) {
45562
45991
  } catch (err) {
45563
45992
  getLogger().debug(`Failed to remove merged-layers tmpdir ${imagePlan.layersTmpDir}: ${err instanceof Error ? err.message : String(err)}`);
45564
45993
  }
45994
+ if (imagePlan?.layerArnTmpDirs) for (const dir of imagePlan.layerArnTmpDirs) try {
45995
+ rmSync(dir, {
45996
+ recursive: true,
45997
+ force: true
45998
+ });
45999
+ } catch (err) {
46000
+ getLogger().debug(`Failed to remove ARN-layer tmpdir ${dir}: ${err instanceof Error ? err.message : String(err)}`);
46001
+ }
45565
46002
  }, (err) => {
45566
46003
  getLogger().debug(`cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
45567
46004
  });
@@ -45747,7 +46184,7 @@ async function resolveZipImagePlan(lambda, options) {
45747
46184
  }
45748
46185
  const image = resolveRuntimeImage(lambda.runtime);
45749
46186
  await pullImage(image, options.pull === false);
45750
- const layerPlan = materializeLambdaLayers(lambda.layers);
46187
+ const layerPlan = await materializeLambdaLayersIncludingArns(lambda.layers, options);
45751
46188
  const containerCodePath = resolveRuntimeCodeMountPath(lambda.runtime);
45752
46189
  const tmpfs = resolveTmpfsForLambda(lambda);
45753
46190
  return {
@@ -45761,10 +46198,48 @@ async function resolveZipImagePlan(lambda, options) {
45761
46198
  cmd: [lambda.handler],
45762
46199
  ...inlineTmpDir !== void 0 && { inlineTmpDir },
45763
46200
  ...layerPlan.tmpDir !== void 0 && { layersTmpDir: layerPlan.tmpDir },
46201
+ ...layerPlan.extraTmpDirs.length > 0 && { layerArnTmpDirs: layerPlan.extraTmpDirs },
45764
46202
  ...tmpfs !== void 0 && { tmpfs }
45765
46203
  };
45766
46204
  }
45767
46205
  /**
46206
+ * Two-stage layer materialization (issue #448).
46207
+ *
46208
+ * - Stage 1: every `{kind: 'arn'}` entry is downloaded + unzipped
46209
+ * into its own tmpdir via `materializeLayerFromArn`. The per-ARN
46210
+ * tmpdirs are tracked in `extraTmpDirs` so the outer cleanup can
46211
+ * remove them.
46212
+ * - Stage 2: the resulting `{logicalId, assetPath}[]` list (in
46213
+ * template order — ARN entries surface their `arn` as the
46214
+ * `logicalId` for log lines) is handed to the existing
46215
+ * `materializeLambdaLayers` `cpSync`-merge path. AWS's "last layer
46216
+ * wins" file-collision semantic is preserved across both layer
46217
+ * kinds because the merge step is unchanged.
46218
+ */
46219
+ async function materializeLambdaLayersIncludingArns(layers, options) {
46220
+ const extraTmpDirs = [];
46221
+ const flat = [];
46222
+ for (const layer of layers) {
46223
+ if (layer.kind === "asset") {
46224
+ flat.push({
46225
+ logicalId: layer.logicalId,
46226
+ assetPath: layer.assetPath
46227
+ });
46228
+ continue;
46229
+ }
46230
+ const dir = await materializeLayerFromArn(layer, { ...options.layerRoleArn !== void 0 && { roleArn: options.layerRoleArn } });
46231
+ extraTmpDirs.push(dir);
46232
+ flat.push({
46233
+ logicalId: layer.arn,
46234
+ assetPath: dir
46235
+ });
46236
+ }
46237
+ return {
46238
+ ...materializeLambdaLayers(flat),
46239
+ extraTmpDirs
46240
+ };
46241
+ }
46242
+ /**
45768
46243
  * Build the `--tmpfs /tmp:rw,size=<N>m` plan for a Lambda (issue #440).
45769
46244
  *
45770
46245
  * The shape is identical for ZIP and IMAGE Lambdas — `--tmpfs` overlays
@@ -46172,7 +46647,7 @@ function pickReferencedLogicalId(intrinsic) {
46172
46647
  */
46173
46648
  function createLocalCommand() {
46174
46649
  const local = new Command("local").description("Local execution of Lambda functions (RIE) and ECS task definitions (Docker required)");
46175
- const invoke = new Command("invoke").description("Run a Lambda function locally in a Docker container (RIE-backed). Target accepts a CDK display path (MyStack/MyApi/Handler) or stack-qualified logical ID (MyStack:MyApiHandler1234ABCD). Single-stack apps may omit the stack prefix.").argument("<target>", "CDK display path or stack-qualified logical ID of the Lambda to invoke").addOption(new Option("-e, --event <file>", "JSON event payload file (default: {})")).addOption(new Option("--event-stdin", "Read event JSON from stdin").default(false)).addOption(new Option("--env-vars <file>", "JSON env-var overrides (SAM-compatible: {\"LogicalId\":{\"KEY\":\"VALUE\"}})")).addOption(new Option("--no-pull", "Skip docker pull (use cached image) — no-op for IMAGE local-build path; `docker build` does not pull base layers by default")).addOption(new Option("--no-build", "Skip docker build on the IMAGE local-build path (use the previously-built tag). Requires the deterministic tag to already be in the local registry; errors with an actionable message when missing. No-op for ZIP Lambdas and the IMAGE ECR-pull path. Compatible with --no-pull.")).addOption(new Option("--debug-port <port>", "Node --inspect-brk port (default: off)")).addOption(new Option("--container-host <host>", "Host to bind the RIE port to").default("127.0.0.1")).addOption(new Option("--assume-role [arn]", "Assume the Lambda's deployed execution role and forward STS-issued temp credentials to the container so the handler runs with the deployed function's narrow permissions (closes the \"developer admin / function narrow\" skew). Three forms: (1) `--assume-role <arn>` assumes the explicit ARN; (2) `--assume-role` (bare) auto-resolves the function's execution role ARN from cdkd state (requires --from-state); (3) `--no-assume-role` explicitly opts out (forces dev creds even with --from-state). Off by default — when omitted, the developer's shell credentials are forwarded unchanged (SAM-compatible default). STS failures degrade to a warn + dev-creds fallback.")).addOption(new Option("--ecr-role-arn <arn>", "Role ARN to assume before authenticating against ECR for cross-account / centralized registries (#455). Issues sts:AssumeRole via the default credential chain and uses the temporary credentials for ecr:GetAuthorizationToken + docker pull. Required when the caller does not have direct cross-account access to the target repository. Same-account / same-region pulls do not need this flag.")).addOption(new Option("--from-state", "Read cdkd S3 state for the target stack and substitute Ref / Fn::GetAtt / Fn::Sub in env vars with the deployed physical IDs / attributes. Off by default — keep PR 1 warn-and-drop semantics; turn on for stacks already deployed via cdkd deploy.").default(false)).addOption(new Option("--stack-region <region>", "Region of the cdkd state record to read (used with --from-state when the same stack name has state in multiple regions).")).action(withErrorHandling(localInvokeCommand));
46650
+ const invoke = new Command("invoke").description("Run a Lambda function locally in a Docker container (RIE-backed). Target accepts a CDK display path (MyStack/MyApi/Handler) or stack-qualified logical ID (MyStack:MyApiHandler1234ABCD). Single-stack apps may omit the stack prefix.").argument("<target>", "CDK display path or stack-qualified logical ID of the Lambda to invoke").addOption(new Option("-e, --event <file>", "JSON event payload file (default: {})")).addOption(new Option("--event-stdin", "Read event JSON from stdin").default(false)).addOption(new Option("--env-vars <file>", "JSON env-var overrides (SAM-compatible: {\"LogicalId\":{\"KEY\":\"VALUE\"}})")).addOption(new Option("--no-pull", "Skip docker pull (use cached image) — no-op for IMAGE local-build path; `docker build` does not pull base layers by default")).addOption(new Option("--no-build", "Skip docker build on the IMAGE local-build path (use the previously-built tag). Requires the deterministic tag to already be in the local registry; errors with an actionable message when missing. No-op for ZIP Lambdas and the IMAGE ECR-pull path. Compatible with --no-pull.")).addOption(new Option("--debug-port <port>", "Node --inspect-brk port (default: off)")).addOption(new Option("--container-host <host>", "Host to bind the RIE port to").default("127.0.0.1")).addOption(new Option("--assume-role [arn]", "Assume the Lambda's deployed execution role and forward STS-issued temp credentials to the container so the handler runs with the deployed function's narrow permissions (closes the \"developer admin / function narrow\" skew). Three forms: (1) `--assume-role <arn>` assumes the explicit ARN; (2) `--assume-role` (bare) auto-resolves the function's execution role ARN from cdkd state (requires --from-state); (3) `--no-assume-role` explicitly opts out (forces dev creds even with --from-state). Off by default — when omitted, the developer's shell credentials are forwarded unchanged (SAM-compatible default). STS failures degrade to a warn + dev-creds fallback.")).addOption(new Option("--layer-role-arn <arn>", "Role to sts:AssumeRole before calling lambda:GetLayerVersion on every literal-ARN entry in Properties.Layers (issue #448). Use only when the dev credentials cannot read the layer — typically cross-account layers. AWS-published public layers (e.g. Lambda Powertools) are readable from every account and need no role.")).addOption(new Option("--ecr-role-arn <arn>", "Role ARN to assume before authenticating against ECR for cross-account / centralized registries (#455). Issues sts:AssumeRole via the default credential chain and uses the temporary credentials for ecr:GetAuthorizationToken + docker pull. Required when the caller does not have direct cross-account access to the target repository. Same-account / same-region pulls do not need this flag.")).addOption(new Option("--from-state", "Read cdkd S3 state for the target stack and substitute Ref / Fn::GetAtt / Fn::Sub in env vars with the deployed physical IDs / attributes. Off by default — keep PR 1 warn-and-drop semantics; turn on for stacks already deployed via cdkd deploy.").default(false)).addOption(new Option("--stack-region <region>", "Region of the cdkd state record to read (used with --from-state when the same stack name has state in multiple regions).")).action(withErrorHandling(localInvokeCommand));
46176
46651
  [
46177
46652
  ...commonOptions,
46178
46653
  ...appOptions,
@@ -47464,7 +47939,7 @@ function reorderArgs(argv) {
47464
47939
  */
47465
47940
  async function main() {
47466
47941
  const program = new Command();
47467
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.124.0");
47942
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.126.0");
47468
47943
  program.addCommand(createBootstrapCommand());
47469
47944
  program.addCommand(createSynthCommand());
47470
47945
  program.addCommand(createListCommand());