@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.
- package/.env.example +99 -2
- package/AGENTS.md +155 -44
- package/CHANGELOG.md +84 -0
- package/bootstrapLambda.test.ts +76 -0
- package/compose.env +5 -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/accesslayer/signing-authority/index.d.ts +8 -2
- package/dist/app.d.ts +361 -8
- package/dist/cache/challenges.d.ts +2 -0
- 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/cache/recoverySessions.d.ts +42 -0
- package/dist/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +253 -2
- package/dist/config/environment.test.d.ts +1 -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/learnCard.signingAuthority.test.d.ts +1 -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/rateLimit.helpers.d.ts +4 -0
- package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
- package/dist/helpers/seedEncryption.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/migrations/signingAuthoritySeeds.d.ts +13 -0
- package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
- package/dist/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +153 -0
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/SigningAuthority.d.ts +7 -1
- package/dist/models/UserKey.d.ts +326 -3
- package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
- package/dist/models/index.d.ts +2 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +164 -0
- package/dist/routes/index.d.ts +6 -0
- package/dist/routes/keys.d.ts +182 -7
- package/dist/routes/notifications.d.ts +5 -0
- package/dist/routes/qr-login.d.ts +1 -1
- 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 +25 -0
- package/dist/services/escrow-enclave/notifications.d.ts +24 -0
- 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 +27 -0
- package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +111 -0
- package/dist/types/notifications.d.ts +5 -0
- package/dist/types/seed-encryption.d.ts +31 -0
- package/dist/types/seed-migration.d.ts +49 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -112
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +30 -14
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +139 -0
- package/scripts/check-signing-authority-redis.mjs +93 -0
- 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/migrate-signing-authority-seeds-local.sh +51 -0
- package/scripts/migrate-signing-authority-seeds.mjs +105 -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 +40 -0
- package/seedMigrationLambda.ts +19 -0
- package/serverless-local.yml +28 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +294 -26
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/create.ts +11 -7
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +4 -0
- package/src/cache/challenges.ts +12 -2
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/in-memory-lru.ts +1 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/cache/recoverySessions.ts +252 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +159 -0
- package/src/config/environment.ts +164 -0
- 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 +13 -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/learnCard.helpers.test.ts +11 -1
- package/src/helpers/learnCard.helpers.ts +57 -45
- package/src/helpers/learnCard.signingAuthority.test.ts +194 -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/rateLimit.helpers.ts +112 -0
- package/src/helpers/seedEncryption.helpers.test.ts +427 -0
- package/src/helpers/seedEncryption.helpers.ts +372 -0
- package/src/helpers/signingAuthority.helpers.ts +4 -1
- 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/migrations/signingAuthoritySeeds.ts +430 -0
- package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
- package/src/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +541 -0
- package/src/models/SigningAuthority.ts +14 -3
- package/src/models/UserKey.escrowPin.test.ts +49 -0
- package/src/models/UserKey.ts +1109 -40
- package/src/models/index.ts +2 -0
- package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/credentials.ts +8 -0
- package/src/routes/escrow.ts +1073 -0
- package/src/routes/firebase.ts +90 -48
- package/src/routes/index.ts +98 -5
- package/src/routes/keys.ts +1022 -174
- package/src/routes/qr-login.ts +51 -53
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +48 -1
- package/src/services/escrow-enclave/README.md +122 -0
- 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 +174 -0
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +348 -0
- 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 +450 -0
- package/src/services/escrow-enclave/softwareEnclave.ts +254 -0
- package/src/services/escrow-enclave/types.ts +122 -0
- package/src/types/seed-encryption.ts +51 -0
- package/src/types/seed-migration.ts +61 -0
- package/test/auth-tickets.spec.ts +480 -0
- package/test/credentials.spec.ts +2 -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 +2328 -0
- package/test/keys-identity-rebind.spec.ts +456 -0
- package/test/keys-lifecycle-invariants.spec.ts +785 -0
- package/test/keys-recovery-confirmation.spec.ts +586 -0
- package/test/keys-recovery-rate-limit.spec.ts +123 -0
- package/test/keys-secret-leakage.spec.ts +265 -0
- package/test/models/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +572 -0
- 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-http.spec.ts +161 -0
- package/test/signing-authority-seed-migration.spec.ts +414 -0
- package/test/signing-authority.spec.ts +90 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +25 -3
- package/dist/cache/signing-authority.d.ts +0 -6
- 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
|
+
};
|