feat: add functional authentication
This commit is contained in:
209
api/stores/event-store/aggregates/account.ts
Normal file
209
api/stores/event-store/aggregates/account.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { toAccountDocument } from "@spec/schemas/account/account.ts";
|
||||
import { Strategy } from "@spec/schemas/account/strategies.ts";
|
||||
import { Avatar } from "@spec/schemas/avatar.ts";
|
||||
import { Contact } from "@spec/schemas/contact.ts";
|
||||
import { Email } from "@spec/schemas/email.ts";
|
||||
import { Name } from "@spec/schemas/name.ts";
|
||||
import { AggregateRoot, getDate } from "@valkyr/event-store";
|
||||
|
||||
import { db } from "~stores/read-store/database.ts";
|
||||
|
||||
import { eventStore } from "../event-store.ts";
|
||||
import { Auditor, systemAuditor } from "../events/auditor.ts";
|
||||
import { EventStoreFactory } from "../events/mod.ts";
|
||||
import { projector } from "../projector.ts";
|
||||
|
||||
export class Account extends AggregateRoot<EventStoreFactory> {
|
||||
static override readonly name = "account";
|
||||
|
||||
avatar?: Avatar;
|
||||
name?: Name;
|
||||
contact: Contact = {
|
||||
emails: [],
|
||||
};
|
||||
strategies: Strategy[] = [];
|
||||
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reducer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
with(event: EventStoreFactory["$events"][number]["$record"]): void {
|
||||
switch (event.type) {
|
||||
case "account:created": {
|
||||
this.id = event.stream;
|
||||
this.createdAt = getDate(event.created);
|
||||
}
|
||||
case "account:avatar:added": {
|
||||
this.avatar = { url: event.data };
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "account:name:added": {
|
||||
this.name = event.data;
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "account:email:added": {
|
||||
this.contact.emails.push(event.data);
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "strategy:email:added": {
|
||||
this.strategies.push({ type: "email", value: event.data });
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "strategy:password:added": {
|
||||
this.strategies.push({ type: "password", ...event.data });
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Actions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
create(meta: Auditor = systemAuditor) {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "account:created",
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addAvatar(url: string, meta: Auditor = systemAuditor): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "account:avatar:added",
|
||||
data: url,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addName(name: Name, meta: Auditor = systemAuditor): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "account:name:added",
|
||||
data: name,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addEmail(email: Email, meta: Auditor = systemAuditor): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "account:email:added",
|
||||
data: email,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addRole(roleId: string, meta: Auditor = systemAuditor): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "account:role:added",
|
||||
data: roleId,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addEmailStrategy(email: string, meta: Auditor = systemAuditor): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "strategy:email:added",
|
||||
data: email,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addPasswordStrategy(alias: string, password: string, meta: Auditor = systemAuditor): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "strategy:password:added",
|
||||
data: { alias, password },
|
||||
meta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Utilities
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export async function isEmailClaimed(email: string): Promise<boolean> {
|
||||
const relations = await eventStore.relations.getByKey(getAccountEmailRelation(email));
|
||||
if (relations.length > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Relations
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export function getAccountEmailRelation(email: string): string {
|
||||
return `/accounts/emails/${email}`;
|
||||
}
|
||||
|
||||
export function getAccountAliasRelation(alias: string): string {
|
||||
return `/accounts/aliases/${alias}`;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Projectors
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
projector.on("account:created", async ({ stream: id }) => {
|
||||
await db.collection("accounts").insertOne(
|
||||
toAccountDocument({
|
||||
id,
|
||||
name: {
|
||||
given: null,
|
||||
family: null,
|
||||
},
|
||||
contact: {
|
||||
emails: [],
|
||||
},
|
||||
strategies: [],
|
||||
roles: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
projector.on("account:avatar:added", async ({ stream: id, data: url }) => {
|
||||
await db.collection("accounts").updateOne({ id }, { $set: { avatar: { url } } });
|
||||
});
|
||||
|
||||
projector.on("account:name:added", async ({ stream: id, data: name }) => {
|
||||
await db.collection("accounts").updateOne({ id }, { $set: { name } });
|
||||
});
|
||||
|
||||
projector.on("account:email:added", async ({ stream: id, data: email }) => {
|
||||
await db.collection("accounts").updateOne({ id }, { $push: { "contact.emails": email } });
|
||||
});
|
||||
|
||||
projector.on("account:role:added", async ({ stream: id, data: roleId }) => {
|
||||
await db.collection("accounts").updateOne({ id }, { $push: { roles: roleId } });
|
||||
});
|
||||
|
||||
projector.on("strategy:email:added", async ({ stream: id, data: email }) => {
|
||||
await eventStore.relations.insert(getAccountEmailRelation(email), id);
|
||||
await db.collection("accounts").updateOne({ id }, { $push: { strategies: { type: "email", value: email } } });
|
||||
});
|
||||
|
||||
projector.on("strategy:password:added", async ({ stream: id, data: strategy }) => {
|
||||
await eventStore.relations.insert(getAccountAliasRelation(strategy.alias), id);
|
||||
await db.collection("accounts").updateOne({ id }, { $push: { strategies: { type: "password", ...strategy } } });
|
||||
});
|
||||
66
api/stores/event-store/aggregates/code.ts
Normal file
66
api/stores/event-store/aggregates/code.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { AggregateRoot, getDate } from "@valkyr/event-store";
|
||||
|
||||
import { CodeIdentity } from "../events/code.ts";
|
||||
import { EventStoreFactory } from "../events/mod.ts";
|
||||
|
||||
export class Code extends AggregateRoot<EventStoreFactory> {
|
||||
static override readonly name = "code";
|
||||
|
||||
identity!: CodeIdentity;
|
||||
value!: string;
|
||||
|
||||
createdAt!: Date;
|
||||
claimedAt?: Date;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Accessors
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
get isClaimed(): boolean {
|
||||
return this.claimedAt !== undefined;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Folder
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
with(event: EventStoreFactory["$events"][number]["$record"]): void {
|
||||
switch (event.type) {
|
||||
case "code:created": {
|
||||
this.value = event.data.value;
|
||||
this.identity = event.data.identity;
|
||||
this.createdAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "code:claimed": {
|
||||
this.claimedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Actions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
create(identity: CodeIdentity): this {
|
||||
return this.push({
|
||||
type: "code:created",
|
||||
stream: this.id,
|
||||
data: {
|
||||
identity,
|
||||
value: crypto
|
||||
.getRandomValues(new Uint8Array(5))
|
||||
.map((v) => v % 10)
|
||||
.join(""),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
claim(): this {
|
||||
return this.push({
|
||||
type: "code:claimed",
|
||||
stream: this.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
118
api/stores/event-store/aggregates/role.ts
Normal file
118
api/stores/event-store/aggregates/role.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
|
||||
|
||||
import { db } from "~libraries/read-store/database.ts";
|
||||
|
||||
import type { Auditor } from "../events/auditor.ts";
|
||||
import { EventStoreFactory } from "../events/mod.ts";
|
||||
import type { RoleCreatedData, RolePermissionOperation } from "../events/role.ts";
|
||||
import { projector } from "../projector.ts";
|
||||
|
||||
export class Role extends AggregateRoot<EventStoreFactory> {
|
||||
static override readonly name = "role";
|
||||
|
||||
id!: string;
|
||||
|
||||
name!: string;
|
||||
permissions: { [resource: string]: Set<string> } = {};
|
||||
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Factories
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static #reducer = makeAggregateReducer(Role);
|
||||
|
||||
static create(data: RoleCreatedData, meta: Auditor): Role {
|
||||
return new Role().push({
|
||||
type: "role:created",
|
||||
data,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
static async getById(stream: string): Promise<Role | undefined> {
|
||||
return this.$store.reduce({ name: "role", stream, reducer: this.#reducer });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reducer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
override with(event: EventStoreFactory["$events"][number]["$record"]): void {
|
||||
switch (event.type) {
|
||||
case "role:created": {
|
||||
this.id = event.stream;
|
||||
this.createdAt = getDate(event.created);
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "role:name-set": {
|
||||
this.name = event.data;
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "role:permissions-set": {
|
||||
for (const operation of event.data) {
|
||||
if (operation.type === "grant") {
|
||||
if (this.permissions[operation.resource] === undefined) {
|
||||
this.permissions[operation.resource] = new Set();
|
||||
}
|
||||
this.permissions[operation.resource].add(operation.action);
|
||||
}
|
||||
if (operation.type === "deny") {
|
||||
if (operation.action === undefined) {
|
||||
delete this.permissions[operation.resource];
|
||||
} else {
|
||||
this.permissions[operation.resource]?.delete(operation.action);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Actions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
setName(name: string, meta: Auditor): this {
|
||||
return this.push({
|
||||
type: "role:name-set",
|
||||
stream: this.id,
|
||||
data: name,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
setPermissions(operations: RolePermissionOperation[], meta: Auditor): this {
|
||||
return this.push({
|
||||
type: "role:permissions-set",
|
||||
stream: this.id,
|
||||
data: operations,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Projectors
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
projector.on("role:created", async ({ stream, data: { name, permissions } }) => {
|
||||
await db.collection("roles").insertOne({
|
||||
id: stream,
|
||||
name,
|
||||
permissions: permissions.reduce(
|
||||
(map, permission) => {
|
||||
map[permission.resource] = permission.actions;
|
||||
return map;
|
||||
},
|
||||
{} as Record<string, string[]>,
|
||||
),
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user