alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
352 lines (351 loc) • 13.4 kB
JavaScript
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