Add user, identity, session, and auth-support data model

Prisma models per data-model.md: users (status enum, site-admin flag),
user_identities (password provider now, OIDC later — subject is the
stable user id), sessions (hashed ids), auth_tokens (hashed, single-
use), plus rate_limits and mail_outbox for the upcoming M1 stories.
UsersService creates accounts transactionally with Argon2id-hashed
password identities (OWASP parameters, rehash detection) and maps
uniqueness violations to field-level conflicts. Database-backed suites
run when TEST_DATABASE_URL is set — locally against the dev db, in CI
via a new postgres service container; shared auth schemas (username,
password policy incl. common-password blocklist) ship with tests.

Closes #10

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-05 00:42:22 +02:00
parent 61da1cc784
commit 36608177f6
18 changed files with 663 additions and 0 deletions

View File

@ -16,6 +16,16 @@ jobs:
checks: checks:
name: Lint, typecheck, test name: Lint, typecheck, test
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
postgres:
image: postgres:17.5-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
env:
# Enables the database-backed test suites (vitest.global-setup.ts).
TEST_DATABASE_URL: postgresql://test:test@postgres:5432/test
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@ -19,6 +19,7 @@
"@nestjs/core": "^11.0.0", "@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.0.0",
"@prisma/client": "^6.3.0", "@prisma/client": "^6.3.0",
"argon2": "^0.44.0",
"i18next": "^26.3.4", "i18next": "^26.3.4",
"nestjs-pino": "^4.3.0", "nestjs-pino": "^4.3.0",
"pino": "^9.6.0", "pino": "^9.6.0",

View File

@ -0,0 +1,123 @@
-- CreateEnum
CREATE TYPE "UserStatus" AS ENUM ('PENDING_VERIFICATION', 'ACTIVE', 'DISABLED');
-- CreateEnum
CREATE TYPE "AuthTokenPurpose" AS ENUM ('EMAIL_VERIFICATION', 'PASSWORD_RESET');
-- CreateEnum
CREATE TYPE "MailStatus" AS ENUM ('PENDING', 'SENT', 'FAILED');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"display_name" TEXT NOT NULL,
"locale" TEXT NOT NULL DEFAULT 'en',
"is_site_admin" BOOLEAN NOT NULL DEFAULT false,
"status" "UserStatus" NOT NULL DEFAULT 'PENDING_VERIFICATION',
"email_verified_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_login_at" TIMESTAMP(3),
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_identities" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"credential" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_identities_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sessions" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
"last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"user_agent" TEXT,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "auth_tokens" (
"id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"purpose" "AuthTokenPurpose" NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"consumed_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "auth_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rate_limits" (
"key" TEXT NOT NULL,
"window_start" TIMESTAMP(3) NOT NULL,
"count" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "rate_limits_pkey" PRIMARY KEY ("key")
);
-- CreateTable
CREATE TABLE "mail_outbox" (
"id" TEXT NOT NULL,
"to_address" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"text_body" TEXT NOT NULL,
"html_body" TEXT NOT NULL,
"status" "MailStatus" NOT NULL DEFAULT 'PENDING',
"attempts" INTEGER NOT NULL DEFAULT 0,
"next_attempt_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_error" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"sent_at" TIMESTAMP(3),
CONSTRAINT "mail_outbox_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE INDEX "user_identities_user_id_idx" ON "user_identities"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "user_identities_provider_subject_key" ON "user_identities"("provider", "subject");
-- CreateIndex
CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id");
-- CreateIndex
CREATE INDEX "sessions_expires_at_idx" ON "sessions"("expires_at");
-- CreateIndex
CREATE UNIQUE INDEX "auth_tokens_token_hash_key" ON "auth_tokens"("token_hash");
-- CreateIndex
CREATE INDEX "auth_tokens_user_id_purpose_idx" ON "auth_tokens"("user_id", "purpose");
-- CreateIndex
CREATE INDEX "mail_outbox_status_next_attempt_at_idx" ON "mail_outbox"("status", "next_attempt_at");
-- AddForeignKey
ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "auth_tokens" ADD CONSTRAINT "auth_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -21,3 +21,121 @@ model InstanceSetting {
@@map("instance_settings") @@map("instance_settings")
} }
enum UserStatus {
PENDING_VERIFICATION
ACTIVE
DISABLED
}
/// Account profile. Login methods live in UserIdentity (OIDC-ready,
/// ADR 0007); Site Admin is a user flag, all other roles are grants.
model User {
id String @id @default(uuid())
username String @unique
email String @unique
displayName String @map("display_name")
locale String @default("en")
isSiteAdmin Boolean @default(false) @map("is_site_admin")
status UserStatus @default(PENDING_VERIFICATION)
emailVerifiedAt DateTime? @map("email_verified_at")
createdAt DateTime @default(now()) @map("created_at")
lastLoginAt DateTime? @map("last_login_at")
identities UserIdentity[]
sessions Session[]
authTokens AuthToken[]
@@map("users")
}
/// One row per login method. `provider` is "password" today and
/// "oidc:<issuer>" later; `credential` holds the Argon2id hash for
/// password identities.
model UserIdentity {
id String @id @default(uuid())
userId String @map("user_id")
provider String
subject String
credential String?
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, subject])
@@index([userId])
@@map("user_identities")
}
/// Server-side browser sessions (ADR 0007). `id` is the SHA-256 hash of
/// the opaque cookie token — the raw token is never stored.
model Session {
id String @id
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
lastSeenAt DateTime @default(now()) @map("last_seen_at")
userAgent String? @map("user_agent")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
@@map("sessions")
}
enum AuthTokenPurpose {
EMAIL_VERIFICATION
PASSWORD_RESET
}
/// Single-use, expiring tokens for e-mail flows. Stored hashed; consuming
/// sets `consumedAt` so replays are detectable.
model AuthToken {
id String @id @default(uuid())
tokenHash String @unique @map("token_hash")
userId String @map("user_id")
purpose AuthTokenPurpose
expiresAt DateTime @map("expires_at")
consumedAt DateTime? @map("consumed_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, purpose])
@@map("auth_tokens")
}
/// Fixed-window rate-limit counters (ADR 0002: no Redis). `key` encodes
/// scope and subject, e.g. "login:ip:203.0.113.7".
model RateLimit {
key String @id
windowStart DateTime @map("window_start")
count Int @default(0)
@@map("rate_limits")
}
enum MailStatus {
PENDING
SENT
FAILED
}
/// Outbox for reliable e-mail delivery with retry (issue #12).
model MailOutbox {
id String @id @default(uuid())
toAddress String @map("to_address")
subject String
textBody String @map("text_body")
htmlBody String @map("html_body")
status MailStatus @default(PENDING)
attempts Int @default(0)
nextAttemptAt DateTime @default(now()) @map("next_attempt_at")
lastError String? @map("last_error")
createdAt DateTime @default(now()) @map("created_at")
sentAt DateTime? @map("sent_at")
@@index([status, nextAttemptAt])
@@map("mail_outbox")
}

View File

@ -7,11 +7,13 @@ import { AppConfig } from './config/app-config.service';
import { ConfigModule } from './config/config.module'; import { ConfigModule } from './config/config.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { UsersModule } from './users/users.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule, ConfigModule,
PrismaModule, PrismaModule,
UsersModule,
LoggerModule.forRootAsync({ LoggerModule.forRootAsync({
inject: [AppConfig], inject: [AppConfig],
useFactory: (config: AppConfig) => ({ useFactory: (config: AppConfig) => ({

View File

@ -0,0 +1,17 @@
import { PrismaClient } from '@prisma/client';
/** True when database-backed tests can run (see vitest.global-setup.ts). */
export const hasTestDb = Boolean(process.env.TEST_DATABASE_URL);
/** Prisma client bound to the test database. Callers own the lifecycle. */
export function createTestPrisma(): PrismaClient {
if (!process.env.TEST_DATABASE_URL) {
throw new Error('TEST_DATABASE_URL is not set — guard the suite with hasTestDb');
}
return new PrismaClient({ datasourceUrl: process.env.TEST_DATABASE_URL });
}
/** Unique suffix so suites never collide on unique columns. */
export function uniqueSuffix(): string {
return Math.random().toString(36).slice(2, 10);
}

View File

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { hashPassword, passwordNeedsRehash, verifyPassword } from './password';
describe('password hashing', () => {
it('hashes with Argon2id and verifies the roundtrip', async () => {
const hash = await hashPassword('korrekt pferd batterie');
expect(hash).toMatch(/^\$argon2id\$/);
expect(await verifyPassword(hash, 'korrekt pferd batterie')).toBe(true);
expect(await verifyPassword(hash, 'falsches passwort')).toBe(false);
});
it('treats malformed hashes as non-matching instead of throwing', async () => {
expect(await verifyPassword('not-a-hash', 'whatever')).toBe(false);
});
it('does not demand a rehash for freshly created hashes', async () => {
expect(passwordNeedsRehash(await hashPassword('korrekt pferd batterie'))).toBe(false);
});
});

View File

@ -0,0 +1,26 @@
import * as argon2 from 'argon2';
/**
* Argon2id parameters following the OWASP password-storage cheat sheet
* (2024 baseline): 19 MiB memory, 2 iterations, single lane. Raising them
* later is safe verify() reads parameters from the stored hash, and
* needsRehash() flags outdated hashes at login time.
*/
const ARGON2_OPTIONS: argon2.Options = {
type: argon2.argon2id,
memoryCost: 19 * 1024,
timeCost: 2,
parallelism: 1,
};
export function hashPassword(password: string): Promise<string> {
return argon2.hash(password, ARGON2_OPTIONS);
}
export function verifyPassword(hash: string, password: string): Promise<boolean> {
return argon2.verify(hash, password).catch(() => false);
}
export function passwordNeedsRehash(hash: string): boolean {
return argon2.needsRehash(hash, ARGON2_OPTIONS);
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
@Module({
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@ -0,0 +1,55 @@
import { ConflictException } from '@nestjs/common';
import { afterAll, describe, expect, it } from 'vitest';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from './users.service';
describe.skipIf(!hasTestDb)('UsersService (database)', () => {
const prisma = hasTestDb ? (createTestPrisma() as unknown as PrismaService) : null!;
const users = hasTestDb ? new UsersService(prisma) : null!;
const suffix = uniqueSuffix();
const input = {
username: `uma-${suffix}`,
email: `uma-${suffix}@example.org`,
displayName: 'Uma Test',
password: 'korrekt pferd batterie',
locale: 'de',
};
afterAll(async () => {
if (!hasTestDb) return;
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
});
it('creates a user with a password identity and verifies the password', async () => {
const user = await users.createUser(input);
expect(user.status).toBe('PENDING_VERIFICATION');
expect(await users.checkPassword(user.id, input.password)).toBe(true);
expect(await users.checkPassword(user.id, 'wrong')).toBe(false);
});
it('rejects duplicate usernames with a field-level conflict', async () => {
await expect(
users.createUser({ ...input, email: `other-${suffix}@example.org` }),
).rejects.toBeInstanceOf(ConflictException);
});
it('rejects duplicate e-mail addresses case-insensitively', async () => {
await expect(
users.createUser({
...input,
username: `other-${suffix}`,
email: input.email.toUpperCase(),
}),
).rejects.toBeInstanceOf(ConflictException);
});
it('normalizes lookup by e-mail or username', async () => {
const byEmail = await users.findByUsernameOrEmail(input.email.toUpperCase());
const byName = await users.findByUsernameOrEmail(input.username);
expect(byEmail?.id).toBe(byName?.id);
});
});

View File

@ -0,0 +1,101 @@
import { ConflictException, Injectable } from '@nestjs/common';
import { Prisma, User } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { hashPassword, verifyPassword } from './password';
export const PASSWORD_PROVIDER = 'password';
export interface CreateUserInput {
username: string;
email: string;
displayName: string;
password: string;
locale: string;
}
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
/**
* Creates the account plus its password identity in one transaction.
* Uniqueness violations surface as field-level conflicts so the client
* can highlight the right input.
*/
async createUser(input: CreateUserInput): Promise<User> {
try {
return await this.prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
username: input.username,
email: input.email.toLowerCase(),
displayName: input.displayName,
locale: input.locale,
},
});
await tx.userIdentity.create({
data: {
userId: user.id,
provider: PASSWORD_PROVIDER,
// The user id (not the username) is the stable subject: a
// future username change must not orphan the identity.
subject: user.id,
credential: await hashPassword(input.password),
},
});
return user;
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
const target = (error.meta?.target as string[] | undefined)?.[0] ?? 'username';
throw new ConflictException({ field: target });
}
throw error;
}
}
findById(id: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { id } });
}
findByUsernameOrEmail(usernameOrEmail: string): Promise<User | null> {
const value = usernameOrEmail.trim();
return this.prisma.user.findFirst({
where: value.includes('@') ? { email: value.toLowerCase() } : { username: value },
});
}
findByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { email: email.toLowerCase() } });
}
async checkPassword(userId: string, password: string): Promise<boolean> {
const identity = await this.prisma.userIdentity.findUnique({
where: { provider_subject: { provider: PASSWORD_PROVIDER, subject: userId } },
});
if (!identity?.credential) return false;
return verifyPassword(identity.credential, password);
}
async setPassword(userId: string, password: string): Promise<void> {
await this.prisma.userIdentity.update({
where: { provider_subject: { provider: PASSWORD_PROVIDER, subject: userId } },
data: { credential: await hashPassword(password) },
});
}
async markEmailVerified(userId: string): Promise<User> {
return this.prisma.user.update({
where: { id: userId },
data: { status: 'ACTIVE', emailVerifiedAt: new Date() },
});
}
async updateProfile(
userId: string,
data: { displayName?: string; locale?: string },
): Promise<User> {
return this.prisma.user.update({ where: { id: userId }, data });
}
}

View File

@ -7,5 +7,8 @@ export default defineConfig({
plugins: [swc.vite({ module: { type: 'es6' } })], plugins: [swc.vite({ module: { type: 'es6' } })],
test: { test: {
environment: 'node', environment: 'node',
globalSetup: './vitest.global-setup.ts',
// DB-backed suites share one database; parallel files would race.
fileParallelism: false,
}, },
}); });

View File

@ -0,0 +1,17 @@
import { execFileSync } from 'node:child_process';
/**
* Database-backed tests run only when TEST_DATABASE_URL is set (locally:
* the compose dev db on port 5434; in CI: the postgres service container).
* This setup pushes the current Prisma schema into that database once per
* test run; tests skip themselves when the variable is absent.
*/
export default function globalSetup(): void {
const url = process.env.TEST_DATABASE_URL;
if (!url) return;
execFileSync(
process.execPath,
[require.resolve('prisma/build/index.js'), 'db', 'push', '--skip-generate'],
{ env: { ...process.env, DATABASE_URL: url }, stdio: 'inherit', cwd: __dirname },
);
}

View File

@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { passwordSchema, signupInputSchema, usernameSchema } from './auth';
describe('auth schemas', () => {
it('accepts sensible usernames and rejects unsafe ones', () => {
expect(usernameSchema.safeParse('stefan-w').success).toBe(true);
expect(usernameSchema.safeParse('ab').success).toBe(false);
expect(usernameSchema.safeParse('-leading').success).toBe(false);
expect(usernameSchema.safeParse('has space').success).toBe(false);
});
it('enforces password length and the common-password blocklist', () => {
expect(passwordSchema.safeParse('korrekt pferd batterie').success).toBe(true);
expect(passwordSchema.safeParse('short').success).toBe(false);
expect(passwordSchema.safeParse('Password123').success).toBe(false);
});
it('parses a complete signup payload with locale default', () => {
const parsed = signupInputSchema.parse({
username: 'uma',
email: 'uma@example.org',
displayName: 'Uma',
password: 'ein sehr gutes passwort',
});
expect(parsed.locale).toBe('en');
});
});

View File

@ -0,0 +1,84 @@
import { z } from 'zod';
/**
* Auth-related schemas shared between api (runtime validation) and web
* (form validation). Field error messages are i18n keys resolved by the
* client; the api returns them inside ApiErrorBody.details.
*/
export const usernameSchema = z
.string()
.min(3, 'validation.username.tooShort')
.max(32, 'validation.username.tooLong')
.regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i, 'validation.username.charset');
/**
* Password policy per issue #13: length over composition rules, plus a
* small blocklist of the most common passwords (full breach-list checks
* are deliberately out of scope for v1).
*/
const COMMON_PASSWORDS = new Set([
'1234567890',
'qwertyuiop',
'password12',
'password123',
'passwort123',
'1q2w3e4r5t',
'iloveyou12',
'sonnenschein',
'schalke04!',
'aaaaaaaaaa',
'1234567890a',
'qwertz1234',
]);
export const passwordSchema = z
.string()
.min(10, 'validation.password.tooShort')
.max(128, 'validation.password.tooLong')
.refine((value) => !COMMON_PASSWORDS.has(value.toLowerCase()), 'validation.password.tooCommon');
export const signupInputSchema = z.object({
username: usernameSchema,
email: z.string().email('validation.email.invalid').max(254),
displayName: z.string().trim().min(1, 'validation.displayName.required').max(80),
password: passwordSchema,
locale: z.enum(['de', 'en']).default('en'),
});
export type SignupInput = z.infer<typeof signupInputSchema>;
export const loginInputSchema = z.object({
usernameOrEmail: z.string().min(1, 'validation.required'),
password: z.string().min(1, 'validation.required'),
});
export type LoginInput = z.infer<typeof loginInputSchema>;
export const verifyEmailInputSchema = z.object({ token: z.string().min(16).max(256) });
export const resendVerificationInputSchema = z.object({
email: z.string().email('validation.email.invalid'),
});
export const forgotPasswordInputSchema = z.object({
email: z.string().email('validation.email.invalid'),
});
export const resetPasswordInputSchema = z.object({
token: z.string().min(16).max(256),
password: passwordSchema,
});
export const updateProfileInputSchema = z.object({
displayName: z.string().trim().min(1, 'validation.displayName.required').max(80).optional(),
locale: z.enum(['de', 'en']).optional(),
});
export const changePasswordInputSchema = z.object({
currentPassword: z.string().min(1, 'validation.required'),
newPassword: passwordSchema,
});
/** Public shape of the signed-in user, returned by /auth/me. */
export interface CurrentUser {
id: string;
username: string;
email: string;
displayName: string;
locale: 'de' | 'en';
isSiteAdmin: boolean;
}

View File

@ -1,4 +1,5 @@
export * from './api-error'; export * from './api-error';
export * from './auth';
export * from './env'; export * from './env';
export * from './health'; export * from './health';
export * from './i18n-tools'; export * from './i18n-tools';

47
pnpm-lock.yaml generated
View File

@ -44,6 +44,9 @@ importers:
'@prisma/client': '@prisma/client':
specifier: ^6.3.0 specifier: ^6.3.0
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
argon2:
specifier: ^0.44.0
version: 0.44.0
i18next: i18next:
specifier: ^26.3.4 specifier: ^26.3.4
version: 26.3.4(typescript@5.9.3) version: 26.3.4(typescript@5.9.3)
@ -292,6 +295,9 @@ packages:
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
engines: {node: '>=0.1.90'} engines: {node: '>=0.1.90'}
'@epic-web/invariant@1.0.0':
resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
'@esbuild/aix-ppc64@0.25.12': '@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -1063,6 +1069,10 @@ packages:
'@paralleldrive/cuid2@2.3.1': '@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
'@phc/format@1.0.0':
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
engines: {node: '>=10'}
'@pinojs/redact@0.4.0': '@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
@ -1664,6 +1674,10 @@ packages:
append-field@1.0.0: append-field@1.0.0:
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
argon2@0.44.0:
resolution: {integrity: sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==}
engines: {node: '>=16.17.0'}
argparse@2.0.1: argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@ -1902,6 +1916,11 @@ packages:
typescript: typescript:
optional: true optional: true
cross-env@10.1.0:
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
engines: {node: '>=20'}
hasBin: true
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -2593,12 +2612,20 @@ packages:
node-abort-controller@3.1.1: node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
node-addon-api@8.9.0:
resolution: {integrity: sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==}
engines: {node: ^18 || ^20 || >= 21}
node-emoji@1.11.0: node-emoji@1.11.0:
resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==}
node-fetch-native@1.6.7: node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
node-releases@2.0.50: node-releases@2.0.50:
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -3672,6 +3699,8 @@ snapshots:
'@colors/colors@1.5.0': '@colors/colors@1.5.0':
optional: true optional: true
'@epic-web/invariant@1.0.0': {}
'@esbuild/aix-ppc64@0.25.12': '@esbuild/aix-ppc64@0.25.12':
optional: true optional: true
@ -4236,6 +4265,8 @@ snapshots:
dependencies: dependencies:
'@noble/hashes': 1.8.0 '@noble/hashes': 1.8.0
'@phc/format@1.0.0': {}
'@pinojs/redact@0.4.0': {} '@pinojs/redact@0.4.0': {}
'@playwright/test@1.61.1': '@playwright/test@1.61.1':
@ -4839,6 +4870,13 @@ snapshots:
append-field@1.0.0: {} append-field@1.0.0: {}
argon2@0.44.0:
dependencies:
'@phc/format': 1.0.0
cross-env: 10.1.0
node-addon-api: 8.9.0
node-gyp-build: 4.8.4
argparse@2.0.1: {} argparse@2.0.1: {}
array-timsort@1.0.3: {} array-timsort@1.0.3: {}
@ -5058,6 +5096,11 @@ snapshots:
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
cross-env@10.1.0:
dependencies:
'@epic-web/invariant': 1.0.0
cross-spawn: 7.0.6
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@ -5785,12 +5828,16 @@ snapshots:
node-abort-controller@3.1.1: {} node-abort-controller@3.1.1: {}
node-addon-api@8.9.0: {}
node-emoji@1.11.0: node-emoji@1.11.0:
dependencies: dependencies:
lodash: 4.18.1 lodash: 4.18.1
node-fetch-native@1.6.7: {} node-fetch-native@1.6.7: {}
node-gyp-build@4.8.4: {}
node-releases@2.0.50: {} node-releases@2.0.50: {}
nypm@0.6.8: nypm@0.6.8:

View File

@ -6,5 +6,6 @@ allowBuilds:
'@prisma/client': true '@prisma/client': true
'@prisma/engines': true '@prisma/engines': true
'@swc/core': true '@swc/core': true
argon2: true
esbuild: true esbuild: true
prisma: true prisma: true