@learncard/lca-api-service 1.4.0 → 1.5.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 (188) hide show
  1. package/.env.example +73 -0
  2. package/AGENTS.md +106 -10
  3. package/CHANGELOG.md +43 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/config/config.dev.json +23 -0
  6. package/config/config.example.json +22 -0
  7. package/config/config.json +1 -0
  8. package/config/config.production.json +12 -0
  9. package/config/config.scouts.dev.json +11 -0
  10. package/config/config.scouts.production.json +11 -0
  11. package/dist/app.d.ts +67 -3
  12. package/dist/cache/getDel.d.ts +2 -0
  13. package/dist/cache/getDel.test.d.ts +1 -0
  14. package/dist/cache/login-tickets.d.ts +12 -0
  15. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  16. package/dist/config/cacheEnvironment.d.ts +16 -0
  17. package/dist/config/environment.d.ts +101 -0
  18. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  19. package/dist/config/escrowDeployment.test.d.ts +1 -0
  20. package/dist/config/oidcEnvironment.d.ts +74 -0
  21. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  22. package/dist/config/stageConfig.d.ts +62 -0
  23. package/dist/config/stageConfig.test.d.ts +1 -0
  24. package/dist/didkit_wasm_bg.wasm +0 -0
  25. package/dist/docker-entry.d.ts +1 -1
  26. package/dist/helpers/auth.helpers.d.ts +1 -1
  27. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  28. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  29. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  30. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  31. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  32. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  33. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  34. package/dist/helpers/oidc.helpers.d.ts +47 -0
  35. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  36. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  37. package/dist/helpers/social-token.helpers.d.ts +19 -0
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.js +2 -2
  40. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  41. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  42. package/dist/models/AuthSubject.d.ts +20 -0
  43. package/dist/models/EscrowHold.d.ts +79 -1
  44. package/dist/models/Notification.d.ts +5 -0
  45. package/dist/models/UserKey.d.ts +11 -1
  46. package/dist/models/index.d.ts +1 -0
  47. package/dist/oidc.d.ts +5 -0
  48. package/dist/routes/auth.d.ts +31 -0
  49. package/dist/routes/escrow.d.ts +18 -4
  50. package/dist/routes/keys.d.ts +1 -0
  51. package/dist/routes/notifications.d.ts +5 -0
  52. package/dist/routes/signing-authority.d.ts +14 -0
  53. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  54. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  55. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  56. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  57. package/dist/services/escrow-enclave/index.d.ts +16 -0
  58. package/dist/services/escrow-enclave/notifications.d.ts +19 -4
  59. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  60. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  61. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  62. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  63. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  64. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  65. package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
  66. package/dist/services/escrow-enclave/types.d.ts +52 -8
  67. package/dist/types/notifications.d.ts +5 -0
  68. package/esbuildPlugins.cjs +1 -0
  69. package/lambda.ts +27 -117
  70. package/lambdaApp.ts +146 -0
  71. package/oidcLambda.test.ts +38 -0
  72. package/oidcLambda.ts +18 -0
  73. package/oidcLambdaApp.ts +11 -0
  74. package/package.json +23 -13
  75. package/scripts/README.md +88 -0
  76. package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
  77. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  78. package/scripts/escrow-blob-mode-report.ts +465 -0
  79. package/scripts/keycloak-admin.ts +108 -0
  80. package/scripts/keycloak-provider-links.ts +147 -0
  81. package/scripts/print-seed-rollout-flags.ts +21 -0
  82. package/scripts/provision-keycloak-users.ts +419 -0
  83. package/seedMigrationApp.test.ts +145 -0
  84. package/seedMigrationApp.ts +50 -0
  85. package/seedMigrationLambda.test.ts +30 -135
  86. package/seedMigrationLambda.ts +15 -46
  87. package/serverless-local.yml +25 -1
  88. package/serverless.function-env.cjs +58 -0
  89. package/serverless.yml +172 -37
  90. package/src/accesslayer/notifications/index.ts +9 -2
  91. package/src/accesslayer/signing-authority/index.ts +4 -1
  92. package/src/app.ts +2 -0
  93. package/src/cache/getDel.test.ts +34 -0
  94. package/src/cache/getDel.ts +32 -0
  95. package/src/cache/index.ts +1 -1
  96. package/src/cache/login-tickets.ts +26 -0
  97. package/src/config/applyDockerStageConfig.ts +6 -0
  98. package/src/config/cacheEnvironment.ts +34 -0
  99. package/src/config/environment.test.ts +45 -2
  100. package/src/config/environment.ts +90 -23
  101. package/src/config/escrowBundleMode.test.ts +52 -0
  102. package/src/config/escrowDeployment.test.ts +114 -0
  103. package/src/config/oidcEnvironment.ts +59 -0
  104. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  105. package/src/config/stageConfig.test.ts +26 -0
  106. package/src/config/stageConfig.ts +24 -0
  107. package/src/docker-entry.ts +9 -0
  108. package/src/helpers/auth.helpers.ts +68 -30
  109. package/src/helpers/escrowCancelLink.test.ts +60 -0
  110. package/src/helpers/escrowCancelLink.ts +38 -0
  111. package/src/helpers/escrowCancelToken.test.ts +105 -0
  112. package/src/helpers/escrowCancelToken.ts +47 -0
  113. package/src/helpers/keycloak.helpers.ts +120 -0
  114. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  115. package/src/helpers/login-ticket.helpers.ts +91 -0
  116. package/src/helpers/oidc.helpers.ts +354 -0
  117. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  118. package/src/helpers/rate-limit.helpers.ts +73 -0
  119. package/src/helpers/social-token.helpers.ts +144 -0
  120. package/src/index.ts +1 -0
  121. package/src/jobs/escrowBlobRewrap.ts +121 -0
  122. package/src/jobs/escrowHoldReminders.ts +177 -0
  123. package/src/models/AuthSubject.ts +147 -0
  124. package/src/models/EscrowHold.ts +321 -5
  125. package/src/models/UserKey.ts +91 -6
  126. package/src/models/index.ts +1 -0
  127. package/src/oidc.ts +371 -0
  128. package/src/routes/auth.ts +173 -0
  129. package/src/routes/escrow.ts +281 -34
  130. package/src/routes/keys.ts +5 -0
  131. package/src/routes/signing-authority.ts +39 -1
  132. package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
  133. package/src/services/escrow-enclave/README.md +81 -10
  134. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  135. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  136. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  137. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  138. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  139. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  140. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  141. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  142. package/src/services/escrow-enclave/cancellation.ts +59 -0
  143. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  144. package/src/services/escrow-enclave/index.ts +145 -7
  145. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  146. package/src/services/escrow-enclave/notifications.ts +340 -6
  147. package/src/services/escrow-enclave/production.test.ts +34 -0
  148. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  149. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  150. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  151. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  152. package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
  153. package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
  154. package/src/services/escrow-enclave/types.ts +63 -17
  155. package/test/auth-tickets.spec.ts +480 -0
  156. package/test/helpers/keycloak-broker.ts +181 -0
  157. package/test/helpers/live-mongo.ts +9 -0
  158. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  159. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  160. package/test/keycloak-admin.spec.ts +58 -0
  161. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  162. package/test/keycloak-migration.integration.spec.ts +266 -0
  163. package/test/keycloak-provider-links.spec.ts +201 -0
  164. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  165. package/test/keycloak-verify.integration.spec.ts +100 -0
  166. package/test/keycloak-verify.spec.ts +307 -0
  167. package/test/keys-escrow.spec.ts +963 -24
  168. package/test/models/authSubjectIndexes.spec.ts +132 -0
  169. package/test/models/escrow.spec.ts +375 -13
  170. package/test/notifications.spec.ts +4 -0
  171. package/test/oidc-e2e/broker.spec.ts +116 -0
  172. package/test/oidc-e2e/compose.yaml +33 -0
  173. package/test/oidc-e2e/fixtures.ts +245 -0
  174. package/test/oidc-e2e/lock.test.mjs +73 -0
  175. package/test/oidc-e2e/playwright.config.ts +21 -0
  176. package/test/oidc-e2e/run.mjs +312 -0
  177. package/test/oidc-e2e/tsconfig.json +10 -0
  178. package/test/oidc-e2e/types.ts +21 -0
  179. package/test/oidc.integration.spec.ts +54 -0
  180. package/test/oidc.spec.ts +697 -0
  181. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  182. package/test/provision-keycloak-users.spec.ts +299 -0
  183. package/test/signing-authority.spec.ts +50 -0
  184. package/tsconfig.build.json +1 -1
  185. package/tsconfig.json +1 -0
  186. package/vitest.config.ts +24 -1
  187. package/vitest.integration.config.ts +20 -3
  188. package/serverless.escrow-env.cjs +0 -20
@@ -0,0 +1,227 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ // Pure-logic test only: the script module (and, transitively, src/models/UserKey.ts)
4
+ // imports the real Mongo connection module for the CLI's live-query path, which
5
+ // requires a real Mongo global outside test-mode integration setup. Stub it so
6
+ // importing the module here never needs one — same pattern as
7
+ // src/models/UserKey.escrowPin.test.ts, but by relative path: `scripts/` sits
8
+ // outside this package's tsconfig `include`, so path-alias mocks (`@mongo`)
9
+ // resolved from here don't reliably match the alias-resolved import inside
10
+ // src/models/UserKey.ts, even though both point at the same file.
11
+ vi.mock('../src/mongo', () => ({ default: {} }));
12
+
13
+ import {
14
+ buildCloudWatchMetricPayload,
15
+ buildReport,
16
+ activeModeFromEnv,
17
+ classifyEnclaveKeyIds,
18
+ CLOUDWATCH_NAMESPACE,
19
+ type RawEscrowCounts,
20
+ } from './escrow-blob-mode-report';
21
+
22
+ const emptyRaw: RawEscrowCounts = {
23
+ totalUserKeys: 0,
24
+ enclaveModeCounts: [],
25
+ enclaveKeyIdCounts: [],
26
+ escrowPinEnabledCount: 0,
27
+ optedOutCount: 0,
28
+ pendingHoldsByPolicy: [],
29
+ };
30
+
31
+ describe('activeModeFromEnv', () => {
32
+ it('maps remote to nitro', () => {
33
+ expect(activeModeFromEnv('remote')).toBe('nitro');
34
+ });
35
+
36
+ it('maps software, undefined, and anything else to software', () => {
37
+ expect(activeModeFromEnv('software')).toBe('software');
38
+ expect(activeModeFromEnv(undefined)).toBe('software');
39
+ expect(activeModeFromEnv('bogus')).toBe('software');
40
+ });
41
+ });
42
+
43
+ describe('buildReport', () => {
44
+ const now = new Date('2026-09-25T00:00:00.000Z');
45
+
46
+ it('reports zero counts for known modes/policies with no data', () => {
47
+ const report = buildReport(emptyRaw, 'nitro', now);
48
+
49
+ expect(report.generatedAt).toBe(now.toISOString());
50
+ expect(report.activeEnclaveMode).toBe('nitro');
51
+ expect(report.byEnclaveMode).toEqual({ software: 0, nitro: 0 });
52
+ expect(report.pendingHoldsByReleasePolicy).toEqual({ hold: 0, pin: 0 });
53
+ expect(report.totals).toEqual({
54
+ userKeys: 0,
55
+ escrowBlobs: 0,
56
+ escrowPinEnabled: 0,
57
+ optedOut: 0,
58
+ pendingHolds: 0,
59
+ });
60
+ expect(report.reseal).toEqual({ activeCount: 0, staleCount: 0, staleRatePercent: 0 });
61
+ });
62
+
63
+ it('computes the re-seal split for a normal software/nitro mix', () => {
64
+ const raw: RawEscrowCounts = {
65
+ ...emptyRaw,
66
+ totalUserKeys: 20,
67
+ enclaveModeCounts: [
68
+ { mode: 'software', count: 7 },
69
+ { mode: 'nitro', count: 3 },
70
+ ],
71
+ escrowPinEnabledCount: 2,
72
+ optedOutCount: 1,
73
+ };
74
+
75
+ const report = buildReport(raw, 'nitro', now);
76
+
77
+ expect(report.byEnclaveMode).toEqual({ software: 7, nitro: 3 });
78
+ expect(report.totals.escrowBlobs).toBe(10);
79
+ expect(report.reseal).toEqual({ activeCount: 3, staleCount: 7, staleRatePercent: 70 });
80
+ });
81
+
82
+ it('surfaces an unknown enclaveMode value instead of dropping it, and counts it as stale', () => {
83
+ const raw: RawEscrowCounts = {
84
+ ...emptyRaw,
85
+ enclaveModeCounts: [
86
+ { mode: 'software', count: 1 },
87
+ { mode: 'nitro', count: 1 },
88
+ { mode: 'legacy-v0', count: 2 },
89
+ ],
90
+ };
91
+
92
+ const report = buildReport(raw, 'nitro', now);
93
+
94
+ expect(report.byEnclaveMode).toEqual({ software: 1, nitro: 1, 'legacy-v0': 2 });
95
+ expect(report.totals.escrowBlobs).toBe(4);
96
+ // activeCount only counts the active mode bucket; the unknown mode is stale too.
97
+ expect(report.reseal).toEqual({ activeCount: 1, staleCount: 3, staleRatePercent: 75 });
98
+ });
99
+
100
+ it('rounds the stale rate to 2 decimal places', () => {
101
+ const raw: RawEscrowCounts = {
102
+ ...emptyRaw,
103
+ enclaveModeCounts: [
104
+ { mode: 'software', count: 1 },
105
+ { mode: 'nitro', count: 2 },
106
+ ],
107
+ };
108
+
109
+ const report = buildReport(raw, 'nitro', now);
110
+
111
+ // stale = 1, total = 3 -> 33.333...% rounds to 33.33
112
+ expect(report.reseal.staleRatePercent).toBe(33.33);
113
+ });
114
+
115
+ it('passes through enclaveKeyId counts and an unknown release policy value', () => {
116
+ const raw: RawEscrowCounts = {
117
+ ...emptyRaw,
118
+ enclaveKeyIdCounts: [
119
+ { keyId: 'key-a', count: 4 },
120
+ { keyId: 'key-b', count: 1 },
121
+ ],
122
+ pendingHoldsByPolicy: [
123
+ { policy: 'hold', count: 3 },
124
+ { policy: 'weird-future-policy', count: 1 },
125
+ ],
126
+ };
127
+
128
+ const report = buildReport(raw, 'software', now);
129
+
130
+ expect(report.byEnclaveKeyId).toEqual({ 'key-a': 4, 'key-b': 1 });
131
+ expect(report.pendingHoldsByReleasePolicy).toEqual({
132
+ hold: 3,
133
+ pin: 0,
134
+ 'weird-future-policy': 1,
135
+ });
136
+ expect(report.totals.pendingHolds).toBe(4);
137
+ });
138
+ });
139
+
140
+ describe('buildCloudWatchMetricPayload', () => {
141
+ it('emits EscrowBlobs per mode dimension plus a single EscrowBlobsStale datum', () => {
142
+ const report = buildReport(
143
+ {
144
+ ...emptyRaw,
145
+ enclaveModeCounts: [
146
+ { mode: 'software', count: 5 },
147
+ { mode: 'nitro', count: 2 },
148
+ ],
149
+ },
150
+ 'nitro',
151
+ new Date('2026-09-25T00:00:00.000Z')
152
+ );
153
+
154
+ const payload = buildCloudWatchMetricPayload(report);
155
+
156
+ expect(payload.Namespace).toBe(CLOUDWATCH_NAMESPACE);
157
+ expect(payload.MetricData).toHaveLength(3);
158
+ expect(payload.MetricData).toEqual(
159
+ expect.arrayContaining([
160
+ {
161
+ MetricName: 'EscrowBlobs',
162
+ Value: 5,
163
+ Unit: 'Count',
164
+ Timestamp: report.generatedAt,
165
+ Dimensions: [{ Name: 'EnclaveMode', Value: 'software' }],
166
+ },
167
+ {
168
+ MetricName: 'EscrowBlobs',
169
+ Value: 2,
170
+ Unit: 'Count',
171
+ Timestamp: report.generatedAt,
172
+ Dimensions: [{ Name: 'EnclaveMode', Value: 'nitro' }],
173
+ },
174
+ {
175
+ MetricName: 'EscrowBlobsStale',
176
+ Value: 5,
177
+ Unit: 'Count',
178
+ Timestamp: report.generatedAt,
179
+ },
180
+ ])
181
+ );
182
+ });
183
+
184
+ it('still emits a zero-value EscrowBlobsStale datum when there is no data at all', () => {
185
+ const report = buildReport(emptyRaw, 'software', new Date('2026-09-25T00:00:00.000Z'));
186
+
187
+ const payload = buildCloudWatchMetricPayload(report);
188
+
189
+ const stale = payload.MetricData.find(datum => datum.MetricName === 'EscrowBlobsStale');
190
+ expect(stale?.Value).toBe(0);
191
+ });
192
+ });
193
+
194
+ describe('classifyEnclaveKeyIds (P9.3)', () => {
195
+ it('splits counts into current/previous/unknown, listing unknown keyIds sorted', () => {
196
+ const classification = classifyEnclaveKeyIds(
197
+ { 'key-2': 10, 'key-1': 4, 'key-0': 1, unknown: 1 },
198
+ 'key-2',
199
+ ['key-1', 'key-0']
200
+ );
201
+
202
+ expect(classification).toEqual({
203
+ current: 10,
204
+ previous: 5,
205
+ unknown: 1,
206
+ unknownKeyIds: ['unknown'],
207
+ });
208
+ });
209
+
210
+ it('reports zero previous/unknown when every blob is already on the current key', () => {
211
+ const classification = classifyEnclaveKeyIds({ 'key-2': 3 }, 'key-2', ['key-1']);
212
+
213
+ expect(classification).toEqual({ current: 3, previous: 0, unknown: 0, unknownKeyIds: [] });
214
+ });
215
+
216
+ it('treats an empty previous-key list as everything non-current being unknown', () => {
217
+ const classification = classifyEnclaveKeyIds(
218
+ { 'key-2': 2, 'retired-1': 1, 'retired-2': 1 },
219
+ 'key-2',
220
+ []
221
+ );
222
+
223
+ expect(classification.previous).toBe(0);
224
+ expect(classification.unknown).toBe(2);
225
+ expect(classification.unknownKeyIds).toEqual(['retired-1', 'retired-2']);
226
+ });
227
+ });
@@ -0,0 +1,465 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * P6.2 — Escrow blob mode report.
4
+ *
5
+ * Read-only report over the `userkeys` and `escrowholds` collections:
6
+ * - escrow blob counts grouped by `escrowBlob.enclaveMode` and `escrowBlob.enclaveKeyId`
7
+ * - count of users with an enabled `escrowPin` (present, not disabled, matching the
8
+ * current `shareVersion` — same definition as `getEscrowPinStatus` in
9
+ * `src/models/UserKey.ts`)
10
+ * - pending `EscrowHold` counts grouped by `releasePolicy`
11
+ * - count of users who have opted out of escrow (`escrowOptedOutAt` set)
12
+ * - a "re-seal" breakdown: how many escrow blobs are sealed for the currently
13
+ * configured active enclave mode vs. a different ("stale") one
14
+ *
15
+ * This script NEVER writes to the database and NEVER prints PII — no DIDs,
16
+ * emails, contact methods, or Mongo `_id`s, only counts.
17
+ *
18
+ * Usage:
19
+ * bun scripts/escrow-blob-mode-report.ts [--json] [--publish-metrics]
20
+ * [--current-key-id=<id>] [--previous-key-ids=<id1,id2,...>]
21
+ *
22
+ * Flags:
23
+ * --json Print a single JSON object instead of a human-readable table.
24
+ * --publish-metrics Print the CloudWatch `PutMetricData` payload (namespace
25
+ * `LearnCard/Escrow`, metrics `EscrowBlobs` dimensioned by
26
+ * `EnclaveMode` and `EscrowBlobsStale`) that a caller with AWS
27
+ * SDK access should forward to CloudWatch.
28
+ * --current-key-id=<id> (P9.3) Classifies `byEnclaveKeyId` into
29
+ * current/previous/unknown buckets — how many
30
+ * escrow blob copies remain under each previous
31
+ * key, ready for the escrowBlobRewrap job/runbook.
32
+ * --previous-key-ids=<a,b,c> Required together with --current-key-id.
33
+ *
34
+ * This script deliberately makes NO live enclave call (see "Known limitations"
35
+ * below), so it cannot discover the current/previous key IDs itself — fetch
36
+ * them once from `GET /keys/escrow/attestation` (`keyId`/`previousKeyIds`) and
37
+ * pass them in explicitly:
38
+ *
39
+ * bun scripts/escrow-blob-mode-report.ts --current-key-id=key-2 \
40
+ * --previous-key-ids=key-1
41
+ *
42
+ * Without these two flags, `byEnclaveKeyId` is still printed/returned as
43
+ * before; only the extra classification section is omitted — compare it
44
+ * manually against the enclave's own attestation response.
45
+ *
46
+ * lca-api does not depend on `@aws-sdk/client-cloudwatch`
47
+ * (only `services/learn-card-network/ai-agent` does in this
48
+ * monorepo), so this script does not publish metrics itself —
49
+ * see "CloudWatch metric emission" below.
50
+ *
51
+ * Environment:
52
+ * Same as the lca-api server: `SEED`, `MONGO_URI`, `MONGO_DB_NAME` (see
53
+ * `.env.example`). `SEED` is required by environment parsing even though this
54
+ * script never uses it. Point `MONGO_URI` at the environment you want to report
55
+ * on (e.g. a staging replica) before running.
56
+ *
57
+ * CloudWatch metric emission:
58
+ * `--publish-metrics` never imports or requires an AWS SDK. It only prints the
59
+ * exact `MetricData` payload. Forward it with the P7.1 monitor Lambda (which
60
+ * already carries AWS SDK access) or any ops job that does, e.g.:
61
+ *
62
+ * bun scripts/escrow-blob-mode-report.ts --json --publish-metrics \
63
+ * | jq .metricsPayload \
64
+ * | <your-cloudwatch-forwarder>
65
+ *
66
+ * Known limitations:
67
+ * - "Stale" here means `escrowBlob.enclaveMode` does not match the currently
68
+ * configured active mode (`ESCROW_ENCLAVE_MODE=remote` → `nitro`, otherwise
69
+ * `software`) — the same comparison `assertFreshEscrowBlob` makes in
70
+ * `src/routes/escrow.ts`. It does NOT detect `enclaveKeyId` (measurement)
71
+ * rotation staleness, because that requires a live call to the enclave's
72
+ * attestation endpoint (`getEscrowBlobStaleReason` in
73
+ * `src/services/escrow-enclave/index.ts`), which this offline counts report
74
+ * deliberately avoids depending on.
75
+ * - `UserKey` has no tenant field, so a per-tenant breakdown of escrow blobs is
76
+ * not possible and is intentionally not reported (see `byEnclaveMode` /
77
+ * `byEnclaveKeyId` instead). `EscrowHold` does carry an optional `tenantId`,
78
+ * but it was added after most holds existed and is not present on every
79
+ * document, so it isn't reported here either — a "tenant breakdown" would
80
+ * misleadingly undercount if only holds happened to be tenant-tagged.
81
+ */
82
+
83
+ import type { Collection } from 'mongodb';
84
+
85
+ import { getUserKeysCollection, type MongoUserKeyType } from '../src/models/UserKey';
86
+ import { getEscrowHoldsCollection, type EscrowHold } from '../src/models/EscrowHold';
87
+ import { environment } from '../src/config/environment';
88
+ import { client as mongoClient } from '../src/mongo';
89
+
90
+ export type ActiveEnclaveMode = 'software' | 'nitro';
91
+
92
+ const KNOWN_ENCLAVE_MODES: readonly ActiveEnclaveMode[] = ['software', 'nitro'];
93
+ const KNOWN_RELEASE_POLICIES: readonly EscrowHold['releasePolicy'][] = ['hold', 'pin'];
94
+
95
+ /** Mirrors `activeEscrowClientMode()` in `src/services/escrow-enclave/index.ts`,
96
+ * re-derived directly from the env var so this script has no import-time
97
+ * dependency on that module (which is mid-edit as part of this same plan). */
98
+ export const activeModeFromEnv = (mode: string | undefined): ActiveEnclaveMode =>
99
+ mode === 'remote' ? 'nitro' : 'software';
100
+
101
+ export interface RawEscrowCounts {
102
+ totalUserKeys: number;
103
+ enclaveModeCounts: { mode: string; count: number }[];
104
+ enclaveKeyIdCounts: { keyId: string; count: number }[];
105
+ escrowPinEnabledCount: number;
106
+ optedOutCount: number;
107
+ pendingHoldsByPolicy: { policy: string; count: number }[];
108
+ }
109
+
110
+ export interface EscrowBlobModeReport {
111
+ generatedAt: string;
112
+ activeEnclaveMode: ActiveEnclaveMode;
113
+ totals: {
114
+ userKeys: number;
115
+ escrowBlobs: number;
116
+ escrowPinEnabled: number;
117
+ optedOut: number;
118
+ pendingHolds: number;
119
+ };
120
+ byEnclaveMode: Record<string, number>;
121
+ byEnclaveKeyId: Record<string, number>;
122
+ pendingHoldsByReleasePolicy: Record<string, number>;
123
+ reseal: {
124
+ activeCount: number;
125
+ staleCount: number;
126
+ /** Rounded to 2 decimal places; 0 when there are no escrow blobs at all. */
127
+ staleRatePercent: number;
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Pure formatting/aggregation-shaping logic — no Mongo access. Takes the raw
133
+ * per-bucket counts from `queryEscrowCounts` and produces the final report,
134
+ * filling in zero counts for known enclave modes / release policies that had
135
+ * no matching documents, while still surfacing any unexpected ("unknown")
136
+ * values verbatim rather than silently dropping them.
137
+ */
138
+ export const buildReport = (
139
+ raw: RawEscrowCounts,
140
+ activeEnclaveMode: ActiveEnclaveMode,
141
+ now: Date = new Date()
142
+ ): EscrowBlobModeReport => {
143
+ const byEnclaveMode: Record<string, number> = Object.fromEntries(
144
+ KNOWN_ENCLAVE_MODES.map(mode => [mode, 0])
145
+ );
146
+ for (const { mode, count } of raw.enclaveModeCounts) {
147
+ byEnclaveMode[mode] = (byEnclaveMode[mode] ?? 0) + count;
148
+ }
149
+
150
+ const byEnclaveKeyId: Record<string, number> = {};
151
+ for (const { keyId, count } of raw.enclaveKeyIdCounts) {
152
+ byEnclaveKeyId[keyId] = (byEnclaveKeyId[keyId] ?? 0) + count;
153
+ }
154
+
155
+ const pendingHoldsByReleasePolicy: Record<string, number> = Object.fromEntries(
156
+ KNOWN_RELEASE_POLICIES.map(policy => [policy, 0])
157
+ );
158
+ for (const { policy, count } of raw.pendingHoldsByPolicy) {
159
+ pendingHoldsByReleasePolicy[policy] = (pendingHoldsByReleasePolicy[policy] ?? 0) + count;
160
+ }
161
+
162
+ const escrowBlobs = Object.values(byEnclaveMode).reduce((sum, count) => sum + count, 0);
163
+ const activeCount = byEnclaveMode[activeEnclaveMode] ?? 0;
164
+ const staleCount = escrowBlobs - activeCount;
165
+ const staleRatePercent =
166
+ escrowBlobs === 0 ? 0 : Math.round((staleCount / escrowBlobs) * 10_000) / 100;
167
+ const pendingHolds = Object.values(pendingHoldsByReleasePolicy).reduce(
168
+ (sum, count) => sum + count,
169
+ 0
170
+ );
171
+
172
+ return {
173
+ generatedAt: now.toISOString(),
174
+ activeEnclaveMode,
175
+ totals: {
176
+ userKeys: raw.totalUserKeys,
177
+ escrowBlobs,
178
+ escrowPinEnabled: raw.escrowPinEnabledCount,
179
+ optedOut: raw.optedOutCount,
180
+ pendingHolds,
181
+ },
182
+ byEnclaveMode,
183
+ byEnclaveKeyId,
184
+ pendingHoldsByReleasePolicy,
185
+ reseal: { activeCount, staleCount, staleRatePercent },
186
+ };
187
+ };
188
+
189
+ export interface CloudWatchMetricDatum {
190
+ MetricName: 'EscrowBlobs' | 'EscrowBlobsStale';
191
+ Value: number;
192
+ Unit: 'Count';
193
+ /** ISO-8601. The forwarder should parse this into whatever `Date`/timestamp
194
+ * shape its AWS SDK client expects — kept as a string here so this module
195
+ * has zero AWS SDK type dependency. */
196
+ Timestamp: string;
197
+ Dimensions?: { Name: 'EnclaveMode'; Value: string }[];
198
+ }
199
+
200
+ export interface CloudWatchMetricPayload {
201
+ Namespace: string;
202
+ MetricData: CloudWatchMetricDatum[];
203
+ }
204
+
205
+ export const CLOUDWATCH_NAMESPACE = 'LearnCard/Escrow';
206
+
207
+ export interface EnclaveKeyIdClassification {
208
+ current: number;
209
+ previous: number;
210
+ unknown: number;
211
+ /** Sorted for stable output; empty when every counted keyId is current or previous. */
212
+ unknownKeyIds: string[];
213
+ }
214
+
215
+ /**
216
+ * Pure: classifies `byEnclaveKeyId` counts against an explicitly-provided
217
+ * current/previous key list (see script header "P9.3" flags — this script
218
+ * makes no live enclave call, so it cannot discover these itself). "Unknown"
219
+ * means neither current nor previous: a truly retired/lost key, never a
220
+ * candidate for `escrowBlobRewrap`, which only ever asks the enclave about
221
+ * keyIds in its OWN attestation's `previousKeyIds`.
222
+ */
223
+ export const classifyEnclaveKeyIds = (
224
+ byEnclaveKeyId: Record<string, number>,
225
+ currentKeyId: string,
226
+ previousKeyIds: string[]
227
+ ): EnclaveKeyIdClassification => {
228
+ const previousSet = new Set(previousKeyIds);
229
+ let current = 0;
230
+ let previous = 0;
231
+ let unknown = 0;
232
+ const unknownKeyIds: string[] = [];
233
+ for (const [keyId, count] of Object.entries(byEnclaveKeyId)) {
234
+ if (keyId === currentKeyId) current += count;
235
+ else if (previousSet.has(keyId)) previous += count;
236
+ else {
237
+ unknown += count;
238
+ unknownKeyIds.push(keyId);
239
+ }
240
+ }
241
+ return { current, previous, unknown, unknownKeyIds: unknownKeyIds.sort() };
242
+ };
243
+
244
+ export const formatKeyIdClassification = (
245
+ classification: EnclaveKeyIdClassification,
246
+ currentKeyId: string,
247
+ previousKeyIds: string[]
248
+ ): string => {
249
+ const lines: string[] = [];
250
+ lines.push('');
251
+ lines.push(
252
+ `Enclave key classification (current=${currentKeyId}, previous=[${previousKeyIds.join(', ')}]):`
253
+ );
254
+ lines.push(` Current: ${classification.current}`);
255
+ lines.push(` Previous: ${classification.previous} (candidates for escrowBlobRewrap)`);
256
+ lines.push(
257
+ ` Unknown: ${classification.unknown}` +
258
+ (classification.unknownKeyIds.length
259
+ ? ` [${classification.unknownKeyIds.join(', ')}] — retired/lost keys, never rewrapped`
260
+ : '')
261
+ );
262
+ return lines.join('\n');
263
+ };
264
+
265
+ /** Pure: shapes a report into a CloudWatch `PutMetricData` payload. Never
266
+ * imports an AWS SDK — see the script header's "CloudWatch metric emission". */
267
+ export const buildCloudWatchMetricPayload = (
268
+ report: EscrowBlobModeReport
269
+ ): CloudWatchMetricPayload => {
270
+ const timestamp = report.generatedAt;
271
+ const MetricData: CloudWatchMetricDatum[] = Object.entries(report.byEnclaveMode).map(
272
+ ([mode, count]) => ({
273
+ MetricName: 'EscrowBlobs',
274
+ Value: count,
275
+ Unit: 'Count',
276
+ Timestamp: timestamp,
277
+ Dimensions: [{ Name: 'EnclaveMode', Value: mode }],
278
+ })
279
+ );
280
+ MetricData.push({
281
+ MetricName: 'EscrowBlobsStale',
282
+ Value: report.reseal.staleCount,
283
+ Unit: 'Count',
284
+ Timestamp: timestamp,
285
+ });
286
+ return { Namespace: CLOUDWATCH_NAMESPACE, MetricData };
287
+ };
288
+
289
+ const pad = (value: string, width: number): string => value.padEnd(width, ' ');
290
+
291
+ export const formatReportTable = (report: EscrowBlobModeReport): string => {
292
+ const lines: string[] = [];
293
+ lines.push(`Escrow blob mode report — generated ${report.generatedAt}`);
294
+ lines.push(`Active enclave mode: ${report.activeEnclaveMode}`);
295
+ lines.push('');
296
+ lines.push('Totals:');
297
+ lines.push(` UserKey documents: ${report.totals.userKeys}`);
298
+ lines.push(` Users with escrow blob: ${report.totals.escrowBlobs}`);
299
+ lines.push(` Escrow PIN enabled: ${report.totals.escrowPinEnabled}`);
300
+ lines.push(` Opted out of escrow: ${report.totals.optedOut}`);
301
+ lines.push(` Pending holds: ${report.totals.pendingHolds}`);
302
+ lines.push('');
303
+ lines.push('Escrow blobs by enclaveMode:');
304
+ for (const [mode, count] of Object.entries(report.byEnclaveMode)) {
305
+ lines.push(` ${pad(mode, 14)} ${count}`);
306
+ }
307
+ lines.push('');
308
+ lines.push('Escrow blobs by enclaveKeyId:');
309
+ const keyIdEntries = Object.entries(report.byEnclaveKeyId);
310
+ if (keyIdEntries.length === 0) lines.push(' (none)');
311
+ for (const [keyId, count] of keyIdEntries) {
312
+ lines.push(` ${pad(keyId, 28)} ${count}`);
313
+ }
314
+ lines.push('');
315
+ lines.push('Pending holds by releasePolicy:');
316
+ for (const [policy, count] of Object.entries(report.pendingHoldsByReleasePolicy)) {
317
+ lines.push(` ${pad(policy, 14)} ${count}`);
318
+ }
319
+ lines.push('');
320
+ lines.push(
321
+ `Re-seal status: ${report.reseal.activeCount} on active mode (${report.activeEnclaveMode}), ` +
322
+ `${report.reseal.staleCount} stale (${report.reseal.staleRatePercent}%). ` +
323
+ `"Stale" = escrowBlob.enclaveMode !== active mode only; does not detect ` +
324
+ `enclaveKeyId rotation — see script header "Known limitations".`
325
+ );
326
+ lines.push('');
327
+ lines.push('Tenant breakdown: unavailable — UserKey has no tenant field.');
328
+ return lines.join('\n');
329
+ };
330
+
331
+ interface UserKeyFacetResult {
332
+ totalUserKeys: { count: number }[];
333
+ enclaveModeCounts: { _id: string | null; count: number }[];
334
+ enclaveKeyIdCounts: { _id: string | null; count: number }[];
335
+ escrowPinEnabled: { count: number }[];
336
+ optedOut: { count: number }[];
337
+ }
338
+
339
+ /**
340
+ * Impure: runs the counting queries. Never writes. Blob-related counts come
341
+ * from a single `$facet` aggregation over the UserKey collection; pending
342
+ * hold counts are a separate aggregation over the (distinct) EscrowHold
343
+ * collection.
344
+ */
345
+ export const queryEscrowCounts = async (
346
+ userKeys: Collection<MongoUserKeyType>,
347
+ escrowHolds: Collection<EscrowHold>
348
+ ): Promise<RawEscrowCounts> => {
349
+ const [facetResult] = await userKeys
350
+ .aggregate<UserKeyFacetResult>([
351
+ {
352
+ $facet: {
353
+ totalUserKeys: [{ $count: 'count' }],
354
+ enclaveModeCounts: [
355
+ { $match: { escrowBlob: { $exists: true } } },
356
+ { $group: { _id: '$escrowBlob.enclaveMode', count: { $sum: 1 } } },
357
+ ],
358
+ enclaveKeyIdCounts: [
359
+ { $match: { escrowBlob: { $exists: true } } },
360
+ { $group: { _id: '$escrowBlob.enclaveKeyId', count: { $sum: 1 } } },
361
+ ],
362
+ // Same definition as getEscrowPinStatus() in src/models/UserKey.ts:
363
+ // present, not disabled, and sealed for the current shareVersion.
364
+ escrowPinEnabled: [
365
+ {
366
+ $match: {
367
+ escrowPin: { $exists: true },
368
+ 'escrowPin.disabledAt': { $exists: false },
369
+ $expr: { $eq: ['$escrowPin.shareVersion', '$shareVersion'] },
370
+ },
371
+ },
372
+ { $count: 'count' },
373
+ ],
374
+ optedOut: [
375
+ { $match: { escrowOptedOutAt: { $exists: true } } },
376
+ { $count: 'count' },
377
+ ],
378
+ },
379
+ },
380
+ ])
381
+ .toArray();
382
+
383
+ // Legacy rows predate the releasePolicy field and are treated as 'hold'
384
+ // everywhere else in the codebase (see EscrowHold.ts's own $or filters).
385
+ const pendingHoldsAgg = await escrowHolds
386
+ .aggregate<{ _id: string; count: number }>([
387
+ { $match: { status: 'pending' } },
388
+ { $group: { _id: { $ifNull: ['$releasePolicy', 'hold'] }, count: { $sum: 1 } } },
389
+ ])
390
+ .toArray();
391
+
392
+ return {
393
+ totalUserKeys: facetResult?.totalUserKeys[0]?.count ?? 0,
394
+ enclaveModeCounts: (facetResult?.enclaveModeCounts ?? []).map(({ _id, count }) => ({
395
+ mode: _id ?? 'unknown',
396
+ count,
397
+ })),
398
+ enclaveKeyIdCounts: (facetResult?.enclaveKeyIdCounts ?? []).map(({ _id, count }) => ({
399
+ keyId: _id ?? 'unknown',
400
+ count,
401
+ })),
402
+ escrowPinEnabledCount: facetResult?.escrowPinEnabled[0]?.count ?? 0,
403
+ optedOutCount: facetResult?.optedOut[0]?.count ?? 0,
404
+ pendingHoldsByPolicy: pendingHoldsAgg.map(({ _id, count }) => ({ policy: _id, count })),
405
+ };
406
+ };
407
+
408
+ const main = async (): Promise<void> => {
409
+ const argv = process.argv.slice(2);
410
+ const args = new Set(argv);
411
+ const jsonOutput = args.has('--json');
412
+ const publishMetrics = args.has('--publish-metrics');
413
+ const currentKeyId = argv
414
+ .find(arg => arg.startsWith('--current-key-id='))
415
+ ?.slice('--current-key-id='.length);
416
+ const previousKeyIds = (
417
+ argv
418
+ .find(arg => arg.startsWith('--previous-key-ids='))
419
+ ?.slice('--previous-key-ids='.length) ?? ''
420
+ )
421
+ .split(',')
422
+ .map(id => id.trim())
423
+ .filter(Boolean);
424
+
425
+ try {
426
+ const raw = await queryEscrowCounts(getUserKeysCollection(), getEscrowHoldsCollection());
427
+ const activeEnclaveMode = activeModeFromEnv(environment.ESCROW_ENCLAVE_MODE);
428
+ const report = buildReport(raw, activeEnclaveMode);
429
+ const metricsPayload = publishMetrics ? buildCloudWatchMetricPayload(report) : undefined;
430
+ const classification = currentKeyId
431
+ ? classifyEnclaveKeyIds(report.byEnclaveKeyId, currentKeyId, previousKeyIds)
432
+ : undefined;
433
+
434
+ if (jsonOutput) {
435
+ console.log(JSON.stringify({ report, metricsPayload, classification }, null, 2));
436
+ } else {
437
+ console.log(formatReportTable(report));
438
+ if (classification) {
439
+ console.log(
440
+ formatKeyIdClassification(classification, currentKeyId, previousKeyIds)
441
+ );
442
+ }
443
+ }
444
+
445
+ if (publishMetrics && !jsonOutput) {
446
+ console.log('');
447
+ console.log(
448
+ 'lca-api has no @aws-sdk/client-cloudwatch dependency (only ' +
449
+ 'services/learn-card-network/ai-agent does in this monorepo). ' +
450
+ 'Forward this payload to CloudWatch PutMetricData from the P7.1 ' +
451
+ 'monitor Lambda or an ops job that already has AWS SDK access:'
452
+ );
453
+ console.log(JSON.stringify(metricsPayload, null, 2));
454
+ }
455
+ } finally {
456
+ await mongoClient.close();
457
+ }
458
+ };
459
+
460
+ if (import.meta.main) {
461
+ main().catch(err => {
462
+ console.error('escrow-blob-mode-report failed:', err);
463
+ process.exit(1);
464
+ });
465
+ }