@prism-d1/cli 1.0.99 → 1.2.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 (162) hide show
  1. package/dist/assets/github-workflows/prism-agent-eval.yml +2 -1
  2. package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +11 -1
  3. package/dist/assets/gitlab-workflows/prism-eval-gate.yml +8 -0
  4. package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +312 -0
  5. package/dist/assets/infra/lib/dashboard-stack.ts +74 -45
  6. package/dist/assets/infra/lib/lambda/otel-receiver.ts +324 -12
  7. package/dist/assets/infra/lib/lambda/pre-token-generation.ts +79 -0
  8. package/dist/assets/infra/lib/lambda/pricing/litellm-snapshot.json +3769 -0
  9. package/dist/assets/infra/lib/lambda/pricing/price-source.ts +56 -0
  10. package/dist/assets/infra/lib/lambda/pricing/price-table.ts +240 -0
  11. package/dist/assets/infra/lib/lambda/pricing/prices-key.ts +6 -0
  12. package/dist/assets/infra/lib/lambda/pricing/reprice.ts +108 -0
  13. package/dist/assets/infra/lib/lambda/pricing-refresh.ts +352 -0
  14. package/dist/assets/infra/lib/lambda/velocity-widget.ts +397 -1
  15. package/dist/assets/infra/lib/metrics-pipeline-stack.ts +13 -0
  16. package/dist/assets/infra/package.json +5 -2
  17. package/dist/assets/metric-hooks/prepare-commit-msg +3 -1
  18. package/dist/src/codewatch/config.d.ts +17 -0
  19. package/dist/src/codewatch/config.d.ts.map +1 -0
  20. package/dist/src/codewatch/config.js +40 -0
  21. package/dist/src/codewatch/config.js.map +1 -0
  22. package/dist/src/codewatch/gitleaks.d.ts +49 -0
  23. package/dist/src/codewatch/gitleaks.d.ts.map +1 -0
  24. package/dist/src/codewatch/gitleaks.js +160 -0
  25. package/dist/src/codewatch/gitleaks.js.map +1 -0
  26. package/dist/src/codewatch/globalHooks.d.ts +52 -0
  27. package/dist/src/codewatch/globalHooks.d.ts.map +1 -0
  28. package/dist/src/codewatch/globalHooks.js +235 -0
  29. package/dist/src/codewatch/globalHooks.js.map +1 -0
  30. package/dist/src/codewatch/hooks.d.ts +38 -0
  31. package/dist/src/codewatch/hooks.d.ts.map +1 -0
  32. package/dist/src/codewatch/hooks.js +176 -0
  33. package/dist/src/codewatch/hooks.js.map +1 -0
  34. package/dist/src/codewatch/prepush.d.ts +37 -0
  35. package/dist/src/codewatch/prepush.d.ts.map +1 -0
  36. package/dist/src/codewatch/prepush.js +231 -0
  37. package/dist/src/codewatch/prepush.js.map +1 -0
  38. package/dist/src/codewatch/results.d.ts +90 -0
  39. package/dist/src/codewatch/results.d.ts.map +1 -0
  40. package/dist/src/codewatch/results.js +121 -0
  41. package/dist/src/codewatch/results.js.map +1 -0
  42. package/dist/src/codewatch/reviewers/claude.d.ts +9 -0
  43. package/dist/src/codewatch/reviewers/claude.d.ts.map +1 -0
  44. package/dist/src/codewatch/reviewers/claude.js +48 -0
  45. package/dist/src/codewatch/reviewers/claude.js.map +1 -0
  46. package/dist/src/codewatch/reviewers/index.d.ts +39 -0
  47. package/dist/src/codewatch/reviewers/index.d.ts.map +1 -0
  48. package/dist/src/codewatch/reviewers/index.js +97 -0
  49. package/dist/src/codewatch/reviewers/index.js.map +1 -0
  50. package/dist/src/codewatch/reviewers/kiro.d.ts +14 -0
  51. package/dist/src/codewatch/reviewers/kiro.d.ts.map +1 -0
  52. package/dist/src/codewatch/reviewers/kiro.js +32 -0
  53. package/dist/src/codewatch/reviewers/kiro.js.map +1 -0
  54. package/dist/src/codewatch/reviewers/types.d.ts +56 -0
  55. package/dist/src/codewatch/reviewers/types.d.ts.map +1 -0
  56. package/dist/src/codewatch/reviewers/types.js +91 -0
  57. package/dist/src/codewatch/reviewers/types.js.map +1 -0
  58. package/dist/src/codewatch/rules.d.ts +12 -0
  59. package/dist/src/codewatch/rules.d.ts.map +1 -0
  60. package/dist/src/codewatch/rules.js +27 -0
  61. package/dist/src/codewatch/rules.js.map +1 -0
  62. package/dist/src/commands/_top/dashboard.d.ts +17 -0
  63. package/dist/src/commands/_top/dashboard.d.ts.map +1 -0
  64. package/dist/src/commands/_top/dashboard.js +76 -0
  65. package/dist/src/commands/_top/dashboard.js.map +1 -0
  66. package/dist/src/commands/bedrock-protection/scan-account.d.ts.map +1 -1
  67. package/dist/src/commands/bedrock-protection/scan-account.js +2 -1
  68. package/dist/src/commands/bedrock-protection/scan-account.js.map +1 -1
  69. package/dist/src/commands/bedrock-protection/scan-org.d.ts.map +1 -1
  70. package/dist/src/commands/bedrock-protection/scan-org.js +16 -4
  71. package/dist/src/commands/bedrock-protection/scan-org.js.map +1 -1
  72. package/dist/src/commands/bootstrapper/install-eval-harness.d.ts.map +1 -1
  73. package/dist/src/commands/bootstrapper/install-eval-harness.js +15 -48
  74. package/dist/src/commands/bootstrapper/install-eval-harness.js.map +1 -1
  75. package/dist/src/commands/bootstrapper/install-git-hooks.d.ts +13 -0
  76. package/dist/src/commands/bootstrapper/install-git-hooks.d.ts.map +1 -1
  77. package/dist/src/commands/bootstrapper/install-git-hooks.js +28 -147
  78. package/dist/src/commands/bootstrapper/install-git-hooks.js.map +1 -1
  79. package/dist/src/commands/bootstrapper/setup-otel-sync.d.ts.map +1 -1
  80. package/dist/src/commands/bootstrapper/setup-otel-sync.js +5 -39
  81. package/dist/src/commands/bootstrapper/setup-otel-sync.js.map +1 -1
  82. package/dist/src/commands/codewatch/pre-commit.d.ts +7 -0
  83. package/dist/src/commands/codewatch/pre-commit.d.ts.map +1 -0
  84. package/dist/src/commands/codewatch/pre-commit.js +30 -0
  85. package/dist/src/commands/codewatch/pre-commit.js.map +1 -0
  86. package/dist/src/commands/codewatch/pre-push.d.ts +16 -0
  87. package/dist/src/commands/codewatch/pre-push.d.ts.map +1 -0
  88. package/dist/src/commands/codewatch/pre-push.js +32 -0
  89. package/dist/src/commands/codewatch/pre-push.js.map +1 -0
  90. package/dist/src/commands/codewatch/report.d.ts +22 -0
  91. package/dist/src/commands/codewatch/report.d.ts.map +1 -0
  92. package/dist/src/commands/codewatch/report.js +64 -0
  93. package/dist/src/commands/codewatch/report.js.map +1 -0
  94. package/dist/src/commands/enable/codewatch.d.ts +15 -0
  95. package/dist/src/commands/enable/codewatch.d.ts.map +1 -0
  96. package/dist/src/commands/enable/codewatch.js +186 -0
  97. package/dist/src/commands/enable/codewatch.js.map +1 -0
  98. package/dist/src/commands/git/claude-session-context.d.ts.map +1 -1
  99. package/dist/src/commands/git/claude-session-context.js +2 -1
  100. package/dist/src/commands/git/claude-session-context.js.map +1 -1
  101. package/dist/src/commands/git/commit-trailers.js +1 -1
  102. package/dist/src/commands/git/commit-trailers.js.map +1 -1
  103. package/dist/src/commands/workshop/verify-setup.d.ts.map +1 -1
  104. package/dist/src/commands/workshop/verify-setup.js +5 -3
  105. package/dist/src/commands/workshop/verify-setup.js.map +1 -1
  106. package/dist/src/dashboard/codewatch.d.ts +106 -0
  107. package/dist/src/dashboard/codewatch.d.ts.map +1 -0
  108. package/dist/src/dashboard/codewatch.js +119 -0
  109. package/dist/src/dashboard/codewatch.js.map +1 -0
  110. package/dist/src/dashboard/commits.d.ts +96 -0
  111. package/dist/src/dashboard/commits.d.ts.map +1 -0
  112. package/dist/src/dashboard/commits.js +207 -0
  113. package/dist/src/dashboard/commits.js.map +1 -0
  114. package/dist/src/dashboard/page.d.ts +10 -0
  115. package/dist/src/dashboard/page.d.ts.map +1 -0
  116. package/dist/src/dashboard/page.js +315 -0
  117. package/dist/src/dashboard/page.js.map +1 -0
  118. package/dist/src/dashboard/payload.d.ts +111 -0
  119. package/dist/src/dashboard/payload.d.ts.map +1 -0
  120. package/dist/src/dashboard/payload.js +93 -0
  121. package/dist/src/dashboard/payload.js.map +1 -0
  122. package/dist/src/dashboard/report.d.ts +99 -0
  123. package/dist/src/dashboard/report.d.ts.map +1 -0
  124. package/dist/src/dashboard/report.js +300 -0
  125. package/dist/src/dashboard/report.js.map +1 -0
  126. package/dist/src/dashboard/repos.d.ts +58 -0
  127. package/dist/src/dashboard/repos.d.ts.map +1 -0
  128. package/dist/src/dashboard/repos.js +111 -0
  129. package/dist/src/dashboard/repos.js.map +1 -0
  130. package/dist/src/dashboard/server.d.ts +52 -0
  131. package/dist/src/dashboard/server.d.ts.map +1 -0
  132. package/dist/src/dashboard/server.js +163 -0
  133. package/dist/src/dashboard/server.js.map +1 -0
  134. package/dist/src/dashboard/setup.d.ts +52 -0
  135. package/dist/src/dashboard/setup.d.ts.map +1 -0
  136. package/dist/src/dashboard/setup.js +129 -0
  137. package/dist/src/dashboard/setup.js.map +1 -0
  138. package/dist/src/dashboard/usage.d.ts +43 -0
  139. package/dist/src/dashboard/usage.d.ts.map +1 -0
  140. package/dist/src/dashboard/usage.js +88 -0
  141. package/dist/src/dashboard/usage.js.map +1 -0
  142. package/dist/src/index.d.ts +1 -0
  143. package/dist/src/index.d.ts.map +1 -1
  144. package/dist/src/index.js +40 -1
  145. package/dist/src/index.js.map +1 -1
  146. package/dist/src/utils/audit-bedrock.d.ts +1 -0
  147. package/dist/src/utils/audit-bedrock.d.ts.map +1 -1
  148. package/dist/src/utils/audit-bedrock.js +133 -0
  149. package/dist/src/utils/audit-bedrock.js.map +1 -1
  150. package/dist/src/utils/audit-org.d.ts +1 -0
  151. package/dist/src/utils/audit-org.d.ts.map +1 -1
  152. package/dist/src/utils/audit-org.js +155 -0
  153. package/dist/src/utils/audit-org.js.map +1 -1
  154. package/dist/src/utils/otel-sync-schedule.d.ts +22 -0
  155. package/dist/src/utils/otel-sync-schedule.d.ts.map +1 -0
  156. package/dist/src/utils/otel-sync-schedule.js +68 -0
  157. package/dist/src/utils/otel-sync-schedule.js.map +1 -0
  158. package/dist/src/utils/tool-check.d.ts +14 -0
  159. package/dist/src/utils/tool-check.d.ts.map +1 -0
  160. package/dist/src/utils/tool-check.js +42 -0
  161. package/dist/src/utils/tool-check.js.map +1 -0
  162. package/package.json +3 -2
@@ -6,7 +6,8 @@
6
6
  # - OIDC provider configured for GitHub Actions in your AWS account
7
7
  # - IAM role with bedrock:InvokeModel and events:PutEvents permissions
8
8
  # - Repository secret PRISM_METRICS_ROLE_ARN set to the OIDC role ARN
9
- # - .prism/config.json committed to the repo (created by: bash prism-cli bootstrapper install-git-hooks)
9
+ # - .prism/config.json committed to the repo with a "team_id" key; add it by hand, e.g.
10
+ # {"team_id": "team-payments"} (prism-cli enable codewatch keeps existing keys in this file)
10
11
  # - .prism/eval-harness/ installed (run: bash prism-cli bootstrapper install-eval-harness)
11
12
  # - (Optional) Agent entry point supports --mock flag for dry-run execution
12
13
 
@@ -4,7 +4,8 @@
4
4
  #
5
5
  # Required setup:
6
6
  # - Kiro API key stored as repository secret: KIRO_API_KEY
7
- # - .kiro/steering/code-review.md in your repo (review rules)
7
+ # - .prism/review/rules.md (shared with the local pre-push review) or
8
+ # .kiro/steering/code-review.md in your repo (review rules)
8
9
  # - (Optional) PRISM_METRICS_ROLE_ARN for EventBridge metrics emission
9
10
  # - (Optional) AWS Continuum configured for security scanning
10
11
  #
@@ -236,6 +237,15 @@ jobs:
236
237
  exit 0
237
238
  fi
238
239
 
240
+ # Shared rule set: .prism/review/rules.md is the one source for this
241
+ # gate and the local pre-push review (prism-cli enable security).
242
+ # kiro-cli loads rules from .kiro/steering/, so sync it in for this run.
243
+ if [ -f .prism/review/rules.md ]; then
244
+ mkdir -p .kiro/steering
245
+ cp .prism/review/rules.md .kiro/steering/code-review.md
246
+ echo "Review rules: .prism/review/rules.md"
247
+ fi
248
+
239
249
  # Get all changed source files in this PR
240
250
  FILES=$(git diff --name-only "${BASE_SHA}..${HEAD_SHA}" \
241
251
  | grep -E '\.(ts|js|py|go|java|rs|tsx|jsx)$' \
@@ -186,6 +186,14 @@ prism-eval-gate:
186
186
  elif [ -z "${BASE_SHA}" ] || [ -z "${HEAD_SHA}" ]; then
187
187
  echo "⚠️ Missing base/head SHA — review skipped."
188
188
  else
189
+ # Shared rule set (also used by the local pre-push review). kiro-cli
190
+ # loads rules from .kiro/steering/, so sync it in for this run.
191
+ if [ -f .prism/review/rules.md ]; then
192
+ mkdir -p .kiro/steering
193
+ cp .prism/review/rules.md .kiro/steering/code-review.md
194
+ echo "Review rules: .prism/review/rules.md"
195
+ fi
196
+
189
197
  FILES=$(git diff --name-only "${BASE_SHA}..${HEAD_SHA}" \
190
198
  | grep -E '\.(ts|js|py|go|java|rs|tsx|jsx)$' \
191
199
  | grep -vE '\.test\.|\.spec\.|__test__|_test\.' \
@@ -24,6 +24,9 @@ import * as cognito from 'aws-cdk-lib/aws-cognito';
24
24
  import * as s3 from 'aws-cdk-lib/aws-s3';
25
25
  import * as kms from 'aws-cdk-lib/aws-kms';
26
26
  import * as logs from 'aws-cdk-lib/aws-logs';
27
+ import * as events from 'aws-cdk-lib/aws-events';
28
+ import * as targets from 'aws-cdk-lib/aws-events-targets';
29
+ import * as iam from 'aws-cdk-lib/aws-iam';
27
30
  import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
28
31
  import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
29
32
  import { HttpJwtAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
@@ -32,6 +35,7 @@ import * as ssm from 'aws-cdk-lib/aws-ssm';
32
35
  import * as path from 'path';
33
36
  import { Construct } from 'constructs';
34
37
  import { NagSuppressions } from 'cdk-nag';
38
+ import { PRICES_KEY } from '../lambda/pricing/prices-key';
35
39
 
36
40
  /** codeburn sync fixed loopback callback ports (see codeburn src/sync/auth.ts). */
37
41
  const CODEBURN_CALLBACK_PORTS = [19876, 19877, 19878];
@@ -49,6 +53,57 @@ export interface OtelCollectorConstructProps {
49
53
  externalClientId?: string;
50
54
  /** JWT claim used as the user identity key (default: username for Cognito, sub for BYO). */
51
55
  identityClaim?: string;
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Federated IdP on the Cognito pool (Okta, Entra ID, any OIDC provider).
59
+ //
60
+ // Distinct from the BYO-IdP props above. Those REPLACE Cognito as the token
61
+ // issuer and drop the machine-to-machine client with it. These keep Cognito as
62
+ // the issuer and add an upstream provider behind it, so the coding agent's
63
+ // client-credentials path survives.
64
+ //
65
+ // All five must be supplied together; a partial set throws rather than
66
+ // half-configuring an IdP that fails at first login.
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /** Provider name, e.g. 'CorpIdP'. Becomes the `<name>_` username prefix. */
70
+ idpName?: string;
71
+ /** OIDC issuer URL, e.g. https://idp.example.com */
72
+ idpIssuer?: string;
73
+ /** OAuth client id registered with the IdP for this user pool. */
74
+ idpClientId?: string;
75
+ /**
76
+ * ARN or name of a Secrets Manager secret holding the IdP client secret.
77
+ *
78
+ * A REFERENCE, never the value: the secret is resolved by a CloudFormation
79
+ * dynamic reference at deploy time, so the plaintext is never written to the
80
+ * template, CloudFormation state, or CI logs. Passing the secret itself
81
+ * through CDK context would put it in `cdk.context.json` and the synthesized
82
+ * template, which is the antipattern the reference exists to avoid.
83
+ *
84
+ * Provide either a plain-text secret whose whole value is the client secret,
85
+ * or a JSON secret with a `clientSecret` key (set idpClientSecretJsonKey).
86
+ *
87
+ * CAVEAT: CloudFormation resolves a dynamic reference only when the resource
88
+ * containing it is created or updated. Rotating the secret in Secrets Manager
89
+ * does NOT reach Cognito on its own — the IdP resource must change too.
90
+ */
91
+ idpClientSecretArn?: string;
92
+ /** JSON key inside the secret holding the client secret. Omit for a plain-text secret. */
93
+ idpClientSecretJsonKey?: string;
94
+ /**
95
+ * Scopes Cognito requests FROM the IdP. Defaults to 'openid email'.
96
+ *
97
+ * `email` is load-bearing and the default exists because of it: without it the
98
+ * IdP returns no email claim, Cognito stores `email: null` on every federated
99
+ * user, and the identity falls back to the `<provider>_<alias>` username —
100
+ * which never matches the git author emails the CI census emits, silently
101
+ * splitting each developer into two identities on the dashboards.
102
+ *
103
+ * NOT the same as the app client's AllowedOAuthScopes, which governs
104
+ * app→Cognito rather than Cognito→IdP.
105
+ */
106
+ idpScopes?: string;
52
107
  }
53
108
 
54
109
  export class OtelCollectorConstruct extends Construct {
@@ -117,6 +172,84 @@ export class OtelCollectorConstruct extends Construct {
117
172
  cognitoDomain: { domainPrefix },
118
173
  });
119
174
 
175
+ // -----------------------------------------------------------------
176
+ // Optional federated IdP in front of the pool.
177
+ //
178
+ // Created BEFORE the app client so the client's
179
+ // supportedIdentityProviders can reference it; CloudFormation rejects a
180
+ // client naming a provider that does not exist yet, and addDependency
181
+ // below makes that ordering explicit rather than incidental.
182
+ // -----------------------------------------------------------------
183
+ const idpFields = [props.idpName, props.idpIssuer, props.idpClientId, props.idpClientSecretArn];
184
+ const idpConfigured = idpFields.some((f) => f !== undefined);
185
+ if (idpConfigured && idpFields.some((f) => f === undefined)) {
186
+ throw new Error(
187
+ 'Federated IdP requires idpName, idpIssuer, idpClientId and idpClientSecretArn together '
188
+ + '(context: -c otelIdpName=... -c otelIdpIssuer=... -c otelIdpClientId=... -c otelIdpClientSecretArn=...)',
189
+ );
190
+ }
191
+
192
+ let federatedIdp: cognito.CfnUserPoolIdentityProvider | undefined;
193
+ if (idpConfigured) {
194
+ // Build the dynamic reference as a LITERAL string.
195
+ //
196
+ // The obvious CDK spelling — Secret.fromSecretNameV2(...).secretValueFromJson(k)
197
+ // .unsafeUnwrap() — composes the ARN with `Ref: AWS::Partition`, so the
198
+ // property renders as an Fn::Join whose OUTPUT happens to be a
199
+ // {{resolve:...}} string. CloudFormation resolves dynamic references
200
+ // during template processing and does not reliably resolve one produced
201
+ // by an intrinsic; the risk is Cognito storing the literal
202
+ // "{{resolve:secretsmanager:...}}" as the client secret, which fails at
203
+ // first federated login with an upstream "invalid_client" and no failed
204
+ // resource to point at. Verified by synthesizing both forms.
205
+ //
206
+ // The secret id comes from CDK context, so it is a plain string and
207
+ // interpolation yields a literal with no intrinsic. Secrets Manager
208
+ // accepts either a friendly name (same account and region) or a full ARN.
209
+ const secretId = props.idpClientSecretArn!;
210
+ if (!/^(arn:[a-z0-9-]+:secretsmanager:[a-z0-9-]+:\d{12}:secret:[A-Za-z0-9/_+=.@-]+|[A-Za-z0-9/_+=.@-]+)$/.test(secretId)) {
211
+ throw new Error(
212
+ `idpClientSecretArn "${secretId}" is not a valid Secrets Manager secret name or ARN. `
213
+ + 'Expected a friendly name or a full arn:...:secretsmanager:...:secret:... ARN.',
214
+ );
215
+ }
216
+ const jsonKey = props.idpClientSecretJsonKey ?? '';
217
+ if (jsonKey && !/^[A-Za-z0-9_.-]+$/.test(jsonKey)) {
218
+ throw new Error(`idpClientSecretJsonKey "${jsonKey}" must be alphanumeric with . _ - only.`);
219
+ }
220
+ // Trailing `::` selects the current version stage (AWSCURRENT), so the
221
+ // reference tracks rotation without pinning a version id.
222
+ const clientSecretRef = `{{resolve:secretsmanager:${secretId}:SecretString:${jsonKey}::}}`;
223
+
224
+ federatedIdp = new cognito.CfnUserPoolIdentityProvider(this, 'FederatedIdp', {
225
+ userPoolId: this.userPool.userPoolId,
226
+ providerName: props.idpName!,
227
+ providerType: 'OIDC',
228
+ // A SUPERSET of what the console-created provider had, never a
229
+ // replacement. `username: sub` is what Cognito uses to derive the
230
+ // native federated username (`<ProviderName>_<sub>`); dropping it
231
+ // changes the username shape, which mints a second set of user
232
+ // records and splits every developer's history a second time. Only
233
+ // `email` and the name claims are additions.
234
+ //
235
+ // `email` is the load-bearing one: it is what lets the identity match
236
+ // the git author emails the CI census emits.
237
+ attributeMapping: {
238
+ username: 'sub',
239
+ email: 'EMAIL',
240
+ given_name: 'GIVEN_NAME',
241
+ family_name: 'FAMILY_NAME',
242
+ },
243
+ providerDetails: {
244
+ oidc_issuer: props.idpIssuer!,
245
+ client_id: props.idpClientId!,
246
+ client_secret: clientSecretRef,
247
+ authorize_scopes: props.idpScopes ?? 'openid email',
248
+ attributes_request_method: 'GET',
249
+ },
250
+ });
251
+ }
252
+
120
253
  // Public client (no secret) — codeburn does Authorization Code + PKCE
121
254
  // with loopback redirects. Cognito allows http:// for localhost, 127.0.0.1,
122
255
  // and [::1] (per CreateUserPoolClient API docs). codeburn uses 127.0.0.1.
@@ -132,10 +265,97 @@ export class OtelCollectorConstruct extends Construct {
132
265
  scopes: [cognito.OAuthScope.OPENID, cognito.OAuthScope.EMAIL, cognito.OAuthScope.PROFILE],
133
266
  callbackUrls,
134
267
  },
268
+ // Declared here so it cannot drift. Setting this in the console instead
269
+ // leaves CloudFormation believing the client is Cognito-only, and the
270
+ // next deploy of this stack silently reverts it — breaking federated
271
+ // login with no failed resource to point at.
272
+ supportedIdentityProviders: idpConfigured
273
+ ? [
274
+ cognito.UserPoolClientIdentityProvider.COGNITO,
275
+ cognito.UserPoolClientIdentityProvider.custom(props.idpName!),
276
+ ]
277
+ : [cognito.UserPoolClientIdentityProvider.COGNITO],
135
278
  preventUserExistenceErrors: true,
136
279
  accessTokenValidity: cdk.Duration.hours(1),
137
280
  refreshTokenValidity: cdk.Duration.days(30),
138
281
  });
282
+ if (federatedIdp) {
283
+ this.userPoolClient.node.defaultChild?.node.addDependency(federatedIdp);
284
+ }
285
+
286
+ // -----------------------------------------------------------------
287
+ // Pre token generation (V2_0) — puts `email` in the ACCESS token.
288
+ //
289
+ // Attached only when a federated IdP is configured: for admin-created
290
+ // users the username IS the email, so the trigger would be a no-op that
291
+ // still billed an invocation on every token issuance.
292
+ //
293
+ // Inert without `identityClaim: 'email'` — see the note in
294
+ // pre-token-generation.ts. The warning below fires on the one
295
+ // half-configured combination that looks like it should work.
296
+ // -----------------------------------------------------------------
297
+ if (idpConfigured) {
298
+ // props.identityClaim is not yet folded into `identityClaim` at this
299
+ // point in the branch, so resolve the same default inline.
300
+ const claimForWarning = props.identityClaim ?? 'username';
301
+ if (claimForWarning !== 'email') {
302
+ cdk.Annotations.of(this).addWarning(
303
+ 'A federated IdP is configured and the pre-token-generation trigger will add an `email` '
304
+ + 'claim to access tokens, but identityClaim is '
305
+ + `"${claimForWarning}" — the receiver checks that claim (and then \`username\`, which is `
306
+ + 'always present) before `email`, so the trigger has no effect. Deploy with '
307
+ + '-c otelIdentityClaim=email to key identities on email.',
308
+ );
309
+ }
310
+
311
+ const preTokenGen = new lambda.Function(this, 'PreTokenGeneration', {
312
+ functionName: 'prism-d1-pre-token-generation',
313
+ runtime: lambda.Runtime.NODEJS_22_X,
314
+ handler: 'pre-token-generation.handler',
315
+ // Deliberately NOT in the VPC: Cognito invokes this synchronously on
316
+ // the token-issuance path, and it makes no network calls, so a VPC
317
+ // attachment would add cold-start ENI latency to every sign-in for
318
+ // nothing.
319
+ code: lambda.Code.fromAsset(path.join(__dirname, '..', 'lambda'), {
320
+ bundling: {
321
+ image: lambda.Runtime.NODEJS_22_X.bundlingImage,
322
+ command: [
323
+ 'bash', '-c',
324
+ [
325
+ 'npm init -y > /dev/null 2>&1',
326
+ 'npm install --save esbuild > /dev/null 2>&1',
327
+ 'npx esbuild pre-token-generation.ts --bundle --platform=node --target=node22 --outfile=/asset-output/pre-token-generation.js --external:@aws-sdk/*',
328
+ ].join(' && '),
329
+ ],
330
+ local: {
331
+ tryBundle(outputDir: string): boolean {
332
+ try {
333
+ const { execSync } = require('child_process');
334
+ execSync(
335
+ `npx esbuild ${path.join(__dirname, '..', 'lambda', 'pre-token-generation.ts')} --bundle --platform=node --target=node22 --outfile=${path.join(outputDir, 'pre-token-generation.js')} --external:@aws-sdk/*`,
336
+ { stdio: 'pipe' },
337
+ );
338
+ return true;
339
+ } catch {
340
+ return false;
341
+ }
342
+ },
343
+ },
344
+ },
345
+ }),
346
+ // Token issuance blocks on this call, so keep the budget tight: the
347
+ // function reads one attribute off the event and returns.
348
+ timeout: cdk.Duration.seconds(5),
349
+ memorySize: 128,
350
+ logRetention: logs.RetentionDays.ONE_MONTH,
351
+ });
352
+
353
+ this.userPool.addTrigger(
354
+ cognito.UserPoolOperation.PRE_TOKEN_GENERATION_CONFIG,
355
+ preTokenGen,
356
+ cognito.LambdaVersion.V2_0,
357
+ );
358
+ }
139
359
 
140
360
  issuer = `https://cognito-idp.${cdk.Aws.REGION}.amazonaws.com/${this.userPool.userPoolId}`;
141
361
  clientId = this.userPoolClient.userPoolClientId;
@@ -293,9 +513,101 @@ export class OtelCollectorConstruct extends Construct {
293
513
 
294
514
  props.aiUsageTable.grantReadWriteData(receiver);
295
515
  this.archiveBucket.grantPut(receiver);
516
+ // The LiteLLM price table the refresh Lambda maintains (pricing/price-source.ts).
517
+ // Exact key, read only. KMS decrypt comes from grantEncryptDecrypt below.
518
+ const pricesObjectArn = this.archiveBucket.arnForObjects(PRICES_KEY);
519
+ receiver.addToRolePolicy(new iam.PolicyStatement({
520
+ actions: ['s3:GetObject'],
521
+ resources: [pricesObjectArn],
522
+ }));
296
523
  props.kmsKey.grantEncryptDecrypt(receiver);
297
524
  this.receiverFunction = receiver;
298
525
 
526
+ // -------------------------------------------------------
527
+ // Price refresh + reprice — daily
528
+ // Keeps the receiver's LiteLLM fallback table current in S3, then
529
+ // corrects spans stored at $0 whose model has since been priced.
530
+ // See lib/lambda/pricing-refresh.ts.
531
+ //
532
+ // Deliberately NOT in the VPC: the PRISM VPC has no NAT gateway, and this
533
+ // function's one job that needs a network path is an HTTPS GET to
534
+ // raw.githubusercontent.com. It accepts no inbound traffic, and reaches
535
+ // only S3 and DynamoDB besides, both over their public endpoints with IAM.
536
+ // -------------------------------------------------------
537
+ const pricingRefresh = new lambda.Function(this, 'PricingRefresh', {
538
+ functionName: 'prism-d1-pricing-refresh',
539
+ runtime: lambda.Runtime.NODEJS_22_X,
540
+ handler: 'pricing-refresh.handler',
541
+ code: lambda.Code.fromAsset(path.join(__dirname, '..', 'lambda'), {
542
+ bundling: {
543
+ image: lambda.Runtime.NODEJS_22_X.bundlingImage,
544
+ command: [
545
+ 'bash', '-c',
546
+ [
547
+ 'npm init -y > /dev/null 2>&1',
548
+ 'npm install --save @aws-sdk/client-dynamodb @aws-sdk/client-s3 esbuild > /dev/null 2>&1',
549
+ 'npx esbuild pricing-refresh.ts --bundle --platform=node --target=node22 --outfile=/asset-output/pricing-refresh.js --external:@aws-sdk/*',
550
+ ].join(' && '),
551
+ ],
552
+ local: {
553
+ tryBundle(outputDir: string): boolean {
554
+ try {
555
+ const { execSync } = require('child_process');
556
+ execSync(
557
+ `npx esbuild ${path.join(__dirname, '..', 'lambda', 'pricing-refresh.ts')} --bundle --platform=node --target=node22 --outfile=${path.join(outputDir, 'pricing-refresh.js')} --external:@aws-sdk/*`,
558
+ { stdio: 'pipe' },
559
+ );
560
+ return true;
561
+ } catch {
562
+ return false;
563
+ }
564
+ },
565
+ },
566
+ },
567
+ }),
568
+ // A full-table scan plus, on the first run, a read of the raw archive to
569
+ // recover cache-token counts for spans stored before they were kept.
570
+ timeout: cdk.Duration.minutes(15),
571
+ memorySize: 1024,
572
+ environment: {
573
+ AI_USAGE_TABLE: props.aiUsageTable.tableName,
574
+ ARCHIVE_BUCKET: this.archiveBucket.bucketName,
575
+ },
576
+ logRetention: logs.RetentionDays.ONE_MONTH,
577
+ description: 'Daily: refresh the LiteLLM price table in S3, then reprice usage spans stored at $0',
578
+ });
579
+ props.aiUsageTable.grantReadWriteData(pricingRefresh);
580
+ // Read the raw archive (to recover cache-token counts), and read/write the
581
+ // one price-table object. No delete: an old table is only ever overwritten.
582
+ this.archiveBucket.grantRead(pricingRefresh, 'otlp/*');
583
+ pricingRefresh.addToRolePolicy(new iam.PolicyStatement({
584
+ actions: ['s3:GetObject', 's3:PutObject'],
585
+ resources: [pricesObjectArn],
586
+ }));
587
+ props.kmsKey.grantEncryptDecrypt(pricingRefresh);
588
+
589
+ new events.Rule(this, 'PricingRefreshSchedule', {
590
+ ruleName: 'prism-d1-pricing-refresh-daily',
591
+ description: 'Refresh LiteLLM prices and reprice $0 spans',
592
+ // 06:17 UTC: off the hour so it is not queued behind other schedules.
593
+ schedule: events.Schedule.cron({ minute: '17', hour: '6' }),
594
+ // No retry: a failed run already repriced what it could and the next day's
595
+ // run starts from the same state. Errors surface on the Lambda metric.
596
+ targets: [new targets.LambdaFunction(pricingRefresh, { retryAttempts: 0 })],
597
+ });
598
+
599
+ NagSuppressions.addResourceSuppressions(pricingRefresh, [
600
+ {
601
+ id: 'AwsSolutions-IAM5',
602
+ reason: 'grantRead scopes S3 reads to the otlp/* prefix of the single archive bucket (raw OTLP batches, read to recover cache-token counts); grantReadWriteData adds the AI-usage table GSI index wildcard.',
603
+ appliesTo: [
604
+ 'Action::s3:GetObject*', 'Action::s3:GetBucket*', 'Action::s3:List*',
605
+ { regex: '/^Resource::<OtelCollectorArchiveBucket.*\\.Arn>\\/otlp\\/\\*$/g' },
606
+ { regex: '/^Resource::<AiUsageTable.*\\.Arn>\\/index\\/\\*$/g' },
607
+ ],
608
+ },
609
+ ], true);
610
+
299
611
  // -------------------------------------------------------
300
612
  // CloudWatch Custom Widget: Developer Productivity
301
613
  // Invoked by CloudWatch dashboards with the VIEWING user's IAM creds
@@ -215,31 +215,31 @@ export class DashboardStack extends cdk.Stack {
215
215
  updateOnResize: false,
216
216
  updateOnTimeRangeChange: true,
217
217
  })] : []),
218
- new cloudwatch.GraphWidget({
219
- title: 'AI Spend Trend (Weekly)',
220
- left: [weeklySum('AICostUSD', 'Spend ($)')],
218
+ new cloudwatch.CustomWidget({
219
+ functionArn: props?.velocityWidgetArn ?? '',
220
+ title: 'AI Spend Trend',
221
221
  width: 8,
222
222
  height: 6,
223
- leftYAxis: { min: 0, label: 'USD' },
223
+ params: { view: 'ai-spend-trend' },
224
+ updateOnRefresh: true,
225
+ updateOnResize: false,
226
+ updateOnTimeRangeChange: true,
224
227
  }),
225
- new cloudwatch.GraphWidget({
226
- title: 'Cost per Shipped Commit (Weekly)',
227
- left: [
228
- // The ROI narrative: spend can rise while this falls, which means AI
229
- // is getting more efficient per unit of shipped work.
230
- new cloudwatch.MathExpression({
231
- expression: 'FILL(IF(FILL(execMerged, 0) > 0, FILL(execCost, 0) / FILL(execMerged, 0)), REPEAT)',
232
- usingMetrics: {
233
- execCost: weeklySum('AICostUSD'),
234
- execMerged: weeklySum('MergedAICommits'),
235
- },
236
- label: '$ / shipped AI commit',
237
- period: cdk.Duration.days(7),
238
- }),
239
- ],
228
+ // The ROI narrative: spend can rise while this falls, which means AI is
229
+ // getting more efficient per unit of shipped work. That reading only holds
230
+ // if both terms are bucketed by when the work happened — the CloudWatch
231
+ // version this replaces divided two ingest-timestamped metrics, so a
232
+ // backfill collapsed the ratio and read as a 94% efficiency gain that
233
+ // never occurred. See renderCostPerCommitTrend.
234
+ new cloudwatch.CustomWidget({
235
+ functionArn: props?.velocityWidgetArn ?? '',
236
+ title: 'Cost per Shipped Commit',
240
237
  width: 8,
241
238
  height: 6,
242
- leftYAxis: { min: 0, label: 'USD' },
239
+ params: { view: 'cost-per-commit-trend' },
240
+ updateOnRefresh: true,
241
+ updateOnResize: false,
242
+ updateOnTimeRangeChange: true,
243
243
  }),
244
244
  );
245
245
 
@@ -676,23 +676,6 @@ export class DashboardStack extends cdk.Stack {
676
676
  );
677
677
 
678
678
  // --- Row 1: Org KPIs (NaN-guarded metric math, follow time range) ---
679
- const dailyMetric = (metricName: string, id: string): cloudwatch.Metric =>
680
- new cloudwatch.Metric({
681
- namespace: METRIC_NAMESPACE,
682
- metricName,
683
- statistic: 'Sum',
684
- period: cdk.Duration.days(1),
685
- label: id,
686
- });
687
- const ratioKpi = (title: string, expression: string, using: Record<string, cloudwatch.IMetric>): cloudwatch.SingleValueWidget =>
688
- new cloudwatch.SingleValueWidget({
689
- title,
690
- metrics: [new cloudwatch.MathExpression({ expression, usingMetrics: using, label: title, period: cdk.Duration.days(1) })],
691
- setPeriodToTimeRange: true,
692
- width: 6,
693
- height: 4,
694
- });
695
-
696
679
  devDashboard.addWidgets(
697
680
  // AI Share and AI Merge Rate are DDB-backed (accurate, no CloudWatch delay)
698
681
  new cloudwatch.CustomWidget({
@@ -715,15 +698,31 @@ export class DashboardStack extends cdk.Stack {
715
698
  updateOnResize: false,
716
699
  updateOnTimeRangeChange: true,
717
700
  }),
718
- ratioKpi('Cost per Shipped Commit ($)', 'IF(FILL(kMerged2, 0) > 0, FILL(kCost, 0) / FILL(kMerged2, 0))', {
719
- kCost: dailyMetric('AICostUSD', 'kCost'), kMerged2: dailyMetric('MergedAICommits', 'kMerged2'),
701
+ // Both of these read the attribution store rather than CloudWatch metric
702
+ // math. The metric versions divided/summed AICostUSD and MergedAICommits,
703
+ // which are ingest-timestamped and subject to CloudWatch's two-week
704
+ // datapoint cutoff, and carry no user dimension — so they disagreed with
705
+ // the store-backed figures in row 3 on both terms AND ignored the
706
+ // Developer variable. See renderCostPerCommitKpi for the measured gap.
707
+ new cloudwatch.CustomWidget({
708
+ functionArn: props.velocityWidgetArn ?? '',
709
+ title: 'Cost per Shipped Commit ($)',
710
+ width: 6,
711
+ height: 4,
712
+ params: { view: 'cost-per-commit-kpi' },
713
+ updateOnRefresh: true,
714
+ updateOnResize: false,
715
+ updateOnTimeRangeChange: true,
720
716
  }),
721
- new cloudwatch.SingleValueWidget({
717
+ new cloudwatch.CustomWidget({
718
+ functionArn: props.velocityWidgetArn ?? '',
722
719
  title: 'AI Spend (range, $)',
723
- metrics: [dailyMetric('AICostUSD', 'AI Spend')],
724
- setPeriodToTimeRange: true,
725
720
  width: 6,
726
721
  height: 4,
722
+ params: { view: 'ai-spend-kpi' },
723
+ updateOnRefresh: true,
724
+ updateOnResize: false,
725
+ updateOnTimeRangeChange: true,
727
726
  }),
728
727
  );
729
728
 
@@ -740,12 +739,15 @@ export class DashboardStack extends cdk.Stack {
740
739
  updateOnResize: false,
741
740
  updateOnTimeRangeChange: true,
742
741
  }),
743
- new cloudwatch.GraphWidget({
742
+ new cloudwatch.CustomWidget({
743
+ functionArn: props?.velocityWidgetArn ?? '',
744
744
  title: 'AI Spend / Day',
745
- left: [dailyMetric('AICostUSD', 'Spend ($)')],
746
745
  width: 8,
747
746
  height: 6,
748
- leftYAxis: { min: 0, label: 'USD' },
747
+ params: { view: 'ai-spend-trend' },
748
+ updateOnRefresh: true,
749
+ updateOnResize: false,
750
+ updateOnTimeRangeChange: true,
749
751
  }),
750
752
  // Merge Rate Trend — DDB-backed line chart replacing CloudWatch AI Merge Ratio Trend
751
753
  new cloudwatch.CustomWidget({
@@ -806,6 +808,33 @@ export class DashboardStack extends cdk.Stack {
806
808
  }),
807
809
  );
808
810
 
811
+ // --- Row 5: Tools the model invoked (codeburn ai.tools) ---
812
+ // Store-backed like the spend tiles: OTEL#TOOLDAY rollups are bucketed by
813
+ // the call's own day and keep full history, so a backfilled sync push is
814
+ // not cut off at CloudWatch's two-week window. Follows the Developer filter.
815
+ devDashboard.addWidgets(
816
+ new cloudwatch.CustomWidget({
817
+ functionArn: props.velocityWidgetArn ?? '',
818
+ title: 'Tool Usage: Top Tools & MCP Servers',
819
+ width: 16,
820
+ height: 9,
821
+ params: { view: 'tool-usage-top', user: USER_TOKEN },
822
+ updateOnRefresh: true,
823
+ updateOnResize: false,
824
+ updateOnTimeRangeChange: true,
825
+ }),
826
+ new cloudwatch.CustomWidget({
827
+ functionArn: props.velocityWidgetArn ?? '',
828
+ title: 'Tool Calls / Day',
829
+ width: 8,
830
+ height: 9,
831
+ params: { view: 'tool-usage-trend', user: USER_TOKEN },
832
+ updateOnRefresh: true,
833
+ updateOnResize: false,
834
+ updateOnTimeRangeChange: true,
835
+ }),
836
+ );
837
+
809
838
  new cdk.CfnOutput(this, 'DevProductivityDashboardUrl', {
810
839
  value: `https://${this.region}.console.aws.amazon.com/cloudwatch/home?region=${this.region}#dashboards:name=PRISM-D1-Developer-Productivity`,
811
840
  description: 'Developer Productivity Dashboard URL',