local-serverless-stack 1.0.1 → 1.1.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.
Files changed (183) hide show
  1. package/CHANGELOG.md +125 -3
  2. package/README.md +75 -26
  3. package/bin/cli.js +81 -15
  4. package/bin/i18n.js +30 -3
  5. package/dist/client/index.d.ts +1 -1
  6. package/dist/client/index.d.ts.map +1 -1
  7. package/dist/client/namespaces/services.d.ts +46 -0
  8. package/dist/client/namespaces/services.d.ts.map +1 -1
  9. package/dist/client/namespaces/services.js +1 -0
  10. package/dist/client/namespaces/services.js.map +1 -1
  11. package/dist/mcp/tools.d.ts.map +1 -1
  12. package/dist/mcp/tools.js +17 -0
  13. package/dist/mcp/tools.js.map +1 -1
  14. package/dist/server/engine/backends/self-backend.d.ts +26 -1
  15. package/dist/server/engine/backends/self-backend.d.ts.map +1 -1
  16. package/dist/server/engine/backends/self-backend.js +104 -11
  17. package/dist/server/engine/backends/self-backend.js.map +1 -1
  18. package/dist/server/engine/emulators/dynamodb/expressions/parser.d.ts.map +1 -1
  19. package/dist/server/engine/emulators/dynamodb/expressions/parser.js +12 -1
  20. package/dist/server/engine/emulators/dynamodb/expressions/parser.js.map +1 -1
  21. package/dist/server/engine/emulators/dynamodb/expressions/reserved-words.d.ts +3 -0
  22. package/dist/server/engine/emulators/dynamodb/expressions/reserved-words.d.ts.map +1 -0
  23. package/dist/server/engine/emulators/dynamodb/expressions/reserved-words.js +78 -0
  24. package/dist/server/engine/emulators/dynamodb/expressions/reserved-words.js.map +1 -0
  25. package/dist/server/engine/emulators/dynamodb/index.d.ts +11 -0
  26. package/dist/server/engine/emulators/dynamodb/index.d.ts.map +1 -1
  27. package/dist/server/engine/emulators/dynamodb/index.js +101 -7
  28. package/dist/server/engine/emulators/dynamodb/index.js.map +1 -1
  29. package/dist/server/engine/emulators/dynamodb/query.d.ts +1 -0
  30. package/dist/server/engine/emulators/dynamodb/query.d.ts.map +1 -1
  31. package/dist/server/engine/emulators/dynamodb/query.js +34 -6
  32. package/dist/server/engine/emulators/dynamodb/query.js.map +1 -1
  33. package/dist/server/engine/emulators/s3/index.d.ts +10 -0
  34. package/dist/server/engine/emulators/s3/index.d.ts.map +1 -1
  35. package/dist/server/engine/emulators/s3/index.js +369 -25
  36. package/dist/server/engine/emulators/s3/index.js.map +1 -1
  37. package/dist/server/engine/emulators/sqs/index.d.ts +2 -0
  38. package/dist/server/engine/emulators/sqs/index.d.ts.map +1 -1
  39. package/dist/server/engine/emulators/sqs/index.js +70 -7
  40. package/dist/server/engine/emulators/sqs/index.js.map +1 -1
  41. package/dist/server/engine/emulators/sqs/queue.d.ts +2 -1
  42. package/dist/server/engine/emulators/sqs/queue.d.ts.map +1 -1
  43. package/dist/server/engine/emulators/sqs/queue.js +68 -35
  44. package/dist/server/engine/emulators/sqs/queue.js.map +1 -1
  45. package/dist/server/engine/emulators/ssm/index.d.ts +31 -0
  46. package/dist/server/engine/emulators/ssm/index.d.ts.map +1 -0
  47. package/dist/server/engine/emulators/ssm/index.js +670 -0
  48. package/dist/server/engine/emulators/ssm/index.js.map +1 -0
  49. package/dist/server/engine/http/errors.d.ts +1 -0
  50. package/dist/server/engine/http/errors.d.ts.map +1 -1
  51. package/dist/server/engine/http/errors.js +13 -5
  52. package/dist/server/engine/http/errors.js.map +1 -1
  53. package/dist/server/engine/http/is-aws-request.d.ts.map +1 -1
  54. package/dist/server/engine/http/is-aws-request.js +13 -3
  55. package/dist/server/engine/http/is-aws-request.js.map +1 -1
  56. package/dist/server/engine/http/protocols/aws-json.d.ts +1 -0
  57. package/dist/server/engine/http/protocols/aws-json.d.ts.map +1 -1
  58. package/dist/server/engine/http/protocols/aws-json.js +13 -0
  59. package/dist/server/engine/http/protocols/aws-json.js.map +1 -1
  60. package/dist/server/engine/http/reset.d.ts +4 -0
  61. package/dist/server/engine/http/reset.d.ts.map +1 -0
  62. package/dist/server/engine/http/reset.js +17 -0
  63. package/dist/server/engine/http/reset.js.map +1 -0
  64. package/dist/server/engine/http/router.d.ts +1 -0
  65. package/dist/server/engine/http/router.d.ts.map +1 -1
  66. package/dist/server/engine/http/router.js +185 -34
  67. package/dist/server/engine/http/router.js.map +1 -1
  68. package/dist/server/engine/types.d.ts +2 -1
  69. package/dist/server/engine/types.d.ts.map +1 -1
  70. package/dist/server/routes/apis.d.ts.map +1 -1
  71. package/dist/server/routes/apis.js +6 -0
  72. package/dist/server/routes/apis.js.map +1 -1
  73. package/dist/server/routes/config.d.ts.map +1 -1
  74. package/dist/server/routes/config.js +19 -1
  75. package/dist/server/routes/config.js.map +1 -1
  76. package/dist/server/routes/services.d.ts.map +1 -1
  77. package/dist/server/routes/services.js +49 -1
  78. package/dist/server/routes/services.js.map +1 -1
  79. package/dist/server/runtime/runtime-worker.js +33 -3
  80. package/dist/server/runtime/runtime-worker.js.map +1 -1
  81. package/dist/server/services/api-gateway-events.d.ts +14 -0
  82. package/dist/server/services/api-gateway-events.d.ts.map +1 -1
  83. package/dist/server/services/api-gateway-events.js +114 -8
  84. package/dist/server/services/api-gateway-events.js.map +1 -1
  85. package/dist/server/services/artifact-staleness.d.ts +20 -0
  86. package/dist/server/services/artifact-staleness.d.ts.map +1 -0
  87. package/dist/server/services/artifact-staleness.js +115 -0
  88. package/dist/server/services/artifact-staleness.js.map +1 -0
  89. package/dist/server/services/authorizer-service.d.ts +34 -5
  90. package/dist/server/services/authorizer-service.d.ts.map +1 -1
  91. package/dist/server/services/authorizer-service.js +0 -0
  92. package/dist/server/services/authorizer-service.js.map +1 -1
  93. package/dist/server/services/cfn-intrinsics.d.ts +63 -0
  94. package/dist/server/services/cfn-intrinsics.d.ts.map +1 -0
  95. package/dist/server/services/cfn-intrinsics.js +403 -0
  96. package/dist/server/services/cfn-intrinsics.js.map +1 -0
  97. package/dist/server/services/cfn-physical-values.d.ts +51 -0
  98. package/dist/server/services/cfn-physical-values.d.ts.map +1 -0
  99. package/dist/server/services/cfn-physical-values.js +186 -0
  100. package/dist/server/services/cfn-physical-values.js.map +1 -0
  101. package/dist/server/services/cloudformation-parser.d.ts +26 -3
  102. package/dist/server/services/cloudformation-parser.d.ts.map +1 -1
  103. package/dist/server/services/cloudformation-parser.js +76 -4
  104. package/dist/server/services/cloudformation-parser.js.map +1 -1
  105. package/dist/server/services/config-manager.d.ts +24 -2
  106. package/dist/server/services/config-manager.d.ts.map +1 -1
  107. package/dist/server/services/config-manager.js +60 -7
  108. package/dist/server/services/config-manager.js.map +1 -1
  109. package/dist/server/services/gateway-manager.d.ts.map +1 -1
  110. package/dist/server/services/gateway-manager.js +52 -20
  111. package/dist/server/services/gateway-manager.js.map +1 -1
  112. package/dist/server/services/lambda-runtime-manager.d.ts +1 -0
  113. package/dist/server/services/lambda-runtime-manager.d.ts.map +1 -1
  114. package/dist/server/services/lambda-runtime-manager.js +36 -14
  115. package/dist/server/services/lambda-runtime-manager.js.map +1 -1
  116. package/dist/server/services/raw-api-assembler.d.ts +1 -1
  117. package/dist/server/services/raw-api-assembler.d.ts.map +1 -1
  118. package/dist/server/services/raw-api-assembler.js +18 -8
  119. package/dist/server/services/raw-api-assembler.js.map +1 -1
  120. package/dist/server/services/resource-provisioner.d.ts +12 -0
  121. package/dist/server/services/resource-provisioner.d.ts.map +1 -1
  122. package/dist/server/services/resource-provisioner.js +128 -13
  123. package/dist/server/services/resource-provisioner.js.map +1 -1
  124. package/dist/server/services/serverless-state-parser.d.ts +45 -2
  125. package/dist/server/services/serverless-state-parser.d.ts.map +1 -1
  126. package/dist/server/services/serverless-state-parser.js +217 -29
  127. package/dist/server/services/serverless-state-parser.js.map +1 -1
  128. package/dist/server/services/service-graph.d.ts +76 -0
  129. package/dist/server/services/service-graph.d.ts.map +1 -0
  130. package/dist/server/services/service-graph.js +914 -0
  131. package/dist/server/services/service-graph.js.map +1 -0
  132. package/dist/server/services/service-registrar.d.ts +4 -0
  133. package/dist/server/services/service-registrar.d.ts.map +1 -1
  134. package/dist/server/services/service-registrar.js +129 -55
  135. package/dist/server/services/service-registrar.js.map +1 -1
  136. package/dist/server/services/source-watcher.d.ts +1 -0
  137. package/dist/server/services/source-watcher.d.ts.map +1 -1
  138. package/dist/server/services/source-watcher.js +19 -1
  139. package/dist/server/services/source-watcher.js.map +1 -1
  140. package/dist/server/services/worker-runtime.d.ts +82 -0
  141. package/dist/server/services/worker-runtime.d.ts.map +1 -0
  142. package/dist/server/services/worker-runtime.js +303 -0
  143. package/dist/server/services/worker-runtime.js.map +1 -0
  144. package/dist/ui/assets/{ApisPage-CTOcNna-.js → ApisPage-BkW-QZ0X.js} +1 -1
  145. package/dist/ui/assets/BucketDetailPage-CJwTQJh7.js +1 -0
  146. package/dist/ui/assets/BucketsPage-B1xPVhZC.js +1 -0
  147. package/dist/ui/assets/{DynamoPage-BnFFKnIU.js → DynamoPage-DvbpwZDc.js} +1 -1
  148. package/dist/ui/assets/DynamoTablePage-xn3Lq3so.js +3 -0
  149. package/dist/ui/assets/LambdaDetailPage-BhUyO77H.js +4 -0
  150. package/dist/ui/assets/{LambdasPage-Bg-MWFBb.js → LambdasPage-CUF_4rOH.js} +1 -1
  151. package/dist/ui/assets/OnboardingPage-U49THUzu.js +1 -0
  152. package/dist/ui/assets/OpenSearchCollectionPage-BJTVNCq4.js +1 -0
  153. package/dist/ui/assets/OpenSearchPage-4s-egkB9.js +1 -0
  154. package/dist/ui/assets/OverviewPage-DJeJQktG.js +1 -0
  155. package/dist/ui/assets/{OverviewPage-CSWY71vZ.css → OverviewPage-DjQETmb_.css} +1 -1
  156. package/dist/ui/assets/QueueDetailPage-DWsy5Yt8.js +1 -0
  157. package/dist/ui/assets/QueuesPage-BZJSqman.js +1 -0
  158. package/dist/ui/assets/SecretsPage-YC9ua54G.js +1 -0
  159. package/dist/ui/assets/ServiceDetailPage-Cum5frP4.js +2 -0
  160. package/dist/ui/assets/ServicesPage-IpaPDuqx.js +2 -0
  161. package/dist/ui/assets/SettingsPage-BiCRU8O6.js +1 -0
  162. package/dist/ui/assets/index-BDlrAJhi.css +1 -0
  163. package/dist/ui/assets/index-BouKSS43.js +38 -0
  164. package/dist/ui/assets/resourceIcons-DZs28nbs.js +1 -0
  165. package/dist/ui/index.html +5 -2
  166. package/package.json +1 -1
  167. package/dist/ui/assets/BucketDetailPage-BXW8-FrE.js +0 -1
  168. package/dist/ui/assets/BucketsPage-DY_ITepb.js +0 -1
  169. package/dist/ui/assets/DynamoTablePage-BLCuyJHU.js +0 -3
  170. package/dist/ui/assets/LambdaDetailPage-D4rZNLRT.js +0 -4
  171. package/dist/ui/assets/OnboardingPage-nnl313Fm.js +0 -1
  172. package/dist/ui/assets/OpenSearchCollectionPage-Dn15lH_F.js +0 -1
  173. package/dist/ui/assets/OpenSearchPage-CgD5isok.js +0 -1
  174. package/dist/ui/assets/OverviewPage-m5sUr8IN.js +0 -1
  175. package/dist/ui/assets/QueueDetailPage-Ch_FDDoN.js +0 -1
  176. package/dist/ui/assets/QueuesPage-C_yDuGDu.js +0 -1
  177. package/dist/ui/assets/SecretsPage-BSGMSoqy.js +0 -1
  178. package/dist/ui/assets/ServiceDetailPage-6KyohyjI.js +0 -2
  179. package/dist/ui/assets/ServicesPage-BbSetOMo.js +0 -2
  180. package/dist/ui/assets/SettingsPage-BgF1jFr6.js +0 -1
  181. package/dist/ui/assets/index-D627ZyYU.css +0 -1
  182. package/dist/ui/assets/index-DuSPnI19.js +0 -37
  183. package/dist/ui/assets/resourceIcons-baecADXA.js +0 -1
@@ -0,0 +1,914 @@
1
+ import { CloudFormationParser } from './cloudformation-parser.js';
2
+ // Same locals the raw API assembler signs with — the engine issues one account
3
+ // and one partition, so a graph ARN must reduce to the same string an
4
+ // assembler-built ARN does or the two would never match.
5
+ const ACCOUNT_ID = '000000000000';
6
+ const PARTITION = 'aws';
7
+ // The Serverless Framework's own execution role, and the helper role it emits
8
+ // for custom resources. The second one is FRAMEWORK plumbing — its statements
9
+ // grant `s3:PutBucketNotification` / `lambda:AddPermission` to a helper Lambda
10
+ // the user never wrote. Walking it would grow a bogus lambda→bucket edge on
11
+ // every service that uses an existing-bucket S3 event.
12
+ const CUSTOM_RESOURCES_ROLE = 'IamRoleCustomResourcesLambdaExecution';
13
+ // --- Small shared helpers ----------------------------------------------------
14
+ // CFN lists are routinely written as a lone scalar (`Action: 'logs:PutLogEvents'`,
15
+ // `Resource: {Fn::Join: […]}`). Coerce before iterating, and keep objects — an
16
+ // IAM Resource entry is usually an intrinsic, not a string.
17
+ function toArray(value) {
18
+ if (Array.isArray(value))
19
+ return value;
20
+ if (value === undefined || value === null)
21
+ return [];
22
+ return [value];
23
+ }
24
+ /**
25
+ * The logical id inside a `{Ref}` / `{Fn::GetAtt}`, or null for anything else.
26
+ *
27
+ * Both JSON spellings of Fn::GetAtt are handled. The parser's own private
28
+ * `extractFunctionName`/`extractArn` assume the array form and misbehave on
29
+ * the dotted string (`{"Fn::GetAtt": "X.Arn"}`) — one returns the first
30
+ * character, the other throws — so this does not reuse them.
31
+ */
32
+ function refLogicalId(ref) {
33
+ if (typeof ref === 'string') {
34
+ // Already reduced by the parser: `X::Arn` (extractArn) or `X.Arn`
35
+ // (resolveArnLike / sanitizeEnvironmentValues). Three encodings of the same
36
+ // reference coexist in this codebase; normalise all of them here.
37
+ if (ref.startsWith('arn:'))
38
+ return null;
39
+ return ref.split(/[:.]/)[0] || null;
40
+ }
41
+ if (typeof ref !== 'object' || ref === null)
42
+ return null;
43
+ const obj = ref;
44
+ if ('Ref' in obj)
45
+ return String(obj.Ref);
46
+ if ('Fn::GetAtt' in obj) {
47
+ const raw = obj['Fn::GetAtt'];
48
+ const parts = Array.isArray(raw) ? raw.map(String) : String(raw).split('.');
49
+ return parts[0] || null;
50
+ }
51
+ return null;
52
+ }
53
+ /**
54
+ * Reduce an ARN to the (aws service, resource name) pair a declared resource
55
+ * would be indexed under.
56
+ *
57
+ * The sub-resource suffixes matter: a policy that grants `dynamodb:Query` on
58
+ * `…:table/Orders/index/*` is about the Orders TABLE, and a stream ARN carries
59
+ * a creation timestamp nobody can predict at synth time. Secrets are the one
60
+ * kind that cannot be matched exactly — AWS appends six random characters at
61
+ * create time — so they match by prefix at the call site.
62
+ */
63
+ export function parseResourceArn(arn) {
64
+ if (!arn.startsWith('arn:'))
65
+ return null;
66
+ const parts = arn.split(':');
67
+ if (parts.length < 6)
68
+ return null;
69
+ const service = parts[2];
70
+ const raw = parts.slice(5).join(':');
71
+ if (!raw)
72
+ return null;
73
+ // AWS spells the resource-type discriminator two ways, and BOTH have to go or
74
+ // the name never matches a declared resource. Secrets Manager and Lambda use
75
+ // a COLON (`secret:app-db-AbCdEf`, `function:svc-dev-worker`) — which the
76
+ // rejoin above has just put back — while DynamoDB, EventBridge and OpenSearch
77
+ // use a SLASH (`table/Orders`). Missing the colon form is not theoretical:
78
+ // the engine's own Secrets Manager emits `…:secret:<name>-<6 chars>`, so an
79
+ // IAM grant on a secret this service declares would have drawn an external
80
+ // node sitting next to the real one.
81
+ const colonPrefixed = ['secret', 'function', 'layer', 'event-source-mapping'];
82
+ const colonSegments = raw.split(':');
83
+ const tail = colonSegments.length > 1 && colonPrefixed.includes(colonSegments[0])
84
+ ? colonSegments.slice(1).join(':')
85
+ : raw;
86
+ if (!tail)
87
+ return null;
88
+ const segments = tail.split('/');
89
+ // `table/Orders`, `event-bus/billing`, `collection/abc`, `domain/search`: the
90
+ // first segment is the discriminator, the second is the name. SQS and SNS
91
+ // have neither form, and S3 puts the bucket first.
92
+ // `function` appears in BOTH lists on purpose: AWS accepts `function:name`
93
+ // and `function/name`, and templates in the wild carry each.
94
+ const prefixed = ['table', 'event-bus', 'rule', 'collection', 'domain', 'stream', 'function'];
95
+ if (segments.length > 1 && prefixed.includes(segments[0])) {
96
+ return segments[1] ? { service, name: segments[1] } : null;
97
+ }
98
+ return segments[0] ? { service, name: segments[0] } : null;
99
+ }
100
+ // Which declared node kind an IAM action grants against. Driven entirely by the
101
+ // prefix before the colon — the action space in play is small and closed.
102
+ // Everything absent from this map is deliberately noise: `logs:*` is on every
103
+ // osls role by construction, and `xray`/`sts`/`ec2`/`elasticfilesystem`/
104
+ // `cloudwatch` are VPC and tracing plumbing that say nothing about wiring.
105
+ const ACTION_SERVICE_TO_KIND = {
106
+ dynamodb: 'dynamodb',
107
+ sqs: 'sqs',
108
+ sns: 'sns',
109
+ s3: 's3',
110
+ secretsmanager: 'secret',
111
+ es: 'opensearch',
112
+ aoss: 'opensearch',
113
+ events: 'eventbus',
114
+ lambda: 'lambda',
115
+ };
116
+ // The ARN service segment uses the same vocabulary as the action prefix, with
117
+ // one exception: an `execute-api` ARN is an API Gateway grant, which the graph
118
+ // models as routes rather than as a node kind of its own.
119
+ function kindForAwsService(service) {
120
+ return ACTION_SERVICE_TO_KIND[service] ?? null;
121
+ }
122
+ // `lambda:CheckpointDurableExecution` and its sibling are an osls self-grant
123
+ // pointing the function at itself; drawing them would put a self-loop on every
124
+ // Lambda of a durable-execution service.
125
+ const IGNORED_ACTIONS = new Set([
126
+ 'lambda:CheckpointDurableExecution',
127
+ 'lambda:GetDurableExecutionState',
128
+ ]);
129
+ // Env vars whose value is infrastructure, not a resource reference. Without
130
+ // this list `AWS_ENDPOINT=http://localhost:14566` matches a "resource" called
131
+ // `14566` on three of the four bundled examples.
132
+ const ENV_KEY_DENYLIST = new Set([
133
+ 'AWS_ENDPOINT',
134
+ 'AWS_ENDPOINT_URL',
135
+ 'AWS_REGION',
136
+ 'AWS_DEFAULT_REGION',
137
+ 'AWS_ACCOUNT_ID',
138
+ 'AWS_LAMBDA_EXEC_WRAPPER',
139
+ 'NODE_OPTIONS',
140
+ 'STAGE',
141
+ 'NODE_ENV',
142
+ 'LOG_LEVEL',
143
+ ]);
144
+ /**
145
+ * Builds the wiring graph for one service.
146
+ *
147
+ * Pure: no I/O, no engine calls, no clock. Everything it needs is the template
148
+ * the registrar cached and the metadata that came with it — which is what lets
149
+ * the unit suite cover every edge kind from hand-written templates.
150
+ */
151
+ export function buildServiceGraph(input) {
152
+ const warnings = [];
153
+ const nodes = new Map();
154
+ const edges = new Map();
155
+ const raw = (input.template ?? {});
156
+ const rawResources = raw.Resources ?? {};
157
+ // --- Index the declared resources ------------------------------------------
158
+ // Two indexes, because references arrive in two currencies: a logical id
159
+ // (every intra-template intrinsic) and a concrete name (every literal ARN,
160
+ // every env-var value). The provisioner builds the first one exactly this way.
161
+ const byLogicalId = new Map();
162
+ const byKindAndName = new Map();
163
+ const lambdas = new Map();
164
+ for (const resource of input.resources) {
165
+ if ('logicalId' in resource && resource.logicalId) {
166
+ byLogicalId.set(resource.logicalId, resource);
167
+ if (resource.type === 'lambda')
168
+ lambdas.set(resource.logicalId, resource);
169
+ }
170
+ const kind = nodeKindFor(resource);
171
+ if (kind && 'name' in resource && typeof resource.name === 'string') {
172
+ byKindAndName.set(kindNameKey(kind, resource.name), resource);
173
+ }
174
+ }
175
+ // --- Nodes: one per declared resource --------------------------------------
176
+ // Order matters only for readability of the payload; the dashboard lays out
177
+ // by kind, not by array position.
178
+ for (const resource of input.resources) {
179
+ const kind = nodeKindFor(resource);
180
+ if (!kind)
181
+ continue;
182
+ addResourceNode(nodes, resource, kind, input.functions);
183
+ }
184
+ // --- Nodes: one per declared HTTP route ------------------------------------
185
+ // Routes are the granular entry point — "POST /orders" is what a developer
186
+ // actually calls, and collapsing them into a single API node would hide which
187
+ // function serves which path, which is the first thing this screen is for.
188
+ const routes = input.routes ?? [];
189
+ const functions = input.functions ?? [];
190
+ for (const route of routes) {
191
+ const node = routeNode(route);
192
+ nodes.set(node.id, node);
193
+ const target = lambdaNodeIdForShortName(route.functionName, functions, byLogicalId);
194
+ if (target && nodes.has(target)) {
195
+ addEdge(edges, {
196
+ from: node.id,
197
+ to: target,
198
+ kind: 'http-route',
199
+ detail: route.eventType === 'http' ? 'REST' : 'HTTP API',
200
+ confidence: 'declared',
201
+ });
202
+ }
203
+ else {
204
+ // A route whose function is not a local Lambda is a cross-service or raw
205
+ // ::Integration route; the assembler keeps its ARN precisely for this.
206
+ const external = externalNodeFromArn(nodes, route.functionArn, 'lambda');
207
+ if (external) {
208
+ addEdge(edges, {
209
+ from: node.id,
210
+ to: external,
211
+ kind: 'http-route',
212
+ detail: route.eventType === 'http' ? 'REST' : 'HTTP API',
213
+ confidence: 'declared',
214
+ });
215
+ }
216
+ else {
217
+ warnings.push(`Route ${route.method} ${route.path} names function "${route.functionName}", which this service does not declare`);
218
+ }
219
+ }
220
+ }
221
+ // --- Edges: authorizer Lambda guards a route -------------------------------
222
+ const authorizers = new Map();
223
+ for (const authorizer of input.authorizers ?? [])
224
+ authorizers.set(authorizer.name, authorizer);
225
+ for (const route of routes) {
226
+ if (!route.authorizerName)
227
+ continue;
228
+ const authorizer = authorizers.get(route.authorizerName);
229
+ if (!authorizer) {
230
+ warnings.push(`Route ${route.method} ${route.path} names authorizer "${route.authorizerName}", which this service does not declare`);
231
+ continue;
232
+ }
233
+ const routeId = routeNode(route).id;
234
+ const local = authorizer.functionName
235
+ ? lambdaNodeIdForShortName(authorizer.functionName, functions, byLogicalId)
236
+ : null;
237
+ const source = local && nodes.has(local)
238
+ ? local
239
+ : externalNodeFromArn(nodes, authorizer.arn, 'lambda');
240
+ if (!source) {
241
+ warnings.push(`Authorizer "${authorizer.name}" resolves to neither a local function nor an ARN`);
242
+ continue;
243
+ }
244
+ addEdge(edges, {
245
+ from: source,
246
+ to: routeId,
247
+ kind: 'authorizer',
248
+ detail: authorizer.type,
249
+ confidence: 'declared',
250
+ });
251
+ }
252
+ // --- Edges: event-source mappings (SQS → Lambda, DynamoDB stream → Lambda) --
253
+ for (const resource of input.resources) {
254
+ if (resource.type !== 'event-source')
255
+ continue;
256
+ addEventSourceEdge(resource, { nodes, edges, byLogicalId, byKindAndName, warnings });
257
+ }
258
+ // --- Edges: S3 notifications, EventBridge rules, SQS redrive ---------------
259
+ for (const resource of input.resources) {
260
+ if (resource.type === 's3')
261
+ addS3NotificationEdges(resource, { nodes, edges, byLogicalId, byKindAndName, warnings });
262
+ if (resource.type === 'event-rule')
263
+ addEventRuleEdges(resource, { nodes, edges, byLogicalId, byKindAndName, warnings });
264
+ if (resource.type === 'sqs')
265
+ addRedriveEdge(resource, { nodes, edges, byLogicalId, byKindAndName, warnings });
266
+ }
267
+ // --- Edges: SNS subscriptions (raw template — the parser has no arm) -------
268
+ addSnsSubscriptionEdges(rawResources, { nodes, edges, byLogicalId, byKindAndName, warnings });
269
+ // --- Edges: IAM grants (raw template — the parser drops IAM roles) ---------
270
+ const parser = new CloudFormationParser();
271
+ const ctx = {
272
+ region: input.region || 'us-east-1',
273
+ accountId: ACCOUNT_ID,
274
+ partition: PARTITION,
275
+ lambdas,
276
+ };
277
+ addIamEdges(rawResources, {
278
+ nodes, edges, byLogicalId, byKindAndName, warnings, parser, ctx,
279
+ });
280
+ // --- Edges: environment variables naming a declared resource ---------------
281
+ addEnvEdges(functions, { nodes, edges, byKindAndName, byLogicalId, warnings });
282
+ const edgeList = [...edges.values()];
283
+ return {
284
+ service: input.serviceName,
285
+ nodes: [...nodes.values()],
286
+ edges: edgeList,
287
+ edgeKinds: [...new Set(edgeList.map(edge => edge.kind))],
288
+ warnings,
289
+ };
290
+ }
291
+ // --- Node construction -------------------------------------------------------
292
+ // Which parsed resources become nodes. The API Gateway v2 plumbing
293
+ // (`apigw-api`/`-integration`/`-route`/`-authorizer`) and `lambda-permission`
294
+ // are deliberately absent: they are how a route is WIRED, not something a
295
+ // developer reasons about, and drawing them would bury the four real nodes of a
296
+ // small service under a dozen framework boxes. Their information reaches the
297
+ // graph as route nodes instead.
298
+ function nodeKindFor(resource) {
299
+ switch (resource.type) {
300
+ case 'lambda': return 'lambda';
301
+ case 'dynamodb': return 'dynamodb';
302
+ case 'sqs': return 'sqs';
303
+ case 'sns': return 'sns';
304
+ case 's3': return 's3';
305
+ case 'secret': return 'secret';
306
+ case 'eventbus': return 'eventbus';
307
+ case 'event-rule': return 'event-rule';
308
+ case 'opensearch': return 'opensearch';
309
+ default: return null;
310
+ }
311
+ }
312
+ function nodeId(kind, key) {
313
+ return `${kind}:${key}`;
314
+ }
315
+ // Key for the (kind, declared name) index. The separator is a NUL rather than a
316
+ // space because it must never occur inside an AWS resource name — splitting on
317
+ // a space would truncate any name that contains one.
318
+ function kindNameKey(kind, name) {
319
+ return `${kind}\u0000${name}`;
320
+ }
321
+ function addResourceNode(nodes, resource, kind, functions) {
322
+ const declared = resource;
323
+ const id = nodeId(kind, declared.logicalId);
324
+ const node = {
325
+ id,
326
+ kind,
327
+ // A `TableName: {Fn::Sub: …}` reaches the parser as an object cast to
328
+ // string, so the label falls back to the logical id rather than rendering
329
+ // "[object Object]" on the canvas.
330
+ label: typeof declared.name === 'string' && declared.name ? declared.name : declared.logicalId,
331
+ logicalId: declared.logicalId,
332
+ };
333
+ if (kind === 'lambda') {
334
+ const lambda = resource;
335
+ node.fullName = lambda.name;
336
+ node.handler = lambda.handler;
337
+ }
338
+ if (kind === 'lambda' && functions) {
339
+ // The dashboard links a Lambda node to its detail screen by SHORT name,
340
+ // which only serverless-state knows; the template carries the full name.
341
+ const match = functions.find(fn => fn.fullName === resource.name);
342
+ if (match)
343
+ node.label = match.name;
344
+ }
345
+ nodes.set(id, node);
346
+ }
347
+ function routeNode(route) {
348
+ return {
349
+ id: nodeId('route', `${route.method} ${route.path}`),
350
+ kind: 'route',
351
+ label: `${route.method} ${route.path}`,
352
+ method: route.method,
353
+ path: route.path,
354
+ };
355
+ }
356
+ // A referenced ARN that this service does not declare still deserves a node —
357
+ // the billing example's grant on the orders service's queue is exactly the
358
+ // cross-service link a per-service view would otherwise hide.
359
+ function externalNodeFromArn(nodes, arn, fallbackKind) {
360
+ if (!arn)
361
+ return null;
362
+ const parsed = parseResourceArn(arn);
363
+ const label = parsed ? parsed.name : arn;
364
+ const service = parsed ? parsed.service : undefined;
365
+ const id = nodeId('external', arn);
366
+ if (!nodes.has(id)) {
367
+ nodes.set(id, {
368
+ id,
369
+ kind: 'external',
370
+ label,
371
+ arn,
372
+ service: service ?? fallbackKind,
373
+ });
374
+ }
375
+ return id;
376
+ }
377
+ // A reference that names — rather than Refs — a resource owned by another
378
+ // service. It has no ARN and no logical id here, only a name.
379
+ function externalNodeByName(nodes, name, kind) {
380
+ const id = nodeId('external', name);
381
+ if (!nodes.has(id)) {
382
+ nodes.set(id, { id, kind: 'external', label: name, service: kind });
383
+ }
384
+ return id;
385
+ }
386
+ // serverless-state speaks SHORT function names; the template speaks logical ids
387
+ // and FULL names. This is the join between the two vocabularies.
388
+ function lambdaNodeIdForShortName(shortName, functions, byLogicalId) {
389
+ const fn = functions.find(candidate => candidate.name === shortName);
390
+ if (!fn)
391
+ return null;
392
+ for (const [logicalId, resource] of byLogicalId) {
393
+ if (resource.type === 'lambda' && resource.name === fn.fullName)
394
+ return nodeId('lambda', logicalId);
395
+ }
396
+ return null;
397
+ }
398
+ /**
399
+ * Adds an edge, keyed by (from, to, kind) so the same wiring described twice
400
+ * lands once.
401
+ *
402
+ * The de-duplication is load-bearing, not hygiene: osls pushes an
403
+ * `sqs:ReceiveMessage` / `dynamodb:GetRecords` statement onto the execution
404
+ * role for every event-source mapping it compiles, aimed at the very same ARN.
405
+ * Without this the canvas draws a doubled arrow on every event source. The
406
+ * structural edge wins because it carries batch size and filters; the IAM
407
+ * statement only re-states it.
408
+ */
409
+ function addEdge(edges, edge) {
410
+ const id = `${edge.from}\u0000${edge.to}\u0000${edge.kind}`;
411
+ if (edges.has(id))
412
+ return;
413
+ edges.set(id, { ...edge, id });
414
+ }
415
+ // True when some edge already connects this pair, whatever its kind — used to
416
+ // keep an IAM grant from restating a structural edge in a second line.
417
+ function connected(edges, from, to) {
418
+ for (const edge of edges.values()) {
419
+ if ((edge.from === from && edge.to === to) || (edge.from === to && edge.to === from))
420
+ return true;
421
+ }
422
+ return false;
423
+ }
424
+ /**
425
+ * Resolve an intra-template reference (logical id, `X::Arn`, `X.Arn`) or a
426
+ * literal ARN to a node id, creating an external node when it points outside.
427
+ *
428
+ * `nameKind` opts a carrier into name-addressed cross-service resolution. Most
429
+ * references are logical ids and a miss means a broken template — but a few
430
+ * CloudFormation properties take a plain NAME that legitimately belongs to
431
+ * another stack. `EventBusName: billing-events` is the bundled demo's own
432
+ * pipeline: the notifications rule listens on the bus the billing service
433
+ * declares. Dropping that reference silently would hide the single most
434
+ * interesting arrow on the screen, so those carriers pass the kind they expect
435
+ * and get an external node instead of a warning.
436
+ */
437
+ function resolveRefToNode(ref, ctx, nameKind) {
438
+ if (typeof ref === 'string' && ref.startsWith('arn:')) {
439
+ const parsed = parseResourceArn(ref);
440
+ if (parsed) {
441
+ const kind = kindForAwsService(parsed.service);
442
+ if (kind && ctx.byKindAndName) {
443
+ const local = ctx.byKindAndName.get(kindNameKey(kind, parsed.name));
444
+ if (local && 'logicalId' in local && local.logicalId)
445
+ return nodeId(kind, local.logicalId);
446
+ }
447
+ }
448
+ return externalNodeFromArn(ctx.nodes, ref, 'lambda');
449
+ }
450
+ const logicalId = refLogicalId(ref);
451
+ if (!logicalId)
452
+ return null;
453
+ const resource = ctx.byLogicalId.get(logicalId);
454
+ if (!resource) {
455
+ if (!nameKind || typeof ref !== 'string')
456
+ return null;
457
+ // Not a logical id in this template, so the string is a name owned
458
+ // elsewhere. Match a same-named local resource first — a service is allowed
459
+ // to name its own bus in prose — then fall back to an external node.
460
+ const local = ctx.byKindAndName?.get(kindNameKey(nameKind, ref));
461
+ if (local && 'logicalId' in local && local.logicalId)
462
+ return nodeId(nameKind, local.logicalId);
463
+ return externalNodeByName(ctx.nodes, ref, nameKind);
464
+ }
465
+ const kind = nodeKindFor(resource);
466
+ if (!kind)
467
+ return null;
468
+ // No `nodes.has` guard: the node pass created a node for every resource
469
+ // `nodeKindFor` accepts, under the very key `byLogicalId` is indexed by, so a
470
+ // kind-ful hit here always has one.
471
+ return nodeId(kind, logicalId);
472
+ }
473
+ function addEventSourceEdge(mapping, ctx) {
474
+ const target = resolveRefToNode(mapping.functionName, ctx);
475
+ const source = resolveRefToNode(mapping.eventSourceArn, ctx);
476
+ if (!target || !source) {
477
+ warnUnresolved(ctx, `Event-source mapping ${mapping.eventSourceArn || '(unnamed)'} → ${mapping.functionName || '(unnamed)'}`);
478
+ return;
479
+ }
480
+ // A DynamoDB source is the table's STREAM, not the table itself. Saying so in
481
+ // the label keeps the arrow honest: a stream edge does not mean the function
482
+ // reads the table through the data plane.
483
+ const isStream = typeof mapping.eventSourceArn === 'string'
484
+ && (mapping.eventSourceArn.includes('StreamArn') || mapping.eventSourceArn.includes('/stream/'));
485
+ const detail = isStream ? 'stream' : 'poll';
486
+ addEdge(ctx.edges, {
487
+ from: source,
488
+ to: target,
489
+ kind: 'event-source',
490
+ detail: mapping.batchSize ? `${detail} ×${mapping.batchSize}` : detail,
491
+ confidence: 'declared',
492
+ });
493
+ }
494
+ function addS3NotificationEdges(bucket, ctx) {
495
+ for (const notification of bucket.notifications) {
496
+ const target = resolveRefToNode(notification.functionRef, ctx);
497
+ if (!target) {
498
+ warnUnresolved(ctx, `S3 notification on ${bucket.name} → ${notification.functionRef}`);
499
+ continue;
500
+ }
501
+ addEdge(ctx.edges, {
502
+ from: nodeId('s3', bucket.logicalId),
503
+ to: target,
504
+ kind: 's3-notification',
505
+ detail: notification.events.join(', '),
506
+ confidence: 'declared',
507
+ });
508
+ }
509
+ }
510
+ function addEventRuleEdges(rule, ctx) {
511
+ const ruleNode = nodeId('event-rule', rule.logicalId);
512
+ // A rule with no `EventBusName` listens on the default bus, which no template
513
+ // declares — there is nothing to point at, so no edge is drawn rather than
514
+ // inventing a node for a bus this service does not own.
515
+ if (rule.eventBusRef) {
516
+ const bus = resolveRefToNode(rule.eventBusRef, ctx, 'eventbus');
517
+ if (bus) {
518
+ addEdge(ctx.edges, {
519
+ from: bus,
520
+ to: ruleNode,
521
+ kind: 'event-bus-rule',
522
+ detail: rule.scheduleExpression || undefined,
523
+ confidence: 'declared',
524
+ });
525
+ }
526
+ else {
527
+ warnUnresolved(ctx, `EventBridge rule ${rule.name} names bus "${rule.eventBusRef}"`);
528
+ }
529
+ }
530
+ for (const target of rule.targets) {
531
+ const node = resolveRefToNode(target.functionRef, ctx);
532
+ if (!node) {
533
+ warnUnresolved(ctx, `EventBridge rule ${rule.name} targets ${target.functionRef}`);
534
+ continue;
535
+ }
536
+ addEdge(ctx.edges, {
537
+ from: ruleNode,
538
+ to: node,
539
+ kind: 'event-rule-target',
540
+ detail: rule.scheduleExpression || undefined,
541
+ confidence: 'declared',
542
+ });
543
+ }
544
+ }
545
+ function addRedriveEdge(queue, ctx) {
546
+ if (!queue.redrivePolicy)
547
+ return;
548
+ const dlq = resolveRefToNode(queue.redrivePolicy.deadLetterTargetRef, ctx);
549
+ if (!dlq) {
550
+ warnUnresolved(ctx, `Queue ${queue.name} redrives to ${queue.redrivePolicy.deadLetterTargetRef}`);
551
+ return;
552
+ }
553
+ addEdge(ctx.edges, {
554
+ from: nodeId('sqs', queue.logicalId),
555
+ to: dlq,
556
+ kind: 'redrive',
557
+ detail: `after ${queue.redrivePolicy.maxReceiveCount}`,
558
+ confidence: 'declared',
559
+ });
560
+ }
561
+ /**
562
+ * SNS subscriptions, read straight from the raw template.
563
+ *
564
+ * `AWS::SNS::Subscription` has no arm in the CloudFormation parser, so without
565
+ * this every declared topic renders as an orphan box — a node with no edges is
566
+ * a question the diagram raises and does not answer.
567
+ */
568
+ function addSnsSubscriptionEdges(rawResources, ctx) {
569
+ for (const resource of Object.values(rawResources)) {
570
+ if (resource.Type !== 'AWS::SNS::Subscription')
571
+ continue;
572
+ const props = resource.Properties ?? {};
573
+ const topic = resolveRefToNode(props.TopicArn, ctx);
574
+ const endpoint = resolveRefToNode(props.Endpoint, ctx);
575
+ if (!topic || !endpoint) {
576
+ warnUnresolved(ctx, 'SNS subscription with an unresolvable topic or endpoint');
577
+ continue;
578
+ }
579
+ addEdge(ctx.edges, {
580
+ from: topic,
581
+ to: endpoint,
582
+ kind: 'sns-subscription',
583
+ detail: typeof props.Protocol === 'string' ? props.Protocol : undefined,
584
+ confidence: 'declared',
585
+ });
586
+ }
587
+ }
588
+ /**
589
+ * Turns the execution role's inline policies into edges.
590
+ *
591
+ * The shape of the answer is dictated by how the Serverless Framework writes
592
+ * roles: ONE `IamRoleLambdaExecution` shared by every function of the service,
593
+ * with a single PolicyDocument that carries no per-function attribution
594
+ * whatsoever. So "this Lambda may touch that table" is, truthfully, "every
595
+ * Lambda bound to this role may touch that table".
596
+ *
597
+ * Two renderings follow from that, and the rule between them is the number of
598
+ * functions on the role:
599
+ *
600
+ * - ONE function on the role → the fan-out is a fan-out of one, so the edge is
601
+ * drawn `lambda → resource` directly. That is the arrow a developer means.
602
+ * - MORE than one → the role becomes a node, and the graph draws N + M edges
603
+ * (each function → role, role → each grant) instead of N × M. At the stack
604
+ * sizes LSS targets (≈60 functions × ≈10 tables) the product form is 600
605
+ * arrows of pure noise; the hub form is 70 and is also what the template
606
+ * literally says.
607
+ */
608
+ function addIamEdges(rawResources, iam) {
609
+ // Which functions sit on which role. `Properties.Role` is
610
+ // `{Fn::GetAtt: [<RoleId>, 'Arn']}` for an in-template role; an ARN string or
611
+ // an ImportValue means the role lives outside the stack and carries no
612
+ // statements we can read.
613
+ const lambdasByRole = new Map();
614
+ for (const [logicalId, resource] of Object.entries(rawResources)) {
615
+ if (resource.Type !== 'AWS::Lambda::Function')
616
+ continue;
617
+ const roleId = refLogicalId(resource.Properties?.Role);
618
+ if (!roleId)
619
+ continue;
620
+ const node = nodeId('lambda', logicalId);
621
+ if (!iam.nodes.has(node))
622
+ continue;
623
+ const list = lambdasByRole.get(roleId) ?? [];
624
+ list.push(node);
625
+ lambdasByRole.set(roleId, list);
626
+ }
627
+ for (const [roleLogicalId, resource] of Object.entries(rawResources)) {
628
+ if (resource.Type !== 'AWS::IAM::Role')
629
+ continue;
630
+ // Framework plumbing: its statements are about a helper Lambda osls
631
+ // generates, not about anything the developer wrote.
632
+ if (roleLogicalId === CUSTOM_RESOURCES_ROLE)
633
+ continue;
634
+ const bound = lambdasByRole.get(roleLogicalId) ?? [];
635
+ if (bound.length === 0)
636
+ continue;
637
+ const grants = collectGrants(resource, iam);
638
+ if (grants.length === 0)
639
+ continue;
640
+ if (bound.length === 1) {
641
+ for (const grant of grants) {
642
+ // Do not restate a structural edge. The queue→lambda arrow already
643
+ // exists from the event-source mapping and carries more information.
644
+ if (connected(iam.edges, bound[0], grant.node))
645
+ continue;
646
+ addEdge(iam.edges, {
647
+ from: bound[0],
648
+ to: grant.node,
649
+ kind: 'iam',
650
+ detail: grant.actions.join(', '),
651
+ confidence: 'declared',
652
+ });
653
+ }
654
+ continue;
655
+ }
656
+ const roleNode = nodeId('iam-role', roleLogicalId);
657
+ iam.nodes.set(roleNode, {
658
+ id: roleNode,
659
+ kind: 'iam-role',
660
+ label: roleLogicalId,
661
+ logicalId: roleLogicalId,
662
+ });
663
+ for (const lambda of bound) {
664
+ addEdge(iam.edges, { from: lambda, to: roleNode, kind: 'iam', confidence: 'declared' });
665
+ }
666
+ for (const grant of grants) {
667
+ addEdge(iam.edges, {
668
+ from: roleNode,
669
+ to: grant.node,
670
+ kind: 'iam',
671
+ detail: grant.actions.join(', '),
672
+ confidence: 'declared',
673
+ });
674
+ }
675
+ }
676
+ }
677
+ /** Every (resource, actions) pair an `Allow` statement of this role grants. */
678
+ function collectGrants(role, iam) {
679
+ const grants = new Map();
680
+ for (const policy of toArray(role.Properties?.Policies)) {
681
+ const document = policy?.PolicyDocument;
682
+ for (const entry of toArray(document?.Statement)) {
683
+ const statement = entry;
684
+ // `Deny` is emitted by osls for `disableLogs`; `NotResource`/`NotAction`
685
+ // describe a complement that cannot be turned into an arrow. Neither is
686
+ // evidence that this function talks to that resource.
687
+ if (statement.Effect !== 'Allow')
688
+ continue;
689
+ if (statement.NotResource !== undefined || statement.NotAction !== undefined)
690
+ continue;
691
+ const actions = toArray(statement.Action)
692
+ .filter((action) => typeof action === 'string')
693
+ .filter(action => !IGNORED_ACTIONS.has(action));
694
+ const kinds = new Set(actions
695
+ .map(action => ACTION_SERVICE_TO_KIND[action.split(':')[0]])
696
+ .filter((kind) => Boolean(kind)));
697
+ if (kinds.size === 0)
698
+ continue;
699
+ for (const target of toArray(statement.Resource)) {
700
+ // `Resource: "*"` is a blanket grant. It is true of everything and
701
+ // therefore evidence of nothing — an arrow to every node would be the
702
+ // least useful drawing this screen could produce.
703
+ if (target === '*')
704
+ continue;
705
+ const node = resolveGrantTarget(target, kinds, iam);
706
+ if (!node)
707
+ continue;
708
+ const bucket = grants.get(node) ?? new Set();
709
+ for (const action of actions)
710
+ bucket.add(action);
711
+ grants.set(node, bucket);
712
+ }
713
+ }
714
+ }
715
+ return [...grants.entries()].map(([node, actions]) => ({ node, actions: [...actions] }));
716
+ }
717
+ /**
718
+ * Resolve one policy `Resource` entry to a node.
719
+ *
720
+ * Order matters. A `Ref`/`Fn::GetAtt` is tried FIRST, because its unresolved
721
+ * form already IS the logical id — going through `resolveArnLike` would reduce
722
+ * it to a string that then has to be reverse-matched for no gain.
723
+ * `Fn::Sub`/`Fn::Join`/`Fn::ImportValue` and literal ARNs take the reducer.
724
+ */
725
+ function resolveGrantTarget(target, kinds, iam) {
726
+ const logicalId = refLogicalId(target);
727
+ if (logicalId) {
728
+ const resource = iam.byLogicalId.get(logicalId);
729
+ if (resource) {
730
+ const kind = nodeKindFor(resource);
731
+ if (kind && iam.nodes.has(nodeId(kind, logicalId)))
732
+ return nodeId(kind, logicalId);
733
+ }
734
+ return null;
735
+ }
736
+ const reduced = iam.parser.resolveArnLike(target, iam.ctx);
737
+ if (!reduced.value || !reduced.value.startsWith('arn:'))
738
+ return null;
739
+ const parsed = parseResourceArn(reduced.value);
740
+ if (!parsed)
741
+ return null;
742
+ const kind = kindForAwsService(parsed.service);
743
+ // The action list and the ARN must agree on what kind of thing this is;
744
+ // disagreement means the reduction went wrong and an arrow would be a guess.
745
+ if (!kind || !kinds.has(kind))
746
+ return null;
747
+ const local = iam.byKindAndName.get(kindNameKey(kind, parsed.name))
748
+ // Secrets Manager appends six random characters at create time, so the ARN
749
+ // in a policy never matches the declared name exactly — match by prefix.
750
+ ?? (kind === 'secret' ? findSecretByPrefix(iam.byKindAndName, parsed.name) : undefined)
751
+ // …and if it is not a declared NAME, it may be a declared LOGICAL ID that
752
+ // survived the reduction. `resolveArnLike` only turns Lambda and API Refs
753
+ // into real values, so the most common S3 grant the Serverless Framework
754
+ // writes — `Fn::Join: ['', ['arn:aws:s3:::', {Ref: UploadsBucket}, '/*']]`
755
+ // — reduces to `arn:aws:s3:::UploadsBucket/*`, carrying the logical id
756
+ // where the bucket name should be. Without this branch that grant draws an
757
+ // external node for a bucket declared three lines further down the same
758
+ // template.
759
+ ?? localByLogicalId(iam.byLogicalId, kind, parsed.name);
760
+ if (local && 'logicalId' in local && local.logicalId)
761
+ return nodeId(kind, local.logicalId);
762
+ return externalNodeFromArn(iam.nodes, reduced.value, kind);
763
+ }
764
+ // A declared resource whose LOGICAL ID is `name` and whose kind matches — the
765
+ // shape left behind when an intrinsic reduced only halfway.
766
+ function localByLogicalId(byLogicalId, kind, name) {
767
+ const resource = byLogicalId.get(name);
768
+ return resource && nodeKindFor(resource) === kind ? resource : undefined;
769
+ }
770
+ function findSecretByPrefix(byKindAndName, name) {
771
+ for (const [key, resource] of byKindAndName) {
772
+ const [kind, declared] = key.split('\u0000');
773
+ if (kind === 'secret' && name.startsWith(declared))
774
+ return resource;
775
+ }
776
+ return undefined;
777
+ }
778
+ /**
779
+ * The weakest edge kind, and the one that needs the loudest caveat.
780
+ *
781
+ * `provider.environment` is copied onto EVERY function of the service by the
782
+ * packager, so a variable naming a table proves the service uses it, not that
783
+ * this function does. Those edges are flagged `serviceWide` so the dashboard
784
+ * can subordinate them; the whole class is `inferred`, because a name match is
785
+ * a guess no matter how good.
786
+ *
787
+ * Matching is exact-first and never substring: `PRODUCTS_INDEX=products` must
788
+ * NOT match a collection called `products-catalog`, which a substring matcher
789
+ * happily invents.
790
+ */
791
+ function addEnvEdges(functions, env) {
792
+ // A key/value pair present on every function came from provider-level
793
+ // configuration, not from this function's own declaration.
794
+ const occurrences = new Map();
795
+ for (const fn of functions) {
796
+ for (const [key, value] of Object.entries(fn.environment ?? {})) {
797
+ const pair = `${key}\u0000${value}`;
798
+ occurrences.set(pair, (occurrences.get(pair) ?? 0) + 1);
799
+ }
800
+ }
801
+ // Which pairs are already linked, in either direction. Built once and kept
802
+ // up to date rather than re-scanned per variable: this is the innermost loop
803
+ // of the whole builder (functions x variables), and at the scale LSS targets
804
+ // — 60 functions, each carrying the provider's environment — a linear scan
805
+ // per check would be the only quadratic thing here.
806
+ const linked = new Set();
807
+ for (const edge of env.edges.values()) {
808
+ linked.add(`${edge.from}\u0000${edge.to}`);
809
+ linked.add(`${edge.to}\u0000${edge.from}`);
810
+ }
811
+ for (const fn of functions) {
812
+ const source = lambdaNodeIdForFullName(fn.fullName, env.byLogicalId);
813
+ if (!source || !env.nodes.has(source))
814
+ continue;
815
+ for (const [key, value] of Object.entries(fn.environment ?? {})) {
816
+ if (ENV_KEY_DENYLIST.has(key))
817
+ continue;
818
+ const target = matchEnvValue(value, env);
819
+ if (!target || target === source)
820
+ continue;
821
+ // An env var that merely restates a link CloudFormation already wires
822
+ // adds a weaker duplicate of a stronger fact.
823
+ if (linked.has(`${source}\u0000${target}`))
824
+ continue;
825
+ linked.add(`${source}\u0000${target}`);
826
+ linked.add(`${target}\u0000${source}`);
827
+ addEdge(env.edges, {
828
+ from: source,
829
+ to: target,
830
+ kind: 'env',
831
+ detail: key,
832
+ confidence: 'inferred',
833
+ serviceWide: functions.length > 1 && occurrences.get(`${key}\u0000${value}`) === functions.length,
834
+ });
835
+ }
836
+ }
837
+ }
838
+ function lambdaNodeIdForFullName(fullName, byLogicalId) {
839
+ for (const [logicalId, resource] of byLogicalId) {
840
+ if (resource.type === 'lambda' && resource.name === fullName)
841
+ return nodeId('lambda', logicalId);
842
+ }
843
+ return null;
844
+ }
845
+ /**
846
+ * Which declared resource, if any, an environment value names.
847
+ *
848
+ * Four grammars, tried in order of how much they prove:
849
+ * 1. the value IS a declared name (`ORDERS_TABLE=orders-Orders`);
850
+ * 2. the value is an ARN;
851
+ * 3. the value is an unresolved intrinsic the packager left as `X.Arn` /
852
+ * `X::Arn` — the logical id is right there;
853
+ * 4. the value is a service URL whose LAST path segment is the name
854
+ * (`…/000000000000/orders-to-process`, `…/_aoss/products-catalog`).
855
+ *
856
+ * Never a substring match, and never a bare last-path-segment match on a value
857
+ * that is not a URL — that is how `http://localhost:14566` becomes a resource
858
+ * called `14566`.
859
+ */
860
+ function matchEnvValue(value, env) {
861
+ if (!value)
862
+ return null;
863
+ const direct = findByName(env.byKindAndName, value);
864
+ if (direct)
865
+ return direct;
866
+ if (value.startsWith('arn:')) {
867
+ const parsed = parseResourceArn(value);
868
+ if (!parsed)
869
+ return null;
870
+ const kind = kindForAwsService(parsed.service);
871
+ if (!kind)
872
+ return null;
873
+ const local = env.byKindAndName.get(kindNameKey(kind, parsed.name));
874
+ if (local && 'logicalId' in local && local.logicalId)
875
+ return nodeId(kind, local.logicalId);
876
+ return null;
877
+ }
878
+ if (!value.includes('/') && (value.includes('::') || value.includes('.'))) {
879
+ const logicalId = refLogicalId(value);
880
+ if (logicalId) {
881
+ const resource = env.byLogicalId.get(logicalId);
882
+ const kind = resource ? nodeKindFor(resource) : null;
883
+ if (kind && env.nodes.has(nodeId(kind, logicalId)))
884
+ return nodeId(kind, logicalId);
885
+ }
886
+ return null;
887
+ }
888
+ if (value.startsWith('http://') || value.startsWith('https://')) {
889
+ const path = value.replace(/^https?:\/\/[^/]+/, '').split('?')[0];
890
+ const segments = path.split('/').filter(Boolean);
891
+ if (segments.length === 0)
892
+ return null;
893
+ return findByName(env.byKindAndName, segments[segments.length - 1]);
894
+ }
895
+ return null;
896
+ }
897
+ // A name is matched against every kind, because an env var does not say which
898
+ // service it points at. A name colliding across two kinds is vanishingly rare
899
+ // and the first match is deterministic (insertion order = template order).
900
+ function findByName(byKindAndName, name) {
901
+ for (const [key, resource] of byKindAndName) {
902
+ const [kind, declared] = key.split('\u0000');
903
+ if (declared === name && 'logicalId' in resource && resource.logicalId) {
904
+ return nodeId(kind, resource.logicalId);
905
+ }
906
+ }
907
+ return null;
908
+ }
909
+ // One phrasing for every "reference pointed at nothing" warning, so the
910
+ // dashboard can show them as a single list without classifying them.
911
+ function warnUnresolved(ctx, what) {
912
+ ctx.warnings.push(`${what} could not be resolved to a declared resource`);
913
+ }
914
+ //# sourceMappingURL=service-graph.js.map