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

@@ -1,5 +1,5 @@
meta { meta {
name: account name: Identity
seq: 1 seq: 1
} }

View File

@@ -5,13 +5,13 @@ meta {
} }
get { get {
url: {{url}}/accounts/:id url: {{url}}/identities/:id
body: none body: none
auth: inherit auth: inherit
} }
params:path { params:path {
id: id: 16b88034-ca82-4a8e-9fe5-13bd0dd29b75
} }
settings { settings {

View File

@@ -5,15 +5,15 @@ meta {
} }
get { get {
url: {{url}}/auth/code/:accountId/code/:codeId/:value url: {{url}}/identities/login/code/:identityId/code/:codeId/:value
body: none body: none
auth: inherit auth: inherit
} }
params:path { params:path {
accountId: identityId: efefa471-905d-4702-bd0a-863d8cf70424
codeId: codeId: 7055b769-0814-47b8-836e-cfef2d8c2e68
value: value: 00597
} }
script:post-response { script:post-response {

View File

@@ -5,7 +5,7 @@ meta {
} }
post { post {
url: {{url}}/auth/email url: {{url}}/identities/login/email
body: json body: json
auth: inherit auth: inherit
} }

View File

@@ -1,5 +1,5 @@
meta { meta {
name: auth name: Login
seq: 2 seq: 2
} }

View File

@@ -1,11 +1,11 @@
meta { meta {
name: Session name: Me
type: http type: http
seq: 3 seq: 3
} }
get { get {
url: {{url}}/auth/session url: {{url}}/identities/me
body: none body: none
auth: inherit auth: inherit
} }

View File

@@ -1,11 +1,11 @@
meta { meta {
name: Create name: Register
type: http type: http
seq: 1 seq: 1
} }
post { post {
url: {{url}}/accounts url: {{url}}/identities
body: json body: json
auth: inherit auth: inherit
} }
@@ -13,10 +13,10 @@ post {
body:json { body:json {
{ {
"name": { "name": {
"given": "John", "given": "Jane",
"family": "Doe" "family": "Doe"
}, },
"email": "john.doe@fixture.none" "email": "jane.doe@fixture.none"
} }
} }

View File

@@ -1,70 +0,0 @@
import { resolve } from "node:path";
import { logger } from "~libraries/logger/mod.ts";
const LIBRARIES_DIR = resolve(import.meta.dirname!, "..", "libraries");
const STORES_DIR = resolve(import.meta.dirname!, "..", "stores");
const log = logger.prefix("Bootstrap");
/*
|--------------------------------------------------------------------------------
| Database
|--------------------------------------------------------------------------------
*/
await import("~libraries/database/.tasks/bootstrap.ts");
/*
|--------------------------------------------------------------------------------
| Packages
|--------------------------------------------------------------------------------
*/
await bootstrap(LIBRARIES_DIR);
await bootstrap(STORES_DIR);
/*
|--------------------------------------------------------------------------------
| Helpers
|--------------------------------------------------------------------------------
*/
/**
* Traverse path and look for a `bootstrap.ts` file in each folder found under
* the given path. If a `boostrap.ts` file is found it is imported so its content
* is executed.
*
* @param path - Path to resolve `bootstrap.ts` files.
*/
export async function bootstrap(path: string): Promise<void> {
const bootstrap: { name: string; path: string }[] = [];
for await (const entry of Deno.readDir(path)) {
if (entry.isDirectory === true) {
const moduleName = path.split("/").pop();
if (moduleName === undefined) {
continue;
}
const filePath = `${path}/${entry.name}/.tasks/bootstrap.ts`;
if (await hasFile(filePath)) {
bootstrap.push({ name: entry.name, path: filePath });
}
}
}
for (const entry of bootstrap) {
log.info(entry.name);
await import(entry.path);
}
}
async function hasFile(filePath: string) {
try {
await Deno.lstat(filePath);
} catch (err) {
if (!(err instanceof Deno.errors.NotFound)) {
throw err;
}
return false;
}
return true;
}

View File

@@ -1,66 +0,0 @@
import { resolve } from "node:path";
import process from "node:process";
import { exists } from "@std/fs";
import { config } from "~libraries/database/config.ts";
import { getMongoClient } from "~libraries/database/connection.ts";
import { container } from "~libraries/database/container.ts";
import { logger } from "~libraries/logger/mod.ts";
/*
|--------------------------------------------------------------------------------
| Dependencies
|--------------------------------------------------------------------------------
*/
const client = getMongoClient(config.mongo);
container.set("client", client);
/*
|--------------------------------------------------------------------------------
| Migrate
|--------------------------------------------------------------------------------
*/
const db = client.db("api:migrations");
const collection = db.collection<MigrationDocument>("migrations");
const { default: journal } = await import(resolve(import.meta.dirname!, "migrations", "meta", "_journal.json"), {
with: { type: "json" },
});
const migrations =
(await collection.findOne({ name: journal.name })) ?? ({ name: journal.name, entries: [] } as MigrationDocument);
for (const entry of journal.entries) {
const migrationFileName = `${String(entry.idx).padStart(4, "0")}_${entry.name}.ts`;
if (migrations.entries.includes(migrationFileName)) {
continue;
}
const migrationPath = resolve(import.meta.dirname!, "migrations", migrationFileName);
if (await exists(migrationPath)) {
await import(migrationPath);
await collection.updateOne(
{
name: journal.name,
},
{
$set: { name: journal.name },
$push: { entries: migrationFileName }, // Assuming 'entries' is an array
},
{
upsert: true,
},
);
logger.info(`Migrated ${migrationPath}`);
}
}
type MigrationDocument = {
name: string;
entries: string[];
};
process.exit(0);

View File

@@ -1,4 +0,0 @@
{
"name": "api",
"entries": []
}

View File

@@ -1,9 +1,12 @@
import { config as auth } from "~libraries/auth/config.ts"; import { getEnvironmentVariable } from "@platform/config/environment.ts";
import { getEnvironmentVariable, toNumber } from "~libraries/config/mod.ts"; import z from "zod";
export const config = { export const config = {
name: "valkyr", name: "@valkyr/boilerplate",
host: getEnvironmentVariable("API_HOST", "0.0.0.0"), host: getEnvironmentVariable({ key: "API_HOST", type: z.ipv4(), fallback: "0.0.0.0" }),
port: getEnvironmentVariable("API_PORT", toNumber, "8370"), port: getEnvironmentVariable({
...auth, key: "API_PORT",
type: z.coerce.number(),
fallback: "8370",
}),
}; };

View File

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

View File

@@ -1,21 +0,0 @@
import { Auth } from "@valkyr/auth";
import { access } from "./access.ts";
import { config } from "./config.ts";
import { principal } from "./principal.ts";
import { resources } from "./resources.ts";
export const auth = new Auth({
principal,
resources,
access,
jwt: {
algorithm: "RS256",
privateKey: config.privateKey,
publicKey: config.publicKey,
issuer: "http://localhost",
audience: "http://localhost",
},
});
export type Session = typeof auth.$session;

View File

@@ -1,25 +0,0 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import type { SerializeOptions } from "cookie";
import { getEnvironmentVariable, toBoolean } from "~libraries/config/mod.ts";
export const config = {
privateKey: getEnvironmentVariable(
"AUTH_PRIVATE_KEY",
await readFile(resolve(import.meta.dirname!, ".keys", "private"), "utf-8"),
),
publicKey: getEnvironmentVariable(
"AUTH_PUBLIC_KEY",
await readFile(resolve(import.meta.dirname!, ".keys", "public"), "utf-8"),
),
cookie: (maxAge: number) =>
({
httpOnly: true,
secure: getEnvironmentVariable("AUTH_COOKIE_SECURE", toBoolean, "false"), // Set to true for HTTPS in production
maxAge,
path: "/",
sameSite: "strict",
}) satisfies SerializeOptions,
};

View File

@@ -1,6 +0,0 @@
import { auth } from "./auth.ts";
export * from "./auth.ts";
export * from "./config.ts";
export type Auth = typeof auth;

View File

@@ -1,18 +0,0 @@
import { RoleSchema } from "@platform/spec/account/role.ts";
import { PrincipalProvider } from "@valkyr/auth";
import { db } from "~stores/read-store/database.ts";
export const principal = new PrincipalProvider(RoleSchema, {}, async function (id: string) {
const account = await db.collection("accounts").findOne({ id });
if (account === null) {
return undefined;
}
return {
id,
roles: account.roles,
attributes: {},
};
});
export type Principal = typeof principal.$principal;

View File

@@ -1,21 +0,0 @@
import { parseArgs } from "@std/cli";
import { Parser, toString } from "./parsers.ts";
export function getArgsVariable(key: string, fallback?: any): string;
export function getArgsVariable<T extends Parser>(key: string, parse: T, fallback?: any): ReturnType<T>;
export function getArgsVariable<T extends Parser>(key: string, parse?: T, fallback?: any): ReturnType<T> {
if (typeof parse === "string") {
fallback = parse;
parse = undefined;
}
const flags = parseArgs(Deno.args);
const value = flags[key];
if (value === undefined) {
if (fallback !== undefined) {
return parse ? parse(fallback) : fallback;
}
throw new Error(`Config Exception: Missing ${key} variable in arguments`);
}
return parse ? parse(value) : (toString(value) as any);
}

View File

@@ -1,79 +0,0 @@
import { load } from "@std/dotenv";
import type { z } from "zod";
import { Env, Parser, toServiceEnv, toString } from "./parsers.ts";
const env = await load();
/**
* Get an environment variable and parse it to the desired type.
*
* @param key - Environment key to resolve.
* @param parse - Parser function to convert the value to the desired type. Default: `string`.
*/
export function getEnvironmentVariable(key: string, fallback?: any): string;
export function getEnvironmentVariable<T extends Parser>(key: string, parse: T, fallback?: any): ReturnType<T>;
export function getEnvironmentVariable<T extends Parser>(key: string, parse?: T, fallback?: any): ReturnType<T> {
if (typeof parse === "string") {
fallback = parse;
parse = undefined;
}
const value = env[key] ?? Deno.env.get(key);
if (value === undefined) {
if (fallback !== undefined) {
return parse ? parse(fallback) : fallback;
}
throw new Error(`Config Exception: Missing ${key} variable in configuration`);
}
return parse ? parse(value) : (toString(value) as any);
}
/**
* Get an environment variable, select value based on ENV map and parse it to the desired type. Can be used with simple primitives or objects / arrays
*
* @export
* @param {{
* key: string;
* envFallback?: FallbackEnvMap;
* fallback: string;
* validation: z.ZodTypeAny,
* }} options
* @param {string} options.key - the name of the env variable
* @param {object} options.envFallback - map with env specific fallbacks that will be used if none value provided
* @param {string} options.envFallback.local - example "local" SERVICE_ENV target fallback value
* @param {string} options.fallback - string fallback that will be used if no env variable found
* @param {z.ZodTypeAny} options.validation - Zod validation object or validation primitive
* @returns {z.infer<typeof validation>} - Returns the inferred type of the validation provided
*/
export function validateEnvVariable({
key,
envFallback,
fallback,
validation,
}: {
key: string;
validation: z.ZodTypeAny;
envFallback?: FallbackEnvMap;
fallback?: string;
}): z.infer<typeof validation> {
const serviceEnv = getEnvironmentVariable("SERVICE_ENV", toServiceEnv, "local");
const providedValue = env[key] ?? Deno.env.get(key);
const fallbackValue = typeof envFallback === "object" ? (envFallback[serviceEnv] ?? fallback) : fallback;
const toBeUsed = providedValue ?? fallbackValue;
try {
if (typeof toBeUsed === "string" && (toBeUsed.trim().startsWith("{") || toBeUsed.trim().startsWith("["))) {
return validation.parse(JSON.parse(toBeUsed));
}
return validation.parse(toBeUsed);
} catch (e) {
throw new Deno.errors.InvalidData(`Config Exception: Missing valid ${key} variable in configuration`, { cause: e });
}
}
type FallbackEnvMap = Partial<Record<Env, string>> & {
testing?: string;
local?: string;
stg?: string;
demo?: string;
prod?: string;
};

View File

@@ -1,98 +0,0 @@
const SERVICE_ENV = ["testing", "local", "stg", "demo", "prod"] as const;
/**
* Convert an variable to a string.
*
* @param value - Value to convert.
*/
export function toString(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (typeof value === "number") {
return value.toString();
}
throw new Error(`Config Exception: Cannot convert ${value} to string`);
}
/**
* Convert an variable to a number.
*
* @param value - Value to convert.
*/
export function toNumber(value: unknown): number {
if (typeof value === "number") {
return value;
}
if (typeof value === "string") {
return parseInt(value);
}
throw new Error(`Config Exception: Cannot convert ${value} to number`);
}
/**
* Convert an variable to a boolean.
*
* @param value - Value to convert.
*/
export function toBoolean(value: unknown): boolean {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
return value === "true" || value === "1";
}
throw new Error(`Config Exception: Cannot convert ${value} to boolean`);
}
/**
* Convert a variable to an array of strings.
*
* Expects a comma seprated, eg. foo,bar,foobar
*
* @param value - Value to convert.
*/
export function toArray(value: unknown): string[] {
if (typeof value === "string") {
if (value === "") {
return [];
}
return value.split(",");
}
throw new Error(`Config Exception: Cannot convert ${value} to array`);
}
/**
* Ensure the given value is a valid SERVICE_ENV variable.
*
* @param value - Value to validate.
*/
export function toServiceEnv(value: unknown): Env {
assertServiceEnv(value);
return value;
}
/*
|--------------------------------------------------------------------------------
| Assertions
|--------------------------------------------------------------------------------
*/
function assertServiceEnv(value: unknown): asserts value is Env {
if (typeof value !== "string") {
throw new Error(`Config Exception: Env ${value} is not a string`);
}
if ((SERVICE_ENV as unknown as string[]).includes(value) === false) {
throw new Error(`Config Exception: Invalid env ${value} provided`);
}
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
export type Parser = (value: unknown) => any;
export type Env = (typeof SERVICE_ENV)[number];

View File

@@ -1,3 +0,0 @@
export * from "./libraries/args.ts";
export * from "./libraries/environment.ts";
export * from "./libraries/parsers.ts";

View File

@@ -1 +0,0 @@
export * from "./password.ts";

View File

@@ -1,5 +0,0 @@
import { config } from "../config.ts";
import { getMongoClient } from "../connection.ts";
import { container } from "../container.ts";
container.set("client", getMongoClient(config.mongo));

View File

@@ -1,10 +0,0 @@
import { getEnvironmentVariable, toNumber } from "~libraries/config/mod.ts";
export const config = {
mongo: {
host: getEnvironmentVariable("DB_MONGO_HOST", "localhost"),
port: getEnvironmentVariable("DB_MONGO_PORT", toNumber, "27017"),
user: getEnvironmentVariable("DB_MONGO_USER", "root"),
pass: getEnvironmentVariable("DB_MONGO_PASSWORD", "password"),
},
};

View File

@@ -1,5 +0,0 @@
import { getArgsVariable } from "~libraries/config/mod.ts";
export const config = {
level: getArgsVariable("LOG_LEVEL", "info"),
};

View File

@@ -1,68 +0,0 @@
import { ServerContext } from "@platform/relay";
import type { Sockets } from "~libraries/socket/sockets.ts";
import { Access } from "../auth/access.ts";
import { Session } from "../auth/auth.ts";
import { Principal } from "../auth/principal.ts";
import { req } from "./request.ts";
declare module "@platform/relay" {
interface ServerContext {
/**
* Current request instance being handled.
*/
request: Request;
/**
* Is the request authenticated.
*/
isAuthenticated: boolean;
/**
* Get request session instance.
*/
session: Session;
/**
* Get request principal.
*/
principal: Principal;
/**
* Get access control session.
*/
access: Access;
/**
* Sockets instance attached to the server.
*/
sockets: Sockets;
}
}
export function getRequestContext(request: Request): ServerContext {
return {
request,
get isAuthenticated(): boolean {
return req.isAuthenticated;
},
get session(): Session {
return req.session;
},
get principal(): Principal {
return req.session.principal;
},
get access(): Access {
return req.session.access;
},
get sockets(): Sockets {
return req.sockets;
},
};
}

View File

@@ -1,5 +0,0 @@
export * from "./api.ts";
export * from "./context.ts";
export * from "./modules.ts";
export * from "./request.ts";
export * from "./storage.ts";

View File

@@ -1,62 +0,0 @@
import { InternalServerError, UnauthorizedError } from "@platform/relay";
import { Session } from "../auth/auth.ts";
import { storage } from "./storage.ts";
export const req = {
get store() {
const store = storage.getStore();
if (store === undefined) {
throw new InternalServerError("AsyncLocalStorage not defined.");
}
return store;
},
get sockets() {
if (this.store.sockets === undefined) {
throw new InternalServerError("Sockets not defined.");
}
return this.store.sockets;
},
/**
* Check if the request is authenticated.
*/
get isAuthenticated(): boolean {
return this.session !== undefined;
},
/**
* Get current session.
*/
get session(): Session {
if (this.store.session === undefined) {
throw new UnauthorizedError();
}
return this.store.session;
},
/**
* Gets the meta information stored in the request.
*/
get info() {
return this.store.info;
},
/**
* Get current session.
*/
getSession(): Session | undefined {
return this.store.session;
},
/**
* Get store that is potentially undefined.
* Typically used when utility functions might run in and out of request scope.
*/
getStore() {
return storage.getStore();
},
} as const;
export type ReqContext = typeof req;

View File

@@ -1,19 +0,0 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type { Session } from "~libraries/auth/mod.ts";
import type { Sockets } from "~libraries/socket/sockets.ts";
export const storage = new AsyncLocalStorage<Storage>();
export type Storage = {
session?: Session;
info: {
method: string;
start: number;
end?: number;
};
sockets?: Sockets;
response: {
headers: Headers;
};
};

View File

@@ -1,61 +0,0 @@
import { toJsonRpc } from "@valkyr/json-rpc";
import { Session } from "~libraries/auth/mod.ts";
import { logger } from "~libraries/logger/mod.ts";
import { asyncLocalStorage } from "~libraries/server/storage.ts";
import { sockets } from "./sockets.ts";
export function upgrade(request: Request, session?: Session) {
const { socket, response } = Deno.upgradeWebSocket(request);
socket.addEventListener("open", () => {
logger.prefix("Socket").info("socket connected", { session });
sockets.add(socket);
});
socket.addEventListener("close", () => {
logger.prefix("Socket").info("socket disconnected", { session });
sockets.del(socket);
});
socket.addEventListener("message", (event) => {
if (event.data === "ping") {
return;
}
const message = toJsonRpc(event.data);
logger.prefix("Socket").info(message);
asyncLocalStorage.run(
{
session,
info: {
method: message.method!,
start: Date.now(),
},
sockets,
response: {
headers: new Headers(),
},
},
async () => {
// api
// .send(body)
// .then((response) => {
// if (response !== undefined) {
// logger.info({ response });
// socket.send(JSON.stringify(response));
// }
// })
// .catch((error) => {
// logger.info({ error });
// socket.send(JSON.stringify(error));
// });
},
);
});
return response;
}

View File

@@ -1,25 +1,9 @@
{ {
"private": true, "private": true,
"scripts": { "scripts": {
"start": "deno --allow-all --watch-hmr=routes/ server.ts", "start": "deno --allow-all --watch-hmr=routes/ server.ts"
"migrate": "deno run --allow-all .tasks/migrate.ts"
}, },
"dependencies": { "dependencies": {
"@cerbos/http": "0.23.1", "zod": "4.1.11"
"@felix/bcrypt": "npm:@jsr/felix__bcrypt@1.0.5",
"@platform/models": "workspace:*",
"@platform/relay": "workspace:*",
"@platform/spec": "workspace:*",
"@std/cli": "npm:@jsr/std__cli@1.0.22",
"@std/dotenv": "npm:@jsr/std__dotenv@0.225.5",
"@std/fs": "npm:@jsr/std__fs@1.0.19",
"@std/path": "npm:@jsr/std__path@1.1.2",
"@valkyr/auth": "npm:@jsr/valkyr__auth@2.1.4",
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.1",
"@valkyr/inverse": "npm:@jsr/valkyr__inverse@1.0.1",
"@valkyr/json-rpc": "npm:@jsr/valkyr__json-rpc@1.1.0",
"cookie": "1.0.2",
"mongodb": "6.20.0",
"zod": "4.1.9"
} }
} }

View File

@@ -1,19 +0,0 @@
import { AccountEmailClaimedError } from "@platform/spec/account/errors.ts";
import { create } from "@platform/spec/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)
.addRole("user")
.save()
.then((account) => account.id);
});

View File

@@ -1,16 +0,0 @@
import { ForbiddenError, NotFoundError } from "@platform/relay";
import { getById } from "@platform/spec/account/routes.ts";
import { db } from "~stores/read-store/database.ts";
export default getById.access("authenticated").handle(async ({ params: { id } }, { access }) => {
const account = await db.collection("accounts").findOne({ id });
if (account === null) {
return new NotFoundError();
}
const decision = await access.isAllowed({ kind: "account", id: account.id, attributes: {} }, "read");
if (decision === false) {
return new ForbiddenError();
}
return account;
});

View File

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

View File

@@ -1,12 +0,0 @@
import { UnauthorizedError } from "@platform/relay";
import { session } from "@platform/spec/auth/routes.ts";
import { getAccountById } from "~stores/read-store/methods.ts";
export default session.access("authenticated").handle(async ({ principal }) => {
const account = await getAccountById(principal.id);
if (account === undefined) {
return new UnauthorizedError();
}
return account;
});

View File

@@ -1,15 +1,14 @@
import { resolve } from "@std/path"; import identity from "@modules/identity/server.ts";
import cookie from "cookie"; import database from "@platform/database/server.ts";
import { logger } from "@platform/logger";
import { auth, type Session } from "~libraries/auth/mod.ts"; import { context } from "@platform/relay";
import { logger } from "~libraries/logger/mod.ts"; import { Api } from "@platform/server/api.ts";
import { type Storage, storage } from "~libraries/server/mod.ts"; import server from "@platform/server/server.ts";
import { Api, resolveRoutes } from "~libraries/server/mod.ts"; import socket from "@platform/socket/server.ts";
import { storage } from "@platform/storage";
import { config } from "./config.ts"; import { config } from "./config.ts";
const ROUTES_DIR = resolve(import.meta.dirname!, "routes");
const log = logger.prefix("Server"); const log = logger.prefix("Server");
/* /*
@@ -18,7 +17,15 @@ const log = logger.prefix("Server");
|-------------------------------------------------------------------------------- |--------------------------------------------------------------------------------
*/ */
await import("./.tasks/bootstrap.ts"); // ### Platform
await database.bootstrap();
await server.bootstrap();
await socket.bootstrap();
// ### Modules
await identity.bootstrap();
/* /*
|-------------------------------------------------------------------------------- |--------------------------------------------------------------------------------
@@ -26,7 +33,7 @@ await import("./.tasks/bootstrap.ts");
|-------------------------------------------------------------------------------- |--------------------------------------------------------------------------------
*/ */
const api = new Api(await resolveRoutes(ROUTES_DIR)); const api = new Api([...identity.routes]);
/* /*
|-------------------------------------------------------------------------------- |--------------------------------------------------------------------------------
@@ -42,42 +49,25 @@ Deno.serve(
logger.prefix("Server").info(`Listening at http://${hostname}:${port}`); logger.prefix("Server").info(`Listening at http://${hostname}:${port}`);
}, },
}, },
async (request) => { async (request) =>
storage.run({}, async () => {
const url = new URL(request.url); const url = new URL(request.url);
let session: Session | undefined; // ### Storage Context
// Resolve storage context for all dependent modules.
const token = cookie.parse(request.headers.get("cookie") ?? "").token; await server.resolve(request);
if (token !== undefined) { await socket.resolve();
const resolved = await auth.resolve(token);
if (resolved.valid === false) {
return new Response(resolved.message, {
status: 401,
headers: {
"set-cookie": cookie.serialize("token", "", config.cookie(0)),
},
});
}
session = resolved;
}
const context = { await identity.resolve(request);
session,
info: { // ### Fetch
method: request.url, // Execute fetch against the api instance.
start: Date.now(),
},
response: {
headers: new Headers(),
},
} satisfies Storage;
return storage.run(context, async () => {
return api.fetch(request).finally(() => { return api.fetch(request).finally(() => {
log.info( log.info(
`${request.method} ${url.pathname} [${((Date.now() - context.info.start) / 1000).toLocaleString()} seconds]`, `${request.method} ${url.pathname} [${((Date.now() - context.info.start) / 1000).toLocaleString()} seconds]`,
); );
}); });
}); }),
},
); );

View File

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

View File

@@ -1,217 +0,0 @@
import { toAccountDocument } from "@platform/models/account.ts";
import { Avatar } from "@platform/models/value-objects/avatar.ts";
import { Contact } from "@platform/models/value-objects/contact.ts";
import { Email } from "@platform/models/value-objects/email.ts";
import { Name } from "@platform/models/value-objects/name.ts";
import { Role } from "@platform/spec/account/role.ts";
import { Strategy } from "@platform/spec/account/strategies.ts";
import { AggregateRoot, getDate } from "@valkyr/event-store";
import { db } from "~stores/read-store/database.ts";
import { eventStore } from "../event-store.ts";
import { Auditor, systemAuditor } from "../events/auditor.ts";
import { EventRecord, EventStoreFactory } from "../events/mod.ts";
import { projector } from "../projector.ts";
export class Account extends AggregateRoot<EventStoreFactory> {
static override readonly name = "account";
avatar?: Avatar;
name?: Name;
contact: Contact = {
emails: [],
};
strategies: Strategy[] = [];
roles: Role[] = [];
createdAt!: Date;
updatedAt!: Date;
// -------------------------------------------------------------------------
// Reducer
// -------------------------------------------------------------------------
with(event: EventRecord): void {
switch (event.type) {
case "account:created": {
this.id = event.stream;
this.createdAt = getDate(event.created);
break;
}
case "account:avatar:added": {
this.avatar = { url: event.data };
this.updatedAt = getDate(event.created);
break;
}
case "account:name:added": {
this.name = event.data;
this.updatedAt = getDate(event.created);
break;
}
case "account:email:added": {
this.contact.emails.push(event.data);
this.updatedAt = getDate(event.created);
break;
}
case "account:role:added": {
this.roles.push(event.data);
this.updatedAt = getDate(event.created);
break;
}
case "strategy:email:added": {
this.strategies.push({ type: "email", value: event.data });
this.updatedAt = getDate(event.created);
break;
}
case "strategy:password:added": {
this.strategies.push({ type: "password", ...event.data });
this.updatedAt = getDate(event.created);
break;
}
}
}
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
create(meta: Auditor = systemAuditor) {
return this.push({
stream: this.id,
type: "account:created",
meta,
});
}
addAvatar(url: string, meta: Auditor = systemAuditor): this {
return this.push({
stream: this.id,
type: "account:avatar:added",
data: url,
meta,
});
}
addName(name: Name, meta: Auditor = systemAuditor): this {
return this.push({
stream: this.id,
type: "account:name:added",
data: name,
meta,
});
}
addEmail(email: Email, meta: Auditor = systemAuditor): this {
return this.push({
stream: this.id,
type: "account:email:added",
data: email,
meta,
});
}
addRole(role: Role, meta: Auditor = systemAuditor): this {
return this.push({
stream: this.id,
type: "account:role:added",
data: role,
meta,
});
}
addEmailStrategy(email: string, meta: Auditor = systemAuditor): this {
return this.push({
stream: this.id,
type: "strategy:email:added",
data: email,
meta,
});
}
addPasswordStrategy(alias: string, password: string, meta: Auditor = systemAuditor): this {
return this.push({
stream: this.id,
type: "strategy:password:added",
data: { alias, password },
meta,
});
}
}
/*
|--------------------------------------------------------------------------------
| 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
|--------------------------------------------------------------------------------
*/
projector.on("account:created", async ({ stream: id }) => {
await db.collection("accounts").insertOne(
toAccountDocument({
id,
name: {
given: null,
family: null,
},
contact: {
emails: [],
},
strategies: [],
roles: [],
}),
);
});
projector.on("account:avatar:added", async ({ stream: id, data: url }) => {
await db.collection("accounts").updateOne({ id }, { $set: { avatar: { url } } });
});
projector.on("account:name:added", async ({ stream: id, data: name }) => {
await db.collection("accounts").updateOne({ id }, { $set: { name } });
});
projector.on("account:email:added", async ({ stream: id, data: email }) => {
await db.collection("accounts").updateOne({ id }, { $push: { "contact.emails": email } });
});
projector.on("account:role:added", async ({ stream: id, data: role }) => {
await db.collection("accounts").updateOne({ id }, { $push: { roles: role } });
});
projector.on("strategy:email:added", async ({ stream: id, data: email }) => {
await eventStore.relations.insert(getAccountEmailRelation(email), id);
await db.collection("accounts").updateOne({ id }, { $push: { strategies: { type: "email", value: email } } });
});
projector.on("strategy:password:added", async ({ stream: id, data: strategy }) => {
await eventStore.relations.insert(getAccountAliasRelation(strategy.alias), id);
await db.collection("accounts").updateOne({ id }, { $push: { strategies: { type: "password", ...strategy } } });
});

View File

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

View File

@@ -1,15 +0,0 @@
import { EmailSchema } from "@platform/models/value-objects/email.ts";
import { NameSchema } from "@platform/models/value-objects/name.ts";
import { RoleSchema } from "@platform/spec/account/role.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(RoleSchema).meta(AuditorSchema),
];

View File

@@ -1,21 +0,0 @@
import z from "zod";
export const AuditorSchema = z.object({
auditor: z.union([
z.object({
type: z.literal("system"),
}),
z.object({
type: z.literal("account"),
accountId: z.string(),
}),
]),
});
export const systemAuditor: Auditor = {
auditor: {
type: "system",
},
};
export type Auditor = z.infer<typeof AuditorSchema>;

View File

@@ -1,12 +0,0 @@
import { EventFactory, Prettify } from "@valkyr/event-store";
import account from "./account.ts";
import code from "./code.ts";
import organization from "./organization.ts";
import strategy from "./strategy.ts";
export const events = new EventFactory([...account, ...code, ...organization, ...strategy]);
export type EventStoreFactory = typeof events;
export type EventRecord = Prettify<EventStoreFactory["$events"][number]["$record"]>;

View File

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

View File

@@ -1,13 +0,0 @@
import { event } from "@valkyr/event-store";
import z from "zod";
import { AuditorSchema } from "./auditor.ts";
export default [
event.type("strategy:email:added").data(z.string()).meta(AuditorSchema),
event.type("strategy:passkey:added").meta(AuditorSchema),
event
.type("strategy:password:added")
.data(z.object({ alias: z.string(), password: z.string() }))
.meta(AuditorSchema),
];

View File

@@ -1,5 +0,0 @@
import { Projector } from "@valkyr/event-store";
import { EventStoreFactory } from "./events/mod.ts";
export const projector = new Projector<EventStoreFactory>();

View File

@@ -1,19 +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,
[{ "strategies.type": 1, "strategies.alias": 1 }, { name: "strategy.password" }],
[{ "strategies.type": 1, "strategies.value": 1 }, { name: "strategy.email" }],
],
},
{
name: "roles",
indexes: [idIndex, [{ name: 1 }, { name: "role.name" }]],
},
]);

View File

@@ -1,12 +0,0 @@
import type { AccountDocument } from "@platform/models/account.ts";
import { config } from "~config";
import { getDatabaseAccessor } from "~libraries/database/accessor.ts";
export const db = getDatabaseAccessor<{
accounts: AccountDocument;
}>(`${config.name}:read-store`);
export function takeOne<TDocument>(documents: TDocument[]): TDocument | undefined {
return documents[0];
}

View File

@@ -20,7 +20,7 @@
"react": "19.1.1", "react": "19.1.1",
"react-dom": "19.1.1", "react-dom": "19.1.1",
"tailwindcss": "4.1.13", "tailwindcss": "4.1.13",
"zod": "4.1.9" "zod": "4.1.11"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.35.0", "@eslint/js": "9.35.0",

View File

@@ -4,14 +4,31 @@
"workspace": [ "workspace": [
"api", "api",
"apps/react", "apps/react",
"platform/models", "modules/identity",
"platform/cerbos",
"platform/config",
"platform/database",
"platform/logger",
"platform/relay", "platform/relay",
"platform/spec" "platform/server",
"platform/socket",
"platform/spec",
"platform/storage",
"platform/vault"
], ],
"imports": { "imports": {
"@platform/models/": "./platform/models/", "@modules/identity/client.ts": "./modules/identity/client.ts",
"@modules/identity/server.ts": "./modules/identity/server.ts",
"@platform/cerbos/": "./platform/cerbos/",
"@platform/config/": "./platform/config/",
"@platform/database/": "./platform/database/",
"@platform/logger": "./platform/logger/mod.ts",
"@platform/relay": "./platform/relay/mod.ts", "@platform/relay": "./platform/relay/mod.ts",
"@platform/spec/": "./platform/spec/" "@platform/server/": "./platform/server/",
"@platform/socket/": "./platform/socket/",
"@platform/spec/": "./platform/spec/",
"@platform/storage": "./platform/storage/storage.ts",
"@platform/vault": "./platform/vault/vault.ts"
}, },
"tasks": { "tasks": {
"start:api": { "start:api": {

210
deno.lock generated
View File

@@ -5,10 +5,7 @@
"npm:@eslint/js@9.35.0": "9.35.0", "npm:@eslint/js@9.35.0": "9.35.0",
"npm:@jsr/felix__bcrypt@1.0.5": "1.0.5", "npm:@jsr/felix__bcrypt@1.0.5": "1.0.5",
"npm:@jsr/std__assert@1.0.14": "1.0.14", "npm:@jsr/std__assert@1.0.14": "1.0.14",
"npm:@jsr/std__cli@1.0.22": "1.0.22",
"npm:@jsr/std__dotenv@0.225.5": "0.225.5", "npm:@jsr/std__dotenv@0.225.5": "0.225.5",
"npm:@jsr/std__fs@1.0.19": "1.0.19",
"npm:@jsr/std__path@1.1.2": "1.1.2",
"npm:@jsr/std__testing@1.0.15": "1.0.15", "npm:@jsr/std__testing@1.0.15": "1.0.15",
"npm:@jsr/valkyr__auth@2.1.4": "2.1.4", "npm:@jsr/valkyr__auth@2.1.4": "2.1.4",
"npm:@jsr/valkyr__db@2.0.0": "2.0.0", "npm:@jsr/valkyr__db@2.0.0": "2.0.0",
@@ -31,7 +28,9 @@
"npm:eslint@9.35.0": "9.35.0", "npm:eslint@9.35.0": "9.35.0",
"npm:fast-equals@5.2.2": "5.2.2", "npm:fast-equals@5.2.2": "5.2.2",
"npm:globals@16.4.0": "16.4.0", "npm:globals@16.4.0": "16.4.0",
"npm:jose@6.1.0": "6.1.0",
"npm:mongodb@6.20.0": "6.20.0", "npm:mongodb@6.20.0": "6.20.0",
"npm:nanoid@5.1.5": "5.1.5",
"npm:path-to-regexp@8": "8.3.0", "npm:path-to-regexp@8": "8.3.0",
"npm:prettier@3.6.2": "3.6.2", "npm:prettier@3.6.2": "3.6.2",
"npm:react-dom@19.1.1": "19.1.1_react@19.1.1", "npm:react-dom@19.1.1": "19.1.1_react@19.1.1",
@@ -40,8 +39,7 @@
"npm:typescript-eslint@8.44.0": "8.44.0_eslint@9.35.0_typescript@5.9.2_@typescript-eslint+parser@8.44.0__eslint@9.35.0__typescript@5.9.2", "npm:typescript-eslint@8.44.0": "8.44.0_eslint@9.35.0_typescript@5.9.2_@typescript-eslint+parser@8.44.0__eslint@9.35.0__typescript@5.9.2",
"npm:typescript@5.9.2": "5.9.2", "npm:typescript@5.9.2": "5.9.2",
"npm:vite@7.1.6": "7.1.6_picomatch@4.0.3_@types+node@24.2.0", "npm:vite@7.1.6": "7.1.6_picomatch@4.0.3_@types+node@24.2.0",
"npm:zod@4": "4.1.9", "npm:zod@4.1.11": "4.1.11"
"npm:zod@4.1.9": "4.1.9"
}, },
"npm": { "npm": {
"@babel/code-frame@7.27.1": { "@babel/code-frame@7.27.1": {
@@ -455,10 +453,6 @@
"integrity": "sha512-aIG8W3TOmW+lKdAJA5w56qASu9EiUmBXbhW6eAlSEUBid+KVESGqQygFFg+awt/c8K+qobVM6M/u3SbIy0NyUQ==", "integrity": "sha512-aIG8W3TOmW+lKdAJA5w56qASu9EiUmBXbhW6eAlSEUBid+KVESGqQygFFg+awt/c8K+qobVM6M/u3SbIy0NyUQ==",
"tarball": "https://npm.jsr.io/~/11/@jsr/std__async/1.0.14.tgz" "tarball": "https://npm.jsr.io/~/11/@jsr/std__async/1.0.14.tgz"
}, },
"@jsr/std__cli@1.0.22": {
"integrity": "sha512-PQkNPxuo8nOby8RgRxaLrQ9UAem/cCYKZYznV1fISZAzBbxMVBfsIeHA9FxMH0OUuRcu4ReEZ9QudeGg6xLdvw==",
"tarball": "https://npm.jsr.io/~/11/@jsr/std__cli/1.0.22.tgz"
},
"@jsr/std__data-structures@1.0.9": { "@jsr/std__data-structures@1.0.9": {
"integrity": "sha512-+mT4Nll6fx+CPNqrlC+huhIOYNSMS+KUdJ4B8NujiQrh/bq++ds5PXpEsfV5EPR+YuWcuDGG0P1DE+Rednd7Wg==", "integrity": "sha512-+mT4Nll6fx+CPNqrlC+huhIOYNSMS+KUdJ4B8NujiQrh/bq++ds5PXpEsfV5EPR+YuWcuDGG0P1DE+Rednd7Wg==",
"dependencies": [ "dependencies": [
@@ -597,108 +591,113 @@
"@rolldown/pluginutils@1.0.0-beta.27": { "@rolldown/pluginutils@1.0.0-beta.27": {
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==" "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="
}, },
"@rollup/rollup-android-arm-eabi@4.51.0": { "@rollup/rollup-android-arm-eabi@4.52.0": {
"integrity": "sha512-VyfldO8T/C5vAXBGIobrAnUE+VJNVLw5z9h4NgSDq/AJZWt/fXqdW+0PJbk+M74xz7yMDRiHtlsuDV7ew6K20w==", "integrity": "sha512-VxDYCDqOaR7NXzAtvRx7G1u54d2kEHopb28YH/pKzY6y0qmogP3gG7CSiWsq9WvDFxOQMpNEyjVAHZFXfH3o/A==",
"os": ["android"], "os": ["android"],
"cpu": ["arm"] "cpu": ["arm"]
}, },
"@rollup/rollup-android-arm64@4.51.0": { "@rollup/rollup-android-arm64@4.52.0": {
"integrity": "sha512-Z3ujzDZgsEVSokgIhmOAReh9SGT2qloJJX2Xo1Q3nPU1EhCXrV0PbpR3r7DWRgozqnjrPZQkLe5cgBPIYp70Vg==", "integrity": "sha512-pqDirm8koABIKvzL59YI9W9DWbRlTX7RWhN+auR8HXJxo89m4mjqbah7nJZjeKNTNYopqL+yGg+0mhCpf3xZtQ==",
"os": ["android"], "os": ["android"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@rollup/rollup-darwin-arm64@4.51.0": { "@rollup/rollup-darwin-arm64@4.52.0": {
"integrity": "sha512-T3gskHgArUdR6TCN69li5VELVAZK+iQ4iwMoSMNYixoj+56EC9lTj35rcxhXzIJt40YfBkvDy3GS+t5zh7zM6g==", "integrity": "sha512-YCdWlY/8ltN6H78HnMsRHYlPiKvqKagBP1r+D7SSylxX+HnsgXGCmLiV3Y4nSyY9hW8qr8U9LDUx/Lo7M6MfmQ==",
"os": ["darwin"], "os": ["darwin"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@rollup/rollup-darwin-x64@4.51.0": { "@rollup/rollup-darwin-x64@4.52.0": {
"integrity": "sha512-Hh7n/fh0g5UjH6ATDF56Qdf5bzdLZKIbhp5KftjMYG546Ocjeyg15dxphCpH1FFY2PJ2G6MiOVL4jMq5VLTyrQ==", "integrity": "sha512-z4nw6y1j+OOSGzuVbSWdIp1IUks9qNw4dc7z7lWuWDKojY38VMWBlEN7F9jk5UXOkUcp97vA1N213DF+Lz8BRg==",
"os": ["darwin"], "os": ["darwin"],
"cpu": ["x64"] "cpu": ["x64"]
}, },
"@rollup/rollup-freebsd-arm64@4.51.0": { "@rollup/rollup-freebsd-arm64@4.52.0": {
"integrity": "sha512-0EddADb6FBvfqYoxwVom3hAbAvpSVUbZqmR1wmjk0MSZ06hn/UxxGHKRqEQDMkts7XiZjejVB+TLF28cDTU+gA==", "integrity": "sha512-Q/dv9Yvyr5rKlK8WQJZVrp5g2SOYeZUs9u/t2f9cQ2E0gJjYB/BWoedXfUT0EcDJefi2zzVfhcOj8drWCzTviw==",
"os": ["freebsd"], "os": ["freebsd"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@rollup/rollup-freebsd-x64@4.51.0": { "@rollup/rollup-freebsd-x64@4.52.0": {
"integrity": "sha512-MpqaEDLo3JuVPF+wWV4mK7V8akL76WCz8ndfz1aVB7RhvXFO3k7yT7eu8OEuog4VTSyNu5ibvN9n6lgjq/qLEQ==", "integrity": "sha512-kdBsLs4Uile/fbjZVvCRcKB4q64R+1mUq0Yd7oU1CMm1Av336ajIFqNFovByipciuUQjBCPMxwJhCgfG2re3rg==",
"os": ["freebsd"], "os": ["freebsd"],
"cpu": ["x64"] "cpu": ["x64"]
}, },
"@rollup/rollup-linux-arm-gnueabihf@4.51.0": { "@rollup/rollup-linux-arm-gnueabihf@4.52.0": {
"integrity": "sha512-WEWAGFNFFpvSWAIT3MYvxTkYHv/cJl9yWKpjhheg7ONfB0hetZt/uwBnM3GZqSHrk5bXCDYTFXg3jQyk/j7eXQ==", "integrity": "sha512-aL6hRwu0k7MTUESgkg7QHY6CoqPgr6gdQXRJI1/VbFlUMwsSzPGSR7sG5d+MCbYnJmJwThc2ol3nixj1fvI/zQ==",
"os": ["linux"], "os": ["linux"],
"cpu": ["arm"] "cpu": ["arm"]
}, },
"@rollup/rollup-linux-arm-musleabihf@4.51.0": { "@rollup/rollup-linux-arm-musleabihf@4.52.0": {
"integrity": "sha512-9bxtxj8QoAp++LOq5PGDGkEEOpCDk9rOEHUcXadnijedDH8IXrBt6PnBa4Y6NblvGWdoxvXZYghZLaliTCmAng==", "integrity": "sha512-BTs0M5s1EJejgIBJhCeiFo7GZZ2IXWkFGcyZhxX4+8usnIo5Mti57108vjXFIQmmJaRyDwmV59Tw64Ap1dkwMw==",
"os": ["linux"], "os": ["linux"],
"cpu": ["arm"] "cpu": ["arm"]
}, },
"@rollup/rollup-linux-arm64-gnu@4.51.0": { "@rollup/rollup-linux-arm64-gnu@4.52.0": {
"integrity": "sha512-DdqA+fARqIsfqDYkKo2nrWMp0kvu/wPJ2G8lZ4DjYhn+8QhrjVuzmsh7tTkhULwjvHTN59nWVzAixmOi6rqjNA==", "integrity": "sha512-uj672IVOU9m08DBGvoPKPi/J8jlVgjh12C9GmjjBxCTQc3XtVmRkRKyeHSmIKQpvJ7fIm1EJieBUcnGSzDVFyw==",
"os": ["linux"], "os": ["linux"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@rollup/rollup-linux-arm64-musl@4.51.0": { "@rollup/rollup-linux-arm64-musl@4.52.0": {
"integrity": "sha512-2XVRNzcUJE1UJua8P4a1GXS5jafFWE+pQ6zhUbZzptOu/70p1F6+0FTi6aGPd6jNtnJqGMjtBCXancC2dhYlWw==", "integrity": "sha512-/+IVbeDMDCtB/HP/wiWsSzduD10SEGzIZX2945KSgZRNi4TSkjHqRJtNTVtVb8IRwhJ65ssI56krlLik+zFWkw==",
"os": ["linux"], "os": ["linux"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@rollup/rollup-linux-loong64-gnu@4.51.0": { "@rollup/rollup-linux-loong64-gnu@4.52.0": {
"integrity": "sha512-R8QhY0kLIPCAVXWi2yftDSpn7Jtejey/WhMoBESSfwGec5SKdFVupjxFlKoQ7clVRuaDpiQf7wNx3EBZf4Ey6g==", "integrity": "sha512-U1vVzvSWtSMWKKrGoROPBXMh3Vwn93TA9V35PldokHGqiUbF6erSzox/5qrSMKp6SzakvyjcPiVF8yB1xKr9Pg==",
"os": ["linux"], "os": ["linux"],
"cpu": ["loong64"] "cpu": ["loong64"]
}, },
"@rollup/rollup-linux-ppc64-gnu@4.51.0": { "@rollup/rollup-linux-ppc64-gnu@4.52.0": {
"integrity": "sha512-I498RPfxx9cMv1KTHQ9tg2Ku1utuQm+T5B+Xro+WNu3FzAFSKp4awKfgMoZwjoPgNbaFGINaOM25cQW6WuBhiQ==", "integrity": "sha512-X/4WfuBAdQRH8cK3DYl8zC00XEE6aM472W+QCycpQJeLWVnHfkv7RyBFVaTqNUMsTgIX8ihMjCvFF9OUgeABzw==",
"os": ["linux"], "os": ["linux"],
"cpu": ["ppc64"] "cpu": ["ppc64"]
}, },
"@rollup/rollup-linux-riscv64-gnu@4.51.0": { "@rollup/rollup-linux-riscv64-gnu@4.52.0": {
"integrity": "sha512-o8COudsb8lvtdm9ixg9aKjfX5aeoc2x9KGE7WjtrmQFquoCRZ9jtzGlonujE4WhvXFepTraWzT4RcwyDDeHXjA==", "integrity": "sha512-xIRYc58HfWDBZoLmWfWXg2Sq8VCa2iJ32B7mqfWnkx5mekekl0tMe7FHpY8I72RXEcUkaWawRvl3qA55og+cwQ==",
"os": ["linux"], "os": ["linux"],
"cpu": ["riscv64"] "cpu": ["riscv64"]
}, },
"@rollup/rollup-linux-riscv64-musl@4.51.0": { "@rollup/rollup-linux-riscv64-musl@4.52.0": {
"integrity": "sha512-0shJPgSXMdYzOQzpM5BJN2euXY1f8uV8mS6AnrbMcH2KrkNsbpMxWB1wp8UEdiJ1NtyBkCk3U/HfX5mEONBq6w==", "integrity": "sha512-mbsoUey05WJIOz8U1WzNdf+6UMYGwE3fZZnQqsM22FZ3wh1N887HT6jAOjXs6CNEK3Ntu2OBsyQDXfIjouI4dw==",
"os": ["linux"], "os": ["linux"],
"cpu": ["riscv64"] "cpu": ["riscv64"]
}, },
"@rollup/rollup-linux-s390x-gnu@4.51.0": { "@rollup/rollup-linux-s390x-gnu@4.52.0": {
"integrity": "sha512-L7pV+ny7865jamSCQwyozBYjFRUKaTsPqDz7ClOtJCDu4paf2uAa0mrcHwSt4XxZP2ogFZS9uuitH3NXdeBEJA==", "integrity": "sha512-qP6aP970bucEi5KKKR4AuPFd8aTx9EF6BvutvYxmZuWLJHmnq4LvBfp0U+yFDMGwJ+AIJEH5sIP+SNypauMWzg==",
"os": ["linux"], "os": ["linux"],
"cpu": ["s390x"] "cpu": ["s390x"]
}, },
"@rollup/rollup-linux-x64-gnu@4.51.0": { "@rollup/rollup-linux-x64-gnu@4.52.0": {
"integrity": "sha512-4YHhP+Rv3T3+H3TPbUvWOw5tuSwhrVhkHHZhk4hC9VXeAOKR26/IsUAT4FsB4mT+kfIdxxb1BezQDEg/voPO8A==", "integrity": "sha512-nmSVN+F2i1yKZ7rJNKO3G7ZzmxJgoQBQZ/6c4MuS553Grmr7WqR7LLDcYG53Z2m9409z3JLt4sCOhLdbKQ3HmA==",
"os": ["linux"], "os": ["linux"],
"cpu": ["x64"] "cpu": ["x64"]
}, },
"@rollup/rollup-linux-x64-musl@4.51.0": { "@rollup/rollup-linux-x64-musl@4.52.0": {
"integrity": "sha512-P7U7U03+E5w7WgJtvSseNLOX1UhknVPmEaqgUENFWfNxNBa1OhExT6qYGmyF8gepcxWSaSfJsAV5UwhWrYefdQ==", "integrity": "sha512-2d0qRo33G6TfQVjaMR71P+yJVGODrt5V6+T0BDYH4EMfGgdC/2HWDVjSSFw888GSzAZUwuska3+zxNUCDco6rQ==",
"os": ["linux"], "os": ["linux"],
"cpu": ["x64"] "cpu": ["x64"]
}, },
"@rollup/rollup-openharmony-arm64@4.51.0": { "@rollup/rollup-openharmony-arm64@4.52.0": {
"integrity": "sha512-FuD8g3u9W6RPwdO1R45hZFORwa1g9YXEMesAKP/sOi7mDqxjbni8S3zAXJiDcRfGfGBqpRYVuH54Gu3FTuSoEw==", "integrity": "sha512-A1JalX4MOaFAAyGgpO7XP5khquv/7xKzLIyLmhNrbiCxWpMlnsTYr8dnsWM7sEeotNmxvSOEL7F65j0HXFcFsw==",
"os": ["openharmony"], "os": ["openharmony"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@rollup/rollup-win32-arm64-msvc@4.51.0": { "@rollup/rollup-win32-arm64-msvc@4.52.0": {
"integrity": "sha512-zST+FdMCX3QAYfmZX3dp/Fy8qLUetfE17QN5ZmmFGPrhl86qvRr+E9u2bk7fzkIXsfQR30Z7ZRS7WMryPPn4rQ==", "integrity": "sha512-YQugafP/rH0eOOHGjmNgDURrpYHrIX0yuojOI8bwCyXwxC9ZdTd3vYkmddPX0oHONLXu9Rb1dDmT0VNpjkzGGw==",
"os": ["win32"], "os": ["win32"],
"cpu": ["arm64"] "cpu": ["arm64"]
}, },
"@rollup/rollup-win32-ia32-msvc@4.51.0": { "@rollup/rollup-win32-ia32-msvc@4.52.0": {
"integrity": "sha512-U+qhoCVAZmTHCmUKxdQxw1jwAFNFXmOpMME7Npt5GTb1W/7itfgAgNluVOvyeuSeqW+dEQLFuNZF3YZPO8XkMg==", "integrity": "sha512-zYdUYhi3Qe2fndujBqL5FjAFzvNeLxtIqfzNEVKD1I7C37/chv1VxhscWSQHTNfjPCrBFQMnynwA3kpZpZ8w4A==",
"os": ["win32"], "os": ["win32"],
"cpu": ["ia32"] "cpu": ["ia32"]
}, },
"@rollup/rollup-win32-x64-msvc@4.51.0": { "@rollup/rollup-win32-x64-gnu@4.52.0": {
"integrity": "sha512-z6UpFzMhXSD8NNUfCi2HO+pbpSzSWIIPgb1TZsEZjmZYtk6RUIC63JYjlFBwbBZS3jt3f1q6IGfkj3g+GnBt2Q==", "integrity": "sha512-fGk03kQylNaCOQ96HDMeT7E2n91EqvCDd3RwvT5k+xNdFCeMGnj5b5hEgTGrQuyidqSsD3zJDQ21QIaxXqTBJw==",
"os": ["win32"],
"cpu": ["x64"]
},
"@rollup/rollup-win32-x64-msvc@4.52.0": {
"integrity": "sha512-6iKDCVSIUQ8jPMoIV0OytRKniaYyy5EbY/RRydmLW8ZR3cEBhxbWl5ro0rkUNe0ef6sScvhbY79HrjRm8i3vDQ==",
"os": ["win32"], "os": ["win32"],
"cpu": ["x64"] "cpu": ["x64"]
}, },
@@ -848,8 +847,8 @@
"tiny-warning" "tiny-warning"
] ]
}, },
"@tanstack/react-store@0.7.5_react@19.1.1_react-dom@19.1.1__react@19.1.1": { "@tanstack/react-store@0.7.7_react@19.1.1_react-dom@19.1.1__react@19.1.1": {
"integrity": "sha512-A+WZtEnHZpvbKXm8qR+xndNKywBLez2KKKKEQc7w0Qs45GvY1LpRI3BTZNmELwEVim8+Apf99iEDH2J+MUIzlQ==", "integrity": "sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==",
"dependencies": [ "dependencies": [
"@tanstack/store", "@tanstack/store",
"react", "react",
@@ -879,8 +878,8 @@
"tiny-invariant" "tiny-invariant"
] ]
}, },
"@tanstack/store@0.7.5": { "@tanstack/store@0.7.7": {
"integrity": "sha512-qd/OjkjaFRKqKU4Yjipaen/EOB9MyEg6Wr9fW103RBPACf1ZcKhbhcu2S5mj5IgdPib6xFIgCUti/mKVkl+fRw==" "integrity": "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ=="
}, },
"@types/babel__core@7.20.5": { "@types/babel__core@7.20.5": {
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
@@ -1766,6 +1765,10 @@
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"bin": true "bin": true
}, },
"nanoid@5.1.5": {
"integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==",
"bin": true
},
"natural-compare@1.4.0": { "natural-compare@1.4.0": {
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
}, },
@@ -1825,7 +1828,7 @@
"postcss@8.5.6": { "postcss@8.5.6": {
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dependencies": [ "dependencies": [
"nanoid", "nanoid@3.3.11",
"picocolors", "picocolors",
"source-map-js" "source-map-js"
] ]
@@ -1871,8 +1874,8 @@
"reusify@1.1.0": { "reusify@1.1.0": {
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==" "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="
}, },
"rollup@4.51.0": { "rollup@4.52.0": {
"integrity": "sha512-7cR0XWrdp/UAj2HMY/Y4QQEUjidn3l2AY1wSeZoFjMbD8aOMPoV9wgTFYbrJpPzzvejDEini1h3CiUP8wLzxQA==", "integrity": "sha512-+IuescNkTJQgX7AkIDtITipZdIGcWF0pnVvZTWStiazUmcGA2ag8dfg0urest2XlXUi9kuhfQ+qmdc5Stc3z7g==",
"dependencies": [ "dependencies": [
"@types/estree" "@types/estree"
], ],
@@ -1897,6 +1900,7 @@
"@rollup/rollup-openharmony-arm64", "@rollup/rollup-openharmony-arm64",
"@rollup/rollup-win32-arm64-msvc", "@rollup/rollup-win32-arm64-msvc",
"@rollup/rollup-win32-ia32-msvc", "@rollup/rollup-win32-ia32-msvc",
"@rollup/rollup-win32-x64-gnu",
"@rollup/rollup-win32-x64-msvc", "@rollup/rollup-win32-x64-msvc",
"fsevents" "fsevents"
], ],
@@ -2168,8 +2172,8 @@
"yocto-queue@0.1.0": { "yocto-queue@0.1.0": {
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
}, },
"zod@4.1.9": { "zod@4.1.11": {
"integrity": "sha512-HI32jTq0AUAC125z30E8bQNz0RQ+9Uc+4J7V97gLYjZVKRjeydPgGt6dvQzFrav7MYOUGFqqOGiHpA/fdbd0cQ==" "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="
} }
}, },
"workspace": { "workspace": {
@@ -2187,19 +2191,7 @@
"api": { "api": {
"packageJson": { "packageJson": {
"dependencies": [ "dependencies": [
"npm:@cerbos/http@0.23.1", "npm:zod@4.1.11"
"npm:@jsr/felix__bcrypt@1.0.5",
"npm:@jsr/std__cli@1.0.22",
"npm:@jsr/std__dotenv@0.225.5",
"npm:@jsr/std__fs@1.0.19",
"npm:@jsr/std__path@1.1.2",
"npm:@jsr/valkyr__auth@2.1.4",
"npm:@jsr/valkyr__event-store@2.0.1",
"npm:@jsr/valkyr__inverse@1.0.1",
"npm:@jsr/valkyr__json-rpc@1.1.0",
"npm:cookie@1.0.2",
"npm:mongodb@6.20.0",
"npm:zod@4.1.9"
] ]
} }
}, },
@@ -2227,29 +2219,91 @@
"npm:typescript-eslint@8.44.0", "npm:typescript-eslint@8.44.0",
"npm:typescript@5.9.2", "npm:typescript@5.9.2",
"npm:vite@7.1.6", "npm:vite@7.1.6",
"npm:zod@4.1.9" "npm:zod@4.1.11"
] ]
} }
}, },
"platform/models": { "modules/identity": {
"packageJson": { "packageJson": {
"dependencies": [ "dependencies": [
"npm:zod@4" "npm:@cerbos/http@0.23.1",
"npm:@jsr/felix__bcrypt@1.0.5",
"npm:@jsr/valkyr__auth@2.1.4",
"npm:@jsr/valkyr__event-store@2.0.1",
"npm:cookie@1.0.2",
"npm:zod@4.1.11"
]
}
},
"platform/cerbos": {
"packageJson": {
"dependencies": [
"npm:@cerbos/http@0.23.1",
"npm:@jsr/valkyr__auth@2.1.4"
]
}
},
"platform/config": {
"packageJson": {
"dependencies": [
"npm:@jsr/std__dotenv@0.225.5",
"npm:zod@4.1.11"
]
}
},
"platform/database": {
"packageJson": {
"dependencies": [
"npm:@jsr/valkyr__inverse@1.0.1",
"npm:mongodb@6.20.0",
"npm:zod@4.1.11"
]
}
},
"platform/logger": {
"packageJson": {
"dependencies": [
"npm:@jsr/valkyr__event-store@2.0.1",
"npm:zod@4.1.11"
] ]
} }
}, },
"platform/relay": { "platform/relay": {
"packageJson": { "packageJson": {
"dependencies": [ "dependencies": [
"npm:@jsr/valkyr__auth@2.1.4",
"npm:path-to-regexp@8", "npm:path-to-regexp@8",
"npm:zod@4" "npm:zod@4.1.11"
]
}
},
"platform/server": {
"packageJson": {
"dependencies": [
"npm:@jsr/valkyr__json-rpc@1.1.0",
"npm:zod@4.1.11"
]
}
},
"platform/socket": {
"packageJson": {
"dependencies": [
"npm:@jsr/valkyr__json-rpc@1.1.0"
] ]
} }
}, },
"platform/spec": { "platform/spec": {
"packageJson": { "packageJson": {
"dependencies": [ "dependencies": [
"npm:zod@4" "npm:zod@4.1.11"
]
}
},
"platform/vault": {
"packageJson": {
"dependencies": [
"npm:jose@6.1.0",
"npm:nanoid@5.1.5"
] ]
} }
} }

View File

@@ -8,8 +8,8 @@ services:
- "3593:3593" - "3593:3593"
- "3594:3594" - "3594:3594"
volumes: volumes:
- ./cerbos/config.yaml:/config.yaml # <--- mount config - ./platform/cerbos/config.yaml:/config.yaml # <--- mount config
- ./cerbos/policies:/data/policies # <--- mount policies - ./platform/cerbos/policies:/data/policies # <--- mount policies
networks: networks:
- localdev - localdev

View File

@@ -1,7 +1,7 @@
import { AggregateRoot, getDate } from "@valkyr/event-store"; import { AggregateRoot, getDate } from "@valkyr/event-store";
import { EventRecord, EventStoreFactory } from "../event-store.ts";
import { CodeIdentity } from "../events/code.ts"; import { CodeIdentity } from "../events/code.ts";
import { EventRecord, 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";

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

@@ -1,6 +1,7 @@
import { cerbos } from "./cerbos.ts"; import { cerbos } from "@platform/cerbos/client.ts";
import { Resource } from "@platform/cerbos/resources.ts";
import type { Principal } from "./principal.ts"; import type { Principal } from "./principal.ts";
import { Resource } from "./resources.ts";
export function access(principal: Principal) { export function access(principal: Principal) {
return { return {

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

@@ -1,46 +1,30 @@
import { Account, fromAccountDocument } from "@platform/models/account.ts"; import { getDatabaseAccessor } from "@platform/database/accessor.ts";
import { PasswordStrategy } from "@platform/spec/auth/strategies.ts";
import { db, takeOne } from "./database.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`);
/* /*
|-------------------------------------------------------------------------------- |--------------------------------------------------------------------------------
| Accounts | Identity
|-------------------------------------------------------------------------------- |--------------------------------------------------------------------------------
*/ */
/** /**
* Retrieve a single account by its primary identifier. * Retrieve a single account by its primary identifier.
* *
* @param id - Account identifier. * @param id - Unique identity.
*/ */
export async function getAccountById(id: string): Promise<Account | undefined> { export async function getIdentityById(id: string): Promise<Identity | undefined> {
return db return db
.collection("accounts") .collection("identities")
.aggregate([ .findOne({ id })
{ .then((document) => parseIdentity(document));
$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. * Get strategy details for the given password strategy alias.
* *
@@ -49,7 +33,7 @@ export async function getAccountById(id: string): Promise<Account | undefined> {
export async function getPasswordStrategyByAlias( export async function getPasswordStrategyByAlias(
alias: string, alias: string,
): Promise<({ accountId: string } & PasswordStrategy) | undefined> { ): Promise<({ accountId: string } & PasswordStrategy) | undefined> {
const account = await db.collection("accounts").findOne({ const account = await db.collection("identities").findOne({
strategies: { strategies: {
$elemMatch: { type: "password", alias }, $elemMatch: { type: "password", alias },
}, },

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

@@ -2,7 +2,7 @@ import { event } from "@valkyr/event-store";
import z from "zod"; import z from "zod";
const CodeIdentitySchema = z.object({ const CodeIdentitySchema = z.object({
accountId: z.string(), id: z.string(),
}); });
export default [ export default [

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

@@ -3,7 +3,7 @@
apiVersion: api.cerbos.dev/v1 apiVersion: api.cerbos.dev/v1
resourcePolicy: resourcePolicy:
resource: account resource: identity
version: default version: default
rules: rules:

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

@@ -1,13 +1,14 @@
import { code } from "@platform/spec/auth/routes.ts"; import { logger } from "@platform/logger";
import cookie from "cookie"; import cookie from "cookie";
import { auth, config } from "~libraries/auth/mod.ts"; import { Code } from "../../../aggregates/code.ts";
import { logger } from "~libraries/logger/mod.ts"; import { Identity } from "../../../aggregates/identity.ts";
import { Account } from "~stores/event-store/aggregates/account.ts"; import { auth } from "../../../auth.ts";
import { Code } from "~stores/event-store/aggregates/code.ts"; import { config } from "../../../config.ts";
import { eventStore } from "~stores/event-store/event-store.ts"; import { eventStore } from "../../../event-store.ts";
import route from "./spec.ts";
export default code.access("public").handle(async ({ params: { accountId, codeId, value }, query: { next } }) => { export default route.access("public").handle(async ({ params: { identityId, codeId, value }, query: { next } }) => {
const code = await eventStore.aggregate.getByStream(Code, codeId); const code = await eventStore.aggregate.getByStream(Code, codeId);
if (code === undefined) { if (code === undefined) {
@@ -40,23 +41,23 @@ export default code.access("public").handle(async ({ params: { accountId, codeId
}); });
} }
if (code.identity.accountId !== accountId) { if (code.identity.id !== identityId) {
return logger.info({ return logger.info({
type: "code:claimed", type: "code:claimed",
session: false, session: false,
message: "Invalid Account ID", message: "Invalid Identity ID",
expected: code.identity.accountId, expected: code.identity.id,
received: accountId, received: identityId,
}); });
} }
const account = await eventStore.aggregate.getByStream(Account, accountId); const account = await eventStore.aggregate.getByStream(Identity, identityId);
if (account === undefined) { if (account === undefined) {
return logger.info({ return logger.info({
type: "code:claimed", type: "code:claimed",
session: false, session: false,
message: "Account Not Found", message: "Account Not Found",
expected: code.identity.accountId, expected: code.identity.id,
received: undefined, received: undefined,
}); });
} }

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

@@ -1,12 +1,12 @@
import { logger } from "@platform/logger";
import { BadRequestError } from "@platform/relay"; import { BadRequestError } from "@platform/relay";
import { password as route } from "@platform/spec/auth/routes.ts";
import cookie from "cookie"; import cookie from "cookie";
import { config } from "~config"; import { auth } from "../../../auth.ts";
import { auth } from "~libraries/auth/mod.ts"; import { config } from "../../../config.ts";
import { password } from "~libraries/crypto/mod.ts"; import { password } from "../../../crypto/password.ts";
import { logger } from "~libraries/logger/mod.ts"; import { getPasswordStrategyByAlias } from "../../../database.ts";
import { getPasswordStrategyByAlias } from "~stores/read-store/methods.ts"; import route from "./spec.ts";
export default route.access("public").handle(async ({ body: { alias, password: userPassword } }) => { export default route.access("public").handle(async ({ body: { alias, password: userPassword } }) => {
const strategy = await getPasswordStrategyByAlias(alias); const strategy = await getPasswordStrategyByAlias(alias);

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

@@ -30,4 +30,8 @@ export const StrategySchema = z.discriminatedUnion("type", [
PasskeyStrategySchema, 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>; 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;
}
}

View File

@@ -0,0 +1,10 @@
{
"name": "@platform/cerbos",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"@cerbos/http": "0.23.1",
"@valkyr/auth": "npm:@jsr/valkyr__auth@2.1.4"
}
}

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

@@ -2,7 +2,7 @@ import { ResourceRegistry } from "@valkyr/auth";
export const resources = new ResourceRegistry([ export const resources = new ResourceRegistry([
{ {
kind: "account", kind: "identity",
attr: {}, attr: {},
}, },
] as const); ] as const);

10
platform/config/dotenv.ts Normal file
View File

@@ -0,0 +1,10 @@
import { load } from "@std/dotenv";
const env = await load();
/**
* TODO ...
*/
export function getDotEnvVariable(key: string): string {
return env[key] ?? Deno.env.get(key);
}

Some files were not shown because too many files have changed in this diff Show More