@intentius/chant-lexicon-aws 0.124.23 → 0.124.25

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.
@@ -7,8 +7,9 @@
7
7
  * because a whoami that names a binding the read does not use is worse than no
8
8
  * whoami at all.
9
9
  */
10
+ import { createHash } from "node:crypto";
10
11
  import { describe, test, expect } from "vitest";
11
- import { describeIdentity } from "./caller-identity";
12
+ import { describeIdentity, type CallerIdentityOptions } from "./caller-identity";
12
13
  import { describeStackResources, type AwsReadHttp } from "./api/read-client";
13
14
 
14
15
  const CALLER_IDENTITY_XML = `
@@ -36,13 +37,24 @@ function recording(text: string, status = 200) {
36
37
  return { http, calls };
37
38
  }
38
39
 
40
+ /**
41
+ * No shared config or credentials files: the chain must never read the real
42
+ * `~/.aws` of whoever runs the suite. Tests that exercise profiles inject
43
+ * their own files.
44
+ */
45
+ const NO_FILES = { home: "/nonexistent-home", readFile: () => undefined };
46
+
47
+ function whoami(options: CallerIdentityOptions) {
48
+ return describeIdentity({ ...options, chain: { ...NO_FILES, ...options.chain } });
49
+ }
50
+
39
51
  /** Credentials that exist only in this record — never read from the process. */
40
52
  const CREDS = { AWS_ACCESS_KEY_ID: "AKIAEXAMPLEKEYID0000", AWS_SECRET_ACCESS_KEY: "notarealsecret" };
41
53
 
42
54
  describe("aws describeIdentity (#1982)", () => {
43
55
  test("reports the STS principal, the account+region scope, and where the binding came from", async () => {
44
56
  const { http, calls } = recording(CALLER_IDENTITY_XML);
45
- const result = await describeIdentity({
57
+ const result = await whoami({
46
58
  environment: "prod",
47
59
  region: "eu-west-1",
48
60
  client: { http, env: CREDS },
@@ -63,31 +75,33 @@ describe("aws describeIdentity (#1982)", () => {
63
75
 
64
76
  test("no environment credentials against real AWS is no-credentials, and the call is never made", async () => {
65
77
  const { http, calls } = recording(CALLER_IDENTITY_XML);
66
- const result = await describeIdentity({ environment: "prod", client: { http, env: {} } });
78
+ const result = await whoami({ environment: "prod", client: { http, env: {} } });
67
79
  expect(result).toEqual({
68
80
  unresolved: {
69
81
  reason: "no-credentials",
70
- detail: "no AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in the environment",
82
+ detail:
83
+ "no AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in the environment, no AWS_PROFILE, and no default profile in /nonexistent-home/.aws/config or /nonexistent-home/.aws/credentials",
71
84
  },
72
85
  });
73
86
  expect(calls).toHaveLength(0);
74
87
  });
75
88
 
76
- test("AWS_PROFILE alone is reported as no-credentials, naming the profile that does not sign", async () => {
77
- const { http } = recording(CALLER_IDENTITY_XML);
78
- const result = await describeIdentity({
89
+ test("an AWS_PROFILE that names no profile is no-credentials, naming the profile and the files read", async () => {
90
+ const { http, calls } = recording(CALLER_IDENTITY_XML);
91
+ const result = await whoami({
79
92
  environment: "prod",
80
93
  client: { http, env: { AWS_PROFILE: "acme-ci" } },
81
94
  });
82
95
  expect(result).toMatchObject({ unresolved: { reason: "no-credentials" } });
83
96
  const detail = (result as { unresolved: { detail: string } }).unresolved.detail;
84
97
  expect(detail).toContain("AWS_PROFILE=acme-ci");
85
- expect(detail).toContain("not the profile file");
98
+ expect(detail).toContain("/nonexistent-home/.aws/config");
99
+ expect(calls).toHaveLength(0);
86
100
  });
87
101
 
88
102
  test("an unsigned emulator read still resolves an identity, and says it was unsigned", async () => {
89
103
  const { http, calls } = recording(CALLER_IDENTITY_XML);
90
- const result = await describeIdentity({
104
+ const result = await whoami({
91
105
  environment: "floci",
92
106
  client: { http, env: { AWS_ENDPOINT_URL: "http://localhost:4566" } },
93
107
  });
@@ -106,21 +120,21 @@ describe("aws describeIdentity (#1982)", () => {
106
120
  403,
107
121
  );
108
122
  expect(
109
- await describeIdentity({ environment: "prod", client: { http: denied.http, env: CREDS } }),
123
+ await whoami({ environment: "prod", client: { http: denied.http, env: CREDS } }),
110
124
  ).toMatchObject({ unresolved: { reason: "no-credentials" } });
111
125
 
112
126
  const broken = recording(
113
127
  "<ErrorResponse><Error><Code>ServiceUnavailable</Code><Message>try later</Message></Error></ErrorResponse>",
114
128
  503,
115
129
  );
116
- const result = await describeIdentity({ environment: "prod", client: { http: broken.http, env: CREDS } });
130
+ const result = await whoami({ environment: "prod", client: { http: broken.http, env: CREDS } });
117
131
  expect(result).toMatchObject({ unresolved: { reason: "read-failed" } });
118
132
  expect((result as { unresolved: { detail: string } }).unresolved.detail).toContain("ServiceUnavailable");
119
133
  });
120
134
 
121
135
  test("an answer with no Arn is read-failed, never an empty identity", async () => {
122
136
  const { http } = recording("<GetCallerIdentityResponse><GetCallerIdentityResult/></GetCallerIdentityResponse>");
123
- const result = await describeIdentity({ environment: "prod", client: { http, env: CREDS } });
137
+ const result = await whoami({ environment: "prod", client: { http, env: CREDS } });
124
138
  expect(result).toMatchObject({ unresolved: { reason: "read-failed" } });
125
139
  });
126
140
 
@@ -129,7 +143,7 @@ describe("aws describeIdentity (#1982)", () => {
129
143
  // to us-east-1; it does not consult AWS_REGION. Reporting the shell's var
130
144
  // here would describe a target no read uses.
131
145
  const { http, calls } = recording(CALLER_IDENTITY_XML);
132
- const result = await describeIdentity({
146
+ const result = await whoami({
133
147
  environment: "prod",
134
148
  client: { http, env: { ...CREDS, AWS_REGION: "ap-south-1" } },
135
149
  });
@@ -140,7 +154,7 @@ describe("aws describeIdentity (#1982)", () => {
140
154
 
141
155
  test("no credential material appears anywhere in the reported row", async () => {
142
156
  const { http } = recording(CALLER_IDENTITY_XML);
143
- const result = await describeIdentity({ environment: "prod", client: { http, env: CREDS } });
157
+ const result = await whoami({ environment: "prod", client: { http, env: CREDS } });
144
158
  const serialized = JSON.stringify(result);
145
159
  expect(serialized).not.toContain(CREDS.AWS_SECRET_ACCESS_KEY);
146
160
  expect(serialized).not.toContain(CREDS.AWS_ACCESS_KEY_ID);
@@ -152,7 +166,7 @@ describe("whoami and the live read resolve the same target (#1982 acceptance)",
152
166
  const env = { ...CREDS, AWS_ENDPOINT_URL: "http://localhost:4566" };
153
167
 
154
168
  const identityHttp = recording(CALLER_IDENTITY_XML);
155
- const identity = await describeIdentity({
169
+ const identity = await whoami({
156
170
  environment: "floci",
157
171
  region: "eu-west-1",
158
172
  client: { http: identityHttp.http, env },
@@ -170,7 +184,7 @@ describe("whoami and the live read resolve the same target (#1982 acceptance)",
170
184
 
171
185
  test("against real AWS both resolve the same region, on each service's own host", async () => {
172
186
  const identityHttp = recording(CALLER_IDENTITY_XML);
173
- await describeIdentity({
187
+ await whoami({
174
188
  environment: "prod",
175
189
  region: "eu-west-1",
176
190
  client: { http: identityHttp.http, env: CREDS },
@@ -189,3 +203,274 @@ describe("whoami and the live read resolve the same target (#1982 acceptance)",
189
203
  }
190
204
  });
191
205
  });
206
+
207
+ describe("whoami resolves AWS profiles the way the AWS CLI does (#3954)", () => {
208
+ const HOME = "/home/op";
209
+ const files = (map: Record<string, string>) => ({ home: HOME, readFile: (path: string) => map[path] });
210
+
211
+ test("a static-key profile named by AWS_PROFILE signs the call and is named as the source", async () => {
212
+ const { http, calls } = recording(CALLER_IDENTITY_XML);
213
+ const result = await whoami({
214
+ environment: "prod",
215
+ client: { http, env: { AWS_PROFILE: "dev" } },
216
+ chain: files({
217
+ [`${HOME}/.aws/credentials`]: "[dev]\naws_access_key_id = AKIAPROFILEKEY00000\naws_secret_access_key = profilesecret\n",
218
+ }),
219
+ });
220
+ expect(result).toMatchObject({
221
+ identity: "arn:aws:sts::491500000000:assumed-role/deploy/ci",
222
+ source: "profile dev (static keys); chant default (no stacks[].region)",
223
+ });
224
+ expect(calls[0].headers.authorization).toContain("Credential=AKIAPROFILEKEY00000/");
225
+ expect(JSON.stringify(result)).not.toContain("profilesecret");
226
+ });
227
+
228
+ test("an SSO-session profile is resolved through the cached token and the SSO portal", async () => {
229
+ const sts = recording(CALLER_IDENTITY_XML);
230
+ const portal: Array<{ url: string; headers: Record<string, string> }> = [];
231
+ const result = await whoami({
232
+ environment: "prod",
233
+ client: { http: sts.http, env: { AWS_PROFILE: "loom" } },
234
+ chain: {
235
+ ...files({
236
+ [`${HOME}/.aws/config`]: [
237
+ "[profile loom]",
238
+ "sso_session = acme",
239
+ "sso_account_id = 491500000000",
240
+ "sso_role_name = Deployer",
241
+ "",
242
+ "[sso-session acme]",
243
+ "sso_start_url = https://acme.awsapps.com/start",
244
+ "sso_region = eu-west-1",
245
+ ].join("\n"),
246
+ [`${HOME}/.aws/sso/cache/${sha1("acme")}.json`]: JSON.stringify({
247
+ accessToken: "sso-bearer",
248
+ expiresAt: "2030-01-01T00:00:00Z",
249
+ }),
250
+ }),
251
+ now: () => new Date("2026-10-12T00:00:00Z"),
252
+ http: (url, init) => {
253
+ portal.push({ url, headers: init.headers });
254
+ return Promise.resolve({
255
+ status: 200,
256
+ text: JSON.stringify({
257
+ roleCredentials: { accessKeyId: "ASIASSOKEY000000000", secretAccessKey: "ssosecret", sessionToken: "ssotoken" },
258
+ }),
259
+ });
260
+ },
261
+ },
262
+ });
263
+ expect(portal).toEqual([
264
+ {
265
+ url: "https://portal.sso.eu-west-1.amazonaws.com/federation/credentials?account_id=491500000000&role_name=Deployer",
266
+ headers: { "x-amz-sso_bearer_token": "sso-bearer" },
267
+ },
268
+ ]);
269
+ expect((result as { source: string }).source).toContain(
270
+ "profile loom (sso session acme), role Deployer in 491500000000",
271
+ );
272
+ expect(sts.calls[0].headers.authorization).toContain("Credential=ASIASSOKEY000000000/");
273
+ expect(sts.calls[0].headers["x-amz-security-token"]).toBe("ssotoken");
274
+ const serialized = JSON.stringify(result);
275
+ for (const secret of ["sso-bearer", "ssosecret", "ssotoken"]) expect(serialized).not.toContain(secret);
276
+ });
277
+
278
+ test("an expired SSO session is no-credentials naming aws sso login --profile", async () => {
279
+ const { http, calls } = recording(CALLER_IDENTITY_XML);
280
+ const portalCalls: string[] = [];
281
+ const result = await whoami({
282
+ environment: "prod",
283
+ client: { http, env: { AWS_PROFILE: "loom" } },
284
+ chain: {
285
+ ...files({
286
+ [`${HOME}/.aws/config`]:
287
+ "[profile loom]\nsso_start_url = https://acme.awsapps.com/start\nsso_region = us-east-1\nsso_account_id = 491500000000\nsso_role_name = Deployer\n",
288
+ [`${HOME}/.aws/sso/cache/${sha1("https://acme.awsapps.com/start")}.json`]: JSON.stringify({
289
+ accessToken: "stale",
290
+ expiresAt: "2026-01-01T00:00:00Z",
291
+ }),
292
+ }),
293
+ now: () => new Date("2026-10-12T00:00:00Z"),
294
+ http: (url) => {
295
+ portalCalls.push(url);
296
+ return Promise.resolve({ status: 200, text: "{}" });
297
+ },
298
+ },
299
+ });
300
+ expect(result).toEqual({
301
+ unresolved: {
302
+ reason: "no-credentials",
303
+ detail: "profile loom: the SSO session has expired; run `aws sso login --profile loom`",
304
+ },
305
+ });
306
+ expect(portalCalls).toHaveLength(0);
307
+ expect(calls).toHaveLength(0);
308
+ });
309
+
310
+ test("a portal refusal of a cached session also names aws sso login", async () => {
311
+ const { http } = recording(CALLER_IDENTITY_XML);
312
+ const result = await whoami({
313
+ environment: "prod",
314
+ client: { http, env: {} },
315
+ chain: {
316
+ ...files({
317
+ [`${HOME}/.aws/config`]:
318
+ "[default]\nsso_session = acme\nsso_account_id = 1\nsso_role_name = R\n[sso-session acme]\nsso_start_url = https://a/start\nsso_region = us-east-1\n",
319
+ [`${HOME}/.aws/sso/cache/${sha1("acme")}.json`]: JSON.stringify({
320
+ accessToken: "revoked",
321
+ expiresAt: "2030-01-01T00:00:00Z",
322
+ }),
323
+ }),
324
+ now: () => new Date("2026-10-12T00:00:00Z"),
325
+ http: () => Promise.resolve({ status: 401, text: '{"message":"Session token not found or invalid"}' }),
326
+ },
327
+ });
328
+ expect(result).toMatchObject({ unresolved: { reason: "no-credentials" } });
329
+ expect((result as { unresolved: { detail: string } }).unresolved.detail).toContain(
330
+ "run `aws sso login --profile default`",
331
+ );
332
+ });
333
+
334
+ test("an expired sso-session token with a refresh token is refreshed, not reported expired", async () => {
335
+ const sts = recording(CALLER_IDENTITY_XML);
336
+ const seen: string[] = [];
337
+ const result = await whoami({
338
+ environment: "prod",
339
+ client: { http: sts.http, env: { AWS_PROFILE: "loom" } },
340
+ chain: {
341
+ ...files({
342
+ [`${HOME}/.aws/config`]:
343
+ "[profile loom]\nsso_session = acme\nsso_account_id = 491500000000\nsso_role_name = Deployer\n[sso-session acme]\nsso_start_url = https://a/start\nsso_region = us-east-1\n",
344
+ [`${HOME}/.aws/sso/cache/${sha1("acme")}.json`]: JSON.stringify({
345
+ accessToken: "stale",
346
+ expiresAt: "2026-01-01T00:00:00Z",
347
+ refreshToken: "refresh",
348
+ clientId: "cid",
349
+ clientSecret: "csecret",
350
+ registrationExpiresAt: "2030-01-01T00:00:00Z",
351
+ }),
352
+ }),
353
+ now: () => new Date("2026-10-12T00:00:00Z"),
354
+ http: (url, init) => {
355
+ seen.push(`${init.method} ${url}`);
356
+ if (url.startsWith("https://oidc.")) {
357
+ expect(JSON.parse(init.body ?? "{}")).toMatchObject({ grantType: "refresh_token", refreshToken: "refresh" });
358
+ return Promise.resolve({ status: 200, text: JSON.stringify({ accessToken: "fresh", expiresIn: 3600 }) });
359
+ }
360
+ expect(init.headers["x-amz-sso_bearer_token"]).toBe("fresh");
361
+ return Promise.resolve({
362
+ status: 200,
363
+ text: JSON.stringify({ roleCredentials: { accessKeyId: "ASIAX", secretAccessKey: "s", sessionToken: "t" } }),
364
+ });
365
+ },
366
+ },
367
+ });
368
+ expect(seen).toEqual([
369
+ "POST https://oidc.us-east-1.amazonaws.com/token",
370
+ "GET https://portal.sso.us-east-1.amazonaws.com/federation/credentials?account_id=491500000000&role_name=Deployer",
371
+ ]);
372
+ expect(result).toMatchObject({ identity: "arn:aws:sts::491500000000:assumed-role/deploy/ci" });
373
+ });
374
+
375
+ test("credential_process output signs the call; a failing process is no-credentials without echoing output", async () => {
376
+ const { http, calls } = recording(CALLER_IDENTITY_XML);
377
+ const config = { [`${HOME}/.aws/config`]: "[profile vault]\ncredential_process = aws-vault exec ci --json\n" };
378
+ const ran: string[] = [];
379
+ const ok = await whoami({
380
+ environment: "prod",
381
+ client: { http, env: { AWS_PROFILE: "vault" } },
382
+ chain: {
383
+ ...files(config),
384
+ runProcess: (command) => {
385
+ ran.push(command);
386
+ return Promise.resolve({
387
+ exitCode: 0,
388
+ stderr: "",
389
+ stdout: JSON.stringify({ Version: 1, AccessKeyId: "ASIAPROC00000000000", SecretAccessKey: "procsecret" }),
390
+ });
391
+ },
392
+ },
393
+ });
394
+ expect(ran).toEqual(["aws-vault exec ci --json"]);
395
+ expect((ok as { source: string }).source).toContain("profile vault (credential_process)");
396
+ expect(calls[0].headers.authorization).toContain("Credential=ASIAPROC00000000000/");
397
+
398
+ const bad = await whoami({
399
+ environment: "prod",
400
+ client: { http, env: { AWS_PROFILE: "vault" } },
401
+ chain: {
402
+ ...files(config),
403
+ runProcess: () => Promise.resolve({ exitCode: 0, stderr: "", stdout: '{"AccessKeyId":"ASIALEAK"' }),
404
+ },
405
+ });
406
+ expect(bad).toMatchObject({ unresolved: { reason: "no-credentials" } });
407
+ expect(JSON.stringify(bad)).not.toContain("ASIALEAK");
408
+ });
409
+
410
+ test("a role_arn profile assumes the role with its source profile's keys", async () => {
411
+ const assumed = `<AssumeRoleResponse><AssumeRoleResult><Credentials>
412
+ <AccessKeyId>ASIAROLE00000000000</AccessKeyId><SecretAccessKey>rolesecret</SecretAccessKey>
413
+ <SessionToken>roletoken</SessionToken><Expiration>2030-01-01T00:00:00Z</Expiration>
414
+ </Credentials></AssumeRoleResult></AssumeRoleResponse>`;
415
+ const calls: Array<{ body: string; auth: string }> = [];
416
+ const http: AwsReadHttp = (_url, init) => {
417
+ calls.push({ body: init.body, auth: init.headers.authorization ?? "" });
418
+ return Promise.resolve({ status: 200, text: init.body.includes("AssumeRole") ? assumed : CALLER_IDENTITY_XML });
419
+ };
420
+ const result = await whoami({
421
+ environment: "prod",
422
+ client: { http, env: { AWS_PROFILE: "deploy" } },
423
+ chain: files({
424
+ [`${HOME}/.aws/config`]:
425
+ "[profile deploy]\nrole_arn = arn:aws:iam::491500000000:role/deploy\nsource_profile = base\nrole_session_name = ci\n",
426
+ [`${HOME}/.aws/credentials`]: "[base]\naws_access_key_id = AKIABASE00000000000\naws_secret_access_key = basesecret\n",
427
+ }),
428
+ });
429
+ expect(calls).toHaveLength(2);
430
+ expect(calls[0].body).toContain("Action=AssumeRole");
431
+ expect(calls[0].body).toContain("RoleSessionName=ci");
432
+ expect(calls[0].auth).toContain("Credential=AKIABASE00000000000/");
433
+ expect(calls[1].auth).toContain("Credential=ASIAROLE00000000000/");
434
+ expect((result as { source: string }).source).toContain(
435
+ "profile deploy (role arn:aws:iam::491500000000:role/deploy via profile base (static keys))",
436
+ );
437
+ });
438
+
439
+ test("environment keys win over AWS_PROFILE, as in the AWS CLI, and the row says the profile was not used", async () => {
440
+ const { http, calls } = recording(CALLER_IDENTITY_XML);
441
+ const result = await whoami({
442
+ environment: "prod",
443
+ client: { http, env: { ...CREDS, AWS_PROFILE: "dev" } },
444
+ chain: files({ [`${HOME}/.aws/credentials`]: "[dev]\naws_access_key_id = AKIAOTHER\naws_secret_access_key = x\n" }),
445
+ });
446
+ expect((result as { source: string }).source).toContain("env AWS_ACCESS_KEY_ID; AWS_PROFILE=dev not used");
447
+ expect(calls[0].headers.authorization).toContain(`Credential=${CREDS.AWS_ACCESS_KEY_ID}/`);
448
+ });
449
+
450
+ test("the default profile answers when AWS_PROFILE is unset", async () => {
451
+ const { http } = recording(CALLER_IDENTITY_XML);
452
+ const result = await whoami({
453
+ environment: "prod",
454
+ client: { http, env: {} },
455
+ chain: files({ [`${HOME}/.aws/credentials`]: "[default]\naws_access_key_id = AKIADEF\naws_secret_access_key = d\n" }),
456
+ });
457
+ expect((result as { source: string }).source).toContain("profile default (static keys)");
458
+ });
459
+
460
+ test("AWS_CONFIG_FILE and AWS_SHARED_CREDENTIALS_FILE move the files read", async () => {
461
+ const { http } = recording(CALLER_IDENTITY_XML);
462
+ const result = await whoami({
463
+ environment: "prod",
464
+ client: {
465
+ http,
466
+ env: { AWS_PROFILE: "x", AWS_CONFIG_FILE: "/etc/aws/config", AWS_SHARED_CREDENTIALS_FILE: "/etc/aws/creds" },
467
+ },
468
+ chain: files({ "/etc/aws/creds": "[x]\naws_access_key_id = AKIAX\naws_secret_access_key = s\n" }),
469
+ });
470
+ expect((result as { source: string }).source).toContain("profile x (static keys)");
471
+ });
472
+ });
473
+
474
+ function sha1(value: string): string {
475
+ return createHash("sha1").update(value).digest("hex");
476
+ }
@@ -20,12 +20,24 @@
20
20
  *
21
21
  * ## Credentials
22
22
  *
23
- * `resolveCredentials` (./api/sigv4.ts) reads `AWS_ACCESS_KEY_ID` /
24
- * `AWS_SECRET_ACCESS_KEY` and nothing else — no profile file, no IMDS, no
25
- * container endpoint, deliberately. `AWS_PROFILE` alone therefore signs
26
- * nothing, and against real AWS the call comes back unauthorized. That is
27
- * reported as `no-credentials` naming the profile, which is a far better
28
- * answer than an empty stack read an hour later.
23
+ * Resolved the way the AWS CLI resolves them (#3954), by
24
+ * `./api/credential-chain.ts`: environment keys, then the profile
25
+ * `AWS_PROFILE` names (or `default`) from the shared config and credentials
26
+ * files, through static keys, `role_arn`, SSO sessions and
27
+ * `credential_process`. Before #3954 only the environment keys were read, so
28
+ * `AWS_PROFILE=dev` on an SSO session answered "no identity is configured"
29
+ * while `aws sts get-caller-identity` answered with a principal. An expired
30
+ * SSO session is `no-credentials` naming `aws sso login --profile <p>`.
31
+ *
32
+ * The native read transport still signs with environment keys only
33
+ * (`resolveCredentials` in ./api/sigv4.ts; #3958). So a profile-sourced row says
34
+ * what the CLI, and anything shelling out to it, would act as; it does not
35
+ * yet say what `describeResources` signs with. The `source` column names the
36
+ * profile, so the difference is visible.
37
+ *
38
+ * Against an endpoint override the chain is not walked: the emulator call is
39
+ * unsigned whatever resolves, and a whoami against Floci should not start an
40
+ * SSO refresh or a `credential_process`.
29
41
  *
30
42
  * No credential value is ever returned. The identity is the principal ARN the
31
43
  * service itself answers with, and the scope is an account and a region.
@@ -39,7 +51,7 @@ import {
39
51
  serviceUrl,
40
52
  type AwsReadClientOptions,
41
53
  } from "./api/read-client";
42
- import { resolveCredentials } from "./api/sigv4";
54
+ import { resolveCredentialChain, type CredentialChainOptions } from "./api/credential-chain";
43
55
 
44
56
  /** The region the read transport falls back to when no stack declares one. */
45
57
  const DEFAULT_REGION = "us-east-1";
@@ -62,9 +74,9 @@ function regionSource(declared: string | undefined, env: Record<string, string |
62
74
  return "chant default (no stacks[].region)";
63
75
  }
64
76
 
65
- /** Where the signature came from, without any part of the credential itself. */
66
- function credentialSource(env: Record<string, string | undefined>): string | undefined {
67
- if (!resolveCredentials(undefined, env)) return undefined;
77
+ /** The environment-key label, used against an endpoint override where the chain is not walked. */
78
+ function envCredentialLabel(env: Record<string, string | undefined>): string | undefined {
79
+ if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) return undefined;
68
80
  return env.AWS_SESSION_TOKEN
69
81
  ? "env AWS_ACCESS_KEY_ID + AWS_SESSION_TOKEN"
70
82
  : "env AWS_ACCESS_KEY_ID";
@@ -73,6 +85,12 @@ function credentialSource(env: Record<string, string | undefined>): string | und
73
85
  /** Options this module needs beyond the contract — the injectable transport, for tests. */
74
86
  export interface CallerIdentityOptions extends DescribeIdentityOptions {
75
87
  client?: AwsReadClientOptions;
88
+ /**
89
+ * The credential chain's seams: shared-file reads, the SSO and
90
+ * `credential_process` transports, the clock. Its `env` defaults to the
91
+ * client's, and its STS transport to the client's `http`.
92
+ */
93
+ chain?: CredentialChainOptions;
76
94
  }
77
95
 
78
96
  /**
@@ -91,19 +109,23 @@ export async function describeIdentity(options: CallerIdentityOptions): Promise<
91
109
  const region = client.region ?? DEFAULT_REGION;
92
110
  const endpoint = serviceUrl("sts", override, client.region);
93
111
 
94
- const credential = credentialSource(env);
95
112
  // Against an emulator the call is unsigned by design (Floci verifies no
96
113
  // signature), so an absent credential is not yet a refusal — the emulator
97
- // answers with the identity it models. Against real AWS it is.
98
- if (!credential && !override) {
99
- return {
100
- unresolved: {
101
- reason: "no-credentials",
102
- detail: env.AWS_PROFILE
103
- ? `no AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in the environment; AWS_PROFILE=${env.AWS_PROFILE} is set, but chant's native transport reads only the environment credentials, not the profile file`
104
- : "no AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in the environment",
105
- },
106
- };
114
+ // answers with the identity it models — and the chain is not walked.
115
+ let credential: string | undefined;
116
+ if (override) {
117
+ credential = envCredentialLabel(env);
118
+ } else if (client.credentials) {
119
+ credential = "credentials injected by the caller";
120
+ } else {
121
+ const chain = await resolveCredentialChain({
122
+ env,
123
+ ...options.chain,
124
+ sts: { ...(client.http ? { http: client.http } : {}), ...options.chain?.sts, env },
125
+ });
126
+ if ("unresolved" in chain) return { unresolved: chain.unresolved };
127
+ credential = chain.resolved.source;
128
+ client.credentials = chain.resolved.credentials;
107
129
  }
108
130
 
109
131
  let identity;
@@ -27,6 +27,15 @@ import {
27
27
  type EnumOverlayApplication,
28
28
  type EnumOverlayEntry,
29
29
  } from "./enum-overlay";
30
+ import {
31
+ applyPropertyOverlay,
32
+ assertPropertyOverlayCoverage,
33
+ propertyOverlayByType,
34
+ propertyOverlayEntries,
35
+ redundantPropertyOverlayWarnings,
36
+ type PropertyOverlayApplication,
37
+ type PropertyOverlayEntry,
38
+ } from "./property-overlay";
30
39
  import { samResources } from "./sam";
31
40
  import { fallbackResources } from "./fallback";
32
41
  import { NamingStrategy, publishedNames, propertyTypeName, extractDefName } from "./naming";
@@ -56,6 +65,9 @@ let awsOverlay: Map<string, EnumOverlayEntry[]> = new Map();
56
65
  let awsOverlayApplications: EnumOverlayApplication[] = [];
57
66
  let awsOverlaySeenTypes = new Set<string>();
58
67
  let awsOverlayStrict = false;
68
+ /** Backfilled properties (chant #3953), reset per `generate()` call; strictness and seen types are shared with the enum overlay. */
69
+ let awsPropertyOverlay: Map<string, PropertyOverlayEntry[]> = new Map();
70
+ let awsPropertyOverlayApplications: PropertyOverlayApplication[] = [];
59
71
 
60
72
  const awsPipelineConfig: GeneratePipelineConfig<SchemaParseResult> = {
61
73
  fetchSchemas: async (opts) => {
@@ -69,8 +81,11 @@ const awsPipelineConfig: GeneratePipelineConfig<SchemaParseResult> = {
69
81
 
70
82
  parseSchema: (typeName, data) => {
71
83
  awsOverlaySeenTypes.add(typeName);
84
+ // Properties first, so an enum overlay entry could name a backfilled one.
85
+ const backfilled = applyPropertyOverlay(data, awsPropertyOverlay.get(typeName) ?? []);
86
+ awsPropertyOverlayApplications.push(...backfilled.applications);
72
87
  const entries = awsOverlay.get(typeName) ?? [];
73
- const overlaid = applyEnumOverlay(typeName, data, entries, { strict: awsOverlayStrict });
88
+ const overlaid = applyEnumOverlay(typeName, backfilled.data, entries, { strict: awsOverlayStrict });
74
89
  awsOverlayApplications.push(...overlaid.applications);
75
90
  const result = parseCFNSchema(overlaid.data);
76
91
  if (!result.resource.typeName) return null;
@@ -142,6 +157,11 @@ const awsPipelineConfig: GeneratePipelineConfig<SchemaParseResult> = {
142
157
  log(`Applied ${applied} curated enum overlay entries`);
143
158
  warnings.push(...redundantOverlayWarnings(awsOverlayApplications));
144
159
 
160
+ assertPropertyOverlayCoverage(propertyOverlayEntries(), awsOverlaySeenTypes, awsOverlayStrict);
161
+ const backfilled = awsPropertyOverlayApplications.filter((a) => a.outcome === "applied").length;
162
+ log(`Backfilled ${backfilled} properties the pinned spec predates`);
163
+ warnings.push(...redundantPropertyOverlayWarnings(awsPropertyOverlayApplications));
164
+
145
165
  log(`Total: ${results.length} resource schemas`);
146
166
  return { results, warnings };
147
167
  },
@@ -174,6 +194,8 @@ export async function generate(opts: GenerateOptions = {}): Promise<GenerateResu
174
194
  awsOverlay = enumOverlayByType();
175
195
  awsOverlayApplications = [];
176
196
  awsOverlaySeenTypes = new Set();
197
+ awsPropertyOverlay = propertyOverlayByType();
198
+ awsPropertyOverlayApplications = [];
177
199
  // A fixture set is a trimmed subset, so an entry it never reaches is
178
200
  // expected; a run over the real zip has no such excuse.
179
201
  awsOverlayStrict = !opts.schemaSource;
@@ -0,0 +1,19 @@
1
+ {
2
+ "$comment": "Properties CloudFormation has shipped since the pinned Registry spec (src/spec/pin.ts) was accepted (chant #3953). Hand-maintained, merged into the raw schema at codegen time by src/codegen/property-overlay.ts. Each entry is copied from the live Registry schema; retire it when a pin bump (#3960) brings the property in; generation warns when it does.",
3
+ "entries": [
4
+ {
5
+ "type": "AWS::BedrockAgentCore::Runtime",
6
+ "property": "PlatformVersion",
7
+ "issue": 3953,
8
+ "source": "https://schema.cloudformation.us-east-1.amazonaws.com/CloudformationSchema.zip (aws-bedrockagentcore-runtime.json)",
9
+ "reviewed": "2026-10-12",
10
+ "schema": {
11
+ "type": "string",
12
+ "description": "The version of the runtime platform",
13
+ "minLength": 1,
14
+ "maxLength": 128,
15
+ "pattern": "^[^\\s]+$"
16
+ }
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ applyPropertyOverlay,
4
+ assertPropertyOverlayCoverage,
5
+ propertyOverlayEntries,
6
+ redundantPropertyOverlayWarnings,
7
+ type PropertyOverlayEntry,
8
+ } from "./property-overlay";
9
+
10
+ const entry = (over: Partial<PropertyOverlayEntry> = {}): PropertyOverlayEntry => ({
11
+ type: "AWS::Test::Resource",
12
+ property: "Version",
13
+ issue: 1,
14
+ source: "https://example.invalid",
15
+ reviewed: "2026-10-12",
16
+ schema: { type: "string", maxLength: 8 },
17
+ ...over,
18
+ });
19
+
20
+ const schema = (properties: Record<string, unknown> = { Name: { type: "string" } }) =>
21
+ JSON.stringify({ typeName: "AWS::Test::Resource", properties, additionalProperties: false });
22
+
23
+ describe("applyPropertyOverlay", () => {
24
+ test("adds a property the schema lacks, leaving the rest alone", () => {
25
+ const { data, applications } = applyPropertyOverlay(schema(), [entry()]);
26
+ expect(applications.map((a) => a.outcome)).toEqual(["applied"]);
27
+ const doc = JSON.parse(data as string);
28
+ expect(doc.properties.Version).toEqual({ type: "string", maxLength: 8 });
29
+ expect(doc.properties.Name).toEqual({ type: "string" });
30
+ });
31
+
32
+ test("the spec wins once it declares the property", () => {
33
+ const upstream = schema({ Version: { type: "string", maxLength: 128 } });
34
+ const { data, applications } = applyPropertyOverlay(upstream, [entry()]);
35
+ expect(data).toBe(upstream);
36
+ expect(applications.map((a) => a.outcome)).toEqual(["redundant"]);
37
+ expect(redundantPropertyOverlayWarnings(applications)).toHaveLength(1);
38
+ });
39
+
40
+ test("keeps the input's encoding", () => {
41
+ const { data } = applyPropertyOverlay(Buffer.from(schema()), [entry()]);
42
+ expect(Buffer.isBuffer(data)).toBe(true);
43
+ });
44
+ });
45
+
46
+ describe("assertPropertyOverlayCoverage", () => {
47
+ test("a strict run refuses an entry on a type it never saw", () => {
48
+ expect(() => assertPropertyOverlayCoverage([entry()], new Set(), true)).toThrow(/AWS::Test::Resource/);
49
+ expect(() => assertPropertyOverlayCoverage([entry()], new Set(), false)).not.toThrow();
50
+ });
51
+ });
52
+
53
+ describe("property-overlay.json", () => {
54
+ test("every entry names a top-level property and a schema with a type", () => {
55
+ for (const e of propertyOverlayEntries()) {
56
+ expect(e.type).toMatch(/^AWS::\w+::\w+$/);
57
+ expect(e.property).toMatch(/^[A-Z]\w*$/);
58
+ expect(typeof e.schema.type).toBe("string");
59
+ }
60
+ });
61
+ });