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:
parent
61da1cc784
commit
36608177f6
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@ -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) => ({
|
||||||
|
|||||||
17
apps/api/src/testing/test-db.ts
Normal file
17
apps/api/src/testing/test-db.ts
Normal 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);
|
||||||
|
}
|
||||||
20
apps/api/src/users/password.test.ts
Normal file
20
apps/api/src/users/password.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
26
apps/api/src/users/password.ts
Normal file
26
apps/api/src/users/password.ts
Normal 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);
|
||||||
|
}
|
||||||
9
apps/api/src/users/users.module.ts
Normal file
9
apps/api/src/users/users.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [UsersService],
|
||||||
|
exports: [UsersService],
|
||||||
|
})
|
||||||
|
export class UsersModule {}
|
||||||
55
apps/api/src/users/users.service.db.test.ts
Normal file
55
apps/api/src/users/users.service.db.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
101
apps/api/src/users/users.service.ts
Normal file
101
apps/api/src/users/users.service.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
17
apps/api/vitest.global-setup.ts
Normal file
17
apps/api/vitest.global-setup.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
28
packages/shared/src/auth.test.ts
Normal file
28
packages/shared/src/auth.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
84
packages/shared/src/auth.ts
Normal file
84
packages/shared/src/auth.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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
47
pnpm-lock.yaml
generated
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user