@learncard/lca-api-service 1.3.2 → 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 (240) hide show
  1. package/.env.example +99 -2
  2. package/AGENTS.md +155 -44
  3. package/CHANGELOG.md +84 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/compose.env +5 -0
  6. package/config/config.dev.json +23 -0
  7. package/config/config.example.json +22 -0
  8. package/config/config.json +1 -0
  9. package/config/config.production.json +12 -0
  10. package/config/config.scouts.dev.json +11 -0
  11. package/config/config.scouts.production.json +11 -0
  12. package/dist/accesslayer/signing-authority/index.d.ts +8 -2
  13. package/dist/app.d.ts +361 -8
  14. package/dist/cache/challenges.d.ts +2 -0
  15. package/dist/cache/getDel.d.ts +2 -0
  16. package/dist/cache/getDel.test.d.ts +1 -0
  17. package/dist/cache/login-tickets.d.ts +12 -0
  18. package/dist/cache/recoverySessions.d.ts +42 -0
  19. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  20. package/dist/config/cacheEnvironment.d.ts +16 -0
  21. package/dist/config/environment.d.ts +253 -2
  22. package/dist/config/environment.test.d.ts +1 -0
  23. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  24. package/dist/config/escrowDeployment.test.d.ts +1 -0
  25. package/dist/config/oidcEnvironment.d.ts +74 -0
  26. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  27. package/dist/config/stageConfig.d.ts +62 -0
  28. package/dist/config/stageConfig.test.d.ts +1 -0
  29. package/dist/didkit_wasm_bg.wasm +0 -0
  30. package/dist/docker-entry.d.ts +1 -1
  31. package/dist/helpers/auth.helpers.d.ts +1 -1
  32. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  33. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  34. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  35. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  36. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  37. package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
  38. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  39. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  40. package/dist/helpers/oidc.helpers.d.ts +47 -0
  41. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  42. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  43. package/dist/helpers/rateLimit.helpers.d.ts +4 -0
  44. package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
  45. package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
  46. package/dist/helpers/social-token.helpers.d.ts +19 -0
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.js +2 -2
  49. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  50. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  51. package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
  52. package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
  53. package/dist/models/AuthSubject.d.ts +20 -0
  54. package/dist/models/EscrowHold.d.ts +153 -0
  55. package/dist/models/Notification.d.ts +5 -0
  56. package/dist/models/SigningAuthority.d.ts +7 -1
  57. package/dist/models/UserKey.d.ts +326 -3
  58. package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
  59. package/dist/models/index.d.ts +2 -0
  60. package/dist/oidc.d.ts +5 -0
  61. package/dist/routes/auth.d.ts +31 -0
  62. package/dist/routes/escrow.d.ts +164 -0
  63. package/dist/routes/index.d.ts +6 -0
  64. package/dist/routes/keys.d.ts +182 -7
  65. package/dist/routes/notifications.d.ts +5 -0
  66. package/dist/routes/qr-login.d.ts +1 -1
  67. package/dist/routes/signing-authority.d.ts +14 -0
  68. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  69. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  70. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  71. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  72. package/dist/services/escrow-enclave/index.d.ts +25 -0
  73. package/dist/services/escrow-enclave/notifications.d.ts +24 -0
  74. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  75. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  76. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  77. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  78. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  79. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  80. package/dist/services/escrow-enclave/softwareEnclave.d.ts +27 -0
  81. package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
  82. package/dist/services/escrow-enclave/types.d.ts +111 -0
  83. package/dist/types/notifications.d.ts +5 -0
  84. package/dist/types/seed-encryption.d.ts +31 -0
  85. package/dist/types/seed-migration.d.ts +49 -0
  86. package/esbuildPlugins.cjs +1 -0
  87. package/lambda.ts +27 -112
  88. package/lambdaApp.ts +146 -0
  89. package/oidcLambda.test.ts +38 -0
  90. package/oidcLambda.ts +18 -0
  91. package/oidcLambdaApp.ts +11 -0
  92. package/package.json +30 -14
  93. package/scripts/README.md +88 -0
  94. package/scripts/check-seed-encryption-infrastructure.mjs +139 -0
  95. package/scripts/check-signing-authority-redis.mjs +93 -0
  96. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  97. package/scripts/escrow-blob-mode-report.ts +465 -0
  98. package/scripts/keycloak-admin.ts +108 -0
  99. package/scripts/keycloak-provider-links.ts +147 -0
  100. package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
  101. package/scripts/migrate-signing-authority-seeds.mjs +105 -0
  102. package/scripts/print-seed-rollout-flags.ts +21 -0
  103. package/scripts/provision-keycloak-users.ts +419 -0
  104. package/seedMigrationApp.test.ts +145 -0
  105. package/seedMigrationApp.ts +50 -0
  106. package/seedMigrationLambda.test.ts +40 -0
  107. package/seedMigrationLambda.ts +19 -0
  108. package/serverless-local.yml +28 -1
  109. package/serverless.function-env.cjs +58 -0
  110. package/serverless.yml +294 -26
  111. package/src/accesslayer/notifications/index.ts +9 -2
  112. package/src/accesslayer/signing-authority/create.ts +11 -7
  113. package/src/accesslayer/signing-authority/index.ts +4 -1
  114. package/src/app.ts +4 -0
  115. package/src/cache/challenges.ts +12 -2
  116. package/src/cache/getDel.test.ts +34 -0
  117. package/src/cache/getDel.ts +32 -0
  118. package/src/cache/in-memory-lru.ts +1 -0
  119. package/src/cache/index.ts +1 -1
  120. package/src/cache/login-tickets.ts +26 -0
  121. package/src/cache/recoverySessions.ts +252 -0
  122. package/src/config/applyDockerStageConfig.ts +6 -0
  123. package/src/config/cacheEnvironment.ts +34 -0
  124. package/src/config/environment.test.ts +159 -0
  125. package/src/config/environment.ts +164 -0
  126. package/src/config/escrowBundleMode.test.ts +52 -0
  127. package/src/config/escrowDeployment.test.ts +114 -0
  128. package/src/config/oidcEnvironment.ts +59 -0
  129. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  130. package/src/config/stageConfig.test.ts +26 -0
  131. package/src/config/stageConfig.ts +24 -0
  132. package/src/docker-entry.ts +13 -0
  133. package/src/helpers/auth.helpers.ts +68 -30
  134. package/src/helpers/escrowCancelLink.test.ts +60 -0
  135. package/src/helpers/escrowCancelLink.ts +38 -0
  136. package/src/helpers/escrowCancelToken.test.ts +105 -0
  137. package/src/helpers/escrowCancelToken.ts +47 -0
  138. package/src/helpers/keycloak.helpers.ts +120 -0
  139. package/src/helpers/learnCard.helpers.test.ts +11 -1
  140. package/src/helpers/learnCard.helpers.ts +57 -45
  141. package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
  142. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  143. package/src/helpers/login-ticket.helpers.ts +91 -0
  144. package/src/helpers/oidc.helpers.ts +354 -0
  145. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  146. package/src/helpers/rate-limit.helpers.ts +73 -0
  147. package/src/helpers/rateLimit.helpers.ts +112 -0
  148. package/src/helpers/seedEncryption.helpers.test.ts +427 -0
  149. package/src/helpers/seedEncryption.helpers.ts +372 -0
  150. package/src/helpers/signingAuthority.helpers.ts +4 -1
  151. package/src/helpers/social-token.helpers.ts +144 -0
  152. package/src/index.ts +1 -0
  153. package/src/jobs/escrowBlobRewrap.ts +121 -0
  154. package/src/jobs/escrowHoldReminders.ts +177 -0
  155. package/src/migrations/signingAuthoritySeeds.ts +430 -0
  156. package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
  157. package/src/models/AuthSubject.ts +147 -0
  158. package/src/models/EscrowHold.ts +541 -0
  159. package/src/models/SigningAuthority.ts +14 -3
  160. package/src/models/UserKey.escrowPin.test.ts +49 -0
  161. package/src/models/UserKey.ts +1109 -40
  162. package/src/models/index.ts +2 -0
  163. package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
  164. package/src/oidc.ts +371 -0
  165. package/src/routes/auth.ts +173 -0
  166. package/src/routes/credentials.ts +8 -0
  167. package/src/routes/escrow.ts +1073 -0
  168. package/src/routes/firebase.ts +90 -48
  169. package/src/routes/index.ts +98 -5
  170. package/src/routes/keys.ts +1022 -174
  171. package/src/routes/qr-login.ts +51 -53
  172. package/src/routes/signing-authority.ts +39 -1
  173. package/src/services/delivery/adapters/postmark.adapter.ts +48 -1
  174. package/src/services/escrow-enclave/README.md +122 -0
  175. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  176. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  177. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  178. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  179. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  180. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  181. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  182. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  183. package/src/services/escrow-enclave/cancellation.ts +59 -0
  184. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  185. package/src/services/escrow-enclave/index.ts +174 -0
  186. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  187. package/src/services/escrow-enclave/notifications.ts +348 -0
  188. package/src/services/escrow-enclave/production.test.ts +34 -0
  189. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  190. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  191. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  192. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  193. package/src/services/escrow-enclave/softwareEnclave.test.ts +450 -0
  194. package/src/services/escrow-enclave/softwareEnclave.ts +254 -0
  195. package/src/services/escrow-enclave/types.ts +122 -0
  196. package/src/types/seed-encryption.ts +51 -0
  197. package/src/types/seed-migration.ts +61 -0
  198. package/test/auth-tickets.spec.ts +480 -0
  199. package/test/credentials.spec.ts +2 -0
  200. package/test/helpers/keycloak-broker.ts +181 -0
  201. package/test/helpers/live-mongo.ts +9 -0
  202. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  203. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  204. package/test/keycloak-admin.spec.ts +58 -0
  205. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  206. package/test/keycloak-migration.integration.spec.ts +266 -0
  207. package/test/keycloak-provider-links.spec.ts +201 -0
  208. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  209. package/test/keycloak-verify.integration.spec.ts +100 -0
  210. package/test/keycloak-verify.spec.ts +307 -0
  211. package/test/keys-escrow.spec.ts +2328 -0
  212. package/test/keys-identity-rebind.spec.ts +456 -0
  213. package/test/keys-lifecycle-invariants.spec.ts +785 -0
  214. package/test/keys-recovery-confirmation.spec.ts +586 -0
  215. package/test/keys-recovery-rate-limit.spec.ts +123 -0
  216. package/test/keys-secret-leakage.spec.ts +265 -0
  217. package/test/models/authSubjectIndexes.spec.ts +132 -0
  218. package/test/models/escrow.spec.ts +572 -0
  219. package/test/notifications.spec.ts +4 -0
  220. package/test/oidc-e2e/broker.spec.ts +116 -0
  221. package/test/oidc-e2e/compose.yaml +33 -0
  222. package/test/oidc-e2e/fixtures.ts +245 -0
  223. package/test/oidc-e2e/lock.test.mjs +73 -0
  224. package/test/oidc-e2e/playwright.config.ts +21 -0
  225. package/test/oidc-e2e/run.mjs +312 -0
  226. package/test/oidc-e2e/tsconfig.json +10 -0
  227. package/test/oidc-e2e/types.ts +21 -0
  228. package/test/oidc.integration.spec.ts +54 -0
  229. package/test/oidc.spec.ts +697 -0
  230. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  231. package/test/provision-keycloak-users.spec.ts +299 -0
  232. package/test/signing-authority-http.spec.ts +161 -0
  233. package/test/signing-authority-seed-migration.spec.ts +414 -0
  234. package/test/signing-authority.spec.ts +90 -0
  235. package/tsconfig.build.json +1 -1
  236. package/tsconfig.json +1 -0
  237. package/vitest.config.ts +24 -1
  238. package/vitest.integration.config.ts +25 -3
  239. package/dist/cache/signing-authority.d.ts +0 -6
  240. package/src/cache/signing-authority.ts +0 -37
@@ -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
+ }
@@ -0,0 +1,108 @@
1
+ import { z } from 'zod';
2
+
3
+ export const keycloakUserSchema = z.object({
4
+ id: z.string(),
5
+ username: z.string().optional(),
6
+ email: z.string().optional(),
7
+ emailVerified: z.boolean().optional(),
8
+ enabled: z.boolean().optional(),
9
+ attributes: z.record(z.string(), z.array(z.string())).optional(),
10
+ });
11
+ export const federatedIdentitySchema = z.object({
12
+ identityProvider: z.string(),
13
+ userId: z.string(),
14
+ userName: z.string(),
15
+ });
16
+ export type KeycloakUser = z.infer<typeof keycloakUserSchema>;
17
+ export type FederatedIdentity = z.infer<typeof federatedIdentitySchema>;
18
+
19
+ export class KeycloakAdminError extends Error {
20
+ constructor(public readonly status: number) {
21
+ super(`Admin operation failed (${status})`);
22
+ this.name = 'KeycloakAdminError';
23
+ }
24
+ }
25
+
26
+ /** Minimal admin client; no tokens or response bodies are included in errors. */
27
+ export const createKeycloakAdmin = async (
28
+ fetchRequest: typeof fetch = fetch,
29
+ now: () => number = Date.now
30
+ ): Promise<{
31
+ request: (path: string, init?: RequestInit) => Promise<Response>;
32
+ findUsers: (email: string) => Promise<KeycloakUser[]>;
33
+ links: (id: string) => Promise<FederatedIdentity[]>;
34
+ }> => {
35
+ const origin = process.env.KEYCLOAK_ADMIN_URL ?? 'http://localhost:8081';
36
+ const realm = process.env.KEYCLOAK_ADMIN_REALM ?? 'master';
37
+ const targetRealm = process.env.KEYCLOAK_REALM ?? 'learncard';
38
+ const clientId = process.env.KEYCLOAK_ADMIN_CLIENT_ID ?? 'admin-cli';
39
+ const secret = process.env.KEYCLOAK_ADMIN_CLIENT_SECRET;
40
+ const username = process.env.KEYCLOAK_ADMIN_USERNAME;
41
+ const password = process.env.KEYCLOAK_ADMIN_PASSWORD;
42
+ if (!secret && (!username || !password)) throw new Error('Admin credentials are required');
43
+ let token = '';
44
+ let refreshAt = 0;
45
+ const refreshToken = async (): Promise<void> => {
46
+ const startedAt = now();
47
+ const tokenResponse = await fetchRequest(
48
+ `${origin}/realms/${encodeURIComponent(realm)}/protocol/openid-connect/token`,
49
+ {
50
+ method: 'POST',
51
+ redirect: 'error',
52
+ signal: AbortSignal.timeout(15_000),
53
+ body: new URLSearchParams({
54
+ client_id: clientId,
55
+ ...(secret
56
+ ? { grant_type: 'client_credentials', client_secret: secret }
57
+ : { grant_type: 'password', username: username!, password: password! }),
58
+ }),
59
+ }
60
+ );
61
+ if (!tokenResponse.ok)
62
+ throw new Error(`Admin authentication failed (${tokenResponse.status})`);
63
+ const result = z
64
+ .object({ access_token: z.string().min(1), expires_in: z.number().positive() })
65
+ .parse(await tokenResponse.json());
66
+ token = result.access_token;
67
+ const lifetime = result.expires_in * 1000;
68
+ refreshAt = startedAt + lifetime - Math.min(30_000, lifetime / 10);
69
+ };
70
+ await refreshToken();
71
+ const request = async (path: string, init: RequestInit = {}): Promise<Response> => {
72
+ if (now() >= refreshAt) await refreshToken();
73
+ const send = async (): Promise<Response> =>
74
+ fetchRequest(`${origin}/admin/realms/${encodeURIComponent(targetRealm)}${path}`, {
75
+ ...init,
76
+ redirect: 'error',
77
+ signal: AbortSignal.timeout(15_000),
78
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
79
+ });
80
+ let response = await send();
81
+ if (response.status === 401) {
82
+ await response.body?.cancel();
83
+ await refreshToken();
84
+ response = await send();
85
+ }
86
+ if (!response.ok) throw new KeycloakAdminError(response.status);
87
+ return response;
88
+ };
89
+ return {
90
+ request,
91
+ findUsers: async (email: string): Promise<KeycloakUser[]> =>
92
+ z
93
+ .array(keycloakUserSchema)
94
+ .parse(
95
+ await (
96
+ await request(`/users?${new URLSearchParams({ email, exact: 'true' })}`)
97
+ ).json()
98
+ ),
99
+ links: async (id: string): Promise<FederatedIdentity[]> =>
100
+ z
101
+ .array(federatedIdentitySchema)
102
+ .parse(
103
+ await (
104
+ await request(`/users/${encodeURIComponent(id)}/federated-identity`)
105
+ ).json()
106
+ ),
107
+ };
108
+ };