fix(api): treat blank env vars as unset; verify the whole flow in Docker Compose
Found by running the delivery flow against the actual built container rather
than a local dev process.
DEPLOY-02 (Critical) — ADMIN LOGIN RETURNED 500 FOR EVERY USER IN DOCKER.
docker-compose.yml passes each optional variable as ${X:-}, so an unconfigured
one arrives as "" rather than undefined. The code read them with
`process.env.X ?? 'default'`, which only falls back on undefined, so the empty
string reached jsonwebtoken and it threw
"expiresIn" should be a number of seconds or string representing a timespan
on every sign. Invisible to every existing suite, all of which run against a
local process where the variable is genuinely absent.
Added src/common/env.ts (envOr / envOrNull), which treat blank as unset, and
applied it to every auth, port, upload and SMS read. Three were not cosmetic:
a blank UPLOAD_DIR would have served static files from the FILESYSTEM ROOT, a
blank PORT would have bound a random ephemeral port (Number('') === 0), and a
blank JWT_SECRET would have become the verifying key. Recorded as D25, with a
standing rule to use these readers instead of `??`.
Pinned by src/common/env.spec.ts (9 tests), which reproduces the original throw
against the real jsonwebtoken and asserts the reader can no longer produce it.
DEPLOY-03 (process, documented) — the API image is `COPY release/ ./`, so it
ships a bundle pre-built on the developer machine. `docker compose build` will
happily produce an image from a STALE release/app.js and report success; at the
start of this run that bundle was four days old and the rebuilt container was
missing every route added since. DELIVERY.md's deploy section now leads with
`pnpm build:release`.
New: scripts/test-compose-e2e.cjs — the whole delivery flow against the built
container, over HTTP only, as a black box: a sales expert and an ordinary
customer both sign in with the master code, both place an order, both orders
appear in the admin grid and open there, and the three token boundaries still
hold in the deployed image. 32/32.
Verified: compose e2e 32/32 · jest 54/54 · build 5/5 ·
against the CONTAINER: complete-profile 38/38, report 40/40, expert login 36/36 ·
locally: apps smoke 43/43, admin smoke 43/43, B2B scan 34 ok/0 broken.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3f549f1749
commit
24b7a4c395
@@ -0,0 +1,104 @@
|
||||
import { envOr, envOrNull } from './env';
|
||||
|
||||
/**
|
||||
* DEPLOY-02 — a blank environment variable must be treated as unset.
|
||||
*
|
||||
* `docker-compose.yml` passes every optional variable as `${X:-}`, so an
|
||||
* unconfigured one arrives as `""`, not `undefined`. The codebase originally
|
||||
* read these with `process.env.X ?? 'default'`, which only falls back on
|
||||
* `undefined` — so the empty string won, and
|
||||
*
|
||||
* new JwtService().sign(payload, { expiresIn: '' })
|
||||
*
|
||||
* threw `"expiresIn" should be a number of seconds or string representing a
|
||||
* timespan` on EVERY login. In Docker only, which is why it survived every
|
||||
* suite that ran against a local dev process. These tests pin the behaviour.
|
||||
*/
|
||||
describe('envOr / envOrNull (DEPLOY-02)', () => {
|
||||
const saved = { ...process.env };
|
||||
const KEY = 'GOLDBANK_ENV_SPEC_KEY';
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env[KEY];
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = { ...saved };
|
||||
});
|
||||
|
||||
describe('envOr', () => {
|
||||
it('falls back when the variable is absent', () => {
|
||||
expect(envOr(KEY, '12h')).toBe('12h');
|
||||
});
|
||||
|
||||
it('falls back when the variable is the EMPTY STRING — the compose case', () => {
|
||||
process.env[KEY] = '';
|
||||
expect(envOr(KEY, '12h')).toBe('12h');
|
||||
});
|
||||
|
||||
it('falls back when the variable is only whitespace', () => {
|
||||
process.env[KEY] = ' ';
|
||||
expect(envOr(KEY, '12h')).toBe('12h');
|
||||
});
|
||||
|
||||
it('returns the value when one is set', () => {
|
||||
process.env[KEY] = '30d';
|
||||
expect(envOr(KEY, '12h')).toBe('30d');
|
||||
});
|
||||
|
||||
it('trims a padded value rather than passing the padding through', () => {
|
||||
process.env[KEY] = ' 30d ';
|
||||
expect(envOr(KEY, '12h')).toBe('30d');
|
||||
});
|
||||
|
||||
it('never returns an empty string, whatever is set', () => {
|
||||
for (const v of ['', ' ', '\t', '\n', ' \t\n ']) {
|
||||
process.env[KEY] = v;
|
||||
expect(envOr(KEY, 'fallback')).not.toBe('');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('envOrNull', () => {
|
||||
it('is null when absent, empty or blank', () => {
|
||||
expect(envOrNull(KEY)).toBeNull();
|
||||
process.env[KEY] = '';
|
||||
expect(envOrNull(KEY)).toBeNull();
|
||||
process.env[KEY] = ' ';
|
||||
expect(envOrNull(KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('is the trimmed value when set', () => {
|
||||
process.env[KEY] = ' secret ';
|
||||
expect(envOrNull(KEY)).toBe('secret');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The specific failure: an empty expiresIn must never reach jsonwebtoken.
|
||||
* Asserted against the real library so the test fails if the reader regresses.
|
||||
*/
|
||||
describe('the original failure', () => {
|
||||
it('jsonwebtoken rejects an empty expiresIn, and envOr never produces one', () => {
|
||||
// Required rather than imported: jsonwebtoken is a transitive dependency
|
||||
// of @nestjs/jwt and ships no types here. Typed loosely on purpose — this
|
||||
// test is about the VALUE reaching it, not about its signature.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const jwt = require('jsonwebtoken') as {
|
||||
sign: (payload: object, secret: string, opts: { expiresIn: string }) => string;
|
||||
};
|
||||
|
||||
// The bug: this is exactly what `process.env.JWT_EXPIRES_IN ?? '12h'`
|
||||
// produced under compose, and it threw on every login.
|
||||
expect(() => jwt.sign({ sub: 'x' }, 'secret', { expiresIn: '' })).toThrow(
|
||||
/expiresIn/,
|
||||
);
|
||||
|
||||
// The fix: envOr turns that same blank into the default, which signs.
|
||||
process.env[KEY] = '';
|
||||
const token = jwt.sign({ sub: 'x' }, 'secret', { expiresIn: envOr(KEY, '12h') });
|
||||
expect(typeof token).toBe('string');
|
||||
expect(token.split('.')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Environment readers that treat a BLANK value as unset.
|
||||
*
|
||||
* `process.env.X ?? 'default'` only falls back when the variable is absent, so
|
||||
* an empty string sails straight through. That matters here because
|
||||
* `docker-compose.yml` passes every optional variable as `${X:-}`, which sets
|
||||
* it to `""` when the operator has not configured it. jsonwebtoken then throws
|
||||
*
|
||||
* "expiresIn" should be a number of seconds or string representing a timespan
|
||||
*
|
||||
* and EVERY login 500s — in Docker only, which is why it survived every test
|
||||
* that ran against a local dev process. Found by the pre-delivery compose run;
|
||||
* recorded as DEPLOY-02.
|
||||
*/
|
||||
|
||||
/** The variable's trimmed value, or `fallback` when it is unset, empty or blank. */
|
||||
export function envOr(name: string, fallback: string): string {
|
||||
const raw = process.env[name];
|
||||
const trimmed = typeof raw === 'string' ? raw.trim() : '';
|
||||
return trimmed === '' ? fallback : trimmed;
|
||||
}
|
||||
|
||||
/** The variable's trimmed value, or null when it is unset, empty or blank. */
|
||||
export function envOrNull(name: string): string | null {
|
||||
const raw = process.env[name];
|
||||
const trimmed = typeof raw === 'string' ? raw.trim() : '';
|
||||
return trimmed === '' ? null : trimmed;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { AppModule } from './app.module';
|
||||
import { prepareDatabase } from './prisma/bootstrap';
|
||||
import { PrismaExceptionFilter } from './prisma/prisma-exception.filter';
|
||||
import { assertSmsConfigured } from './modules/sms/sms.service';
|
||||
import { envOr, envOrNull } from './common/env';
|
||||
import { masterOtpCode, masterOtpEnabled } from './modules/customer-auth/customer-auth.service';
|
||||
|
||||
// serve-static's bundled `mime` version predates .avif, so it falls back to
|
||||
@@ -29,8 +30,8 @@ async function bootstrap() {
|
||||
// and a sales expert's app token carries their AdminUser id in `sub`. Warn as
|
||||
// loudly as the master-code banner rather than fail the boot, so a running
|
||||
// deployment that has not set it yet still starts.
|
||||
if (!process.env.JWT_CUSTOMER_SECRET?.trim()) {
|
||||
const sharedWithAdmin = Boolean(process.env.JWT_SECRET?.trim());
|
||||
if (!envOrNull('JWT_CUSTOMER_SECRET')) {
|
||||
const sharedWithAdmin = Boolean(envOrNull('JWT_SECRET'));
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'\n' +
|
||||
@@ -87,7 +88,7 @@ async function bootstrap() {
|
||||
app.enableCors();
|
||||
// Uploaded files, mounted OUTSIDE the /api prefix so the URLs stored on
|
||||
// UploadedFile.url ("/uploads/<name>") resolve directly.
|
||||
app.useStaticAssets(process.env.UPLOAD_DIR ?? join(process.cwd(), 'storage', 'uploads'), {
|
||||
app.useStaticAssets(envOr('UPLOAD_DIR', join(process.cwd(), 'storage', 'uploads')), {
|
||||
prefix: '/uploads/',
|
||||
index: false,
|
||||
dotfiles: 'deny',
|
||||
@@ -96,7 +97,9 @@ async function bootstrap() {
|
||||
if (mime) res.setHeader('Content-Type', mime);
|
||||
},
|
||||
});
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
// envOr, not `??`: Number('') is 0, which would bind a random ephemeral port
|
||||
// and make the container unreachable at the port compose maps. See DEPLOY-02.
|
||||
const port = Number(envOr('PORT', '3000'));
|
||||
|
||||
try {
|
||||
// Bind all interfaces explicitly so the container is reachable from nginx.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { envOr } from '../../common/env';
|
||||
|
||||
/**
|
||||
* Directory files are written to and served from.
|
||||
@@ -10,7 +11,7 @@ import { basename, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
* directory and the previous static root then 404'd every file.
|
||||
*/
|
||||
export function getUploadDir(): string {
|
||||
return resolve(process.env.UPLOAD_DIR ?? join(process.cwd(), 'storage', 'uploads'));
|
||||
return resolve(envOr('UPLOAD_DIR', join(process.cwd(), 'storage', 'uploads')));
|
||||
}
|
||||
|
||||
const UPLOAD_MIME: Record<string, string> = {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { APP_GUARD } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { envOr } from '../../common/env';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
@@ -13,11 +14,15 @@ import { JwtAuthGuard, PermissionsGuard } from './guards';
|
||||
PrismaModule,
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET ?? 'dev-only-insecure-secret',
|
||||
// envOr, not `??`: docker-compose passes every optional variable as
|
||||
// `${X:-}`, so an unconfigured one arrives as "" rather than undefined.
|
||||
// An empty expiresIn makes jsonwebtoken throw on EVERY sign, which 500s
|
||||
// admin login in Docker only. See DEPLOY-02.
|
||||
secret: envOr('JWT_SECRET', 'dev-only-insecure-secret'),
|
||||
// jsonwebtoken types expiresIn as a template-literal union, which a plain
|
||||
// `string` from process.env does not satisfy; the cast is the documented escape.
|
||||
signOptions: {
|
||||
expiresIn: (process.env.JWT_EXPIRES_IN ?? '12h') as `${number}h`,
|
||||
expiresIn: envOr('JWT_EXPIRES_IN', '12h') as `${number}h`,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { envOr } from '../../common/env';
|
||||
import { expandWithParents } from './acl.constants';
|
||||
|
||||
export interface JwtPayload {
|
||||
@@ -49,7 +50,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET ?? 'dev-only-insecure-secret',
|
||||
secretOrKey: envOr('JWT_SECRET', 'dev-only-insecure-secret'),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { AdminRole, CustomerKind, MessageAudience, Prisma } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { envOr, envOrNull } from '../../common/env';
|
||||
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
|
||||
import { faToEn } from './digits';
|
||||
import { SmsService, smsConfigured } from '../sms/sms.service';
|
||||
@@ -386,7 +387,7 @@ export class CustomerAuthService {
|
||||
};
|
||||
return this.jwt.signAsync(payload, {
|
||||
secret: customerJwtSecret(),
|
||||
expiresIn: (process.env.JWT_CUSTOMER_EXPIRES_IN ?? '30d') as `${number}d`,
|
||||
expiresIn: envOr('JWT_CUSTOMER_EXPIRES_IN', '30d') as `${number}d`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -408,7 +409,7 @@ export class CustomerAuthService {
|
||||
secret: customerJwtSecret(),
|
||||
// jsonwebtoken types expiresIn as a template-literal union, which a plain
|
||||
// string from process.env does not satisfy — same cast AuthModule uses.
|
||||
expiresIn: (process.env.JWT_CUSTOMER_EXPIRES_IN ?? '30d') as `${number}d`,
|
||||
expiresIn: envOr('JWT_CUSTOMER_EXPIRES_IN', '30d') as `${number}d`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -512,8 +513,8 @@ export class CustomerAuthService {
|
||||
*/
|
||||
export function customerJwtSecret(): string {
|
||||
return (
|
||||
process.env.JWT_CUSTOMER_SECRET ??
|
||||
process.env.JWT_SECRET ??
|
||||
envOrNull('JWT_CUSTOMER_SECRET') ??
|
||||
envOrNull('JWT_SECRET') ??
|
||||
'dev-only-insecure-customer-secret'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { MessageAudience } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { envOr } from '../../common/env';
|
||||
|
||||
/**
|
||||
* SMS delivery — port of henza_server/app/Models/Sms.php.
|
||||
@@ -49,8 +50,8 @@ export interface SmsSendOptions {
|
||||
/** Reads the provider config. Exported so main.ts can validate it at boot. */
|
||||
export function smsConfig() {
|
||||
return {
|
||||
provider: (process.env.SMS_PROVIDER ?? 'trez').toLowerCase(),
|
||||
url: process.env.SMS_TREZ_URL ?? DEFAULT_TREZ_URL,
|
||||
provider: envOr('SMS_PROVIDER', 'trez').toLowerCase(),
|
||||
url: envOr('SMS_TREZ_URL', DEFAULT_TREZ_URL),
|
||||
username: process.env.SMS_TREZ_USERNAME ?? '',
|
||||
password: process.env.SMS_TREZ_PASSWORD ?? '',
|
||||
senderLine: process.env.SMS_TREZ_SENDER_LINE ?? '',
|
||||
|
||||
Reference in New Issue
Block a user