good forst commit

This commit is contained in:
2026-06-09 02:02:40 +05:30
parent 801c1d7121
commit 249d759e6a
215 changed files with 15425 additions and 1240 deletions
@@ -0,0 +1,230 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { createHash, randomBytes } from 'crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types';
import { ConsentScope, OnboardingTokenPayload, PublicOnboardInfo, RequestOtpResponse, VerifyOtpResponse } from '@tower/types';
import { ConsentStatus } from '@prisma/client';
const POLICY_VERSION = 'v1';
const OTP_TTL_MIN = 5;
const DEFAULT_SCOPES: ConsentScope[] = ['INGEST', 'DISPLAY'];
const DEFAULT_RETENTION_DAYS = 90;
function hashPhone(phone: string, pepper: string): string {
const normalized = phone.replace(/[^\d+]/g, '');
return createHash('sha256').update(`${pepper}:${normalized}`).digest('hex');
}
@Injectable()
export class OnboardingService {
private readonly logger = new Logger(OnboardingService.name);
private readonly policyVersion = POLICY_VERSION;
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly audit: AuditService,
) {}
decodeOnboardingToken(token: string): OnboardingTokenPayload {
// Phase 2B: token is base64url({groupId, jid, tenantId}) — no signature.
// The OTP step (sent via DM to the jid) is the real authentication.
try {
const json = Buffer.from(token, 'base64url').toString('utf8');
const parsed = JSON.parse(json) as OnboardingTokenPayload;
if (!parsed.groupId || !parsed.jid || !parsed.tenantId) {
throw new Error('Missing required fields');
}
return parsed;
} catch {
throw new UnauthorizedException('Invalid onboarding link');
}
}
async getOnboardInfo(token: string): Promise<PublicOnboardInfo> {
const payload = this.decodeOnboardingToken(token);
const group = await this.prisma.group.findUnique({ where: { id: payload.groupId } });
if (!group) throw new NotFoundException('Group not found');
if (!group.tenantId) {
throw new ConflictException('Group is not yet claimed by a tenant');
}
const tenant = await this.prisma.tenant.findUnique({ where: { id: payload.tenantId } });
if (!tenant) throw new NotFoundException('Tenant not found');
return {
groupName: group.name,
tenantName: tenant.name,
policyVersion: this.policyVersion,
defaultScopes: DEFAULT_SCOPES,
defaultRetentionDays: DEFAULT_RETENTION_DAYS,
};
}
async requestOtp(token: string, phone: string): Promise<RequestOtpResponse> {
const payload = this.decodeOnboardingToken(token);
if (payload.jid !== this.normalizeJid(payload.jid)) {
// sanity
}
if (!phone || phone.length < 6) {
throw new BadRequestException('Invalid phone number');
}
const group = await this.prisma.group.findUnique({ where: { id: payload.groupId } });
if (!group || !group.tenantId) {
throw new ConflictException('Group is not claimable');
}
const pepper = this.config.get<string>('JWT_SECRET') ?? '';
const phoneHash = hashPhone(phone, pepper);
const code = String(Math.floor(100000 + Math.random() * 900000));
const expiresAt = new Date(Date.now() + OTP_TTL_MIN * 60 * 1000);
const challengeId = randomBytes(16).toString('hex');
const challenge = await this.prisma.otpChallenge.create({
data: {
id: challengeId,
tenantId: payload.tenantId,
jid: payload.jid,
phoneHash,
code,
scopes: DEFAULT_SCOPES,
retentionDays: DEFAULT_RETENTION_DAYS,
policyVersion: this.policyVersion,
groupId: payload.groupId,
expiresAt,
},
});
await this.audit.log({
tenantId: payload.tenantId,
action: AuditAction.OTP_REQUESTED,
resourceType: 'OtpChallenge',
resourceId: challenge.id,
payload: { jid: payload.jid },
});
return {
ok: true,
challengeId,
expiresInSeconds: OTP_TTL_MIN * 60,
};
}
async verifyOtp(
token: string,
challengeId: string,
phone: string,
code: string,
scopes: ConsentScope[],
retentionDays?: number,
): Promise<VerifyOtpResponse> {
const payload = this.decodeOnboardingToken(token);
const pepper = this.config.get<string>('JWT_SECRET') ?? '';
const phoneHash = hashPhone(phone, pepper);
const challenge = await this.prisma.otpChallenge.findUnique({ where: { id: challengeId } });
if (!challenge) throw new NotFoundException('Challenge not found');
if (challenge.consumedAt) throw new UnauthorizedException('Challenge already used');
if (challenge.expiresAt < new Date()) throw new UnauthorizedException('Challenge expired');
if (challenge.code !== code) throw new UnauthorizedException('Invalid code');
if (challenge.phoneHash !== phoneHash) throw new UnauthorizedException('Phone mismatch');
const effectiveScopes = scopes.length > 0 ? scopes : DEFAULT_SCOPES;
const effectiveRetention = retentionDays ?? DEFAULT_RETENTION_DAYS;
const user = await this.prisma.towerUser.upsert({
where: { tenantId_phoneHash: { tenantId: payload.tenantId, phoneHash } },
update: { jid: payload.jid, displayName: payload.jid },
create: {
tenantId: payload.tenantId,
phoneHash,
jid: payload.jid,
displayName: payload.jid,
},
});
const existing = await this.prisma.consentRecord.findFirst({
where: { tenantId: payload.tenantId, groupId: payload.groupId, userId: user.id },
});
let consent;
if (existing) {
consent = await this.prisma.consentRecord.update({
where: { id: existing.id },
data: {
scopes: effectiveScopes,
retentionDays: effectiveRetention,
policyVersion: this.policyVersion,
status: ConsentStatus.GRANTED,
revokedAt: null,
effectiveAt: new Date(),
},
});
} else {
consent = await this.prisma.consentRecord.create({
data: {
tenantId: payload.tenantId,
groupId: payload.groupId,
userId: user.id,
scopes: effectiveScopes,
retentionDays: effectiveRetention,
policyVersion: this.policyVersion,
status: ConsentStatus.GRANTED,
proofEventId: 'pending',
},
});
}
await this.prisma.consentRecord.update({
where: { id: consent.id },
data: { proofEventId: consent.id },
});
await this.prisma.otpChallenge.update({
where: { id: challengeId },
data: { consumedAt: new Date() },
});
await this.audit.log({
tenantId: payload.tenantId,
action: AuditAction.MEMBER_ONBOARDED,
resourceType: 'TowerUser',
resourceId: user.id,
payload: {
jid: payload.jid,
groupId: payload.groupId,
consentId: consent.id,
scopes: effectiveScopes,
},
});
const memberToken = await this.jwt.signAsync({
kind: 'member',
sub: user.id,
tenantId: user.tenantId,
jid: user.jid,
phoneHash: user.phoneHash,
} as const);
return {
memberToken,
user: {
id: user.id,
tenantId: user.tenantId,
jid: user.jid,
displayName: user.displayName,
},
consent: {
scopes: consent.scopes as ConsentScope[],
retentionDays: consent.retentionDays,
policyVersion: consent.policyVersion,
},
};
}
private normalizeJid(jid: string): string {
return jid.trim();
}
}