feat: add functional authentication
This commit is contained in:
25
api/.bruno/account/Create.bru
Normal file
25
api/.bruno/account/Create.bru
Normal 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
|
||||||
|
}
|
||||||
8
api/.bruno/account/folder.bru
Normal file
8
api/.bruno/account/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
meta {
|
||||||
|
name: account
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
auth {
|
||||||
|
mode: inherit
|
||||||
|
}
|
||||||
28
api/.bruno/auth/Code.bru
Normal file
28
api/.bruno/auth/Code.bru
Normal 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
22
api/.bruno/auth/Email.bru
Normal 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
|
||||||
|
}
|
||||||
15
api/.bruno/auth/Session.bru
Normal file
15
api/.bruno/auth/Session.bru
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
meta {
|
||||||
|
name: Session
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{url}}/auth/session
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
}
|
||||||
8
api/.bruno/auth/folder.bru
Normal file
8
api/.bruno/auth/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
meta {
|
||||||
|
name: auth
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
auth {
|
||||||
|
mode: inherit
|
||||||
|
}
|
||||||
9
api/.bruno/bruno.json
Normal file
9
api/.bruno/bruno.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"version": "1",
|
||||||
|
"name": "Valkyr",
|
||||||
|
"type": "collection",
|
||||||
|
"ignore": [
|
||||||
|
"node_modules",
|
||||||
|
".git"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
api/.bruno/environments/localhost.bru
Normal file
3
api/.bruno/environments/localhost.bru
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
vars {
|
||||||
|
url: http://localhost:8370/api/v1
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------------
|
|--------------------------------------------------------------------------------
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
"~config": "./config.ts",
|
"~libraries/": "./libraries/",
|
||||||
"~libraries/": "./libraries/"
|
"~stores/": "./stores/",
|
||||||
|
"~config": "./config.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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),
|
|
||||||
];
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import z from "zod";
|
|
||||||
|
|
||||||
export const auditor = z.object({
|
|
||||||
accountId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Auditor = z.infer<typeof auditor>;
|
|
||||||
@@ -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>;
|
|
||||||
@@ -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),
|
|
||||||
];
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from "./event-store.ts";
|
|
||||||
export * from "./projector.ts";
|
|
||||||
@@ -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],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from "./database.ts";
|
|
||||||
export * from "./methods.ts";
|
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { authenticate } from "@spec/modules/auth/routes/authenticate.ts";
|
|
||||||
|
|
||||||
export default authenticate.access("public").handle(async ({ body }) => {
|
|
||||||
console.log({ body });
|
|
||||||
});
|
|
||||||
18
api/routes/account/create.ts
Normal file
18
api/routes/account/create.ts
Normal 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
84
api/routes/auth/code.ts
Normal 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
27
api/routes/auth/email.ts
Normal 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`,
|
||||||
|
});
|
||||||
|
});
|
||||||
36
api/routes/auth/password.ts
Normal file
36
api/routes/auth/password.ts
Normal 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),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
12
api/routes/auth/session.ts
Normal file
12
api/routes/auth/session.ts
Normal 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;
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------------
|
|--------------------------------------------------------------------------------
|
||||||
|
|||||||
5
api/stores/event-store/.tasks/bootstrap.ts
Normal file
5
api/stores/event-store/.tasks/bootstrap.ts
Normal 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);
|
||||||
@@ -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 });
|
|
||||||
});
|
});
|
||||||
@@ -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",
|
||||||
@@ -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",
|
||||||
});
|
});
|
||||||
|
|
||||||
14
api/stores/event-store/events/account.ts
Normal file
14
api/stores/event-store/events/account.ts
Normal 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),
|
||||||
|
];
|
||||||
21
api/stores/event-store/events/auditor.ts
Normal file
21
api/stores/event-store/events/auditor.ts
Normal 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>;
|
||||||
18
api/stores/event-store/events/code.ts
Normal file
18
api/stores/event-store/events/code.ts
Normal 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>;
|
||||||
@@ -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),
|
||||||
];
|
];
|
||||||
@@ -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>;
|
||||||
13
api/stores/event-store/events/strategy.ts
Normal file
13
api/stores/event-store/events/strategy.ts
Normal 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),
|
||||||
|
];
|
||||||
19
api/stores/read-store/.tasks/bootstrap.ts
Normal file
19
api/stores/read-store/.tasks/bootstrap.ts
Normal 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" }]],
|
||||||
|
},
|
||||||
|
]);
|
||||||
@@ -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 {
|
||||||
65
api/stores/read-store/methods.ts
Normal file
65
api/stores/read-store/methods.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
9
deno.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { create } from "./routes/create.ts";
|
|
||||||
|
|
||||||
export const routes = {
|
|
||||||
create,
|
|
||||||
};
|
|
||||||
@@ -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 }));
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { authenticate } from "./routes/authenticate.ts";
|
|
||||||
|
|
||||||
export * from "./errors.ts";
|
|
||||||
export * from "./strategies.ts";
|
|
||||||
|
|
||||||
export const routes = {
|
|
||||||
authenticate,
|
|
||||||
};
|
|
||||||
@@ -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]);
|
|
||||||
@@ -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] } & {};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -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>;
|
||||||
7
spec/schemas/account/errors.ts
Normal file
7
spec/schemas/account/errors.ts
Normal 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.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
spec/schemas/account/routes.ts
Normal file
20
spec/schemas/account/routes.ts
Normal 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,
|
||||||
|
};
|
||||||
41
spec/schemas/auth/routes.ts
Normal file
41
spec/schemas/auth/routes.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -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>;
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export * from "./avatar.ts";
|
|
||||||
export * from "./contact.ts";
|
|
||||||
export * from "./database.ts";
|
|
||||||
export * from "./email.ts";
|
|
||||||
export * from "./name.ts";
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@spec/shared",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"main": "./mod.ts",
|
|
||||||
"exports": {
|
|
||||||
".": "./mod.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"zod": "4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user