@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
|
@@ -13,8 +13,16 @@ import {
|
|
|
13
13
|
|
|
14
14
|
export const lcaApiEnvironmentShape = {
|
|
15
15
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
|
16
|
+
LAMBDA_STAGE: optionalEnvironmentString,
|
|
17
|
+
CONFIG_STAGE: optionalEnvironmentString,
|
|
18
|
+
RUNTIME_SECRETS_ID: optionalEnvironmentString,
|
|
16
19
|
PORT: environmentPort.default(3000),
|
|
17
20
|
SEED: requiredEnvironmentString,
|
|
21
|
+
SA_SEED_KMS_KEY_ARN: optionalEnvironmentString,
|
|
22
|
+
SA_SEED_LOCAL_KEK: optionalEnvironmentString,
|
|
23
|
+
// Defaults depend on offline/test mode and are resolved below.
|
|
24
|
+
SA_SEED_ENCRYPT_WRITES: optionalEnvironmentBoolean,
|
|
25
|
+
SA_SEED_ALLOW_LEGACY_READ: optionalEnvironmentBoolean,
|
|
18
26
|
MONGO_URI: requiredEnvironmentString,
|
|
19
27
|
MONGO_DB_NAME: requiredEnvironmentString,
|
|
20
28
|
DOMAIN_NAME: optionalEnvironmentString,
|
|
@@ -25,6 +33,17 @@ export const lcaApiEnvironmentShape = {
|
|
|
25
33
|
OPENAI_API_KEY: optionalEnvironmentString,
|
|
26
34
|
METABASE_SECRET_KEY: optionalEnvironmentString,
|
|
27
35
|
SCOUTS_SSO_CLIENT_SECRET: optionalEnvironmentString,
|
|
36
|
+
KEYCLOAK_ISSUERS: optionalEnvironmentString,
|
|
37
|
+
KEYCLOAK_AUDIENCES: optionalEnvironmentString,
|
|
38
|
+
KEYCLOAK_JWKS_URL_OVERRIDES: optionalEnvironmentString,
|
|
39
|
+
OIDC_ISSUER: optionalEnvironmentString,
|
|
40
|
+
OIDC_SIGNING_KEY_JWK: optionalEnvironmentString,
|
|
41
|
+
OIDC_SIGNING_KEY_SECRET_ID: optionalEnvironmentString,
|
|
42
|
+
OIDC_CLIENT_ID: optionalEnvironmentString,
|
|
43
|
+
OIDC_CLIENT_SECRET: optionalEnvironmentString,
|
|
44
|
+
OIDC_REDIRECT_URIS: optionalEnvironmentString,
|
|
45
|
+
GOOGLE_OAUTH_CLIENT_IDS: optionalEnvironmentString,
|
|
46
|
+
APPLE_OAUTH_CLIENT_IDS: optionalEnvironmentString,
|
|
28
47
|
POSTMARK_SERVER_TOKEN: optionalEnvironmentString,
|
|
29
48
|
POSTMARK_FROM_EMAIL: optionalEnvironmentString,
|
|
30
49
|
POSTMARK_BRAND_NAME: optionalEnvironmentString,
|
|
@@ -32,6 +51,27 @@ export const lcaApiEnvironmentShape = {
|
|
|
32
51
|
POSTMARK_ENDORSEMENT_REQUEST_TEMPLATE_ALIAS: optionalEnvironmentString,
|
|
33
52
|
POSTMARK_RECOVERY_EMAIL_CODE_TEMPLATE_ALIAS: optionalEnvironmentString,
|
|
34
53
|
POSTMARK_RECOVERY_KEY_TEMPLATE_ALIAS: optionalEnvironmentString,
|
|
54
|
+
ESCROW_RELAY_URL: optionalEnvironmentUrl,
|
|
55
|
+
ESCROW_RELAY_AUTH_TOKEN: optionalEnvironmentString,
|
|
56
|
+
ESCROW_ENCLAVE_MODE: optionalEnvironmentString.pipe(z.enum(['software', 'remote']).optional()),
|
|
57
|
+
ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON: optionalEnvironmentString,
|
|
58
|
+
ESCROW_ENCLAVE_ACTIVE_KEY_ID: optionalEnvironmentString,
|
|
59
|
+
ESCROW_ENCLAVE_REMOTE_URL: optionalEnvironmentUrl,
|
|
60
|
+
ESCROW_ENCLAVE_REMOTE_TOKEN: optionalEnvironmentString,
|
|
61
|
+
/** Operator-flipped, manual-only kill switch (P7.1). When true, startRecovery/completeRecovery
|
|
62
|
+
* refuse with a friendly message; hold cancellation and notifications keep working. Never
|
|
63
|
+
* auto-flipped by the escrow-ledger-monitor — a compromised monitor must not be able to
|
|
64
|
+
* silently DoS recovery. See services/escrow-ledger-monitor/README.md. */
|
|
65
|
+
ESCROW_RELEASE_KILL_SWITCH: optionalEnvironmentBoolean,
|
|
66
|
+
ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS: optionalEnvironmentString
|
|
67
|
+
.transform(value => (value === undefined ? 10_000 : Number(value)))
|
|
68
|
+
.pipe(z.number().int().positive().max(30_000)),
|
|
69
|
+
ESCROW_HOLD_DURATION_MS: optionalEnvironmentString
|
|
70
|
+
.transform(value => (value === undefined ? 604_800_000 : Number(value)))
|
|
71
|
+
.pipe(z.number().int().positive().max(Number.MAX_SAFE_INTEGER)),
|
|
72
|
+
ESCROW_HOLD_RESTART_MIN_AGE_MS: optionalEnvironmentString
|
|
73
|
+
.transform(value => (value === undefined ? 86_400_000 : Number(value)))
|
|
74
|
+
.pipe(z.number().int().positive().max(Number.MAX_SAFE_INTEGER)),
|
|
35
75
|
ANDROID_PUSH_ICON: optionalEnvironmentString,
|
|
36
76
|
REDIS_HOST: optionalEnvironmentString,
|
|
37
77
|
REDIS_PORT: optionalEnvironmentPort,
|
|
@@ -49,7 +89,110 @@ export const lcaApiEnvironmentShape = {
|
|
|
49
89
|
|
|
50
90
|
export const lcaApiEnvironmentSchema = z
|
|
51
91
|
.object(lcaApiEnvironmentShape)
|
|
92
|
+
.transform(environment => {
|
|
93
|
+
const legacyLocalDefaults = environment.IS_OFFLINE && environment.NODE_ENV !== 'test';
|
|
94
|
+
return {
|
|
95
|
+
...environment,
|
|
96
|
+
SA_SEED_ENCRYPT_WRITES: environment.SA_SEED_ENCRYPT_WRITES ?? !legacyLocalDefaults,
|
|
97
|
+
SA_SEED_ALLOW_LEGACY_READ: environment.SA_SEED_ALLOW_LEGACY_READ ?? legacyLocalDefaults,
|
|
98
|
+
};
|
|
99
|
+
})
|
|
52
100
|
.superRefine((environment, context) => {
|
|
101
|
+
if (
|
|
102
|
+
environment.KEYCLOAK_ISSUERS?.split(',').some(value => value.trim()) &&
|
|
103
|
+
!environment.KEYCLOAK_AUDIENCES?.split(',').some(value => value.trim())
|
|
104
|
+
) {
|
|
105
|
+
context.addIssue({
|
|
106
|
+
code: 'custom',
|
|
107
|
+
path: ['KEYCLOAK_AUDIENCES'],
|
|
108
|
+
message: 'Required when KEYCLOAK_ISSUERS is configured',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (environment.SA_SEED_KMS_KEY_ARN) {
|
|
112
|
+
if (
|
|
113
|
+
!/^arn:aws[a-z-]*:kms:[a-z0-9-]+:\d{12}:key\/[a-zA-Z0-9-]+$/.test(
|
|
114
|
+
environment.SA_SEED_KMS_KEY_ARN
|
|
115
|
+
)
|
|
116
|
+
) {
|
|
117
|
+
context.addIssue({
|
|
118
|
+
code: 'custom',
|
|
119
|
+
path: ['SA_SEED_KMS_KEY_ARN'],
|
|
120
|
+
message: 'Must be a KMS key ARN (not an alias)',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
} else if (!environment.IS_OFFLINE && environment.NODE_ENV !== 'test') {
|
|
124
|
+
context.addIssue({
|
|
125
|
+
code: 'custom',
|
|
126
|
+
path: ['SA_SEED_KMS_KEY_ARN'],
|
|
127
|
+
message: 'Required outside offline/test environments',
|
|
128
|
+
});
|
|
129
|
+
} else if (
|
|
130
|
+
(environment.SA_SEED_ENCRYPT_WRITES || environment.SA_SEED_LOCAL_KEK !== undefined) &&
|
|
131
|
+
!/^[a-fA-F0-9]{64}$/.test(environment.SA_SEED_LOCAL_KEK ?? '')
|
|
132
|
+
) {
|
|
133
|
+
context.addIssue({
|
|
134
|
+
code: 'custom',
|
|
135
|
+
path: ['SA_SEED_LOCAL_KEK'],
|
|
136
|
+
message: 'A 32-byte hex key is required for local seed encryption',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (!environment.SA_SEED_ENCRYPT_WRITES && !environment.SA_SEED_ALLOW_LEGACY_READ) {
|
|
140
|
+
context.addIssue({
|
|
141
|
+
code: 'custom',
|
|
142
|
+
path: ['SA_SEED_ENCRYPT_WRITES'],
|
|
143
|
+
message: 'Plaintext writes require the compatibility reader',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (environment.ESCROW_ENCLAVE_MODE === 'software') {
|
|
148
|
+
try {
|
|
149
|
+
const keys = parseEscrowPrivateKeys(
|
|
150
|
+
environment.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON ?? ''
|
|
151
|
+
);
|
|
152
|
+
if (
|
|
153
|
+
!environment.ESCROW_ENCLAVE_ACTIVE_KEY_ID ||
|
|
154
|
+
!Object.hasOwn(keys, environment.ESCROW_ENCLAVE_ACTIVE_KEY_ID)
|
|
155
|
+
) {
|
|
156
|
+
context.addIssue({
|
|
157
|
+
code: 'custom',
|
|
158
|
+
path: ['ESCROW_ENCLAVE_ACTIVE_KEY_ID'],
|
|
159
|
+
message: 'Software escrow requires an active key ID present in the key map',
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
context.addIssue({
|
|
164
|
+
code: 'custom',
|
|
165
|
+
path: ['ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON'],
|
|
166
|
+
message:
|
|
167
|
+
'Software escrow requires a valid nonempty key ID to private key JSON map',
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Format-only: absence at 'remote' mode is intentionally NOT a parse-time
|
|
172
|
+
// failure. getEscrowEnclave() fails closed per-request instead, so a
|
|
173
|
+
// misconfigured remote backend degrades that one feature rather than
|
|
174
|
+
// crashing the whole service at boot (see escrow-enclave/index.ts).
|
|
175
|
+
if (environment.ESCROW_ENCLAVE_REMOTE_URL) {
|
|
176
|
+
const { protocol, hostname } = new URL(environment.ESCROW_ENCLAVE_REMOTE_URL);
|
|
177
|
+
const isLocalhost = ['localhost', '127.0.0.1', '::1'].includes(hostname);
|
|
178
|
+
if (protocol !== 'https:' && environment.NODE_ENV !== 'test' && !isLocalhost) {
|
|
179
|
+
context.addIssue({
|
|
180
|
+
code: 'custom',
|
|
181
|
+
path: ['ESCROW_ENCLAVE_REMOTE_URL'],
|
|
182
|
+
message: 'Must use https:// outside tests or localhost',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (
|
|
187
|
+
environment.ESCROW_ENCLAVE_REMOTE_TOKEN &&
|
|
188
|
+
environment.ESCROW_ENCLAVE_REMOTE_TOKEN.length < 32
|
|
189
|
+
) {
|
|
190
|
+
context.addIssue({
|
|
191
|
+
code: 'custom',
|
|
192
|
+
path: ['ESCROW_ENCLAVE_REMOTE_TOKEN'],
|
|
193
|
+
message: 'Must be at least 32 characters',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
53
196
|
if (
|
|
54
197
|
environment.NODE_ENV === 'production' &&
|
|
55
198
|
!environment.IS_OFFLINE &&
|
|
@@ -85,6 +228,7 @@ export const parseLcaApiEnvironment = (
|
|
|
85
228
|
raw.NODE_ENV === 'test'
|
|
86
229
|
? {
|
|
87
230
|
SEED: 'a'.repeat(64),
|
|
231
|
+
SA_SEED_LOCAL_KEK: 'f'.repeat(64),
|
|
88
232
|
MONGO_URI: 'mongodb://localhost:27017',
|
|
89
233
|
MONGO_DB_NAME: 'lca-api-test',
|
|
90
234
|
}
|
|
@@ -101,4 +245,24 @@ export const parseLcaApiEnvironment = (
|
|
|
101
245
|
);
|
|
102
246
|
};
|
|
103
247
|
|
|
248
|
+
// Read live from process.env (not the frozen `environment` snapshot below) so an
|
|
249
|
+
// operator's flip takes effect on the next request without a full restart, and so
|
|
250
|
+
// tests can toggle it per-case. The escrow-ledger-monitor must never set this itself
|
|
251
|
+
// — see services/escrow-ledger-monitor/README.md.
|
|
252
|
+
export const isEscrowReleaseKillSwitchEnabled = (): boolean =>
|
|
253
|
+
optionalEnvironmentBoolean.parse(process.env.ESCROW_RELEASE_KILL_SWITCH) ?? false;
|
|
254
|
+
|
|
255
|
+
/** Parse configuration without exposing secret values in validation errors. */
|
|
256
|
+
export const parseEscrowPrivateKeys = (serialized: string): Record<string, string> => {
|
|
257
|
+
try {
|
|
258
|
+
const parsed: unknown = JSON.parse(serialized);
|
|
259
|
+
return z
|
|
260
|
+
.record(z.string().regex(/^[A-Za-z0-9._-]{1,128}$/), z.string().trim().min(1))
|
|
261
|
+
.refine(keys => Object.keys(keys).length > 0)
|
|
262
|
+
.parse(parsed);
|
|
263
|
+
} catch {
|
|
264
|
+
throw new Error('Invalid escrow private key configuration');
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
104
268
|
export const environment = parseLcaApiEnvironment(process.env);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { mergeRuntimeSecrets, resolveStageDefaults } from '@learncard/service-config';
|
|
4
|
+
|
|
5
|
+
import { parseLcaApiEnvironment } from './environment';
|
|
6
|
+
import { base, stages } from './stageConfig';
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const functions: {
|
|
10
|
+
api: () => Record<string, string>;
|
|
11
|
+
provider: (context?: { options: { stage: string } }) => Record<string, unknown>;
|
|
12
|
+
} = require('../../serverless.function-env.cjs');
|
|
13
|
+
|
|
14
|
+
// Deployed precedence for an api function in bundle mode: real Lambda env > bundle > stage file.
|
|
15
|
+
const deployedEnvironment = (
|
|
16
|
+
bundle: Record<string, string>
|
|
17
|
+
): Record<string, string | undefined> => {
|
|
18
|
+
const lambdaEnv = Object.fromEntries(
|
|
19
|
+
Object.entries({ ...functions.provider({ options: { stage: 'dev' } }), ...functions.api() })
|
|
20
|
+
// CloudFormation intrinsics (KMS ARN, Redis endpoint) resolve only at deploy time.
|
|
21
|
+
.filter((entry): entry is [string, string] => typeof entry[1] === 'string')
|
|
22
|
+
);
|
|
23
|
+
const stageDefaults = resolveStageDefaults({ base, stages, stage: 'dev', env: lambdaEnv });
|
|
24
|
+
const resolvedIntrinsics = {
|
|
25
|
+
SA_SEED_KMS_KEY_ARN: 'arn:aws:kms:us-east-1:000000000000:key/test',
|
|
26
|
+
};
|
|
27
|
+
return mergeRuntimeSecrets({ ...stageDefaults, ...lambdaEnv, ...resolvedIntrinsics }, bundle);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
31
|
+
|
|
32
|
+
describe('escrow in runtime-secrets bundle mode', () => {
|
|
33
|
+
it('keeps software escrow enabled with keys supplied only by the bundle', () => {
|
|
34
|
+
vi.stubEnv('RUNTIME_SECRETS_ID', 'lca-api/dev/runtime-secrets');
|
|
35
|
+
vi.stubEnv('ESCROW_ENCLAVE_MODE', 'software');
|
|
36
|
+
vi.stubEnv('SEED', '');
|
|
37
|
+
|
|
38
|
+
const activeKeyId = stages.dev.ESCROW_ENCLAVE_ACTIVE_KEY_ID;
|
|
39
|
+
const env = parseLcaApiEnvironment(
|
|
40
|
+
deployedEnvironment({
|
|
41
|
+
SEED: 'a'.repeat(64),
|
|
42
|
+
MONGO_URI: 'mongodb://localhost:27017',
|
|
43
|
+
MONGO_DB_NAME: 'test',
|
|
44
|
+
POSTMARK_SERVER_TOKEN: 'postmark',
|
|
45
|
+
ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON: JSON.stringify({ [activeKeyId]: 'k' }),
|
|
46
|
+
})
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(env.ESCROW_ENCLAVE_MODE).toBe('software');
|
|
50
|
+
expect(env.ESCROW_ENCLAVE_ACTIVE_KEY_ID).toBe(activeKeyId);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { lcaApiEnvironmentShape } from './environment';
|
|
6
|
+
import { stages } from './stageConfig';
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const serverlessRequire = createRequire(require.resolve('serverless/package.json'));
|
|
10
|
+
const yaml: { load: (source: string) => unknown } = serverlessRequire('js-yaml');
|
|
11
|
+
|
|
12
|
+
const functionEnvironment = require('../../serverless.function-env.cjs') as Record<
|
|
13
|
+
'api' | 'escrow',
|
|
14
|
+
() => Record<string, string>
|
|
15
|
+
>;
|
|
16
|
+
|
|
17
|
+
// Every schema ESCROW_* setting has exactly one source under the config model.
|
|
18
|
+
const SOURCES = {
|
|
19
|
+
// Credentials and the endpoints bound to them: runtime bundle, GitHub fallback.
|
|
20
|
+
bundle: [
|
|
21
|
+
'ESCROW_RELAY_URL',
|
|
22
|
+
'ESCROW_RELAY_AUTH_TOKEN',
|
|
23
|
+
'ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON',
|
|
24
|
+
'ESCROW_ENCLAVE_REMOTE_URL',
|
|
25
|
+
'ESCROW_ENCLAVE_REMOTE_TOKEN',
|
|
26
|
+
],
|
|
27
|
+
// Operator toggles: GitHub vars forwarded in every mode.
|
|
28
|
+
toggle: ['ESCROW_ENCLAVE_MODE', 'ESCROW_RELEASE_KILL_SWITCH'],
|
|
29
|
+
// Non-secret tuning: checked-in config.<stage>.json (or schema default).
|
|
30
|
+
stage: [
|
|
31
|
+
'ESCROW_ENCLAVE_ACTIVE_KEY_ID',
|
|
32
|
+
'ESCROW_HOLD_DURATION_MS',
|
|
33
|
+
'ESCROW_HOLD_RESTART_MIN_AGE_MS',
|
|
34
|
+
'ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS',
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
const SECRETS = new Set([
|
|
38
|
+
'ESCROW_RELAY_AUTH_TOKEN',
|
|
39
|
+
'ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON',
|
|
40
|
+
'ESCROW_ENCLAVE_REMOTE_TOKEN',
|
|
41
|
+
]);
|
|
42
|
+
// serverless.yml PrivateLink inputs; read at deploy time, never by the Lambda runtime.
|
|
43
|
+
const DEPLOY_TIME = ['ESCROW_ENCLAVE_ENDPOINT_SERVICE_NAME', 'ESCROW_ENCLAVE_HOSTNAME'];
|
|
44
|
+
|
|
45
|
+
const settings = Object.keys(lcaApiEnvironmentShape).filter(name => name.startsWith('ESCROW_'));
|
|
46
|
+
|
|
47
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
48
|
+
|
|
49
|
+
describe('escrow deployment wiring', () => {
|
|
50
|
+
it('classifies every schema ESCROW_* setting exactly once', () => {
|
|
51
|
+
const classified = Object.values(SOURCES).flat();
|
|
52
|
+
expect([...classified].sort()).toEqual([...settings].sort());
|
|
53
|
+
expect(new Set(classified).size).toBe(classified.length);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('passes only bundle fallbacks, toggles and deploy-time inputs through GitHub', () => {
|
|
57
|
+
const workflow = yaml.load(
|
|
58
|
+
readFileSync(
|
|
59
|
+
new URL('../../../../../.github/workflows/deploy.yml', import.meta.url),
|
|
60
|
+
'utf8'
|
|
61
|
+
)
|
|
62
|
+
) as {
|
|
63
|
+
jobs: Record<string, { steps: { name: string; env?: Record<string, string> }[] }>;
|
|
64
|
+
};
|
|
65
|
+
const env = workflow.jobs['deploy-lca-api']!.steps.find(
|
|
66
|
+
step => step.name === 'Deploy LCA API Service Lambda'
|
|
67
|
+
)!.env!;
|
|
68
|
+
|
|
69
|
+
for (const name of [...SOURCES.bundle, ...SOURCES.toggle, ...DEPLOY_TIME]) {
|
|
70
|
+
expect(env[name], name).toBe(
|
|
71
|
+
`\${{ ${SECRETS.has(name) ? 'secrets' : 'vars'}.${name} }}`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
for (const name of SOURCES.stage) expect(env[name], name).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('runs the scheduled escrow jobs with the API environment (bundle + toggles)', () => {
|
|
78
|
+
const serverless = yaml.load(
|
|
79
|
+
readFileSync(new URL('../../serverless.yml', import.meta.url), 'utf8')
|
|
80
|
+
) as { functions: Record<string, { environment?: string; role?: string }> };
|
|
81
|
+
for (const name of ['escrowHoldReminders', 'escrowBlobRewrap']) {
|
|
82
|
+
expect(serverless.functions[name]?.environment, name).toBe(
|
|
83
|
+
'${file(./serverless.function-env.cjs):escrow}'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
expect(functionEnvironment.escrow).toBe(functionEnvironment.api);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('keeps escrow toggles and drops credential fallbacks in bundle mode', () => {
|
|
90
|
+
for (const name of [...SOURCES.bundle, ...SOURCES.toggle]) vi.stubEnv(name, name);
|
|
91
|
+
vi.stubEnv('RUNTIME_SECRETS_ID', 'lca-api/dev/runtime-secrets');
|
|
92
|
+
expect(functionEnvironment.escrow()).toEqual({
|
|
93
|
+
RUNTIME_SECRETS_ID: 'lca-api/dev/runtime-secrets',
|
|
94
|
+
ESCROW_ENCLAVE_MODE: 'ESCROW_ENCLAVE_MODE',
|
|
95
|
+
ESCROW_RELEASE_KILL_SWITCH: 'ESCROW_RELEASE_KILL_SWITCH',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
vi.stubEnv('RUNTIME_SECRETS_ID', '');
|
|
99
|
+
expect(Object.keys(functionEnvironment.escrow()).sort()).toEqual(
|
|
100
|
+
[...SOURCES.bundle, ...SOURCES.toggle].sort()
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('never checks in escrow credentials', () => {
|
|
105
|
+
for (const [stage, config] of Object.entries(stages)) {
|
|
106
|
+
for (const name of [...SOURCES.bundle, ...SOURCES.toggle]) {
|
|
107
|
+
expect(
|
|
108
|
+
(config as Record<string, string>)[name],
|
|
109
|
+
`${stage}/${name}`
|
|
110
|
+
).toBeUndefined();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import {
|
|
5
|
+
optionalEnvironmentBoolean,
|
|
6
|
+
optionalEnvironmentString,
|
|
7
|
+
parseEnvironment,
|
|
8
|
+
} from '@learncard/helpers';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Focused environment contract for the OIDC provider surface so the OIDC Lambda
|
|
12
|
+
* can boot without SEED/MONGO/Postmark/Firebase/bundle. Validates ONLY the
|
|
13
|
+
* fields read by src/oidc.ts and src/helpers/oidc.helpers.ts, reproducing the
|
|
14
|
+
* full schema's parsers and its KEYCLOAK_ISSUERS->KEYCLOAK_AUDIENCES superRefine
|
|
15
|
+
* verbatim so no constraint is weakened.
|
|
16
|
+
*/
|
|
17
|
+
export const oidcEnvironmentShape = {
|
|
18
|
+
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
|
19
|
+
LAMBDA_STAGE: optionalEnvironmentString,
|
|
20
|
+
IS_OFFLINE: optionalEnvironmentBoolean.default(false),
|
|
21
|
+
IS_E2E_TEST: optionalEnvironmentBoolean.default(false),
|
|
22
|
+
KEYCLOAK_ISSUERS: optionalEnvironmentString,
|
|
23
|
+
KEYCLOAK_AUDIENCES: optionalEnvironmentString,
|
|
24
|
+
OIDC_ISSUER: optionalEnvironmentString,
|
|
25
|
+
OIDC_SIGNING_KEY_JWK: optionalEnvironmentString,
|
|
26
|
+
OIDC_SIGNING_KEY_SECRET_ID: optionalEnvironmentString,
|
|
27
|
+
OIDC_CLIENT_ID: optionalEnvironmentString,
|
|
28
|
+
OIDC_CLIENT_SECRET: optionalEnvironmentString,
|
|
29
|
+
OIDC_REDIRECT_URIS: optionalEnvironmentString,
|
|
30
|
+
} satisfies z.ZodRawShape;
|
|
31
|
+
|
|
32
|
+
export const oidcEnvironmentSchema = z
|
|
33
|
+
.object(oidcEnvironmentShape)
|
|
34
|
+
.superRefine((environment, context) => {
|
|
35
|
+
if (
|
|
36
|
+
environment.KEYCLOAK_ISSUERS?.split(',').some(value => value.trim()) &&
|
|
37
|
+
!environment.KEYCLOAK_AUDIENCES?.split(',').some(value => value.trim())
|
|
38
|
+
) {
|
|
39
|
+
context.addIssue({
|
|
40
|
+
code: 'custom',
|
|
41
|
+
path: ['KEYCLOAK_AUDIENCES'],
|
|
42
|
+
message: 'Required when KEYCLOAK_ISSUERS is configured',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export type OidcEnvironment = z.output<typeof oidcEnvironmentSchema>;
|
|
48
|
+
|
|
49
|
+
export const parseOidcEnvironment = (
|
|
50
|
+
raw: Record<string, unknown>,
|
|
51
|
+
source = 'process environment'
|
|
52
|
+
): OidcEnvironment =>
|
|
53
|
+
parseEnvironment(oidcEnvironmentSchema, raw, {
|
|
54
|
+
project: 'lca-api (oidc)',
|
|
55
|
+
source,
|
|
56
|
+
examplePath: 'services/learn-card-network/lca-api/.env.example',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export const environment = parseOidcEnvironment(process.env);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
const functions: {
|
|
6
|
+
api: () => Record<string, string>;
|
|
7
|
+
oidc: () => Record<string, string>;
|
|
8
|
+
provider: (context?: {
|
|
9
|
+
options: { stage: string; httpPort?: string };
|
|
10
|
+
}) => Record<string, unknown>;
|
|
11
|
+
} = require('../../serverless.function-env.cjs');
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.stubEnv('RUNTIME_SECRETS_ID', '');
|
|
15
|
+
vi.stubEnv('SEED', 'seed');
|
|
16
|
+
vi.stubEnv('GOOGLE_APPLICATION_CREDENTIAL', 'credential');
|
|
17
|
+
vi.stubEnv('OIDC_CLIENT_SECRET', 'broker-secret');
|
|
18
|
+
vi.stubEnv('OIDC_REDIRECT_URIS', 'https://example.com/callback');
|
|
19
|
+
});
|
|
20
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
21
|
+
|
|
22
|
+
describe('lca-api serverless function environments', () => {
|
|
23
|
+
it('omits all fallback credentials in bundle mode', () => {
|
|
24
|
+
vi.stubEnv('RUNTIME_SECRETS_ID', 'lca-api/dev/runtime-secrets');
|
|
25
|
+
expect(functions.api()).toEqual({ RUNTIME_SECRETS_ID: 'lca-api/dev/runtime-secrets' });
|
|
26
|
+
expect(Object.keys(functions.provider()).sort()).toEqual([
|
|
27
|
+
'CONFIG_TENANT',
|
|
28
|
+
'LAMBDA_STAGE',
|
|
29
|
+
'PORT',
|
|
30
|
+
'REDIS_HOST',
|
|
31
|
+
'REDIS_PORT',
|
|
32
|
+
'SA_SEED_KMS_KEY_ARN',
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('keeps the non-secret escrow toggle in bundle mode', () => {
|
|
37
|
+
vi.stubEnv('RUNTIME_SECRETS_ID', 'lca-api/dev/runtime-secrets');
|
|
38
|
+
vi.stubEnv('ESCROW_ENCLAVE_MODE', 'software');
|
|
39
|
+
vi.stubEnv('ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON', '{"k":"secret"}');
|
|
40
|
+
expect(functions.api()).toEqual({
|
|
41
|
+
RUNTIME_SECRETS_ID: 'lca-api/dev/runtime-secrets',
|
|
42
|
+
ESCROW_ENCLAVE_MODE: 'software',
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('bakes the tenant selector into every function, defaulting to learncard', () => {
|
|
47
|
+
expect(functions.provider().CONFIG_TENANT).toBe('learncard');
|
|
48
|
+
vi.stubEnv('CONFIG_TENANT', 'scouts');
|
|
49
|
+
expect(functions.provider().CONFIG_TENANT).toBe('scouts');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('retains the existing provider and API credentials only in fallback mode', () => {
|
|
53
|
+
expect(functions.provider().SEED).toBe('seed');
|
|
54
|
+
expect(functions.api().GOOGLE_APPLICATION_CREDENTIAL).toBe('credential');
|
|
55
|
+
expect(functions.api().RUNTIME_SECRETS_ID).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('keeps the OIDC exception scoped to its separate role', () => {
|
|
59
|
+
vi.stubEnv('RUNTIME_SECRETS_ID', 'bundle');
|
|
60
|
+
expect(functions.oidc()).toEqual({
|
|
61
|
+
OIDC_CLIENT_SECRET: 'broker-secret',
|
|
62
|
+
OIDC_REDIRECT_URIS: 'https://example.com/callback',
|
|
63
|
+
});
|
|
64
|
+
expect(functions.provider().OIDC_CLIENT_SECRET).toBeUndefined();
|
|
65
|
+
expect(functions.api().OIDC_CLIENT_SECRET).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it.each(['', 'bundle'])(
|
|
69
|
+
'resolves the full provider environment through Serverless v3 (%s)',
|
|
70
|
+
async bundle => {
|
|
71
|
+
const source = require('serverless/lib/configuration/variables/sources/file');
|
|
72
|
+
vi.stubEnv('RUNTIME_SECRETS_ID', bundle);
|
|
73
|
+
const options = { stage: 'production', httpPort: '5100' };
|
|
74
|
+
for (const name of ['provider', 'api', 'oidc'] as const) {
|
|
75
|
+
const result = await source.resolve({
|
|
76
|
+
serviceDir: new URL('../../', import.meta.url).pathname,
|
|
77
|
+
params: [
|
|
78
|
+
new URL('../../serverless.function-env.cjs', import.meta.url).pathname,
|
|
79
|
+
],
|
|
80
|
+
address: name,
|
|
81
|
+
options,
|
|
82
|
+
resolveConfigurationProperty: vi.fn(),
|
|
83
|
+
resolveVariable: vi.fn(),
|
|
84
|
+
});
|
|
85
|
+
const expected =
|
|
86
|
+
name === 'provider' ? functions.provider({ options }) : functions[name]();
|
|
87
|
+
expect(result.value).toEqual(expected);
|
|
88
|
+
if (name === 'provider') {
|
|
89
|
+
expect(result.value.LAMBDA_STAGE).toBe('production');
|
|
90
|
+
expect(result.value.PORT).toBe('5100');
|
|
91
|
+
expect(result.value.SA_SEED_KMS_KEY_ARN).toEqual({
|
|
92
|
+
'Fn::GetAtt': ['SigningAuthoritySeedKey', 'Arn'],
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { afterEach, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Model a non-empty .env value arriving before the stage defaults are applied.
|
|
4
|
+
vi.mock('dotenv/config', () => {
|
|
5
|
+
vi.stubEnv('SENTRY_ENV', 'self-hosted');
|
|
6
|
+
return {};
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
10
|
+
|
|
11
|
+
it('loads local env before applying statically imported stage defaults', async () => {
|
|
12
|
+
const { applyLcaApiStageConfig, stages } = await import('./stageConfig');
|
|
13
|
+
for (const key of Object.keys(stages.dev)) {
|
|
14
|
+
if (key !== 'SENTRY_ENV') vi.stubEnv(key, '');
|
|
15
|
+
}
|
|
16
|
+
applyLcaApiStageConfig('dev');
|
|
17
|
+
expect(process.env.SENTRY_ENV).toBe('self-hosted');
|
|
18
|
+
expect(process.env.DOMAIN_NAME).toBe(stages.dev.DOMAIN_NAME);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('does not select a deployed stage for an unknown local stage', async () => {
|
|
22
|
+
const { applyLcaApiStageConfig } = await import('./stageConfig');
|
|
23
|
+
vi.stubEnv('DOMAIN_NAME', '');
|
|
24
|
+
applyLcaApiStageConfig('unregistered-self-hosted-stage');
|
|
25
|
+
expect(process.env.DOMAIN_NAME).toBe('');
|
|
26
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import { applyStageConfig } from '@learncard/service-config';
|
|
3
|
+
|
|
4
|
+
import baseConfig from '../../config/config.json';
|
|
5
|
+
import devConfig from '../../config/config.dev.json';
|
|
6
|
+
import productionConfig from '../../config/config.production.json';
|
|
7
|
+
import scoutsDevConfig from '../../config/config.scouts.dev.json';
|
|
8
|
+
import scoutsProductionConfig from '../../config/config.scouts.production.json';
|
|
9
|
+
|
|
10
|
+
export const base: Record<string, string> = baseConfig;
|
|
11
|
+
|
|
12
|
+
export const stages = {
|
|
13
|
+
dev: devConfig,
|
|
14
|
+
production: productionConfig,
|
|
15
|
+
// ScoutPass deploys the same services and stage names; CONFIG_TENANT=scouts selects these.
|
|
16
|
+
'scouts.dev': scoutsDevConfig,
|
|
17
|
+
'scouts.production': scoutsProductionConfig,
|
|
18
|
+
} satisfies Record<string, Record<string, string>>;
|
|
19
|
+
|
|
20
|
+
// Must run before any module that reads configuration on import (e.g. @environment):
|
|
21
|
+
// merges base + the selected stage into process.env; explicit non-empty env still wins.
|
|
22
|
+
export const applyLcaApiStageConfig = (stage: string | undefined): void => {
|
|
23
|
+
applyStageConfig({ base, stages, stage });
|
|
24
|
+
};
|
package/src/docker-entry.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import './config/applyDockerStageConfig';
|
|
2
|
+
|
|
1
3
|
import { environment } from '@environment';
|
|
2
4
|
import path from 'path';
|
|
3
5
|
|
|
@@ -9,6 +11,9 @@ import { fastifyTRPCOpenApiPlugin, CreateOpenApiFastifyPluginOptions } from 'trp
|
|
|
9
11
|
import { appRouter, type AppRouter, createContext } from './app';
|
|
10
12
|
import { openApiDocument } from './openapi';
|
|
11
13
|
import { didFastifyPlugin } from './dids';
|
|
14
|
+
import { ensureUserKeysIndexes, createEscrowHoldsIndexes } from './models';
|
|
15
|
+
import { oidcFastifyPlugin } from './oidc';
|
|
16
|
+
import { ensureAuthSubjectIndexes } from './models/AuthSubject';
|
|
12
17
|
|
|
13
18
|
const server = Fastify({ maxParamLength: 5000 });
|
|
14
19
|
|
|
@@ -50,6 +55,7 @@ server.register(fastifyCors, {
|
|
|
50
55
|
'Authorization',
|
|
51
56
|
'X-Tenant-Id',
|
|
52
57
|
'X-Guardian-Approval',
|
|
58
|
+
'X-Auth-Token',
|
|
53
59
|
'baggage',
|
|
54
60
|
'sentry-trace',
|
|
55
61
|
],
|
|
@@ -100,10 +106,17 @@ server.register(fastifyStatic, {
|
|
|
100
106
|
});
|
|
101
107
|
|
|
102
108
|
server.register(didFastifyPlugin);
|
|
109
|
+
server.register(oidcFastifyPlugin);
|
|
103
110
|
|
|
104
111
|
(async () => {
|
|
105
112
|
try {
|
|
113
|
+
await ensureAuthSubjectIndexes().catch(error => {
|
|
114
|
+
// Login requests retry index creation and must succeed before writing subjects.
|
|
115
|
+
console.error('AuthSubject indexes unavailable at startup; login will retry:', error);
|
|
116
|
+
});
|
|
106
117
|
console.log('Server starting on port ', environment.PORT || 3000);
|
|
118
|
+
await ensureUserKeysIndexes();
|
|
119
|
+
await createEscrowHoldsIndexes();
|
|
107
120
|
await server.listen({ host: '0.0.0.0', port: Number(environment.PORT || 3000) });
|
|
108
121
|
} catch (err) {
|
|
109
122
|
console.error(err);
|