@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.
- package/.env.example +73 -0
- package/AGENTS.md +106 -10
- package/CHANGELOG.md +43 -0
- package/bootstrapLambda.test.ts +76 -0
- package/config/config.dev.json +23 -0
- package/config/config.example.json +22 -0
- package/config/config.json +1 -0
- package/config/config.production.json +12 -0
- package/config/config.scouts.dev.json +11 -0
- package/config/config.scouts.production.json +11 -0
- package/dist/app.d.ts +67 -3
- package/dist/cache/getDel.d.ts +2 -0
- package/dist/cache/getDel.test.d.ts +1 -0
- package/dist/cache/login-tickets.d.ts +12 -0
- package/dist/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +101 -0
- package/dist/config/escrowBundleMode.test.d.ts +1 -0
- package/dist/config/escrowDeployment.test.d.ts +1 -0
- package/dist/config/oidcEnvironment.d.ts +74 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
- package/dist/config/stageConfig.d.ts +62 -0
- package/dist/config/stageConfig.test.d.ts +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/helpers/auth.helpers.d.ts +1 -1
- package/dist/helpers/escrowCancelLink.d.ts +7 -0
- package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
- package/dist/helpers/escrowCancelToken.d.ts +4 -0
- package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
- package/dist/helpers/keycloak.helpers.d.ts +23 -0
- package/dist/helpers/login-ticket.helpers.d.ts +31 -0
- package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
- package/dist/helpers/oidc.helpers.d.ts +47 -0
- package/dist/helpers/rate-limit.helpers.d.ts +6 -0
- package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
- package/dist/helpers/social-token.helpers.d.ts +19 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
- package/dist/jobs/escrowHoldReminders.d.ts +12 -0
- package/dist/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +79 -1
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/UserKey.d.ts +11 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +18 -4
- package/dist/routes/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +5 -0
- package/dist/routes/signing-authority.d.ts +14 -0
- package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
- package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
- package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
- package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
- package/dist/services/escrow-enclave/index.d.ts +16 -0
- package/dist/services/escrow-enclave/notifications.d.ts +19 -4
- package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
- package/dist/services/escrow-enclave/production.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
- package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
- package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
- package/dist/services/escrow-enclave/types.d.ts +52 -8
- package/dist/types/notifications.d.ts +5 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -117
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +23 -13
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
- package/scripts/escrow-blob-mode-report.test.ts +227 -0
- package/scripts/escrow-blob-mode-report.ts +465 -0
- package/scripts/keycloak-admin.ts +108 -0
- package/scripts/keycloak-provider-links.ts +147 -0
- package/scripts/print-seed-rollout-flags.ts +21 -0
- package/scripts/provision-keycloak-users.ts +419 -0
- package/seedMigrationApp.test.ts +145 -0
- package/seedMigrationApp.ts +50 -0
- package/seedMigrationLambda.test.ts +30 -135
- package/seedMigrationLambda.ts +15 -46
- package/serverless-local.yml +25 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +172 -37
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +2 -0
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +45 -2
- package/src/config/environment.ts +90 -23
- package/src/config/escrowBundleMode.test.ts +52 -0
- package/src/config/escrowDeployment.test.ts +114 -0
- package/src/config/oidcEnvironment.ts +59 -0
- package/src/config/serverlessFunctionEnv.test.ts +98 -0
- package/src/config/stageConfig.test.ts +26 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +9 -0
- package/src/helpers/auth.helpers.ts +68 -30
- package/src/helpers/escrowCancelLink.test.ts +60 -0
- package/src/helpers/escrowCancelLink.ts +38 -0
- package/src/helpers/escrowCancelToken.test.ts +105 -0
- package/src/helpers/escrowCancelToken.ts +47 -0
- package/src/helpers/keycloak.helpers.ts +120 -0
- package/src/helpers/login-ticket.helpers.test.ts +45 -0
- package/src/helpers/login-ticket.helpers.ts +91 -0
- package/src/helpers/oidc.helpers.ts +354 -0
- package/src/helpers/rate-limit.helpers.test.ts +61 -0
- package/src/helpers/rate-limit.helpers.ts +73 -0
- package/src/helpers/social-token.helpers.ts +144 -0
- package/src/index.ts +1 -0
- package/src/jobs/escrowBlobRewrap.ts +121 -0
- package/src/jobs/escrowHoldReminders.ts +177 -0
- package/src/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +321 -5
- package/src/models/UserKey.ts +91 -6
- package/src/models/index.ts +1 -0
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/escrow.ts +281 -34
- package/src/routes/keys.ts +5 -0
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
- package/src/services/escrow-enclave/README.md +81 -10
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
- package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
- package/src/services/escrow-enclave/blobIdentity.ts +55 -0
- package/src/services/escrow-enclave/cancellation.ts +59 -0
- package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
- package/src/services/escrow-enclave/index.ts +145 -7
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +340 -6
- package/src/services/escrow-enclave/production.test.ts +34 -0
- package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
- package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
- package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
- package/src/services/escrow-enclave/types.ts +63 -17
- package/test/auth-tickets.spec.ts +480 -0
- package/test/helpers/keycloak-broker.ts +181 -0
- package/test/helpers/live-mongo.ts +9 -0
- package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
- package/test/jobs/escrowHoldReminders.spec.ts +319 -0
- package/test/keycloak-admin.spec.ts +58 -0
- package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
- package/test/keycloak-migration.integration.spec.ts +266 -0
- package/test/keycloak-provider-links.spec.ts +201 -0
- package/test/keycloak-social-broker.integration.spec.ts +228 -0
- package/test/keycloak-verify.integration.spec.ts +100 -0
- package/test/keycloak-verify.spec.ts +307 -0
- package/test/keys-escrow.spec.ts +963 -24
- package/test/models/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +375 -13
- package/test/notifications.spec.ts +4 -0
- package/test/oidc-e2e/broker.spec.ts +116 -0
- package/test/oidc-e2e/compose.yaml +33 -0
- package/test/oidc-e2e/fixtures.ts +245 -0
- package/test/oidc-e2e/lock.test.mjs +73 -0
- package/test/oidc-e2e/playwright.config.ts +21 -0
- package/test/oidc-e2e/run.mjs +312 -0
- package/test/oidc-e2e/tsconfig.json +10 -0
- package/test/oidc-e2e/types.ts +21 -0
- package/test/oidc.integration.spec.ts +54 -0
- package/test/oidc.spec.ts +697 -0
- package/test/oidcLambdaApp.boot.spec.ts +105 -0
- package/test/provision-keycloak-users.spec.ts +299 -0
- package/test/signing-authority.spec.ts +50 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +20 -3
- 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
|
+
}
|