diff --git a/apps/syntropy/domain/README.md b/apps/syntropy/domain/README.md new file mode 100644 index 0000000..d165475 --- /dev/null +++ b/apps/syntropy/domain/README.md @@ -0,0 +1 @@ +# @syntropy/domain diff --git a/apps/syntropy/domain/deno.json b/apps/syntropy/domain/deno.json new file mode 100644 index 0000000..6f3df75 --- /dev/null +++ b/apps/syntropy/domain/deno.json @@ -0,0 +1,10 @@ +{ + "name": "@syntropy/domain", + "exports": { + ".": "./index.ts", + "./use-cases": "./use-cases.ts" + }, + "imports": { + "@fabric/core": "jsr:@fabric/core" + } +} diff --git a/apps/syntropy/domain/events/index.ts b/apps/syntropy/domain/events/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/syntropy/domain/index.ts b/apps/syntropy/domain/index.ts new file mode 100644 index 0000000..265b110 --- /dev/null +++ b/apps/syntropy/domain/index.ts @@ -0,0 +1,5 @@ +export * from "./events/index.ts"; +export * from "./models/index.ts"; +export * from "./security/index.ts"; +export * from "./services/index.ts"; +export * from "./use-cases/index.ts"; diff --git a/apps/syntropy/domain/models/index.ts b/apps/syntropy/domain/models/index.ts new file mode 100644 index 0000000..e80c300 --- /dev/null +++ b/apps/syntropy/domain/models/index.ts @@ -0,0 +1,4 @@ +import type { ProjectModel } from "./project.ts"; +import type { UserModel } from "./user.ts"; + +export type DomainModels = UserModel | ProjectModel; diff --git a/apps/syntropy/domain/models/project.ts b/apps/syntropy/domain/models/project.ts new file mode 100644 index 0000000..df718eb --- /dev/null +++ b/apps/syntropy/domain/models/project.ts @@ -0,0 +1,11 @@ +import { Field, Model, type ModelToType } from "@fabric/domain"; + +export const ProjectModel = Model.aggregateFrom("projects", { + name: Field.string(), + description: Field.string(), + userId: Field.reference({ + targetModel: "users", + }), +}); +export type ProjectModel = typeof ProjectModel; +export type Project = ModelToType; diff --git a/apps/syntropy/domain/models/user.ts b/apps/syntropy/domain/models/user.ts new file mode 100644 index 0000000..8007f6c --- /dev/null +++ b/apps/syntropy/domain/models/user.ts @@ -0,0 +1,18 @@ +import { Field, Model, type ModelToType } from "@fabric/domain"; +import type { ReadStateStore } from "../services/state-store.ts"; + +export const UserModel = Model.aggregateFrom("users", { + email: Field.string(), + hashedPassword: Field.string(), + role: Field.string(), +}); +export type UserModel = typeof UserModel; +export type User = ModelToType; + +export function findUserByEmail(stateStore: ReadStateStore, email: string) { + return stateStore.from("users") + .where({ + email, + }) + .selectOneOrFail(); +} diff --git a/apps/syntropy/domain/security/index.ts b/apps/syntropy/domain/security/index.ts new file mode 100644 index 0000000..cf32983 --- /dev/null +++ b/apps/syntropy/domain/security/index.ts @@ -0,0 +1,3 @@ +export * from "./permission.ts"; +export * from "./policy.ts"; +export * from "./users.ts"; diff --git a/apps/syntropy/domain/security/permission.ts b/apps/syntropy/domain/security/permission.ts new file mode 100644 index 0000000..0f5c88a --- /dev/null +++ b/apps/syntropy/domain/security/permission.ts @@ -0,0 +1,10 @@ +import { EnumToType } from "@fabric/core"; + +/** + * A permission is a string that represents a something that a user is allowed to do in the system. It should be in the form of: `ACTION_ENTITY`. + * - `ACTION`: The domain action that the user can perform on the domain object. This is a domain verb in the imperative mood. i.e. "CREATE", "EDIT", "VIEW", "FIX", "RELEASE", etc. + * - `ENTITY`: The domain object that the user can perform the action on. This is a domain noun in the singular form. + */ +export const Permission = {} as const; + +export type Permission = EnumToType; diff --git a/apps/syntropy/domain/security/policy.ts b/apps/syntropy/domain/security/policy.ts new file mode 100644 index 0000000..eb7be74 --- /dev/null +++ b/apps/syntropy/domain/security/policy.ts @@ -0,0 +1,5 @@ +import { Policy } from "@fabric/domain"; +import { Permission } from "./permission.ts"; +import { UserType } from "./users.ts"; + +export const policy = {} as const satisfies Policy; diff --git a/apps/syntropy/domain/security/users.ts b/apps/syntropy/domain/security/users.ts new file mode 100644 index 0000000..864902c --- /dev/null +++ b/apps/syntropy/domain/security/users.ts @@ -0,0 +1,11 @@ +import { EnumToType } from "@fabric/core"; + +/** + * A User Type is a string that represents a user type. + * It should be in uppercase and singular form. + */ +export const UserType = { + ADMIN: "ADMIN", + // SPECIAL_USER: "SPECIAL_USER", +}; +export type UserType = EnumToType; diff --git a/apps/syntropy/domain/services/auth-service.ts b/apps/syntropy/domain/services/auth-service.ts new file mode 100644 index 0000000..bb37f0a --- /dev/null +++ b/apps/syntropy/domain/services/auth-service.ts @@ -0,0 +1,6 @@ +import type { User } from "../models/user.ts"; + +export interface AuthService { + generateAccessToken(user: User): string; + generateRefreshToken(user: User): string; +} diff --git a/apps/syntropy/domain/services/crypto-service.ts b/apps/syntropy/domain/services/crypto-service.ts new file mode 100644 index 0000000..fef859a --- /dev/null +++ b/apps/syntropy/domain/services/crypto-service.ts @@ -0,0 +1,28 @@ +import { type AsyncResult, TaggedError } from "@fabric/core"; + +export interface CryptoService { + hashPassword(password: string): AsyncResult; + verifyPassword( + password: string, + hash: string, + ): AsyncResult; +} + +export class InvalidPasswordError extends TaggedError<"InvalidPasswordError"> { + constructor() { + super( + "InvalidPasswordError", + "The password is invalid or was not provided.", + ); + } +} + +export class InvalidPrivateKeyError + extends TaggedError<"InvalidPrivateKeyError"> { + constructor() { + super( + "InvalidPrivateKeyError", + "The private key is invalid or was not provided.", + ); + } +} diff --git a/apps/syntropy/domain/services/index.ts b/apps/syntropy/domain/services/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/syntropy/domain/services/state-store.ts b/apps/syntropy/domain/services/state-store.ts new file mode 100644 index 0000000..43dbe2e --- /dev/null +++ b/apps/syntropy/domain/services/state-store.ts @@ -0,0 +1,4 @@ +import type { ReadonlyStateStore } from "@fabric/domain"; +import type { DomainModels } from "../models/index.ts"; + +export type ReadStateStore = ReadonlyStateStore; diff --git a/apps/syntropy/domain/use-cases.ts b/apps/syntropy/domain/use-cases.ts new file mode 100644 index 0000000..36dcdf4 --- /dev/null +++ b/apps/syntropy/domain/use-cases.ts @@ -0,0 +1,5 @@ +import { Query } from "@fabric/domain"; + +export const UseCases = [] as const satisfies Query[]; + +export type UseCases = typeof UseCases; diff --git a/apps/syntropy/domain/use-cases/auth/create-admin-account.ts b/apps/syntropy/domain/use-cases/auth/create-admin-account.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/syntropy/domain/use-cases/auth/login.ts b/apps/syntropy/domain/use-cases/auth/login.ts new file mode 100644 index 0000000..f3d9f54 --- /dev/null +++ b/apps/syntropy/domain/use-cases/auth/login.ts @@ -0,0 +1,55 @@ +import { TaggedError } from "@fabric/core"; +import { Field, Model, type ModelToType, type Query } from "@fabric/domain"; +import type { AuthService } from "../../services/auth-service.ts"; +import type { CryptoService } from "../../services/crypto-service.ts"; +import type { ReadStateStore } from "../../services/state-store.ts"; + +export interface LoginDependencies { + state: ReadStateStore; + crypto: CryptoService; + auth: AuthService; +} + +export const LoginRequestModel = Model.from("LoginRequestModel", { + email: Field.email(), + password: Field.string(), + rememberMe: Field.boolean({ isOptional: true }), +}); +export type LoginRequestModel = ModelToType; + +export const LoginResponseModel = Model.from("LoginResponseModel", { + accessToken: Field.string(), + refreshToken: Field.string(), +}); +export type LoginResponseModel = ModelToType; + +export type LoginErrors = InvalidCredentialsError; + +export default { + name: "login", + isAuthRequired: false, + useCase: ({ state, crypto, auth }, { email, password }) => + state.from("users") + .where({ + email, + }) + .selectOneOrFail() + .assert((user) => crypto.verifyPassword(password, user.hashedPassword)) + .errorMap(() => new InvalidCredentialsError()) + .map((user) => ({ + accessToken: auth.generateAccessToken(user), + refreshToken: auth.generateRefreshToken(user), + })), +} as const satisfies Query< + LoginDependencies, + LoginRequestModel, + LoginResponseModel, + LoginErrors +>; + +export class InvalidCredentialsError + extends TaggedError<"InvalidCredentialsError"> { + constructor() { + super("InvalidCredentialsError"); + } +} diff --git a/apps/syntropy/domain/use-cases/index.ts b/apps/syntropy/domain/use-cases/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/syntropy/domain/use-cases/projects/create.test.ts b/apps/syntropy/domain/use-cases/projects/create.test.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/syntropy/domain/use-cases/projects/create.ts b/apps/syntropy/domain/use-cases/projects/create.ts new file mode 100644 index 0000000..3600da2 --- /dev/null +++ b/apps/syntropy/domain/use-cases/projects/create.ts @@ -0,0 +1,74 @@ +import { TaggedError, type UUID } from "@fabric/core"; +import { + type Command, + type DomainEvent, + Field, + Model, + type ModelToType, + type UUIDGenerator, +} from "@fabric/domain"; +import type { ReadStateStore } from "../../services/state-store.ts"; + +export interface CreateProjectDependencies { + state: ReadStateStore; + uuid: UUIDGenerator; + currentUserId: UUID; +} + +export const CreateProjectRequestModel = Model.from( + "CreateProjectRequestModel", + { + name: Field.string(), + description: Field.string(), + }, +); +export type CreateProjectRequestModel = ModelToType< + typeof CreateProjectRequestModel +>; + +export type ProjectCreatedEvent = DomainEvent<"ProjectCreated", { + id: string; + name: string; + description: string; + userId: string; +}>; + +export type CreateProjectErrors = ProjectNameInUseError; + +export default { + name: "createProject", + isAuthRequired: true, + useCase: ({ state, uuid, currentUserId }, { name, description }) => + state.from("projects") + .where({ name }) + .assertNone() + .errorMap(() => new ProjectNameInUseError()) + .map(() => { + const newEventId = uuid.generate(); + const newProjectId = uuid.generate(); + + return { + _tag: "ProjectCreated", + id: newEventId, + streamId: newProjectId, + payload: { + id: newProjectId, + name, + description, + userId: currentUserId, + }, + }; + }), +} as const satisfies Command< + CreateProjectDependencies, + CreateProjectRequestModel, + ProjectCreatedEvent, + CreateProjectErrors +>; + +export class ProjectNameInUseError + extends TaggedError<"ProjectNameInUseError"> { + constructor() { + super("ProjectNameInUseError"); + } +} diff --git a/deno.lock b/deno.lock index bfd8e9a..aa4a71b 100644 --- a/deno.lock +++ b/deno.lock @@ -125,13 +125,61 @@ "integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==" } }, + "redirects": { + "https://deno.land/std/testing/asserts.ts": "https://deno.land/std@0.224.0/testing/asserts.ts" + }, + "remote": { + "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", + "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", + "https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", + "https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7", + "https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74", + "https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd", + "https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff", + "https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46", + "https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b", + "https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c", + "https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491", + "https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68", + "https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3", + "https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7", + "https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29", + "https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a", + "https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a", + "https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8", + "https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693", + "https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31", + "https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5", + "https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8", + "https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb", + "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", + "https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47", + "https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68", + "https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3", + "https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73", + "https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19", + "https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5", + "https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6", + "https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2", + "https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e", + "https://deno.land/std@0.224.0/testing/asserts.ts": "d0cdbabadc49cc4247a50732ee0df1403fdcd0f95360294ad448ae8c240f3f5c" + }, "workspace": { "members": { + "apps/syntropy/domain": { + "dependencies": [ + "jsr:@fabric/core@*" + ] + }, + "packages/fabric/core": { + "dependencies": [ + "jsr:@quentinadam/decimal@~0.1.6" + ] + }, "packages/fabric/domain": { "dependencies": [ "jsr:@fabric/core@*", - "jsr:@fabric/validations@*", - "jsr:@quentinadam/decimal@~0.1.6" + "jsr:@fabric/validations@*" ] }, "packages/fabric/sqlite-store": {