@intentius/chant-lexicon-aws 0.44.9 → 0.44.12

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.
@@ -0,0 +1,243 @@
1
+ /**
2
+ * AWS Signature Version 4, for the shared read transport (#1686).
3
+ *
4
+ * Two modules asked for this by name before it existed: `./read-client.ts` and
5
+ * cedar's `lexicons/cedar/src/avp/client.ts` both send a placeholder
6
+ * `Signature=unsigned` `Authorization` header that carries a credential scope
7
+ * and nothing else, and both say in prose that the real thing belongs here
8
+ * once rather than twice. Nothing else in the repo signs an AWS request — the
9
+ * appliers (`op/activities/aws-apply.ts`) are unsigned too, which is why the
10
+ * native paths have been emulator-scoped.
11
+ *
12
+ * ## No dependency
13
+ *
14
+ * SigV4 is four HMACs and two SHA-256s. `node:crypto` has both, so signing
15
+ * costs nothing in the dependency tree — which matters here, because the whole
16
+ * reason these transports are hand-written is to keep `@aws-sdk/*` and its
17
+ * thirty-odd transitive packages out of a lexicon.
18
+ *
19
+ * ## What is signed, and what is not
20
+ *
21
+ * Header signing only. Query-string (presigned-URL) signing has no caller: the
22
+ * read paths POST to a service endpoint, they do not hand out URLs.
23
+ *
24
+ * `host` is signed but never emitted. `fetch` computes `Host` from the URL and
25
+ * forbids overriding it, so the signer derives the same value from the URL it
26
+ * was given. A transport that rewrites the URL underneath the signer — a proxy
27
+ * that keeps the original `Host`, say — would invalidate the signature; the
28
+ * injectable-HTTP seam is for tests and emulators, not for URL rewriting.
29
+ */
30
+ import { createHash, createHmac } from "node:crypto";
31
+
32
+ const ALGORITHM = "AWS4-HMAC-SHA256";
33
+ const TERMINATOR = "aws4_request";
34
+
35
+ /** SHA-256 of the empty string — the payload hash of every bodyless request. */
36
+ export const EMPTY_PAYLOAD_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
37
+
38
+ /**
39
+ * Headers that must not be signed even when a caller sets them: `authorization`
40
+ * is the output, and the others are rewritten in transit by proxies and agents,
41
+ * so signing them produces intermittent 403s rather than security.
42
+ */
43
+ const UNSIGNABLE = new Set(["authorization", "connection", "expect", "user-agent", "x-amzn-trace-id"]);
44
+
45
+ /** A resolved credential set. `sessionToken` is present for STS/role credentials. */
46
+ export interface AwsCredentials {
47
+ accessKeyId: string;
48
+ secretAccessKey: string;
49
+ sessionToken?: string;
50
+ }
51
+
52
+ /**
53
+ * The credential injection seam: a function the caller supplies to decide, by
54
+ * whatever means it likes, what to sign with. Returning `undefined` means "this
55
+ * process has no credentials" and is respected as an answer, not overridden.
56
+ */
57
+ export type AwsCredentialResolver = () => AwsCredentials | undefined;
58
+
59
+ /** Either literal credentials or a resolver for them. */
60
+ export type AwsCredentialSource = AwsCredentials | AwsCredentialResolver;
61
+
62
+ /**
63
+ * Credentials for a request: explicit → environment → absent.
64
+ *
65
+ * A resolver function is authoritative — if one is injected and it declines,
66
+ * the environment is not consulted behind its back, because the point of
67
+ * injecting one is to control the answer. Literal credentials are likewise
68
+ * final. Only the no-source case falls through to `AWS_ACCESS_KEY_ID` /
69
+ * `AWS_SECRET_ACCESS_KEY` (plus `AWS_SESSION_TOKEN` when set), and a half-set
70
+ * environment — a key id with no secret — is absent rather than a signature
71
+ * that cannot verify.
72
+ *
73
+ * Deliberately not implemented: the profile file, IMDS, and the container
74
+ * credential endpoints. Each is a separate transport with its own failure and
75
+ * caching story; a caller that has those can resolve them itself and pass the
76
+ * result in, which is what the resolver seam is for.
77
+ */
78
+ export function resolveCredentials(
79
+ source?: AwsCredentialSource,
80
+ env: Record<string, string | undefined> = process.env,
81
+ ): AwsCredentials | undefined {
82
+ if (typeof source === "function") return source();
83
+ if (source) return source;
84
+ const accessKeyId = env.AWS_ACCESS_KEY_ID;
85
+ const secretAccessKey = env.AWS_SECRET_ACCESS_KEY;
86
+ if (!accessKeyId || !secretAccessKey) return undefined;
87
+ return {
88
+ accessKeyId,
89
+ secretAccessKey,
90
+ ...(env.AWS_SESSION_TOKEN ? { sessionToken: env.AWS_SESSION_TOKEN } : {}),
91
+ };
92
+ }
93
+
94
+ /** One request to sign. `headers` are the caller's; the signer adds its own. */
95
+ export interface SigV4Request {
96
+ method: string;
97
+ /** Absolute URL. Its host is signed and its path/query are canonicalized. */
98
+ url: string;
99
+ headers: Record<string, string>;
100
+ body: string;
101
+ /** Service name as it appears in the credential scope (`cloudformation`, `cloudcontrolapi`, …). */
102
+ service: string;
103
+ region: string;
104
+ credentials: AwsCredentials;
105
+ /** Signing clock. Injected by tests; otherwise now. */
106
+ now?: Date;
107
+ }
108
+
109
+ /** `YYYYMMDDTHHMMSSZ` — the `X-Amz-Date` format, which is ISO-8601 basic. */
110
+ export function amzDate(date: Date): string {
111
+ return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
112
+ }
113
+
114
+ /** Lowercase hex SHA-256. */
115
+ export function sha256Hex(payload: string): string {
116
+ return createHash("sha256").update(payload, "utf8").digest("hex");
117
+ }
118
+
119
+ function hmac(key: Buffer | string, data: string): Buffer {
120
+ return createHmac("sha256", key).update(data, "utf8").digest();
121
+ }
122
+
123
+ /**
124
+ * The signing key: four chained HMACs from the secret, so the key on the wire
125
+ * is scoped to one day, one region and one service rather than being the
126
+ * account secret itself.
127
+ */
128
+ export function signingKey(secretAccessKey: string, day: string, region: string, service: string): Buffer {
129
+ const dateKey = hmac(`AWS4${secretAccessKey}`, day);
130
+ const regionKey = hmac(dateKey, region);
131
+ const serviceKey = hmac(regionKey, service);
132
+ return hmac(serviceKey, TERMINATOR);
133
+ }
134
+
135
+ /**
136
+ * RFC 3986 percent-encoding. `encodeURIComponent` leaves `!'()*` alone and AWS
137
+ * does not, which is the difference between a signature that verifies and one
138
+ * that does not for any path or parameter containing them.
139
+ */
140
+ function encodeRfc3986(value: string): string {
141
+ return encodeURIComponent(value).replace(
142
+ /[!'()*]/g,
143
+ (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
144
+ );
145
+ }
146
+
147
+ /**
148
+ * The canonical path.
149
+ *
150
+ * Every caller of this module POSTs to `/`, so the one place implementations
151
+ * genuinely diverge — S3 signs the path once-encoded, every other service signs
152
+ * it twice-encoded — never arises. Segments are encoded once; a future caller
153
+ * with a real path on a non-S3 service is the moment to revisit that.
154
+ */
155
+ function canonicalPath(pathname: string): string {
156
+ if (!pathname || pathname === "/") return "/";
157
+ return pathname.split("/").map(encodeRfc3986).join("/");
158
+ }
159
+
160
+ /** Query parameters sorted by name, then by value, each side encoded. */
161
+ function canonicalQuery(search: URLSearchParams): string {
162
+ const pairs: Array<[string, string]> = [];
163
+ search.forEach((value, name) => pairs.push([encodeRfc3986(name), encodeRfc3986(value)]));
164
+ pairs.sort((a, b) => (a[0] === b[0] ? (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0) : a[0] < b[0] ? -1 : 1));
165
+ return pairs.map(([name, value]) => `${name}=${value}`).join("&");
166
+ }
167
+
168
+ /**
169
+ * Header names lowercased, values trimmed with internal whitespace runs
170
+ * collapsed, sorted by name. Returns the canonical block and the `;`-joined
171
+ * signed-header list that has to agree with it exactly.
172
+ */
173
+ export function canonicalHeaders(headers: Record<string, string>): { canonical: string; signed: string } {
174
+ const entries = Object.entries(headers)
175
+ .map(([name, value]) => [name.toLowerCase().trim(), value.trim().replace(/\s+/g, " ")] as const)
176
+ .filter(([name]) => !UNSIGNABLE.has(name))
177
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
178
+ return {
179
+ canonical: entries.map(([name, value]) => `${name}:${value}\n`).join(""),
180
+ signed: entries.map(([name]) => name).join(";"),
181
+ };
182
+ }
183
+
184
+ /** The canonical request, verbatim as it is hashed into the string to sign. */
185
+ export function canonicalRequest(
186
+ method: string,
187
+ url: URL,
188
+ headers: Record<string, string>,
189
+ payloadHash: string,
190
+ ): { canonical: string; signed: string } {
191
+ const { canonical: headerBlock, signed } = canonicalHeaders(headers);
192
+ return {
193
+ canonical: [
194
+ method.toUpperCase(),
195
+ canonicalPath(url.pathname),
196
+ canonicalQuery(url.searchParams),
197
+ headerBlock,
198
+ signed,
199
+ payloadHash,
200
+ ].join("\n"),
201
+ signed,
202
+ };
203
+ }
204
+
205
+ /** The string to sign: algorithm, timestamp, scope, hashed canonical request. */
206
+ export function stringToSign(timestamp: string, scope: string, canonical: string): string {
207
+ return [ALGORITHM, timestamp, scope, sha256Hex(canonical)].join("\n");
208
+ }
209
+
210
+ /**
211
+ * The caller's headers plus everything SigV4 adds: `x-amz-date`,
212
+ * `x-amz-content-sha256`, `x-amz-security-token` when the credentials are
213
+ * temporary, and the `Authorization` header itself.
214
+ *
215
+ * `host` is signed (AWS requires it) but not returned — see the module header.
216
+ */
217
+ export function signRequest(request: SigV4Request): Record<string, string> {
218
+ const url = new URL(request.url);
219
+ const timestamp = amzDate(request.now ?? new Date());
220
+ const day = timestamp.slice(0, 8);
221
+ const payloadHash = request.body.length === 0 ? EMPTY_PAYLOAD_SHA256 : sha256Hex(request.body);
222
+
223
+ const signable: Record<string, string> = {
224
+ ...request.headers,
225
+ host: url.host,
226
+ "x-amz-date": timestamp,
227
+ "x-amz-content-sha256": payloadHash,
228
+ ...(request.credentials.sessionToken ? { "x-amz-security-token": request.credentials.sessionToken } : {}),
229
+ };
230
+
231
+ const { canonical, signed } = canonicalRequest(request.method, url, signable, payloadHash);
232
+ const scope = `${day}/${request.region}/${request.service}/${TERMINATOR}`;
233
+ const key = signingKey(request.credentials.secretAccessKey, day, request.region, request.service);
234
+ const signature = hmac(key, stringToSign(timestamp, scope, canonical)).toString("hex");
235
+
236
+ const { host: _host, ...emitted } = signable;
237
+ return {
238
+ ...emitted,
239
+ authorization:
240
+ `${ALGORITHM} Credential=${request.credentials.accessKeyId}/${scope}, ` +
241
+ `SignedHeaders=${signed}, Signature=${signature}`,
242
+ };
243
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The identity fallback (#1647) — unit half. The carve state in miniature: a
3
+ * declared Bucket absent from every stack, present live, named precisely by
4
+ * its own `BucketName`. The Cloud Control edge is the injected `http` seam
5
+ * `read-client` already exposes.
6
+ */
7
+ import { describe, test, expect } from "vitest";
8
+ import { declaredIdentifier, observeByIdentity } from "./identity-observe";
9
+
10
+ const entity = (entityType: string, props: Record<string, unknown>) => ({ entityType, props });
11
+
12
+ describe("declaredIdentifier (#1647)", () => {
13
+ test("a scalar primary identifier reads straight off the props", () => {
14
+ expect(declaredIdentifier("AWS::S3::Bucket", { BucketName: "acme-platform-assets-prod" })).toBe(
15
+ "acme-platform-assets-prod",
16
+ );
17
+ });
18
+
19
+ test("absent, empty, or non-scalar identifier parts refuse — a Ref is not an identity", () => {
20
+ expect(declaredIdentifier("AWS::S3::Bucket", {})).toBeUndefined();
21
+ expect(declaredIdentifier("AWS::S3::Bucket", { BucketName: "" })).toBeUndefined();
22
+ expect(declaredIdentifier("AWS::S3::Bucket", { BucketName: { Ref: "Other" } })).toBeUndefined();
23
+ });
24
+
25
+ test("an unknown type has no identifier to spell", () => {
26
+ expect(declaredIdentifier("AWS::Made::Up", { Name: "x" })).toBeUndefined();
27
+ });
28
+ });
29
+
30
+ /** A Cloud Control `GetResource` answer, double-encoded the way the API is. */
31
+ const ccFound = (identifier: string, properties: Record<string, unknown>) => ({
32
+ status: 200,
33
+ text: JSON.stringify({
34
+ ResourceDescription: { Identifier: identifier, Properties: JSON.stringify(properties) },
35
+ }),
36
+ });
37
+
38
+ const ccError = (type: string, message: string) => ({
39
+ status: 400,
40
+ text: JSON.stringify({ __type: type, message }),
41
+ });
42
+
43
+ describe("observeByIdentity (#1647)", () => {
44
+ const bucket = new Map([["assets", entity("AWS::S3::Bucket", { BucketName: "acme-platform-assets-prod" })]]);
45
+
46
+ test("a live identifier-named resource reads OBSERVED — external, foreign, never absent", async () => {
47
+ const calls: Array<{ url: string; body: string }> = [];
48
+ const { resources, queried } = await observeByIdentity(["assets"], bucket, {}, {
49
+ http: async (url, init) => {
50
+ calls.push({ url, body: init.body });
51
+ return ccFound("acme-platform-assets-prod", { BucketName: "acme-platform-assets-prod", SecretToken: "s3cr3t" });
52
+ },
53
+ });
54
+ expect(calls).toHaveLength(1);
55
+ expect(calls[0].url).toContain("cloudcontrol");
56
+ expect(resources.assets).toMatchObject({
57
+ type: "AWS::S3::Bucket",
58
+ physicalId: "acme-platform-assets-prod",
59
+ status: "EXTERNAL",
60
+ ownership: "foreign",
61
+ });
62
+ // Live properties ride along, sensitive keys scrubbed like stack outputs.
63
+ expect(resources.assets.attributes).toMatchObject({ BucketName: "acme-platform-assets-prod", SecretToken: "[REDACTED]" });
64
+ // #1620: the attempted address is on the wire whatever the verdict.
65
+ expect(queried.assets).toContain("AWS::S3::Bucket");
66
+ expect(queried.assets).toContain("acme-platform-assets-prod");
67
+ });
68
+
69
+ test("a genuine miss keeps the stack's absent verdict, with the address still recorded", async () => {
70
+ const { resources, queried } = await observeByIdentity(["assets"], bucket, {}, {
71
+ http: async () => ccError("ResourceNotFoundException", "no such bucket"),
72
+ });
73
+ expect(resources.assets).toBeUndefined();
74
+ expect(queried.assets).toBeDefined();
75
+ });
76
+
77
+ test("an emulator without Cloud Control at all keeps the absent verdict — never a hole, or pre-first-apply plans stop proposing create", async () => {
78
+ const { resources } = await observeByIdentity(["assets"], bucket, {}, {
79
+ http: async () => ccError("UnsupportedOperation", "not supported"),
80
+ });
81
+ expect(resources.assets).toBeUndefined();
82
+ });
83
+
84
+ // Floci serves ListResources but answers UnsupportedOperation for
85
+ // GetResource (read-client's own note) — and the emulator is where the carve
86
+ // walkthrough films the observe beat. Verified against Floci 1.5.34 on the
87
+ // behold demo: the list leg is what turns the miss into a read.
88
+ test("GetResource-unsupported falls back to ListResources and matches the identifier (Floci)", async () => {
89
+ const targets: string[] = [];
90
+ const { resources } = await observeByIdentity(["assets"], bucket, {}, {
91
+ http: async (_url, init) => {
92
+ const target = (init.headers as Record<string, string>)["x-amz-target"] ?? "";
93
+ targets.push(target);
94
+ if (target.endsWith("GetResource")) return ccError("UnsupportedOperation", "Operation GetResource is not supported.");
95
+ return {
96
+ status: 200,
97
+ text: JSON.stringify({
98
+ ResourceDescriptions: [
99
+ { Identifier: "some-other-bucket", Properties: JSON.stringify({ BucketName: "some-other-bucket" }) },
100
+ { Identifier: "acme-platform-assets-prod", Properties: JSON.stringify({ BucketName: "acme-platform-assets-prod" }) },
101
+ ],
102
+ }),
103
+ };
104
+ },
105
+ });
106
+ expect(targets.some((t) => t.endsWith("ListResources"))).toBe(true);
107
+ expect(resources.assets).toMatchObject({
108
+ type: "AWS::S3::Bucket",
109
+ physicalId: "acme-platform-assets-prod",
110
+ status: "EXTERNAL",
111
+ ownership: "foreign",
112
+ });
113
+ });
114
+
115
+ test("the list leg missing the identifier keeps the absent verdict", async () => {
116
+ const { resources } = await observeByIdentity(["assets"], bucket, {}, {
117
+ http: async (_url, init) => {
118
+ const target = (init.headers as Record<string, string>)["x-amz-target"] ?? "";
119
+ if (target.endsWith("GetResource")) return ccError("UnsupportedOperation", "not supported");
120
+ return { status: 200, text: JSON.stringify({ ResourceDescriptions: [] }) };
121
+ },
122
+ });
123
+ expect(resources.assets).toBeUndefined();
124
+ });
125
+
126
+ test("entities the stack already answered for are never re-read", async () => {
127
+ let called = 0;
128
+ const already = { assets: { type: "AWS::S3::Bucket", physicalId: "b", status: "CREATE_COMPLETE" } };
129
+ const { resources } = await observeByIdentity(["assets"], bucket, already, {
130
+ http: async () => ((called += 1), ccFound("x", {})),
131
+ });
132
+ expect(called).toBe(0);
133
+ expect(resources).toEqual({});
134
+ });
135
+
136
+ test("entities with no spellable identifier are skipped without a call", async () => {
137
+ let called = 0;
138
+ const anonymous = new Map([["q", entity("AWS::SQS::Queue", {})]]);
139
+ const { resources, queried } = await observeByIdentity(["q"], anonymous, {}, {
140
+ http: async () => ((called += 1), ccFound("x", {})),
141
+ });
142
+ expect(called).toBe(0);
143
+ expect(resources).toEqual({});
144
+ expect(queried).toEqual({});
145
+ });
146
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The identity fallback on the stack read (#1647).
3
+ *
4
+ * `describeResources` asks CloudFormation exactly one question — is there a
5
+ * logical id named <entity> in this stack? — which reads a freshly
6
+ * carve-emitted, still-Terraform-owned resource as confirmed-absent even
7
+ * though the declared `BucketName` names it precisely. When a declared entity
8
+ * is absent from the stack AND its props spell the type's full primary
9
+ * identifier (the spec knowledge the codegen already compiled into
10
+ * `lexicon-aws.json`), ask Cloud Control for it by identity. Found means
11
+ * OBSERVED: `ownership: "foreign"` (it exists and something other than this
12
+ * stack owns it — Terraform, a console hand, another tool) and
13
+ * `status: "EXTERNAL"` (live outside the stack, deliberately not a
14
+ * CloudFormation status word).
15
+ *
16
+ * Best-effort ON TOP of a stack answer, never instead of one: a genuine miss
17
+ * (`ResourceNotFoundException`) keeps the stack's absent verdict, and so does
18
+ * every other refusal (`UnsupportedOperation` — a Floci without Cloud
19
+ * Control — credentials, throttling). The stack read succeeded; absence at
20
+ * stack scope is an honest verdict this fallback can refine but must never
21
+ * degrade into a hole, or a pre-first-apply plan would stop proposing
22
+ * `create` the moment the emulator lacks Cloud Control.
23
+ */
24
+ import { createRequire } from "module";
25
+ import { AwsReadError, getResource, listResources, type AwsReadClientOptions, type CloudControlDescription } from "./api/read-client";
26
+ import type { ResourceMetadata } from "@intentius/chant/lexicon";
27
+
28
+ const require = createRequire(import.meta.url);
29
+
30
+ interface LexiconEntry {
31
+ resourceType: string;
32
+ kind: string;
33
+ primaryIdentifier?: string[];
34
+ }
35
+
36
+ let byResourceType: Map<string, LexiconEntry> | undefined;
37
+ function manifestByType(): Map<string, LexiconEntry> {
38
+ if (!byResourceType) {
39
+ const manifest = require("./generated/lexicon-aws.json") as Record<string, LexiconEntry>;
40
+ byResourceType = new Map(Object.values(manifest).map((e) => [e.resourceType, e]));
41
+ }
42
+ return byResourceType;
43
+ }
44
+
45
+ /**
46
+ * The Cloud Control identifier the declared props spell, or undefined when the
47
+ * type has no primary identifier on record or any part of it is absent or
48
+ * non-scalar (a Ref, an intrinsic, a server-assigned name). Multi-part
49
+ * identifiers join with `|`, Cloud Control's own separator.
50
+ */
51
+ export function declaredIdentifier(entityType: string, props: Record<string, unknown>): string | undefined {
52
+ const entry = manifestByType().get(entityType);
53
+ const parts = entry?.primaryIdentifier;
54
+ if (!entry || entry.kind !== "resource" || !parts || parts.length === 0) return undefined;
55
+ const values: string[] = [];
56
+ for (const part of parts) {
57
+ const v = props[part];
58
+ if (typeof v !== "string" && typeof v !== "number") return undefined;
59
+ const s = String(v);
60
+ if (!s) return undefined;
61
+ values.push(s);
62
+ }
63
+ return values.join("|");
64
+ }
65
+
66
+ /**
67
+ * `GetResource`, with a `ListResources` + identifier-match fallback when the
68
+ * endpoint says the operation itself is unsupported. That is Floci's exact
69
+ * answer (read-client's own note) while it serves `ListResources` fine — and
70
+ * an emulator is exactly where the carve state lives, so without this leg the
71
+ * fallback proves itself everywhere except the one place the walkthrough
72
+ * films it. Every other refusal propagates: the caller decides what a failed
73
+ * refinement means.
74
+ */
75
+ async function readByIdentity(
76
+ typeName: string,
77
+ identifier: string,
78
+ client: AwsReadClientOptions,
79
+ ): Promise<CloudControlDescription | null> {
80
+ try {
81
+ return await getResource(typeName, identifier, client);
82
+ } catch (err) {
83
+ if (!(err instanceof AwsReadError) || err.code !== "UnsupportedOperation") throw err;
84
+ const listed = await listResources(typeName, client);
85
+ return listed.find((d) => d.identifier === identifier) ?? null;
86
+ }
87
+ }
88
+
89
+ /** The same scrub the stack-output path applies, on live property KEYS. */
90
+ function redactSensitive(properties: Record<string, unknown>): Record<string, unknown> | undefined {
91
+ const out: Record<string, unknown> = {};
92
+ for (const [key, value] of Object.entries(properties)) {
93
+ out[key] = /password|secret|token/i.test(key) ? "[REDACTED]" : value;
94
+ }
95
+ return Object.keys(out).length > 0 ? out : undefined;
96
+ }
97
+
98
+ /**
99
+ * Identity-read every entity the stack did not answer for and whose props
100
+ * spell an identifier. `already` is the stack's answer — an entity the stack
101
+ * DID return is never re-read. The `queried` map records the address each
102
+ * attempted read was issued against (#1620), whatever the verdict.
103
+ */
104
+ export async function observeByIdentity(
105
+ entityNames: string[],
106
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }> | undefined,
107
+ already: Record<string, ResourceMetadata>,
108
+ client: AwsReadClientOptions,
109
+ ): Promise<{ resources: Record<string, ResourceMetadata>; queried: Record<string, string> }> {
110
+ const resources: Record<string, ResourceMetadata> = {};
111
+ const queried: Record<string, string> = {};
112
+ if (!entities) return { resources, queried };
113
+ for (const name of entityNames) {
114
+ if (already[name]) continue;
115
+ const entity = entities.get(name);
116
+ if (!entity) continue;
117
+ const identifier = declaredIdentifier(entity.entityType, entity.props ?? {});
118
+ if (!identifier) continue;
119
+ queried[name] = `cloudcontrol:GetResource:${entity.entityType}:${identifier}`;
120
+ try {
121
+ const found = await readByIdentity(entity.entityType, identifier, client);
122
+ if (!found) continue;
123
+ resources[name] = {
124
+ type: entity.entityType,
125
+ physicalId: found.identifier || identifier,
126
+ status: "EXTERNAL",
127
+ ownership: "foreign",
128
+ ...(redactSensitive(found.properties) ? { attributes: redactSensitive(found.properties) } : {}),
129
+ };
130
+ } catch {
131
+ // Refusals of every kind keep the stack's verdict — see the module
132
+ // comment for why a failed refinement must not become a hole.
133
+ continue;
134
+ }
135
+ }
136
+ return { resources, queried };
137
+ }
package/src/index.ts CHANGED
@@ -52,8 +52,21 @@ export {
52
52
  type AwsReadClientOptions,
53
53
  type CloudControlDescription,
54
54
  type StackResource,
55
+ type AwsCredentials,
56
+ type AwsCredentialResolver,
57
+ type AwsCredentialSource,
55
58
  } from "./api/read-client";
56
59
 
60
+ // SigV4 (#1686). Exported as its own surface because it is deliberately not
61
+ // specific to this transport: cedar's AVP client is the next caller, and the
62
+ // point of the module is that there is one signer rather than one per lexicon.
63
+ export {
64
+ signRequest,
65
+ resolveCredentials,
66
+ EMPTY_PAYLOAD_SHA256,
67
+ type SigV4Request,
68
+ } from "./api/sigv4";
69
+
57
70
  // Intrinsics
58
71
  export {
59
72
  Sub,
@@ -143,6 +143,75 @@ describe("aws lifecycle integration (#163)", () => {
143
143
  expect(cs2.entries.find((e) => e.name === "MyBucket")!.action).toBe("noop");
144
144
  });
145
145
 
146
+ // #1647 — the carve state, end to end: terraform applied the bucket, carve
147
+ // emitted a carveout declaring it by BucketName, and no CFN stack has ever
148
+ // heard of it. The stack read alone said confirmed-absent (missing → a plan
149
+ // proposing create for a bucket that EXISTS); the identity fallback asks
150
+ // Cloud Control by the declared identifier and the verdict comes back
151
+ // observed.
152
+ test("identity fallback: a declared, stack-absent, live resource reads observed, not missing (#1647)", async () => {
153
+ const routeBoth = (cc: { status?: number; text: string }): void => {
154
+ vi.spyOn(globalThis, "fetch").mockImplementation((async (url: string, init: { body: string; headers?: Record<string, string> }) => {
155
+ const target = init.headers?.["x-amz-target"] ?? "";
156
+ if (target.endsWith("GetResource")) return { status: cc.status ?? 200, text: () => Promise.resolve(cc.text) };
157
+ const action = new URLSearchParams(init.body).get("Action") ?? "";
158
+ return {
159
+ status: 200,
160
+ text: () => Promise.resolve(action === "DescribeStackResources" ? stackResourcesXml([]) : stackOutputsXml()),
161
+ };
162
+ }) as unknown as typeof fetch);
163
+ };
164
+
165
+ const entities = new Map([
166
+ ["assets", { entityType: "AWS::S3::Bucket", props: { BucketName: "acme-platform-assets-prod" } }],
167
+ ]);
168
+
169
+ routeBoth({
170
+ text: JSON.stringify({
171
+ ResourceDescription: {
172
+ Identifier: "acme-platform-assets-prod",
173
+ Properties: JSON.stringify({ BucketName: "acme-platform-assets-prod" }),
174
+ },
175
+ }),
176
+ });
177
+ const observed = normalizeObservation(
178
+ await awsPlugin.describeResources!({
179
+ environment: "prod",
180
+ buildOutput: "",
181
+ entityNames: ["assets"],
182
+ entities,
183
+ }),
184
+ );
185
+ expect(observed.resources.assets).toMatchObject({
186
+ type: "AWS::S3::Bucket",
187
+ status: "EXTERNAL",
188
+ ownership: "foreign",
189
+ });
190
+ // #1620: the identity read's address rides the observation.
191
+ expect(observed.queried.assets).toContain("acme-platform-assets-prod");
192
+
193
+ // Through the change set: declared + live → noop, never create. `foreign`
194
+ // ownership never escalates anything (#120's rule holds).
195
+ const cs = buildChangeSet("prod", { declared: new Set(["assets"]), observedNow: observed.resources, observedThen: undefined });
196
+ expect(cs.entries.find((e) => e.name === "assets")!.action).toBe("noop");
197
+
198
+ // An emulator without Cloud Control keeps today's verdict exactly: absent,
199
+ // create proposed — the fallback must not turn pre-first-apply into a hole.
200
+ routeBoth({ status: 400, text: JSON.stringify({ __type: "UnsupportedOperation", message: "not supported" }) });
201
+ const degraded = normalizeObservation(
202
+ await awsPlugin.describeResources!({
203
+ environment: "prod",
204
+ buildOutput: "",
205
+ entityNames: ["assets"],
206
+ entities,
207
+ }),
208
+ );
209
+ expect(degraded.resources.assets).toBeUndefined();
210
+ expect(degraded.unobserved.assets).toBeUndefined();
211
+ const cs2 = buildChangeSet("prod", { declared: new Set(["assets"]), observedNow: degraded.resources, observedThen: undefined });
212
+ expect(cs2.entries.find((e) => e.name === "assets")!.action).toBe("create");
213
+ });
214
+
146
215
  describe("describeStackStatus (#57 — per-component stack presence)", () => {
147
216
  const err = (stderr: string) => ({ stdout: "", stderr, exitCode: 255 });
148
217
 
@@ -4,6 +4,14 @@
4
4
  * emulator lifecycle (`flociUp`/`flociDown`) and the native CloudFormation
5
5
  * applier (`awsApply`), which calls the CloudFormation API directly rather than
6
6
  * shelling `aws` — the direct twin of `azApply`/`gcpApply`.
7
+ *
8
+ * The registry keys every exported *function* here by its name, so only the
9
+ * activities themselves belong in this barrel. `awsAgentCoreFetchTrace`'s
10
+ * helpers — the normalizer, the JSON coercion, the `ListEvents` walk — stay
11
+ * importable from `@intentius/chant-lexicon-aws/agentcore/trace-fetch` rather
12
+ * than being registered as activities nobody would ever name in a step, and the
13
+ * pure renderer lives one module further out again, in
14
+ * `@intentius/chant-lexicon-aws/agentcore/trace-render`.
7
15
  */
8
16
  export {
9
17
  flociUp,
@@ -35,3 +43,10 @@ export {
35
43
  isTerminalStatus,
36
44
  } from "./aws-apply";
37
45
  export type { AwsApplyArgs, AwsHttp } from "./aws-apply";
46
+
47
+ export { awsAgentCoreFetchTrace } from "../../agentcore/trace-fetch";
48
+ export type {
49
+ AgentCoreTraceSource,
50
+ AwsAgentCoreFetchTraceArgs,
51
+ AwsAgentCoreFetchTraceResult,
52
+ } from "../../agentcore/trace-fetch";