Initial commit: Hedena Gold monorepo.

First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
This commit is contained in:
s.esmaeili
2026-10-15 17:19:10 +03:30
commit ea86636027
1121 changed files with 401065 additions and 0 deletions
@@ -0,0 +1,19 @@
import { Global, Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminSessionService } from './admin-session.service';
import { AppSessionService } from './app-session.service';
/**
* Revocable admin sessions.
*
* Global so every login path (admin panel and app) and the IAM
* back-channel-logout endpoint can use these without wiring imports. Both
* services are inert while tracking is off (see AdminSessionService).
*/
@Global()
@Module({
imports: [PrismaModule],
providers: [AdminSessionService, AppSessionService],
exports: [AdminSessionService, AppSessionService],
})
export class AdminSessionModule {}
@@ -0,0 +1,164 @@
import { AdminSessionService } from './admin-session.service';
import type { IamService } from '../iam/iam.service';
/**
* Revocable admin sessions.
*
* Two contracts are locked down here. First, with tracking off nothing changes:
* no `jti`, no row, and no extra query on an authenticated request. Second, with
* it on, a revoked session stops working immediately — which is the whole point,
* because before this table an issued token was usable for its full 12h no matter
* what happened centrally.
*/
describe('AdminSessionService', () => {
const ORIGINAL_ENV = process.env;
const build = (opts: { iamEnabled?: boolean; tracking?: string | null } = {}) => {
process.env = { ...ORIGINAL_ENV };
delete process.env.AUTH_SESSION_TRACKING;
if (opts.tracking != null) process.env.AUTH_SESSION_TRACKING = opts.tracking;
const prisma = {
adminSession: {
create: jest.fn().mockResolvedValue({}),
findUnique: jest.fn(),
updateMany: jest.fn().mockResolvedValue({ count: 2 }),
},
};
const iam = { enabled: opts.iamEnabled ?? false } as IamService;
return { service: new AdminSessionService(prisma as never, iam), prisma };
};
afterEach(() => {
process.env = ORIGINAL_ENV;
});
/* ─────────────── off by default ─────────────── */
it('is off in a deployment that does not use the identity service', () => {
expect(build().service.enabled).toBe(false);
});
it('turns itself on with the identity service, so central revocation reaches us', () => {
expect(build({ iamEnabled: true }).service.enabled).toBe(true);
});
it('can be turned on on its own', () => {
expect(build({ tracking: 'true' }).service.enabled).toBe(true);
});
it('can be turned off explicitly even with the identity service on', () => {
// Opting out of the per-request lookup; central revocation then cannot reach here
expect(build({ iamEnabled: true, tracking: 'false' }).service.enabled).toBe(false);
});
/* ─────────────── unchanged behaviour while off ─────────────── */
it('issues no token id and writes no row while off', async () => {
const { service, prisma } = build();
expect(service.newTokenId()).toBeNull();
await service.record({ jti: 'x', adminUserId: 'a' });
expect(prisma.adminSession.create).not.toHaveBeenCalled();
});
it('authenticates without a database lookup while off', async () => {
const { service, prisma } = build();
await expect(service.isActive('some-jti')).resolves.toBe(true);
expect(prisma.adminSession.findUnique).not.toHaveBeenCalled();
});
/* ─────────────── enforcement while on ─────────────── */
it('accepts a live session', async () => {
const { service, prisma } = build({ iamEnabled: true });
prisma.adminSession.findUnique.mockResolvedValue({ revokedAt: null });
await expect(service.isActive('jti-1')).resolves.toBe(true);
});
it('refuses a revoked session', async () => {
const { service, prisma } = build({ iamEnabled: true });
prisma.adminSession.findUnique.mockResolvedValue({ revokedAt: new Date() });
// The gap this closes: previously the token kept working until it expired
await expect(service.isActive('jti-1')).resolves.toBe(false);
});
it('refuses a token whose session row is gone', async () => {
const { service, prisma } = build({ iamEnabled: true });
prisma.adminSession.findUnique.mockResolvedValue(null);
await expect(service.isActive('jti-1')).resolves.toBe(false);
});
it('keeps tokens issued before tracking was switched on working', async () => {
const { service, prisma } = build({ iamEnabled: true });
// Such a token carries no jti; refusing it would sign every admin out at once
await expect(service.isActive(undefined)).resolves.toBe(true);
expect(prisma.adminSession.findUnique).not.toHaveBeenCalled();
});
/* ─────────────── recording and revoking ─────────────── */
it('records the session with an expiry derived from the token lifetime', async () => {
process.env.JWT_EXPIRES_IN = '12h';
const { service, prisma } = build({ iamEnabled: true });
process.env.JWT_EXPIRES_IN = '12h';
await service.record({ jti: 'jti-1', adminUserId: 'admin-1', iamSessionId: 'sid-1', iamSub: 'sub-1' });
const data = prisma.adminSession.create.mock.calls[0][0].data;
expect(data).toMatchObject({ jti: 'jti-1', adminUserId: 'admin-1', iamSessionId: 'sid-1' });
expect(data.expiresAt.getTime()).toBeGreaterThan(Date.now() + 11 * 3600 * 1000);
});
it('never breaks login when the session row cannot be written', async () => {
const { service, prisma } = build({ iamEnabled: true });
prisma.adminSession.create.mockRejectedValue(new Error('unique violation'));
// Worst case is a session that cannot be revoked early — not a failed login
await expect(service.record({ jti: 'jti-1', adminUserId: 'a' })).resolves.toBeUndefined();
});
it('revokes by the identity service session id', async () => {
const { service, prisma } = build({ iamEnabled: true });
await expect(service.revokeByIamSession('sid-1')).resolves.toBe(2);
expect(prisma.adminSession.updateMany).toHaveBeenCalledWith({
where: { iamSessionId: 'sid-1', revokedAt: null },
data: expect.objectContaining({ revokedReason: 'iam_backchannel_logout' }),
});
});
it('revokes every session of an identity when the token names only a subject', async () => {
const { service, prisma } = build({ iamEnabled: true });
await service.revokeByIamSubject('sub-1');
expect(prisma.adminSession.updateMany).toHaveBeenCalledWith({
where: { iamSub: 'sub-1', revokedAt: null },
data: expect.objectContaining({ revokedReason: 'iam_backchannel_logout' }),
});
});
it('revokes every session of a local admin', async () => {
const { service, prisma } = build({ iamEnabled: true });
await service.revokeAllForAdmin('admin-1', 'account_disabled');
expect(prisma.adminSession.updateMany).toHaveBeenCalledWith({
where: { adminUserId: 'admin-1', revokedAt: null },
data: expect.objectContaining({ revokedReason: 'account_disabled' }),
});
});
it('does not touch anything while off', async () => {
const { service, prisma } = build();
await expect(service.revokeByIamSession('sid-1')).resolves.toBe(0);
expect(prisma.adminSession.updateMany).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,137 @@
import { Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { envOr } from '../../common/env';
import { IamService } from '../iam/iam.service';
/**
* Revocable admin sessions.
*
* An admin token is self-contained and lives for 12h, so until now nothing could
* end a session early: deactivating the account or signing the user out centrally
* left every already-issued token working until it expired. Each issued token now
* carries a `jti` recorded on AdminSession, and JwtStrategy refuses a token whose
* row is revoked or gone.
*
* The previous behaviour is preserved exactly when tracking is off: no row is
* written, no extra query runs on authentication, and tokens keep working as
* before. Tracking turns itself on when the IAM is enabled — without it a central
* revocation would silently do nothing here — and can be turned on independently
* with `AUTH_SESSION_TRACKING=true`.
*/
@Injectable()
export class AdminSessionService {
private readonly logger = new Logger(AdminSessionService.name);
constructor(
private readonly prisma: PrismaService,
private readonly iam: IamService,
) {}
get enabled(): boolean {
if (envOr('AUTH_SESSION_TRACKING', '').toLowerCase() === 'true') return true;
// Explicit opt-out for a deployment that uses the IAM but does not want the
// per-request lookup; central revocation then cannot reach this app.
if (envOr('AUTH_SESSION_TRACKING', '').toLowerCase() === 'false') return false;
return this.iam.enabled;
}
/** A fresh `jti` for a token about to be signed, or null while tracking is off. */
newTokenId(): string | null {
return this.enabled ? randomUUID() : null;
}
/**
* Record an issued token. A failure here must not break login, so it is logged
* and swallowed: the worst case is a session that cannot be revoked early,
* which is exactly the behaviour before this table existed.
*/
async record(input: {
jti: string;
adminUserId: string;
iamSessionId?: string | null;
iamSub?: string | null;
ipAddress?: string | null;
userAgent?: string | null;
}): Promise<void> {
if (!this.enabled) return;
try {
await this.prisma.adminSession.create({
data: {
jti: input.jti,
adminUserId: input.adminUserId,
iamSessionId: input.iamSessionId ?? null,
iamSub: input.iamSub ?? null,
ipAddress: input.ipAddress ?? null,
userAgent: input.userAgent ?? null,
expiresAt: this.tokenExpiry(),
},
});
} catch (error) {
this.logger.warn(`could not record the admin session: ${(error as Error).message}`);
}
}
/**
* Is this token still usable? Called on every authenticated request.
*
* A token signed before tracking was switched on carries no `jti`; it stays
* valid until it expires, so enabling the feature does not sign everyone out.
*/
async isActive(jti: string | undefined): Promise<boolean> {
if (!this.enabled || !jti) return true;
const session = await this.prisma.adminSession.findUnique({
where: { jti },
select: { revokedAt: true },
});
// A missing row means the session was deleted (or the token was signed
// elsewhere): refuse rather than assume.
return session !== null && session.revokedAt === null;
}
/** Revoke by the IAM's session id (`sid` in a back-channel logout token). */
async revokeByIamSession(iamSessionId: string, reason = 'iam_backchannel_logout'): Promise<number> {
return this.revokeWhere({ iamSessionId }, reason);
}
/** Revoke every session of an IAM identity (a logout token carrying only `sub`). */
async revokeByIamSubject(iamSub: string, reason = 'iam_backchannel_logout'): Promise<number> {
return this.revokeWhere({ iamSub }, reason);
}
/** Revoke every session of a local admin (account disabled, password changed). */
async revokeAllForAdmin(adminUserId: string, reason = 'revoked'): Promise<number> {
return this.revokeWhere({ adminUserId }, reason);
}
private async revokeWhere(
where: { iamSessionId?: string; iamSub?: string; adminUserId?: string },
reason: string,
): Promise<number> {
if (!this.enabled) return 0;
const result = await this.prisma.adminSession.updateMany({
where: { ...where, revokedAt: null },
data: { revokedAt: new Date(), revokedReason: reason },
});
return result.count;
}
/**
* When the recorded token expires, mirroring JWT_EXPIRES_IN so old rows can be
* pruned. Only `<n>h` / `<n>m` / `<n>d` / `<n>s` are understood — anything else
* leaves the column null rather than guessing.
*/
private tokenExpiry(): Date | null {
const raw = envOr('JWT_EXPIRES_IN', '12h');
const match = /^(\d+)\s*([smhd])$/.exec(raw);
if (!match) return null;
const seconds = Number(match[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[match[2] as 's' | 'm' | 'h' | 'd'];
return new Date(Date.now() + seconds * 1000);
}
}
@@ -0,0 +1,140 @@
import { Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { envOr } from '../../common/env';
import { IamService } from '../iam/iam.service';
/** What an app token's `sub` points at — a Customer, or an AdminUser (D21). */
export type AppSubjectType = 'customer' | 'sales-expert';
/**
* Revocable app sessions (customer app and sales-expert app).
*
* The counterpart of AdminSessionService for the other token family. It matters
* more here: the app's login is OTP-only, so there is no password an operator
* could change to cut off a stolen 30-day token — before this table, nothing
* could end an app session early.
*
* Exactly as with the admin side, everything is inert while tracking is off: no
* `jti`, no row, and no extra query on the authenticated path.
*/
@Injectable()
export class AppSessionService {
private readonly logger = new Logger(AppSessionService.name);
constructor(
private readonly prisma: PrismaService,
private readonly iam: IamService,
) {}
get enabled(): boolean {
const flag = envOr('AUTH_SESSION_TRACKING', '').toLowerCase();
if (flag === 'true') return true;
if (flag === 'false') return false;
return this.iam.enabled;
}
newTokenId(): string | null {
return this.enabled ? randomUUID() : null;
}
/**
* Record an issued token. A failure must not break the login: the worst case
* is a session that cannot be revoked early, which is the behaviour that
* existed before this table.
*/
async record(input: {
jti: string;
subjectId: string;
subjectType: AppSubjectType;
mobile?: string | null;
iamSessionId?: string | null;
iamSub?: string | null;
ipAddress?: string | null;
userAgent?: string | null;
}): Promise<void> {
if (!this.enabled) return;
try {
await this.prisma.appSession.create({
data: {
jti: input.jti,
subjectId: input.subjectId,
subjectType: input.subjectType,
mobile: input.mobile ?? null,
iamSessionId: input.iamSessionId ?? null,
iamSub: input.iamSub ?? null,
ipAddress: input.ipAddress ?? null,
userAgent: input.userAgent ?? null,
expiresAt: this.tokenExpiry(),
},
});
} catch (error) {
this.logger.warn(`could not record the app session: ${(error as Error).message}`);
}
}
/**
* Is this token still usable? A token signed before tracking was switched on
* carries no `jti` and stays valid until it expires, so enabling the feature
* does not sign every app user out at once.
*/
async isActive(jti: string | undefined): Promise<boolean> {
if (!this.enabled || !jti) return true;
const session = await this.prisma.appSession.findUnique({
where: { jti },
select: { revokedAt: true },
});
return session !== null && session.revokedAt === null;
}
/** IAM session ids still tied to this mobile, so logout can end the other app too. */
async activeIamSessionIds(mobile: string): Promise<string[]> {
if (!this.enabled || !mobile) return [];
const rows = await this.prisma.appSession.findMany({
where: { mobile, revokedAt: null, iamSessionId: { not: null } },
select: { iamSessionId: true },
});
return [...new Set(rows.map((row) => row.iamSessionId).filter((id): id is string => Boolean(id)))];
}
/** Revoke by the IAM's session id (`sid` in a back-channel logout token). */
revokeByIamSession(iamSessionId: string, reason = 'iam_backchannel_logout'): Promise<number> {
return this.revokeWhere({ iamSessionId }, reason);
}
/** Revoke every session of an IAM identity (a logout token carrying only `sub`). */
revokeByIamSubject(iamSub: string, reason = 'iam_backchannel_logout'): Promise<number> {
return this.revokeWhere({ iamSub }, reason);
}
/** Revoke every app session of one subject (customer archived/deactivated). */
revokeAllForSubject(subjectId: string, reason = 'revoked'): Promise<number> {
return this.revokeWhere({ subjectId }, reason);
}
private async revokeWhere(
where: { iamSessionId?: string; iamSub?: string; subjectId?: string },
reason: string,
): Promise<number> {
if (!this.enabled) return 0;
const result = await this.prisma.appSession.updateMany({
where: { ...where, revokedAt: null },
data: { revokedAt: new Date(), revokedReason: reason },
});
return result.count;
}
/** Mirrors JWT_CUSTOMER_EXPIRES_IN so old rows can be pruned. */
private tokenExpiry(): Date | null {
const match = /^(\d+)\s*([smhd])$/.exec(envOr('JWT_CUSTOMER_EXPIRES_IN', '30d'));
if (!match) return null;
const seconds = Number(match[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[match[2] as 's' | 'm' | 'h' | 'd'];
return new Date(Date.now() + seconds * 1000);
}
}