Files
web-app-launcher/src/lib/server/services/apiTokenService.ts
T
alexei.dolgolyov 1c0a7cb850 feat: Phases 4-7 — Full Feature Expansion (26 features)
Phase 4 — New Widget Types:
- Clock/Weather, System Stats, RSS/Feed, Calendar, Markdown,
  Metric/Counter, Link Group, Camera/Stream widgets
- Backend services with caching for each data source
- Full creation form with dynamic config fields per type

Phase 5 — Visual & Styling Enhancements:
- Glassmorphism card style (solid/glass/outline)
- Board-level themes with per-board hue/saturation
- Animated SVG status rings replacing static dots
- Card size options (compact/medium/large)
- Custom CSS injection (admin + per-board, sanitized)
- Wallpaper backgrounds with blur/overlay/parallax

Phase 6 — Functional Features:
- Favorites bar with drag-and-drop reordering
- Recent apps tracking with privacy toggle
- Uptime dashboard page (/status, guest-accessible)
- Notifications system (Discord/Slack/Telegram/HTTP webhooks)
- App tags with filtering in board view
- Multi-URL app cards with expandable sub-links
- Personal API tokens with scoped permissions
- Audit log with retention and admin viewer

Phase 7 — Quality of Life:
- Onboarding wizard (5-step first-launch setup)
- App URL health preview with favicon/title detection
- Board templates (4 built-in + custom import/export)
- Keyboard shortcut overlay (j/k nav, 1-9 boards, ? help)

212 files changed, 15641 insertions, 980 deletions.
Build, lint, type check, and 222 tests all pass.
2026-03-25 14:18:10 +03:00

128 lines
3.0 KiB
TypeScript

import { randomBytes, createHash } from 'crypto';
import bcrypt from 'bcryptjs';
import { prisma } from '../prisma.js';
const BCRYPT_ROUNDS = 10;
/**
* Hash a token string using SHA-256 for fast lookup, then bcrypt for storage.
* We use SHA-256 as an intermediate to create a fixed-length input for bcrypt
* (bcrypt has a 72-byte limit).
*/
function sha256(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
/**
* Generate a new API token. Returns the plaintext token (shown once) and the DB record.
*/
export async function generateToken(
userId: string,
name: string,
scope: string,
expiresAt?: string
) {
const plainToken = randomBytes(32).toString('hex');
const tokenHash = await bcrypt.hash(sha256(plainToken), BCRYPT_ROUNDS);
const token = await prisma.apiToken.create({
data: {
userId,
name,
tokenHash,
scope,
expiresAt: expiresAt ? new Date(expiresAt) : null
}
});
return {
id: token.id,
name: token.name,
scope: token.scope,
expiresAt: token.expiresAt,
createdAt: token.createdAt,
token: plainToken // Only returned once at creation time
};
}
/**
* Revoke (delete) an API token.
*/
export async function revokeToken(tokenId: string, userId: string) {
const token = await prisma.apiToken.findUnique({ where: { id: tokenId } });
if (!token || token.userId !== userId) {
throw new Error('API token not found');
}
await prisma.apiToken.delete({ where: { id: tokenId } });
}
/**
* List all tokens for a user (without the hash).
*/
export async function listTokens(userId: string) {
const tokens = await prisma.apiToken.findMany({
where: { userId },
select: {
id: true,
name: true,
scope: true,
lastUsedAt: true,
expiresAt: true,
createdAt: true
},
orderBy: { createdAt: 'desc' }
});
return tokens;
}
/**
* Validate a plaintext token string. Returns the user info if valid, null otherwise.
* Updates lastUsedAt on successful validation.
*/
export async function validateToken(tokenString: string): Promise<{
readonly userId: string;
readonly scope: string;
} | null> {
const tokenSha = sha256(tokenString);
// We need to check against all tokens since bcrypt hashes are unique per-hash.
// For better performance at scale, consider indexing on a prefix or using a different scheme.
const allTokens = await prisma.apiToken.findMany({
select: {
id: true,
userId: true,
tokenHash: true,
scope: true,
expiresAt: true
}
});
for (const token of allTokens) {
const isMatch = await bcrypt.compare(tokenSha, token.tokenHash);
if (isMatch) {
// Check expiry
if (token.expiresAt && token.expiresAt < new Date()) {
return null; // Token expired
}
// Update lastUsedAt (fire-and-forget)
prisma.apiToken
.update({
where: { id: token.id },
data: { lastUsedAt: new Date() }
})
.catch(() => {
// Swallow errors from lastUsedAt update
});
return {
userId: token.userId,
scope: token.scope
};
}
}
return null;
}