Template
1
0

feat: add functional authentication

This commit is contained in:
2025-08-12 23:11:08 +02:00
parent f0630d43b7
commit 82d7a0d9cd
74 changed files with 763 additions and 396 deletions

View File

@@ -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
}

View File

@@ -0,0 +1,8 @@
meta {
name: account
seq: 1
}
auth {
mode: inherit
}

28
api/.bruno/auth/Code.bru Normal file
View File

@@ -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
}

22
api/.bruno/auth/Email.bru Normal file
View File

@@ -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
}

View File

@@ -0,0 +1,15 @@
meta {
name: Session
type: http
seq: 3
}
get {
url: {{url}}/auth/session
body: none
auth: inherit
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,8 @@
meta {
name: auth
seq: 2
}
auth {
mode: inherit
}

9
api/.bruno/bruno.json Normal file
View File

@@ -0,0 +1,9 @@
{
"version": "1",
"name": "Valkyr",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

@@ -0,0 +1,3 @@
vars {
url: http://localhost:8370/api/v1
}

View File

@@ -3,6 +3,7 @@ import { resolve } from "node:path";
import { logger } from "~libraries/logger/mod.ts"; import { logger } from "~libraries/logger/mod.ts";
const LIBRARIES_DIR = resolve(import.meta.dirname!, "..", "libraries"); const LIBRARIES_DIR = resolve(import.meta.dirname!, "..", "libraries");
const STORES_DIR = resolve(import.meta.dirname!, "..", "stores");
const log = logger.prefix("Bootstrap"); 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(LIBRARIES_DIR);
await bootstrap(STORES_DIR);
/* /*
|-------------------------------------------------------------------------------- |--------------------------------------------------------------------------------

View File

@@ -1,6 +1,7 @@
{ {
"imports": { "imports": {
"~config": "./config.ts", "~libraries/": "./libraries/",
"~libraries/": "./libraries/" "~stores/": "./stores/",
"~config": "./config.ts"
} }
} }

View File

@@ -1,7 +1,7 @@
import { Auth, ResolvedSession } from "@valkyr/auth"; import { Auth, ResolvedSession } from "@valkyr/auth";
import z from "zod"; 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"; import { config } from "./config.ts";
@@ -11,18 +11,13 @@ export const auth = new Auth(
algorithm: "RS256", algorithm: "RS256",
privateKey: config.privateKey, privateKey: config.privateKey,
publicKey: config.publicKey, publicKey: config.publicKey,
issuer: "https://balto.health", issuer: "http://localhost",
audience: "https://balto.health", audience: "http://localhost",
}, },
session: z.object({ session: z.object({
accountId: z.string(), accountId: z.string(),
}), }),
permissions: { permissions: {} as const,
admin: ["create", "read", "update", "delete"],
organization: ["create", "read", "update", "delete"],
consultant: ["create", "read", "update", "delete"],
task: ["create", "update", "read", "delete"],
} as const,
guards: [], guards: [],
}, },
{ {

View File

@@ -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<EventStoreFactory> {
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<Organization | undefined> {
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),
});
});

View File

@@ -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),
];

View File

@@ -1,7 +0,0 @@
import z from "zod";
export const auditor = z.object({
accountId: z.string(),
});
export type Auditor = z.infer<typeof auditor>;

View File

@@ -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<typeof identity>;

View File

@@ -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),
];

View File

@@ -1,2 +0,0 @@
export * from "./event-store.ts";
export * from "./projector.ts";

View File

@@ -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],
},
]);

View File

@@ -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<Account | undefined> {
return db
.collection("accounts")
.aggregate([
{
$match: { id },
},
{
$lookup: {
from: "roles",
localField: "roles",
foreignField: "id",
as: "roles",
},
},
])
.toArray()
.then(parseAccount)
.then(takeOne);
}

View File

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

View File

@@ -1,16 +1,41 @@
import { RouteContext } from "@spec/relay"; import { ServerContext, UnauthorizedError } from "@spec/relay";
export function getRequestContext(request: Request): RouteContext { import { Session } from "../auth/auth.ts";
return { import { req } from "./request.ts";
request,
};
}
declare module "@spec/relay" { declare module "@spec/relay" {
interface RouteContext { interface ServerContext {
/** /**
* Current request instance being handled. * Current request instance being handled.
*/ */
request: Request; 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;
},
};
}

View File

@@ -14,7 +14,7 @@ import { Route } from "@spec/relay";
export async function resolveRoutes(path: string, routes: Route[] = []): Promise<Route[]> { export async function resolveRoutes(path: string, routes: Route[] = []): Promise<Route[]> {
for await (const entry of Deno.readDir(path)) { for await (const entry of Deno.readDir(path)) {
if (entry.isDirectory === true) { if (entry.isDirectory === true) {
await loadRoutes(`${path}/${entry.name}/routes`, routes, [name]); await loadRoutes(`${path}/${entry.name}`, routes, [name]);
} }
} }
return routes; return routes;

View File

@@ -1,3 +1,4 @@
import { Session } from "../auth/auth.ts";
import { asyncLocalStorage } from "./storage.ts"; import { asyncLocalStorage } from "./storage.ts";
export const req = { export const req = {
@@ -24,14 +25,14 @@ export const req = {
/** /**
* Check if the request is authenticated. * Check if the request is authenticated.
*/ */
get isAuthenticated() { get isAuthenticated(): boolean {
return this.session !== undefined; return this.session !== undefined;
}, },
/** /**
* Get current session. * Get current session.
*/ */
get session() { get session(): Session | undefined {
return this.store.session; return this.store.session;
}, },

View File

@@ -1,5 +0,0 @@
import { authenticate } from "@spec/modules/auth/routes/authenticate.ts";
export default authenticate.access("public").handle(async ({ body }) => {
console.log({ body });
});

View File

@@ -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);
});

84
api/routes/auth/code.ts Normal file
View File

@@ -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),
},
});
});

27
api/routes/auth/email.ts Normal file
View File

@@ -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`,
});
});

View File

@@ -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),
),
},
});
});

View File

@@ -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;
});

View File

@@ -8,7 +8,7 @@ import { Api, resolveRoutes } from "~libraries/server/mod.ts";
import { config } from "./config.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"); 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));
/* /*
|-------------------------------------------------------------------------------- |--------------------------------------------------------------------------------

View File

@@ -0,0 +1,5 @@
import { register } from "@valkyr/event-store/mongo";
import { eventStore } from "../event-store.ts";
await register(eventStore.db.db, console.info);

View File

@@ -1,11 +1,15 @@
import { Strategy } from "@spec/modules/account/strategies.ts"; import { toAccountDocument } from "@spec/schemas/account/account.ts";
import { Avatar, Contact, Email, Name } from "@spec/shared"; 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 { 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 { 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 { EventStoreFactory } from "../events/mod.ts";
import { projector } from "../projector.ts"; import { projector } from "../projector.ts";
@@ -28,6 +32,10 @@ export class Account extends AggregateRoot<EventStoreFactory> {
with(event: EventStoreFactory["$events"][number]["$record"]): void { with(event: EventStoreFactory["$events"][number]["$record"]): void {
switch (event.type) { switch (event.type) {
case "account:created": {
this.id = event.stream;
this.createdAt = getDate(event.created);
}
case "account:avatar:added": { case "account:avatar:added": {
this.avatar = { url: event.data }; this.avatar = { url: event.data };
this.updatedAt = getDate(event.created); this.updatedAt = getDate(event.created);
@@ -60,7 +68,15 @@ export class Account extends AggregateRoot<EventStoreFactory> {
// Actions // 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({ return this.push({
stream: this.id, stream: this.id,
type: "account:avatar:added", type: "account:avatar:added",
@@ -69,7 +85,7 @@ export class Account extends AggregateRoot<EventStoreFactory> {
}); });
} }
addName(name: Name, meta: Auditor): this { addName(name: Name, meta: Auditor = systemAuditor): this {
return this.push({ return this.push({
stream: this.id, stream: this.id,
type: "account:name:added", type: "account:name:added",
@@ -78,7 +94,7 @@ export class Account extends AggregateRoot<EventStoreFactory> {
}); });
} }
addEmail(email: Email, meta: Auditor): this { addEmail(email: Email, meta: Auditor = systemAuditor): this {
return this.push({ return this.push({
stream: this.id, stream: this.id,
type: "account:email:added", type: "account:email:added",
@@ -87,7 +103,7 @@ export class Account extends AggregateRoot<EventStoreFactory> {
}); });
} }
addRole(roleId: string, meta: Auditor): this { addRole(roleId: string, meta: Auditor = systemAuditor): this {
return this.push({ return this.push({
stream: this.id, stream: this.id,
type: "account:role:added", type: "account:role:added",
@@ -96,7 +112,7 @@ export class Account extends AggregateRoot<EventStoreFactory> {
}); });
} }
addEmailStrategy(email: string, meta: Auditor): this { addEmailStrategy(email: string, meta: Auditor = systemAuditor): this {
return this.push({ return this.push({
stream: this.id, stream: this.id,
type: "strategy:email:added", type: "strategy:email:added",
@@ -105,7 +121,7 @@ export class Account extends AggregateRoot<EventStoreFactory> {
}); });
} }
addPasswordStrategy(alias: string, password: string, meta: Auditor): this { addPasswordStrategy(alias: string, password: string, meta: Auditor = systemAuditor): this {
return this.push({ return this.push({
stream: this.id, stream: this.id,
type: "strategy:password:added", type: "strategy:password:added",
@@ -115,38 +131,79 @@ export class Account extends AggregateRoot<EventStoreFactory> {
} }
} }
/*
|--------------------------------------------------------------------------------
| Utilities
|--------------------------------------------------------------------------------
*/
export async function isEmailClaimed(email: string): Promise<boolean> {
const relations = await eventStore.relations.getByKey(getAccountEmailRelation(email));
if (relations.length > 0) {
return true;
}
return false;
}
/*
|--------------------------------------------------------------------------------
| Relations
|--------------------------------------------------------------------------------
*/
export function getAccountEmailRelation(email: string): string {
return `/accounts/emails/${email}`;
}
export function getAccountAliasRelation(alias: string): string {
return `/accounts/aliases/${alias}`;
}
/* /*
|-------------------------------------------------------------------------------- |--------------------------------------------------------------------------------
| Projectors | 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 }) => { 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 }) => { 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 }) => { 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 }) => { 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 }) => { projector.on("strategy:email:added", async ({ stream: id, data: email }) => {
await eventStore.relations.insert(`account:email:${email}`, id); await eventStore.relations.insert(getAccountEmailRelation(email), id);
await db await db.collection("accounts").updateOne({ id }, { $push: { strategies: { type: "email", value: email } } });
.collection("accounts")
.updateOne({ id }, { $push: { strategies: { type: "email", value: email } } }, { upsert: true });
}); });
projector.on("strategy:password:added", async ({ stream: id, data: strategy }) => { projector.on("strategy:password:added", async ({ stream: id, data: strategy }) => {
await eventStore.relations.insert(`account:alias:${strategy.alias}`, id); await eventStore.relations.insert(getAccountAliasRelation(strategy.alias), id);
await db await db.collection("accounts").updateOne({ id }, { $push: { strategies: { type: "password", ...strategy } } });
.collection("accounts")
.updateOne({ id }, { $push: { strategies: { type: "password", ...strategy } } }, { upsert: true });
}); });

View File

@@ -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 { CodeIdentity } from "../events/code.ts";
import { EventStoreFactory } from "../events/mod.ts"; import { EventStoreFactory } from "../events/mod.ts";
@@ -6,8 +6,6 @@ import { EventStoreFactory } from "../events/mod.ts";
export class Code extends AggregateRoot<EventStoreFactory> { export class Code extends AggregateRoot<EventStoreFactory> {
static override readonly name = "code"; static override readonly name = "code";
id!: string;
identity!: CodeIdentity; identity!: CodeIdentity;
value!: string; value!: string;
@@ -15,32 +13,9 @@ export class Code extends AggregateRoot<EventStoreFactory> {
claimedAt?: Date; 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<Code | undefined> {
return this.$store.reduce({
name: "code",
stream,
reducer: this.#reducer,
});
}
get isClaimed(): boolean { get isClaimed(): boolean {
return this.claimedAt !== undefined; return this.claimedAt !== undefined;
} }
@@ -52,7 +27,6 @@ export class Code extends AggregateRoot<EventStoreFactory> {
with(event: EventStoreFactory["$events"][number]["$record"]): void { with(event: EventStoreFactory["$events"][number]["$record"]): void {
switch (event.type) { switch (event.type) {
case "code:created": { case "code:created": {
this.id = event.stream;
this.value = event.data.value; this.value = event.data.value;
this.identity = event.data.identity; this.identity = event.data.identity;
this.createdAt = getDate(event.created); this.createdAt = getDate(event.created);
@@ -69,6 +43,20 @@ export class Code extends AggregateRoot<EventStoreFactory> {
// Actions // 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 { claim(): this {
return this.push({ return this.push({
type: "code:claimed", type: "code:claimed",

View File

@@ -1,16 +1,15 @@
import { EventStore } from "@valkyr/event-store"; import { EventStore } from "@valkyr/event-store";
import { MongoAdapter } from "@valkyr/event-store/mongo"; import { MongoAdapter } from "@valkyr/event-store/mongo";
import { config } from "~config";
import { container } from "~libraries/database/container.ts"; import { container } from "~libraries/database/container.ts";
import { aggregates } from "./aggregates/mod.ts";
import { events } from "./events/mod.ts"; import { events } from "./events/mod.ts";
import { projector } from "./projector.ts"; import { projector } from "./projector.ts";
export const eventStore = new EventStore({ 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, events,
aggregates,
snapshot: "auto", snapshot: "auto",
}); });

View File

@@ -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),
];

View File

@@ -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<typeof AuditorSchema>;

View File

@@ -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<typeof CodeIdentitySchema>;

View File

@@ -1,11 +1,11 @@
import { event } from "@valkyr/event-store"; import { event } from "@valkyr/event-store";
import z from "zod"; import z from "zod";
import { auditor } from "./auditor.ts"; import { AuditorSchema } from "./auditor.ts";
export default [ export default [
event event
.type("organization:created") .type("organization:created")
.data(z.object({ name: z.string() })) .data(z.object({ name: z.string() }))
.meta(auditor), .meta(AuditorSchema),
]; ];

View File

@@ -1,7 +1,7 @@
import { event } from "@valkyr/event-store"; import { event } from "@valkyr/event-store";
import z from "zod"; import z from "zod";
import { auditor } from "./auditor.ts"; import { AuditorSchema } from "./auditor.ts";
const CreatedSchema = z.object({ const CreatedSchema = z.object({
name: z.string(), name: z.string(),
@@ -27,9 +27,9 @@ const OperationSchema = z.discriminatedUnion("type", [
]); ]);
export default [ export default [
event.type("role:created").data(CreatedSchema).meta(auditor), event.type("role:created").data(CreatedSchema).meta(AuditorSchema),
event.type("role:name-set").data(z.string()).meta(auditor), event.type("role:name-set").data(z.string()).meta(AuditorSchema),
event.type("role:permissions-set").data(z.array(OperationSchema)).meta(auditor), event.type("role:permissions-set").data(z.array(OperationSchema)).meta(AuditorSchema),
]; ];
export type RoleCreatedData = z.infer<typeof CreatedSchema>; export type RoleCreatedData = z.infer<typeof CreatedSchema>;

View File

@@ -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),
];

View File

@@ -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" }]],
},
]);

View File

@@ -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 { config } from "~config";
import { getDatabaseAccessor } from "~libraries/database/accessor.ts"; import { getDatabaseAccessor } from "~libraries/database/accessor.ts";
export const db = getDatabaseAccessor<{ export const db = getDatabaseAccessor<{
accounts: AccountDocument; accounts: AccountDocument;
roles: RoleDocument;
}>(`${config.name}:read-store`); }>(`${config.name}:read-store`);
export function takeOne<TDocument>(documents: TDocument[]): TDocument | undefined { export function takeOne<TDocument>(documents: TDocument[]): TDocument | undefined {

View File

@@ -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<Account | undefined> {
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;
}

View File

@@ -4,10 +4,13 @@
"workspace": [ "workspace": [
"api", "api",
"apps/react", "apps/react",
"spec/modules",
"spec/relay", "spec/relay",
"spec/shared" "spec/schemas"
], ],
"imports": {
"@spec/relay/": "./spec/relay/",
"@spec/schemas/": "./spec/schemas/"
},
"tasks": { "tasks": {
"start:api": { "start:api": {
"command": "cd ./api && deno run start", "command": "cd ./api && deno run start",

9
deno.lock generated
View File

@@ -1771,13 +1771,6 @@
] ]
} }
}, },
"spec/modules": {
"packageJson": {
"dependencies": [
"npm:zod@4"
]
}
},
"spec/relay": { "spec/relay": {
"packageJson": { "packageJson": {
"dependencies": [ "dependencies": [
@@ -1786,7 +1779,7 @@
] ]
} }
}, },
"spec/shared": { "spec/schemas": {
"packageJson": { "packageJson": {
"dependencies": [ "dependencies": [
"npm:zod@4" "npm:zod@4"

View File

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

View File

@@ -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 }));

View File

@@ -1,8 +0,0 @@
import { authenticate } from "./routes/authenticate.ts";
export * from "./errors.ts";
export * from "./strategies.ts";
export const routes = {
authenticate,
};

View File

@@ -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]);

View File

@@ -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 type { RelayAdapter, RelayInput, RelayResponse } from "./adapter.ts";
import { Route, type Routes } from "./route.ts"; import { Route, type Routes } from "./route.ts";
@@ -45,7 +47,7 @@ function getNestedRoute<TRoutes extends Routes>(config: Config, routes: TRoutes)
} }
function getRouteFn(route: Route, { adapter }: Config) { function getRouteFn(route: Route, { adapter }: Config) {
return async (options: any) => { return async (options: any = {}) => {
const input: RelayInput = { const input: RelayInput = {
method: route.state.method, method: route.state.method,
endpoint: route.state.path, endpoint: route.state.path,
@@ -146,34 +148,45 @@ type RelayRequest = {
type RelayRoutes<TRoutes extends Routes> = { type RelayRoutes<TRoutes extends Routes> = {
[TKey in keyof TRoutes]: TRoutes[TKey] extends Route [TKey in keyof TRoutes]: TRoutes[TKey] extends Route
? (( ? HasPayload<TRoutes[TKey]> extends true
payload: OmitNever<{ ? (
params: TRoutes[TKey]["$params"]; payload: Prettify<
query: TRoutes[TKey]["$query"]; (TRoutes[TKey]["state"]["params"] extends ZodObject ? { params: TRoutes[TKey]["$params"] } : {}) &
body: TRoutes[TKey]["$body"]; (TRoutes[TKey]["state"]["query"] extends ZodObject ? { query: TRoutes[TKey]["$query"] } : {}) &
headers?: Headers; (TRoutes[TKey]["state"]["body"] extends ZodType ? { body: TRoutes[TKey]["$body"] } : {}) & {
}>, headers?: HeadersInit;
) => Promise<RelayResponse<RelayRouteResponse<TRoutes[TKey]>, RelayRouteErrors<TRoutes[TKey]>>>) & {
$params: TRoutes[TKey]["$params"];
$query: TRoutes[TKey]["$query"];
$body: TRoutes[TKey]["$body"];
$response: TRoutes[TKey]["$response"];
} }
>,
) => RouteResponse<TRoutes[TKey]>
: (payload?: { headers: HeadersInit }) => RouteResponse<TRoutes[TKey]>
: TRoutes[TKey] extends Routes : TRoutes[TKey] extends Routes
? RelayClient<TRoutes[TKey]> ? RelayRoutes<TRoutes[TKey]>
: never; : never;
}; };
type RelayRouteResponse<TRoute extends Route> = TRoute["state"]["output"] extends ZodType type HasPayload<TRoute extends Route> = TRoute["state"]["params"] extends ZodObject
? true
: TRoute["state"]["query"] extends ZodObject
? true
: TRoute["state"]["body"] extends ZodType
? true
: false;
type RouteResponse<TRoute extends Route> = Promise<RelayResponse<RouteOutput<TRoute>, RouteErrors<TRoute>>> & {
$params: TRoute["$params"];
$query: TRoute["$query"];
$body: TRoute["$body"];
$response: TRoute["$response"];
};
type RouteOutput<TRoute extends Route> = TRoute["state"]["output"] extends ZodType
? z.infer<TRoute["state"]["output"]> ? z.infer<TRoute["state"]["output"]>
: null; : null;
type RelayRouteErrors<TRoute extends Route> = InstanceType<TRoute["state"]["errors"][number]>; type RouteErrors<TRoute extends Route> = InstanceType<TRoute["state"]["errors"][number]>;
type OmitNever<T> = {
[K in keyof T as T[K] extends never ? never : K]: T[K];
};
type Config = { type Config = {
adapter: RelayAdapter; adapter: RelayAdapter;
}; };
type Prettify<T> = { [K in keyof T]: T[K] } & {};

View File

@@ -10,7 +10,7 @@ export class Route<const TState extends RouteState = RouteState> {
declare readonly $params: TState["params"] extends ZodObject ? z.input<TState["params"]> : never; declare readonly $params: TState["params"] extends ZodObject ? z.input<TState["params"]> : never;
declare readonly $query: TState["query"] extends ZodObject ? z.input<TState["query"]> : never; declare readonly $query: TState["query"] extends ZodObject ? z.input<TState["query"]> : never;
declare readonly $body: TState["body"] extends ZodType ? z.input<TState["body"]> : never; declare readonly $body: TState["body"] extends ZodType ? z.input<TState["body"]> : never;
declare readonly $response: TState["output"] extends ZodType ? z.output<TState["output"]> : never; declare readonly $response: TState["response"] extends ZodType ? z.output<TState["response"]> : never;
#matchFn?: MatchFunction<any>; #matchFn?: MatchFunction<any>;
@@ -69,16 +69,6 @@ export class Route<const TState extends RouteState = RouteState> {
return result.params as TParams; 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<TContent extends RouteContent>(content: TContent): Route<Omit<TState, "content"> & { content: TContent }> {
return new Route({ ...this.state, content });
}
/** /**
* Set the meta data for this route which can be used in e.g. OpenAPI generation * Set the meta data for this route which can be used in e.g. OpenAPI generation
* *
@@ -218,33 +208,6 @@ export class Route<const TState extends RouteState = RouteState> {
return new Route({ ...this.state, body }); 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<TResponse extends ZodType>(output: TResponse): Route<Omit<TState, "output"> & { output: TResponse }> {
return new Route({ ...this.state, output });
}
/** /**
* Instances of the possible error responses this route produces. * Instances of the possible error responses this route produces.
* *
@@ -267,6 +230,33 @@ export class Route<const TState extends RouteState = RouteState> {
return new Route({ ...this.state, errors }); 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<TResponse extends ZodType>(response: TResponse): Route<Omit<TState, "response"> & { response: TResponse }> {
return new Route({ ...this.state, response });
}
/** /**
* Server handler callback method. * Server handler callback method.
* *
@@ -286,7 +276,7 @@ export class Route<const TState extends RouteState = RouteState> {
* .handle(async ({ bar }, [ "string", number ]) => {}); * .handle(async ({ bar }, [ "string", number ]) => {});
* ``` * ```
*/ */
handle<THandleFn extends HandleFn<ServerArgs<TState>, TState["output"]>>( handle<THandleFn extends HandleFn<ServerArgs<TState>, TState["response"]>>(
handle: THandleFn, handle: THandleFn,
): Route<Omit<TState, "handle"> & { handle: THandleFn }> { ): Route<Omit<TState, "handle"> & { handle: THandleFn }> {
return new Route({ ...this.state, handle }); return new Route({ ...this.state, handle });
@@ -433,14 +423,13 @@ export type Routes = {
type RouteState = { type RouteState = {
method: RouteMethod; method: RouteMethod;
path: string; path: string;
content: RouteContent;
meta?: RouteMeta; meta?: RouteMeta;
access?: RouteAccess; access?: RouteAccess;
params?: ZodObject; params?: ZodObject;
query?: ZodObject; query?: ZodObject;
body?: ZodType; body?: ZodType;
output?: ZodType;
errors: ServerErrorClass[]; errors: ServerErrorClass[];
response?: ZodType;
handle?: HandleFn; handle?: HandleFn;
hooks?: Hooks; hooks?: Hooks;
}; };
@@ -454,8 +443,6 @@ export type RouteMeta = {
export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE"; export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE";
export type RouteContent = "json" | "form-data";
export type RouteAccess = "public" | "session" | (() => boolean)[]; export type RouteAccess = "public" | "session" | (() => boolean)[];
export type AccessFn = (resource: string, action: string) => () => boolean; export type AccessFn = (resource: string, action: string) => () => boolean;
@@ -466,8 +453,8 @@ export interface ServerContext {}
type HandleFn<TArgs extends Array<any> = any[], TResponse = any> = ( type HandleFn<TArgs extends Array<any> = any[], TResponse = any> = (
...args: TArgs ...args: TArgs
) => TResponse extends ZodType ) => TResponse extends ZodType
? Promise<z.infer<TResponse> | Response | ServerError | unknown> ? Promise<z.infer<TResponse> | Response | ServerError>
: Promise<Response | ServerError | unknown | void>; : Promise<Response | ServerError | void>;
type ServerArgs<TState extends RouteState> = type ServerArgs<TState extends RouteState> =
HasInputArgs<TState> extends true HasInputArgs<TState> extends true

View File

@@ -1,6 +1,7 @@
import { makeSchemaParser } from "@spec/shared";
import z from "zod"; import z from "zod";
import { makeSchemaParser } from "../database.ts";
export const RoleSchema = z.object({ export const RoleSchema = z.object({
id: z.uuid(), id: z.uuid(),
name: z.string(), name: z.string(),
@@ -10,3 +11,4 @@ export const RoleSchema = z.object({
export const parseRole = makeSchemaParser(RoleSchema); export const parseRole = makeSchemaParser(RoleSchema);
export type Role = z.infer<typeof RoleSchema>; export type Role = z.infer<typeof RoleSchema>;
export type RoleDocument = z.infer<typeof RoleSchema>;

View File

@@ -1,7 +1,10 @@
import { AvatarSchema, ContactSchema, makeSchemaParser, NameSchema } from "@spec/shared";
import { z } from "zod"; import { z } from "zod";
import { RoleSchema } from "../access/role.ts"; 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"; import { StrategySchema } from "./strategies.ts";
export const AccountSchema = z.object({ export const AccountSchema = z.object({
@@ -15,9 +18,10 @@ export const AccountSchema = z.object({
roles: z.array(RoleSchema).default([]), 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<typeof AccountSchema>; export type Account = z.infer<typeof AccountSchema>;
export type AccountDocument = z.infer<typeof AccountDocumentSchema>; export type AccountDocument = z.infer<typeof AccountDocumentSchema>;

View File

@@ -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.`);
}
}

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -34,6 +34,11 @@ export const PasswordStrategySchema = z.object({
password: z.string().describe("User's password"), password: z.string().describe("User's password"),
}); });
export const StrategyPayloadSchema = z export const StrategySchema = z
.union([PasskeyStrategySchema, EmailStrategySchema, PasswordStrategySchema]) .union([PasskeyStrategySchema, EmailStrategySchema, PasswordStrategySchema])
.describe("Union of all available authentication strategy schemas"); .describe("Union of all available authentication strategy schemas");
export type PasskeyStrategy = z.infer<typeof PasskeyStrategySchema>;
export type EmailStrategy = z.infer<typeof EmailStrategySchema>;
export type PasswordStrategy = z.infer<typeof PasswordStrategySchema>;
export type Strategy = z.infer<typeof StrategySchema>;

View File

@@ -1,11 +1,10 @@
{ {
"name": "@spec/modules", "name": "@spec/schemas",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@spec/relay": "workspace:*", "@spec/relay": "workspace:*",
"@spec/shared": "workspace:*",
"zod": "4" "zod": "4"
} }
} }

View File

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

View File

@@ -1,13 +0,0 @@
{
"name": "@spec/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./mod.ts",
"exports": {
".": "./mod.ts"
},
"dependencies": {
"zod": "4"
}
}