import { createHash, randomBytes } from 'node:crypto'; import { Injectable, NotFoundException } from '@nestjs/common'; import { FEED_TOKEN_PREFIX, type CreateFeedTokenInput, type FeedTokenCreatedView, type FeedTokenView, } from '@dorfteich/shared'; import { FeedToken, User } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; /** * Feed-token lifecycle (issue #149). Mirrors the API-token mechanics — the * secret (`dt_feed_`) is shown once and only its SHA-256 lands in the * database — but the token is read-only by construction: the sole consumer is * {@link FeedService}, which resolves it to a user for the two feed endpoints. */ @Injectable() export class FeedTokensService { constructor(private readonly prisma: PrismaService) {} async list(user: User): Promise { const rows = await this.prisma.feedToken.findMany({ where: { userId: user.id }, orderBy: { createdAt: 'desc' }, }); return rows.map((row) => this.view(row)); } async create(user: User, input: CreateFeedTokenInput): Promise { const secret = `${FEED_TOKEN_PREFIX}${randomBytes(24).toString('hex')}`; const row = await this.prisma.feedToken.create({ data: { userId: user.id, name: input.name, tokenHash: hashFeedToken(secret) }, }); return { ...this.view(row), token: secret }; } async remove(user: User, id: string): Promise { const { count } = await this.prisma.feedToken.deleteMany({ where: { id, userId: user.id }, }); if (count === 0) throw new NotFoundException(); } /** The token's user, or null for a missing/invalid secret. */ async resolve(secret: string): Promise { if (!secret.startsWith(FEED_TOKEN_PREFIX)) return null; const row = await this.prisma.feedToken.findUnique({ where: { tokenHash: hashFeedToken(secret) }, include: { user: true }, }); if (!row) return null; // Best-effort usage stamp; a lost update here is harmless. await this.prisma.feedToken .update({ where: { id: row.id }, data: { lastUsedAt: new Date() } }) .catch(() => undefined); return row.user; } private view(row: FeedToken): FeedTokenView { return { id: row.id, name: row.name, lastUsedAt: row.lastUsedAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), }; } } function hashFeedToken(raw: string): string { return createHash('sha256').update(raw).digest('hex'); }