UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

352 lines (351 loc) 13.4 kB
import { $atom, $inject, $module, $state, z } from "alepha"; import { $action, BadRequestError, NotFoundError } from "alepha/server"; import { createHash, randomInt } from "node:crypto"; import { CryptoProvider } from "alepha/crypto"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { $entity, $repository, db } from "alepha/orm"; import { $scheduler } from "alepha/scheduler"; //#region ../../src/api/verifications/schemas/validateVerificationCodeResponseSchema.ts const validateVerificationCodeResponseSchema = z.object({ ok: z.boolean().describe("Indicates whether the verification was successful."), alreadyVerified: z.boolean().describe("Indicates whether the target was already verified.").optional() }); //#endregion //#region ../../src/api/verifications/schemas/verificationTypeEnumSchema.ts const verificationTypeEnumSchema = z.enum(["code", "link"]); //#endregion //#region ../../src/api/verifications/entities/verifications.ts const verifications = $entity({ name: "verification", schema: z.object({ id: db.primaryKey(z.bigint()), createdAt: db.createdAt(), updatedAt: db.updatedAt(), version: db.version(), type: verificationTypeEnumSchema, target: z.text({ description: "Can be a phone (E.164 format) or email address" }), purpose: db.default(z.text({ description: "Logical purpose bucket (e.g. 'default', 'password-reset'). Scopes the cooldown and daily-limit checks so unrelated flows that share the same (type, target) — most notably email verification and password reset — don't collide." }), "default"), code: z.text({ description: "Hashed verification token (n-digit code or UUID)" }), verifiedAt: z.datetime().describe("When it was successfully verified").optional(), attempts: db.default(z.integer().describe("Number of failed attempts (to prevent brute-force)"), 0) }), indexes: ["createdAt", { columns: ["target", "code"] }] }); const verificationEntitySchema = verifications.schema; const verificationEntityInsertSchema = verifications.insertSchema; //#endregion //#region ../../src/api/verifications/parameters/VerificationParameters.ts /** * Verification settings configuration atom */ const verificationOptions = $atom({ name: "alepha.api.verifications.options", schema: z.object({ code: z.object({ maxAttempts: z.integer().min(1).max(10).describe("Maximum number of attempts before locking the verification."), codeLength: z.integer().min(4).max(12).describe("Length of the verification code."), codeExpiration: z.integer().min(60).max(3600).describe("Time in seconds before the verification code expires."), verificationCooldown: z.integer().min(0).max(3600).describe("Cooldown period in seconds after a request verification."), limitPerDay: z.integer().min(1).max(100).describe("Maximum number of verification requests per day for one entry.") }).describe("Settings specific to code verifications."), link: z.object({ maxAttempts: z.integer().min(1).max(10).describe("Maximum number of attempts before locking the verification."), codeExpiration: z.integer().min(60).max(7200).describe("Time in seconds before the verification token expires."), verificationCooldown: z.integer().min(0).max(3600).describe("Cooldown period in seconds after a request verification."), limitPerDay: z.integer().min(1).max(100).describe("Maximum number of verification requests per day for one entry.") }).describe("Settings specific to link verifications."), purgeDays: z.integer().min(0).max(365).describe("Number of days after which expired verifications are automatically deleted. Set to 0 to disable auto-deletion.") }), default: { code: { maxAttempts: 5, codeLength: 6, codeExpiration: 300, verificationCooldown: 90, limitPerDay: 10 }, link: { maxAttempts: 3, codeExpiration: 1800, verificationCooldown: 90, limitPerDay: 10 }, purgeDays: 1 } }); var VerificationParameters = class { options = $state(verificationOptions); get(key) { return this.options[key]; } }; //#endregion //#region ../../src/api/verifications/services/VerificationService.ts var VerificationService = class { log = $logger(); dateTimeProvider = $inject(DateTimeProvider); crypto = $inject(CryptoProvider); verificationParameters = $inject(VerificationParameters); verificationRepository = $repository(verifications); async findByEntry(entry) { this.log.trace("Finding verification by entry", { type: entry.type, target: entry.target }); const results = await this.verificationRepository.findMany({ limit: 1, orderBy: { column: "createdAt", direction: "desc" }, where: { type: { eq: entry.type }, target: { eq: entry.target }, purpose: { eq: entry.purpose ?? "default" } } }); if (results.length === 0) { this.log.debug("Verification entry not found", { type: entry.type, target: entry.target }); throw new NotFoundError("Verification entry not found"); } this.log.debug("Verification entry found", { id: results[0].id, type: entry.type, target: entry.target }); return results[0]; } findRecentsByEntry(entry) { this.log.trace("Finding recent verifications by entry", { type: entry.type, target: entry.target }); return this.verificationRepository.findMany({ orderBy: { column: "createdAt", direction: "desc" }, where: { type: { eq: entry.type }, target: { eq: entry.target }, purpose: { eq: entry.purpose ?? "default" }, createdAt: { gte: this.dateTimeProvider.now().startOf("day").toISOString() } } }); } /** * Creates a verification entry and returns the token. * The caller is responsible for sending notifications with the token. * This allows for context-specific notifications (e.g., password reset vs email verification). */ async createVerification(entry) { this.log.trace("Creating verification", { type: entry.type, target: entry.target }); const settings = this.verificationParameters.get(entry.type); const recents = await this.findRecentsByEntry(entry); if (recents.length >= settings.limitPerDay) { this.log.warn("Daily verification limit reached", { type: entry.type, target: entry.target, limit: settings.limitPerDay, count: recents.length }); throw new BadRequestError(`Maximum number of verification requests per day reached (${settings.limitPerDay})`); } const existingVerification = recents[0]; if (existingVerification) { const diffSec = this.dateTimeProvider.now().unix() - this.dateTimeProvider.of(existingVerification.createdAt).unix(); if (diffSec < settings.verificationCooldown) { const remainingCooldown = Math.floor(settings.verificationCooldown - diffSec); this.log.debug("Verification on cooldown", { type: entry.type, target: entry.target, remainingSeconds: remainingCooldown }); throw new BadRequestError(`Verification is on cooldown for ${remainingCooldown} seconds`); } } const token = this.generateToken(entry.type); const verification = await this.verificationRepository.create({ type: entry.type, target: entry.target, purpose: entry.purpose ?? "default", code: this.hashCode(token), createdAt: this.dateTimeProvider.nowISOString() }); this.log.info("Verification created", { id: verification.id, type: entry.type, target: entry.target, expiresInSeconds: settings.codeExpiration }); return { token, codeExpiration: settings.codeExpiration, verificationCooldown: settings.verificationCooldown, maxVerificationAttempts: settings.maxAttempts }; } async verifyCode(entry, code) { this.log.trace("Verifying code", { type: entry.type, target: entry.target }); const settings = this.verificationParameters.get(entry.type); const verification = await this.findByEntry(entry); if (verification.verifiedAt) { if (verification.code !== this.hashCode(code)) { this.log.warn("Invalid code submitted for already-verified entry", { id: verification.id, type: entry.type, target: entry.target }); throw new BadRequestError("Invalid verification code"); } this.log.debug("Verification already verified", { id: verification.id, type: entry.type, target: entry.target, verifiedAt: verification.verifiedAt }); return { ok: true, alreadyVerified: true }; } const now = this.dateTimeProvider.now(); const expirationDate = this.dateTimeProvider.of(verification.createdAt).add(settings.codeExpiration, "seconds"); if (now > expirationDate) { this.log.warn("Verification code expired", { id: verification.id, type: entry.type, target: entry.target, createdAt: verification.createdAt, expiredAt: expirationDate.toISOString() }); throw new BadRequestError("Verification code has expired"); } if (verification.attempts >= settings.maxAttempts) { this.log.warn("Verification locked due to max attempts", { id: verification.id, type: entry.type, target: entry.target, attempts: verification.attempts, maxAttempts: settings.maxAttempts }); throw new BadRequestError("Maximum number of attempts reached - verification is locked"); } if (verification.code !== this.hashCode(code)) { const newAttempts = verification.attempts + 1; this.log.warn("Invalid verification code", { id: verification.id, type: entry.type, target: entry.target, attempts: newAttempts, maxAttempts: settings.maxAttempts }); await this.verificationRepository.updateById(verification.id, { attempts: newAttempts }); throw new BadRequestError("Invalid verification code"); } await this.verificationRepository.updateById(verification.id, { verifiedAt: this.dateTimeProvider.nowISOString() }); this.log.info("Verification code verified", { id: verification.id, type: entry.type, target: entry.target }); return { ok: true }; } hashCode(code) { return createHash("sha256").update(code).digest("hex"); } generateToken(type) { if (type === "code") { const settings = this.verificationParameters.get("code"); return randomInt(0, 1e6).toString().padStart(settings.codeLength, "0"); } else if (type === "link") return this.crypto.randomUUID(); throw new BadRequestError(`Invalid verification type: ${type}`); } }; //#endregion //#region ../../src/api/verifications/controllers/VerificationController.ts var VerificationController = class { verificationService = $inject(VerificationService); url = "/verifications"; group = "verifications"; validateVerificationCode = $action({ path: `${this.url}/:type/validate`, group: this.group, method: "POST", schema: { params: z.object({ type: verificationTypeEnumSchema }), body: z.object({ target: z.text(), token: z.text({ description: "The verification token (6-digit code for phone, UUID for email)." }) }), response: validateVerificationCodeResponseSchema }, handler: async ({ body, params }) => { return this.verificationService.verifyCode({ type: params.type, target: body.target }, body.token); } }); }; //#endregion //#region ../../src/api/verifications/jobs/VerificationJobs.ts var VerificationJobs = class { verificationRepository = $repository(verifications); verificationParameters = $inject(VerificationParameters); dateTimeProvider = $inject(DateTimeProvider); cleanExpired = $scheduler({ name: "api:verifications:cleanExpired", cron: "0 * * * *", description: "Clean expired verifications", handler: async () => { const purgeDays = this.verificationParameters.get("purgeDays"); if (purgeDays <= 0) return; const purgeThreshold = this.dateTimeProvider.nowMillis() - purgeDays * (1440 * 60 * 1e3); await this.verificationRepository.deleteMany({ createdAt: { lt: this.dateTimeProvider.of(purgeThreshold).toISOString() } }); } }); }; //#endregion //#region ../../src/api/verifications/schemas/requestVerificationCodeResponseSchema.ts const requestVerificationCodeResponseSchema = z.object({ token: z.string().describe("The verification token (6-digit code for phone, UUID for email). The caller should send this to the user via their preferred notification method."), codeExpiration: z.integer().describe("Time in seconds before your verification token expires."), verificationCooldown: z.integer().describe("Cooldown period in seconds before you can request another verification."), maxVerificationAttempts: z.integer().describe("Maximum number of verification attempts allowed before the token is locked.") }); //#endregion //#region ../../src/api/verifications/index.ts /** * Email and phone verification workflows. * * **Features:** * - Verification token generation * - Verification code sending * - Verification completion tracking * - Resend functionality * * @module alepha.api.verifications */ const AlephaApiVerification = $module({ name: "alepha.api.verifications", services: [ VerificationController, VerificationJobs, VerificationService, VerificationParameters ] }); //#endregion export { AlephaApiVerification, VerificationController, VerificationJobs, VerificationParameters, VerificationService, requestVerificationCodeResponseSchema, validateVerificationCodeResponseSchema, verificationEntityInsertSchema, verificationEntitySchema, verificationOptions, verificationTypeEnumSchema, verifications }; //# sourceMappingURL=index.js.map