feat(death): I want to die...
This commit is contained in:
parent
33e893ec58
commit
a16852c1b9
34 changed files with 761 additions and 321 deletions
|
|
@ -10,6 +10,8 @@
|
|||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@fastify/jwt": "^9.1.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"fastify": "^5.0.0",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
|
|
|
|||
43
src/@shared/src/auth/_inner.ts
Normal file
43
src/@shared/src/auth/_inner.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
//! Anything in this file shouldn't be used...
|
||||
//!
|
||||
//! This file is here because it is easier to share code in here.
|
||||
|
||||
import { FastifyInstance } from "fastify";
|
||||
import type { Database } from "@shared/database"
|
||||
import { UserId } from "../database/mixin/user";
|
||||
|
||||
export default {};
|
||||
|
||||
|
||||
class OTP {
|
||||
private db: Database;
|
||||
private static EPOCH: number = 0;
|
||||
private static KEY_SIZE: number = 64;
|
||||
private static TIME_STEP: number = 30;
|
||||
|
||||
constructor(db: Database) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
private static Now(): number {
|
||||
return Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
private static getT(): number {
|
||||
return Math.floor((OTP.Now() - this.EPOCH) / this.TIME_STEP)
|
||||
}
|
||||
|
||||
public verify(userid: UserId, code: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
public newUser(userid: UserId): string {
|
||||
return "super topt secret";
|
||||
}
|
||||
|
||||
public generate(userid: UserId): string | null {
|
||||
let secret = this.db.getUserOTPSecret(userid);
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,24 @@
|
|||
import fastifyJwt from "@fastify/jwt";
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import fp from 'fastify-plugin'
|
||||
import { user } from "@shared/database"
|
||||
|
||||
export const jwtPlugin = fp<FastifyPluginAsync>(async (fastify, _opts) => {
|
||||
let env = process.env.JWT_SECRET;
|
||||
if (env === undefined || env === null)
|
||||
throw "JWT_SECRET is not defined"
|
||||
void fastify.register(fastifyJwt, { secret: env });
|
||||
void fastify.register(fastifyJwt, {
|
||||
secret: env,
|
||||
decode: { complete: false },
|
||||
});
|
||||
});
|
||||
|
||||
export type JwtClaims = {
|
||||
id: user.UserId,
|
||||
};
|
||||
|
||||
export * as _inner from "./_inner.js";
|
||||
|
||||
export const otpPlugin = fp<FastifyPluginAsync>(async (fastify, _opts) => {
|
||||
fastify.decorate('otp', {}, ["db"]);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
import fp from 'fastify-plugin'
|
||||
import { FastifyInstance, FastifyPluginAsync } from 'fastify'
|
||||
|
||||
import { Base } from "./mixin/_base";
|
||||
import { UserDb } from "./mixin/user";
|
||||
import { SessionDb } from "./mixin/session";
|
||||
import { Database as DbImpl } from "./mixin/_base";
|
||||
import { UserImpl, IUserDb } from "./mixin/user";
|
||||
|
||||
class Database extends UserDb(SessionDb(Base as any)) {
|
||||
constructor(path: string) {
|
||||
super(path);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(DbImpl.prototype, UserImpl);
|
||||
|
||||
export interface Database extends DbImpl, IUserDb { }
|
||||
|
||||
// When using .decorate you have to specify added properties for Typescript
|
||||
declare module 'fastify' {
|
||||
|
|
@ -26,8 +24,10 @@ export const useDatabase = fp<FastifyPluginAsync>(async function(
|
|||
if (path === null || path === undefined)
|
||||
throw "env `DATABASE_DIR` not defined";
|
||||
f.log.info(`Opening database with path: ${path}/database.db`)
|
||||
f.decorate('db', new Database(`${path}/database.db`));
|
||||
let db: Database = new DbImpl(`${path}/database.db`) as Database;
|
||||
f.decorate('db', db);
|
||||
});
|
||||
|
||||
export * as user from "./mixin/user"
|
||||
export default useDatabase;
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ Table user {
|
|||
id integer [PK, not null, increment]
|
||||
name text [unique, not null]
|
||||
password text [null, Note: "If password is NULL, this means that the user is created through OAUTH2"]
|
||||
otp text [null, Note: "If otp is NULL, then the user didn't configure 2FA"]
|
||||
|
||||
Note: "Represent a user"
|
||||
}
|
||||
|
|
@ -32,14 +33,3 @@ Table auth {
|
|||
Aka can't have two account bound to the same <OAUTH2> account
|
||||
''']
|
||||
}
|
||||
|
||||
Table session {
|
||||
id integer [PK, not null, increment]
|
||||
cookie text [unique, not null]
|
||||
userid integer [ref: > user.id, not null]
|
||||
createAt text [not null]
|
||||
userAgent text [not null]
|
||||
reason integer [null]
|
||||
|
||||
Note: "Every session for users"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
CREATE TABLE IF NOT EXISTS user (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
password TEXT
|
||||
password TEXT,
|
||||
otp TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS auth (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
|
|
@ -10,13 +11,3 @@ CREATE TABLE IF NOT EXISTS auth (
|
|||
oauth2_user TEXT NOT NULL UNIQUE,
|
||||
FOREIGN KEY(user) REFERENCES user(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS session (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
cookie TEXT NOT NULL UNIQUE,
|
||||
userid INTEGER NOT NULL,
|
||||
createAt TEXT NOT NULL,
|
||||
userAgent TEXT NOT NULL,
|
||||
reason INTEGER,
|
||||
FOREIGN KEY(userid) REFERENCES user(id)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,6 @@ import sqlite from "better-sqlite3";
|
|||
// @ts-ignore: this file is included using vite, typescript doesn't know how to include this...
|
||||
import initSql from "../init.sql?raw"
|
||||
|
||||
export type MixinBase<T = {}> = new (...args: any[]) => {
|
||||
constructor(db_path: string): void,
|
||||
destroy(): void,
|
||||
prepare(s: string): sqlite.Statement,
|
||||
db: sqlite.Database,
|
||||
} & T;
|
||||
|
||||
export type SqliteReturn = object | undefined;
|
||||
|
||||
// Only way to use the database. Everything must be done through this.
|
||||
|
|
@ -18,48 +11,47 @@ export type SqliteReturn = object | undefined;
|
|||
// this is the base, meaning that no actual query are made from this file.
|
||||
// To create a new query function, go open another file, create a class that inherit from this class
|
||||
// in the `index.ts` file, import the new class, and make the `Database` class inherit it
|
||||
export class Base {
|
||||
private db: sqlite.Database;
|
||||
private st: Map<string, sqlite.Statement> = new Map();
|
||||
export class Database {
|
||||
private db: sqlite.Database;
|
||||
private st: Map<string, sqlite.Statement> = new Map();
|
||||
|
||||
|
||||
/**
|
||||
* Create a new instance of the database, and init it to a known state
|
||||
* the file ./init.sql will be ran onto the database, creating any table that might be missing
|
||||
*/
|
||||
constructor(db_path: string) {
|
||||
console.log("NEW DB :)");
|
||||
this.db = sqlite(db_path, {});
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.db.transaction(() => this.db.exec(initSql))();
|
||||
}
|
||||
/**
|
||||
* Create a new instance of the database, and init it to a known state
|
||||
* the file ./init.sql will be ran onto the database, creating any table that might be missing
|
||||
*/
|
||||
constructor(db_path: string) {
|
||||
this.db = sqlite(db_path, {});
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.db.transaction(() => this.db.exec(initSql))();
|
||||
}
|
||||
|
||||
/**
|
||||
* close the database
|
||||
*/
|
||||
public destroy(): void {
|
||||
// remove any statement from the cache
|
||||
this.st.clear();
|
||||
// close the database
|
||||
this.db?.close();
|
||||
}
|
||||
/**
|
||||
* close the database
|
||||
*/
|
||||
public destroy(): void {
|
||||
// remove any statement from the cache
|
||||
this.st.clear();
|
||||
// close the database
|
||||
this.db?.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* use this to create queries. This will create statements (kinda expensive) and cache them
|
||||
* since they will be cached, this means that they are only created once,
|
||||
* otherwise they'll be just spat out from the cache
|
||||
* the statements are cached by the {query} argument,
|
||||
* meaning that if you try to make two identiqual statement, but with different {query} they won't be cached
|
||||
*
|
||||
* @example this.prepare('SELECT * FROM users WHERE id = ?')
|
||||
* @example this.prepare('SELECT * FROM users LIMIT 100 OFFSET ?')
|
||||
*/
|
||||
protected prepare(query: string): sqlite.Statement {
|
||||
let st = this.st.get(query);
|
||||
if (st !== undefined) return st;
|
||||
/**
|
||||
* use this to create queries. This will create statements (kinda expensive) and cache them
|
||||
* since they will be cached, this means that they are only created once,
|
||||
* otherwise they'll be just spat out from the cache
|
||||
* the statements are cached by the {query} argument,
|
||||
* meaning that if you try to make two identiqual statement, but with different {query} they won't be cached
|
||||
*
|
||||
* @example this.prepare('SELECT * FROM users WHERE id = ?')
|
||||
* @example this.prepare('SELECT * FROM users LIMIT 100 OFFSET ?')
|
||||
*/
|
||||
protected prepare(query: string): sqlite.Statement {
|
||||
let st = this.st.get(query);
|
||||
if (st !== undefined) return st;
|
||||
|
||||
st = this.db.prepare(query);
|
||||
this.st.set(query, st);
|
||||
return st;
|
||||
}
|
||||
st = this.db.prepare(query);
|
||||
this.st.set(query, st);
|
||||
return st;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,55 @@
|
|||
//import sqlite from "better-sqlite3"
|
||||
import { MixinBase } from "./_base"
|
||||
import type { Database } from "./_base";
|
||||
|
||||
// never use this directly
|
||||
export const TemplateDb = function <TBase extends MixinBase>(Base: TBase) {
|
||||
return class extends Base {
|
||||
constructor(...args: any[]) {
|
||||
if (args.length != 1 && !(args[0] instanceof String))
|
||||
throw "Invalid arguments to mixing class"
|
||||
super(args[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// describe every function in the object
|
||||
export interface ITemplateDb extends Database {
|
||||
normalFunction(id: TemplateId): TemplateData | null,
|
||||
asyncFunction(id: TemplateId): Promise<TemplateData | null>,
|
||||
};
|
||||
|
||||
export const UserImpl: Omit<ITemplateDb, keyof Database> = {
|
||||
/**
|
||||
* whole function description
|
||||
*
|
||||
* @param id the argument description
|
||||
*
|
||||
* @returns what does the function return ?
|
||||
*/
|
||||
normalFunction(this: ITemplateDb, id: TemplateId): TemplateData | null {
|
||||
void id;
|
||||
return null;
|
||||
},
|
||||
/**
|
||||
* whole function description
|
||||
*
|
||||
* @param id the argument description
|
||||
*
|
||||
* @returns what does the function return ?
|
||||
*/
|
||||
async asyncFunction(this: ITemplateDb, id: TemplateId): Promise<TemplateData | null> {
|
||||
void id;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
export type TemplateId = number & { readonly __brand: unique symbol };
|
||||
|
||||
export type TemplateType = {
|
||||
readonly id: TemplateId,
|
||||
readonly field: string,
|
||||
readonly field2: number,
|
||||
export type TemplateData = {
|
||||
readonly id: TemplateId;
|
||||
readonly name: string;
|
||||
readonly password?: string;
|
||||
};
|
||||
|
||||
// this function will be able to be called from everywhere
|
||||
export async function freeFloatingExportedFunction(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
// this function will never be able to be called outside of this module
|
||||
async function privateFunction(): Promise<string | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
//silence warnings
|
||||
void privateFunction;
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
//import sqlite from "better-sqlite3"
|
||||
import { MixinBase } from "./_base"
|
||||
|
||||
// never use this directly
|
||||
|
||||
export const SessionDb = function <TBase extends MixinBase>(Base: TBase) {
|
||||
return class extends Base {
|
||||
constructor(...args: any[]) {
|
||||
if (args.length != 1 && !(args[0] instanceof String))
|
||||
throw "Invalid arguments to mixing class"
|
||||
super(args[0]);
|
||||
}
|
||||
|
||||
public getSessionFromId(id: SessionId): Session | null {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionId = number & { readonly __brand: unique symbol };
|
||||
|
||||
export type Session = {
|
||||
readonly id: SessionId,
|
||||
readonly name: string,
|
||||
readonly salt: string,
|
||||
readonly password: string,
|
||||
};
|
||||
|
|
@ -1,57 +1,174 @@
|
|||
//import sqlite from "better-sqlite3"
|
||||
import Joi from "joi"
|
||||
import { MixinBase, SqliteReturn } from "./_base"
|
||||
import type { Database, SqliteReturn } from "./_base";
|
||||
import * as bcrypt from "bcrypt";
|
||||
|
||||
// never use this directly
|
||||
|
||||
const schema = Joi.object({
|
||||
id: Joi.number(),
|
||||
name: Joi.string(),
|
||||
password: Joi.string().optional().allow(null),
|
||||
salt: Joi.string().optional().allow(null),
|
||||
})
|
||||
export interface IUserDb extends Database {
|
||||
getUser(id: UserId): User | null,
|
||||
getUserFromName(name: string): User | null,
|
||||
getUserFromRawId(id: number): User | null,
|
||||
getUserOtpSecret(id: UserId): string | null,
|
||||
createUser(name: string, password: string | null): Promise<User | null>,
|
||||
setUserPassword(id: UserId, password: string | null): Promise<User | null>,
|
||||
};
|
||||
|
||||
export const UserImpl: Omit<IUserDb, keyof Database> = {
|
||||
/**
|
||||
* Get a user from an [UserId]
|
||||
*
|
||||
* @param id the userid to fetch
|
||||
*
|
||||
* @returns The user if it exists, null otherwise
|
||||
*/
|
||||
getUser(this: IUserDb, id: UserId): User | null {
|
||||
return this.getUserFromRawId(id);
|
||||
},
|
||||
|
||||
export const UserDb = function <TBase extends MixinBase>(Base: TBase) {
|
||||
return class extends Base {
|
||||
constructor(...args: any[]) {
|
||||
if (args.length != 1 && !(args[0] instanceof String))
|
||||
throw "Invalid arguments to mixing class"
|
||||
super(args[0]);
|
||||
}
|
||||
/**
|
||||
* Get a user from a username [string]
|
||||
*
|
||||
* @param name the username to fetch
|
||||
*
|
||||
* @returns The user if it exists, null otherwise
|
||||
*/
|
||||
getUserFromName(this: IUserDb, name: string): User | null {
|
||||
return userFromRow(
|
||||
this.prepare(
|
||||
"SELECT * FROM user WHERE name = @name LIMIT 1",
|
||||
).get({ name }),
|
||||
);
|
||||
},
|
||||
|
||||
private userFromRow(row: any): User {
|
||||
const v = Joi.attempt(row, schema);
|
||||
return {
|
||||
id: v.id as UserId,
|
||||
name: v.name || null,
|
||||
password: v.password || null,
|
||||
salt: v.salt,
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Get a user from a raw [UserId]
|
||||
*
|
||||
* @param id the userid to modify
|
||||
*
|
||||
* @returns The user if it exists, null otherwise
|
||||
*/
|
||||
getUserFromRawId(this: IUserDb, id: number): User | null {
|
||||
return userFromRow(
|
||||
this.prepare("SELECT * FROM user WHERE id = @id LIMIT 1").get({
|
||||
id,
|
||||
}) as SqliteReturn,
|
||||
);
|
||||
},
|
||||
|
||||
public getUser(id: UserId): User | null {
|
||||
return this.getUserFromRawId(id);
|
||||
}
|
||||
/**
|
||||
* Create a new user using password hash
|
||||
*
|
||||
* @param name the username for the new user (must be unique and sanitized)
|
||||
* @param password the plaintext password of the new user (if any)
|
||||
*
|
||||
* @returns The user struct
|
||||
*/
|
||||
async createUser(this: IUserDb, name: string, password: string | null): Promise<User | null> {
|
||||
password = await hashPassword(password);
|
||||
return userFromRow(
|
||||
this.prepare(
|
||||
"INSERT OR FAIL INTO user (name, password) VALUES (@name, @password) RETURNING *",
|
||||
).get({ name, password }),
|
||||
);
|
||||
},
|
||||
|
||||
public getUserFromRawId(id: number): User | null {
|
||||
let res = this.prepare('SELECT * FROM user WHERE id = ?').get(id) as SqliteReturn;
|
||||
if (res === null || res === undefined) return null;
|
||||
return this.userFromRow(res);
|
||||
}
|
||||
/**
|
||||
* Set the hash of a password in the database for a specific user.
|
||||
* You are required to hash the password before storing it in the database
|
||||
*
|
||||
* @param id the userid to modify
|
||||
* @param password the plaintext password to store (can be null to remove password login)
|
||||
*
|
||||
* @returns The modified user if it exists, null otherwise
|
||||
*/
|
||||
async setUserPassword(this: IUserDb, id: UserId, password: string | null): Promise<User | null> {
|
||||
password = await hashPassword(password);
|
||||
return userFromRow(
|
||||
this.prepare(
|
||||
"UPDATE OR FAIL user SET password = @password WHERE id = @id RETURNING *",
|
||||
).get({ password, id }) as SqliteReturn,
|
||||
);
|
||||
},
|
||||
|
||||
public setUser(id: UserId, partialUser: Partial<Omit<User, 'id'>>): User | null {
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
getUserOtpSecret(this: IUserDb, id: UserId): string | null {
|
||||
let otp: any = this.prepare("SELECT otp FROM user WHERE id = @id LIMIT 1").get({ id }) as SqliteReturn;
|
||||
console.log(otp);
|
||||
if (otp?.otp === undefined || otp?.otp === null) return null;
|
||||
return otp.otp;
|
||||
},
|
||||
};
|
||||
|
||||
export type UserId = number & { readonly __brand: unique symbol };
|
||||
|
||||
export type User = {
|
||||
readonly id: UserId,
|
||||
readonly name: string,
|
||||
readonly salt: string,
|
||||
readonly password: string,
|
||||
readonly id: UserId;
|
||||
readonly name: string;
|
||||
readonly password?: string;
|
||||
readonly otp?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represent different state a "username" might be
|
||||
*
|
||||
* @enum V_valid The username is valid
|
||||
* @enum E_tooShort The username is too short
|
||||
* @enum E_tooLong The username is too long
|
||||
* @enum E_invalChar the username contains invalid characters (must be alphanumeric)
|
||||
*
|
||||
*/
|
||||
export const enum ValidUserNameRet {
|
||||
V_valid = "username.valid",
|
||||
E_tooShort = "username.tooShort",
|
||||
E_tooLong = "username.toLong",
|
||||
E_invalChar = "username.invalChar"
|
||||
}
|
||||
|
||||
export function validUserName(username: string): ValidUserNameRet {
|
||||
if (username.length < 4)
|
||||
return ValidUserNameRet.E_tooShort;
|
||||
if (username.length > 16)
|
||||
return ValidUserNameRet.E_tooLong;
|
||||
if (!(RegExp("^[0-9a-zA-Z]$").test(username)))
|
||||
return ValidUserNameRet.E_invalChar;
|
||||
return ValidUserNameRet.V_valid;
|
||||
}
|
||||
|
||||
export async function verifyUserPassword(
|
||||
user: User,
|
||||
password: string,
|
||||
): Promise<boolean> {
|
||||
// The user doesn't have a password, so it can't match.
|
||||
// This is somewhat bad thing to do, since it is a time-attack vector, but I don't care ?
|
||||
if (user.password == null) return false;
|
||||
return await bcrypt.compare(password, user.password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a password using the correct options
|
||||
*
|
||||
* @param password the plaintext password to hash (if any)\
|
||||
* @returns the bcrypt hashed password
|
||||
*
|
||||
* @note: This function will do nothing if [`null`] is passed (it'll return null directly)
|
||||
*/
|
||||
async function hashPassword(
|
||||
password: string | null,
|
||||
): Promise<string | null> {
|
||||
if (password === null) return null;
|
||||
return await bcrypt.hash(password, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user from a row
|
||||
*
|
||||
* @param row The data from sqlite
|
||||
*
|
||||
* @returns The user if it exists, null otherwise
|
||||
*/
|
||||
function userFromRow(row: any): User | null {
|
||||
if (row == null || row == undefined) return null;
|
||||
return {
|
||||
id: row.id as UserId,
|
||||
name: row.name || null,
|
||||
password: row.password || null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
// ************************************************************************** //
|
||||
// //
|
||||
// ::: :::::::: //
|
||||
// index.ts :+: :+: :+: //
|
||||
// +:+ +:+ +:+ //
|
||||
// By: maiboyer <maiboyer@student.42.fr> +#+ +:+ +#+ //
|
||||
// +#+#+#+#+#+ +#+ //
|
||||
// Created: 2025/06/20 17:41:01 by maiboyer #+# #+# //
|
||||
// Updated: 2025/07/30 16:08:19 by maiboyer ### ########.fr //
|
||||
// //
|
||||
// ************************************************************************** //
|
||||
|
||||
import { uuidv7 } from "uuidv7";
|
||||
|
||||
export class InvalidUUID extends Error {
|
||||
public readonly type = 'invalid-uuid';
|
||||
};
|
||||
|
||||
// A UUID is a all lowercase string that looks like this:
|
||||
// `xxxxxxxx-xxxx-7xxx-xxx-xxxxxxxxxxxx`
|
||||
// where x is any hex number
|
||||
//
|
||||
// it is a unique identifier, where when created can be assumed to be unique
|
||||
// (aka no checks are needed)
|
||||
//
|
||||
// this uses the v7 of UUID, which means that every uuid is part random,
|
||||
// part based on the timestamp it was Created
|
||||
//
|
||||
// This allows better ergonomics as you can "see" which uuid are older
|
||||
// and which one are newer.
|
||||
// This also makes sure that random UUID don't collide (unless you go back in time...).
|
||||
export type UUIDv7 = string & { readonly __brand: unique symbol };
|
||||
|
||||
const uuidv7Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function isUUIDv7(value: string): value is UUIDv7 {
|
||||
return uuidv7Regex.test(value);
|
||||
}
|
||||
|
||||
//export function toUUIDv7(value: string): Result<UUIDv7, InvalidUUID> {
|
||||
// if (!isUUIDv7(value)) return Result.error(new InvalidUUID());
|
||||
//
|
||||
// return Result.ok(value.toLowerCase() as UUIDv7);
|
||||
//}
|
||||
|
||||
export function newUUIDv7(): UUIDv7 {
|
||||
return uuidv7() as UUIDv7;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue