Template
1
0

feat: modular domain driven boilerplate

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

View File

@@ -0,0 +1,8 @@
import { HTTP } from "@cerbos/http";
export const cerbos = new HTTP("http://localhost:3592", {
adminCredentials: {
username: "cerbos",
password: "cerbosAdmin",
},
});

View File

@@ -0,0 +1,14 @@
server:
adminAPI:
enabled: true
adminCredentials:
username: cerbos
passwordHash: JDJ5JDEwJDc5VzBkQ0NUWHFTT3N1OW9xZkx5ZC43M0tuM0JBSTU0dVRsMVBkOEtuYVBCaWFzVXk5d0phCgo=
httpListenAddr: ":3592"
grpcListenAddr: ":3593"
storage:
driver: disk
disk:
directory: /data/policies
watchForChanges: true

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
import { ResourceRegistry } from "@valkyr/auth";
export const resources = new ResourceRegistry([
{
kind: "identity",
attr: {},
},
] as const);
export type Resource = typeof resources.$resource;

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

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

View File

@@ -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<TType extends ZodType>({
key,
type,
envFallback,
fallback,
}: {
key: string;
type: TType;
envFallback?: EnvironmentFallback;
fallback?: string;
}): z.infer<TType> {
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<Record<ServiceEnvironment, string>> & {
testing?: string;
local?: string;
stg?: string;
demo?: string;
prod?: string;
};

22
platform/config/errors.ts Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
import { Collection, type CollectionOptions, type Db, type Document, type MongoClient } from "mongodb";
import { container } from "./container.ts";
export function getDatabaseAccessor<TSchemas extends Record<string, Document>>(
database: string,
): DatabaseAccessor<TSchemas> {
let instance: Db | undefined;
return {
get db(): Db {
if (instance === undefined) {
instance = this.client.db(database);
}
return instance;
},
get client(): MongoClient {
return container.get("mongo");
},
collection<TSchema extends keyof TSchemas>(
name: TSchema,
options?: CollectionOptions,
): Collection<TSchemas[TSchema]> {
return this.db.collection<TSchemas[TSchema]>(name.toString(), options);
},
};
}
export type DatabaseAccessor<TSchemas extends Record<string, Document>> = {
/**
* Database for given accessor.
*/
db: Db;
/**
* Lazy loaded mongo client.
*/
client: MongoClient;
/**
* Returns a reference to a MongoDB Collection. If it does not exist it will be created implicitly.
*
* Collection namespace validation is performed server-side.
*
* @param name - Collection name we wish to access.
* @param options - Optional settings for the command.
*/
collection<TSchema extends keyof TSchemas>(name: TSchema, options?: CollectionOptions): Collection<TSchemas[TSchema]>;
};

View File

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

View File

@@ -0,0 +1,24 @@
import { MongoClient } from "mongodb";
export function getMongoClient(config: MongoConnectionInfo) {
return new MongoClient(getConnectionUrl(config));
}
export function getConnectionUrl({ host, port, user, pass }: MongoConnectionInfo): MongoConnectionUrl {
return `mongodb://${user}:${pass}@${host}:${port}`;
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
export type MongoConnectionUrl = `mongodb://${string}:${string}@${string}:${number}`;
export type MongoConnectionInfo = {
host: string;
port: number;
user: string;
pass: string;
};

View File

@@ -0,0 +1,6 @@
import { Container } from "@valkyr/inverse";
import { MongoClient } from "mongodb";
export const container = new Container<{
mongo: MongoClient;
}>("@platform/database");

3
platform/database/id.ts Normal file
View File

@@ -0,0 +1,3 @@
import type { CreateIndexesOptions, IndexSpecification } from "mongodb";
export const idIndex: [IndexSpecification, CreateIndexesOptions] = [{ id: 1 }, { unique: true }];

View File

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

View File

@@ -0,0 +1,30 @@
import type { CreateIndexesOptions, Db, IndexSpecification } from "mongodb";
import { getCollectionsSet } from "./utilities.ts";
/**
* Takes a mongo database and registers the event store collections and
* indexes defined internally.
*
* @param db - Mongo database to register event store collections against.
* @param registrars - List of registrars to register with the database.
* @param logger - Logger method to print internal logs.
*/
export async function register(db: Db, registrars: Registrar[], logger?: (...args: any[]) => any) {
const list = await getCollectionsSet(db);
for (const { name, indexes } of registrars) {
if (list.has(name) === false) {
await db.createCollection(name);
}
for (const [indexSpec, options] of indexes) {
await db.collection(name).createIndex(indexSpec, options);
logger?.("Mongo Event Store > Collection '%s' is indexed [%O] with options %O", name, indexSpec, options ?? {});
}
logger?.("Mongo Event Store > Collection '%s' is registered", name);
}
}
export type Registrar = {
name: string;
indexes: [IndexSpecification, CreateIndexesOptions?][];
};

View File

@@ -0,0 +1,9 @@
import { config } from "./config.ts";
import { getMongoClient } from "./connection.ts";
import { container } from "./container.ts";
export default {
bootstrap: async (): Promise<void> => {
container.set("mongo", getMongoClient(config.mongo));
},
};

View File

@@ -0,0 +1,70 @@
import type { Db } from "mongodb";
import z, { type ZodObject, type ZodType } from "zod";
/**
* TODO ...
*/
export function takeOne<TDocument>(documents: TDocument[]): TDocument | undefined {
return documents[0];
}
/**
* TODO ...
*/
export function makeDocumentParser<TSchema extends ZodObject>(schema: TSchema): ModelParserFn<TSchema> {
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<TSchema>;
}
/**
* TODO ...
*/
export function toParsedDocuments<TSchema extends ZodType>(
schema: TSchema,
): (documents: unknown[]) => Promise<z.infer<TSchema>[]> {
return async function (documents: unknown[]) {
const parsed = [];
for (const document of documents) {
parsed.push(await schema.parseAsync(document));
}
return parsed;
};
}
/**
* TODO ...
*/
export function toParsedDocument<TSchema extends ZodType>(
schema: TSchema,
): (document?: unknown) => Promise<z.infer<TSchema> | 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.
*
* @param db - Mongo database to fetch collection list for.
*/
export async function getCollectionsSet(db: Db) {
return db
.listCollections()
.toArray()
.then((collections) => new Set(collections.map((c) => c.name)));
}
type ModelParserFn<TSchema extends ZodObject> = {
(value: unknown): z.infer<TSchema> | undefined;
(value: unknown[]): z.infer<TSchema>[];
};

48
platform/logger/chalk.ts Normal file
View File

@@ -0,0 +1,48 @@
import { HexValue } from "./color/hex.ts";
import { type BGColor, type Color, hexToBgColor, hexToColor, type Modifier, styles } from "./color/styles.ts";
export const chalk = {
color(hex: HexValue): (value: string) => string {
const color = hexToColor(hex);
return (value: string) => `${color}${value}${styles.modifier.reset}`;
},
bgColor(hex: HexValue): (value: string) => string {
const color = hexToBgColor(hex);
return (value: string) => `${color}${value}${styles.modifier.reset}`;
},
} as Chalk;
for (const key in styles.modifier) {
chalk[key as Modifier] = function (value: string) {
return toModifiedValue(key as Modifier, value);
};
}
for (const key in styles.color) {
chalk[key as Color] = function (value: string) {
return toColorValue(key as Color, value);
};
}
for (const key in styles.bgColor) {
chalk[key as BGColor] = function (value: string) {
return toBGColorValue(key as BGColor, value);
};
}
function toModifiedValue(key: Modifier, value: string): string {
return `${styles.modifier[key]}${value}${styles.modifier.reset}`;
}
function toColorValue(key: Color, value: string): string {
return `${styles.color[key]}${value}${styles.modifier.reset}`;
}
function toBGColorValue(key: BGColor, value: string): string {
return `${styles.bgColor[key]}${value}${styles.modifier.reset}`;
}
type Chalk = Record<Modifier | Color | BGColor, (value: string) => string> & {
color(hex: HexValue): (value: string) => string;
bgColor(hex: HexValue): (value: string) => string;
};

View File

@@ -0,0 +1,28 @@
import { rgbToAnsi256 } from "./rgb.ts";
/**
* Convert provided hex value to closest 256-Color value.
*
* @param hex - Hex to convert.
*/
export function hexToAnsi256(hex: HexValue) {
const { r, g, b } = hexToRGB(hex);
return rgbToAnsi256(r, g, b);
}
/**
* Take a hex value and return its RGB values.
*
* @param hex - Hex to convert to RGB
* @returns
*/
export function hexToRGB(hex: HexValue): { r: number; g: number; b: number } {
return {
r: parseInt(hex.slice(1, 3), 16),
g: parseInt(hex.slice(3, 5), 16),
b: parseInt(hex.slice(5, 7), 16),
};
}
export type HexValue =
`#${string | number}${string | number}${string | number}${string | number}${string | number}${string | number}`;

View File

@@ -0,0 +1,24 @@
/**
* Convert RGB to the nearest 256-color ANSI value
*
* @param r - Red value.
* @param g - Green value.
* @param b - Blue value.
*/
export function rgbToAnsi256(r: number, g: number, b: number): number {
if (r === g && g === b) {
if (r < 8) return 16;
if (r > 248) return 231;
return Math.round(((r - 8) / 247) * 24) + 232;
}
// Map RGB to 6×6×6 color cube (16231)
const conv = (val: number) => Math.round(val / 51);
const ri = conv(r);
const gi = conv(g);
const bi = conv(b);
return 16 + 36 * ri + 6 * gi + bi;
}
export type RGB = { r: number; g: number; b: number };

View File

@@ -0,0 +1,76 @@
import { hexToAnsi256, HexValue } from "./hex.ts";
import { toEscapeSequence } from "./utilities.ts";
export const styles = {
modifier: {
reset: toEscapeSequence(0), // Reset to normal
bold: toEscapeSequence(1), // Bold text
dim: toEscapeSequence(2), // Dim text
italic: toEscapeSequence(3), // Italic text
underline: toEscapeSequence(4), // Underlined text
overline: toEscapeSequence(53), // Overline text
inverse: toEscapeSequence(7), // Inverse
hidden: toEscapeSequence(8), // Hidden text
strikethrough: toEscapeSequence(9), // Strikethrough
},
color: {
black: toEscapeSequence(30), // Black color
red: toEscapeSequence(31), // Red color
green: toEscapeSequence(32), // Green color
yellow: toEscapeSequence(33), // Yellow color
blue: toEscapeSequence(34), // Blue color
magenta: toEscapeSequence(35), // Magenta color
cyan: toEscapeSequence(36), // Cyan color
white: toEscapeSequence(37), // White color
orange: hexToColor("#FFA500"),
// Bright colors
blackBright: toEscapeSequence(90),
gray: toEscapeSequence(90), // Alias for blackBright
grey: toEscapeSequence(90), // Alias for blackBright
redBright: toEscapeSequence(91),
greenBright: toEscapeSequence(92),
yellowBright: toEscapeSequence(93),
blueBright: toEscapeSequence(94),
magentaBright: toEscapeSequence(95),
cyanBright: toEscapeSequence(96),
whiteBright: toEscapeSequence(97),
},
bgColor: {
bgBlack: toEscapeSequence(40),
bgRed: toEscapeSequence(41),
bgGreen: toEscapeSequence(42),
bgYellow: toEscapeSequence(43),
bgBlue: toEscapeSequence(44),
bgMagenta: toEscapeSequence(45),
bgCyan: toEscapeSequence(46),
bgWhite: toEscapeSequence(47),
bgOrange: hexToBgColor("#FFA500"),
// Bright background colors
bgBlackBright: toEscapeSequence(100),
bgGray: toEscapeSequence(100), // Alias for bgBlackBright
bgGrey: toEscapeSequence(100), // Alias for bgBlackBright
bgRedBright: toEscapeSequence(101),
bgGreenBright: toEscapeSequence(102),
bgYellowBright: toEscapeSequence(103),
bgBlueBright: toEscapeSequence(104),
bgMagentaBright: toEscapeSequence(105),
bgCyanBright: toEscapeSequence(106),
bgWhiteBright: toEscapeSequence(107),
},
};
export function hexToColor(hex: HexValue): string {
return toEscapeSequence(`38;5;${hexToAnsi256(hex)}`); // Foreground color
}
export function hexToBgColor(hex: HexValue): string {
return toEscapeSequence(`48;5;${hexToAnsi256(hex)}`); // Background color
}
export type Modifier = keyof typeof styles.modifier;
export type Color = keyof typeof styles.color;
export type BGColor = keyof typeof styles.bgColor;

View File

@@ -0,0 +1,3 @@
export function toEscapeSequence(value: string | number): `\x1b[${string}m` {
return `\x1b[${value}m`;
}

13
platform/logger/config.ts Normal file
View File

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

View File

@@ -0,0 +1,19 @@
import { EventValidationError } from "@valkyr/event-store";
import type { Level } from "../level.ts";
import { getTracedAt } from "../stack.ts";
export function toEventStoreLog(arg: any, level: Level): any {
if (arg instanceof EventValidationError) {
const obj: any = {
origin: "EventStore",
message: arg.message,
at: getTracedAt(arg.stack, "/api/domains"),
data: arg.errors,
};
if (level === "debug") {
obj.stack = arg.stack;
}
return obj;
}
}

View File

@@ -0,0 +1,18 @@
import { ServerError } from "@platform/relay";
import type { Level } from "../level.ts";
import { getTracedAt } from "../stack.ts";
export function toServerLog(arg: any, level: Level): any {
if (arg instanceof ServerError) {
const obj: any = {
message: arg.message,
data: arg.data,
at: getTracedAt(arg.stack, "/api/domains"),
};
if (level === "debug") {
obj.stack = arg.stack;
}
return obj;
}
}

8
platform/logger/level.ts Normal file
View File

@@ -0,0 +1,8 @@
export const logLevel = {
debug: 0,
info: 1,
warning: 2,
error: 3,
};
export type Level = "debug" | "error" | "warning" | "info";

95
platform/logger/logger.ts Normal file
View File

@@ -0,0 +1,95 @@
import { chalk } from "./chalk.ts";
import { type Level, logLevel } from "./level.ts";
export class Logger {
#level: Level = "info";
#config: Config;
constructor(config: Config) {
this.#config = config;
}
get #prefix(): [string?] {
if (this.#config.prefix !== undefined) {
return [chalk.bold(chalk.green(this.#config.prefix))];
}
return [];
}
/**
* Set the highest logging level in the order of debug, info, warn, error.
*
* When value is 'info', info, warn and error will be logged and debug
* will be ignored.
*
* @param value Highest log level.
*/
level(value: Level): this {
this.#level = value;
return this;
}
/**
* Returns a new logger instance with the given name as prefix.
*
* @param name - Prefix name.
*/
prefix(name: string): Logger {
return new Logger({ prefix: name, loggers: this.#config.loggers }).level(this.#level);
}
/**
* Emit a debug message to terminal.
*/
debug(...args: any[]) {
if (this.#isLevelEnabled(0)) {
console.log(new Date(), chalk.bold("Debug"), ...this.#prefix, ...args.map(this.#toFormattedArg));
}
}
/**
* Emit a info message to terminal.
*/
info(...args: any[]) {
if (this.#isLevelEnabled(1)) {
console.log(new Date(), chalk.bold(chalk.blue("Info")), ...this.#prefix, ...args.map(this.#toFormattedArg));
}
}
/**
* Emit a warning message to terminal.
*/
warn(...args: any[]) {
if (this.#isLevelEnabled(2)) {
console.log(new Date(), chalk.bold(chalk.orange("Warning")), ...this.#prefix, ...args.map(this.#toFormattedArg));
}
}
/**
* Emit a errpr message to terminal.
*/
error(...args: any[]) {
if (this.#isLevelEnabled(3)) {
console.log(new Date(), chalk.bold(chalk.red("Error")), ...this.#prefix, ...args.map(this.#toFormattedArg));
}
}
#isLevelEnabled(level: 0 | 1 | 2 | 3): boolean {
return level >= logLevel[this.#level];
}
#toFormattedArg = (arg: any): string => {
for (const logger of this.#config.loggers) {
const res = logger(arg, this.#level);
if (res !== undefined) {
return res;
}
}
return arg;
};
}
type Config = {
prefix?: string;
loggers: ((arg: any, level: Level) => any)[];
};

7
platform/logger/mod.ts Normal file
View File

@@ -0,0 +1,7 @@
import { toEventStoreLog } from "./format/event-store.ts";
import { toServerLog } from "./format/server.ts";
import { Logger } from "./logger.ts";
export const logger = new Logger({
loggers: [toServerLog, toEventStoreLog],
});

View File

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

20
platform/logger/stack.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Fetch the most closest relevant error from the local code base so it can
* be more easily traced to its source.
*
* @param stack - Error stack.
* @param search - Relevant stack line search value.
*/
export function getTracedAt(stack: string | undefined, search: string): string | undefined {
if (stack === undefined) {
return undefined;
}
const firstMatch = stack.split("\n").find((line) => line.includes(search));
if (firstMatch === undefined) {
return undefined;
}
return firstMatch
.replace(/^.*?(file:\/\/\/)/, "$1")
.replace(/\)$/, "")
.trim();
}

View File

@@ -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<typeof AccountSchema>;
export type AccountDocument = z.infer<typeof AccountSchema>;

View File

@@ -1,15 +0,0 @@
import z, { ZodObject } from "zod";
export function makeModelParser<TSchema extends ZodObject>(schema: TSchema): ModelParserFn<TSchema> {
return ((value: unknown | unknown[]) => {
if (Array.isArray(value)) {
return value.map((value: unknown) => schema.parse(value));
}
return schema.parse(value);
}) as ModelParserFn<TSchema>;
}
type ModelParserFn<TSchema extends ZodObject> = {
(value: unknown): z.infer<TSchema>;
(value: unknown[]): z.infer<TSchema>[];
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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<RelayResponse> {
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<RelayResponse> {
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<string, unknown>, 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<string, unknown>, formData, `${formKey}[${index}]`);
} else {
formData.append(`${formKey}[${index}]`, String(item));
}
});
} else if (typeof value === "object") {
this.#getFormData(value as Record<string, unknown>, 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<RelayResponse> {
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<void>)[];
};
};

View File

@@ -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<RelayResponse>;
send(input: RelayInput, publicKey?: string): Promise<RelayResponse>;
/**
* Sends a fetch request using the given options and returns a
@@ -75,10 +77,12 @@ export type RelayInput = {
export type RelayResponse<TData = unknown, TError = ServerErrorType | ServerErrorResponse["error"]> =
| {
result: "success";
headers: Headers;
data: TData;
}
| {
result: "error";
headers: Headers;
error: TError;
};

View File

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

View File

@@ -0,0 +1,4 @@
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ServerContext {}
export const context: ServerContext = {} as any;

View File

@@ -1,6 +1,8 @@
import type { $ZodErrorTree } from "zod/v4/core";
import { ZodError } from "zod";
export abstract class ServerError<TData = unknown> extends Error {
abstract readonly code: string;
constructor(
message: string,
readonly status: number,
@@ -12,40 +14,40 @@ export abstract class ServerError<TData = unknown> 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<TData = unknown> 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<TData = unknown> extends Error {
}
export class BadRequestError<TData = unknown> extends ServerError<TData> {
readonly code = "BAD_REQUEST";
/**
* Instantiate a new BadRequestError.
*
@@ -70,6 +74,7 @@ export class BadRequestError<TData = unknown> extends ServerError<TData> {
* 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<TData = unknown> extends ServerError<TData> {
}
export class UnauthorizedError<TData = unknown> extends ServerError<TData> {
readonly code = "UNAUTHORIZED";
/**
* Instantiate a new UnauthorizedError.
*
@@ -104,6 +111,8 @@ export class UnauthorizedError<TData = unknown> extends ServerError<TData> {
}
export class ForbiddenError<TData = unknown> extends ServerError<TData> {
readonly code = "FORBIDDEN";
/**
* Instantiate a new ForbiddenError.
*
@@ -125,6 +134,8 @@ export class ForbiddenError<TData = unknown> extends ServerError<TData> {
}
export class NotFoundError<TData = unknown> extends ServerError<TData> {
readonly code = "NOT_FOUND";
/**
* Instantiate a new NotFoundError.
*
@@ -147,6 +158,8 @@ export class NotFoundError<TData = unknown> extends ServerError<TData> {
}
export class MethodNotAllowedError<TData = unknown> extends ServerError<TData> {
readonly code = "METHOD_NOT_ALLOWED";
/**
* Instantiate a new MethodNotAllowedError.
*
@@ -164,6 +177,8 @@ export class MethodNotAllowedError<TData = unknown> extends ServerError<TData> {
}
export class NotAcceptableError<TData = unknown> extends ServerError<TData> {
readonly code = "NOT_ACCEPTABLE";
/**
* Instantiate a new NotAcceptableError.
*
@@ -181,6 +196,8 @@ export class NotAcceptableError<TData = unknown> extends ServerError<TData> {
}
export class ConflictError<TData = unknown> extends ServerError<TData> {
readonly code = "CONFLICT";
/**
* Instantiate a new ConflictError.
*
@@ -202,6 +219,8 @@ export class ConflictError<TData = unknown> extends ServerError<TData> {
}
export class GoneError<TData = unknown> extends ServerError<TData> {
readonly code = "GONE";
/**
* Instantiate a new GoneError.
*
@@ -225,6 +244,8 @@ export class GoneError<TData = unknown> extends ServerError<TData> {
}
export class UnsupportedMediaTypeError<TData = unknown> extends ServerError<TData> {
readonly code = "UNSUPPORTED_MEDIA_TYPE";
/**
* Instantiate a new UnsupportedMediaTypeError.
*
@@ -242,6 +263,8 @@ export class UnsupportedMediaTypeError<TData = unknown> extends ServerError<TDat
}
export class UnprocessableContentError<TData = unknown> extends ServerError<TData> {
readonly code = "UNPROCESSABLE_CONTENT";
/**
* Instantiate a new UnprocessableContentError.
*
@@ -263,22 +286,49 @@ export class UnprocessableContentError<TData = unknown> extends ServerError<TDat
}
}
export class ZodValidationError<TData extends $ZodErrorTree<any, any>> extends ServerError<TData> {
export class ValidationError extends ServerError<ValidationErrorData> {
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<TData = unknown> extends ServerError<TData> {
readonly code = "INTERNAL_SERVER";
/**
* Instantiate a new InternalServerError.
*
@@ -302,6 +352,8 @@ export class InternalServerError<TData = unknown> extends ServerError<TData> {
}
export class NotImplementedError<TData = unknown> extends ServerError<TData> {
readonly code = "NOT_IMPLEMENTED";
/**
* Instantiate a new NotImplementedError.
*
@@ -319,6 +371,8 @@ export class NotImplementedError<TData = unknown> extends ServerError<TData> {
}
export class ServiceUnavailableError<TData = unknown> extends ServerError<TData> {
readonly code = "SERVICE_UNAVAILABLE";
/**
* Instantiate a new ServiceUnavailableError.
*
@@ -338,15 +392,21 @@ export class ServiceUnavailableError<TData = unknown> extends ServerError<TData>
}
}
export type ServerErrorClass<TData = unknown> = typeof ServerError<TData>;
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
export type ServerErrorJSON = {
type: "relay";
code: ServerErrorType["code"];
status: number;
message: string;
data?: any;
};
export type ServerErrorClass<TData = unknown> = typeof ServerError<TData>;
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;
};

View File

@@ -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<const TState extends State = State> {
readonly type = "procedure" as const;

View File

@@ -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<const TState extends RouteState = RouteState> {
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<TCrypto extends { publicKey: string }>(
crypto: TCrypto,
): Route<Prettify<Omit<TState, "crypto"> & { 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<TArgs extends Array<any> = any[], TResponse = any> = (
...args: TArgs

View File

@@ -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";

View File

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

426
platform/server/api.ts Normal file
View File

@@ -0,0 +1,426 @@
import { logger } from "@platform/logger";
import {
BadRequestError,
context,
ForbiddenError,
InternalServerError,
NotFoundError,
NotImplementedError,
Route,
RouteMethod,
ServerError,
type ServerErrorResponse,
UnauthorizedError,
ValidationError,
} from "@platform/relay";
import { decrypt } from "@platform/vault";
const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
export class Api {
readonly #index = new Map<string, Route>();
/**
* Route maps funneling registered routes to the specific methods supported by
* the relay instance.
*/
readonly routes: Routes = {
POST: [],
GET: [],
PUT: [],
PATCH: [],
DELETE: [],
};
/**
* List of paths in the '${method} ${path}' format allowing us to quickly throw
* errors if a duplicate route path is being added.
*/
readonly #paths = new Set<string>();
/**
* Instantiate a new Api instance.
*
* @param routes - Initial list of routes to register with the api.
*/
constructor(routes: Route[] = []) {
this.register(routes);
}
/**
* Register relays with the API instance allowing for decoupled registration
* of server side handling of relay contracts.
*
* @param routes - Relays to register with the instance.
*/
register(routes: Route[]): this {
const methods: (keyof typeof this.routes)[] = [];
for (const route of routes) {
const path = `${route.method} ${route.path}`;
if (this.#paths.has(path)) {
throw new Error(`Router > Path ${path} already exists`);
}
this.#paths.add(path);
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);
}
return this;
}
/**
* Executes request and returns a `Response` instance.
*
* @param request - REST request to pass to a route handler.
*/
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
// ### Route
// Locate a route matching the incoming request method and path.
const resolved = this.#getResolvedRoute(request.method, url.pathname);
if (resolved === undefined) {
return toResponse(
new NotFoundError(`Invalid routing path provided for ${request.url}`, {
method: request.method,
url: request.url,
}),
request,
);
}
// ### Handle
// Execute request and return a response.
const response = await this.#getRouteResponse(resolved, request).catch((error) =>
this.#getErrorResponse(error, resolved.route, request),
);
return response;
}
/**
* Attempt to resolve a route based on the given method and pathname.
*
* @param method - HTTP method.
* @param url - HTTP request url.
*/
#getResolvedRoute(method: string, url: string): ResolvedRoute | undefined {
assertMethod(method);
for (const route of this.routes[method]) {
if (route.match(url) === true) {
return { route, params: route.getParsedParams(url) };
}
}
}
/**
* Resolve the request on the given route and return a `Response` instance.
*
* @param resolved - Route and paramter details resolved for the request.
* @param request - Request instance to resolve.
*/
async #getRouteResponse({ route, params }: ResolvedRoute, request: Request): Promise<Response> {
const url = new URL(request.url);
// ### Args
// Arguments is passed to every route handler and provides a suite of functionality
// and request data.
const args: any[] = [];
// ### Input
// Generate route input which contains a map fo params, query, and/or body. If
// none of these are present then the input is not added to the final argument
// context of the handler.
const input: {
params?: object;
query?: object;
body?: unknown;
} = {
params: undefined,
query: undefined,
body: undefined,
};
// ### Access
// Check the access requirements of the route and run any additional checks
// if nessesary before proceeding with further request handling.
// 1. All routes needs access assignment, else we consider it an internal error.
// 2. If access requires a session we throw Unauthorized if the request is not authenticated.
// 3. If access is an array of access resources, we check that each resources can be
// accessed by the request.
if (route.state.access === undefined) {
return toResponse(
new InternalServerError(`Route '${route.method} ${route.path}' is missing access assignment.`),
request,
);
}
if (route.state.access === "session" && context.isAuthenticated === false) {
return toResponse(new UnauthorizedError(), request);
}
if (Array.isArray(route.state.access)) {
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<string>(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);
}
}
// ### Params
// If the route has params we want to coerce the values to the expected types.
if (route.state.params !== undefined) {
const result = await route.state.params.safeParseAsync(params);
if (result.success === false) {
return toResponse(ValidationError.fromZod(result.error, "params", "Invalid request params"), request);
}
input.params = result.data;
}
// ### Query
// If the route has a query schema we need to validate and parse the query.
if (route.state.query !== undefined) {
const result = await route.state.query.safeParseAsync(toQuery(url.searchParams) ?? {});
if (result.success === false) {
return toResponse(ValidationError.fromZod(result.error, "query", "Invalid request query"), request);
}
input.query = result.data;
}
// ### Body
// If the route has a body schema we need to validate and parse the body.
if (route.state.body !== undefined) {
const body = await this.#getRequestBody(request);
const result = await route.state.body.safeParseAsync(body);
if (result.success === false) {
return toResponse(ValidationError.fromZod(result.error, "body", "Invalid request body"), request);
}
input.body = result.data;
}
if (input.params !== undefined || input.query !== undefined || input.body !== undefined) {
args.push(input);
}
// ### Context
// Request context pass to every route as the last argument.
args.push(context);
// ### Handler
// Execute the route handler and apply the result.
if (route.state.handle === undefined) {
return toResponse(new NotImplementedError(`Path '${route.method} ${route.path}' is not implemented.`), request);
}
return toResponse(await route.state.handle(...args), request);
}
#getErrorResponse(error: unknown, route: Route, request: Request): Response {
if (route?.state.hooks?.onError !== undefined) {
return route.state.hooks.onError(error);
}
if (error instanceof ServerError) {
return toResponse(error, request);
}
logger.error(error);
if (error instanceof Error) {
return toResponse(new InternalServerError(error.message), request);
}
return toResponse(new InternalServerError(), request);
}
/**
* Resolves request body and returns it.
*
* @param request - Request to resolve body from.
* @param files - Files to populate if present.
*/
async #getRequestBody(request: Request): Promise<Record<string, unknown>> {
let body: Record<string, unknown> = {};
const type = request.headers.get("content-type");
if (!type || request.method === "GET") {
return body;
}
if (type.includes("json")) {
body = await request.json();
}
if (type.includes("application/x-www-form-urlencoded") || type.includes("multipart/form-data")) {
try {
const formData = await request.formData();
for (const [name, value] of Array.from(formData.entries())) {
body[name] = value;
}
} catch (error) {
logger.error(error);
throw new BadRequestError(`Malformed FormData`, { error });
}
}
return body;
}
}
/*
|--------------------------------------------------------------------------------
| Helpers
|--------------------------------------------------------------------------------
*/
/**
* Assert that the given method string is a valid routing method.
*
* @param candidate - Method candidate.
*/
function assertMethod(candidate: string): asserts candidate is RouteMethod {
if (!SUPPORTED_MEHODS.includes(candidate)) {
throw new Error(`Router > Unsupported method '${candidate}'`);
}
}
/**
* Sorting method for routes to ensure that static properties takes precedence
* for when a route is matched against incoming requests.
*
* @param a - Route A
* @param b - Route B
*/
function byStaticPriority(a: Route, b: Route) {
const aSegments = a.path.split("/");
const bSegments = b.path.split("/");
const maxLength = Math.max(aSegments.length, bSegments.length);
for (let i = 0; i < maxLength; i++) {
const aSegment = aSegments[i] || "";
const bSegment = bSegments[i] || "";
const isADynamic = aSegment.startsWith(":");
const isBDynamic = bSegment.startsWith(":");
if (isADynamic !== isBDynamic) {
return isADynamic ? 1 : -1;
}
if (isADynamic === false && aSegment !== bSegment) {
return aSegment.localeCompare(bSegment);
}
}
return a.path.localeCompare(b.path);
}
/**
* Resolve and return query object from the provided search parameters, or undefined
* if the search parameters does not have any entries.
*
* @param searchParams - Search params to create a query object from.
*/
function toQuery(searchParams: URLSearchParams): object | undefined {
if (searchParams.size === 0) {
return undefined;
}
const result: Record<string, string> = {};
for (const [key, value] of searchParams.entries()) {
result[key] = value;
}
return result;
}
/**
* Takes a server side request result and returns a fetch Response.
*
* @param result - Result to send back as a Response.
* @param request - Request instance.
*/
export function toResponse(result: unknown, request: Request): Response {
const method = request.method;
if (result instanceof Response) {
if (method === "HEAD") {
return new Response(null, {
status: result.status,
statusText: result.statusText,
headers: new Headers(result.headers),
});
}
return result;
}
if (result instanceof ServerError) {
const body = JSON.stringify({
error: {
code: result.code,
status: result.status,
message: result.message,
data: result.data,
},
} satisfies ServerErrorResponse);
return new Response(method === "HEAD" ? null : body, {
statusText: result.message || "Internal Server Error",
status: result.status || 500,
headers: {
"content-type": "application/json",
},
});
}
const body = JSON.stringify({
data: result ?? null,
});
return new Response(method === "HEAD" ? null : body, {
status: 200,
headers: {
"content-type": "application/json",
},
});
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type Routes = {
POST: Route[];
GET: Route[];
PUT: Route[];
PATCH: Route[];
DELETE: Route[];
};
type ResolvedRoute = {
route: Route;
params: any;
};

View File

@@ -0,0 +1,40 @@
import { Route } from "@platform/relay";
/**
* Resolve and return all routes that has been created under any 'routes'
* folders that can be found under the given path.
*
* If the filter is empty, all paths are resolved, otherwise only paths
* declared in the array is resolved.
*
* @param path - Path to resolve routes from.
* @param filter - List of modules to include.
* @param routes - List of routes that has been resolved.
*/
export async function resolveRoutes(path: string, routes: Route[] = []): Promise<Route[]> {
for await (const entry of Deno.readDir(path)) {
if (entry.isDirectory === true) {
await loadRoutes(`${path}/${entry.name}`, routes, [name]);
}
}
return routes;
}
async function loadRoutes(path: string, routes: Route[], modules: string[]): Promise<void> {
for await (const entry of Deno.readDir(path)) {
if (entry.isDirectory === true) {
await loadRoutes(`${path}/${entry.name}`, routes, [...modules, entry.name]);
} else {
if (!entry.name.endsWith(".ts") || entry.name.endsWith("i9n.ts")) {
continue;
}
const { default: route } = (await import(`${path}/${entry.name}`)) as { default: Route };
if (route instanceof Route === false) {
throw new Error(
`Router Violation: Could not load '${path}/${entry.name}' as it does not export a default Route instance.`,
);
}
routes.push(route);
}
}
}

View File

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

71
platform/server/server.ts Normal file
View File

@@ -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<void> => {
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<void> => {
const context = getStorageContext();
context.request = {
headers: request.headers,
};
context.response = {
headers: new Headers(),
};
context.info = {
method: request.url,
start: Date.now(),
};
},
};

50
platform/server/socket.ts Normal file
View File

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

46
platform/server/types.d.ts vendored Normal file
View File

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

View File

@@ -0,0 +1,81 @@
import type { Params } from "@valkyr/json-rpc";
import { SocketRegistry } from "./sockets.ts";
export class Channels {
readonly #channels = new Map<string, SocketRegistry>();
/**
* Add a new channel.
*
* @param channel
*/
add(channel: string): this {
this.#channels.set(channel, new SocketRegistry());
return this;
}
/**
* Deletes a channel.
*
* @param channel
*/
del(channel: string): this {
this.#channels.delete(channel);
return this;
}
/**
* Add socket to the given channel. If the channel does not exist it is
* automatically created.
*
* @param channel - Channel to add socket to.
* @param socket - Socket to add to the channel.
*/
join(channel: string, socket: WebSocket): this {
const sockets = this.#channels.get(channel);
if (sockets === undefined) {
this.#channels.set(channel, new SocketRegistry().add(socket));
} else {
sockets.add(socket);
}
return this;
}
/**
* Remove a socket from the given channel.
*
* @param channel - Channel to leave.
* @param socket - Socket to remove from the channel.
*/
leave(channel: string, socket: WebSocket): this {
this.#channels.get(channel)?.del(socket);
return this;
}
/**
* Sends a JSON-RPC notification to all sockets in given channel.
*
* @param channel - Channel to emit method to.
* @param method - Method to send the notification to.
* @param params - Message data to send to the clients.
*/
notify(channel: string, method: string, params: Params): this {
this.#channels.get(channel)?.notify(method, params);
return this;
}
/**
* Transmits data to all registered WebSocket connections in the given channel.
* Data can be a string, a Blob, an ArrayBuffer, or an ArrayBufferView.
*
* @param channel - Channel to emit message to.
* @param data - Data to send to each connected socket in the channel.
*/
send(channel: string, data: string | ArrayBufferLike | Blob | ArrayBufferView): this {
this.#channels.get(channel)?.send(data);
return this;
}
}
export const channels = new Channels();

View File

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

39
platform/socket/server.ts Normal file
View File

@@ -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<void> => {
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<void> => {
const context = getStorageContext();
context.sockets = sockets;
},
};

View File

@@ -0,0 +1,47 @@
import type { Params } from "@valkyr/json-rpc";
export class SocketRegistry {
readonly #sockets = new Set<WebSocket>();
/**
* Add a socket to the pool.
*
* @param socket - WebSocket to add.
*/
add(socket: WebSocket): this {
this.#sockets.add(socket);
return this;
}
/**
* Remove a socket from the pool.
*
* @param socket - WebSocket to remove.
*/
del(socket: WebSocket): this {
this.#sockets.delete(socket);
return this;
}
/**
* Sends a JSON-RPC notification to all connected sockets.
*
* @param method - Method to send the notification to.
* @param params - Message data to send to the clients.
*/
notify(method: string, params: Params): this {
this.send(JSON.stringify({ jsonrpc: "2.0", method, params }));
return this;
}
/**
* Transmits data to all registered WebSocket connections. Data can be a string,
* a Blob, an ArrayBuffer, or an ArrayBufferView.
*
* @param data - Data to send to each connected socket.
*/
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): this {
this.#sockets.forEach((socket) => socket.send(data));
return this;
}
}

22
platform/socket/types.d.ts vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,6 @@
"dependencies": {
"@platform/models": "workspace:*",
"@platform/relay": "workspace:*",
"zod": "4"
"zod": "4.1.11"
}
}

View File

@@ -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:*"
}
}

View File

@@ -0,0 +1,21 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { InternalServerError } from "@platform/relay";
export const storage = new AsyncLocalStorage<StorageContext>();
/**
* 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 {}

51
platform/vault/hmac.ts Normal file
View File

@@ -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<string> {
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<boolean> {
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<CryptoKey> {
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;
}

134
platform/vault/key-pair.ts Normal file
View File

@@ -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<KeyPair> {
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<KeyPair> {
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<Jose.CryptoKey> {
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<Jose.CryptoKey> {
return Jose.importPKCS8(privateKey, algorithm, { extractable: true });
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
export type ExportedKeyPair = {
publicKey: string;
privateKey: string;
};

View File

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

84
platform/vault/vault.ts Normal file
View File

@@ -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<T extends Record<string, unknown> | unknown[] | string>(value: T): Promise<string> {
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<T>(cypherText: string): Promise<T> {
const { plaintext } = await Jose.compactDecrypt(cypherText, this.#keyPair.private.key);
return JSON.parse(new TextDecoder().decode(plaintext));
}
}
/*
|--------------------------------------------------------------------------------
| Factories
|--------------------------------------------------------------------------------
*/
export async function createVault(): Promise<Vault> {
return new Vault(await createKeyPair(VAULT_ALGORITHM));
}
export async function importVault(keyPair: ExportedKeyPair): Promise<Vault> {
return new Vault(await loadKeyPair(keyPair, VAULT_ALGORITHM));
}
export async function encrypt<T extends Record<string, unknown> | 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<T>(cypherText: string, privateKey: string): Promise<T> {
const { plaintext } = await Jose.compactDecrypt(cypherText, await importPrivateKey(privateKey, VAULT_ALGORITHM));
return JSON.parse(new TextDecoder().decode(plaintext));
}