Add 4 files, update 14 files

This commit is contained in:
[dyad] committed 2026-08-02 17:58:35 +02:00
1 parent 278293b397
commit 2913b0332f
18 files changed
+227 -22

No files matched your search

+3
View File
@@ -17,6 +17,9 @@ PASSWORD_RESET_TTL_SECONDS=3600
AUTH_REQUIRE_EMAIL_VERIFICATION=false
# Development-only: return reset tokens in API responses instead of email.
AUTH_SHOW_RESET_TOKEN=false
# Enables optional TOTP enrollment for accounts. Users without enrollment can
# continue to sign in normally.
AUTH_TOTP_ENABLED=true
SCHULOS_PORT=3000
# Local filesystem storage. Docker Compose maps these paths to a persistent volume.
+4
View File
@@ -38,6 +38,10 @@ jobs:
run: pnpm lint
- name: Unit tests
run: pnpm test
- name: Install Playwright Chromium
run: pnpm exec playwright install --with-deps chromium
- name: Browser smoke tests
run: pnpm test:e2e
- name: Typecheck and production build
run: pnpm typecheck
- name: Audit production dependencies
+2 -2
View File
@@ -73,10 +73,10 @@ Use these sections only. Do not add a new sidebar entry if a workflow belongs in
- [x] Add `docs/DATA_MODEL.md`, `docs/CONTRIBUTING.md`, and `docs/ROADMAP.md`.
- [x] Add `docs/ARCHITECTURE.md` and `docs/SELF_HOSTING.md`.
- [x] Move the root PRD to `docs/PRD.md` and keep one canonical index in `docs/README.md`.
- [ ] Add GitHub Actions for lint, typecheck, unit tests, Prisma validation, migration drift checks, and Playwright smoke tests. Prisma migration deploy/drift, lint, typecheck, unit, and dependency audit checks are implemented; Playwright smoke tests wait on the test environment.
- [x] Add GitHub Actions for lint, typecheck, unit tests, Prisma validation, migration drift checks, and Playwright smoke tests.
- [ ] Add dependency audit and secret scanning in CI.
- [x] Replace `bcryptjs` password hashing with Argon2id and document a safe rehash-on-login migration path. Legacy bcrypt hashes verify once and rehash on successful login.
- [ ] Implement password reset tokens, email verification/self-hosted bypass flag, configurable session expiry, optional TOTP setup/recovery codes, and rate-limited auth routes. Reset tokens, self-hosted verification bypass, and session configuration are implemented; TOTP/recovery codes remain.
- [x] Implement password reset tokens, email verification/self-hosted bypass flag, configurable session expiry, optional TOTP setup/recovery codes, and rate-limited auth routes.
- [x] Implement central origin-based CSRF protection for cookie-authenticated mutation requests below `/api/`.
- [x] Add real local file storage abstraction for uploads, exports, and backups with path traversal protection. S3-compatible adapter remains a future implementation behind the same interface.
- [x] Add SQLite WAL configuration and verified online backup scripts. Restore remains a deliberate maintenance-environment workflow and requires a smoke test before production cutover.
+10 -2
View File
@@ -85,8 +85,16 @@ success message whether or not an account exists. In development only, or when
By default, self-hosted registrations are marked verified immediately. Set
`AUTH_REQUIRE_EMAIL_VERIFICATION=true` only after integrating a reviewed email
verification delivery flow. TOTP and recovery-code enrollment remain a planned
security enhancement.
verification delivery flow.
## Two-Factor Authentication
Set `AUTH_TOTP_ENABLED=true` to allow authenticated users to enroll an RFC
compatible authenticator. Enrollment verifies one current six-digit code before
the secret is saved and returns ten single-use recovery codes once. Store these
codes offline; raw recovery codes are never stored. During login, an enrolled
user must provide either a current authenticator code or one unused recovery
code.
## Compliance Notice
+20
View File
@@ -0,0 +1,20 @@
import { expect, test } from '@playwright/test';
test('loads the signed-out application shell', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/SchulOS/i);
await expect(page.getByText('SchulOS', { exact: true }).first()).toBeVisible();
await expect(page.getByLabel(/email/i).first()).toBeVisible();
await expect(page.getByLabel(/password/i).first()).toBeVisible();
});
test('rejects cross-origin API mutations', async ({ request }) => {
const response = await request.post('/api/auth', {
headers: { origin: 'https://attacker.example' },
data: { action: 'logout' },
});
expect(response.status()).toBe(403);
await expect(response.json()).resolves.toMatchObject({ error: 'Invalid request origin' });
});
+2
View File
@@ -34,6 +34,7 @@
"db:wal": "node scripts/sqlite-wal.mjs",
"db:backup": "node scripts/sqlite-backup.mjs",
"test": "bun test",
"test:e2e": "playwright test --config playwright-dyad.config.ts",
"typecheck": "next build --webpack && tsc --noEmit --pretty false"
},
"dependencies": {
@@ -88,6 +89,7 @@
"next-auth": "^4.24.11",
"next-intl": "^4.3.4",
"next-themes": "^0.4.6",
"otpauth": "^9.5.1",
"prisma": "^6.11.1",
"react": "^19.0.0",
"react-day-picker": "^9.8.0",
+14 -10
View File
@@ -1,20 +1,24 @@
import { defineConfig } from "@playwright/test";
import { defineConfig, devices } from "@playwright/test";
// Generated by Dyad. The dev server is started separately by Dyad's
// preview, so we point baseURL at the already-running proxy URL (passed via
// env) rather than using Playwright's `webServer` (which would double-start
// the app).
// // Uses your installed Google Chrome (no extra browser download).
export default defineConfig({
testDir: "./e2e-tests",
// Run serially against the single dev server.
workers: 1,
fullyParallel: false,
reporter: [["json", { outputFile: "test-results/results.json" }]],
reporter: [["list"], ["json", { outputFile: "test-results/results.json" }]],
use: {
baseURL: process.env.DYAD_TEST_BASE_URL || "http://localhost:32100",
channel: "chrome",
baseURL: process.env.DYAD_TEST_BASE_URL || process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:32104",
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
webServer: process.env.DYAD_TEST_BASE_URL
? undefined
: {
command: "pnpm dev --hostname 127.0.0.1 --port 32104",
url: "http://127.0.0.1:32104",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
],
});
+16
View File
@@ -161,6 +161,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
otpauth:
specifier: ^9.5.1
version: 9.5.1
prisma:
specifier: ^6.11.1
version: 6.19.3(typescript@5.9.3)
@@ -1023,6 +1026,10 @@ packages:
cpu: [x64]
os: [win32]
'@noble/hashes@2.2.0':
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
engines: {node: '>= 20.19.0'}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -3916,6 +3923,9 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
otpauth@9.5.1:
resolution: {integrity: sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==}
outvariant@1.4.0:
resolution: {integrity: sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==}
@@ -5696,6 +5706,8 @@ snapshots:
'@next/swc-win32-x64-msvc@16.2.12':
optional: true
'@noble/hashes@2.2.0': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -8943,6 +8955,10 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
otpauth@9.5.1:
dependencies:
'@noble/hashes': 2.2.0
outvariant@1.4.0: {}
own-keys@1.0.2:
@@ -25,9 +25,9 @@ CREATE TABLE "new_communication_rooms" (
"closeReason" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "communication_rooms_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "communication_rooms_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "communication_rooms_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "communication_rooms_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "communication_rooms_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "communication_rooms_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "communication_rooms_classGroupId_fkey" FOREIGN KEY ("classGroupId") REFERENCES "ClassGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_communication_rooms" ("id", "schoolId", "studentId", "teacherId", "roomType", "audienceType", "classGroupId", "status", "requestedBy", "escalationEligibleAt", "escalatedAt", "resolvedAt", "resolutionStatus", "acceptedAt", "closedAt", "closeReason", "createdAt", "updatedAt") SELECT "id", "schoolId", "studentId", "teacherId", "roomType", "audienceType", "classGroupId", "status", "requestedBy", "escalationEligibleAt", "escalatedAt", "resolvedAt", "resolutionStatus", "acceptedAt", "closedAt", "closeReason", "createdAt", "updatedAt" FROM "communication_rooms";
@@ -37,3 +37,7 @@ PRAGMA foreign_keys=ON;
CREATE INDEX "communication_rooms_classGroupId_idx" ON "communication_rooms"("classGroupId");
CREATE INDEX "communication_rooms_escalationEligibleAt_idx" ON "communication_rooms"("escalationEligibleAt");
CREATE INDEX "communication_rooms_schoolId_idx" ON "communication_rooms"("schoolId");
CREATE INDEX "communication_rooms_studentId_idx" ON "communication_rooms"("studentId");
CREATE INDEX "communication_rooms_teacherId_idx" ON "communication_rooms"("teacherId");
CREATE INDEX "communication_rooms_status_idx" ON "communication_rooms"("status");
@@ -33,9 +33,9 @@ CREATE TABLE "new_illness_reports" (
"calendarEventId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "illness_reports_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "illness_reports_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "illness_reports_reportedBy_fkey" FOREIGN KEY ("reportedBy") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "illness_reports_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "illness_reports_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "illness_reports_reportedBy_fkey" FOREIGN KEY ("reportedBy") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "illness_reports_parentApprovedBy_fkey" FOREIGN KEY ("parentApprovedBy") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "illness_reports_adminApprovedBy_fkey" FOREIGN KEY ("adminApprovedBy") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "illness_reports_calendarEventId_fkey" FOREIGN KEY ("calendarEventId") REFERENCES "CalendarEvent" ("id") ON DELETE SET NULL ON UPDATE CASCADE
@@ -47,3 +47,8 @@ PRAGMA foreign_keys=ON;
CREATE UNIQUE INDEX "illness_reports_calendarEventId_key" ON "illness_reports"("calendarEventId");
CREATE INDEX "illness_reports_adminApprovalStatus_idx" ON "illness_reports"("adminApprovalStatus");
CREATE INDEX "illness_reports_schoolId_idx" ON "illness_reports"("schoolId");
CREATE INDEX "illness_reports_studentId_idx" ON "illness_reports"("studentId");
CREATE INDEX "illness_reports_reportedBy_idx" ON "illness_reports"("reportedBy");
CREATE INDEX "illness_reports_parentApprovalStatus_idx" ON "illness_reports"("parentApprovalStatus");
CREATE INDEX "illness_reports_status_idx" ON "illness_reports"("status");
@@ -24,8 +24,8 @@ CREATE TABLE "new_teacher_absences" (
"calendarEventId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "teacher_absences_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "teacher_absences_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "teacher_absences_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "teacher_absences_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "teacher_absences_approvedBy_fkey" FOREIGN KEY ("approvedBy") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "teacher_absences_calendarEventId_fkey" FOREIGN KEY ("calendarEventId") REFERENCES "CalendarEvent" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
@@ -36,3 +36,6 @@ PRAGMA foreign_keys=ON;
CREATE UNIQUE INDEX "teacher_absences_calendarEventId_key" ON "teacher_absences"("calendarEventId");
CREATE INDEX "teacher_absences_approvalStatus_idx" ON "teacher_absences"("approvalStatus");
CREATE INDEX "teacher_absences_schoolId_idx" ON "teacher_absences"("schoolId");
CREATE INDEX "teacher_absences_teacherId_idx" ON "teacher_absences"("teacherId");
CREATE INDEX "teacher_absences_startDate_idx" ON "teacher_absences"("startDate");
@@ -0,0 +1,11 @@
CREATE TABLE "TwoFactorRecoveryCode" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"codeHash" TEXT NOT NULL,
"usedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TwoFactorRecoveryCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "TwoFactorRecoveryCode_codeHash_key" ON "TwoFactorRecoveryCode"("codeHash");
CREATE INDEX "TwoFactorRecoveryCode_userId_idx" ON "TwoFactorRecoveryCode"("userId");
Binary file not shown.
+13
View File
@@ -245,6 +245,7 @@ model User {
dashboardWidgets DashboardWidget[]
dashboardLayouts DashboardLayout[]
passwordResetTokens PasswordResetToken[]
twoFactorRecoveryCodes TwoFactorRecoveryCode[]
}
model PasswordResetToken {
@@ -261,6 +262,18 @@ model PasswordResetToken {
@@index([expiresAt])
}
model TwoFactorRecoveryCode {
id String @id @default(cuid())
userId String
codeHash String @unique
usedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model SchoolYear {
id String @id @default(cuid())
schoolId String
+64
View File
@@ -14,6 +14,7 @@ import {
} from '@/lib/auth';
import { withRateLimit, checkRateLimit, getRateLimitKey, getRateLimitHeaders, createRateLimitResponse } from '@/lib/rate-limit';
import { createPasswordResetToken, hashResetToken, passwordResetExpiry } from '@/lib/password-reset';
import { createRecoveryCodes, createTotpSecret, hashRecoveryCode, totpUri, verifyTotp } from '@/lib/two-factor';
const loginSchema = z.object({
email: z.string().email(),
@@ -37,11 +38,56 @@ const resetPasswordSchema = z.object({
password: z.string().min(12),
});
const twoFactorTokenSchema = z.object({
token: z.string().min(6).max(20),
});
export const POST = withRateLimit(async function POST(request: Request) {
try {
const body = await request.json();
const { action } = body;
if (action === 'setup-two-factor') {
const session = await getSession();
if (!session?.user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
if (process.env.AUTH_TOTP_ENABLED !== 'true') return NextResponse.json({ error: 'Two-factor authentication is disabled' }, { status: 403 });
const secret = createTotpSecret();
return NextResponse.json({ secret, otpauthUrl: totpUri(session.user.email, secret) });
}
if (action === 'enable-two-factor') {
const session = await getSession();
const parsed = twoFactorTokenSchema.extend({ secret: z.string().min(10) }).safeParse(body);
if (!session?.user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
if (process.env.AUTH_TOTP_ENABLED !== 'true') return NextResponse.json({ error: 'Two-factor authentication is disabled' }, { status: 403 });
if (!parsed.success || !verifyTotp(parsed.data.secret, parsed.data.token)) {
return NextResponse.json({ error: 'Invalid authenticator code' }, { status: 400 });
}
const recoveryCodes = createRecoveryCodes();
await db.$transaction([
db.user.update({ where: { id: session.userId }, data: { twoFactorSecret: parsed.data.secret } }),
db.twoFactorRecoveryCode.deleteMany({ where: { userId: session.userId } }),
db.twoFactorRecoveryCode.createMany({
data: recoveryCodes.map((code) => ({ userId: session.userId, codeHash: hashRecoveryCode(code) })),
}),
]);
return NextResponse.json({ recoveryCodes });
}
if (action === 'disable-two-factor') {
const session = await getSession();
const parsed = twoFactorTokenSchema.safeParse(body);
if (!session?.user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
if (!parsed.success || !session.user.twoFactorSecret || !verifyTotp(session.user.twoFactorSecret, parsed.data.token)) {
return NextResponse.json({ error: 'Invalid authenticator code' }, { status: 400 });
}
await db.$transaction([
db.user.update({ where: { id: session.userId }, data: { twoFactorSecret: null } }),
db.twoFactorRecoveryCode.deleteMany({ where: { userId: session.userId } }),
]);
return NextResponse.json({ message: 'Two-factor authentication disabled' });
}
if (action === 'request-password-reset') {
const parsed = requestPasswordResetSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: 'Validation failed' }, { status: 400 });
@@ -129,6 +175,24 @@ export const POST = withRateLimit(async function POST(request: Request) {
});
}
if (user.twoFactorSecret) {
const parsedTwoFactor = twoFactorTokenSchema.safeParse(body);
if (!parsedTwoFactor.success) {
return NextResponse.json({ error: 'Two-factor code required', twoFactorRequired: true }, { status: 401 });
}
const providedCode = parsedTwoFactor.data.token;
const validTotp = verifyTotp(user.twoFactorSecret, providedCode);
const recoveryCode = validTotp ? null : await db.twoFactorRecoveryCode.findFirst({
where: { userId: user.id, codeHash: hashRecoveryCode(providedCode), usedAt: null },
});
if (!validTotp && !recoveryCode) {
return NextResponse.json({ error: 'Invalid two-factor code', twoFactorRequired: true }, { status: 401 });
}
if (recoveryCode) {
await db.twoFactorRecoveryCode.update({ where: { id: recoveryCode.id }, data: { usedAt: new Date() } });
}
}
const secure = isSecureRequest(request);
await createSession(user.id, secure);
+1
View File
@@ -76,6 +76,7 @@ const sessionUserSelect = Prisma.validator<Prisma.UserSelect>()({
schoolId: true,
locale: true,
isDemo: true,
twoFactorSecret: true,
createdAt: true,
updatedAt: true,
});
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, test } from 'bun:test';
import { createRecoveryCodes, createTotpSecret, hashRecoveryCode, totpUri, verifyTotp } from './two-factor';
describe('two factor authentication', () => {
test('creates TOTP enrollment data', () => {
const secret = createTotpSecret();
expect(secret.length).toBeGreaterThan(10);
expect(totpUri('teacher@school.example', secret)).toStartWith('otpauth://totp/');
});
test('creates unique hashed recovery codes', () => {
const codes = createRecoveryCodes();
expect(codes).toHaveLength(10);
expect(new Set(codes).size).toBe(10);
expect(hashRecoveryCode(codes[0])).not.toBe(codes[0]);
});
test('rejects malformed TOTP values', () => {
expect(verifyTotp(createTotpSecret(), 'invalid')).toBeFalse();
});
});
+26
View File
@@ -0,0 +1,26 @@
import { createHash, randomBytes } from 'node:crypto';
import { TOTP } from 'otpauth';
const RECOVERY_CODE_COUNT = 10;
export function createTotpSecret(): string {
return new TOTP({ issuer: 'SchoolOS', label: 'SchoolOS account' }).secret.base32;
}
export function totpUri(email: string, secret: string): string {
return new TOTP({ issuer: 'SchoolOS', label: email, secret }).toString();
}
export function verifyTotp(secret: string, token: string): boolean {
const normalizedToken = token.replace(/\s/g, '');
if (!/^\d{6}$/.test(normalizedToken)) return false;
return new TOTP({ issuer: 'SchoolOS', label: 'SchoolOS account', secret }).validate({ token: normalizedToken, window: 1 }) !== null;
}
export function hashRecoveryCode(code: string): string {
return createHash('sha256').update(code).digest('hex');
}
export function createRecoveryCodes(): string[] {
return Array.from({ length: RECOVERY_CODE_COUNT }, () => randomBytes(5).toString('hex').toUpperCase());
}