diff --git a/api/.bruno/bruno.json b/.bruno/bruno.json similarity index 100% rename from api/.bruno/bruno.json rename to .bruno/bruno.json diff --git a/api/.bruno/environments/localhost.bru b/.bruno/environments/localhost.bru similarity index 100% rename from api/.bruno/environments/localhost.bru rename to .bruno/environments/localhost.bru diff --git a/api/.bruno/account/folder.bru b/.bruno/identity/folder.bru similarity index 72% rename from api/.bruno/account/folder.bru rename to .bruno/identity/folder.bru index 3d54050..a89bdf5 100644 --- a/api/.bruno/account/folder.bru +++ b/.bruno/identity/folder.bru @@ -1,5 +1,5 @@ meta { - name: account + name: Identity seq: 1 } diff --git a/api/.bruno/account/GetById.bru b/.bruno/identity/get-by-id.bru similarity index 65% rename from api/.bruno/account/GetById.bru rename to .bruno/identity/get-by-id.bru index 53c4253..e0a2198 100644 --- a/api/.bruno/account/GetById.bru +++ b/.bruno/identity/get-by-id.bru @@ -5,13 +5,13 @@ meta { } get { - url: {{url}}/accounts/:id + url: {{url}}/identities/:id body: none auth: inherit } params:path { - id: + id: 16b88034-ca82-4a8e-9fe5-13bd0dd29b75 } settings { diff --git a/api/.bruno/auth/Code.bru b/.bruno/identity/login/code.bru similarity index 59% rename from api/.bruno/auth/Code.bru rename to .bruno/identity/login/code.bru index e17b3c0..9402175 100644 --- a/api/.bruno/auth/Code.bru +++ b/.bruno/identity/login/code.bru @@ -5,15 +5,15 @@ meta { } get { - url: {{url}}/auth/code/:accountId/code/:codeId/:value + url: {{url}}/identities/login/code/:identityId/code/:codeId/:value body: none auth: inherit } params:path { - accountId: - codeId: - value: + identityId: efefa471-905d-4702-bd0a-863d8cf70424 + codeId: 7055b769-0814-47b8-836e-cfef2d8c2e68 + value: 00597 } script:post-response { diff --git a/api/.bruno/auth/Email.bru b/.bruno/identity/login/email.bru similarity index 84% rename from api/.bruno/auth/Email.bru rename to .bruno/identity/login/email.bru index 37c4c94..227a806 100644 --- a/api/.bruno/auth/Email.bru +++ b/.bruno/identity/login/email.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{url}}/auth/email + url: {{url}}/identities/login/email body: json auth: inherit } diff --git a/api/.bruno/auth/folder.bru b/.bruno/identity/login/folder.bru similarity index 75% rename from api/.bruno/auth/folder.bru rename to .bruno/identity/login/folder.bru index 4394d36..d460507 100644 --- a/api/.bruno/auth/folder.bru +++ b/.bruno/identity/login/folder.bru @@ -1,5 +1,5 @@ meta { - name: auth + name: Login seq: 2 } diff --git a/api/.bruno/auth/Session.bru b/.bruno/identity/me.bru similarity index 69% rename from api/.bruno/auth/Session.bru rename to .bruno/identity/me.bru index cfbd550..1dc0a82 100644 --- a/api/.bruno/auth/Session.bru +++ b/.bruno/identity/me.bru @@ -1,11 +1,11 @@ meta { - name: Session + name: Me type: http seq: 3 } get { - url: {{url}}/auth/session + url: {{url}}/identities/me body: none auth: inherit } diff --git a/api/.bruno/account/Create.bru b/.bruno/identity/register.bru similarity index 61% rename from api/.bruno/account/Create.bru rename to .bruno/identity/register.bru index cea1e27..b2344ec 100644 --- a/api/.bruno/account/Create.bru +++ b/.bruno/identity/register.bru @@ -1,11 +1,11 @@ meta { - name: Create + name: Register type: http seq: 1 } post { - url: {{url}}/accounts + url: {{url}}/identities body: json auth: inherit } @@ -13,10 +13,10 @@ post { body:json { { "name": { - "given": "John", + "given": "Jane", "family": "Doe" }, - "email": "john.doe@fixture.none" + "email": "jane.doe@fixture.none" } } diff --git a/api/.tasks/bootstrap.ts b/api/.tasks/bootstrap.ts deleted file mode 100644 index a754497..0000000 --- a/api/.tasks/bootstrap.ts +++ /dev/null @@ -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 { - 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; -} diff --git a/api/.tasks/migrate.ts b/api/.tasks/migrate.ts deleted file mode 100644 index d2c6a37..0000000 --- a/api/.tasks/migrate.ts +++ /dev/null @@ -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("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); diff --git a/api/.tasks/migrations/meta/_journal.json b/api/.tasks/migrations/meta/_journal.json deleted file mode 100644 index e82d343..0000000 --- a/api/.tasks/migrations/meta/_journal.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "api", - "entries": [] -} \ No newline at end of file diff --git a/api/config.ts b/api/config.ts index 12556de..79604a5 100644 --- a/api/config.ts +++ b/api/config.ts @@ -1,9 +1,12 @@ -import { config as auth } from "~libraries/auth/config.ts"; -import { getEnvironmentVariable, toNumber } from "~libraries/config/mod.ts"; +import { getEnvironmentVariable } from "@platform/config/environment.ts"; +import z from "zod"; export const config = { - name: "valkyr", - host: getEnvironmentVariable("API_HOST", "0.0.0.0"), - port: getEnvironmentVariable("API_PORT", toNumber, "8370"), - ...auth, + name: "@valkyr/boilerplate", + host: getEnvironmentVariable({ key: "API_HOST", type: z.ipv4(), fallback: "0.0.0.0" }), + port: getEnvironmentVariable({ + key: "API_PORT", + type: z.coerce.number(), + fallback: "8370", + }), }; diff --git a/api/deno.json b/api/deno.json deleted file mode 100644 index 4f86b67..0000000 --- a/api/deno.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "imports": { - "~libraries/": "./libraries/", - "~stores/": "./stores/", - "~config": "./config.ts" - } -} \ No newline at end of file diff --git a/api/libraries/auth/auth.ts b/api/libraries/auth/auth.ts deleted file mode 100644 index 970946b..0000000 --- a/api/libraries/auth/auth.ts +++ /dev/null @@ -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; diff --git a/api/libraries/auth/config.ts b/api/libraries/auth/config.ts deleted file mode 100644 index edd734b..0000000 --- a/api/libraries/auth/config.ts +++ /dev/null @@ -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, -}; diff --git a/api/libraries/auth/mod.ts b/api/libraries/auth/mod.ts deleted file mode 100644 index 000354c..0000000 --- a/api/libraries/auth/mod.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { auth } from "./auth.ts"; - -export * from "./auth.ts"; -export * from "./config.ts"; - -export type Auth = typeof auth; diff --git a/api/libraries/auth/principal.ts b/api/libraries/auth/principal.ts deleted file mode 100644 index b3dbd7d..0000000 --- a/api/libraries/auth/principal.ts +++ /dev/null @@ -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; diff --git a/api/libraries/config/libraries/args.ts b/api/libraries/config/libraries/args.ts deleted file mode 100644 index f43b629..0000000 --- a/api/libraries/config/libraries/args.ts +++ /dev/null @@ -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(key: string, parse: T, fallback?: any): ReturnType; -export function getArgsVariable(key: string, parse?: T, fallback?: any): ReturnType { - 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); -} diff --git a/api/libraries/config/libraries/environment.ts b/api/libraries/config/libraries/environment.ts deleted file mode 100644 index 087e384..0000000 --- a/api/libraries/config/libraries/environment.ts +++ /dev/null @@ -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(key: string, parse: T, fallback?: any): ReturnType; -export function getEnvironmentVariable(key: string, parse?: T, fallback?: any): ReturnType { - 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} - 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 { - 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> & { - testing?: string; - local?: string; - stg?: string; - demo?: string; - prod?: string; -}; diff --git a/api/libraries/config/libraries/parsers.ts b/api/libraries/config/libraries/parsers.ts deleted file mode 100644 index 1789b02..0000000 --- a/api/libraries/config/libraries/parsers.ts +++ /dev/null @@ -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]; diff --git a/api/libraries/config/mod.ts b/api/libraries/config/mod.ts deleted file mode 100644 index a8b8c39..0000000 --- a/api/libraries/config/mod.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./libraries/args.ts"; -export * from "./libraries/environment.ts"; -export * from "./libraries/parsers.ts"; diff --git a/api/libraries/crypto/mod.ts b/api/libraries/crypto/mod.ts deleted file mode 100644 index 4885382..0000000 --- a/api/libraries/crypto/mod.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./password.ts"; diff --git a/api/libraries/database/.tasks/bootstrap.ts b/api/libraries/database/.tasks/bootstrap.ts deleted file mode 100644 index eee8dac..0000000 --- a/api/libraries/database/.tasks/bootstrap.ts +++ /dev/null @@ -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)); diff --git a/api/libraries/database/config.ts b/api/libraries/database/config.ts deleted file mode 100644 index ed9e973..0000000 --- a/api/libraries/database/config.ts +++ /dev/null @@ -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"), - }, -}; diff --git a/api/libraries/logger/config.ts b/api/libraries/logger/config.ts deleted file mode 100644 index 72816f5..0000000 --- a/api/libraries/logger/config.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { getArgsVariable } from "~libraries/config/mod.ts"; - -export const config = { - level: getArgsVariable("LOG_LEVEL", "info"), -}; diff --git a/api/libraries/server/context.ts b/api/libraries/server/context.ts deleted file mode 100644 index d1e0929..0000000 --- a/api/libraries/server/context.ts +++ /dev/null @@ -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; - }, - }; -} diff --git a/api/libraries/server/mod.ts b/api/libraries/server/mod.ts deleted file mode 100644 index 316ad24..0000000 --- a/api/libraries/server/mod.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./api.ts"; -export * from "./context.ts"; -export * from "./modules.ts"; -export * from "./request.ts"; -export * from "./storage.ts"; diff --git a/api/libraries/server/request.ts b/api/libraries/server/request.ts deleted file mode 100644 index d3e9889..0000000 --- a/api/libraries/server/request.ts +++ /dev/null @@ -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; diff --git a/api/libraries/server/storage.ts b/api/libraries/server/storage.ts deleted file mode 100644 index e731db1..0000000 --- a/api/libraries/server/storage.ts +++ /dev/null @@ -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(); - -export type Storage = { - session?: Session; - info: { - method: string; - start: number; - end?: number; - }; - sockets?: Sockets; - response: { - headers: Headers; - }; -}; diff --git a/api/libraries/socket/upgrade.ts b/api/libraries/socket/upgrade.ts deleted file mode 100644 index 824faf8..0000000 --- a/api/libraries/socket/upgrade.ts +++ /dev/null @@ -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; -} diff --git a/api/package.json b/api/package.json index adcf4e4..80a4434 100644 --- a/api/package.json +++ b/api/package.json @@ -1,25 +1,9 @@ { "private": true, "scripts": { - "start": "deno --allow-all --watch-hmr=routes/ server.ts", - "migrate": "deno run --allow-all .tasks/migrate.ts" + "start": "deno --allow-all --watch-hmr=routes/ server.ts" }, "dependencies": { - "@cerbos/http": "0.23.1", - "@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" + "zod": "4.1.11" } } \ No newline at end of file diff --git a/api/routes/account/create.ts b/api/routes/account/create.ts deleted file mode 100644 index 5cac17d..0000000 --- a/api/routes/account/create.ts +++ /dev/null @@ -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); -}); diff --git a/api/routes/account/get-by-id.ts b/api/routes/account/get-by-id.ts deleted file mode 100644 index 151b9e9..0000000 --- a/api/routes/account/get-by-id.ts +++ /dev/null @@ -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; -}); diff --git a/api/routes/auth/email.ts b/api/routes/auth/email.ts deleted file mode 100644 index 56a10d7..0000000 --- a/api/routes/auth/email.ts +++ /dev/null @@ -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`, - }); -}); diff --git a/api/routes/auth/session.ts b/api/routes/auth/session.ts deleted file mode 100644 index f920cb8..0000000 --- a/api/routes/auth/session.ts +++ /dev/null @@ -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; -}); diff --git a/api/server.ts b/api/server.ts index 0dd37ab..a9b4594 100644 --- a/api/server.ts +++ b/api/server.ts @@ -1,15 +1,14 @@ -import { resolve } from "@std/path"; -import cookie from "cookie"; - -import { auth, type Session } from "~libraries/auth/mod.ts"; -import { logger } from "~libraries/logger/mod.ts"; -import { type Storage, storage } from "~libraries/server/mod.ts"; -import { Api, resolveRoutes } from "~libraries/server/mod.ts"; +import identity from "@modules/identity/server.ts"; +import database from "@platform/database/server.ts"; +import { logger } from "@platform/logger"; +import { context } from "@platform/relay"; +import { Api } from "@platform/server/api.ts"; +import server from "@platform/server/server.ts"; +import socket from "@platform/socket/server.ts"; +import { storage } from "@platform/storage"; import { config } from "./config.ts"; -const ROUTES_DIR = resolve(import.meta.dirname!, "routes"); - 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}`); }, }, - async (request) => { - const url = new URL(request.url); + async (request) => + storage.run({}, async () => { + 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; - if (token !== undefined) { - 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; - } + await server.resolve(request); + await socket.resolve(); - const context = { - session, - info: { - method: request.url, - start: Date.now(), - }, - response: { - headers: new Headers(), - }, - } satisfies Storage; + await identity.resolve(request); + + // ### Fetch + // Execute fetch against the api instance. - return storage.run(context, async () => { return api.fetch(request).finally(() => { log.info( `${request.method} ${url.pathname} [${((Date.now() - context.info.start) / 1000).toLocaleString()} seconds]`, ); }); - }); - }, + }), ); diff --git a/api/stores/event-store/.tasks/bootstrap.ts b/api/stores/event-store/.tasks/bootstrap.ts deleted file mode 100644 index 9c728a3..0000000 --- a/api/stores/event-store/.tasks/bootstrap.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { register } from "@valkyr/event-store/mongo"; - -import { eventStore } from "../event-store.ts"; - -await register(eventStore.db.db, console.info); diff --git a/api/stores/event-store/aggregates/account.ts b/api/stores/event-store/aggregates/account.ts deleted file mode 100644 index cdcff45..0000000 --- a/api/stores/event-store/aggregates/account.ts +++ /dev/null @@ -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 { - 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 { - 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 } } }); -}); diff --git a/api/stores/event-store/event-store.ts b/api/stores/event-store/event-store.ts deleted file mode 100644 index 5d4cfb9..0000000 --- a/api/stores/event-store/event-store.ts +++ /dev/null @@ -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 }); - } - } -}); diff --git a/api/stores/event-store/events/account.ts b/api/stores/event-store/events/account.ts deleted file mode 100644 index 0b4e2ce..0000000 --- a/api/stores/event-store/events/account.ts +++ /dev/null @@ -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), -]; diff --git a/api/stores/event-store/events/auditor.ts b/api/stores/event-store/events/auditor.ts deleted file mode 100644 index f55bc80..0000000 --- a/api/stores/event-store/events/auditor.ts +++ /dev/null @@ -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; diff --git a/api/stores/event-store/events/mod.ts b/api/stores/event-store/events/mod.ts deleted file mode 100644 index 6aada05..0000000 --- a/api/stores/event-store/events/mod.ts +++ /dev/null @@ -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; diff --git a/api/stores/event-store/events/organization.ts b/api/stores/event-store/events/organization.ts deleted file mode 100644 index 99b9981..0000000 --- a/api/stores/event-store/events/organization.ts +++ /dev/null @@ -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), -]; diff --git a/api/stores/event-store/events/strategy.ts b/api/stores/event-store/events/strategy.ts deleted file mode 100644 index 61b88b7..0000000 --- a/api/stores/event-store/events/strategy.ts +++ /dev/null @@ -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), -]; diff --git a/api/stores/event-store/projector.ts b/api/stores/event-store/projector.ts deleted file mode 100644 index 4a7e831..0000000 --- a/api/stores/event-store/projector.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Projector } from "@valkyr/event-store"; - -import { EventStoreFactory } from "./events/mod.ts"; - -export const projector = new Projector(); diff --git a/api/stores/read-store/.tasks/bootstrap.ts b/api/stores/read-store/.tasks/bootstrap.ts deleted file mode 100644 index d5c4dc2..0000000 --- a/api/stores/read-store/.tasks/bootstrap.ts +++ /dev/null @@ -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" }]], - }, -]); diff --git a/api/stores/read-store/database.ts b/api/stores/read-store/database.ts deleted file mode 100644 index 1ef1e90..0000000 --- a/api/stores/read-store/database.ts +++ /dev/null @@ -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(documents: TDocument[]): TDocument | undefined { - return documents[0]; -} diff --git a/apps/react/package.json b/apps/react/package.json index 5cefc4f..256152c 100644 --- a/apps/react/package.json +++ b/apps/react/package.json @@ -20,7 +20,7 @@ "react": "19.1.1", "react-dom": "19.1.1", "tailwindcss": "4.1.13", - "zod": "4.1.9" + "zod": "4.1.11" }, "devDependencies": { "@eslint/js": "9.35.0", diff --git a/deno.json b/deno.json index 98b4415..de41fac 100644 --- a/deno.json +++ b/deno.json @@ -4,14 +4,31 @@ "workspace": [ "api", "apps/react", - "platform/models", + "modules/identity", + "platform/cerbos", + "platform/config", + "platform/database", + "platform/logger", "platform/relay", - "platform/spec" + "platform/server", + "platform/socket", + "platform/spec", + "platform/storage", + "platform/vault" ], "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/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": { "start:api": { diff --git a/deno.lock b/deno.lock index 7ae5159..d4ec9ea 100644 --- a/deno.lock +++ b/deno.lock @@ -5,10 +5,7 @@ "npm:@eslint/js@9.35.0": "9.35.0", "npm:@jsr/felix__bcrypt@1.0.5": "1.0.5", "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__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/valkyr__auth@2.1.4": "2.1.4", "npm:@jsr/valkyr__db@2.0.0": "2.0.0", @@ -31,7 +28,9 @@ "npm:eslint@9.35.0": "9.35.0", "npm:fast-equals@5.2.2": "5.2.2", "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:nanoid@5.1.5": "5.1.5", "npm:path-to-regexp@8": "8.3.0", "npm:prettier@3.6.2": "3.6.2", "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@5.9.2": "5.9.2", "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.9": "4.1.9" + "npm:zod@4.1.11": "4.1.11" }, "npm": { "@babel/code-frame@7.27.1": { @@ -455,10 +453,6 @@ "integrity": "sha512-aIG8W3TOmW+lKdAJA5w56qASu9EiUmBXbhW6eAlSEUBid+KVESGqQygFFg+awt/c8K+qobVM6M/u3SbIy0NyUQ==", "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": { "integrity": "sha512-+mT4Nll6fx+CPNqrlC+huhIOYNSMS+KUdJ4B8NujiQrh/bq++ds5PXpEsfV5EPR+YuWcuDGG0P1DE+Rednd7Wg==", "dependencies": [ @@ -597,108 +591,113 @@ "@rolldown/pluginutils@1.0.0-beta.27": { "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==" }, - "@rollup/rollup-android-arm-eabi@4.51.0": { - "integrity": "sha512-VyfldO8T/C5vAXBGIobrAnUE+VJNVLw5z9h4NgSDq/AJZWt/fXqdW+0PJbk+M74xz7yMDRiHtlsuDV7ew6K20w==", + "@rollup/rollup-android-arm-eabi@4.52.0": { + "integrity": "sha512-VxDYCDqOaR7NXzAtvRx7G1u54d2kEHopb28YH/pKzY6y0qmogP3gG7CSiWsq9WvDFxOQMpNEyjVAHZFXfH3o/A==", "os": ["android"], "cpu": ["arm"] }, - "@rollup/rollup-android-arm64@4.51.0": { - "integrity": "sha512-Z3ujzDZgsEVSokgIhmOAReh9SGT2qloJJX2Xo1Q3nPU1EhCXrV0PbpR3r7DWRgozqnjrPZQkLe5cgBPIYp70Vg==", + "@rollup/rollup-android-arm64@4.52.0": { + "integrity": "sha512-pqDirm8koABIKvzL59YI9W9DWbRlTX7RWhN+auR8HXJxo89m4mjqbah7nJZjeKNTNYopqL+yGg+0mhCpf3xZtQ==", "os": ["android"], "cpu": ["arm64"] }, - "@rollup/rollup-darwin-arm64@4.51.0": { - "integrity": "sha512-T3gskHgArUdR6TCN69li5VELVAZK+iQ4iwMoSMNYixoj+56EC9lTj35rcxhXzIJt40YfBkvDy3GS+t5zh7zM6g==", + "@rollup/rollup-darwin-arm64@4.52.0": { + "integrity": "sha512-YCdWlY/8ltN6H78HnMsRHYlPiKvqKagBP1r+D7SSylxX+HnsgXGCmLiV3Y4nSyY9hW8qr8U9LDUx/Lo7M6MfmQ==", "os": ["darwin"], "cpu": ["arm64"] }, - "@rollup/rollup-darwin-x64@4.51.0": { - "integrity": "sha512-Hh7n/fh0g5UjH6ATDF56Qdf5bzdLZKIbhp5KftjMYG546Ocjeyg15dxphCpH1FFY2PJ2G6MiOVL4jMq5VLTyrQ==", + "@rollup/rollup-darwin-x64@4.52.0": { + "integrity": "sha512-z4nw6y1j+OOSGzuVbSWdIp1IUks9qNw4dc7z7lWuWDKojY38VMWBlEN7F9jk5UXOkUcp97vA1N213DF+Lz8BRg==", "os": ["darwin"], "cpu": ["x64"] }, - "@rollup/rollup-freebsd-arm64@4.51.0": { - "integrity": "sha512-0EddADb6FBvfqYoxwVom3hAbAvpSVUbZqmR1wmjk0MSZ06hn/UxxGHKRqEQDMkts7XiZjejVB+TLF28cDTU+gA==", + "@rollup/rollup-freebsd-arm64@4.52.0": { + "integrity": "sha512-Q/dv9Yvyr5rKlK8WQJZVrp5g2SOYeZUs9u/t2f9cQ2E0gJjYB/BWoedXfUT0EcDJefi2zzVfhcOj8drWCzTviw==", "os": ["freebsd"], "cpu": ["arm64"] }, - "@rollup/rollup-freebsd-x64@4.51.0": { - "integrity": "sha512-MpqaEDLo3JuVPF+wWV4mK7V8akL76WCz8ndfz1aVB7RhvXFO3k7yT7eu8OEuog4VTSyNu5ibvN9n6lgjq/qLEQ==", + "@rollup/rollup-freebsd-x64@4.52.0": { + "integrity": "sha512-kdBsLs4Uile/fbjZVvCRcKB4q64R+1mUq0Yd7oU1CMm1Av336ajIFqNFovByipciuUQjBCPMxwJhCgfG2re3rg==", "os": ["freebsd"], "cpu": ["x64"] }, - "@rollup/rollup-linux-arm-gnueabihf@4.51.0": { - "integrity": "sha512-WEWAGFNFFpvSWAIT3MYvxTkYHv/cJl9yWKpjhheg7ONfB0hetZt/uwBnM3GZqSHrk5bXCDYTFXg3jQyk/j7eXQ==", + "@rollup/rollup-linux-arm-gnueabihf@4.52.0": { + "integrity": "sha512-aL6hRwu0k7MTUESgkg7QHY6CoqPgr6gdQXRJI1/VbFlUMwsSzPGSR7sG5d+MCbYnJmJwThc2ol3nixj1fvI/zQ==", "os": ["linux"], "cpu": ["arm"] }, - "@rollup/rollup-linux-arm-musleabihf@4.51.0": { - "integrity": "sha512-9bxtxj8QoAp++LOq5PGDGkEEOpCDk9rOEHUcXadnijedDH8IXrBt6PnBa4Y6NblvGWdoxvXZYghZLaliTCmAng==", + "@rollup/rollup-linux-arm-musleabihf@4.52.0": { + "integrity": "sha512-BTs0M5s1EJejgIBJhCeiFo7GZZ2IXWkFGcyZhxX4+8usnIo5Mti57108vjXFIQmmJaRyDwmV59Tw64Ap1dkwMw==", "os": ["linux"], "cpu": ["arm"] }, - "@rollup/rollup-linux-arm64-gnu@4.51.0": { - "integrity": "sha512-DdqA+fARqIsfqDYkKo2nrWMp0kvu/wPJ2G8lZ4DjYhn+8QhrjVuzmsh7tTkhULwjvHTN59nWVzAixmOi6rqjNA==", + "@rollup/rollup-linux-arm64-gnu@4.52.0": { + "integrity": "sha512-uj672IVOU9m08DBGvoPKPi/J8jlVgjh12C9GmjjBxCTQc3XtVmRkRKyeHSmIKQpvJ7fIm1EJieBUcnGSzDVFyw==", "os": ["linux"], "cpu": ["arm64"] }, - "@rollup/rollup-linux-arm64-musl@4.51.0": { - "integrity": "sha512-2XVRNzcUJE1UJua8P4a1GXS5jafFWE+pQ6zhUbZzptOu/70p1F6+0FTi6aGPd6jNtnJqGMjtBCXancC2dhYlWw==", + "@rollup/rollup-linux-arm64-musl@4.52.0": { + "integrity": "sha512-/+IVbeDMDCtB/HP/wiWsSzduD10SEGzIZX2945KSgZRNi4TSkjHqRJtNTVtVb8IRwhJ65ssI56krlLik+zFWkw==", "os": ["linux"], "cpu": ["arm64"] }, - "@rollup/rollup-linux-loong64-gnu@4.51.0": { - "integrity": "sha512-R8QhY0kLIPCAVXWi2yftDSpn7Jtejey/WhMoBESSfwGec5SKdFVupjxFlKoQ7clVRuaDpiQf7wNx3EBZf4Ey6g==", + "@rollup/rollup-linux-loong64-gnu@4.52.0": { + "integrity": "sha512-U1vVzvSWtSMWKKrGoROPBXMh3Vwn93TA9V35PldokHGqiUbF6erSzox/5qrSMKp6SzakvyjcPiVF8yB1xKr9Pg==", "os": ["linux"], "cpu": ["loong64"] }, - "@rollup/rollup-linux-ppc64-gnu@4.51.0": { - "integrity": "sha512-I498RPfxx9cMv1KTHQ9tg2Ku1utuQm+T5B+Xro+WNu3FzAFSKp4awKfgMoZwjoPgNbaFGINaOM25cQW6WuBhiQ==", + "@rollup/rollup-linux-ppc64-gnu@4.52.0": { + "integrity": "sha512-X/4WfuBAdQRH8cK3DYl8zC00XEE6aM472W+QCycpQJeLWVnHfkv7RyBFVaTqNUMsTgIX8ihMjCvFF9OUgeABzw==", "os": ["linux"], "cpu": ["ppc64"] }, - "@rollup/rollup-linux-riscv64-gnu@4.51.0": { - "integrity": "sha512-o8COudsb8lvtdm9ixg9aKjfX5aeoc2x9KGE7WjtrmQFquoCRZ9jtzGlonujE4WhvXFepTraWzT4RcwyDDeHXjA==", + "@rollup/rollup-linux-riscv64-gnu@4.52.0": { + "integrity": "sha512-xIRYc58HfWDBZoLmWfWXg2Sq8VCa2iJ32B7mqfWnkx5mekekl0tMe7FHpY8I72RXEcUkaWawRvl3qA55og+cwQ==", "os": ["linux"], "cpu": ["riscv64"] }, - "@rollup/rollup-linux-riscv64-musl@4.51.0": { - "integrity": "sha512-0shJPgSXMdYzOQzpM5BJN2euXY1f8uV8mS6AnrbMcH2KrkNsbpMxWB1wp8UEdiJ1NtyBkCk3U/HfX5mEONBq6w==", + "@rollup/rollup-linux-riscv64-musl@4.52.0": { + "integrity": "sha512-mbsoUey05WJIOz8U1WzNdf+6UMYGwE3fZZnQqsM22FZ3wh1N887HT6jAOjXs6CNEK3Ntu2OBsyQDXfIjouI4dw==", "os": ["linux"], "cpu": ["riscv64"] }, - "@rollup/rollup-linux-s390x-gnu@4.51.0": { - "integrity": "sha512-L7pV+ny7865jamSCQwyozBYjFRUKaTsPqDz7ClOtJCDu4paf2uAa0mrcHwSt4XxZP2ogFZS9uuitH3NXdeBEJA==", + "@rollup/rollup-linux-s390x-gnu@4.52.0": { + "integrity": "sha512-qP6aP970bucEi5KKKR4AuPFd8aTx9EF6BvutvYxmZuWLJHmnq4LvBfp0U+yFDMGwJ+AIJEH5sIP+SNypauMWzg==", "os": ["linux"], "cpu": ["s390x"] }, - "@rollup/rollup-linux-x64-gnu@4.51.0": { - "integrity": "sha512-4YHhP+Rv3T3+H3TPbUvWOw5tuSwhrVhkHHZhk4hC9VXeAOKR26/IsUAT4FsB4mT+kfIdxxb1BezQDEg/voPO8A==", + "@rollup/rollup-linux-x64-gnu@4.52.0": { + "integrity": "sha512-nmSVN+F2i1yKZ7rJNKO3G7ZzmxJgoQBQZ/6c4MuS553Grmr7WqR7LLDcYG53Z2m9409z3JLt4sCOhLdbKQ3HmA==", "os": ["linux"], "cpu": ["x64"] }, - "@rollup/rollup-linux-x64-musl@4.51.0": { - "integrity": "sha512-P7U7U03+E5w7WgJtvSseNLOX1UhknVPmEaqgUENFWfNxNBa1OhExT6qYGmyF8gepcxWSaSfJsAV5UwhWrYefdQ==", + "@rollup/rollup-linux-x64-musl@4.52.0": { + "integrity": "sha512-2d0qRo33G6TfQVjaMR71P+yJVGODrt5V6+T0BDYH4EMfGgdC/2HWDVjSSFw888GSzAZUwuska3+zxNUCDco6rQ==", "os": ["linux"], "cpu": ["x64"] }, - "@rollup/rollup-openharmony-arm64@4.51.0": { - "integrity": "sha512-FuD8g3u9W6RPwdO1R45hZFORwa1g9YXEMesAKP/sOi7mDqxjbni8S3zAXJiDcRfGfGBqpRYVuH54Gu3FTuSoEw==", + "@rollup/rollup-openharmony-arm64@4.52.0": { + "integrity": "sha512-A1JalX4MOaFAAyGgpO7XP5khquv/7xKzLIyLmhNrbiCxWpMlnsTYr8dnsWM7sEeotNmxvSOEL7F65j0HXFcFsw==", "os": ["openharmony"], "cpu": ["arm64"] }, - "@rollup/rollup-win32-arm64-msvc@4.51.0": { - "integrity": "sha512-zST+FdMCX3QAYfmZX3dp/Fy8qLUetfE17QN5ZmmFGPrhl86qvRr+E9u2bk7fzkIXsfQR30Z7ZRS7WMryPPn4rQ==", + "@rollup/rollup-win32-arm64-msvc@4.52.0": { + "integrity": "sha512-YQugafP/rH0eOOHGjmNgDURrpYHrIX0yuojOI8bwCyXwxC9ZdTd3vYkmddPX0oHONLXu9Rb1dDmT0VNpjkzGGw==", "os": ["win32"], "cpu": ["arm64"] }, - "@rollup/rollup-win32-ia32-msvc@4.51.0": { - "integrity": "sha512-U+qhoCVAZmTHCmUKxdQxw1jwAFNFXmOpMME7Npt5GTb1W/7itfgAgNluVOvyeuSeqW+dEQLFuNZF3YZPO8XkMg==", + "@rollup/rollup-win32-ia32-msvc@4.52.0": { + "integrity": "sha512-zYdUYhi3Qe2fndujBqL5FjAFzvNeLxtIqfzNEVKD1I7C37/chv1VxhscWSQHTNfjPCrBFQMnynwA3kpZpZ8w4A==", "os": ["win32"], "cpu": ["ia32"] }, - "@rollup/rollup-win32-x64-msvc@4.51.0": { - "integrity": "sha512-z6UpFzMhXSD8NNUfCi2HO+pbpSzSWIIPgb1TZsEZjmZYtk6RUIC63JYjlFBwbBZS3jt3f1q6IGfkj3g+GnBt2Q==", + "@rollup/rollup-win32-x64-gnu@4.52.0": { + "integrity": "sha512-fGk03kQylNaCOQ96HDMeT7E2n91EqvCDd3RwvT5k+xNdFCeMGnj5b5hEgTGrQuyidqSsD3zJDQ21QIaxXqTBJw==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@rollup/rollup-win32-x64-msvc@4.52.0": { + "integrity": "sha512-6iKDCVSIUQ8jPMoIV0OytRKniaYyy5EbY/RRydmLW8ZR3cEBhxbWl5ro0rkUNe0ef6sScvhbY79HrjRm8i3vDQ==", "os": ["win32"], "cpu": ["x64"] }, @@ -848,8 +847,8 @@ "tiny-warning" ] }, - "@tanstack/react-store@0.7.5_react@19.1.1_react-dom@19.1.1__react@19.1.1": { - "integrity": "sha512-A+WZtEnHZpvbKXm8qR+xndNKywBLez2KKKKEQc7w0Qs45GvY1LpRI3BTZNmELwEVim8+Apf99iEDH2J+MUIzlQ==", + "@tanstack/react-store@0.7.7_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==", "dependencies": [ "@tanstack/store", "react", @@ -879,8 +878,8 @@ "tiny-invariant" ] }, - "@tanstack/store@0.7.5": { - "integrity": "sha512-qd/OjkjaFRKqKU4Yjipaen/EOB9MyEg6Wr9fW103RBPACf1ZcKhbhcu2S5mj5IgdPib6xFIgCUti/mKVkl+fRw==" + "@tanstack/store@0.7.7": { + "integrity": "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==" }, "@types/babel__core@7.20.5": { "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", @@ -1766,6 +1765,10 @@ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "bin": true }, + "nanoid@5.1.5": { + "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==", + "bin": true + }, "natural-compare@1.4.0": { "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" }, @@ -1825,7 +1828,7 @@ "postcss@8.5.6": { "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dependencies": [ - "nanoid", + "nanoid@3.3.11", "picocolors", "source-map-js" ] @@ -1871,8 +1874,8 @@ "reusify@1.1.0": { "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==" }, - "rollup@4.51.0": { - "integrity": "sha512-7cR0XWrdp/UAj2HMY/Y4QQEUjidn3l2AY1wSeZoFjMbD8aOMPoV9wgTFYbrJpPzzvejDEini1h3CiUP8wLzxQA==", + "rollup@4.52.0": { + "integrity": "sha512-+IuescNkTJQgX7AkIDtITipZdIGcWF0pnVvZTWStiazUmcGA2ag8dfg0urest2XlXUi9kuhfQ+qmdc5Stc3z7g==", "dependencies": [ "@types/estree" ], @@ -1897,6 +1900,7 @@ "@rollup/rollup-openharmony-arm64", "@rollup/rollup-win32-arm64-msvc", "@rollup/rollup-win32-ia32-msvc", + "@rollup/rollup-win32-x64-gnu", "@rollup/rollup-win32-x64-msvc", "fsevents" ], @@ -2168,8 +2172,8 @@ "yocto-queue@0.1.0": { "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" }, - "zod@4.1.9": { - "integrity": "sha512-HI32jTq0AUAC125z30E8bQNz0RQ+9Uc+4J7V97gLYjZVKRjeydPgGt6dvQzFrav7MYOUGFqqOGiHpA/fdbd0cQ==" + "zod@4.1.11": { + "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==" } }, "workspace": { @@ -2187,19 +2191,7 @@ "api": { "packageJson": { "dependencies": [ - "npm:@cerbos/http@0.23.1", - "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" + "npm:zod@4.1.11" ] } }, @@ -2227,29 +2219,91 @@ "npm:typescript-eslint@8.44.0", "npm:typescript@5.9.2", "npm:vite@7.1.6", - "npm:zod@4.1.9" + "npm:zod@4.1.11" ] } }, - "platform/models": { + "modules/identity": { "packageJson": { "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": { "packageJson": { "dependencies": [ + "npm:@jsr/valkyr__auth@2.1.4", "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": { "packageJson": { "dependencies": [ - "npm:zod@4" + "npm:zod@4.1.11" + ] + } + }, + "platform/vault": { + "packageJson": { + "dependencies": [ + "npm:jose@6.1.0", + "npm:nanoid@5.1.5" ] } } diff --git a/docker-compose.yml b/docker-compose.yml index 761488a..1aba769 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,8 +8,8 @@ services: - "3593:3593" - "3594:3594" volumes: - - ./cerbos/config.yaml:/config.yaml # <--- mount config - - ./cerbos/policies:/data/policies # <--- mount policies + - ./platform/cerbos/config.yaml:/config.yaml # <--- mount config + - ./platform/cerbos/policies:/data/policies # <--- mount policies networks: - localdev diff --git a/api/libraries/auth/.keys/private b/modules/identity/.keys/private similarity index 100% rename from api/libraries/auth/.keys/private rename to modules/identity/.keys/private diff --git a/api/libraries/auth/.keys/public b/modules/identity/.keys/public similarity index 100% rename from api/libraries/auth/.keys/public rename to modules/identity/.keys/public diff --git a/api/stores/event-store/aggregates/code.ts b/modules/identity/aggregates/code.ts similarity index 96% rename from api/stores/event-store/aggregates/code.ts rename to modules/identity/aggregates/code.ts index 0f01b8f..31c49d2 100644 --- a/api/stores/event-store/aggregates/code.ts +++ b/modules/identity/aggregates/code.ts @@ -1,7 +1,7 @@ import { AggregateRoot, getDate } from "@valkyr/event-store"; +import { EventRecord, EventStoreFactory } from "../event-store.ts"; import { CodeIdentity } from "../events/code.ts"; -import { EventRecord, EventStoreFactory } from "../events/mod.ts"; export class Code extends AggregateRoot { static override readonly name = "code"; diff --git a/modules/identity/aggregates/identity.ts b/modules/identity/aggregates/identity.ts new file mode 100644 index 0000000..05dfd5c --- /dev/null +++ b/modules/identity/aggregates/identity.ts @@ -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 { + 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 { + 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 } } }); +}); diff --git a/modules/identity/auth.ts b/modules/identity/auth.ts new file mode 100644 index 0000000..88a9fea --- /dev/null +++ b/modules/identity/auth.ts @@ -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; diff --git a/api/libraries/auth/access.ts b/modules/identity/auth/access.ts similarity index 95% rename from api/libraries/auth/access.ts rename to modules/identity/auth/access.ts index fb75ba0..cd4f4ed 100644 --- a/api/libraries/auth/access.ts +++ b/modules/identity/auth/access.ts @@ -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 { Resource } from "./resources.ts"; export function access(principal: Principal) { return { diff --git a/modules/identity/auth/jwt.ts b/modules/identity/auth/jwt.ts new file mode 100644 index 0000000..d5bc976 --- /dev/null +++ b/modules/identity/auth/jwt.ts @@ -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", +}; diff --git a/modules/identity/auth/principal.ts b/modules/identity/auth/principal.ts new file mode 100644 index 0000000..5f8d5ee --- /dev/null +++ b/modules/identity/auth/principal.ts @@ -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; diff --git a/modules/identity/client.ts b/modules/identity/client.ts new file mode 100644 index 0000000..eb728b2 --- /dev/null +++ b/modules/identity/client.ts @@ -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, + }, + }, +); diff --git a/modules/identity/config.ts b/modules/identity/config.ts new file mode 100644 index 0000000..fb6548f --- /dev/null +++ b/modules/identity/config.ts @@ -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, +}; diff --git a/api/libraries/crypto/password.ts b/modules/identity/crypto/password.ts similarity index 100% rename from api/libraries/crypto/password.ts rename to modules/identity/crypto/password.ts diff --git a/api/stores/read-store/methods.ts b/modules/identity/database.ts similarity index 51% rename from api/stores/read-store/methods.ts rename to modules/identity/database.ts index f9a4602..aa42ba9 100644 --- a/api/stores/read-store/methods.ts +++ b/modules/identity/database.ts @@ -1,46 +1,30 @@ -import { Account, fromAccountDocument } from "@platform/models/account.ts"; -import { PasswordStrategy } from "@platform/spec/auth/strategies.ts"; +import { getDatabaseAccessor } from "@platform/database/accessor.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. * - * @param id - Account identifier. + * @param id - Unique identity. */ -export async function getAccountById(id: string): Promise { +export async function getIdentityById(id: string): Promise { return db - .collection("accounts") - .aggregate([ - { - $match: { id }, - }, - { - $lookup: { - from: "roles", - localField: "roles", - foreignField: "id", - as: "roles", - }, - }, - ]) - .toArray() - .then(fromAccountDocument) - .then(takeOne); + .collection("identities") + .findOne({ id }) + .then((document) => parseIdentity(document)); } -/* - |-------------------------------------------------------------------------------- - | Auth - |-------------------------------------------------------------------------------- - */ - /** * Get strategy details for the given password strategy alias. * @@ -49,7 +33,7 @@ export async function getAccountById(id: string): Promise { export async function getPasswordStrategyByAlias( alias: string, ): Promise<({ accountId: string } & PasswordStrategy) | undefined> { - const account = await db.collection("accounts").findOne({ + const account = await db.collection("identities").findOne({ strategies: { $elemMatch: { type: "password", alias }, }, diff --git a/modules/identity/errors.ts b/modules/identity/errors.ts new file mode 100644 index 0000000..60700ad --- /dev/null +++ b/modules/identity/errors.ts @@ -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.`); + } +} diff --git a/modules/identity/event-store.ts b/modules/identity/event-store.ts new file mode 100644 index 0000000..da376c1 --- /dev/null +++ b/modules/identity/event-store.ts @@ -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(); + +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; diff --git a/api/stores/event-store/events/code.ts b/modules/identity/events/code.ts similarity index 93% rename from api/stores/event-store/events/code.ts rename to modules/identity/events/code.ts index 7dd6964..42a06a2 100644 --- a/api/stores/event-store/events/code.ts +++ b/modules/identity/events/code.ts @@ -2,7 +2,7 @@ import { event } from "@valkyr/event-store"; import z from "zod"; const CodeIdentitySchema = z.object({ - accountId: z.string(), + id: z.string(), }); export default [ diff --git a/modules/identity/events/identity.ts b/modules/identity/events/identity.ts new file mode 100644 index 0000000..86eb995 --- /dev/null +++ b/modules/identity/events/identity.ts @@ -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), +]; diff --git a/modules/identity/models/identity.ts b/modules/identity/models/identity.ts new file mode 100644 index 0000000..668f796 --- /dev/null +++ b/modules/identity/models/identity.ts @@ -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; diff --git a/modules/identity/package.json b/modules/identity/package.json new file mode 100644 index 0000000..9a8be51 --- /dev/null +++ b/modules/identity/package.json @@ -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" + } +} \ No newline at end of file diff --git a/cerbos/policies/account.yaml b/modules/identity/policies/identity.yaml similarity index 97% rename from cerbos/policies/account.yaml rename to modules/identity/policies/identity.yaml index c3da02b..86f22db 100644 --- a/cerbos/policies/account.yaml +++ b/modules/identity/policies/identity.yaml @@ -3,7 +3,7 @@ apiVersion: api.cerbos.dev/v1 resourcePolicy: - resource: account + resource: identity version: default rules: diff --git a/modules/identity/routes/identities/get/handle.ts b/modules/identity/routes/identities/get/handle.ts new file mode 100644 index 0000000..1a135b1 --- /dev/null +++ b/modules/identity/routes/identities/get/handle.ts @@ -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; +}); diff --git a/modules/identity/routes/identities/get/spec.ts b/modules/identity/routes/identities/get/spec.ts new file mode 100644 index 0000000..b9b9d06 --- /dev/null +++ b/modules/identity/routes/identities/get/spec.ts @@ -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); diff --git a/modules/identity/routes/identities/me/handle.ts b/modules/identity/routes/identities/me/handle.ts new file mode 100644 index 0000000..babd738 --- /dev/null +++ b/modules/identity/routes/identities/me/handle.ts @@ -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; +}); diff --git a/modules/identity/routes/identities/me/spec.ts b/modules/identity/routes/identities/me/spec.ts new file mode 100644 index 0000000..ed750db --- /dev/null +++ b/modules/identity/routes/identities/me/spec.ts @@ -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]); diff --git a/modules/identity/routes/identities/register/handle.ts b/modules/identity/routes/identities/register/handle.ts new file mode 100644 index 0000000..1b62a62 --- /dev/null +++ b/modules/identity/routes/identities/register/handle.ts @@ -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(); +}); diff --git a/modules/identity/routes/identities/register/spec.ts b/modules/identity/routes/identities/register/spec.ts new file mode 100644 index 0000000..b6df314 --- /dev/null +++ b/modules/identity/routes/identities/register/spec.ts @@ -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); diff --git a/modules/identity/routes/identities/resolve/handle.ts b/modules/identity/routes/identities/resolve/handle.ts new file mode 100644 index 0000000..772aa38 --- /dev/null +++ b/modules/identity/routes/identities/resolve/handle.ts @@ -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; +}); diff --git a/modules/identity/routes/identities/resolve/keys.ts b/modules/identity/routes/identities/resolve/keys.ts new file mode 100644 index 0000000..5cc2e99 --- /dev/null +++ b/modules/identity/routes/identities/resolve/keys.ts @@ -0,0 +1,5 @@ +import { importVault } from "@platform/vault"; + +import { config } from "../../../config.ts"; + +export const vault = importVault(config.internal); diff --git a/modules/identity/routes/identities/resolve/spec.ts b/modules/identity/routes/identities/resolve/spec.ts new file mode 100644 index 0000000..e017d28 --- /dev/null +++ b/modules/identity/routes/identities/resolve/spec.ts @@ -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]); diff --git a/api/routes/auth/code.ts b/modules/identity/routes/login/code/handle.ts similarity index 67% rename from api/routes/auth/code.ts rename to modules/identity/routes/login/code/handle.ts index d105534..738863f 100644 --- a/api/routes/auth/code.ts +++ b/modules/identity/routes/login/code/handle.ts @@ -1,13 +1,14 @@ -import { code } from "@platform/spec/auth/routes.ts"; +import { logger } from "@platform/logger"; import cookie from "cookie"; -import { auth, config } from "~libraries/auth/mod.ts"; -import { logger } from "~libraries/logger/mod.ts"; -import { Account } from "~stores/event-store/aggregates/account.ts"; -import { Code } from "~stores/event-store/aggregates/code.ts"; -import { eventStore } from "~stores/event-store/event-store.ts"; +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 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); 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({ type: "code:claimed", session: false, - message: "Invalid Account ID", - expected: code.identity.accountId, - received: accountId, + message: "Invalid Identity ID", + expected: code.identity.id, + received: identityId, }); } - const account = await eventStore.aggregate.getByStream(Account, accountId); + 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.accountId, + expected: code.identity.id, received: undefined, }); } diff --git a/modules/identity/routes/login/code/spec.ts b/modules/identity/routes/login/code/spec.ts new file mode 100644 index 0000000..531b981 --- /dev/null +++ b/modules/identity/routes/login/code/spec.ts @@ -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(), + }); diff --git a/modules/identity/routes/login/email/handle.ts b/modules/identity/routes/login/email/handle.ts new file mode 100644 index 0000000..e27ce78 --- /dev/null +++ b/modules/identity/routes/login/email/handle.ts @@ -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`, + }); +}); diff --git a/modules/identity/routes/login/email/spec.ts b/modules/identity/routes/login/email/spec.ts new file mode 100644 index 0000000..6da65bc --- /dev/null +++ b/modules/identity/routes/login/email/spec.ts @@ -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(), + }), +); diff --git a/api/routes/auth/password.ts b/modules/identity/routes/login/password/handle.ts similarity index 72% rename from api/routes/auth/password.ts rename to modules/identity/routes/login/password/handle.ts index a9ac703..978c264 100644 --- a/api/routes/auth/password.ts +++ b/modules/identity/routes/login/password/handle.ts @@ -1,12 +1,12 @@ +import { logger } from "@platform/logger"; import { BadRequestError } from "@platform/relay"; -import { password as route } from "@platform/spec/auth/routes.ts"; import cookie from "cookie"; -import { config } from "~config"; -import { auth } from "~libraries/auth/mod.ts"; -import { password } from "~libraries/crypto/mod.ts"; -import { logger } from "~libraries/logger/mod.ts"; -import { getPasswordStrategyByAlias } from "~stores/read-store/methods.ts"; +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); diff --git a/modules/identity/routes/login/password/spec.ts b/modules/identity/routes/login/password/spec.ts new file mode 100644 index 0000000..48a42d7 --- /dev/null +++ b/modules/identity/routes/login/password/spec.ts @@ -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(), + }), +); diff --git a/platform/models/value-objects/avatar.ts b/modules/identity/schemas/avatar.ts similarity index 100% rename from platform/models/value-objects/avatar.ts rename to modules/identity/schemas/avatar.ts diff --git a/platform/models/value-objects/contact.ts b/modules/identity/schemas/contact.ts similarity index 100% rename from platform/models/value-objects/contact.ts rename to modules/identity/schemas/contact.ts diff --git a/platform/models/value-objects/email.ts b/modules/identity/schemas/email.ts similarity index 100% rename from platform/models/value-objects/email.ts rename to modules/identity/schemas/email.ts diff --git a/platform/models/value-objects/name.ts b/modules/identity/schemas/name.ts similarity index 100% rename from platform/models/value-objects/name.ts rename to modules/identity/schemas/name.ts diff --git a/platform/spec/account/role.ts b/modules/identity/schemas/role.ts similarity index 100% rename from platform/spec/account/role.ts rename to modules/identity/schemas/role.ts diff --git a/platform/spec/account/strategies.ts b/modules/identity/schemas/strategies.ts similarity index 78% rename from platform/spec/account/strategies.ts rename to modules/identity/schemas/strategies.ts index 8e2829a..3ef6c29 100644 --- a/platform/spec/account/strategies.ts +++ b/modules/identity/schemas/strategies.ts @@ -30,4 +30,8 @@ export const StrategySchema = z.discriminatedUnion("type", [ PasskeyStrategySchema, ]); +export type EmailStrategy = z.infer; +export type PasswordStrategy = z.infer; +export type PasskeyStrategy = z.infer; + export type Strategy = z.infer; diff --git a/modules/identity/server.ts b/modules/identity/server.ts new file mode 100644 index 0000000..6550cb4 --- /dev/null +++ b/modules/identity/server.ts @@ -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 => { + 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 => { + 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; + } + } + }, +}; diff --git a/modules/identity/types.d.ts b/modules/identity/types.d.ts new file mode 100644 index 0000000..da3cc86 --- /dev/null +++ b/modules/identity/types.d.ts @@ -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; + } +} diff --git a/api/libraries/auth/cerbos.ts b/platform/cerbos/client.ts similarity index 100% rename from api/libraries/auth/cerbos.ts rename to platform/cerbos/client.ts diff --git a/cerbos/config.yaml b/platform/cerbos/config.yaml similarity index 100% rename from cerbos/config.yaml rename to platform/cerbos/config.yaml diff --git a/platform/cerbos/package.json b/platform/cerbos/package.json new file mode 100644 index 0000000..e2ac91d --- /dev/null +++ b/platform/cerbos/package.json @@ -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" + } +} \ No newline at end of file diff --git a/platform/cerbos/policies/identity.yaml b/platform/cerbos/policies/identity.yaml new file mode 100644 index 0000000..86f22db --- /dev/null +++ b/platform/cerbos/policies/identity.yaml @@ -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 diff --git a/api/libraries/auth/resources.ts b/platform/cerbos/resources.ts similarity index 89% rename from api/libraries/auth/resources.ts rename to platform/cerbos/resources.ts index 6c2e36c..f2507e9 100644 --- a/api/libraries/auth/resources.ts +++ b/platform/cerbos/resources.ts @@ -2,7 +2,7 @@ import { ResourceRegistry } from "@valkyr/auth"; export const resources = new ResourceRegistry([ { - kind: "account", + kind: "identity", attr: {}, }, ] as const); diff --git a/platform/config/dotenv.ts b/platform/config/dotenv.ts new file mode 100644 index 0000000..833af0f --- /dev/null +++ b/platform/config/dotenv.ts @@ -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); +} diff --git a/platform/config/environment.ts b/platform/config/environment.ts new file mode 100644 index 0000000..5ff6a40 --- /dev/null +++ b/platform/config/environment.ts @@ -0,0 +1,51 @@ +import { load } from "@std/dotenv"; +import { z, type ZodType } from "zod"; + +import { InvalidEnvironmentKeyError } from "./errors.ts"; +import { getServiceEnvironment, type ServiceEnvironment } from "./service.ts"; + +const env = await load(); + +/** + * TODO ... + */ +export function getEnvironmentVariable({ + key, + type, + envFallback, + fallback, +}: { + key: string; + type: TType; + envFallback?: EnvironmentFallback; + fallback?: string; +}): z.infer { + const serviceEnv = getServiceEnvironment(); + 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 type.parse(JSON.parse(toBeUsed)); + } + return type.parse(toBeUsed); + } catch (error) { + throw new InvalidEnvironmentKeyError(key, { + cause: error, + }); + } +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type EnvironmentFallback = Partial> & { + testing?: string; + local?: string; + stg?: string; + demo?: string; + prod?: string; +}; diff --git a/platform/config/errors.ts b/platform/config/errors.ts new file mode 100644 index 0000000..14013e4 --- /dev/null +++ b/platform/config/errors.ts @@ -0,0 +1,22 @@ +import { SERVICE_ENV } from "./service.ts"; + +export class InvalidServiceEnvironmentError extends Error { + readonly code = "INVALID_SERVICE_ENVIRONMENT"; + + constructor(value: string) { + super( + `@platform/config requested invalid service environment, expected '${SERVICE_ENV.join(", ")}' got '${value}'.`, + ); + } +} + +export class InvalidEnvironmentKeyError extends Error { + readonly code = "INVALID_ENVIRONMENT_KEY"; + + constructor( + key: string, + readonly details: unknown, + ) { + super(`@platform/config invalid environment key '${key}' provided.`); + } +} diff --git a/platform/config/package.json b/platform/config/package.json new file mode 100644 index 0000000..0ef6bc6 --- /dev/null +++ b/platform/config/package.json @@ -0,0 +1,10 @@ +{ + "name": "@platform/config", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@std/dotenv": "npm:@jsr/std__dotenv@0.225.5", + "zod": "4.1.11" + } +} \ No newline at end of file diff --git a/platform/config/service.ts b/platform/config/service.ts new file mode 100644 index 0000000..b7bf33d --- /dev/null +++ b/platform/config/service.ts @@ -0,0 +1,19 @@ +import { getDotEnvVariable } from "./dotenv.ts"; + +export const SERVICE_ENV = ["testing", "local", "stg", "demo", "prod"] as const; + +/** + * TODO ... + */ +export function getServiceEnvironment(): ServiceEnvironment { + const value = getDotEnvVariable("SERVICE_ENV"); + if (value === undefined) { + return "local"; + } + if ((SERVICE_ENV as unknown as string[]).includes(value) === false) { + throw new Error(`Config Exception: Invalid env ${value} provided`); + } + return value as ServiceEnvironment; +} + +export type ServiceEnvironment = (typeof SERVICE_ENV)[number]; diff --git a/api/libraries/database/accessor.ts b/platform/database/accessor.ts similarity index 97% rename from api/libraries/database/accessor.ts rename to platform/database/accessor.ts index 77cc614..fe1eb4e 100644 --- a/api/libraries/database/accessor.ts +++ b/platform/database/accessor.ts @@ -14,7 +14,7 @@ export function getDatabaseAccessor>( return instance; }, get client(): MongoClient { - return container.get("client"); + return container.get("mongo"); }, collection( name: TSchema, diff --git a/platform/database/config.ts b/platform/database/config.ts new file mode 100644 index 0000000..8fbc6b2 --- /dev/null +++ b/platform/database/config.ts @@ -0,0 +1,27 @@ +import { getEnvironmentVariable } from "@platform/config/environment.ts"; +import z from "zod"; + +export const config = { + mongo: { + host: getEnvironmentVariable({ + key: "DB_MONGO_HOST", + type: z.string(), + fallback: "localhost", + }), + port: getEnvironmentVariable({ + key: "DB_MONGO_PORT", + type: z.coerce.number(), + fallback: "27017", + }), + user: getEnvironmentVariable({ + key: "DB_MONGO_USER", + type: z.string(), + fallback: "root", + }), + pass: getEnvironmentVariable({ + key: "DB_MONGO_PASSWORD", + type: z.string(), + fallback: "password", + }), + }, +}; diff --git a/api/libraries/database/connection.ts b/platform/database/connection.ts similarity index 100% rename from api/libraries/database/connection.ts rename to platform/database/connection.ts diff --git a/api/libraries/database/container.ts b/platform/database/container.ts similarity index 72% rename from api/libraries/database/container.ts rename to platform/database/container.ts index 9c5d17c..6fa9354 100644 --- a/api/libraries/database/container.ts +++ b/platform/database/container.ts @@ -2,5 +2,5 @@ import { Container } from "@valkyr/inverse"; import { MongoClient } from "mongodb"; export const container = new Container<{ - client: MongoClient; -}>("database"); + mongo: MongoClient; +}>("@platform/database"); diff --git a/api/libraries/database/id.ts b/platform/database/id.ts similarity index 100% rename from api/libraries/database/id.ts rename to platform/database/id.ts diff --git a/platform/database/package.json b/platform/database/package.json new file mode 100644 index 0000000..2aad5e4 --- /dev/null +++ b/platform/database/package.json @@ -0,0 +1,12 @@ +{ + "name": "@platform/database", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@platform/config": "workspace:*", + "@valkyr/inverse": "npm:@jsr/valkyr__inverse@1.0.1", + "mongodb": "6.20.0", + "zod": "4.1.11" + } +} \ No newline at end of file diff --git a/api/libraries/database/registrar.ts b/platform/database/registrar.ts similarity index 100% rename from api/libraries/database/registrar.ts rename to platform/database/registrar.ts diff --git a/platform/database/server.ts b/platform/database/server.ts new file mode 100644 index 0000000..d6cbdb5 --- /dev/null +++ b/platform/database/server.ts @@ -0,0 +1,9 @@ +import { config } from "./config.ts"; +import { getMongoClient } from "./connection.ts"; +import { container } from "./container.ts"; + +export default { + bootstrap: async (): Promise => { + container.set("mongo", getMongoClient(config.mongo)); + }, +}; diff --git a/api/libraries/database/utilities.ts b/platform/database/utilities.ts similarity index 56% rename from api/libraries/database/utilities.ts rename to platform/database/utilities.ts index aa87878..44ab4d9 100644 --- a/api/libraries/database/utilities.ts +++ b/platform/database/utilities.ts @@ -1,5 +1,56 @@ import type { Db } from "mongodb"; -import z, { ZodType } from "zod"; +import z, { type ZodObject, type ZodType } from "zod"; + +/** + * TODO ... + */ +export function takeOne(documents: TDocument[]): TDocument | undefined { + return documents[0]; +} + +/** + * TODO ... + */ +export function makeDocumentParser(schema: TSchema): ModelParserFn { + return ((value: unknown | unknown[]) => { + if (Array.isArray(value)) { + return value.map((value: unknown) => schema.parse(value)); + } + if (value === undefined || value === null) { + return undefined; + } + return schema.parse(value); + }) as ModelParserFn; +} + +/** + * TODO ... + */ +export function toParsedDocuments( + schema: TSchema, +): (documents: unknown[]) => Promise[]> { + return async function (documents: unknown[]) { + const parsed = []; + for (const document of documents) { + parsed.push(await schema.parseAsync(document)); + } + return parsed; + }; +} + +/** + * TODO ... + */ +export function toParsedDocument( + schema: TSchema, +): (document?: unknown) => Promise | undefined> { + return async function (document: unknown) { + if (document === undefined || document === null) { + return undefined; + } + return schema.parseAsync(document); + }; +} /** * Get a Set of collections that exists on a given mongo database instance. @@ -13,25 +64,7 @@ export async function getCollectionsSet(db: Db) { .then((collections) => new Set(collections.map((c) => c.name))); } -export function toParsedDocuments( - schema: TSchema, -): (documents: unknown[]) => Promise[]> { - return async function (documents: unknown[]) { - const parsed = []; - for (const document of documents) { - parsed.push(await schema.parseAsync(document)); - } - return parsed; - }; -} - -export function toParsedDocument( - schema: TSchema, -): (document?: unknown) => Promise | undefined> { - return async function (document: unknown) { - if (document === undefined || document === null) { - return undefined; - } - return schema.parseAsync(document); - }; -} +type ModelParserFn = { + (value: unknown): z.infer | undefined; + (value: unknown[]): z.infer[]; +}; diff --git a/api/libraries/logger/chalk.ts b/platform/logger/chalk.ts similarity index 100% rename from api/libraries/logger/chalk.ts rename to platform/logger/chalk.ts diff --git a/api/libraries/logger/color/hex.ts b/platform/logger/color/hex.ts similarity index 100% rename from api/libraries/logger/color/hex.ts rename to platform/logger/color/hex.ts diff --git a/api/libraries/logger/color/rgb.ts b/platform/logger/color/rgb.ts similarity index 100% rename from api/libraries/logger/color/rgb.ts rename to platform/logger/color/rgb.ts diff --git a/api/libraries/logger/color/styles.ts b/platform/logger/color/styles.ts similarity index 100% rename from api/libraries/logger/color/styles.ts rename to platform/logger/color/styles.ts diff --git a/api/libraries/logger/color/utilities.ts b/platform/logger/color/utilities.ts similarity index 100% rename from api/libraries/logger/color/utilities.ts rename to platform/logger/color/utilities.ts diff --git a/platform/logger/config.ts b/platform/logger/config.ts new file mode 100644 index 0000000..1cf4947 --- /dev/null +++ b/platform/logger/config.ts @@ -0,0 +1,13 @@ +import { getEnvironmentVariable } from "@platform/config/environment.ts"; +import z from "zod"; + +export const config = { + level: getEnvironmentVariable({ + key: "LOG_LEVEL", + type: z.string(), + fallback: "info", + envFallback: { + local: "debug", + }, + }), +}; diff --git a/api/libraries/logger/format/event-store.ts b/platform/logger/format/event-store.ts similarity index 100% rename from api/libraries/logger/format/event-store.ts rename to platform/logger/format/event-store.ts diff --git a/api/libraries/logger/format/server.ts b/platform/logger/format/server.ts similarity index 100% rename from api/libraries/logger/format/server.ts rename to platform/logger/format/server.ts diff --git a/api/libraries/logger/level.ts b/platform/logger/level.ts similarity index 100% rename from api/libraries/logger/level.ts rename to platform/logger/level.ts diff --git a/api/libraries/logger/logger.ts b/platform/logger/logger.ts similarity index 100% rename from api/libraries/logger/logger.ts rename to platform/logger/logger.ts diff --git a/api/libraries/logger/mod.ts b/platform/logger/mod.ts similarity index 100% rename from api/libraries/logger/mod.ts rename to platform/logger/mod.ts diff --git a/platform/logger/package.json b/platform/logger/package.json new file mode 100644 index 0000000..67cc7fe --- /dev/null +++ b/platform/logger/package.json @@ -0,0 +1,15 @@ +{ + "name": "@platform/logger", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./mod.ts", + "exports": { + ".": "./mod.ts" + }, + "dependencies": { + "@platform/config": "workspace:*", + "@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.1", + "zod": "4.1.11" + } +} \ No newline at end of file diff --git a/api/libraries/logger/stack.ts b/platform/logger/stack.ts similarity index 100% rename from api/libraries/logger/stack.ts rename to platform/logger/stack.ts diff --git a/platform/models/account.ts b/platform/models/account.ts deleted file mode 100644 index 1da1964..0000000 --- a/platform/models/account.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { RoleSchema } from "@platform/spec/account/role.ts"; -import { StrategySchema } from "@platform/spec/account/strategies.ts"; -import { z } from "zod"; - -import { makeModelParser } from "./helpers/parser.ts"; -import { AvatarSchema } from "./value-objects/avatar.ts"; -import { ContactSchema } from "./value-objects/contact.ts"; -import { NameSchema } from "./value-objects/name.ts"; - -export const AccountSchema = 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([]), -}); - -export const toAccountDocument = makeModelParser(AccountSchema); -export const fromAccountDocument = makeModelParser(AccountSchema); - -export type Account = z.infer; -export type AccountDocument = z.infer; diff --git a/platform/models/helpers/parser.ts b/platform/models/helpers/parser.ts deleted file mode 100644 index 6a76b68..0000000 --- a/platform/models/helpers/parser.ts +++ /dev/null @@ -1,15 +0,0 @@ -import z, { ZodObject } from "zod"; - -export function makeModelParser(schema: TSchema): ModelParserFn { - return ((value: unknown | unknown[]) => { - if (Array.isArray(value)) { - return value.map((value: unknown) => schema.parse(value)); - } - return schema.parse(value); - }) as ModelParserFn; -} - -type ModelParserFn = { - (value: unknown): z.infer; - (value: unknown[]): z.infer[]; -}; diff --git a/platform/relay/adapters/http.ts b/platform/relay/adapters/http.ts new file mode 100644 index 0000000..d6e5ef2 --- /dev/null +++ b/platform/relay/adapters/http.ts @@ -0,0 +1,296 @@ +import { encrypt } from "@platform/vault"; + +import { + assertServerErrorResponse, + RelayAdapter, + RelayInput, + RelayResponse, + ServerErrorResponse, +} from "../libraries/adapter.ts"; +import { ServerError, ServerErrorType } from "../libraries/errors.ts"; + +/** + * HttpAdapter provides a unified transport layer for Relay. + * + * It supports sending JSON objects, nested structures, arrays, and file uploads + * via FormData. The adapter automatically detects the payload type and formats + * the request accordingly. Responses are normalized into `RelayResponse`. + * + * @example + * ```ts + * const adapter = new HttpAdapter({ url: "https://api.example.com" }); + * + * // Sending JSON data + * const jsonResponse = await adapter.send({ + * method: "POST", + * endpoint: "/users", + * body: { name: "Alice", age: 30 }, + * }); + * + * // Sending files and nested objects + * const formResponse = await adapter.send({ + * method: "POST", + * endpoint: "/upload", + * body: { + * user: { name: "Bob", avatar: fileInput.files[0] }, + * documents: [fileInput.files[1], fileInput.files[2]], + * }, + * }); + * ``` + */ +export class HttpAdapter implements RelayAdapter { + /** + * Instantiate a new HttpAdapter instance. + * + * @param options - Adapter options. + */ + constructor(readonly options: HttpAdapterOptions) {} + + /** + * Override the initial url value set by instantiator. + */ + set url(value: string) { + this.options.url = value; + } + + /** + * Retrieve the URL value from options object. + */ + get url() { + return this.options.url; + } + + /** + * Return the full URL from given endpoint. + * + * @param endpoint - Endpoint to get url for. + */ + getUrl(endpoint: string): string { + return `${this.url}${endpoint}`; + } + + async send( + { method, endpoint, query, body, headers = new Headers() }: RelayInput, + publicKey: string, + ): Promise { + const init: RequestInit = { method, headers }; + + // ### Before Request + // If any before request hooks has been defined, we run them here passing in the + // request headers for further modification. + + await this.#beforeRequest(headers); + + // ### Body + + if (body !== undefined) { + const type = this.#getRequestFormat(body); + if (type === "form-data") { + headers.delete("content-type"); + init.body = this.#getFormData(body); + } + if (type === "json") { + headers.set("content-type", "application/json"); + init.body = JSON.stringify(body); + } + } + + // ### Internal + // If public key is present we create a encrypted token on the header that + // is verified by the server before allowing the request through. + + if (publicKey !== undefined) { + headers.set("x-internal", await encrypt("internal", publicKey)); + } + + // ### Response + + return this.request(`${endpoint}${query}`, init); + } + + /** + * Send a fetch request using the given fetch options and returns + * a relay formatted response. + * + * @param endpoint - Which endpoint to submit request to. + * @param init - Request init details to submit with the request. + */ + async request(endpoint: string, init?: RequestInit): Promise { + return this.#toResponse(await fetch(this.getUrl(endpoint), init)); + } + + /** + * Run before request operations. + * + * @param headers - Headers to pass to hooks. + */ + async #beforeRequest(headers: Headers) { + if (this.options.hooks?.beforeRequest !== undefined) { + for (const hook of this.options.hooks.beforeRequest) { + await hook(headers); + } + } + } + + /** + * Determine the parser method required for the request. + * + * @param body - Request body. + */ + #getRequestFormat(body: unknown): "form-data" | "json" { + if (containsFile(body) === true) { + return "form-data"; + } + return "json"; + } + + /** + * Get FormData instance for the given body. + * + * @param body - Request body. + */ + #getFormData(data: Record, formData = new FormData(), parentKey?: string): FormData { + for (const key in data) { + const value = data[key]; + if (value === undefined || value === null) continue; + + const formKey = parentKey ? `${parentKey}[${key}]` : key; + + if (value instanceof File) { + formData.append(formKey, value, value.name); + } else if (Array.isArray(value)) { + value.forEach((item, index) => { + if (item instanceof File) { + formData.append(`${formKey}[${index}]`, item, item.name); + } else if (typeof item === "object") { + this.#getFormData(item as Record, formData, `${formKey}[${index}]`); + } else { + formData.append(`${formKey}[${index}]`, String(item)); + } + }); + } else if (typeof value === "object") { + this.#getFormData(value as Record, formData, formKey); + } else { + formData.append(formKey, String(value)); + } + } + + return formData; + } + + /** + * Convert a fetch response to a compliant relay response. + * + * @param response - Fetch response to convert. + */ + async #toResponse(response: Response): Promise { + const type = response.headers.get("content-type"); + + // ### Content Type + // Ensure that the server responds with a 'content-type' definition. We should + // always expect the server to respond with a type. + + if (type === null) { + return { + result: "error", + headers: response.headers, + error: { + code: "CONTENT_TYPE_MISSING", + status: response.status, + message: "Missing 'content-type' in header returned from server.", + }, + }; + } + + // ### Empty Response + // If the response comes back with empty response status 204 we simply return a + // empty success. + + if (response.status === 204) { + return { + result: "success", + headers: response.headers, + data: null, + }; + } + + // ### JSON + // If the 'content-type' contains 'json' we treat it as a 'json' compliant response + // and attempt to resolve it as such. + + if (type.includes("json") === true) { + const parsed = await response.json(); + if ("data" in parsed) { + return { + result: "success", + headers: response.headers, + data: parsed.data, + }; + } + if ("error" in parsed) { + return { + result: "error", + headers: response.headers, + error: this.#toError(parsed), + }; + } + return { + result: "error", + headers: response.headers, + error: { + code: "INVALID_SERVER_RESPONSE", + status: response.status, + message: "Unsupported 'json' body returned from server, missing 'data' or 'error' key.", + }, + }; + } + + return { + result: "error", + headers: response.headers, + error: { + code: "UNSUPPORTED_CONTENT_TYPE", + status: response.status, + message: "Unsupported 'content-type' in header returned from server.", + }, + }; + } + + #toError(candidate: unknown, status: number = 500): ServerErrorType | ServerErrorResponse["error"] { + if (assertServerErrorResponse(candidate)) { + return ServerError.fromJSON(candidate.error); + } + if (typeof candidate === "string") { + return { + code: "ERROR", + status, + message: candidate, + }; + } + return { + code: "UNSUPPORTED_SERVER_ERROR", + status, + message: "Unsupported 'error' returned from server.", + }; + } +} + +function containsFile(value: unknown): boolean { + if (value instanceof File) { + return true; + } + if (Array.isArray(value)) { + return value.some(containsFile); + } + if (typeof value === "object" && value !== null) { + return Object.values(value).some(containsFile); + } + return false; +} + +export type HttpAdapterOptions = { + url: string; + hooks?: { + beforeRequest?: ((headers: Headers) => Promise)[]; + }; +}; diff --git a/platform/relay/libraries/adapter.ts b/platform/relay/libraries/adapter.ts index f2736ae..b909243 100644 --- a/platform/relay/libraries/adapter.ts +++ b/platform/relay/libraries/adapter.ts @@ -11,6 +11,7 @@ import type { RouteMethod } from "./route.ts"; const ServerErrorResponseSchema = z.object({ error: z.object({ + code: z.any(), status: z.number(), message: z.string(), data: z.any().optional(), @@ -51,9 +52,10 @@ export type RelayAdapter = { /** * Send a request to the configured relay url. * - * @param input - Request input parameters. + * @param input - Request input parameters. + * @param publicKey - Key to encrypt the payload with. */ - send(input: RelayInput): Promise; + send(input: RelayInput, publicKey?: string): Promise; /** * Sends a fetch request using the given options and returns a @@ -75,10 +77,12 @@ export type RelayInput = { export type RelayResponse = | { result: "success"; + headers: Headers; data: TData; } | { result: "error"; + headers: Headers; error: TError; }; diff --git a/platform/relay/libraries/client.ts b/platform/relay/libraries/client.ts index f78df05..ee96afc 100644 --- a/platform/relay/libraries/client.ts +++ b/platform/relay/libraries/client.ts @@ -110,7 +110,7 @@ function getRouteFn(route: Route, { adapter }: Config) { // ### Fetch - const response = await adapter.send(input); + const response = await adapter.send(input, route.state.crypto?.publicKey); if ("data" in response && route.state.response !== undefined) { response.data = route.state.response.parse(response.data); diff --git a/platform/relay/libraries/context.ts b/platform/relay/libraries/context.ts new file mode 100644 index 0000000..616527a --- /dev/null +++ b/platform/relay/libraries/context.ts @@ -0,0 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ServerContext {} + +export const context: ServerContext = {} as any; diff --git a/platform/relay/libraries/errors.ts b/platform/relay/libraries/errors.ts index f3e7286..dc16946 100644 --- a/platform/relay/libraries/errors.ts +++ b/platform/relay/libraries/errors.ts @@ -1,6 +1,8 @@ -import type { $ZodErrorTree } from "zod/v4/core"; +import { ZodError } from "zod"; export abstract class ServerError extends Error { + abstract readonly code: string; + constructor( message: string, readonly status: number, @@ -12,40 +14,40 @@ export abstract class ServerError extends Error { /** * Converts a server delivered JSON error to its native instance. * - * @param value - Error JSON. + * @param error - Error JSON. */ - static fromJSON(value: ServerErrorJSON): ServerErrorType { - switch (value.status) { - case 400: - return new BadRequestError(value.message, value.data); - case 401: - return new UnauthorizedError(value.message, value.data); - case 403: - return new ForbiddenError(value.message, value.data); - case 404: - return new NotFoundError(value.message, value.data); - case 405: - return new MethodNotAllowedError(value.message, value.data); - case 406: - return new NotAcceptableError(value.message, value.data); - case 409: - return new ConflictError(value.message, value.data); - case 410: - return new GoneError(value.message, value.data); - case 415: - return new UnsupportedMediaTypeError(value.message, value.data); - case 422: - return new UnprocessableContentError(value.message, value.data); - case 432: - return new ZodValidationError(value.message, value.data); - case 500: - return new InternalServerError(value.message, value.data); - case 501: - return new NotImplementedError(value.message, value.data); - case 503: - return new ServiceUnavailableError(value.message, value.data); + static fromJSON(error: ServerErrorJSON): ServerErrorType { + switch (error.code) { + case "BAD_REQUEST": + return new BadRequestError(error.message, error.data); + case "UNAUTHORIZED": + return new UnauthorizedError(error.message, error.data); + case "FORBIDDEN": + return new ForbiddenError(error.message, error.data); + case "NOT_FOUND": + return new NotFoundError(error.message, error.data); + case "METHOD_NOT_ALLOWED": + return new MethodNotAllowedError(error.message, error.data); + case "NOT_ACCEPTABLE": + return new NotAcceptableError(error.message, error.data); + case "CONFLICT": + return new ConflictError(error.message, error.data); + case "GONE": + return new GoneError(error.message, error.data); + case "UNSUPPORTED_MEDIA_TYPE": + return new UnsupportedMediaTypeError(error.message, error.data); + case "UNPROCESSABLE_CONTENT": + return new UnprocessableContentError(error.message, error.data); + case "VALIDATION": + return new ValidationError(error.message, error.data); + case "INTERNAL_SERVER": + return new InternalServerError(error.message, error.data); + case "NOT_IMPLEMENTED": + return new NotImplementedError(error.message, error.data); + case "SERVICE_UNAVAILABLE": + return new ServiceUnavailableError(error.message, error.data); default: - return new InternalServerError(value.message, value.data); + return new InternalServerError(error.message, error.data); } } @@ -54,7 +56,7 @@ export abstract class ServerError extends Error { */ toJSON(): ServerErrorJSON { return { - type: "relay", + code: this.code as ServerErrorJSON["code"], status: this.status, message: this.message, data: this.data, @@ -63,6 +65,8 @@ export abstract class ServerError extends Error { } export class BadRequestError extends ServerError { + readonly code = "BAD_REQUEST"; + /** * Instantiate a new BadRequestError. * @@ -70,6 +74,7 @@ export class BadRequestError extends ServerError { * cannot or will not process the request due to something that is perceived to * be a client error. * + * @param message - the message that describes the error. Default: "Bad Request". * @param data - Optional data to send with the error. */ constructor(message = "Bad Request", data?: TData) { @@ -78,6 +83,8 @@ export class BadRequestError extends ServerError { } export class UnauthorizedError extends ServerError { + readonly code = "UNAUTHORIZED"; + /** * Instantiate a new UnauthorizedError. * @@ -104,6 +111,8 @@ export class UnauthorizedError extends ServerError { } export class ForbiddenError extends ServerError { + readonly code = "FORBIDDEN"; + /** * Instantiate a new ForbiddenError. * @@ -125,6 +134,8 @@ export class ForbiddenError extends ServerError { } export class NotFoundError extends ServerError { + readonly code = "NOT_FOUND"; + /** * Instantiate a new NotFoundError. * @@ -147,6 +158,8 @@ export class NotFoundError extends ServerError { } export class MethodNotAllowedError extends ServerError { + readonly code = "METHOD_NOT_ALLOWED"; + /** * Instantiate a new MethodNotAllowedError. * @@ -164,6 +177,8 @@ export class MethodNotAllowedError extends ServerError { } export class NotAcceptableError extends ServerError { + readonly code = "NOT_ACCEPTABLE"; + /** * Instantiate a new NotAcceptableError. * @@ -181,6 +196,8 @@ export class NotAcceptableError extends ServerError { } export class ConflictError extends ServerError { + readonly code = "CONFLICT"; + /** * Instantiate a new ConflictError. * @@ -202,6 +219,8 @@ export class ConflictError extends ServerError { } export class GoneError extends ServerError { + readonly code = "GONE"; + /** * Instantiate a new GoneError. * @@ -225,6 +244,8 @@ export class GoneError extends ServerError { } export class UnsupportedMediaTypeError extends ServerError { + readonly code = "UNSUPPORTED_MEDIA_TYPE"; + /** * Instantiate a new UnsupportedMediaTypeError. * @@ -242,6 +263,8 @@ export class UnsupportedMediaTypeError extends ServerError extends ServerError { + readonly code = "UNPROCESSABLE_CONTENT"; + /** * Instantiate a new UnprocessableContentError. * @@ -263,22 +286,49 @@ export class UnprocessableContentError extends ServerError> extends ServerError { +export class ValidationError extends ServerError { + readonly code = "VALIDATION"; + /** - * Instantiate a new ZodValidationError. + * Instantiate a new ValidationError. * - * This indicates that the server understood the request body, but the structure - * failed validation against the expected schema. + * This indicates that the server understood the request, but the content + * failed semantic validation against the expected schema. * - * @param message - Optional message to send with the error. Default: "Unprocessable Content". - * @param data - ZodError instance to pass through. + * @param message - Optional message to send with the error. Default: "Validation Failed". + * @param data - Data with validation failure details. */ - constructor(message: string, data: TData) { - super(message, 432, data); + constructor(message = "Validation Failed", data: ValidationErrorData) { + super(message, 422, data); + } + + /** + * Instantiate a new ValidationError. + * + * This indicates that the server understood the request, but the content + * failed semantic validation against the expected schema. + * + * @param zodError - The original ZodError instance. + * @param source - The source of the validation error. + * @param message - Optional override for the main error message. + */ + static fromZod(zodError: ZodError, source: ErrorSource, message?: string) { + return new ValidationError(message, { + details: zodError.issues.map((issue) => { + return { + source: source, + code: issue.code, + field: issue.path.join("."), + message: issue.message, + }; + }), + }); } } export class InternalServerError extends ServerError { + readonly code = "INTERNAL_SERVER"; + /** * Instantiate a new InternalServerError. * @@ -302,6 +352,8 @@ export class InternalServerError extends ServerError { } export class NotImplementedError extends ServerError { + readonly code = "NOT_IMPLEMENTED"; + /** * Instantiate a new NotImplementedError. * @@ -319,6 +371,8 @@ export class NotImplementedError extends ServerError { } export class ServiceUnavailableError extends ServerError { + readonly code = "SERVICE_UNAVAILABLE"; + /** * Instantiate a new ServiceUnavailableError. * @@ -338,15 +392,21 @@ export class ServiceUnavailableError extends ServerError } } -export type ServerErrorClass = typeof ServerError; +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ export type ServerErrorJSON = { - type: "relay"; + code: ServerErrorType["code"]; status: number; message: string; data?: any; }; +export type ServerErrorClass = typeof ServerError; + export type ServerErrorType = | BadRequestError | UnauthorizedError @@ -360,4 +420,18 @@ export type ServerErrorType = | UnprocessableContentError | NotImplementedError | ServiceUnavailableError + | ValidationError | InternalServerError; + +export type ErrorSource = "body" | "query" | "params" | "client"; + +type ValidationErrorData = { + details: ValidationErrorDetail[]; +}; + +type ValidationErrorDetail = { + source: ErrorSource; + code: string; + field: string; + message: string; +}; diff --git a/platform/relay/libraries/procedure.ts b/platform/relay/libraries/procedure.ts index c5097a9..4b7ff33 100644 --- a/platform/relay/libraries/procedure.ts +++ b/platform/relay/libraries/procedure.ts @@ -1,7 +1,8 @@ import z, { ZodType } from "zod"; +import { ServerContext } from "./context.ts"; import { ServerError, ServerErrorClass } from "./errors.ts"; -import { RouteAccess, ServerContext } from "./route.ts"; +import { RouteAccess } from "./route.ts"; export class Procedure { readonly type = "procedure" as const; diff --git a/platform/relay/libraries/route.ts b/platform/relay/libraries/route.ts index 1904ca5..15643ee 100644 --- a/platform/relay/libraries/route.ts +++ b/platform/relay/libraries/route.ts @@ -1,6 +1,7 @@ import { match, type MatchFunction } from "path-to-regexp"; import z, { ZodObject, ZodRawShape, ZodType } from "zod"; +import { ServerContext } from "./context.ts"; import { ServerError, ServerErrorClass } from "./errors.ts"; import { Hooks } from "./hooks.ts"; @@ -84,6 +85,23 @@ export class Route { return new Route({ ...this.state, meta }); } + /** + * Set cryptographic keys used to resolve cryptographic requests. + * + * @param crypto - Crypto configuration object. + * + * @examples + * + * ```ts + * route.post("/foo").crypto({ publicKey: "..." }); + * ``` + */ + crypto( + crypto: TCrypto, + ): Route & { crypto: TCrypto }>> { + return new Route({ ...this.state, crypto }); + } + /** * Access level of the route which acts as the first barrier of entry * to ensure that requests are valid. @@ -431,6 +449,9 @@ export type Routes = { type RouteState = { method: RouteMethod; path: string; + crypto?: { + publicKey: string; + }; meta?: RouteMeta; access?: RouteAccess; params?: ZodObject; @@ -451,10 +472,7 @@ export type RouteMeta = { export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE"; -export type RouteAccess = "public" | "authenticated"; - -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface ServerContext {} +export type RouteAccess = "public" | "session" | ["internal:public", string] | ["internal:session", string]; type HandleFn = any[], TResponse = any> = ( ...args: TArgs diff --git a/platform/relay/mod.ts b/platform/relay/mod.ts index b2c68e6..71a4bbe 100644 --- a/platform/relay/mod.ts +++ b/platform/relay/mod.ts @@ -1,5 +1,7 @@ +export * from "./adapters/http.ts"; export * from "./libraries/adapter.ts"; export * from "./libraries/client.ts"; +export * from "./libraries/context.ts"; export * from "./libraries/errors.ts"; export * from "./libraries/hooks.ts"; export * from "./libraries/procedure.ts"; diff --git a/platform/relay/package.json b/platform/relay/package.json index fc490eb..4607b79 100644 --- a/platform/relay/package.json +++ b/platform/relay/package.json @@ -8,7 +8,11 @@ ".": "./mod.ts" }, "dependencies": { + "@platform/auth": "workspace:*", + "@platform/socket": "workspace:*", + "@platform/vault": "workspace:*", + "@valkyr/auth": "npm:@jsr/valkyr__auth@2.1.4", "path-to-regexp": "8", - "zod": "4" + "zod": "4.1.11" } } \ No newline at end of file diff --git a/api/libraries/server/api.ts b/platform/server/api.ts similarity index 88% rename from api/libraries/server/api.ts rename to platform/server/api.ts index 44e0f64..ec83320 100644 --- a/api/libraries/server/api.ts +++ b/platform/server/api.ts @@ -1,5 +1,7 @@ +import { logger } from "@platform/logger"; import { BadRequestError, + context, ForbiddenError, InternalServerError, NotFoundError, @@ -9,14 +11,9 @@ import { ServerError, type ServerErrorResponse, UnauthorizedError, - ZodValidationError, + ValidationError, } from "@platform/relay"; -import { treeifyError } from "zod"; - -import { logger } from "~libraries/logger/mod.ts"; - -import { getRequestContext } from "./context.ts"; -import { req } from "./request.ts"; +import { decrypt } from "@platform/vault"; const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; @@ -67,6 +64,7 @@ export class Api { this.routes[route.method].push(route); methods.push(route.method); this.#index.set(`${route.method} ${route.path}`, route); + logger.prefix("API").info(`Registered ${route.method} ${route.path}`); } for (const method of methods) { this.routes[method].sort(byStaticPriority); @@ -166,15 +164,28 @@ export class Api { ); } - if (route.state.access === "authenticated" && req.isAuthenticated === false) { + if (route.state.access === "session" && context.isAuthenticated === false) { return toResponse(new UnauthorizedError(), request); } if (Array.isArray(route.state.access)) { - for (const hasAccess of route.state.access) { - if (hasAccess() === false) { - return toResponse(new ForbiddenError(), request); - } + const [access, privateKey] = route.state.access; + const value = request.headers.get("x-internal"); + if (value === null) { + return toResponse( + new ForbiddenError(`Route '${route.method} ${route.path}' is missing 'x-internal' token.`), + request, + ); + } + const decrypted = await decrypt(value, privateKey); + if (decrypted !== "internal") { + return toResponse( + new ForbiddenError(`Route '${route.method} ${route.path}' has invalid 'x-internal' token.`), + request, + ); + } + if (access === "internal:session" && context.isAuthenticated === false) { + return toResponse(new UnauthorizedError(), request); } } @@ -184,7 +195,7 @@ export class Api { if (route.state.params !== undefined) { const result = await route.state.params.safeParseAsync(params); if (result.success === false) { - return toResponse(new ZodValidationError("Invalid request params", treeifyError(result.error)), request); + return toResponse(ValidationError.fromZod(result.error, "params", "Invalid request params"), request); } input.params = result.data; } @@ -195,7 +206,7 @@ export class Api { if (route.state.query !== undefined) { const result = await route.state.query.safeParseAsync(toQuery(url.searchParams) ?? {}); if (result.success === false) { - return toResponse(new ZodValidationError("Invalid request query", treeifyError(result.error)), request); + return toResponse(ValidationError.fromZod(result.error, "query", "Invalid request query"), request); } input.query = result.data; } @@ -207,7 +218,7 @@ export class Api { const body = await this.#getRequestBody(request); const result = await route.state.body.safeParseAsync(body); if (result.success === false) { - return toResponse(new ZodValidationError("Invalid request body", treeifyError(result.error)), request); + return toResponse(ValidationError.fromZod(result.error, "body", "Invalid request body"), request); } input.body = result.data; } @@ -219,7 +230,7 @@ export class Api { // ### Context // Request context pass to every route as the last argument. - args.push(getRequestContext(request)); + args.push(context); // ### Handler // Execute the route handler and apply the result. @@ -364,10 +375,10 @@ export function toResponse(result: unknown, request: Request): Response { } return result; } - if (result instanceof ServerError) { const body = JSON.stringify({ error: { + code: result.code, status: result.status, message: result.message, data: result.data, diff --git a/api/libraries/server/modules.ts b/platform/server/modules.ts similarity index 100% rename from api/libraries/server/modules.ts rename to platform/server/modules.ts diff --git a/platform/server/package.json b/platform/server/package.json new file mode 100644 index 0000000..a34e4ba --- /dev/null +++ b/platform/server/package.json @@ -0,0 +1,16 @@ +{ + "name": "@platform/server", + "version": "0.0.0", + "private": true, + "type": "module", + "types": "types.d.ts", + "dependencies": { + "@platform/auth": "workspace:*", + "@platform/logger": "workspace:*", + "@platform/relay": "workspace:*", + "@platform/socket": "workspace:*", + "@platform/storage": "workspace:*", + "@valkyr/json-rpc": "npm:@jsr/valkyr__json-rpc@1.1.0", + "zod": "4.1.11" + } +} \ No newline at end of file diff --git a/platform/server/server.ts b/platform/server/server.ts new file mode 100644 index 0000000..f9a44e8 --- /dev/null +++ b/platform/server/server.ts @@ -0,0 +1,71 @@ +import "./types.d.ts"; + +import { context } from "@platform/relay"; +import { InternalServerError } from "@platform/relay"; +import { storage } from "@platform/storage"; +import { getStorageContext } from "@platform/storage"; + +export default { + /** + * TODO ... + */ + bootstrap: async (): Promise => { + Object.defineProperties(context, { + /** + * TODO ... + */ + request: { + get() { + const request = storage.getStore()?.request; + if (request === undefined) { + throw new InternalServerError("Storage missing 'request' assignment."); + } + return request; + }, + }, + + /** + * TODO ... + */ + response: { + get() { + const response = storage.getStore()?.response; + if (response === undefined) { + throw new InternalServerError("Storage missing 'response' assignment."); + } + return response; + }, + }, + + /** + * TODO ... + */ + info: { + get() { + const info = storage.getStore()?.info; + if (info === undefined) { + throw new InternalServerError("Storage missing 'info' assignment."); + } + return info; + }, + }, + }); + }, + + /** + * TODO ... + */ + resolve: async (request: Request): Promise => { + const context = getStorageContext(); + context.request = { + headers: request.headers, + }; + context.response = { + headers: new Headers(), + }; + context.info = { + method: request.url, + start: Date.now(), + }; + }, +}; diff --git a/platform/server/socket.ts b/platform/server/socket.ts new file mode 100644 index 0000000..c04d949 --- /dev/null +++ b/platform/server/socket.ts @@ -0,0 +1,50 @@ +import { logger } from "@platform/logger"; +import { context } from "@platform/relay"; +import { storage } from "@platform/storage"; +import { toJsonRpc } from "@valkyr/json-rpc"; + +import type { Api } from "./api.ts"; + +/** + * TODO ... + */ +export function upgradeWebSocket(request: Request, api: Api) { + const { socket, response } = Deno.upgradeWebSocket(request); + + socket.addEventListener("open", () => { + logger.prefix("Socket").info("socket connected", {}); + context.sockets.add(socket); + }); + + socket.addEventListener("close", () => { + logger.prefix("Socket").info("socket disconnected", {}); + context.sockets.del(socket); + }); + + socket.addEventListener("message", (event) => { + if (event.data === "ping") { + return; + } + + const message = toJsonRpc(event.data); + + logger.prefix("Socket").info(message); + + storage.run({}, 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; +} diff --git a/platform/server/types.d.ts b/platform/server/types.d.ts new file mode 100644 index 0000000..218db8d --- /dev/null +++ b/platform/server/types.d.ts @@ -0,0 +1,46 @@ +import "@platform/relay"; +import "@platform/storage"; + +declare module "@platform/storage" { + interface StorageContext { + /** + * TODO ... + */ + request?: { + headers: Headers; + }; + + /** + * TODO ... + */ + response?: { + headers: Headers; + }; + + /** + * TODO ... + */ + info?: { + method: string; + start: number; + end?: number; + }; + } +} + +declare module "@platform/relay" { + interface ServerContext { + isAuthenticated: boolean; + request: { + headers: Headers; + }; + response: { + headers: Headers; + }; + info: { + method: string; + start: number; + end?: number; + }; + } +} diff --git a/api/libraries/socket/channels.ts b/platform/socket/channels.ts similarity index 89% rename from api/libraries/socket/channels.ts rename to platform/socket/channels.ts index 6653dd8..bd6ba61 100644 --- a/api/libraries/socket/channels.ts +++ b/platform/socket/channels.ts @@ -1,9 +1,9 @@ import type { Params } from "@valkyr/json-rpc"; -import { Sockets } from "./sockets.ts"; +import { SocketRegistry } from "./sockets.ts"; export class Channels { - readonly #channels = new Map(); + readonly #channels = new Map(); /** * Add a new channel. @@ -11,7 +11,7 @@ export class Channels { * @param channel */ add(channel: string): this { - this.#channels.set(channel, new Sockets()); + this.#channels.set(channel, new SocketRegistry()); return this; } @@ -35,7 +35,7 @@ export class Channels { join(channel: string, socket: WebSocket): this { const sockets = this.#channels.get(channel); if (sockets === undefined) { - this.#channels.set(channel, new Sockets().add(socket)); + this.#channels.set(channel, new SocketRegistry().add(socket)); } else { sockets.add(socket); } diff --git a/platform/socket/package.json b/platform/socket/package.json new file mode 100644 index 0000000..9dbbc7e --- /dev/null +++ b/platform/socket/package.json @@ -0,0 +1,14 @@ +{ + "name": "@platform/socket", + "version": "0.0.0", + "private": true, + "type": "module", + "types": "types.d.ts", + "dependencies": { + "@platform/auth": "workspace:*", + "@platform/logger": "workspace:*", + "@platform/relay": "workspace:*", + "@platform/storage": "workspace:*", + "@valkyr/json-rpc": "npm:@jsr/valkyr__json-rpc@1.1.0" + } +} \ No newline at end of file diff --git a/platform/socket/server.ts b/platform/socket/server.ts new file mode 100644 index 0000000..14d0e54 --- /dev/null +++ b/platform/socket/server.ts @@ -0,0 +1,39 @@ +import "./types.d.ts"; + +import { InternalServerError } from "@platform/relay"; +import { context } from "@platform/relay"; +import { getStorageContext, storage } from "@platform/storage"; + +import { SocketRegistry } from "./sockets.ts"; + +export const sockets = new SocketRegistry(); + +export default { + /** + * TODO ... + */ + bootstrap: async (): Promise => { + Object.defineProperties(context, { + /** + * TODO ... + */ + sockets: { + get() { + const sockets = storage.getStore()?.sockets; + if (sockets === undefined) { + throw new InternalServerError("Sockets not defined."); + } + return sockets; + }, + }, + }); + }, + + /** + * TODO ... + */ + resolve: async (): Promise => { + const context = getStorageContext(); + context.sockets = sockets; + }, +}; diff --git a/api/libraries/socket/sockets.ts b/platform/socket/sockets.ts similarity index 94% rename from api/libraries/socket/sockets.ts rename to platform/socket/sockets.ts index fc981e9..f355ad0 100644 --- a/api/libraries/socket/sockets.ts +++ b/platform/socket/sockets.ts @@ -1,6 +1,6 @@ import type { Params } from "@valkyr/json-rpc"; -export class Sockets { +export class SocketRegistry { readonly #sockets = new Set(); /** @@ -45,5 +45,3 @@ export class Sockets { return this; } } - -export const sockets = new Sockets(); diff --git a/platform/socket/types.d.ts b/platform/socket/types.d.ts new file mode 100644 index 0000000..d00509f --- /dev/null +++ b/platform/socket/types.d.ts @@ -0,0 +1,22 @@ +import "@platform/relay"; +import "@platform/storage"; + +import { SocketRegistry } from "./sockets.ts"; + +declare module "@platform/storage" { + interface StorageContext { + /** + * TODO ... + */ + sockets?: SocketRegistry; + } +} + +declare module "@platform/relay" { + export interface ServerContext { + /** + * TODO ... + */ + sockets: SocketRegistry; + } +} diff --git a/platform/spec/account/errors.ts b/platform/spec/account/errors.ts deleted file mode 100644 index 50fc6c2..0000000 --- a/platform/spec/account/errors.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ConflictError } from "@platform/relay"; - -export class AccountEmailClaimedError extends ConflictError { - constructor(email: string) { - super(`Email '${email}' is already claimed by another account.`); - } -} diff --git a/platform/spec/account/routes.ts b/platform/spec/account/routes.ts deleted file mode 100644 index 5443d56..0000000 --- a/platform/spec/account/routes.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { AccountSchema } from "@platform/models/account.ts"; -import { NameSchema } from "@platform/models/value-objects/name.ts"; -import { ForbiddenError, NotFoundError, route, UnauthorizedError } from "@platform/relay"; -import z from "zod"; - -import { AccountEmailClaimedError } from "./errors.ts"; - -export const create = route - .post("/api/v1/accounts") - .body( - z.object({ - name: NameSchema, - email: z.email(), - }), - ) - .errors([AccountEmailClaimedError]) - .response(z.uuid()); - -export const getById = route - .get("/api/v1/accounts/:id") - .params({ - id: z.string(), - }) - .errors([UnauthorizedError, ForbiddenError, NotFoundError]) - .response(AccountSchema); - -export const routes = { - create, - getById, -}; diff --git a/platform/spec/audit/actor.ts b/platform/spec/audit/actor.ts new file mode 100644 index 0000000..a72d94e --- /dev/null +++ b/platform/spec/audit/actor.ts @@ -0,0 +1,17 @@ +import z from "zod"; + +import { AuditUserSchema, AuditUserType } from "./user.ts"; + +export const AuditActorSchema = z.object({ + user: AuditUserSchema, +}); + +export const auditors = { + system: AuditActorSchema.parse({ + user: { + typeId: AuditUserType.System, + }, + }), +}; + +export type AuditActor = z.infer; diff --git a/platform/spec/audit/user.ts b/platform/spec/audit/user.ts new file mode 100644 index 0000000..77c6b4b --- /dev/null +++ b/platform/spec/audit/user.ts @@ -0,0 +1,17 @@ +import z from "zod"; + +export enum AuditUserType { + Unknown = 0, + Identity = 1, + System = 2, + Service = 3, + Other = 99, +} + +export const AuditUserSchema = z.object({ + typeId: z.enum(AuditUserType).describe("The account type identifier."), + uid: z + .string() + .optional() + .describe("The unique user identifier. For example, the Windows user SID, ActiveDirectory DN or AWS user ARN."), +}); diff --git a/platform/spec/auth/routes.ts b/platform/spec/auth/routes.ts deleted file mode 100644 index 4943e82..0000000 --- a/platform/spec/auth/routes.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { AccountSchema } from "@platform/models/account.ts"; -import { route, UnauthorizedError } from "@platform/relay"; -import z from "zod"; - -export * from "./errors.ts"; -export * from "./strategies.ts"; - -export const email = route.post("/api/v1/auth/email").body( - z.object({ - base: z.url(), - email: z.email(), - }), -); - -export const password = route.post("/api/v1/auth/password").body( - z.object({ - alias: z.string(), - password: z.string(), - }), -); - -export const code = route - .get("/api/v1/auth/code/:accountId/code/:codeId/:value") - .params({ - accountId: z.string(), - codeId: z.string(), - value: z.string(), - }) - .query({ - next: z.string().optional(), - }); - -export const session = route.get("/api/v1/auth/session").response(AccountSchema).errors([UnauthorizedError]); - -export const routes = { - email, - password, - code, - session, -}; diff --git a/platform/spec/package.json b/platform/spec/package.json index 8fc2ac4..68715f7 100644 --- a/platform/spec/package.json +++ b/platform/spec/package.json @@ -6,6 +6,6 @@ "dependencies": { "@platform/models": "workspace:*", "@platform/relay": "workspace:*", - "zod": "4" + "zod": "4.1.11" } } \ No newline at end of file diff --git a/platform/storage/package.json b/platform/storage/package.json new file mode 100644 index 0000000..4cb2b59 --- /dev/null +++ b/platform/storage/package.json @@ -0,0 +1,13 @@ +{ + "name": "@platform/storage", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./storage.ts", + "exports": { + ".": "./storage.ts" + }, + "dependencies": { + "@platform/relay": "workspace:*" + } +} \ No newline at end of file diff --git a/platform/storage/storage.ts b/platform/storage/storage.ts new file mode 100644 index 0000000..401d142 --- /dev/null +++ b/platform/storage/storage.ts @@ -0,0 +1,21 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import { InternalServerError } from "@platform/relay"; + +export const storage = new AsyncLocalStorage(); + +/** + * TODO ... + */ +export function getStorageContext(): StorageContext { + const store = storage.getStore(); + if (store === undefined) { + throw new InternalServerError( + "Storage 'store' missing, make sure to resolve within a 'node:async_hooks' wrapped context.", + ); + } + return store; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface StorageContext {} diff --git a/platform/vault/hmac.ts b/platform/vault/hmac.ts new file mode 100644 index 0000000..a94009a --- /dev/null +++ b/platform/vault/hmac.ts @@ -0,0 +1,51 @@ +/** + * Hash a value with given secret. + * + * @param value - Value to hash. + * @param secret - Secret to hash the value against. + */ +export async function hash(value: string, secret: string): Promise { + const key = await getImportKey(secret, ["sign"]); + const encoder = new TextEncoder(); + const valueData = encoder.encode(value); + const signature = await crypto.subtle.sign("HMAC", key, valueData); + return bufferToHex(signature); +} + +/** + * Verify that the given value results in the expected hash using the provided secret. + * + * @param value - Value to verify. + * @param expectedHash - Expected hash value. + * @param secret - Secret used to hash the value. + */ +export async function verify(value: string, expectedHash: string, secret: string): Promise { + const key = await getImportKey(secret, ["verify"]); + const encoder = new TextEncoder(); + const valueData = encoder.encode(value); + const signature = hexToBuffer(expectedHash); + return crypto.subtle.verify("HMAC", key, signature, valueData); +} + +/* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + +async function getImportKey(secret: string, usages: KeyUsage[]): Promise { + const encoder = new TextEncoder(); + const keyData = encoder.encode(secret); + return crypto.subtle.importKey("raw", keyData, { name: "HMAC", hash: { name: "SHA-256" } }, false, usages); +} + +function bufferToHex(buffer: ArrayBuffer): string { + return Array.from(new Uint8Array(buffer)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +function hexToBuffer(hex: string): ArrayBuffer { + const bytes = new Uint8Array(hex.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16))); + return bytes.buffer; +} diff --git a/platform/vault/key-pair.ts b/platform/vault/key-pair.ts new file mode 100644 index 0000000..7c42368 --- /dev/null +++ b/platform/vault/key-pair.ts @@ -0,0 +1,134 @@ +import * as Jose from "jose"; + +export class KeyPair { + readonly #public: PublicKey; + readonly #private: PrivateKey; + readonly #algorithm: string; + + constructor({ publicKey, privateKey }: Jose.GenerateKeyPairResult, algorithm: string) { + this.#public = new PublicKey(publicKey); + this.#private = new PrivateKey(privateKey); + this.#algorithm = algorithm; + } + + get public() { + return this.#public; + } + + get private() { + return this.#private; + } + + get algorithm() { + return this.#algorithm; + } + + async toJSON() { + return { + publicKey: await this.public.toString(), + privateKey: await this.private.toString(), + }; + } +} + +export class PublicKey { + readonly #key: Jose.CryptoKey; + + constructor(key: Jose.CryptoKey) { + this.#key = key; + } + + get key(): Jose.CryptoKey { + return this.#key; + } + + async toString() { + return Jose.exportSPKI(this.#key); + } +} + +export class PrivateKey { + readonly #key: Jose.CryptoKey; + + constructor(key: Jose.CryptoKey) { + this.#key = key; + } + + get key(): Jose.CryptoKey { + return this.#key; + } + + async toString() { + return Jose.exportPKCS8(this.#key); + } +} + +/* + |-------------------------------------------------------------------------------- + | Factories + |-------------------------------------------------------------------------------- + */ + +/** + * Create a new key pair using the provided algorithm. + * + * @param algorithm - Algorithm to use for key generation. + * + * @returns new key pair instance + */ +export async function createKeyPair(algorithm: string): Promise { + return new KeyPair(await Jose.generateKeyPair(algorithm, { extractable: true }), algorithm); +} + +/** + * Loads a keypair from a previously exported keypair into a new KeyPair instance. + * + * @param keyPair - KeyPair to load into a new keyPair instance. + * @param algorithm - Algorithm to use for key generation. + * + * @returns new key pair instance + */ +export async function loadKeyPair({ publicKey, privateKey }: ExportedKeyPair, algorithm: string): Promise { + return new KeyPair( + { + publicKey: await importPublicKey(publicKey, algorithm), + privateKey: await importPrivateKey(privateKey, algorithm), + }, + algorithm, + ); +} + +/** + * Get a new Jose.KeyLike instance from a public key string. + * + * @param publicKey - Public key string. + * @param algorithm - Algorithm to used for key generation. + * + * @returns new Jose.KeyLike instance + */ +export async function importPublicKey(publicKey: string, algorithm: string): Promise { + return Jose.importSPKI(publicKey, algorithm, { extractable: true }); +} + +/** + * get a new Jose.KeyLike instance from a private key string. + * + * @param privateKey - Private key string. + * @param algorithm - Algorithm to used for key generation. + * + * @returns new Jose.KeyLike instance + */ +export async function importPrivateKey(privateKey: string, algorithm: string): Promise { + return Jose.importPKCS8(privateKey, algorithm, { extractable: true }); +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type ExportedKeyPair = { + publicKey: string; + privateKey: string; +}; diff --git a/platform/models/package.json b/platform/vault/package.json similarity index 51% rename from platform/models/package.json rename to platform/vault/package.json index 96904ce..223ab96 100644 --- a/platform/models/package.json +++ b/platform/vault/package.json @@ -1,10 +1,10 @@ { - "name": "@platform/models", + "name": "@platform/vault", "version": "0.0.0", "private": true, "type": "module", "dependencies": { - "@platform/spec": "workspace:*", - "zod": "4" + "jose": "6.1.0", + "nanoid": "5.1.5" } } \ No newline at end of file diff --git a/platform/vault/vault.ts b/platform/vault/vault.ts new file mode 100644 index 0000000..ed17f00 --- /dev/null +++ b/platform/vault/vault.ts @@ -0,0 +1,84 @@ +import * as Jose from "jose"; + +import { createKeyPair, ExportedKeyPair, importPrivateKey, importPublicKey, KeyPair, loadKeyPair } from "./key-pair.ts"; + +/* + |-------------------------------------------------------------------------------- + | Security Settings + |-------------------------------------------------------------------------------- + */ + +const VAULT_ALGORITHM = "ECDH-ES+A256KW"; +const VAULT_ENCRYPTION = "A256GCM"; + +/* + |-------------------------------------------------------------------------------- + | Vault + |-------------------------------------------------------------------------------- + */ + +export class Vault { + #keyPair: KeyPair; + + constructor(keyPair: KeyPair) { + this.#keyPair = keyPair; + } + + get keys() { + return this.#keyPair; + } + + /** + * Enecrypt the given value with the vaults key pair. + * + * @param value - Value to encrypt. + */ + async encrypt | unknown[] | string>(value: T): Promise { + const text = new TextEncoder().encode(JSON.stringify(value)); + return new Jose.CompactEncrypt(text) + .setProtectedHeader({ + alg: VAULT_ALGORITHM, + enc: VAULT_ENCRYPTION, + }) + .encrypt(this.#keyPair.public.key); + } + + /** + * Decrypts the given cypher text with the vaults key pair. + * + * @param cypherText - String to decrypt. + */ + async decrypt(cypherText: string): Promise { + const { plaintext } = await Jose.compactDecrypt(cypherText, this.#keyPair.private.key); + return JSON.parse(new TextDecoder().decode(plaintext)); + } +} + +/* + |-------------------------------------------------------------------------------- + | Factories + |-------------------------------------------------------------------------------- + */ + +export async function createVault(): Promise { + return new Vault(await createKeyPair(VAULT_ALGORITHM)); +} + +export async function importVault(keyPair: ExportedKeyPair): Promise { + return new Vault(await loadKeyPair(keyPair, VAULT_ALGORITHM)); +} + +export async function encrypt | unknown[] | string>(value: T, publicKey: string) { + const text = new TextEncoder().encode(JSON.stringify(value)); + return new Jose.CompactEncrypt(text) + .setProtectedHeader({ + alg: VAULT_ALGORITHM, + enc: VAULT_ENCRYPTION, + }) + .encrypt(await importPublicKey(publicKey, VAULT_ALGORITHM)); +} + +export async function decrypt(cypherText: string, privateKey: string): Promise { + const { plaintext } = await Jose.compactDecrypt(cypherText, await importPrivateKey(privateKey, VAULT_ALGORITHM)); + return JSON.parse(new TextDecoder().decode(plaintext)); +}