diff --git a/api/libraries/event-store/aggregates/account.ts b/api/libraries/event-store/aggregates/account.ts index 51abb57..a7918f9 100644 --- a/api/libraries/event-store/aggregates/account.ts +++ b/api/libraries/event-store/aggregates/account.ts @@ -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 { 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 { - return this.$store.reduce({ name: "account", stream, reducer: this.#reducer }); - } - - static async getByEmail(email: string): Promise { - 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 { 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 { }); } - 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 { 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 }); }); diff --git a/api/libraries/event-store/aggregates/mod.ts b/api/libraries/event-store/aggregates/mod.ts deleted file mode 100644 index fad22fc..0000000 --- a/api/libraries/event-store/aggregates/mod.ts +++ /dev/null @@ -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]); diff --git a/api/libraries/event-store/events/account.ts b/api/libraries/event-store/events/account.ts index ff00930..ae6a351 100644 --- a/api/libraries/event-store/events/account.ts +++ b/api/libraries/event-store/events/account.ts @@ -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; diff --git a/api/libraries/event-store/events/role.ts b/api/libraries/event-store/events/role.ts index 6635512..e4239ce 100644 --- a/api/libraries/event-store/events/role.ts +++ b/api/libraries/event-store/events/role.ts @@ -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; +export type RoleCreatedData = z.infer; -export type RolePermissionOperation = z.infer; +export type RolePermissionOperation = z.infer; diff --git a/api/libraries/read-store/account/methods.ts b/api/libraries/read-store/account/methods.ts deleted file mode 100644 index 510393b..0000000 --- a/api/libraries/read-store/account/methods.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { db, takeOne } from "../database.ts"; -import { type AccountSchema, fromAccountDriver } from "./schema.ts"; - -export async function getAccountById(id: string): Promise { - return db.collection("accounts").find({ id }).toArray().then(fromAccountDriver).then(takeOne); -} diff --git a/api/libraries/read-store/account/schema.ts b/api/libraries/read-store/account/schema.ts deleted file mode 100644 index 7e74c30..0000000 --- a/api/libraries/read-store/account/schema.ts +++ /dev/null @@ -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; -export type AccountInsert = z.infer; diff --git a/api/libraries/read-store/database.ts b/api/libraries/read-store/database.ts index f2d0452..f0b0691 100644 --- a/api/libraries/read-store/database.ts +++ b/api/libraries/read-store/database.ts @@ -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(documents: TDocument[]): TDocument | undefined { diff --git a/api/libraries/read-store/methods.ts b/api/libraries/read-store/methods.ts new file mode 100644 index 0000000..0d579a6 --- /dev/null +++ b/api/libraries/read-store/methods.ts @@ -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 { + return db + .collection("accounts") + .aggregate([ + { + $match: { id }, + }, + { + $lookup: { + from: "roles", + localField: "roles", + foreignField: "id", + as: "roles", + }, + }, + ]) + .toArray() + .then(parseAccount) + .then(takeOne); +} diff --git a/api/libraries/read-store/mod.ts b/api/libraries/read-store/mod.ts index 739683a..7327492 100644 --- a/api/libraries/read-store/mod.ts +++ b/api/libraries/read-store/mod.ts @@ -1,3 +1,2 @@ -export * from "./account/methods.ts"; -export * from "./account/schema.ts"; export * from "./database.ts"; +export * from "./methods.ts"; diff --git a/api/package.json b/api/package.json index 6adc53b..06b5a34 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/apps/react/src/components/session.controller.ts b/apps/react/src/components/session.controller.ts index 9c83254..1de21e9 100644 --- a/apps/react/src/components/session.controller.ts +++ b/apps/react/src/components/session.controller.ts @@ -12,9 +12,7 @@ export class SessionController extends Controller<{ const response = await api.auth.authenticate({ body: { type: "email", - payload: { - email: "john.doe@fixture.none", - }, + email: "john.doe@fixture.none", }, }); if ("error" in response) { diff --git a/apps/react/src/services/api.ts b/apps/react/src/services/api.ts index 197b702..24d9bed 100644 --- a/apps/react/src/services/api.ts +++ b/apps/react/src/services/api.ts @@ -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, }, ); diff --git a/deno.lock b/deno.lock index 5d8a21f..81ea38d 100644 --- a/deno.lock +++ b/deno.lock @@ -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", diff --git a/spec/modules/access/role.ts b/spec/modules/access/role.ts new file mode 100644 index 0000000..be69f77 --- /dev/null +++ b/spec/modules/access/role.ts @@ -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; diff --git a/spec/modules/account/account.ts b/spec/modules/account/account.ts new file mode 100644 index 0000000..529c43c --- /dev/null +++ b/spec/modules/account/account.ts @@ -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; +export type AccountDocument = z.infer; diff --git a/spec/modules/account/mod.ts b/spec/modules/account/mod.ts new file mode 100644 index 0000000..5fddec5 --- /dev/null +++ b/spec/modules/account/mod.ts @@ -0,0 +1,5 @@ +import { create } from "./routes/create.ts"; + +export const routes = { + create, +}; diff --git a/spec/modules/account/routes/create.ts b/spec/modules/account/routes/create.ts new file mode 100644 index 0000000..5cb9a4e --- /dev/null +++ b/spec/modules/account/routes/create.ts @@ -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 })); diff --git a/spec/modules/account/strategies.ts b/spec/modules/account/strategies.ts new file mode 100644 index 0000000..8e2829a --- /dev/null +++ b/spec/modules/account/strategies.ts @@ -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; diff --git a/spec/modules/auth/strategies.ts b/spec/modules/auth/strategies.ts index 0e23334..1c9d173 100644 --- a/spec/modules/auth/strategies.ts +++ b/spec/modules/auth/strategies.ts @@ -2,48 +2,36 @@ import { z } from "zod"; export const PasskeyStrategySchema = z.object({ type: z.literal("passkey").describe("Authentication strategy type for WebAuthn/Passkey"), - payload: z + id: z.string().describe("Base64URL encoded credential ID"), + rawId: z.string().describe("Raw credential ID as base64URL encoded string"), + response: z .object({ - id: z.string().describe("Base64URL encoded credential ID"), - rawId: z.string().describe("Raw credential ID as base64URL encoded string"), - response: z - .object({ - clientDataJSON: z.string().describe("Base64URL encoded client data JSON"), - authenticatorData: z.string().describe("Base64URL encoded authenticator data"), - signature: z.string().optional().describe("Signature for authentication responses"), - userHandle: z.string().optional().describe("Optional user handle identifier"), - attestationObject: z.string().optional().describe("Attestation object for registration responses"), - }) - .describe("WebAuthn response data"), - clientExtensionResults: z - .record(z.string(), z.unknown()) - .default({}) - .describe("Results from WebAuthn extension inputs"), - authenticatorAttachment: z - .enum(["platform", "cross-platform"]) - .optional() - .describe("Type of authenticator used (platform or cross-platform)"), + clientDataJSON: z.string().describe("Base64URL encoded client data JSON"), + authenticatorData: z.string().describe("Base64URL encoded authenticator data"), + signature: z.string().optional().describe("Signature for authentication responses"), + userHandle: z.string().optional().describe("Optional user handle identifier"), + attestationObject: z.string().optional().describe("Attestation object for registration responses"), }) - .describe("WebAuthn credential payload"), + .describe("WebAuthn response data"), + clientExtensionResults: z + .record(z.string(), z.unknown()) + .default({}) + .describe("Results from WebAuthn extension inputs"), + authenticatorAttachment: z + .enum(["platform", "cross-platform"]) + .optional() + .describe("Type of authenticator used (platform or cross-platform)"), }); 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"), + email: z.email().describe("User's email address for authentication"), }); 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)"), - password: z.string().describe("User's password"), - }) - .describe("Password authentication payload"), + alias: z.string().describe("User alias (username or email)"), + password: z.string().describe("User's password"), }); export const StrategyPayloadSchema = z diff --git a/spec/shared/avatar.ts b/spec/shared/avatar.ts new file mode 100644 index 0000000..2a46586 --- /dev/null +++ b/spec/shared/avatar.ts @@ -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; diff --git a/spec/shared/contact.ts b/spec/shared/contact.ts new file mode 100644 index 0000000..5d885e0 --- /dev/null +++ b/spec/shared/contact.ts @@ -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; diff --git a/spec/shared/database.ts b/spec/shared/database.ts new file mode 100644 index 0000000..a708966 --- /dev/null +++ b/spec/shared/database.ts @@ -0,0 +1,15 @@ +import z, { ZodObject } from "zod"; + +export function makeSchemaParser(schema: TSchema): SchemaParserFn { + return ((value: unknown | unknown[]) => { + if (Array.isArray(value)) { + return value.map((value: unknown) => schema.parse(value)); + } + return schema.parse(value); + }) as SchemaParserFn; +} + +type SchemaParserFn = { + (value: unknown): z.infer; + (value: unknown[]): z.infer[]; +}; diff --git a/spec/shared/email.ts b/spec/shared/email.ts new file mode 100644 index 0000000..4e313aa --- /dev/null +++ b/spec/shared/email.ts @@ -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; diff --git a/spec/shared/mod.ts b/spec/shared/mod.ts index e69de29..e7c69f5 100644 --- a/spec/shared/mod.ts +++ b/spec/shared/mod.ts @@ -0,0 +1,5 @@ +export * from "./avatar.ts"; +export * from "./contact.ts"; +export * from "./database.ts"; +export * from "./email.ts"; +export * from "./name.ts"; diff --git a/spec/shared/name.ts b/spec/shared/name.ts new file mode 100644 index 0000000..2a8bf12 --- /dev/null +++ b/spec/shared/name.ts @@ -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;