Template
1
0

feat: refactor account

This commit is contained in:
2025-08-12 05:24:20 +02:00
parent 1215a98afc
commit f0630d43b7
25 changed files with 256 additions and 332 deletions

View File

@@ -1,10 +1,10 @@
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
import { Avatar, Contact, Email, Name, Phone, Strategy } from "relay/schemas";
import { Strategy } from "@spec/modules/account/strategies.ts";
import { Avatar, Contact, Email, Name } from "@spec/shared";
import { AggregateRoot, getDate } from "@valkyr/event-store";
import { db, toAccountDriver } from "~libraries/read-store/mod.ts";
import { db } from "~libraries/read-store/mod.ts";
import { eventStore } from "../event-store.ts";
import { AccountCreatedData } from "../events/account.ts";
import { Auditor } from "../events/auditor.ts";
import { EventStoreFactory } from "../events/mod.ts";
import { projector } from "../projector.ts";
@@ -12,69 +12,22 @@ import { projector } from "../projector.ts";
export class Account extends AggregateRoot<EventStoreFactory> {
static override readonly name = "account";
id!: string;
organizationId?: string;
type!: "admin" | "consultant" | "organization";
avatar?: Avatar;
name?: Name;
contact: Contact = {
emails: [],
phones: [],
};
strategies: Strategy[] = [];
createdAt!: Date;
updatedAt!: Date;
// -------------------------------------------------------------------------
// Factories
// -------------------------------------------------------------------------
static #reducer = makeAggregateReducer(Account);
static create(data: AccountCreatedData, meta: Auditor): Account {
return new Account().push({
type: "account:created",
data,
meta,
});
}
static async getById(stream: string): Promise<Account | undefined> {
return this.$store.reduce({ name: "account", stream, reducer: this.#reducer });
}
static async getByEmail(email: string): Promise<Account | undefined> {
return this.$store.reduce({ name: "account", relation: Account.emailRelation(email), reducer: this.#reducer });
}
// -------------------------------------------------------------------------
// Relations
// -------------------------------------------------------------------------
static emailRelation(email: string): `account:email:${string}` {
return `account:email:${email}`;
}
static passwordRelation(alias: string): `account:password:${string}` {
return `account:password:${alias}`;
}
// -------------------------------------------------------------------------
// Reducer
// -------------------------------------------------------------------------
with(event: EventStoreFactory["$events"][number]["$record"]): void {
switch (event.type) {
case "account:created": {
this.id = event.stream;
this.organizationId = event.data.type === "organization" ? event.data.organizationId : undefined;
this.type = event.data.type;
this.createdAt = getDate(event.created);
break;
}
case "account:avatar:added": {
this.avatar = { url: event.data };
this.updatedAt = getDate(event.created);
@@ -90,11 +43,6 @@ export class Account extends AggregateRoot<EventStoreFactory> {
this.updatedAt = getDate(event.created);
break;
}
case "account:phone:added": {
this.contact.phones.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);
@@ -139,15 +87,6 @@ export class Account extends AggregateRoot<EventStoreFactory> {
});
}
addPhone(phone: Phone, meta: Auditor): this {
return this.push({
stream: this.id,
type: "account:phone:added",
data: phone,
meta,
});
}
addRole(roleId: string, meta: Auditor): this {
return this.push({
stream: this.id,
@@ -174,95 +113,40 @@ export class Account extends AggregateRoot<EventStoreFactory> {
meta,
});
}
// -------------------------------------------------------------------------
// Utilities
// -------------------------------------------------------------------------
toSession(): Session {
if (this.type === "organization") {
if (this.organizationId === undefined) {
throw new Error("Account .toSession failed, no organization id present");
}
return {
type: this.type,
accountId: this.id,
organizationId: this.organizationId,
};
}
return {
type: this.type,
accountId: this.id,
};
}
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type Session =
| {
type: "organization";
accountId: string;
organizationId: string;
}
| {
type: "admin" | "consultant";
accountId: string;
};
/*
|--------------------------------------------------------------------------------
| Projectors
|--------------------------------------------------------------------------------
*/
projector.on("account:created", async ({ stream, data }) => {
const schema: any = {
id: stream,
type: data.type,
contact: {
emails: [],
phones: [],
},
strategies: [],
roles: [],
};
if (data.type === "organization") {
schema.organizationId = data.organizationId;
}
await db.collection("accounts").insertOne(toAccountDriver(schema));
});
projector.on("account:avatar:added", async ({ stream: id, data: url }) => {
await db.collection("accounts").updateOne({ id }, { $set: { avatar: { url } } });
await db.collection("accounts").updateOne({ id }, { $set: { avatar: { url } } }, { upsert: true });
});
projector.on("account:name:added", async ({ stream: id, data: name }) => {
await db.collection("accounts").updateOne({ id }, { $set: { name } });
await db.collection("accounts").updateOne({ id }, { $set: { name } }, { upsert: true });
});
projector.on("account:email:added", async ({ stream: id, data: email }) => {
await db.collection("accounts").updateOne({ id }, { $push: { "contact.emails": email } });
});
projector.on("account:phone:added", async ({ stream: id, data: phone }) => {
await db.collection("accounts").updateOne({ id }, { $push: { "contact.phones": phone } });
await db.collection("accounts").updateOne({ id }, { $push: { "contact.emails": email } }, { upsert: true });
});
projector.on("account:role:added", async ({ stream: id, data: roleId }) => {
await db.collection("accounts").updateOne({ id }, { $push: { roles: roleId } });
await db.collection("accounts").updateOne({ id }, { $push: { roles: roleId } }, { upsert: true });
});
projector.on("strategy:email:added", async ({ stream: id, data: email }) => {
await eventStore.relations.insert(Account.emailRelation(email), id);
await db.collection("accounts").updateOne({ id }, { $push: { strategies: { type: "email", value: email } } });
await eventStore.relations.insert(`account:email:${email}`, id);
await db
.collection("accounts")
.updateOne({ id }, { $push: { strategies: { type: "email", value: email } } }, { upsert: true });
});
projector.on("strategy:password:added", async ({ stream: id, data: strategy }) => {
await eventStore.relations.insert(Account.passwordRelation(strategy.alias), id);
await db.collection("accounts").updateOne({ id }, { $push: { strategies: { type: "password", ...strategy } } });
await eventStore.relations.insert(`account:alias:${strategy.alias}`, id);
await db
.collection("accounts")
.updateOne({ id }, { $push: { strategies: { type: "password", ...strategy } } }, { upsert: true });
});

View File

@@ -1,8 +0,0 @@
import { AggregateFactory } from "@valkyr/event-store";
import { Account } from "./account.ts";
import { Code } from "./code.ts";
import { Organization } from "./organization.ts";
import { Role } from "./role.ts";
export const aggregates = new AggregateFactory([Account, Code, Organization, Role]);

View File

@@ -1,29 +1,12 @@
import { EmailSchema, NameSchema } from "@spec/shared";
import { event } from "@valkyr/event-store";
import { email, name, phone } from "relay/schemas";
import z from "zod";
import { auditor } from "./auditor.ts";
const created = z.discriminatedUnion([
z.object({
type: z.literal("admin"),
}),
z.object({
type: z.literal("consultant"),
}),
z.object({
type: z.literal("organization"),
organizationId: z.string(),
}),
]);
export default [
event.type("account:created").data(created).meta(auditor),
event.type("account:avatar:added").data(z.string()).meta(auditor),
event.type("account:name:added").data(name).meta(auditor),
event.type("account:email:added").data(email).meta(auditor),
event.type("account:phone:added").data(phone).meta(auditor),
event.type("account:name:added").data(NameSchema).meta(auditor),
event.type("account:email:added").data(EmailSchema).meta(auditor),
event.type("account:role:added").data(z.string()).meta(auditor),
];
export type AccountCreatedData = z.infer<typeof created>;

View File

@@ -3,7 +3,7 @@ import z from "zod";
import { auditor } from "./auditor.ts";
const created = z.object({
const CreatedSchema = z.object({
name: z.string(),
permissions: z.array(
z.object({
@@ -13,7 +13,7 @@ const created = z.object({
),
});
const operation = z.discriminatedUnion([
const OperationSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("grant"),
resource: z.string(),
@@ -27,11 +27,11 @@ const operation = z.discriminatedUnion([
]);
export default [
event.type("role:created").data(created).meta(auditor),
event.type("role:created").data(CreatedSchema).meta(auditor),
event.type("role:name-set").data(z.string()).meta(auditor),
event.type("role:permissions-set").data(z.array(operation)).meta(auditor),
event.type("role:permissions-set").data(z.array(OperationSchema)).meta(auditor),
];
export type RoleCreatedData = z.infer<typeof created>;
export type RoleCreatedData = z.infer<typeof CreatedSchema>;
export type RolePermissionOperation = z.infer<typeof operation>;
export type RolePermissionOperation = z.infer<typeof OperationSchema>;

View File

@@ -1,6 +0,0 @@
import { db, takeOne } from "../database.ts";
import { type AccountSchema, fromAccountDriver } from "./schema.ts";
export async function getAccountById(id: string): Promise<AccountSchema | undefined> {
return db.collection("accounts").find({ id }).toArray().then(fromAccountDriver).then(takeOne);
}

View File

@@ -1,36 +0,0 @@
import { z } from "zod";
const account = z.object({
id: z.uuid(),
name: z.object({
given: z.string(),
family: z.string(),
}),
email: z.email(),
});
/*
|--------------------------------------------------------------------------------
| Parsers
|--------------------------------------------------------------------------------
*/
const select = account;
const insert = account;
export function toAccountDriver(documents: unknown): AccountInsert {
return insert.parse(documents);
}
export function fromAccountDriver(documents: unknown[]): AccountSchema[] {
return documents.map((document) => select.parse(document));
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
export type AccountSchema = z.infer<typeof select>;
export type AccountInsert = z.infer<typeof insert>;

View File

@@ -1,10 +1,10 @@
import type { AccountDocument } from "@spec/modules/account/account.ts";
import { config } from "~config";
import { getDatabaseAccessor } from "~libraries/database/accessor.ts";
import { AccountInsert } from "./account/schema.ts";
export const db = getDatabaseAccessor<{
accounts: AccountInsert;
accounts: AccountDocument;
}>(`${config.name}:read-store`);
export function takeOne<TDocument>(documents: TDocument[]): TDocument | undefined {

View File

@@ -0,0 +1,35 @@
import { type Account, parseAccount } from "@spec/modules/account/account.ts";
import { db, takeOne } from "./database.ts";
/*
|--------------------------------------------------------------------------------
| Accounts
|--------------------------------------------------------------------------------
*/
/**
* Retrieve a single account by its primary identifier.
*
* @param id - Account identifier.
*/
export async function getAccountById(id: string): Promise<Account | undefined> {
return db
.collection("accounts")
.aggregate([
{
$match: { id },
},
{
$lookup: {
from: "roles",
localField: "roles",
foreignField: "id",
as: "roles",
},
},
])
.toArray()
.then(parseAccount)
.then(takeOne);
}

View File

@@ -1,3 +1,2 @@
export * from "./account/methods.ts";
export * from "./account/schema.ts";
export * from "./database.ts";
export * from "./methods.ts";

View File

@@ -14,7 +14,7 @@
"@std/fs": "npm:@jsr/std__fs@1",
"@std/path": "npm:@jsr/std__path@1",
"@valkyr/auth": "npm:@jsr/valkyr__auth@2",
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.0-beta.5",
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.0-beta.6",
"@valkyr/inverse": "npm:@jsr/valkyr__inverse@1",
"cookie": "1",
"mongodb": "6",

View File

@@ -12,10 +12,8 @@ export class SessionController extends Controller<{
const response = await api.auth.authenticate({
body: {
type: "email",
payload: {
email: "john.doe@fixture.none",
},
},
});
if ("error" in response) {
this.setState("error", undefined);

View File

@@ -9,6 +9,7 @@ export const api = makeClient(
}),
},
{
account: (await import("@spec/modules/account/mod.ts")).routes,
auth: (await import("@spec/modules/auth/mod.ts")).routes,
},
);

119
deno.lock generated
View File

@@ -11,15 +11,14 @@
"npm:@jsr/std__testing@1": "1.0.15",
"npm:@jsr/valkyr__auth@2": "2.0.2",
"npm:@jsr/valkyr__event-emitter@1": "1.0.1",
"npm:@jsr/valkyr__event-store@2.0.0-beta.5": "2.0.0-beta.5",
"npm:@jsr/valkyr__event-store@2.0.0-beta.6": "2.0.0-beta.6",
"npm:@jsr/valkyr__inverse@1": "1.0.1",
"npm:@tanstack/react-query@5": "5.84.2_react@19.1.1",
"npm:@tanstack/react-router@1": "1.131.5_react@19.1.1_react-dom@19.1.1__react@19.1.1",
"npm:@types/node@*": "22.15.15",
"npm:@types/react-dom@19": "19.1.7_@types+react@19.1.9",
"npm:@types/react@19": "19.1.9",
"npm:@valkyr/db@1": "1.0.1",
"npm:@vitejs/plugin-react@4": "4.7.0_vite@7.1.1__picomatch@4.0.3_@babel+core@7.28.0_@types+node@22.15.15",
"npm:@vitejs/plugin-react@4": "4.7.0_vite@7.1.2__picomatch@4.0.3_@babel+core@7.28.0",
"npm:cookie@1": "1.0.2",
"npm:eslint-plugin-react-hooks@5": "5.2.0_eslint@9.33.0",
"npm:eslint-plugin-react-refresh@0.4": "0.4.20_eslint@9.33.0",
@@ -32,10 +31,9 @@
"npm:prettier@3": "3.6.2",
"npm:react-dom@19": "19.1.1_react@19.1.1",
"npm:react@19": "19.1.1",
"npm:typescript-eslint@8": "8.39.0_eslint@9.33.0_typescript@5.9.2_@typescript-eslint+parser@8.39.0__eslint@9.33.0__typescript@5.9.2",
"npm:typescript-eslint@8": "8.39.1_eslint@9.33.0_typescript@5.9.2_@typescript-eslint+parser@8.39.1__eslint@9.33.0__typescript@5.9.2",
"npm:typescript@5": "5.9.2",
"npm:vite@7": "7.1.1_picomatch@4.0.3_@types+node@22.15.15",
"npm:vite@7.1.1": "7.1.1_picomatch@4.0.3_@types+node@22.15.15",
"npm:vite@7": "7.1.2_picomatch@4.0.3",
"npm:zod@4": "4.0.17"
},
"npm": {
@@ -507,17 +505,16 @@
],
"tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__event-emitter/1.0.1.tgz"
},
"@jsr/valkyr__event-store@2.0.0-beta.5": {
"integrity": "sha512-+xScdSFcIXbQUSofgQJJUdwJWssRzu42oHm8acsmbIStmYa0docCFTPtUQlUrRewND4lmFXvMlidsTb4tS7jww==",
"@jsr/valkyr__event-store@2.0.0-beta.6": {
"integrity": "sha512-4ybdvjW2SIXPy9WOwG0UyCEu4XYsrorL5ATGgZmKFDLzhlhrLDMlmDSzpMouPEOBlEFohR4080rvWRD0bCe/pA==",
"dependencies": [
"@jsr/valkyr__testcontainers",
"@valkyr/db",
"mongodb",
"nanoid@5.1.5",
"postgres",
"zod@4.0.17"
],
"tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__event-store/2.0.0-beta.5.tgz"
"tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__event-store/2.0.0-beta.6.tgz"
},
"@jsr/valkyr__inverse@1.0.1": {
"integrity": "sha512-uZpzPct9FGobgl6H+iR3VJlzZbTFVmJSrB4z5In8zHgIJCkmgYj0diU3soU6MuiKR7SFBfD4PGSuUpTTJHNMlg==",
@@ -745,12 +742,6 @@
"@types/json-schema@7.0.15": {
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
},
"@types/node@22.15.15": {
"integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==",
"dependencies": [
"undici-types"
]
},
"@types/react-dom@19.1.7_@types+react@19.1.9": {
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
"dependencies": [
@@ -772,8 +763,8 @@
"@types/webidl-conversions"
]
},
"@typescript-eslint/eslint-plugin@8.39.0_@typescript-eslint+parser@8.39.0__eslint@9.33.0__typescript@5.9.2_eslint@9.33.0_typescript@5.9.2": {
"integrity": "sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==",
"@typescript-eslint/eslint-plugin@8.39.1_@typescript-eslint+parser@8.39.1__eslint@9.33.0__typescript@5.9.2_eslint@9.33.0_typescript@5.9.2": {
"integrity": "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g==",
"dependencies": [
"@eslint-community/regexpp",
"@typescript-eslint/parser",
@@ -789,8 +780,8 @@
"typescript"
]
},
"@typescript-eslint/parser@8.39.0_eslint@9.33.0_typescript@5.9.2": {
"integrity": "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==",
"@typescript-eslint/parser@8.39.1_eslint@9.33.0_typescript@5.9.2": {
"integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==",
"dependencies": [
"@typescript-eslint/scope-manager",
"@typescript-eslint/types",
@@ -801,8 +792,8 @@
"typescript"
]
},
"@typescript-eslint/project-service@8.39.0_typescript@5.9.2": {
"integrity": "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==",
"@typescript-eslint/project-service@8.39.1_typescript@5.9.2": {
"integrity": "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw==",
"dependencies": [
"@typescript-eslint/tsconfig-utils",
"@typescript-eslint/types",
@@ -810,21 +801,21 @@
"typescript"
]
},
"@typescript-eslint/scope-manager@8.39.0": {
"integrity": "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==",
"@typescript-eslint/scope-manager@8.39.1": {
"integrity": "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw==",
"dependencies": [
"@typescript-eslint/types",
"@typescript-eslint/visitor-keys"
]
},
"@typescript-eslint/tsconfig-utils@8.39.0_typescript@5.9.2": {
"integrity": "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==",
"@typescript-eslint/tsconfig-utils@8.39.1_typescript@5.9.2": {
"integrity": "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA==",
"dependencies": [
"typescript"
]
},
"@typescript-eslint/type-utils@8.39.0_eslint@9.33.0_typescript@5.9.2": {
"integrity": "sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==",
"@typescript-eslint/type-utils@8.39.1_eslint@9.33.0_typescript@5.9.2": {
"integrity": "sha512-gu9/ahyatyAdQbKeHnhT4R+y3YLtqqHyvkfDxaBYk97EcbfChSJXyaJnIL3ygUv7OuZatePHmQvuH5ru0lnVeA==",
"dependencies": [
"@typescript-eslint/types",
"@typescript-eslint/typescript-estree",
@@ -835,11 +826,11 @@
"typescript"
]
},
"@typescript-eslint/types@8.39.0": {
"integrity": "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="
"@typescript-eslint/types@8.39.1": {
"integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw=="
},
"@typescript-eslint/typescript-estree@8.39.0_typescript@5.9.2": {
"integrity": "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==",
"@typescript-eslint/typescript-estree@8.39.1_typescript@5.9.2": {
"integrity": "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==",
"dependencies": [
"@typescript-eslint/project-service",
"@typescript-eslint/tsconfig-utils",
@@ -854,8 +845,8 @@
"typescript"
]
},
"@typescript-eslint/utils@8.39.0_eslint@9.33.0_typescript@5.9.2": {
"integrity": "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==",
"@typescript-eslint/utils@8.39.1_eslint@9.33.0_typescript@5.9.2": {
"integrity": "sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg==",
"dependencies": [
"@eslint-community/eslint-utils",
"@typescript-eslint/scope-manager",
@@ -865,8 +856,8 @@
"typescript"
]
},
"@typescript-eslint/visitor-keys@8.39.0": {
"integrity": "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==",
"@typescript-eslint/visitor-keys@8.39.1": {
"integrity": "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==",
"dependencies": [
"@typescript-eslint/types",
"eslint-visitor-keys@4.2.1"
@@ -884,7 +875,7 @@
"rxjs"
]
},
"@vitejs/plugin-react@4.7.0_vite@7.1.1__picomatch@4.0.3_@babel+core@7.28.0": {
"@vitejs/plugin-react@4.7.0_vite@7.1.2__picomatch@4.0.3_@babel+core@7.28.0": {
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
"dependencies": [
"@babel/core",
@@ -893,19 +884,7 @@
"@rolldown/pluginutils",
"@types/babel__core",
"react-refresh",
"vite@7.1.1_picomatch@4.0.3"
]
},
"@vitejs/plugin-react@4.7.0_vite@7.1.1__picomatch@4.0.3_@babel+core@7.28.0_@types+node@22.15.15": {
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
"dependencies": [
"@babel/core",
"@babel/plugin-transform-react-jsx-self",
"@babel/plugin-transform-react-jsx-source",
"@rolldown/pluginutils",
"@types/babel__core",
"react-refresh",
"vite@7.1.1_picomatch@4.0.3_@types+node@22.15.15"
"vite"
]
},
"acorn-jsx@5.3.2_acorn@8.15.0": {
@@ -1031,8 +1010,8 @@
"type-fest"
]
},
"electron-to-chromium@1.5.199": {
"integrity": "sha512-3gl0S7zQd88kCAZRO/DnxtBKuhMO4h0EaQIN3YgZfV6+pW+5+bf2AdQeHNESCoaQqo/gjGVYEf2YM4O5HJQqpQ=="
"electron-to-chromium@1.5.200": {
"integrity": "sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w=="
},
"esbuild@0.25.8": {
"integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==",
@@ -1424,10 +1403,6 @@
"integrity": "sha512-2ustYUX1R2rL/Br5B/FMhi8d5/QzvkJ912rBYxskcpu0myTHzSZfTr1LAS2Sm7jxRUObRrSBFoyzwAhL49aVSg==",
"bin": true
},
"nanoid@5.1.5": {
"integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==",
"bin": true
},
"natural-compare@1.4.0": {
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
},
@@ -1659,8 +1634,8 @@
"type-fest@3.13.1": {
"integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="
},
"typescript-eslint@8.39.0_eslint@9.33.0_typescript@5.9.2_@typescript-eslint+parser@8.39.0__eslint@9.33.0__typescript@5.9.2": {
"integrity": "sha512-lH8FvtdtzcHJCkMOKnN73LIn6SLTpoojgJqDAxPm1jCR14eWSGPX8ul/gggBdPMk/d5+u9V854vTYQ8T5jF/1Q==",
"typescript-eslint@8.39.1_eslint@9.33.0_typescript@5.9.2_@typescript-eslint+parser@8.39.1__eslint@9.33.0__typescript@5.9.2": {
"integrity": "sha512-GDUv6/NDYngUlNvwaHM1RamYftxf782IyEDbdj3SeaIHHv8fNQVRC++fITT7kUJV/5rIA/tkoRSSskt6osEfqg==",
"dependencies": [
"@typescript-eslint/eslint-plugin",
"@typescript-eslint/parser",
@@ -1674,9 +1649,6 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"bin": true
},
"undici-types@6.21.0": {
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
},
"update-browserslist-db@1.1.3_browserslist@4.25.2": {
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"dependencies": [
@@ -1698,8 +1670,8 @@
"react"
]
},
"vite@7.1.1_picomatch@4.0.3": {
"integrity": "sha512-yJ+Mp7OyV+4S+afWo+QyoL9jFWD11QFH0i5i7JypnfTcA1rmgxCbiA8WwAICDEtZ1Z1hzrVhN8R8rGTqkTY8ZQ==",
"vite@7.1.2_picomatch@4.0.3": {
"integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==",
"dependencies": [
"esbuild",
"fdir",
@@ -1713,25 +1685,6 @@
],
"bin": true
},
"vite@7.1.1_picomatch@4.0.3_@types+node@22.15.15": {
"integrity": "sha512-yJ+Mp7OyV+4S+afWo+QyoL9jFWD11QFH0i5i7JypnfTcA1rmgxCbiA8WwAICDEtZ1Z1hzrVhN8R8rGTqkTY8ZQ==",
"dependencies": [
"@types/node",
"esbuild",
"fdir",
"picomatch@4.0.3",
"postcss",
"rollup",
"tinyglobby"
],
"optionalDependencies": [
"fsevents"
],
"optionalPeers": [
"@types/node"
],
"bin": true
},
"webidl-conversions@7.0.0": {
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
},
@@ -1786,7 +1739,7 @@
"npm:@jsr/std__fs@1",
"npm:@jsr/std__path@1",
"npm:@jsr/valkyr__auth@2",
"npm:@jsr/valkyr__event-store@2.0.0-beta.5",
"npm:@jsr/valkyr__event-store@2.0.0-beta.6",
"npm:@jsr/valkyr__inverse@1",
"npm:cookie@1",
"npm:mongodb@6",

View File

@@ -0,0 +1,12 @@
import { makeSchemaParser } from "@spec/shared";
import z from "zod";
export const RoleSchema = z.object({
id: z.uuid(),
name: z.string(),
permissions: z.record(z.string(), z.array(z.string())),
});
export const parseRole = makeSchemaParser(RoleSchema);
export type Role = z.infer<typeof RoleSchema>;

View File

@@ -0,0 +1,23 @@
import { AvatarSchema, ContactSchema, makeSchemaParser, NameSchema } from "@spec/shared";
import { z } from "zod";
import { RoleSchema } from "../access/role.ts";
import { StrategySchema } from "./strategies.ts";
export const AccountSchema = z.object({
id: z.uuid(),
avatar: AvatarSchema.optional(),
name: NameSchema.optional(),
contact: ContactSchema.default({
emails: [],
}),
strategies: z.array(StrategySchema).default([]),
roles: z.array(RoleSchema).default([]),
});
export const AccountDocumentSchema = AccountSchema.omit({ roles: true }).extend({ roles: z.string().array() });
export const parseAccount = makeSchemaParser(AccountSchema);
export type Account = z.infer<typeof AccountSchema>;
export type AccountDocument = z.infer<typeof AccountDocumentSchema>;

View File

@@ -0,0 +1,5 @@
import { create } from "./routes/create.ts";
export const routes = {
create,
};

View File

@@ -0,0 +1,5 @@
import { route } from "@spec/relay";
import { NameSchema } from "@spec/shared";
import z from "zod";
export const create = route.post("/api/v1/accounts").body(z.object({ name: NameSchema }));

View File

@@ -0,0 +1,33 @@
import z from "zod";
const EmailStrategySchema = z.object({
type: z.literal("email"),
value: z.string(),
});
const PasswordStrategySchema = z.object({
type: z.literal("password"),
alias: z.string(),
password: z.string(),
});
const PasskeyStrategySchema = z.object({
type: z.literal("passkey"),
credId: z.string(),
credPublicKey: z.string(),
webauthnUserId: z.string(),
counter: z.number(),
backupEligible: z.boolean(),
backupStatus: z.boolean(),
transports: z.string(),
createdAt: z.date(),
lastUsed: z.date(),
});
export const StrategySchema = z.discriminatedUnion("type", [
EmailStrategySchema,
PasswordStrategySchema,
PasskeyStrategySchema,
]);
export type Strategy = z.infer<typeof StrategySchema>;

View File

@@ -2,8 +2,6 @@ import { z } from "zod";
export const PasskeyStrategySchema = z.object({
type: z.literal("passkey").describe("Authentication strategy type for WebAuthn/Passkey"),
payload: z
.object({
id: z.string().describe("Base64URL encoded credential ID"),
rawId: z.string().describe("Raw credential ID as base64URL encoded string"),
response: z
@@ -23,27 +21,17 @@ export const PasskeyStrategySchema = z.object({
.enum(["platform", "cross-platform"])
.optional()
.describe("Type of authenticator used (platform or cross-platform)"),
})
.describe("WebAuthn credential payload"),
});
export const EmailStrategySchema = z.object({
type: z.literal("email").describe("Authentication strategy type for email"),
payload: z
.object({
email: z.email().describe("User's email address for authentication"),
})
.describe("Email authentication payload"),
});
export const PasswordStrategySchema = z.object({
type: z.literal("password").describe("Authentication strategy type for password"),
payload: z
.object({
identifier: z.string().describe("User identifier (username or email)"),
alias: z.string().describe("User alias (username or email)"),
password: z.string().describe("User's password"),
})
.describe("Password authentication payload"),
});
export const StrategyPayloadSchema = z

7
spec/shared/avatar.ts Normal file
View File

@@ -0,0 +1,7 @@
import z from "zod";
export const AvatarSchema = z.object({
url: z.string().describe("A valid URL pointing to the user's avatar image."),
});
export type Avatar = z.infer<typeof AvatarSchema>;

9
spec/shared/contact.ts Normal file
View File

@@ -0,0 +1,9 @@
import z from "zod";
import { EmailSchema } from "./email.ts";
export const ContactSchema = z.object({
emails: z.array(EmailSchema).default([]).describe("A list of email addresses associated with the contact."),
});
export type Contact = z.infer<typeof ContactSchema>;

15
spec/shared/database.ts Normal file
View File

@@ -0,0 +1,15 @@
import z, { ZodObject } from "zod";
export function makeSchemaParser<TSchema extends ZodObject>(schema: TSchema): SchemaParserFn<TSchema> {
return ((value: unknown | unknown[]) => {
if (Array.isArray(value)) {
return value.map((value: unknown) => schema.parse(value));
}
return schema.parse(value);
}) as SchemaParserFn<TSchema>;
}
type SchemaParserFn<TSchema extends ZodObject> = {
(value: unknown): z.infer<TSchema>;
(value: unknown[]): z.infer<TSchema>[];
};

11
spec/shared/email.ts Normal file
View File

@@ -0,0 +1,11 @@
import z from "zod";
export const EmailSchema = z.object({
type: z.enum(["personal", "work"]).describe("The context of the email address, e.g., personal or work."),
value: z.email().describe("A valid email address string."),
primary: z.boolean().describe("Indicates if this is the primary email."),
verified: z.boolean().describe("True if the email address has been verified."),
label: z.string().optional().describe("Optional display label for the email address."),
});
export type Email = z.infer<typeof EmailSchema>;

View File

@@ -0,0 +1,5 @@
export * from "./avatar.ts";
export * from "./contact.ts";
export * from "./database.ts";
export * from "./email.ts";
export * from "./name.ts";

8
spec/shared/name.ts Normal file
View File

@@ -0,0 +1,8 @@
import { z } from "zod";
export const NameSchema = z.object({
family: z.string().nullable().describe("Family name, also known as last name or surname."),
given: z.string().nullable().describe("Given name, also known as first name."),
});
export type Name = z.infer<typeof NameSchema>;