Template
1
0

feat: modular domain driven boilerplate

This commit is contained in:
2025-09-22 01:29:55 +02:00
parent 2433f59d1a
commit 9be3230c84
160 changed files with 2468 additions and 1525 deletions

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQCy5ZoXkKP9mZTk
sKbQdSwspHZqyMH33Gby23+9ycNHMIww7djcWFfPRW4s7tu3SNaac6qVg9OI43+Z
6BPXxuh4nhQ4LX5No9iVEmcWvZtKE4ghwzsoU0llT7+aKl9UYvgqU1YX4zyfiyo2
bW0nVPasEHTyjLCVPK5BKlq+UmuyJTVcduALDnVETpUefu5Vca6tIRXsOovvAf5b
zmcxPccaXIatR/AeipxT0YWoInn8dxD3kyFgTPXtinuBZxvp6MUeSs5IE8OJRJRP
PEo1MQ9HFw9aYRIn9uIkbARbNZMGz77zB1+0TrPGyKOB5lLReWGMUFAJhjLrnTsY
z19se4kNAgMBAAECgf9QkG6A6ViiHIMnUskIDeP5Xir19d9kbGwrcn0F2OXYaX+l
Oot9w3KM6loRJx380/zk/e0Uch1MeZ2fyqQRUmAGQIzkXUm6LUWIekYQN6vZ3JlP
YA2/M+otdd8Tpws9hFSDMUlx0SP3GAi0cE48xdBkVAT0NjZ3Jjor7Wv6GLe//Kzg
1OVrbPAA/+RrPB+BQn5nmZFT0aLuLpyxB4f4ArHG/8DEBY49Syy7/3Ke0kfHMnhl
5Eg5Yau89wSLqEoUSuQvNixu/5nTTQ6v1VYPVG8D1hn773SbNoY9o5vZOPRl1P0q
9YC/qpzPJkm/A5TZLsoalIxuGTdwts+DaEeoKmECgYEA5CddLQbMNu9kYElxpSA3
xXoTL71ZBCQsWExmJrcGe2lQhGO40lF8jE6QnEvMt0mp8Dg9n2ih4J87+2Ozb0fp
2G2ilNeMxM7keywA/+Cwg71QyImppU0lQ5PYLv+pllfxN8FPpLBluy7rDahzphkn
1rijqI5d4bHNG6IgD2ynteECgYEAyLs2eBWxX39Jff3OdpSVmHf7NtacbtsUf1qM
RJSvLsiSwKn39n1+Y6ebzftxm/XD/j8FbN8XvMZMI4OrlfzP+YJaTybIbHrLzCE2
B5E9j0GbJRhJ/D3l9FQBGdY4g5yC4mgbncXURQqqQTtKk2d+ixZSrw8iyDGN+aMJ
ybqZoK0CgYALb6GvARk5Y7R/Uw8cPMou3tiZWv9cQsfqQSIZrLDpfLTpfeokuKrq
iYGcI/yF725SOS91jxQWI0Upa6zx1gP1skEk/szyjIBNYD5IlSWj5NhoxOW5AG3u
vjlm2a/RdmUD62+njKP8xvRHQftSBw7FJ4okh8ZS6suiJ/U9cK/TYQKBgFg+jTyP
dNGhuKJN0NUqjvVfUa4S/ORzJXizStTfdIAhpvpR/nN7SfPvfDw6nQBOM+JyvCTX
kqznlBNM0EL4yElNN/xx9UxTU4Ki2wjKngB7fAP7wJLGd3BI+c7s8R1S0etMj091
59KOVLimoytYJTZqEuFoywatWlfzh9sKUH1lAoGBAID6mqGL3SZhh+i2/kAytfzw
UswTQqA0CCBTzN/Eo1QozmUVTLQPj8rBchNSoiSc92y+lPIL8ePdU7imRB77i+9D
9MSmc5u3ACACOSkwF0JCEGN+Rju4HR5wwm3h6Kvf/FQ3yvSEOKAWhqXIY95qtYTU
j3O+iJbY32pbQsawIAkw
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsuWaF5Cj/ZmU5LCm0HUs
LKR2asjB99xm8tt/vcnDRzCMMO3Y3FhXz0VuLO7bt0jWmnOqlYPTiON/megT18bo
eJ4UOC1+TaPYlRJnFr2bShOIIcM7KFNJZU+/mipfVGL4KlNWF+M8n4sqNm1tJ1T2
rBB08oywlTyuQSpavlJrsiU1XHbgCw51RE6VHn7uVXGurSEV7DqL7wH+W85nMT3H
GlyGrUfwHoqcU9GFqCJ5/HcQ95MhYEz17Yp7gWcb6ejFHkrOSBPDiUSUTzxKNTEP
RxcPWmESJ/biJGwEWzWTBs++8wdftE6zxsijgeZS0XlhjFBQCYYy6507GM9fbHuJ
DQIDAQAB
-----END PUBLIC KEY-----

View File

@@ -0,0 +1,66 @@
import { AggregateRoot, getDate } from "@valkyr/event-store";
import { EventRecord, EventStoreFactory } from "../event-store.ts";
import { CodeIdentity } from "../events/code.ts";
export class Code extends AggregateRoot<EventStoreFactory> {
static override readonly name = "code";
identity!: CodeIdentity;
value!: string;
createdAt!: Date;
claimedAt?: Date;
// -------------------------------------------------------------------------
// Accessors
// -------------------------------------------------------------------------
get isClaimed(): boolean {
return this.claimedAt !== undefined;
}
// -------------------------------------------------------------------------
// Folder
// -------------------------------------------------------------------------
with(event: EventRecord): void {
switch (event.type) {
case "code:created": {
this.value = event.data.value;
this.identity = event.data.identity;
this.createdAt = getDate(event.created);
break;
}
case "code:claimed": {
this.claimedAt = getDate(event.created);
break;
}
}
}
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
create(identity: CodeIdentity): this {
return this.push({
type: "code:created",
stream: this.id,
data: {
identity,
value: crypto
.getRandomValues(new Uint8Array(5))
.map((v) => v % 10)
.join(""),
},
});
}
claim(): this {
return this.push({
type: "code:claimed",
stream: this.id,
});
}
}

View File

@@ -0,0 +1,211 @@
import { AuditActor, auditors } from "@platform/spec/audit/actor.ts";
import { AggregateRoot, getDate } from "@valkyr/event-store";
import { db } from "../database.ts";
import { type EventRecord, eventStore, type EventStoreFactory, projector } from "../event-store.ts";
import type { Avatar } from "../schemas/avatar.ts";
import type { Contact } from "../schemas/contact.ts";
import type { Email } from "../schemas/email.ts";
import type { Name } from "../schemas/name.ts";
import type { Role } from "../schemas/role.ts";
import type { Strategy } from "../schemas/strategies.ts";
export class Identity extends AggregateRoot<EventStoreFactory> {
static override readonly name = "identity";
avatar?: Avatar;
name?: Name;
contact: Contact = {
emails: [],
};
strategies: Strategy[] = [];
roles: Role[] = [];
createdAt!: Date;
updatedAt!: Date;
// -------------------------------------------------------------------------
// Reducer
// -------------------------------------------------------------------------
with(event: EventRecord): void {
switch (event.type) {
case "identity:created": {
this.id = event.stream;
this.createdAt = getDate(event.created);
break;
}
case "identity:avatar:added": {
this.avatar = { url: event.data };
this.updatedAt = getDate(event.created);
break;
}
case "identity:name:added": {
this.name = event.data;
this.updatedAt = getDate(event.created);
break;
}
case "identity:email:added": {
this.contact.emails.push(event.data);
this.updatedAt = getDate(event.created);
break;
}
case "identity:role:added": {
this.roles.push(event.data);
this.updatedAt = getDate(event.created);
break;
}
case "identity:strategy:email:added": {
this.strategies.push({ type: "email", value: event.data });
this.updatedAt = getDate(event.created);
break;
}
case "identity:strategy:password:added": {
this.strategies.push({ type: "password", ...event.data });
this.updatedAt = getDate(event.created);
break;
}
}
}
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
create(meta: AuditActor = auditors.system) {
return this.push({
stream: this.id,
type: "identity:created",
meta,
});
}
addAvatar(url: string, meta: AuditActor = auditors.system): this {
return this.push({
stream: this.id,
type: "identity:avatar:added",
data: url,
meta,
});
}
addName(name: Name, meta: AuditActor = auditors.system): this {
return this.push({
stream: this.id,
type: "identity:name:added",
data: name,
meta,
});
}
addEmail(email: Email, meta: AuditActor = auditors.system): this {
return this.push({
stream: this.id,
type: "identity:email:added",
data: email,
meta,
});
}
addRole(role: Role, meta: AuditActor = auditors.system): this {
return this.push({
stream: this.id,
type: "identity:role:added",
data: role,
meta,
});
}
addEmailStrategy(email: string, meta: AuditActor = auditors.system): this {
return this.push({
stream: this.id,
type: "identity:strategy:email:added",
data: email,
meta,
});
}
addPasswordStrategy(alias: string, password: string, meta: AuditActor = auditors.system): this {
return this.push({
stream: this.id,
type: "identity:strategy:password:added",
data: { alias, password },
meta,
});
}
}
/*
|--------------------------------------------------------------------------------
| Utilities
|--------------------------------------------------------------------------------
*/
export async function isEmailClaimed(email: string): Promise<boolean> {
const relations = await eventStore.relations.getByKey(getIdentityEmailRelation(email));
if (relations.length > 0) {
return true;
}
return false;
}
/*
|--------------------------------------------------------------------------------
| Relations
|--------------------------------------------------------------------------------
*/
export function getIdentityEmailRelation(email: string): string {
return `/identities/emails/${email}`;
}
export function getIdentityAliasRelation(alias: string): string {
return `/identities/aliases/${alias}`;
}
/*
|--------------------------------------------------------------------------------
| Projectors
|--------------------------------------------------------------------------------
*/
projector.on("identity:created", async ({ stream: id }) => {
await db.collection("identities").insertOne({
id,
name: {
given: null,
family: null,
},
contact: {
emails: [],
},
strategies: [],
roles: [],
});
});
projector.on("identity:avatar:added", async ({ stream: id, data: url }) => {
await db.collection("identities").updateOne({ id }, { $set: { avatar: { url } } });
});
projector.on("identity:name:added", async ({ stream: id, data: name }) => {
await db.collection("identities").updateOne({ id }, { $set: { name } });
});
projector.on("identity:email:added", async ({ stream: id, data: email }) => {
await db.collection("identities").updateOne({ id }, { $push: { "contact.emails": email } });
});
projector.on("identity:role:added", async ({ stream: id, data: role }) => {
await db.collection("identities").updateOne({ id }, { $push: { roles: role } });
});
projector.on("identity:strategy:email:added", async ({ stream: id, data: email }) => {
await eventStore.relations.insert(getIdentityEmailRelation(email), id);
await db.collection("identities").updateOne({ id }, { $push: { strategies: { type: "email", value: email } } });
});
projector.on("identity:strategy:password:added", async ({ stream: id, data: strategy }) => {
await eventStore.relations.insert(getIdentityAliasRelation(strategy.alias), id);
await db.collection("identities").updateOne({ id }, { $push: { strategies: { type: "password", ...strategy } } });
});

15
modules/identity/auth.ts Normal file
View File

@@ -0,0 +1,15 @@
import { resources } from "@platform/cerbos/resources.ts";
import { Auth } from "@valkyr/auth";
import { access } from "./auth/access.ts";
import { jwt } from "./auth/jwt.ts";
import { principal } from "./auth/principal.ts";
export const auth = new Auth({
principal,
resources,
access,
jwt,
});
export type Session = typeof auth.$session;

View File

@@ -0,0 +1,89 @@
import { cerbos } from "@platform/cerbos/client.ts";
import { Resource } from "@platform/cerbos/resources.ts";
import type { Principal } from "./principal.ts";
export function access(principal: Principal) {
return {
/**
* Check if a principal is allowed to perform an action on a resource.
*
* @param resource - Resource which we are validating.
* @param action - Action which we are validating.
*
* @example
*
* await access.isAllowed(
* {
* kind: "document",
* id: "1",
* attr: { owner: "user@example.com" },
* },
* "view"
* ); // => true
*/
isAllowed(resource: Resource, action: string) {
return cerbos.isAllowed({ principal, resource, action });
},
/**
* Check a principal's permissions on a resource.
*
* @param resource - Resource which we are validating.
* @param actions - Actions which we are validating.
*
* @example
*
* const decision = await access.checkResource(
* {
* kind: "document",
* id: "1",
* attr: { owner: "user@example.com" },
* },
* ["view", "edit"],
* );
*
* decision.isAllowed("view"); // => true
*/
checkResource(resource: Resource, actions: string[]) {
return cerbos.checkResource({ principal, resource, actions });
},
/**
* Check a principal's permissions on a set of resources.
*
* @param resources - Resources which we are validating.
*
* @example
*
* const decision = await access.checkResources([
* {
* resource: {
* kind: "document",
* id: "1",
* attr: { owner: "user@example.com" },
* },
* actions: ["view", "edit"],
* },
* {
* resource: {
* kind: "image",
* id: "1",
* attr: { owner: "user@example.com" },
* },
* actions: ["delete"],
* },
* ]);
*
* decision.isAllowed({
* resource: { kind: "document", id: "1" },
* action: "view",
* }); // => true
*/
checkResources(resources: { resource: Resource; actions: string[] }[]) {
return cerbos.checkResources({ principal, resources });
},
};
}
export type Access = ReturnType<typeof access>;

View File

@@ -0,0 +1,9 @@
import { config } from "../config.ts";
export const jwt = {
algorithm: "RS256",
privateKey: config.auth.privateKey,
publicKey: config.auth.publicKey,
issuer: "http://localhost",
audience: "http://localhost",
};

View File

@@ -0,0 +1,32 @@
import { HttpAdapter, makeClient } from "@platform/relay";
import { PrincipalProvider } from "@valkyr/auth";
import { config } from "../config.ts";
import resolve from "../routes/identities/resolve/spec.ts";
import { RoleSchema } from "../schemas/role.ts";
export const identity = makeClient(
{
adapter: new HttpAdapter({
url: config.url,
}),
},
{
resolve: resolve.crypto({
publicKey: config.internal.publicKey,
}),
},
);
export const principal = new PrincipalProvider(RoleSchema, {}, async function (id: string) {
const response = await identity.resolve({ params: { id } });
if ("data" in response) {
return {
id,
roles: response.data.roles,
attributes: {},
};
}
});
export type Principal = typeof principal.$principal;

View File

@@ -0,0 +1,53 @@
import { HttpAdapter, makeClient } from "@platform/relay";
import { config } from "./config.ts";
import getById from "./routes/identities/get/spec.ts";
import me from "./routes/identities/me/spec.ts";
import register from "./routes/identities/register/spec.ts";
import loginByPassword from "./routes/login/code/spec.ts";
import loginByEmail from "./routes/login/email/spec.ts";
import loginByCode from "./routes/login/password/spec.ts";
export const identity = makeClient(
{
adapter: new HttpAdapter({
url: config.url,
}),
},
{
/**
* TODO ...
*/
register,
/**
* TODO ...
*/
getById,
/**
* TODO ...
*/
me,
/**
* TODO ...
*/
login: {
/**
* TODO ...
*/
email: loginByEmail,
/**
* TODO ...
*/
password: loginByPassword,
/**
* TODO ...
*/
code: loginByCode,
},
},
);

View File

@@ -0,0 +1,59 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { getEnvironmentVariable } from "@platform/config/environment.ts";
import type { SerializeOptions } from "cookie";
import z from "zod";
export const config = {
url: getEnvironmentVariable({
key: "IDENTITY_SERVICE_URL",
type: z.url(),
fallback: "http://localhost:8370",
}),
auth: {
privateKey: getEnvironmentVariable({
key: "AUTH_PRIVATE_KEY",
type: z.string(),
fallback: await readFile(resolve(import.meta.dirname!, ".keys", "private"), "utf-8"),
}),
publicKey: getEnvironmentVariable({
key: "AUTH_PUBLIC_KEY",
type: z.string(),
fallback: await readFile(resolve(import.meta.dirname!, ".keys", "public"), "utf-8"),
}),
},
internal: {
privateKey: getEnvironmentVariable({
key: "INTERNAL_PRIVATE_KEY",
type: z.string(),
fallback:
"-----BEGIN PRIVATE KEY-----\n" +
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg2WYKMJZUWff5XOWC\n" +
"XGuU+wmsRzhQGEIzfUoL6rrGoaehRANCAATCpiGiFQxTA76EIVG0cBbj+AFt6BuJ\n" +
"t4q+zoInPUzkChCdwI+XfAYokrZwBjcyRGluC02HaN3cptrmjYSGSMSx\n" +
"-----END PRIVATE KEY-----",
}),
publicKey: getEnvironmentVariable({
key: "INTERNAL_PUBLIC_KEY",
type: z.string(),
fallback:
"-----BEGIN PUBLIC KEY-----\n" +
"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwqYhohUMUwO+hCFRtHAW4/gBbegb\n" +
"ibeKvs6CJz1M5AoQncCPl3wGKJK2cAY3MkRpbgtNh2jd3Kba5o2EhkjEsQ==\n" +
"-----END PUBLIC KEY-----",
}),
},
cookie: (maxAge: number) =>
({
httpOnly: true,
secure: getEnvironmentVariable({
key: "AUTH_COOKIE_SECURE",
type: z.coerce.boolean(),
fallback: "false",
}), // Set to true for HTTPS in production
maxAge,
path: "/",
sameSite: "strict",
}) satisfies SerializeOptions,
};

View File

@@ -0,0 +1,11 @@
import * as bcrypt from "@felix/bcrypt";
export const password = { hash, verify };
async function hash(password: string): Promise<string> {
return bcrypt.hash(password);
}
async function verify(password: string, hash: string): Promise<boolean> {
return bcrypt.verify(password, hash);
}

View File

@@ -0,0 +1,49 @@
import { getDatabaseAccessor } from "@platform/database/accessor.ts";
import { type Identity, parseIdentity } from "./models/identity.ts";
import type { PasswordStrategy } from "./schemas/strategies.ts";
export const db = getDatabaseAccessor<{
identities: Identity;
}>(`identity:read-store`);
/*
|--------------------------------------------------------------------------------
| Identity
|--------------------------------------------------------------------------------
*/
/**
* Retrieve a single account by its primary identifier.
*
* @param id - Unique identity.
*/
export async function getIdentityById(id: string): Promise<Identity | undefined> {
return db
.collection("identities")
.findOne({ id })
.then((document) => parseIdentity(document));
}
/**
* 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("identities").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

@@ -0,0 +1,7 @@
import { ConflictError } from "@platform/relay";
export class IdentityEmailClaimedError extends ConflictError {
constructor(email: string) {
super(`Email '${email}' is already claimed by another identity.`);
}
}

View File

@@ -0,0 +1,54 @@
import { container } from "@platform/database/container.ts";
import { EventFactory, EventStore, Prettify, Projector } from "@valkyr/event-store";
import { MongoAdapter } from "@valkyr/event-store/mongo";
/*
|--------------------------------------------------------------------------------
| Event Factory
|--------------------------------------------------------------------------------
*/
const eventFactory = new EventFactory([
...(await import("./events/code.ts")).default,
...(await import("./events/identity.ts")).default,
]);
/*
|--------------------------------------------------------------------------------
| Event Store
|--------------------------------------------------------------------------------
*/
export const eventStore = new EventStore({
adapter: new MongoAdapter(() => container.get("mongo"), `identity:event-store`),
events: eventFactory,
snapshot: "auto",
});
/*
|--------------------------------------------------------------------------------
| Projector
|--------------------------------------------------------------------------------
*/
export const projector = new Projector<EventStoreFactory>();
eventStore.onEventsInserted(async (records, { batch }) => {
if (batch !== undefined) {
await projector.pushMany(batch, records);
} else {
for (const record of records) {
await projector.push(record, { hydrated: false, outdated: false });
}
}
});
/*
|--------------------------------------------------------------------------------
| Events
|--------------------------------------------------------------------------------
*/
export type EventStoreFactory = typeof eventFactory;
export type EventRecord = Prettify<EventStoreFactory["$events"][number]["$record"]>;

View File

@@ -0,0 +1,18 @@
import { event } from "@valkyr/event-store";
import z from "zod";
const CodeIdentitySchema = z.object({
id: 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

@@ -0,0 +1,21 @@
import { AuditActorSchema } from "@platform/spec/audit/actor.ts";
import { event } from "@valkyr/event-store";
import z from "zod";
import { EmailSchema } from "../schemas/email.ts";
import { NameSchema } from "../schemas/name.ts";
import { RoleSchema } from "../schemas/role.ts";
export default [
event.type("identity:created").meta(AuditActorSchema),
event.type("identity:avatar:added").data(z.string()).meta(AuditActorSchema),
event.type("identity:name:added").data(NameSchema).meta(AuditActorSchema),
event.type("identity:email:added").data(EmailSchema).meta(AuditActorSchema),
event.type("identity:role:added").data(RoleSchema).meta(AuditActorSchema),
event.type("identity:strategy:email:added").data(z.string()).meta(AuditActorSchema),
event.type("identity:strategy:passkey:added").meta(AuditActorSchema),
event
.type("identity:strategy:password:added")
.data(z.object({ alias: z.string(), password: z.string() }))
.meta(AuditActorSchema),
];

View File

@@ -0,0 +1,35 @@
import { makeDocumentParser } from "@platform/database/utilities.ts";
import { z } from "zod";
import { AvatarSchema } from "../schemas/avatar.ts";
import { ContactSchema } from "../schemas/contact.ts";
import { NameSchema } from "../schemas/name.ts";
import { RoleSchema } from "../schemas/role.ts";
import { StrategySchema } from "../schemas/strategies.ts";
export const IdentitySchema = z.object({
id: z.uuid(),
avatar: AvatarSchema.optional(),
name: NameSchema.optional(),
contact: ContactSchema.default({
emails: [],
}),
strategies: z.array(StrategySchema).default([]),
roles: z.array(RoleSchema).default([]),
});
/*
|--------------------------------------------------------------------------------
| Parsers
|--------------------------------------------------------------------------------
*/
export const parseIdentity = makeDocumentParser(IdentitySchema);
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
export type Identity = z.infer<typeof IdentitySchema>;

View File

@@ -0,0 +1,28 @@
{
"name": "@modules/identity",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./client.ts": "./client.ts",
"./server.ts": "./server.ts"
},
"types": "types.d.ts",
"dependencies": {
"@cerbos/http": "0.23.1",
"@felix/bcrypt": "npm:@jsr/felix__bcrypt@1.0.5",
"@platform/cerbos": "workspace:*",
"@platform/config": "workspace:*",
"@platform/database": "workspace:*",
"@platform/logger": "workspace:*",
"@platform/relay": "workspace:*",
"@platform/server": "workspace:*",
"@platform/spec": "workspace:*",
"@platform/storage": "workspace:*",
"@platform/vault": "workspace:*",
"@valkyr/auth": "npm:@jsr/valkyr__auth@2.1.4",
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.1",
"cookie": "1.0.2",
"zod": "4.1.11"
}
}

View File

@@ -0,0 +1,47 @@
# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/Policy.schema.json
# docs: https://docs.cerbos.dev/cerbos/latest/policies/resource_policies
apiVersion: api.cerbos.dev/v1
resourcePolicy:
resource: identity
version: default
rules:
### Read
- actions:
- read
effect: EFFECT_ALLOW
roles:
- admin
- actions:
- read
effect: EFFECT_ALLOW
roles:
- user
condition:
match:
expr: request.resource.id == request.principal.id
### Update
- actions:
- update
effect: EFFECT_ALLOW
roles:
- user
condition:
match:
expr: request.resource.id == request.principal.id
### Delete
- actions:
- delete
effect: EFFECT_ALLOW
roles:
- user
condition:
match:
expr: request.resource.id == request.principal.id

View File

@@ -0,0 +1,16 @@
import { ForbiddenError, NotFoundError } from "@platform/relay";
import { getIdentityById } from "../../../database.ts";
import route from "./spec.ts";
export default route.access("session").handle(async ({ params: { id } }, { access }) => {
const identity = await getIdentityById(id);
if (identity === undefined) {
return new NotFoundError("Identity does not exist, or has been removed.");
}
const decision = await access.isAllowed({ kind: "identity", id: identity.id, attr: {} }, "read");
if (decision === false) {
return new ForbiddenError("You do not have permission to view this identity.");
}
return identity;
});

View File

@@ -0,0 +1,12 @@
import { ForbiddenError, NotFoundError, route, UnauthorizedError } from "@platform/relay";
import z from "zod";
import { IdentitySchema } from "../../../models/identity.ts";
export default route
.get("/api/v1/identities/:id")
.params({
id: z.string(),
})
.errors([UnauthorizedError, ForbiddenError, NotFoundError])
.response(IdentitySchema);

View File

@@ -0,0 +1,12 @@
import { UnauthorizedError } from "@platform/relay";
import { getIdentityById } from "../../../database.ts";
import route from "./spec.ts";
export default route.access("session").handle(async ({ principal }) => {
const identity = await getIdentityById(principal.id);
if (identity === undefined) {
return new UnauthorizedError("You must be signed in to view your session.");
}
return identity;
});

View File

@@ -0,0 +1,5 @@
import { NotFoundError, route, UnauthorizedError } from "@platform/relay";
import { IdentitySchema } from "../../../models/identity.ts";
export default route.get("/api/v1/identities/me").response(IdentitySchema).errors([UnauthorizedError, NotFoundError]);

View File

@@ -0,0 +1,11 @@
import { Identity, isEmailClaimed } from "../../../aggregates/identity.ts";
import { IdentityEmailClaimedError } from "../../../errors.ts";
import { eventStore } from "../../../event-store.ts";
import route from "./spec.ts";
export default route.access("public").handle(async ({ body: { name, email } }) => {
if ((await isEmailClaimed(email)) === true) {
return new IdentityEmailClaimedError(email);
}
return eventStore.aggregate.from(Identity).create().addName(name).addEmailStrategy(email).addRole("user").save();
});

View File

@@ -0,0 +1,17 @@
import { route } from "@platform/relay";
import z from "zod";
import { IdentityEmailClaimedError } from "../../../errors.ts";
import { IdentitySchema } from "../../../models/identity.ts";
import { NameSchema } from "../../../schemas/name.ts";
export default route
.post("/api/v1/identities")
.body(
z.object({
name: NameSchema,
email: z.email(),
}),
)
.errors([IdentityEmailClaimedError])
.response(IdentitySchema);

View File

@@ -0,0 +1,13 @@
import { NotFoundError } from "@platform/relay";
import { config } from "../../../config.ts";
import { getIdentityById } from "../../../database.ts";
import route from "./spec.ts";
export default route.access(["internal:public", config.internal.privateKey]).handle(async ({ params: { id } }) => {
const identity = await getIdentityById(id);
if (identity === undefined) {
return new NotFoundError();
}
return identity;
});

View File

@@ -0,0 +1,5 @@
import { importVault } from "@platform/vault";
import { config } from "../../../config.ts";
export const vault = importVault(config.internal);

View File

@@ -0,0 +1,12 @@
import { NotFoundError, route, UnauthorizedError } from "@platform/relay";
import z from "zod";
import { IdentitySchema } from "../../../models/identity.ts";
export default route
.get("/api/v1/identities/:id/resolve")
.params({
id: z.string(),
})
.response(IdentitySchema)
.errors([UnauthorizedError, NotFoundError]);

View File

@@ -0,0 +1,85 @@
import { logger } from "@platform/logger";
import cookie from "cookie";
import { Code } from "../../../aggregates/code.ts";
import { Identity } from "../../../aggregates/identity.ts";
import { auth } from "../../../auth.ts";
import { config } from "../../../config.ts";
import { eventStore } from "../../../event-store.ts";
import route from "./spec.ts";
export default route.access("public").handle(async ({ params: { identityId, 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.id !== identityId) {
return logger.info({
type: "code:claimed",
session: false,
message: "Invalid Identity ID",
expected: code.identity.id,
received: identityId,
});
}
const account = await eventStore.aggregate.getByStream(Identity, identityId);
if (account === undefined) {
return logger.info({
type: "code:claimed",
session: false,
message: "Account Not Found",
expected: code.identity.id,
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({ id: account.id }, "1 week"), options),
},
});
}
return new Response(null, {
status: 200,
headers: {
"set-cookie": cookie.serialize("token", await auth.generate({ id: account.id }, "1 week"), options),
},
});
});

View File

@@ -0,0 +1,13 @@
import { route } from "@platform/relay";
import z from "zod";
export default route
.get("/api/v1/identities/login/code/:identityId/code/:codeId/:value")
.params({
identityId: z.string(),
codeId: z.string(),
value: z.string(),
})
.query({
next: z.string().optional(),
});

View File

@@ -0,0 +1,27 @@
import { logger } from "@platform/logger";
import { Code } from "../../../aggregates/code.ts";
import { getIdentityEmailRelation, Identity } from "../../../aggregates/identity.ts";
import { eventStore } from "../../../event-store.ts";
import route from "./spec.ts";
export default route.access("public").handle(async ({ body: { base, email } }) => {
const identity = await eventStore.aggregate.getByRelation(Identity, getIdentityEmailRelation(email));
if (identity === undefined) {
return logger.info({
type: "auth:email",
code: false,
message: "Identity Not Found",
received: email,
});
}
const code = await eventStore.aggregate.from(Code).create({ id: identity.id }).save();
logger.info({
type: "auth:email",
data: {
code: code.id,
identityId: identity.id,
},
link: `${base}/api/v1/admin/auth/${identity.id}/code/${code.id}/${code.value}?next=${base}/admin`,
});
});

View File

@@ -0,0 +1,9 @@
import { route } from "@platform/relay";
import z from "zod";
export default route.post("/api/v1/identities/login/email").body(
z.object({
base: z.url(),
email: z.email(),
}),
);

View File

@@ -0,0 +1,36 @@
import { logger } from "@platform/logger";
import { BadRequestError } from "@platform/relay";
import cookie from "cookie";
import { auth } from "../../../auth.ts";
import { config } from "../../../config.ts";
import { password } from "../../../crypto/password.ts";
import { getPasswordStrategyByAlias } from "../../../database.ts";
import route from "./spec.ts";
export default route.access("public").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({ id: strategy.accountId }, "1 week"),
config.cookie(1000 * 60 * 60 * 24 * 7),
),
},
});
});

View File

@@ -0,0 +1,9 @@
import { route } from "@platform/relay";
import z from "zod";
export default route.post("/api/v1/identities/login/password").body(
z.object({
alias: z.string(),
password: z.string(),
}),
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import z from "zod";
export const RoleSchema = z.union([z.literal("user"), z.literal("admin")]);
export type Role = z.infer<typeof RoleSchema>;

View File

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

View File

@@ -0,0 +1,96 @@
import "./types.d.ts";
import { idIndex } from "@platform/database/id.ts";
import { register as registerReadStore } from "@platform/database/registrar.ts";
import { UnauthorizedError } from "@platform/relay";
import { context } from "@platform/relay";
import { storage } from "@platform/storage";
import { register as registerEventStore } from "@valkyr/event-store/mongo";
import cookie from "cookie";
import { auth } from "./auth.ts";
import { db } from "./database.ts";
import { eventStore } from "./event-store.ts";
export default {
routes: [
(await import("./routes/identities/get/handle.ts")).default,
(await import("./routes/identities/register/handle.ts")).default,
(await import("./routes/identities/me/handle.ts")).default,
(await import("./routes/identities/resolve/handle.ts")).default,
(await import("./routes/login/code/handle.ts")).default,
(await import("./routes/login/email/handle.ts")).default,
(await import("./routes/login/password/handle.ts")).default,
],
/**
* TODO ...
*/
bootstrap: async (): Promise<void> => {
await registerReadStore(db.db, [
{
name: "identities",
indexes: [
idIndex,
[{ "strategies.type": 1, "strategies.alias": 1 }, { name: "strategy.password" }],
[{ "strategies.type": 1, "strategies.value": 1 }, { name: "strategy.email" }],
],
},
]);
await registerEventStore(eventStore.db.db, console.info);
Object.defineProperties(context, {
/**
* TODO ...
*/
isAuthenticated: {
get() {
return storage.getStore()?.principal !== undefined;
},
},
/**
* TODO ...
*/
principal: {
get() {
const principal = storage.getStore()?.principal;
if (principal === undefined) {
throw new UnauthorizedError();
}
return principal;
},
},
/**
* TODO ...
*/
access: {
get() {
const access = storage.getStore()?.access;
if (access === undefined) {
throw new UnauthorizedError();
}
return access;
},
},
});
},
/**
* TODO ...
*/
resolve: async (request: Request): Promise<void> => {
const token = cookie.parse(request.headers.get("cookie") ?? "").token;
if (token !== undefined) {
const session = await auth.resolve(token);
if (session.valid === true) {
const context = storage.getStore();
if (context === undefined) {
return;
}
context.principal = session.principal;
context.access = session.access;
}
}
},
};

38
modules/identity/types.d.ts vendored Normal file
View File

@@ -0,0 +1,38 @@
import "@platform/relay";
import "@platform/storage";
import type { Access } from "./auth/access.ts";
import type { Principal } from "./auth/principal.ts";
declare module "@platform/storage" {
interface StorageContext {
/**
* TODO ...
*/
principal?: Principal;
/**
* TODO ...
*/
access?: Access;
}
}
declare module "@platform/relay" {
interface ServerContext {
/**
* TODO ...
*/
isAuthenticated: boolean;
/**
* TODO ...
*/
principal: Principal;
/**
* TODO ...
*/
access: Access;
}
}