From 82d7a0d9cd46c789f69bd9405e2bacccfe7df08f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoffer=20R=C3=B8dvik?= Date: Tue, 12 Aug 2025 23:11:08 +0200 Subject: [PATCH] feat: add functional authentication --- api/.bruno/account/Create.bru | 25 +++++ api/.bruno/account/folder.bru | 8 ++ api/.bruno/auth/Code.bru | 28 +++++ api/.bruno/auth/Email.bru | 22 ++++ api/.bruno/auth/Session.bru | 15 +++ api/.bruno/auth/folder.bru | 8 ++ api/.bruno/bruno.json | 9 ++ api/.bruno/environments/localhost.bru | 3 + api/{tasks => .tasks}/bootstrap.ts | 4 +- api/{tasks => .tasks}/migrate.ts | 0 .../migrations/meta/_journal.json | 0 api/deno.json | 5 +- api/libraries/auth/auth.ts | 13 +-- .../database/{tasks => .tasks}/bootstrap.ts | 0 .../event-store/aggregates/organization.ts | 65 ----------- api/libraries/event-store/events/account.ts | 12 --- api/libraries/event-store/events/auditor.ts | 7 -- api/libraries/event-store/events/code.ts | 30 ------ api/libraries/event-store/events/strategy.ts | 13 --- api/libraries/event-store/mod.ts | 2 - api/libraries/read-store/.tasks/bootstrap.ts | 11 -- api/libraries/read-store/methods.ts | 35 ------ api/libraries/read-store/mod.ts | 2 - api/libraries/server/context.ts | 39 +++++-- api/libraries/server/modules.ts | 2 +- api/libraries/server/request.ts | 5 +- api/modules/auth/routes/authenticate.ts | 5 - api/routes/account/create.ts | 18 ++++ api/routes/auth/code.ts | 84 +++++++++++++++ api/routes/auth/email.ts | 27 +++++ api/routes/auth/password.ts | 36 +++++++ api/routes/auth/session.ts | 12 +++ api/server.ts | 6 +- api/stores/event-store/.tasks/bootstrap.ts | 5 + .../event-store/aggregates/account.ts | 101 ++++++++++++++---- .../event-store/aggregates/code.ts | 44 +++----- .../event-store/aggregates/role.ts | 0 .../event-store/event-store.ts | 5 +- api/stores/event-store/events/account.ts | 14 +++ api/stores/event-store/events/auditor.ts | 21 ++++ api/stores/event-store/events/code.ts | 18 ++++ .../event-store/events/mod.ts | 0 .../event-store/events/organization.ts | 4 +- .../event-store/events/role.ts | 8 +- api/stores/event-store/events/strategy.ts | 13 +++ .../event-store/projector.ts | 0 api/stores/read-store/.tasks/bootstrap.ts | 19 ++++ .../read-store/database.ts | 4 +- api/stores/read-store/methods.ts | 65 +++++++++++ deno.json | 7 +- deno.lock | 9 +- spec/modules/account/mod.ts | 5 - spec/modules/account/routes/create.ts | 5 - spec/modules/auth/mod.ts | 8 -- spec/modules/auth/routes/authenticate.ts | 9 -- spec/relay/libraries/client.ts | 57 ++++++---- spec/relay/libraries/route.ts | 77 ++++++------- spec/{modules => schemas}/README.md | 0 spec/{modules => schemas}/access/role.ts | 4 +- spec/{modules => schemas}/account/account.ts | 10 +- spec/schemas/account/errors.ts | 7 ++ spec/schemas/account/routes.ts | 20 ++++ .../account/strategies.ts | 0 spec/{modules => schemas}/auth/errors.ts | 0 spec/schemas/auth/routes.ts | 41 +++++++ spec/{modules => schemas}/auth/strategies.ts | 7 +- spec/{shared => schemas}/avatar.ts | 0 spec/{shared => schemas}/contact.ts | 0 spec/{shared => schemas}/database.ts | 0 spec/{shared => schemas}/email.ts | 0 spec/{shared => schemas}/name.ts | 0 spec/{modules => schemas}/package.json | 3 +- spec/shared/mod.ts | 5 - spec/shared/package.json | 13 --- 74 files changed, 763 insertions(+), 396 deletions(-) create mode 100644 api/.bruno/account/Create.bru create mode 100644 api/.bruno/account/folder.bru create mode 100644 api/.bruno/auth/Code.bru create mode 100644 api/.bruno/auth/Email.bru create mode 100644 api/.bruno/auth/Session.bru create mode 100644 api/.bruno/auth/folder.bru create mode 100644 api/.bruno/bruno.json create mode 100644 api/.bruno/environments/localhost.bru rename api/{tasks => .tasks}/bootstrap.ts (92%) rename api/{tasks => .tasks}/migrate.ts (100%) rename api/{tasks => .tasks}/migrations/meta/_journal.json (100%) rename api/libraries/database/{tasks => .tasks}/bootstrap.ts (100%) delete mode 100644 api/libraries/event-store/aggregates/organization.ts delete mode 100644 api/libraries/event-store/events/account.ts delete mode 100644 api/libraries/event-store/events/auditor.ts delete mode 100644 api/libraries/event-store/events/code.ts delete mode 100644 api/libraries/event-store/events/strategy.ts delete mode 100644 api/libraries/event-store/mod.ts delete mode 100644 api/libraries/read-store/.tasks/bootstrap.ts delete mode 100644 api/libraries/read-store/methods.ts delete mode 100644 api/libraries/read-store/mod.ts delete mode 100644 api/modules/auth/routes/authenticate.ts create mode 100644 api/routes/account/create.ts create mode 100644 api/routes/auth/code.ts create mode 100644 api/routes/auth/email.ts create mode 100644 api/routes/auth/password.ts create mode 100644 api/routes/auth/session.ts create mode 100644 api/stores/event-store/.tasks/bootstrap.ts rename api/{libraries => stores}/event-store/aggregates/account.ts (56%) rename api/{libraries => stores}/event-store/aggregates/code.ts (77%) rename api/{libraries => stores}/event-store/aggregates/role.ts (100%) rename api/{libraries => stores}/event-store/event-store.ts (80%) create mode 100644 api/stores/event-store/events/account.ts create mode 100644 api/stores/event-store/events/auditor.ts create mode 100644 api/stores/event-store/events/code.ts rename api/{libraries => stores}/event-store/events/mod.ts (100%) rename api/{libraries => stores}/event-store/events/organization.ts (70%) rename api/{libraries => stores}/event-store/events/role.ts (77%) create mode 100644 api/stores/event-store/events/strategy.ts rename api/{libraries => stores}/event-store/projector.ts (100%) create mode 100644 api/stores/read-store/.tasks/bootstrap.ts rename api/{libraries => stores}/read-store/database.ts (68%) create mode 100644 api/stores/read-store/methods.ts delete mode 100644 spec/modules/account/mod.ts delete mode 100644 spec/modules/account/routes/create.ts delete mode 100644 spec/modules/auth/mod.ts delete mode 100644 spec/modules/auth/routes/authenticate.ts rename spec/{modules => schemas}/README.md (100%) rename spec/{modules => schemas}/access/role.ts (70%) rename spec/{modules => schemas}/account/account.ts (61%) create mode 100644 spec/schemas/account/errors.ts create mode 100644 spec/schemas/account/routes.ts rename spec/{modules => schemas}/account/strategies.ts (100%) rename spec/{modules => schemas}/auth/errors.ts (100%) create mode 100644 spec/schemas/auth/routes.ts rename spec/{modules => schemas}/auth/strategies.ts (85%) rename spec/{shared => schemas}/avatar.ts (100%) rename spec/{shared => schemas}/contact.ts (100%) rename spec/{shared => schemas}/database.ts (100%) rename spec/{shared => schemas}/email.ts (100%) rename spec/{shared => schemas}/name.ts (100%) rename spec/{modules => schemas}/package.json (68%) delete mode 100644 spec/shared/mod.ts delete mode 100644 spec/shared/package.json diff --git a/api/.bruno/account/Create.bru b/api/.bruno/account/Create.bru new file mode 100644 index 0000000..cea1e27 --- /dev/null +++ b/api/.bruno/account/Create.bru @@ -0,0 +1,25 @@ +meta { + name: Create + type: http + seq: 1 +} + +post { + url: {{url}}/accounts + body: json + auth: inherit +} + +body:json { + { + "name": { + "given": "John", + "family": "Doe" + }, + "email": "john.doe@fixture.none" + } +} + +settings { + encodeUrl: true +} diff --git a/api/.bruno/account/folder.bru b/api/.bruno/account/folder.bru new file mode 100644 index 0000000..3d54050 --- /dev/null +++ b/api/.bruno/account/folder.bru @@ -0,0 +1,8 @@ +meta { + name: account + seq: 1 +} + +auth { + mode: inherit +} diff --git a/api/.bruno/auth/Code.bru b/api/.bruno/auth/Code.bru new file mode 100644 index 0000000..e17b3c0 --- /dev/null +++ b/api/.bruno/auth/Code.bru @@ -0,0 +1,28 @@ +meta { + name: Code + type: http + seq: 2 +} + +get { + url: {{url}}/auth/code/:accountId/code/:codeId/:value + body: none + auth: inherit +} + +params:path { + accountId: + codeId: + value: +} + +script:post-response { + const cookies = res.getHeader('set-cookie'); + if (cookies) { + bru.setVar("cookie", cookies.join('; ')); + } +} + +settings { + encodeUrl: true +} diff --git a/api/.bruno/auth/Email.bru b/api/.bruno/auth/Email.bru new file mode 100644 index 0000000..37c4c94 --- /dev/null +++ b/api/.bruno/auth/Email.bru @@ -0,0 +1,22 @@ +meta { + name: Email + type: http + seq: 1 +} + +post { + url: {{url}}/auth/email + body: json + auth: inherit +} + +body:json { + { + "base": "http://localhost:5170", + "email": "john.doe@fixture.none" + } +} + +settings { + encodeUrl: true +} diff --git a/api/.bruno/auth/Session.bru b/api/.bruno/auth/Session.bru new file mode 100644 index 0000000..cfbd550 --- /dev/null +++ b/api/.bruno/auth/Session.bru @@ -0,0 +1,15 @@ +meta { + name: Session + type: http + seq: 3 +} + +get { + url: {{url}}/auth/session + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/api/.bruno/auth/folder.bru b/api/.bruno/auth/folder.bru new file mode 100644 index 0000000..4394d36 --- /dev/null +++ b/api/.bruno/auth/folder.bru @@ -0,0 +1,8 @@ +meta { + name: auth + seq: 2 +} + +auth { + mode: inherit +} diff --git a/api/.bruno/bruno.json b/api/.bruno/bruno.json new file mode 100644 index 0000000..b2c9a3a --- /dev/null +++ b/api/.bruno/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "Valkyr", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/api/.bruno/environments/localhost.bru b/api/.bruno/environments/localhost.bru new file mode 100644 index 0000000..1f2c42d --- /dev/null +++ b/api/.bruno/environments/localhost.bru @@ -0,0 +1,3 @@ +vars { + url: http://localhost:8370/api/v1 +} diff --git a/api/tasks/bootstrap.ts b/api/.tasks/bootstrap.ts similarity index 92% rename from api/tasks/bootstrap.ts rename to api/.tasks/bootstrap.ts index c1501c0..a754497 100644 --- a/api/tasks/bootstrap.ts +++ b/api/.tasks/bootstrap.ts @@ -3,6 +3,7 @@ import { resolve } from "node:path"; import { logger } from "~libraries/logger/mod.ts"; const LIBRARIES_DIR = resolve(import.meta.dirname!, "..", "libraries"); +const STORES_DIR = resolve(import.meta.dirname!, "..", "stores"); const log = logger.prefix("Bootstrap"); @@ -12,7 +13,7 @@ const log = logger.prefix("Bootstrap"); |-------------------------------------------------------------------------------- */ -await import("~libraries/database/tasks/bootstrap.ts"); +await import("~libraries/database/.tasks/bootstrap.ts"); /* |-------------------------------------------------------------------------------- @@ -21,6 +22,7 @@ await import("~libraries/database/tasks/bootstrap.ts"); */ await bootstrap(LIBRARIES_DIR); +await bootstrap(STORES_DIR); /* |-------------------------------------------------------------------------------- diff --git a/api/tasks/migrate.ts b/api/.tasks/migrate.ts similarity index 100% rename from api/tasks/migrate.ts rename to api/.tasks/migrate.ts diff --git a/api/tasks/migrations/meta/_journal.json b/api/.tasks/migrations/meta/_journal.json similarity index 100% rename from api/tasks/migrations/meta/_journal.json rename to api/.tasks/migrations/meta/_journal.json diff --git a/api/deno.json b/api/deno.json index 45c14aa..4f86b67 100644 --- a/api/deno.json +++ b/api/deno.json @@ -1,6 +1,7 @@ { "imports": { - "~config": "./config.ts", - "~libraries/": "./libraries/" + "~libraries/": "./libraries/", + "~stores/": "./stores/", + "~config": "./config.ts" } } \ No newline at end of file diff --git a/api/libraries/auth/auth.ts b/api/libraries/auth/auth.ts index b6a5872..74ecbfb 100644 --- a/api/libraries/auth/auth.ts +++ b/api/libraries/auth/auth.ts @@ -1,7 +1,7 @@ import { Auth, ResolvedSession } from "@valkyr/auth"; import z from "zod"; -import { db } from "~libraries/read-store/database.ts"; +import { db } from "~stores/read-store/database.ts"; import { config } from "./config.ts"; @@ -11,18 +11,13 @@ export const auth = new Auth( algorithm: "RS256", privateKey: config.privateKey, publicKey: config.publicKey, - issuer: "https://balto.health", - audience: "https://balto.health", + issuer: "http://localhost", + audience: "http://localhost", }, session: z.object({ accountId: z.string(), }), - permissions: { - admin: ["create", "read", "update", "delete"], - organization: ["create", "read", "update", "delete"], - consultant: ["create", "read", "update", "delete"], - task: ["create", "update", "read", "delete"], - } as const, + permissions: {} as const, guards: [], }, { diff --git a/api/libraries/database/tasks/bootstrap.ts b/api/libraries/database/.tasks/bootstrap.ts similarity index 100% rename from api/libraries/database/tasks/bootstrap.ts rename to api/libraries/database/.tasks/bootstrap.ts diff --git a/api/libraries/event-store/aggregates/organization.ts b/api/libraries/event-store/aggregates/organization.ts deleted file mode 100644 index d561cff..0000000 --- a/api/libraries/event-store/aggregates/organization.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store"; - -import { db } from "~libraries/read-store/mod.ts"; - -import { Auditor } from "../events/auditor.ts"; -import { EventStoreFactory } from "../events/mod.ts"; -import { projector } from "../projector.ts"; - -export class Organization extends AggregateRoot { - static override readonly name = "organization"; - - id!: string; - - name!: string; - - createdAt!: Date; - updatedAt!: Date; - - // ------------------------------------------------------------------------- - // Factories - // ------------------------------------------------------------------------- - - static #reducer = makeAggregateReducer(Organization); - - static create(name: string, meta: Auditor): Organization { - return new Organization().push({ - type: "organization:created", - data: { name }, - meta, - }); - } - - static async getById(stream: string): Promise { - return this.$store.reduce({ name: "organization", stream, reducer: this.#reducer }); - } - - // ------------------------------------------------------------------------- - // Reducer - // ------------------------------------------------------------------------- - - with(event: EventStoreFactory["$events"][number]["$record"]): void { - switch (event.type) { - case "organization:created": { - this.id = event.stream; - this.name = event.data.name; - this.createdAt = getDate(event.created); - break; - } - } - } -} - -/* - |-------------------------------------------------------------------------------- - | Projectors - |-------------------------------------------------------------------------------- - */ - -projector.on("organization:created", async ({ stream: id, data: { name }, created }) => { - await db.collection("organizations").insertOne({ - id, - name, - createdAt: getDate(created), - }); -}); diff --git a/api/libraries/event-store/events/account.ts b/api/libraries/event-store/events/account.ts deleted file mode 100644 index ae6a351..0000000 --- a/api/libraries/event-store/events/account.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { EmailSchema, NameSchema } from "@spec/shared"; -import { event } from "@valkyr/event-store"; -import z from "zod"; - -import { auditor } from "./auditor.ts"; - -export default [ - event.type("account:avatar:added").data(z.string()).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), -]; diff --git a/api/libraries/event-store/events/auditor.ts b/api/libraries/event-store/events/auditor.ts deleted file mode 100644 index 9819416..0000000 --- a/api/libraries/event-store/events/auditor.ts +++ /dev/null @@ -1,7 +0,0 @@ -import z from "zod"; - -export const auditor = z.object({ - accountId: z.string(), -}); - -export type Auditor = z.infer; diff --git a/api/libraries/event-store/events/code.ts b/api/libraries/event-store/events/code.ts deleted file mode 100644 index cdca4d7..0000000 --- a/api/libraries/event-store/events/code.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { event } from "@valkyr/event-store"; -import z from "zod"; - -const identity = z.discriminatedUnion([ - z.object({ - type: z.literal("admin"), - accountId: z.string(), - }), - z.object({ - type: z.literal("consultant"), - accountId: z.string(), - }), - z.object({ - type: z.literal("organization"), - organizationId: z.string(), - accountId: z.string(), - }), -]); - -export default [ - event.type("code:created").data( - z.object({ - value: z.string(), - identity, - }), - ), - event.type("code:claimed"), -]; - -export type CodeIdentity = z.infer; diff --git a/api/libraries/event-store/events/strategy.ts b/api/libraries/event-store/events/strategy.ts deleted file mode 100644 index b21d12e..0000000 --- a/api/libraries/event-store/events/strategy.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { event } from "@valkyr/event-store"; -import z from "zod"; - -import { auditor } from "./auditor.ts"; - -export default [ - event.type("strategy:email:added").data(z.string()).meta(auditor), - event.type("strategy:passkey:added").meta(auditor), - event - .type("strategy:password:added") - .data(z.object({ alias: z.string(), password: z.string() })) - .meta(auditor), -]; diff --git a/api/libraries/event-store/mod.ts b/api/libraries/event-store/mod.ts deleted file mode 100644 index 23cc215..0000000 --- a/api/libraries/event-store/mod.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./event-store.ts"; -export * from "./projector.ts"; diff --git a/api/libraries/read-store/.tasks/bootstrap.ts b/api/libraries/read-store/.tasks/bootstrap.ts deleted file mode 100644 index 32ddac9..0000000 --- a/api/libraries/read-store/.tasks/bootstrap.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { idIndex } from "~libraries/database/id.ts"; -import { register } from "~libraries/database/registrar.ts"; - -import { db } from "../database.ts"; - -await register(db.db, [ - { - name: "accounts", - indexes: [idIndex], - }, -]); diff --git a/api/libraries/read-store/methods.ts b/api/libraries/read-store/methods.ts deleted file mode 100644 index 0d579a6..0000000 --- a/api/libraries/read-store/methods.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 7327492..0000000 --- a/api/libraries/read-store/mod.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./database.ts"; -export * from "./methods.ts"; diff --git a/api/libraries/server/context.ts b/api/libraries/server/context.ts index d77d389..5f40a10 100644 --- a/api/libraries/server/context.ts +++ b/api/libraries/server/context.ts @@ -1,16 +1,41 @@ -import { RouteContext } from "@spec/relay"; +import { ServerContext, UnauthorizedError } from "@spec/relay"; -export function getRequestContext(request: Request): RouteContext { - return { - request, - }; -} +import { Session } from "../auth/auth.ts"; +import { req } from "./request.ts"; declare module "@spec/relay" { - interface RouteContext { + interface ServerContext { /** * Current request instance being handled. */ request: Request; + + /** + * Get request session instance. + */ + session: Session; + + /** + * Get account id from session, throws an error if the request + * does not have a valid session. + */ + accountId: string; } } + +export function getRequestContext(request: Request): ServerContext { + return { + request, + + get session(): Session { + if (req.session === undefined) { + throw new UnauthorizedError(); + } + return req.session; + }, + + get accountId() { + return this.session.accountId; + }, + }; +} diff --git a/api/libraries/server/modules.ts b/api/libraries/server/modules.ts index bc3752e..c5bbee0 100644 --- a/api/libraries/server/modules.ts +++ b/api/libraries/server/modules.ts @@ -14,7 +14,7 @@ import { Route } from "@spec/relay"; export async function resolveRoutes(path: string, routes: Route[] = []): Promise { for await (const entry of Deno.readDir(path)) { if (entry.isDirectory === true) { - await loadRoutes(`${path}/${entry.name}/routes`, routes, [name]); + await loadRoutes(`${path}/${entry.name}`, routes, [name]); } } return routes; diff --git a/api/libraries/server/request.ts b/api/libraries/server/request.ts index c6d4678..7b5a425 100644 --- a/api/libraries/server/request.ts +++ b/api/libraries/server/request.ts @@ -1,3 +1,4 @@ +import { Session } from "../auth/auth.ts"; import { asyncLocalStorage } from "./storage.ts"; export const req = { @@ -24,14 +25,14 @@ export const req = { /** * Check if the request is authenticated. */ - get isAuthenticated() { + get isAuthenticated(): boolean { return this.session !== undefined; }, /** * Get current session. */ - get session() { + get session(): Session | undefined { return this.store.session; }, diff --git a/api/modules/auth/routes/authenticate.ts b/api/modules/auth/routes/authenticate.ts deleted file mode 100644 index 92ca136..0000000 --- a/api/modules/auth/routes/authenticate.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { authenticate } from "@spec/modules/auth/routes/authenticate.ts"; - -export default authenticate.access("public").handle(async ({ body }) => { - console.log({ body }); -}); diff --git a/api/routes/account/create.ts b/api/routes/account/create.ts new file mode 100644 index 0000000..60c6bd0 --- /dev/null +++ b/api/routes/account/create.ts @@ -0,0 +1,18 @@ +import { AccountEmailClaimedError } from "@spec/schemas/account/errors.ts"; +import { create } from "@spec/schemas/account/routes.ts"; + +import { Account, isEmailClaimed } from "~stores/event-store/aggregates/account.ts"; +import { eventStore } from "~stores/event-store/event-store.ts"; + +export default create.access("public").handle(async ({ body: { name, email } }) => { + if ((await isEmailClaimed(email)) === true) { + return new AccountEmailClaimedError(email); + } + return eventStore.aggregate + .from(Account) + .create() + .addName(name) + .addEmailStrategy(email) + .save() + .then((account) => account.id); +}); diff --git a/api/routes/auth/code.ts b/api/routes/auth/code.ts new file mode 100644 index 0000000..b89492c --- /dev/null +++ b/api/routes/auth/code.ts @@ -0,0 +1,84 @@ +import { code } from "@spec/schemas/auth/routes.ts"; +import cookie from "cookie"; + +import { auth, config } from "~libraries/auth/mod.ts"; +import { logger } from "~libraries/logger/mod.ts"; +import { Account } from "~stores/event-store/aggregates/account.ts"; +import { Code } from "~stores/event-store/aggregates/code.ts"; +import { eventStore } from "~stores/event-store/event-store.ts"; + +export default code.access("public").handle(async ({ params: { accountId, codeId, value }, query: { next } }) => { + const code = await eventStore.aggregate.getByStream(Code, codeId); + + if (code === undefined) { + return logger.info({ + type: "code:claimed", + session: false, + message: "Invalid Code ID", + received: codeId, + }); + } + + if (code.claimedAt !== undefined) { + return logger.info({ + type: "code:claimed", + session: false, + message: "Code Already Claimed", + received: codeId, + }); + } + + await code.claim().save(); + + if (code.value !== value) { + return logger.info({ + type: "code:claimed", + session: false, + message: "Invalid Value", + expected: code.value, + received: value, + }); + } + + if (code.identity.accountId !== accountId) { + return logger.info({ + type: "code:claimed", + session: false, + message: "Invalid Account ID", + expected: code.identity.accountId, + received: accountId, + }); + } + + const account = await eventStore.aggregate.getByStream(Account, accountId); + if (account === undefined) { + return logger.info({ + type: "code:claimed", + session: false, + message: "Account Not Found", + expected: code.identity.accountId, + received: undefined, + }); + } + + logger.info({ type: "code:claimed", session: true }); + + const options = config.cookie(1000 * 60 * 60 * 24 * 7); + + if (next !== undefined) { + return new Response(null, { + status: 302, + headers: { + location: next, + "set-cookie": cookie.serialize("token", await auth.generate({ accountId: account.id }, "1 week"), options), + }, + }); + } + + return new Response(null, { + status: 200, + headers: { + "set-cookie": cookie.serialize("token", await auth.generate({ accountId: account.id }, "1 week"), options), + }, + }); +}); diff --git a/api/routes/auth/email.ts b/api/routes/auth/email.ts new file mode 100644 index 0000000..29fcc64 --- /dev/null +++ b/api/routes/auth/email.ts @@ -0,0 +1,27 @@ +import { email } from "@spec/schemas/auth/routes.ts"; + +import { logger } from "~libraries/logger/mod.ts"; +import { Account, getAccountEmailRelation } from "~stores/event-store/aggregates/account.ts"; +import { Code } from "~stores/event-store/aggregates/code.ts"; +import { eventStore } from "~stores/event-store/event-store.ts"; + +export default email.access("public").handle(async ({ body: { base, email } }) => { + const account = await eventStore.aggregate.getByRelation(Account, getAccountEmailRelation(email)); + if (account === undefined) { + return logger.info({ + type: "auth:email", + code: false, + message: "Account Not Found", + received: email, + }); + } + const code = await eventStore.aggregate.from(Code).create({ accountId: account.id }).save(); + logger.info({ + type: "auth:email", + data: { + code: code.id, + accountId: account.id, + }, + link: `${base}/api/v1/admin/auth/${account.id}/code/${code.id}/${code.value}?next=${base}/admin`, + }); +}); diff --git a/api/routes/auth/password.ts b/api/routes/auth/password.ts new file mode 100644 index 0000000..e6afb09 --- /dev/null +++ b/api/routes/auth/password.ts @@ -0,0 +1,36 @@ +import { BadRequestError } from "@spec/relay"; +import { password as route } from "@spec/schemas/auth/routes.ts"; +import cookie from "cookie"; + +import { config } from "~config"; +import { auth } from "~libraries/auth/mod.ts"; +import { password } from "~libraries/crypto/mod.ts"; +import { logger } from "~libraries/logger/mod.ts"; +import { getPasswordStrategyByAlias } from "~stores/read-store/methods.ts"; + +export default route.handle(async ({ body: { alias, password: userPassword } }) => { + const strategy = await getPasswordStrategyByAlias(alias); + if (strategy === undefined) { + return logger.info({ + type: "auth:password", + message: "Failed to get account with 'password' strategy.", + alias, + }); + } + + const isValidPassword = await password.verify(userPassword, strategy.password); + if (isValidPassword === false) { + return new BadRequestError("Invalid email/password provided."); + } + + return new Response(null, { + status: 204, + headers: { + "set-cookie": cookie.serialize( + "token", + await auth.generate({ accountId: strategy.accountId }, "1 week"), + config.cookie(1000 * 60 * 60 * 24 * 7), + ), + }, + }); +}); diff --git a/api/routes/auth/session.ts b/api/routes/auth/session.ts new file mode 100644 index 0000000..6d30732 --- /dev/null +++ b/api/routes/auth/session.ts @@ -0,0 +1,12 @@ +import { UnauthorizedError } from "@spec/relay/mod.ts"; +import { session } from "@spec/schemas/auth/routes.ts"; + +import { getAccountById } from "~stores/read-store/methods.ts"; + +export default session.access("session").handle(async ({ accountId }) => { + const account = await getAccountById(accountId); + if (account === undefined) { + return new UnauthorizedError(); + } + return account; +}); diff --git a/api/server.ts b/api/server.ts index da850ab..53ad0a1 100644 --- a/api/server.ts +++ b/api/server.ts @@ -8,7 +8,7 @@ import { Api, resolveRoutes } from "~libraries/server/mod.ts"; import { config } from "./config.ts"; -const MODULES_DIR = resolve(import.meta.dirname!, "modules"); +const ROUTES_DIR = resolve(import.meta.dirname!, "routes"); const log = logger.prefix("Server"); @@ -18,7 +18,7 @@ const log = logger.prefix("Server"); |-------------------------------------------------------------------------------- */ -await import("./tasks/bootstrap.ts"); +await import("./.tasks/bootstrap.ts"); /* |-------------------------------------------------------------------------------- @@ -26,7 +26,7 @@ await import("./tasks/bootstrap.ts"); |-------------------------------------------------------------------------------- */ -const api = new Api(await resolveRoutes(MODULES_DIR)); +const api = new Api(await resolveRoutes(ROUTES_DIR)); /* |-------------------------------------------------------------------------------- diff --git a/api/stores/event-store/.tasks/bootstrap.ts b/api/stores/event-store/.tasks/bootstrap.ts new file mode 100644 index 0000000..9c728a3 --- /dev/null +++ b/api/stores/event-store/.tasks/bootstrap.ts @@ -0,0 +1,5 @@ +import { register } from "@valkyr/event-store/mongo"; + +import { eventStore } from "../event-store.ts"; + +await register(eventStore.db.db, console.info); diff --git a/api/libraries/event-store/aggregates/account.ts b/api/stores/event-store/aggregates/account.ts similarity index 56% rename from api/libraries/event-store/aggregates/account.ts rename to api/stores/event-store/aggregates/account.ts index a7918f9..998e8f1 100644 --- a/api/libraries/event-store/aggregates/account.ts +++ b/api/stores/event-store/aggregates/account.ts @@ -1,11 +1,15 @@ -import { Strategy } from "@spec/modules/account/strategies.ts"; -import { Avatar, Contact, Email, Name } from "@spec/shared"; +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 "~libraries/read-store/mod.ts"; +import { db } from "~stores/read-store/database.ts"; import { eventStore } from "../event-store.ts"; -import { Auditor } from "../events/auditor.ts"; +import { Auditor, systemAuditor } from "../events/auditor.ts"; import { EventStoreFactory } from "../events/mod.ts"; import { projector } from "../projector.ts"; @@ -28,6 +32,10 @@ export class Account extends AggregateRoot { 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); @@ -60,7 +68,15 @@ export class Account extends AggregateRoot { // Actions // ------------------------------------------------------------------------- - addAvatar(url: string, meta: Auditor): this { + 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", @@ -69,7 +85,7 @@ export class Account extends AggregateRoot { }); } - addName(name: Name, meta: Auditor): this { + addName(name: Name, meta: Auditor = systemAuditor): this { return this.push({ stream: this.id, type: "account:name:added", @@ -78,7 +94,7 @@ export class Account extends AggregateRoot { }); } - addEmail(email: Email, meta: Auditor): this { + addEmail(email: Email, meta: Auditor = systemAuditor): this { return this.push({ stream: this.id, type: "account:email:added", @@ -87,7 +103,7 @@ export class Account extends AggregateRoot { }); } - addRole(roleId: string, meta: Auditor): this { + addRole(roleId: string, meta: Auditor = systemAuditor): this { return this.push({ stream: this.id, type: "account:role:added", @@ -96,7 +112,7 @@ export class Account extends AggregateRoot { }); } - addEmailStrategy(email: string, meta: Auditor): this { + addEmailStrategy(email: string, meta: Auditor = systemAuditor): this { return this.push({ stream: this.id, type: "strategy:email:added", @@ -105,7 +121,7 @@ export class Account extends AggregateRoot { }); } - addPasswordStrategy(alias: string, password: string, meta: Auditor): this { + addPasswordStrategy(alias: string, password: string, meta: Auditor = systemAuditor): this { return this.push({ stream: this.id, type: "strategy:password:added", @@ -115,38 +131,79 @@ export class Account extends AggregateRoot { } } +/* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + +export async function isEmailClaimed(email: string): Promise { + 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 } } }, { upsert: true }); + 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 } }, { upsert: true }); + 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 } }, { upsert: true }); + 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 } }, { upsert: true }); + await db.collection("accounts").updateOne({ id }, { $push: { roles: roleId } }); }); projector.on("strategy:email:added", async ({ stream: id, data: email }) => { - await eventStore.relations.insert(`account:email:${email}`, id); - await db - .collection("accounts") - .updateOne({ id }, { $push: { strategies: { type: "email", value: email } } }, { upsert: true }); + 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(`account:alias:${strategy.alias}`, id); - await db - .collection("accounts") - .updateOne({ id }, { $push: { strategies: { type: "password", ...strategy } } }, { upsert: true }); + await eventStore.relations.insert(getAccountAliasRelation(strategy.alias), id); + await db.collection("accounts").updateOne({ id }, { $push: { strategies: { type: "password", ...strategy } } }); }); diff --git a/api/libraries/event-store/aggregates/code.ts b/api/stores/event-store/aggregates/code.ts similarity index 77% rename from api/libraries/event-store/aggregates/code.ts rename to api/stores/event-store/aggregates/code.ts index efd315a..067f5e6 100644 --- a/api/libraries/event-store/aggregates/code.ts +++ b/api/stores/event-store/aggregates/code.ts @@ -1,4 +1,4 @@ -import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store"; +import { AggregateRoot, getDate } from "@valkyr/event-store"; import { CodeIdentity } from "../events/code.ts"; import { EventStoreFactory } from "../events/mod.ts"; @@ -6,8 +6,6 @@ import { EventStoreFactory } from "../events/mod.ts"; export class Code extends AggregateRoot { static override readonly name = "code"; - id!: string; - identity!: CodeIdentity; value!: string; @@ -15,32 +13,9 @@ export class Code extends AggregateRoot { claimedAt?: Date; // ------------------------------------------------------------------------- - // Factories + // Accessors // ------------------------------------------------------------------------- - static #reducer = makeAggregateReducer(Code); - - static create(identity: CodeIdentity): Code { - return new Code().push({ - type: "code:created", - data: { - identity, - value: crypto - .getRandomValues(new Uint8Array(5)) - .map((v) => v % 10) - .join(""), - }, - }); - } - - static async getById(stream: string): Promise { - return this.$store.reduce({ - name: "code", - stream, - reducer: this.#reducer, - }); - } - get isClaimed(): boolean { return this.claimedAt !== undefined; } @@ -52,7 +27,6 @@ export class Code extends AggregateRoot { with(event: EventStoreFactory["$events"][number]["$record"]): void { switch (event.type) { case "code:created": { - this.id = event.stream; this.value = event.data.value; this.identity = event.data.identity; this.createdAt = getDate(event.created); @@ -69,6 +43,20 @@ export class Code extends AggregateRoot { // 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", diff --git a/api/libraries/event-store/aggregates/role.ts b/api/stores/event-store/aggregates/role.ts similarity index 100% rename from api/libraries/event-store/aggregates/role.ts rename to api/stores/event-store/aggregates/role.ts diff --git a/api/libraries/event-store/event-store.ts b/api/stores/event-store/event-store.ts similarity index 80% rename from api/libraries/event-store/event-store.ts rename to api/stores/event-store/event-store.ts index 5c5a315..5d4cfb9 100644 --- a/api/libraries/event-store/event-store.ts +++ b/api/stores/event-store/event-store.ts @@ -1,16 +1,15 @@ import { EventStore } from "@valkyr/event-store"; import { MongoAdapter } from "@valkyr/event-store/mongo"; +import { config } from "~config"; import { container } from "~libraries/database/container.ts"; -import { aggregates } from "./aggregates/mod.ts"; import { events } from "./events/mod.ts"; import { projector } from "./projector.ts"; export const eventStore = new EventStore({ - adapter: new MongoAdapter(() => container.get("client"), "balto:event-store"), + adapter: new MongoAdapter(() => container.get("client"), `${config.name}:event-store`), events, - aggregates, snapshot: "auto", }); diff --git a/api/stores/event-store/events/account.ts b/api/stores/event-store/events/account.ts new file mode 100644 index 0000000..534dfa1 --- /dev/null +++ b/api/stores/event-store/events/account.ts @@ -0,0 +1,14 @@ +import { EmailSchema } from "@spec/schemas/email.ts"; +import { NameSchema } from "@spec/schemas/name.ts"; +import { event } from "@valkyr/event-store"; +import z from "zod"; + +import { AuditorSchema } from "./auditor.ts"; + +export default [ + event.type("account:created").meta(AuditorSchema), + event.type("account:avatar:added").data(z.string()).meta(AuditorSchema), + event.type("account:name:added").data(NameSchema).meta(AuditorSchema), + event.type("account:email:added").data(EmailSchema).meta(AuditorSchema), + event.type("account:role:added").data(z.string()).meta(AuditorSchema), +]; diff --git a/api/stores/event-store/events/auditor.ts b/api/stores/event-store/events/auditor.ts new file mode 100644 index 0000000..f55bc80 --- /dev/null +++ b/api/stores/event-store/events/auditor.ts @@ -0,0 +1,21 @@ +import z from "zod"; + +export const AuditorSchema = z.object({ + auditor: z.union([ + z.object({ + type: z.literal("system"), + }), + z.object({ + type: z.literal("account"), + accountId: z.string(), + }), + ]), +}); + +export const systemAuditor: Auditor = { + auditor: { + type: "system", + }, +}; + +export type Auditor = z.infer; diff --git a/api/stores/event-store/events/code.ts b/api/stores/event-store/events/code.ts new file mode 100644 index 0000000..7dd6964 --- /dev/null +++ b/api/stores/event-store/events/code.ts @@ -0,0 +1,18 @@ +import { event } from "@valkyr/event-store"; +import z from "zod"; + +const CodeIdentitySchema = z.object({ + accountId: z.string(), +}); + +export default [ + event.type("code:created").data( + z.object({ + identity: CodeIdentitySchema, + value: z.string(), + }), + ), + event.type("code:claimed"), +]; + +export type CodeIdentity = z.infer; diff --git a/api/libraries/event-store/events/mod.ts b/api/stores/event-store/events/mod.ts similarity index 100% rename from api/libraries/event-store/events/mod.ts rename to api/stores/event-store/events/mod.ts diff --git a/api/libraries/event-store/events/organization.ts b/api/stores/event-store/events/organization.ts similarity index 70% rename from api/libraries/event-store/events/organization.ts rename to api/stores/event-store/events/organization.ts index cea6003..99b9981 100644 --- a/api/libraries/event-store/events/organization.ts +++ b/api/stores/event-store/events/organization.ts @@ -1,11 +1,11 @@ import { event } from "@valkyr/event-store"; import z from "zod"; -import { auditor } from "./auditor.ts"; +import { AuditorSchema } from "./auditor.ts"; export default [ event .type("organization:created") .data(z.object({ name: z.string() })) - .meta(auditor), + .meta(AuditorSchema), ]; diff --git a/api/libraries/event-store/events/role.ts b/api/stores/event-store/events/role.ts similarity index 77% rename from api/libraries/event-store/events/role.ts rename to api/stores/event-store/events/role.ts index e4239ce..957148c 100644 --- a/api/libraries/event-store/events/role.ts +++ b/api/stores/event-store/events/role.ts @@ -1,7 +1,7 @@ import { event } from "@valkyr/event-store"; import z from "zod"; -import { auditor } from "./auditor.ts"; +import { AuditorSchema } from "./auditor.ts"; const CreatedSchema = z.object({ name: z.string(), @@ -27,9 +27,9 @@ const OperationSchema = z.discriminatedUnion("type", [ ]); export default [ - 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(OperationSchema)).meta(auditor), + event.type("role:created").data(CreatedSchema).meta(AuditorSchema), + event.type("role:name-set").data(z.string()).meta(AuditorSchema), + event.type("role:permissions-set").data(z.array(OperationSchema)).meta(AuditorSchema), ]; export type RoleCreatedData = z.infer; diff --git a/api/stores/event-store/events/strategy.ts b/api/stores/event-store/events/strategy.ts new file mode 100644 index 0000000..61b88b7 --- /dev/null +++ b/api/stores/event-store/events/strategy.ts @@ -0,0 +1,13 @@ +import { event } from "@valkyr/event-store"; +import z from "zod"; + +import { AuditorSchema } from "./auditor.ts"; + +export default [ + event.type("strategy:email:added").data(z.string()).meta(AuditorSchema), + event.type("strategy:passkey:added").meta(AuditorSchema), + event + .type("strategy:password:added") + .data(z.object({ alias: z.string(), password: z.string() })) + .meta(AuditorSchema), +]; diff --git a/api/libraries/event-store/projector.ts b/api/stores/event-store/projector.ts similarity index 100% rename from api/libraries/event-store/projector.ts rename to api/stores/event-store/projector.ts diff --git a/api/stores/read-store/.tasks/bootstrap.ts b/api/stores/read-store/.tasks/bootstrap.ts new file mode 100644 index 0000000..d5c4dc2 --- /dev/null +++ b/api/stores/read-store/.tasks/bootstrap.ts @@ -0,0 +1,19 @@ +import { idIndex } from "~libraries/database/id.ts"; +import { register } from "~libraries/database/registrar.ts"; + +import { db } from "../database.ts"; + +await register(db.db, [ + { + name: "accounts", + indexes: [ + idIndex, + [{ "strategies.type": 1, "strategies.alias": 1 }, { name: "strategy.password" }], + [{ "strategies.type": 1, "strategies.value": 1 }, { name: "strategy.email" }], + ], + }, + { + name: "roles", + indexes: [idIndex, [{ name: 1 }, { name: "role.name" }]], + }, +]); diff --git a/api/libraries/read-store/database.ts b/api/stores/read-store/database.ts similarity index 68% rename from api/libraries/read-store/database.ts rename to api/stores/read-store/database.ts index f0b0691..6fbb919 100644 --- a/api/libraries/read-store/database.ts +++ b/api/stores/read-store/database.ts @@ -1,10 +1,12 @@ -import type { AccountDocument } from "@spec/modules/account/account.ts"; +import { RoleDocument } from "@spec/schemas/access/role.ts"; +import type { AccountDocument } from "@spec/schemas/account/account.ts"; import { config } from "~config"; import { getDatabaseAccessor } from "~libraries/database/accessor.ts"; export const db = getDatabaseAccessor<{ accounts: AccountDocument; + roles: RoleDocument; }>(`${config.name}:read-store`); export function takeOne(documents: TDocument[]): TDocument | undefined { diff --git a/api/stores/read-store/methods.ts b/api/stores/read-store/methods.ts new file mode 100644 index 0000000..5e1062e --- /dev/null +++ b/api/stores/read-store/methods.ts @@ -0,0 +1,65 @@ +import { type Account, fromAccountDocument } from "@spec/schemas/account/account.ts"; +import { PasswordStrategy } from "@spec/schemas/auth/strategies.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(fromAccountDocument) + .then(takeOne); +} + +/* + |-------------------------------------------------------------------------------- + | Auth + |-------------------------------------------------------------------------------- + */ + +/** + * Get strategy details for the given password strategy alias. + * + * @param alias - Alias to get strategy for. + */ +export async function getPasswordStrategyByAlias( + alias: string, +): Promise<({ accountId: string } & PasswordStrategy) | undefined> { + const account = await db.collection("accounts").findOne({ + strategies: { + $elemMatch: { type: "password", alias }, + }, + }); + if (account === null) { + return undefined; + } + const strategy = account.strategies.find((strategy) => strategy.type === "password" && strategy.alias === alias); + if (strategy === undefined) { + return undefined; + } + return { accountId: account.id, ...strategy } as { accountId: string } & PasswordStrategy; +} diff --git a/deno.json b/deno.json index d87e2a5..c3565f5 100644 --- a/deno.json +++ b/deno.json @@ -4,10 +4,13 @@ "workspace": [ "api", "apps/react", - "spec/modules", "spec/relay", - "spec/shared" + "spec/schemas" ], + "imports": { + "@spec/relay/": "./spec/relay/", + "@spec/schemas/": "./spec/schemas/" + }, "tasks": { "start:api": { "command": "cd ./api && deno run start", diff --git a/deno.lock b/deno.lock index 81ea38d..45a7c2e 100644 --- a/deno.lock +++ b/deno.lock @@ -1771,13 +1771,6 @@ ] } }, - "spec/modules": { - "packageJson": { - "dependencies": [ - "npm:zod@4" - ] - } - }, "spec/relay": { "packageJson": { "dependencies": [ @@ -1786,7 +1779,7 @@ ] } }, - "spec/shared": { + "spec/schemas": { "packageJson": { "dependencies": [ "npm:zod@4" diff --git a/spec/modules/account/mod.ts b/spec/modules/account/mod.ts deleted file mode 100644 index 5fddec5..0000000 --- a/spec/modules/account/mod.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 5cb9a4e..0000000 --- a/spec/modules/account/routes/create.ts +++ /dev/null @@ -1,5 +0,0 @@ -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/auth/mod.ts b/spec/modules/auth/mod.ts deleted file mode 100644 index 1412c22..0000000 --- a/spec/modules/auth/mod.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { authenticate } from "./routes/authenticate.ts"; - -export * from "./errors.ts"; -export * from "./strategies.ts"; - -export const routes = { - authenticate, -}; diff --git a/spec/modules/auth/routes/authenticate.ts b/spec/modules/auth/routes/authenticate.ts deleted file mode 100644 index ceb0fbc..0000000 --- a/spec/modules/auth/routes/authenticate.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { route } from "@spec/relay"; - -import { AuthenticationStrategyPayloadError } from "../errors.ts"; -import { StrategyPayloadSchema } from "../strategies.ts"; - -export const authenticate = route - .post("/api/v1/authenticate") - .body(StrategyPayloadSchema) - .errors([AuthenticationStrategyPayloadError]); diff --git a/spec/relay/libraries/client.ts b/spec/relay/libraries/client.ts index 34c1b98..075b6c1 100644 --- a/spec/relay/libraries/client.ts +++ b/spec/relay/libraries/client.ts @@ -1,4 +1,6 @@ -import z, { ZodType } from "zod"; +/* eslint-disable @typescript-eslint/no-empty-object-type */ + +import z, { ZodObject, ZodType } from "zod"; import type { RelayAdapter, RelayInput, RelayResponse } from "./adapter.ts"; import { Route, type Routes } from "./route.ts"; @@ -45,7 +47,7 @@ function getNestedRoute(config: Config, routes: TRoutes) } function getRouteFn(route: Route, { adapter }: Config) { - return async (options: any) => { + return async (options: any = {}) => { const input: RelayInput = { method: route.state.method, endpoint: route.state.path, @@ -146,34 +148,45 @@ type RelayRequest = { type RelayRoutes = { [TKey in keyof TRoutes]: TRoutes[TKey] extends Route - ? (( - payload: OmitNever<{ - params: TRoutes[TKey]["$params"]; - query: TRoutes[TKey]["$query"]; - body: TRoutes[TKey]["$body"]; - headers?: Headers; - }>, - ) => Promise, RelayRouteErrors>>) & { - $params: TRoutes[TKey]["$params"]; - $query: TRoutes[TKey]["$query"]; - $body: TRoutes[TKey]["$body"]; - $response: TRoutes[TKey]["$response"]; - } + ? HasPayload extends true + ? ( + payload: Prettify< + (TRoutes[TKey]["state"]["params"] extends ZodObject ? { params: TRoutes[TKey]["$params"] } : {}) & + (TRoutes[TKey]["state"]["query"] extends ZodObject ? { query: TRoutes[TKey]["$query"] } : {}) & + (TRoutes[TKey]["state"]["body"] extends ZodType ? { body: TRoutes[TKey]["$body"] } : {}) & { + headers?: HeadersInit; + } + >, + ) => RouteResponse + : (payload?: { headers: HeadersInit }) => RouteResponse : TRoutes[TKey] extends Routes - ? RelayClient + ? RelayRoutes : never; }; -type RelayRouteResponse = TRoute["state"]["output"] extends ZodType +type HasPayload = TRoute["state"]["params"] extends ZodObject + ? true + : TRoute["state"]["query"] extends ZodObject + ? true + : TRoute["state"]["body"] extends ZodType + ? true + : false; + +type RouteResponse = Promise, RouteErrors>> & { + $params: TRoute["$params"]; + $query: TRoute["$query"]; + $body: TRoute["$body"]; + $response: TRoute["$response"]; +}; + +type RouteOutput = TRoute["state"]["output"] extends ZodType ? z.infer : null; -type RelayRouteErrors = InstanceType; - -type OmitNever = { - [K in keyof T as T[K] extends never ? never : K]: T[K]; -}; +type RouteErrors = InstanceType; type Config = { adapter: RelayAdapter; }; + +type Prettify = { [K in keyof T]: T[K] } & {}; diff --git a/spec/relay/libraries/route.ts b/spec/relay/libraries/route.ts index d4ae799..75c9e74 100644 --- a/spec/relay/libraries/route.ts +++ b/spec/relay/libraries/route.ts @@ -10,7 +10,7 @@ export class Route { declare readonly $params: TState["params"] extends ZodObject ? z.input : never; declare readonly $query: TState["query"] extends ZodObject ? z.input : never; declare readonly $body: TState["body"] extends ZodType ? z.input : never; - declare readonly $response: TState["output"] extends ZodType ? z.output : never; + declare readonly $response: TState["response"] extends ZodType ? z.output : never; #matchFn?: MatchFunction; @@ -69,16 +69,6 @@ export class Route { return result.params as TParams; } - /** - * Set the content the route expects, 'json' or 'form-data' which the client uses - * to determine which adapter operation to execute on requests. - * - * @param content - Content expected during transfers. - */ - content(content: TContent): Route & { content: TContent }> { - return new Route({ ...this.state, content }); - } - /** * Set the meta data for this route which can be used in e.g. OpenAPI generation * @@ -218,33 +208,6 @@ export class Route { return new Route({ ...this.state, body }); } - /** - * Shape of the success response this route produces. This is used by the transform - * tools to ensure the client receives parsed data. - * - * @param response - Response shape of the route. - * - * @examples - * - * ```ts - * route - * .post("/foo") - * .response( - * z.object({ - * bar: z.number() - * }) - * ) - * .handle(async () => { - * return { - * bar: 1 - * } - * }); - * ``` - */ - response(output: TResponse): Route & { output: TResponse }> { - return new Route({ ...this.state, output }); - } - /** * Instances of the possible error responses this route produces. * @@ -267,6 +230,33 @@ export class Route { return new Route({ ...this.state, errors }); } + /** + * Shape of the success response this route produces. This is used by the transform + * tools to ensure the client receives parsed data. + * + * @param response - Response shape of the route. + * + * @examples + * + * ```ts + * route + * .post("/foo") + * .response( + * z.object({ + * bar: z.number() + * }) + * ) + * .handle(async () => { + * return { + * bar: 1 + * } + * }); + * ``` + */ + response(response: TResponse): Route & { response: TResponse }> { + return new Route({ ...this.state, response }); + } + /** * Server handler callback method. * @@ -286,7 +276,7 @@ export class Route { * .handle(async ({ bar }, [ "string", number ]) => {}); * ``` */ - handle, TState["output"]>>( + handle, TState["response"]>>( handle: THandleFn, ): Route & { handle: THandleFn }> { return new Route({ ...this.state, handle }); @@ -433,14 +423,13 @@ export type Routes = { type RouteState = { method: RouteMethod; path: string; - content: RouteContent; meta?: RouteMeta; access?: RouteAccess; params?: ZodObject; query?: ZodObject; body?: ZodType; - output?: ZodType; errors: ServerErrorClass[]; + response?: ZodType; handle?: HandleFn; hooks?: Hooks; }; @@ -454,8 +443,6 @@ export type RouteMeta = { export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE"; -export type RouteContent = "json" | "form-data"; - export type RouteAccess = "public" | "session" | (() => boolean)[]; export type AccessFn = (resource: string, action: string) => () => boolean; @@ -466,8 +453,8 @@ export interface ServerContext {} type HandleFn = any[], TResponse = any> = ( ...args: TArgs ) => TResponse extends ZodType - ? Promise | Response | ServerError | unknown> - : Promise; + ? Promise | Response | ServerError> + : Promise; type ServerArgs = HasInputArgs extends true diff --git a/spec/modules/README.md b/spec/schemas/README.md similarity index 100% rename from spec/modules/README.md rename to spec/schemas/README.md diff --git a/spec/modules/access/role.ts b/spec/schemas/access/role.ts similarity index 70% rename from spec/modules/access/role.ts rename to spec/schemas/access/role.ts index be69f77..f1313d2 100644 --- a/spec/modules/access/role.ts +++ b/spec/schemas/access/role.ts @@ -1,6 +1,7 @@ -import { makeSchemaParser } from "@spec/shared"; import z from "zod"; +import { makeSchemaParser } from "../database.ts"; + export const RoleSchema = z.object({ id: z.uuid(), name: z.string(), @@ -10,3 +11,4 @@ export const RoleSchema = z.object({ export const parseRole = makeSchemaParser(RoleSchema); export type Role = z.infer; +export type RoleDocument = z.infer; diff --git a/spec/modules/account/account.ts b/spec/schemas/account/account.ts similarity index 61% rename from spec/modules/account/account.ts rename to spec/schemas/account/account.ts index 529c43c..bb45e8a 100644 --- a/spec/modules/account/account.ts +++ b/spec/schemas/account/account.ts @@ -1,7 +1,10 @@ -import { AvatarSchema, ContactSchema, makeSchemaParser, NameSchema } from "@spec/shared"; import { z } from "zod"; import { RoleSchema } from "../access/role.ts"; +import { AvatarSchema } from "../avatar.ts"; +import { ContactSchema } from "../contact.ts"; +import { makeSchemaParser } from "../database.ts"; +import { NameSchema } from "../name.ts"; import { StrategySchema } from "./strategies.ts"; export const AccountSchema = z.object({ @@ -15,9 +18,10 @@ export const AccountSchema = z.object({ roles: z.array(RoleSchema).default([]), }); -export const AccountDocumentSchema = AccountSchema.omit({ roles: true }).extend({ roles: z.string().array() }); +export const AccountDocumentSchema = AccountSchema.omit({ roles: true }).extend({ roles: z.array(z.string()) }); -export const parseAccount = makeSchemaParser(AccountSchema); +export const toAccountDocument = makeSchemaParser(AccountDocumentSchema); +export const fromAccountDocument = makeSchemaParser(AccountSchema); export type Account = z.infer; export type AccountDocument = z.infer; diff --git a/spec/schemas/account/errors.ts b/spec/schemas/account/errors.ts new file mode 100644 index 0000000..445f722 --- /dev/null +++ b/spec/schemas/account/errors.ts @@ -0,0 +1,7 @@ +import { ConflictError } from "@spec/relay/mod.ts"; + +export class AccountEmailClaimedError extends ConflictError { + constructor(email: string) { + super(`Email '${email}' is already claimed by another account.`); + } +} diff --git a/spec/schemas/account/routes.ts b/spec/schemas/account/routes.ts new file mode 100644 index 0000000..6610e8c --- /dev/null +++ b/spec/schemas/account/routes.ts @@ -0,0 +1,20 @@ +import { route } from "@spec/relay"; +import z from "zod"; + +import { NameSchema } from "../name.ts"; +import { AccountEmailClaimedError } from "./errors.ts"; + +export const create = route + .post("/api/v1/accounts") + .body( + z.object({ + name: NameSchema, + email: z.email(), + }), + ) + .errors([AccountEmailClaimedError]) + .response(z.uuid()); + +export const routes = { + create, +}; diff --git a/spec/modules/account/strategies.ts b/spec/schemas/account/strategies.ts similarity index 100% rename from spec/modules/account/strategies.ts rename to spec/schemas/account/strategies.ts diff --git a/spec/modules/auth/errors.ts b/spec/schemas/auth/errors.ts similarity index 100% rename from spec/modules/auth/errors.ts rename to spec/schemas/auth/errors.ts diff --git a/spec/schemas/auth/routes.ts b/spec/schemas/auth/routes.ts new file mode 100644 index 0000000..d98ed2f --- /dev/null +++ b/spec/schemas/auth/routes.ts @@ -0,0 +1,41 @@ +import { route, UnauthorizedError } from "@spec/relay"; +import z from "zod"; + +import { AccountSchema } from "../account/account.ts"; + +export * from "./errors.ts"; +export * from "./strategies.ts"; + +export const email = route.post("/api/v1/auth/email").body( + z.object({ + base: z.url(), + email: z.email(), + }), +); + +export const password = route.post("/api/v1/auth/password").body( + z.object({ + alias: z.string(), + password: z.string(), + }), +); + +export const code = route + .get("/api/v1/auth/code/:accountId/code/:codeId/:value") + .params({ + accountId: z.string(), + codeId: z.string(), + value: z.string(), + }) + .query({ + next: z.string().optional(), + }); + +export const session = route.get("/api/v1/auth/session").response(AccountSchema).errors([UnauthorizedError]); + +export const routes = { + email, + password, + code, + session, +}; diff --git a/spec/modules/auth/strategies.ts b/spec/schemas/auth/strategies.ts similarity index 85% rename from spec/modules/auth/strategies.ts rename to spec/schemas/auth/strategies.ts index 1c9d173..a113cda 100644 --- a/spec/modules/auth/strategies.ts +++ b/spec/schemas/auth/strategies.ts @@ -34,6 +34,11 @@ export const PasswordStrategySchema = z.object({ password: z.string().describe("User's password"), }); -export const StrategyPayloadSchema = z +export const StrategySchema = z .union([PasskeyStrategySchema, EmailStrategySchema, PasswordStrategySchema]) .describe("Union of all available authentication strategy schemas"); + +export type PasskeyStrategy = z.infer; +export type EmailStrategy = z.infer; +export type PasswordStrategy = z.infer; +export type Strategy = z.infer; diff --git a/spec/shared/avatar.ts b/spec/schemas/avatar.ts similarity index 100% rename from spec/shared/avatar.ts rename to spec/schemas/avatar.ts diff --git a/spec/shared/contact.ts b/spec/schemas/contact.ts similarity index 100% rename from spec/shared/contact.ts rename to spec/schemas/contact.ts diff --git a/spec/shared/database.ts b/spec/schemas/database.ts similarity index 100% rename from spec/shared/database.ts rename to spec/schemas/database.ts diff --git a/spec/shared/email.ts b/spec/schemas/email.ts similarity index 100% rename from spec/shared/email.ts rename to spec/schemas/email.ts diff --git a/spec/shared/name.ts b/spec/schemas/name.ts similarity index 100% rename from spec/shared/name.ts rename to spec/schemas/name.ts diff --git a/spec/modules/package.json b/spec/schemas/package.json similarity index 68% rename from spec/modules/package.json rename to spec/schemas/package.json index c1982ad..2a57cdf 100644 --- a/spec/modules/package.json +++ b/spec/schemas/package.json @@ -1,11 +1,10 @@ { - "name": "@spec/modules", + "name": "@spec/schemas", "version": "0.0.0", "private": true, "type": "module", "dependencies": { "@spec/relay": "workspace:*", - "@spec/shared": "workspace:*", "zod": "4" } } \ No newline at end of file diff --git a/spec/shared/mod.ts b/spec/shared/mod.ts deleted file mode 100644 index e7c69f5..0000000 --- a/spec/shared/mod.ts +++ /dev/null @@ -1,5 +0,0 @@ -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/package.json b/spec/shared/package.json deleted file mode 100644 index d11b594..0000000 --- a/spec/shared/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "@spec/shared", - "version": "0.0.0", - "private": true, - "type": "module", - "main": "./mod.ts", - "exports": { - ".": "./mod.ts" - }, - "dependencies": { - "zod": "4" - } -} \ No newline at end of file