feat: initial boilerplate
This commit is contained in:
9
api/config.ts
Normal file
9
api/config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { config as auth } from "~libraries/auth/config.ts";
|
||||
import { getEnvironmentVariable, toNumber } from "~libraries/config/mod.ts";
|
||||
|
||||
export const config = {
|
||||
name: "valkyr",
|
||||
host: getEnvironmentVariable("API_HOST", "0.0.0.0"),
|
||||
port: getEnvironmentVariable("API_PORT", toNumber, "8370"),
|
||||
...auth,
|
||||
};
|
||||
6
api/deno.json
Normal file
6
api/deno.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"imports": {
|
||||
"~config": "./config.ts",
|
||||
"~libraries/": "./libraries/"
|
||||
}
|
||||
}
|
||||
28
api/libraries/auth/.keys/private
Normal file
28
api/libraries/auth/.keys/private
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQCy5ZoXkKP9mZTk
|
||||
sKbQdSwspHZqyMH33Gby23+9ycNHMIww7djcWFfPRW4s7tu3SNaac6qVg9OI43+Z
|
||||
6BPXxuh4nhQ4LX5No9iVEmcWvZtKE4ghwzsoU0llT7+aKl9UYvgqU1YX4zyfiyo2
|
||||
bW0nVPasEHTyjLCVPK5BKlq+UmuyJTVcduALDnVETpUefu5Vca6tIRXsOovvAf5b
|
||||
zmcxPccaXIatR/AeipxT0YWoInn8dxD3kyFgTPXtinuBZxvp6MUeSs5IE8OJRJRP
|
||||
PEo1MQ9HFw9aYRIn9uIkbARbNZMGz77zB1+0TrPGyKOB5lLReWGMUFAJhjLrnTsY
|
||||
z19se4kNAgMBAAECgf9QkG6A6ViiHIMnUskIDeP5Xir19d9kbGwrcn0F2OXYaX+l
|
||||
Oot9w3KM6loRJx380/zk/e0Uch1MeZ2fyqQRUmAGQIzkXUm6LUWIekYQN6vZ3JlP
|
||||
YA2/M+otdd8Tpws9hFSDMUlx0SP3GAi0cE48xdBkVAT0NjZ3Jjor7Wv6GLe//Kzg
|
||||
1OVrbPAA/+RrPB+BQn5nmZFT0aLuLpyxB4f4ArHG/8DEBY49Syy7/3Ke0kfHMnhl
|
||||
5Eg5Yau89wSLqEoUSuQvNixu/5nTTQ6v1VYPVG8D1hn773SbNoY9o5vZOPRl1P0q
|
||||
9YC/qpzPJkm/A5TZLsoalIxuGTdwts+DaEeoKmECgYEA5CddLQbMNu9kYElxpSA3
|
||||
xXoTL71ZBCQsWExmJrcGe2lQhGO40lF8jE6QnEvMt0mp8Dg9n2ih4J87+2Ozb0fp
|
||||
2G2ilNeMxM7keywA/+Cwg71QyImppU0lQ5PYLv+pllfxN8FPpLBluy7rDahzphkn
|
||||
1rijqI5d4bHNG6IgD2ynteECgYEAyLs2eBWxX39Jff3OdpSVmHf7NtacbtsUf1qM
|
||||
RJSvLsiSwKn39n1+Y6ebzftxm/XD/j8FbN8XvMZMI4OrlfzP+YJaTybIbHrLzCE2
|
||||
B5E9j0GbJRhJ/D3l9FQBGdY4g5yC4mgbncXURQqqQTtKk2d+ixZSrw8iyDGN+aMJ
|
||||
ybqZoK0CgYALb6GvARk5Y7R/Uw8cPMou3tiZWv9cQsfqQSIZrLDpfLTpfeokuKrq
|
||||
iYGcI/yF725SOS91jxQWI0Upa6zx1gP1skEk/szyjIBNYD5IlSWj5NhoxOW5AG3u
|
||||
vjlm2a/RdmUD62+njKP8xvRHQftSBw7FJ4okh8ZS6suiJ/U9cK/TYQKBgFg+jTyP
|
||||
dNGhuKJN0NUqjvVfUa4S/ORzJXizStTfdIAhpvpR/nN7SfPvfDw6nQBOM+JyvCTX
|
||||
kqznlBNM0EL4yElNN/xx9UxTU4Ki2wjKngB7fAP7wJLGd3BI+c7s8R1S0etMj091
|
||||
59KOVLimoytYJTZqEuFoywatWlfzh9sKUH1lAoGBAID6mqGL3SZhh+i2/kAytfzw
|
||||
UswTQqA0CCBTzN/Eo1QozmUVTLQPj8rBchNSoiSc92y+lPIL8ePdU7imRB77i+9D
|
||||
9MSmc5u3ACACOSkwF0JCEGN+Rju4HR5wwm3h6Kvf/FQ3yvSEOKAWhqXIY95qtYTU
|
||||
j3O+iJbY32pbQsawIAkw
|
||||
-----END PRIVATE KEY-----
|
||||
9
api/libraries/auth/.keys/public
Normal file
9
api/libraries/auth/.keys/public
Normal file
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsuWaF5Cj/ZmU5LCm0HUs
|
||||
LKR2asjB99xm8tt/vcnDRzCMMO3Y3FhXz0VuLO7bt0jWmnOqlYPTiON/megT18bo
|
||||
eJ4UOC1+TaPYlRJnFr2bShOIIcM7KFNJZU+/mipfVGL4KlNWF+M8n4sqNm1tJ1T2
|
||||
rBB08oywlTyuQSpavlJrsiU1XHbgCw51RE6VHn7uVXGurSEV7DqL7wH+W85nMT3H
|
||||
GlyGrUfwHoqcU9GFqCJ5/HcQ95MhYEz17Yp7gWcb6ejFHkrOSBPDiUSUTzxKNTEP
|
||||
RxcPWmESJ/biJGwEWzWTBs++8wdftE6zxsijgeZS0XlhjFBQCYYy6507GM9fbHuJ
|
||||
DQIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
87
api/libraries/auth/auth.ts
Normal file
87
api/libraries/auth/auth.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Auth, ResolvedSession } from "@valkyr/auth";
|
||||
import z from "zod";
|
||||
|
||||
import { db } from "~libraries/read-store/database.ts";
|
||||
|
||||
import { config } from "./config.ts";
|
||||
|
||||
export const auth = new Auth(
|
||||
{
|
||||
settings: {
|
||||
algorithm: "RS256",
|
||||
privateKey: config.privateKey,
|
||||
publicKey: config.publicKey,
|
||||
issuer: "https://balto.health",
|
||||
audience: "https://balto.health",
|
||||
},
|
||||
session: z.object({
|
||||
accountId: z.string(),
|
||||
}),
|
||||
permissions: {
|
||||
admin: ["create", "read", "update", "delete"],
|
||||
organization: ["create", "read", "update", "delete"],
|
||||
consultant: ["create", "read", "update", "delete"],
|
||||
task: ["create", "update", "read", "delete"],
|
||||
} as const,
|
||||
guards: [],
|
||||
},
|
||||
{
|
||||
roles: {
|
||||
async add(role) {
|
||||
await db.collection("roles").insertOne(role);
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const role = await db.collection("roles").findOne({ id });
|
||||
if (role === null) {
|
||||
return undefined;
|
||||
}
|
||||
return role;
|
||||
},
|
||||
|
||||
async getBySession({ accountId }) {
|
||||
const account = await db.collection("accounts").findOne({ id: accountId });
|
||||
if (account === null) {
|
||||
return [];
|
||||
}
|
||||
return db
|
||||
.collection("roles")
|
||||
.find({ id: { $in: account.roles } })
|
||||
.toArray();
|
||||
},
|
||||
|
||||
async setPermissions() {
|
||||
throw new Error("MongoRolesProvider > .setPermissions is managed by Role aggregate projections");
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
await db.collection("roles").deleteOne({ id });
|
||||
},
|
||||
|
||||
async assignAccount(roleId: string, accountId: string): Promise<void> {
|
||||
await db.collection("accounts").updateOne(
|
||||
{ id: accountId },
|
||||
{
|
||||
$push: {
|
||||
roles: roleId,
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async removeAccount(roleId: string, accountId: string): Promise<void> {
|
||||
await db.collection("roles").updateOne(
|
||||
{ id: accountId },
|
||||
{
|
||||
$pull: {
|
||||
roles: roleId,
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export type Session = ResolvedSession<typeof auth>;
|
||||
export type Permissions = (typeof auth)["$permissions"];
|
||||
25
api/libraries/auth/config.ts
Normal file
25
api/libraries/auth/config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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,
|
||||
};
|
||||
6
api/libraries/auth/mod.ts
Normal file
6
api/libraries/auth/mod.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { auth } from "./auth.ts";
|
||||
|
||||
export * from "./auth.ts";
|
||||
export * from "./config.ts";
|
||||
|
||||
export type Auth = typeof auth;
|
||||
21
api/libraries/config/libraries/args.ts
Normal file
21
api/libraries/config/libraries/args.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { parseArgs } from "@std/cli";
|
||||
|
||||
import { Parser, toString } from "./parsers.ts";
|
||||
|
||||
export function getArgsVariable(key: string, fallback?: string): string;
|
||||
export function getArgsVariable<T extends Parser>(key: string, parse: T, fallback?: string): ReturnType<T>;
|
||||
export function getArgsVariable<T extends Parser>(key: string, parse?: T, fallback?: string): ReturnType<T> {
|
||||
if (typeof parse === "string") {
|
||||
fallback = parse;
|
||||
parse = undefined;
|
||||
}
|
||||
const flags = parseArgs(Deno.args);
|
||||
const value = flags[key];
|
||||
if (value === undefined) {
|
||||
if (fallback !== undefined) {
|
||||
return parse ? parse(fallback) : fallback;
|
||||
}
|
||||
throw new Error(`Config Exception: Missing ${key} variable in arguments`);
|
||||
}
|
||||
return parse ? parse(value) : toString(value);
|
||||
}
|
||||
79
api/libraries/config/libraries/environment.ts
Normal file
79
api/libraries/config/libraries/environment.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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?: string): string;
|
||||
export function getEnvironmentVariable<T extends Parser>(key: string, parse: T, fallback?: string): ReturnType<T>;
|
||||
export function getEnvironmentVariable<T extends Parser>(key: string, parse?: T, fallback?: string): ReturnType<T> {
|
||||
if (typeof parse === "string") {
|
||||
fallback = parse;
|
||||
parse = undefined;
|
||||
}
|
||||
const value = env[key] ?? Deno.env.get(key);
|
||||
if (value === undefined) {
|
||||
if (fallback !== undefined) {
|
||||
return parse ? parse(fallback) : fallback;
|
||||
}
|
||||
throw new Error(`Config Exception: Missing ${key} variable in configuration`);
|
||||
}
|
||||
return parse ? parse(value) : toString(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an environment variable, select value based on ENV map and parse it to the desired type. Can be used with simple primitives or objects / arrays
|
||||
*
|
||||
* @export
|
||||
* @param {{
|
||||
* key: string;
|
||||
* envFallback?: FallbackEnvMap;
|
||||
* fallback: string;
|
||||
* validation: z.ZodTypeAny,
|
||||
* }} options
|
||||
* @param {string} options.key - the name of the env variable
|
||||
* @param {object} options.envFallback - map with env specific fallbacks that will be used if none value provided
|
||||
* @param {string} options.envFallback.local - example "local" SERVICE_ENV target fallback value
|
||||
* @param {string} options.fallback - string fallback that will be used if no env variable found
|
||||
* @param {z.ZodTypeAny} options.validation - Zod validation object or validation primitive
|
||||
* @returns {z.infer<typeof validation>} - Returns the inferred type of the validation provided
|
||||
*/
|
||||
export function validateEnvVariable({
|
||||
key,
|
||||
envFallback,
|
||||
fallback,
|
||||
validation,
|
||||
}: {
|
||||
key: string;
|
||||
validation: z.ZodTypeAny;
|
||||
envFallback?: FallbackEnvMap;
|
||||
fallback?: string;
|
||||
}): z.infer<typeof validation> {
|
||||
const serviceEnv = getEnvironmentVariable("SERVICE_ENV", toServiceEnv, "local");
|
||||
const providedValue = env[key] ?? Deno.env.get(key);
|
||||
const fallbackValue = typeof envFallback === "object" ? (envFallback[serviceEnv] ?? fallback) : fallback;
|
||||
const toBeUsed = providedValue ?? fallbackValue;
|
||||
try {
|
||||
if (typeof toBeUsed === "string" && (toBeUsed.trim().startsWith("{") || toBeUsed.trim().startsWith("["))) {
|
||||
return validation.parse(JSON.parse(toBeUsed));
|
||||
}
|
||||
return validation.parse(toBeUsed);
|
||||
} catch (e) {
|
||||
throw new Deno.errors.InvalidData(`Config Exception: Missing valid ${key} variable in configuration`, { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
type FallbackEnvMap = Partial<Record<Env, string>> & {
|
||||
testing?: string;
|
||||
local?: string;
|
||||
stg?: string;
|
||||
demo?: string;
|
||||
prod?: string;
|
||||
};
|
||||
98
api/libraries/config/libraries/parsers.ts
Normal file
98
api/libraries/config/libraries/parsers.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
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];
|
||||
3
api/libraries/config/mod.ts
Normal file
3
api/libraries/config/mod.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./libraries/args.ts";
|
||||
export * from "./libraries/environment.ts";
|
||||
export * from "./libraries/parsers.ts";
|
||||
1
api/libraries/crypto/mod.ts
Normal file
1
api/libraries/crypto/mod.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./password.ts";
|
||||
11
api/libraries/crypto/password.ts
Normal file
11
api/libraries/crypto/password.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import * as bcrypt from "@felix/bcrypt";
|
||||
|
||||
export const password = { hash, verify };
|
||||
|
||||
async function hash(password: string): Promise<string> {
|
||||
return bcrypt.hash(password);
|
||||
}
|
||||
|
||||
async function verify(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.verify(password, hash);
|
||||
}
|
||||
48
api/libraries/database/accessor.ts
Normal file
48
api/libraries/database/accessor.ts
Normal 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("client");
|
||||
},
|
||||
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]>;
|
||||
};
|
||||
10
api/libraries/database/config.ts
Normal file
10
api/libraries/database/config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
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"),
|
||||
},
|
||||
};
|
||||
24
api/libraries/database/connection.ts
Normal file
24
api/libraries/database/connection.ts
Normal 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;
|
||||
};
|
||||
6
api/libraries/database/container.ts
Normal file
6
api/libraries/database/container.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Container } from "@valkyr/inverse";
|
||||
import { MongoClient } from "mongodb";
|
||||
|
||||
export const container = new Container<{
|
||||
client: MongoClient;
|
||||
}>("database");
|
||||
3
api/libraries/database/id.ts
Normal file
3
api/libraries/database/id.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { CreateIndexesOptions, IndexSpecification } from "mongodb";
|
||||
|
||||
export const idIndex: [IndexSpecification, CreateIndexesOptions] = [{ id: 1 }, { unique: true }];
|
||||
30
api/libraries/database/registrar.ts
Normal file
30
api/libraries/database/registrar.ts
Normal 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?][];
|
||||
};
|
||||
5
api/libraries/database/tasks/bootstrap.ts
Normal file
5
api/libraries/database/tasks/bootstrap.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { config } from "../config.ts";
|
||||
import { getMongoClient } from "../connection.ts";
|
||||
import { container } from "../container.ts";
|
||||
|
||||
container.set("client", getMongoClient(config.mongo));
|
||||
37
api/libraries/database/utilities.ts
Normal file
37
api/libraries/database/utilities.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Db } from "mongodb";
|
||||
import z, { ZodType } from "zod";
|
||||
|
||||
/**
|
||||
* 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)));
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
268
api/libraries/event-store/aggregates/account.ts
Normal file
268
api/libraries/event-store/aggregates/account.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
|
||||
import { Avatar, Contact, Email, Name, Phone, Strategy } from "relay/schemas";
|
||||
|
||||
import { db, toAccountDriver } from "~libraries/read-store/mod.ts";
|
||||
|
||||
import { eventStore } from "../event-store.ts";
|
||||
import { AccountCreatedData } from "../events/account.ts";
|
||||
import { Auditor } from "../events/auditor.ts";
|
||||
import { EventStoreFactory } from "../events/mod.ts";
|
||||
import { projector } from "../projector.ts";
|
||||
|
||||
export class Account extends AggregateRoot<EventStoreFactory> {
|
||||
static override readonly name = "account";
|
||||
|
||||
id!: string;
|
||||
organizationId?: string;
|
||||
|
||||
type!: "admin" | "consultant" | "organization";
|
||||
|
||||
avatar?: Avatar;
|
||||
name?: Name;
|
||||
contact: Contact = {
|
||||
emails: [],
|
||||
phones: [],
|
||||
};
|
||||
strategies: Strategy[] = [];
|
||||
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Factories
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static #reducer = makeAggregateReducer(Account);
|
||||
|
||||
static create(data: AccountCreatedData, meta: Auditor): Account {
|
||||
return new Account().push({
|
||||
type: "account:created",
|
||||
data,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
static async getById(stream: string): Promise<Account | undefined> {
|
||||
return this.$store.reduce({ name: "account", stream, reducer: this.#reducer });
|
||||
}
|
||||
|
||||
static async getByEmail(email: string): Promise<Account | undefined> {
|
||||
return this.$store.reduce({ name: "account", relation: Account.emailRelation(email), reducer: this.#reducer });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Relations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static emailRelation(email: string): `account:email:${string}` {
|
||||
return `account:email:${email}`;
|
||||
}
|
||||
|
||||
static passwordRelation(alias: string): `account:password:${string}` {
|
||||
return `account:password:${alias}`;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reducer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
with(event: EventStoreFactory["$events"][number]["$record"]): void {
|
||||
switch (event.type) {
|
||||
case "account:created": {
|
||||
this.id = event.stream;
|
||||
this.organizationId = event.data.type === "organization" ? event.data.organizationId : undefined;
|
||||
this.type = event.data.type;
|
||||
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:phone:added": {
|
||||
this.contact.phones.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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
addAvatar(url: string, meta: Auditor): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "account:avatar:added",
|
||||
data: url,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addName(name: Name, meta: Auditor): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "account:name:added",
|
||||
data: name,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addEmail(email: Email, meta: Auditor): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "account:email:added",
|
||||
data: email,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addPhone(phone: Phone, meta: Auditor): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "account:phone:added",
|
||||
data: phone,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addRole(roleId: string, meta: Auditor): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "account:role:added",
|
||||
data: roleId,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addEmailStrategy(email: string, meta: Auditor): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "strategy:email:added",
|
||||
data: email,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addPasswordStrategy(alias: string, password: string, meta: Auditor): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "strategy:password:added",
|
||||
data: { alias, password },
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
toSession(): Session {
|
||||
if (this.type === "organization") {
|
||||
if (this.organizationId === undefined) {
|
||||
throw new Error("Account .toSession failed, no organization id present");
|
||||
}
|
||||
return {
|
||||
type: this.type,
|
||||
accountId: this.id,
|
||||
organizationId: this.organizationId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: this.type,
|
||||
accountId: this.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type Session =
|
||||
| {
|
||||
type: "organization";
|
||||
accountId: string;
|
||||
organizationId: string;
|
||||
}
|
||||
| {
|
||||
type: "admin" | "consultant";
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Projectors
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
projector.on("account:created", async ({ stream, data }) => {
|
||||
const schema: any = {
|
||||
id: stream,
|
||||
type: data.type,
|
||||
contact: {
|
||||
emails: [],
|
||||
phones: [],
|
||||
},
|
||||
strategies: [],
|
||||
roles: [],
|
||||
};
|
||||
if (data.type === "organization") {
|
||||
schema.organizationId = data.organizationId;
|
||||
}
|
||||
await db.collection("accounts").insertOne(toAccountDriver(schema));
|
||||
});
|
||||
|
||||
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:phone:added", async ({ stream: id, data: phone }) => {
|
||||
await db.collection("accounts").updateOne({ id }, { $push: { "contact.phones": phone } });
|
||||
});
|
||||
|
||||
projector.on("account:role:added", async ({ stream: id, data: roleId }) => {
|
||||
await db.collection("accounts").updateOne({ id }, { $push: { roles: roleId } });
|
||||
});
|
||||
|
||||
projector.on("strategy:email:added", async ({ stream: id, data: email }) => {
|
||||
await eventStore.relations.insert(Account.emailRelation(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(Account.passwordRelation(strategy.alias), id);
|
||||
await db.collection("accounts").updateOne({ id }, { $push: { strategies: { type: "password", ...strategy } } });
|
||||
});
|
||||
78
api/libraries/event-store/aggregates/code.ts
Normal file
78
api/libraries/event-store/aggregates/code.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
|
||||
|
||||
import { CodeIdentity } from "../events/code.ts";
|
||||
import { EventStoreFactory } from "../events/mod.ts";
|
||||
|
||||
export class Code extends AggregateRoot<EventStoreFactory> {
|
||||
static override readonly name = "code";
|
||||
|
||||
id!: string;
|
||||
|
||||
identity!: CodeIdentity;
|
||||
value!: string;
|
||||
|
||||
createdAt!: Date;
|
||||
claimedAt?: Date;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Factories
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static #reducer = makeAggregateReducer(Code);
|
||||
|
||||
static create(identity: CodeIdentity): Code {
|
||||
return new Code().push({
|
||||
type: "code:created",
|
||||
data: {
|
||||
identity,
|
||||
value: crypto
|
||||
.getRandomValues(new Uint8Array(5))
|
||||
.map((v) => v % 10)
|
||||
.join(""),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async getById(stream: string): Promise<Code | undefined> {
|
||||
return this.$store.reduce({
|
||||
name: "code",
|
||||
stream,
|
||||
reducer: this.#reducer,
|
||||
});
|
||||
}
|
||||
|
||||
get isClaimed(): boolean {
|
||||
return this.claimedAt !== undefined;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Folder
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
with(event: EventStoreFactory["$events"][number]["$record"]): void {
|
||||
switch (event.type) {
|
||||
case "code:created": {
|
||||
this.id = event.stream;
|
||||
this.value = event.data.value;
|
||||
this.identity = event.data.identity;
|
||||
this.createdAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "code:claimed": {
|
||||
this.claimedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Actions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
claim(): this {
|
||||
return this.push({
|
||||
type: "code:claimed",
|
||||
stream: this.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
8
api/libraries/event-store/aggregates/mod.ts
Normal file
8
api/libraries/event-store/aggregates/mod.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { AggregateFactory } from "@valkyr/event-store";
|
||||
|
||||
import { Account } from "./account.ts";
|
||||
import { Code } from "./code.ts";
|
||||
import { Organization } from "./organization.ts";
|
||||
import { Role } from "./role.ts";
|
||||
|
||||
export const aggregates = new AggregateFactory([Account, Code, Organization, Role]);
|
||||
65
api/libraries/event-store/aggregates/organization.ts
Normal file
65
api/libraries/event-store/aggregates/organization.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
|
||||
|
||||
import { db } from "~libraries/read-store/mod.ts";
|
||||
|
||||
import { Auditor } from "../events/auditor.ts";
|
||||
import { EventStoreFactory } from "../events/mod.ts";
|
||||
import { projector } from "../projector.ts";
|
||||
|
||||
export class Organization extends AggregateRoot<EventStoreFactory> {
|
||||
static override readonly name = "organization";
|
||||
|
||||
id!: string;
|
||||
|
||||
name!: string;
|
||||
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Factories
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static #reducer = makeAggregateReducer(Organization);
|
||||
|
||||
static create(name: string, meta: Auditor): Organization {
|
||||
return new Organization().push({
|
||||
type: "organization:created",
|
||||
data: { name },
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
static async getById(stream: string): Promise<Organization | undefined> {
|
||||
return this.$store.reduce({ name: "organization", stream, reducer: this.#reducer });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reducer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
with(event: EventStoreFactory["$events"][number]["$record"]): void {
|
||||
switch (event.type) {
|
||||
case "organization:created": {
|
||||
this.id = event.stream;
|
||||
this.name = event.data.name;
|
||||
this.createdAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Projectors
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
projector.on("organization:created", async ({ stream: id, data: { name }, created }) => {
|
||||
await db.collection("organizations").insertOne({
|
||||
id,
|
||||
name,
|
||||
createdAt: getDate(created),
|
||||
});
|
||||
});
|
||||
118
api/libraries/event-store/aggregates/role.ts
Normal file
118
api/libraries/event-store/aggregates/role.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
|
||||
|
||||
import { db } from "~libraries/read-store/database.ts";
|
||||
|
||||
import type { Auditor } from "../events/auditor.ts";
|
||||
import { EventStoreFactory } from "../events/mod.ts";
|
||||
import type { RoleCreatedData, RolePermissionOperation } from "../events/role.ts";
|
||||
import { projector } from "../projector.ts";
|
||||
|
||||
export class Role extends AggregateRoot<EventStoreFactory> {
|
||||
static override readonly name = "role";
|
||||
|
||||
id!: string;
|
||||
|
||||
name!: string;
|
||||
permissions: { [resource: string]: Set<string> } = {};
|
||||
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Factories
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static #reducer = makeAggregateReducer(Role);
|
||||
|
||||
static create(data: RoleCreatedData, meta: Auditor): Role {
|
||||
return new Role().push({
|
||||
type: "role:created",
|
||||
data,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
static async getById(stream: string): Promise<Role | undefined> {
|
||||
return this.$store.reduce({ name: "role", stream, reducer: this.#reducer });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reducer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
override with(event: EventStoreFactory["$events"][number]["$record"]): void {
|
||||
switch (event.type) {
|
||||
case "role:created": {
|
||||
this.id = event.stream;
|
||||
this.createdAt = getDate(event.created);
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "role:name-set": {
|
||||
this.name = event.data;
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "role:permissions-set": {
|
||||
for (const operation of event.data) {
|
||||
if (operation.type === "grant") {
|
||||
if (this.permissions[operation.resource] === undefined) {
|
||||
this.permissions[operation.resource] = new Set();
|
||||
}
|
||||
this.permissions[operation.resource].add(operation.action);
|
||||
}
|
||||
if (operation.type === "deny") {
|
||||
if (operation.action === undefined) {
|
||||
delete this.permissions[operation.resource];
|
||||
} else {
|
||||
this.permissions[operation.resource]?.delete(operation.action);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Actions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
setName(name: string, meta: Auditor): this {
|
||||
return this.push({
|
||||
type: "role:name-set",
|
||||
stream: this.id,
|
||||
data: name,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
setPermissions(operations: RolePermissionOperation[], meta: Auditor): this {
|
||||
return this.push({
|
||||
type: "role:permissions-set",
|
||||
stream: this.id,
|
||||
data: operations,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Projectors
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
projector.on("role:created", async ({ stream, data: { name, permissions } }) => {
|
||||
await db.collection("roles").insertOne({
|
||||
id: stream,
|
||||
name,
|
||||
permissions: permissions.reduce(
|
||||
(map, permission) => {
|
||||
map[permission.resource] = permission.actions;
|
||||
return map;
|
||||
},
|
||||
{} as Record<string, string[]>,
|
||||
),
|
||||
});
|
||||
});
|
||||
25
api/libraries/event-store/event-store.ts
Normal file
25
api/libraries/event-store/event-store.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { EventStore } from "@valkyr/event-store";
|
||||
import { MongoAdapter } from "@valkyr/event-store/mongo";
|
||||
|
||||
import { container } from "~libraries/database/container.ts";
|
||||
|
||||
import { aggregates } from "./aggregates/mod.ts";
|
||||
import { events } from "./events/mod.ts";
|
||||
import { projector } from "./projector.ts";
|
||||
|
||||
export const eventStore = new EventStore({
|
||||
adapter: new MongoAdapter(() => container.get("client"), "balto:event-store"),
|
||||
events,
|
||||
aggregates,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
29
api/libraries/event-store/events/account.ts
Normal file
29
api/libraries/event-store/events/account.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { event } from "@valkyr/event-store";
|
||||
import { email, name, phone } from "relay/schemas";
|
||||
import z from "zod";
|
||||
|
||||
import { auditor } from "./auditor.ts";
|
||||
|
||||
const created = z.discriminatedUnion([
|
||||
z.object({
|
||||
type: z.literal("admin"),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("consultant"),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("organization"),
|
||||
organizationId: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export default [
|
||||
event.type("account:created").data(created).meta(auditor),
|
||||
event.type("account:avatar:added").data(z.string()).meta(auditor),
|
||||
event.type("account:name:added").data(name).meta(auditor),
|
||||
event.type("account:email:added").data(email).meta(auditor),
|
||||
event.type("account:phone:added").data(phone).meta(auditor),
|
||||
event.type("account:role:added").data(z.string()).meta(auditor),
|
||||
];
|
||||
|
||||
export type AccountCreatedData = z.infer<typeof created>;
|
||||
7
api/libraries/event-store/events/auditor.ts
Normal file
7
api/libraries/event-store/events/auditor.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import z from "zod";
|
||||
|
||||
export const auditor = z.object({
|
||||
accountId: z.string(),
|
||||
});
|
||||
|
||||
export type Auditor = z.infer<typeof auditor>;
|
||||
30
api/libraries/event-store/events/code.ts
Normal file
30
api/libraries/event-store/events/code.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { event } from "@valkyr/event-store";
|
||||
import z from "zod";
|
||||
|
||||
const identity = z.discriminatedUnion([
|
||||
z.object({
|
||||
type: z.literal("admin"),
|
||||
accountId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("consultant"),
|
||||
accountId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("organization"),
|
||||
organizationId: z.string(),
|
||||
accountId: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export default [
|
||||
event.type("code:created").data(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
identity,
|
||||
}),
|
||||
),
|
||||
event.type("code:claimed"),
|
||||
];
|
||||
|
||||
export type CodeIdentity = z.infer<typeof identity>;
|
||||
11
api/libraries/event-store/events/mod.ts
Normal file
11
api/libraries/event-store/events/mod.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { EventFactory } from "@valkyr/event-store";
|
||||
|
||||
import account from "./account.ts";
|
||||
import code from "./code.ts";
|
||||
import organization from "./organization.ts";
|
||||
import role from "./role.ts";
|
||||
import strategy from "./strategy.ts";
|
||||
|
||||
export const events = new EventFactory([...account, ...code, ...organization, ...role, ...strategy]);
|
||||
|
||||
export type EventStoreFactory = typeof events;
|
||||
11
api/libraries/event-store/events/organization.ts
Normal file
11
api/libraries/event-store/events/organization.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { event } from "@valkyr/event-store";
|
||||
import z from "zod";
|
||||
|
||||
import { auditor } from "./auditor.ts";
|
||||
|
||||
export default [
|
||||
event
|
||||
.type("organization:created")
|
||||
.data(z.object({ name: z.string() }))
|
||||
.meta(auditor),
|
||||
];
|
||||
37
api/libraries/event-store/events/role.ts
Normal file
37
api/libraries/event-store/events/role.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { event } from "@valkyr/event-store";
|
||||
import z from "zod";
|
||||
|
||||
import { auditor } from "./auditor.ts";
|
||||
|
||||
const created = z.object({
|
||||
name: z.string(),
|
||||
permissions: z.array(
|
||||
z.object({
|
||||
resource: z.string(),
|
||||
actions: z.array(z.string()),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const operation = z.discriminatedUnion([
|
||||
z.object({
|
||||
type: z.literal("grant"),
|
||||
resource: z.string(),
|
||||
action: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("deny"),
|
||||
resource: z.string(),
|
||||
action: z.string().optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export default [
|
||||
event.type("role:created").data(created).meta(auditor),
|
||||
event.type("role:name-set").data(z.string()).meta(auditor),
|
||||
event.type("role:permissions-set").data(z.array(operation)).meta(auditor),
|
||||
];
|
||||
|
||||
export type RoleCreatedData = z.infer<typeof created>;
|
||||
|
||||
export type RolePermissionOperation = z.infer<typeof operation>;
|
||||
13
api/libraries/event-store/events/strategy.ts
Normal file
13
api/libraries/event-store/events/strategy.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { event } from "@valkyr/event-store";
|
||||
import z from "zod";
|
||||
|
||||
import { auditor } from "./auditor.ts";
|
||||
|
||||
export default [
|
||||
event.type("strategy:email:added").data(z.string()).meta(auditor),
|
||||
event.type("strategy:passkey:added").meta(auditor),
|
||||
event
|
||||
.type("strategy:password:added")
|
||||
.data(z.object({ alias: z.string(), password: z.string() }))
|
||||
.meta(auditor),
|
||||
];
|
||||
2
api/libraries/event-store/mod.ts
Normal file
2
api/libraries/event-store/mod.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./event-store.ts";
|
||||
export * from "./projector.ts";
|
||||
5
api/libraries/event-store/projector.ts
Normal file
5
api/libraries/event-store/projector.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Projector } from "@valkyr/event-store";
|
||||
|
||||
import { EventStoreFactory } from "./events/mod.ts";
|
||||
|
||||
export const projector = new Projector<EventStoreFactory>();
|
||||
48
api/libraries/logger/chalk.ts
Normal file
48
api/libraries/logger/chalk.ts
Normal 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;
|
||||
};
|
||||
28
api/libraries/logger/color/hex.ts
Normal file
28
api/libraries/logger/color/hex.ts
Normal 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}`;
|
||||
24
api/libraries/logger/color/rgb.ts
Normal file
24
api/libraries/logger/color/rgb.ts
Normal 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 (16–231)
|
||||
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 };
|
||||
76
api/libraries/logger/color/styles.ts
Normal file
76
api/libraries/logger/color/styles.ts
Normal 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;
|
||||
3
api/libraries/logger/color/utilities.ts
Normal file
3
api/libraries/logger/color/utilities.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function toEscapeSequence(value: string | number): `\x1b[${string}m` {
|
||||
return `\x1b[${value}m`;
|
||||
}
|
||||
5
api/libraries/logger/config.ts
Normal file
5
api/libraries/logger/config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { getArgsVariable } from "~libraries/config/mod.ts";
|
||||
|
||||
export const config = {
|
||||
level: getArgsVariable("LOG_LEVEL", "info"),
|
||||
};
|
||||
19
api/libraries/logger/format/event-store.ts
Normal file
19
api/libraries/logger/format/event-store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
18
api/libraries/logger/format/server.ts
Normal file
18
api/libraries/logger/format/server.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ServerError } from "@spec/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
api/libraries/logger/level.ts
Normal file
8
api/libraries/logger/level.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const logLevel = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warning: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
export type Level = "debug" | "error" | "warning" | "info";
|
||||
95
api/libraries/logger/logger.ts
Normal file
95
api/libraries/logger/logger.ts
Normal 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
api/libraries/logger/mod.ts
Normal file
7
api/libraries/logger/mod.ts
Normal 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],
|
||||
});
|
||||
20
api/libraries/logger/stack.ts
Normal file
20
api/libraries/logger/stack.ts
Normal 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();
|
||||
}
|
||||
11
api/libraries/read-store/.tasks/bootstrap.ts
Normal file
11
api/libraries/read-store/.tasks/bootstrap.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
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],
|
||||
},
|
||||
]);
|
||||
6
api/libraries/read-store/account/methods.ts
Normal file
6
api/libraries/read-store/account/methods.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { db, takeOne } from "../database.ts";
|
||||
import { type AccountSchema, fromAccountDriver } from "./schema.ts";
|
||||
|
||||
export async function getAccountById(id: string): Promise<AccountSchema | undefined> {
|
||||
return db.collection("accounts").find({ id }).toArray().then(fromAccountDriver).then(takeOne);
|
||||
}
|
||||
36
api/libraries/read-store/account/schema.ts
Normal file
36
api/libraries/read-store/account/schema.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const account = z.object({
|
||||
id: z.uuid(),
|
||||
name: z.object({
|
||||
given: z.string(),
|
||||
family: z.string(),
|
||||
}),
|
||||
email: z.email(),
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Parsers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const select = account;
|
||||
const insert = account;
|
||||
|
||||
export function toAccountDriver(documents: unknown): AccountInsert {
|
||||
return insert.parse(documents);
|
||||
}
|
||||
|
||||
export function fromAccountDriver(documents: unknown[]): AccountSchema[] {
|
||||
return documents.map((document) => select.parse(document));
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type AccountSchema = z.infer<typeof select>;
|
||||
export type AccountInsert = z.infer<typeof insert>;
|
||||
12
api/libraries/read-store/database.ts
Normal file
12
api/libraries/read-store/database.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { config } from "~config";
|
||||
import { getDatabaseAccessor } from "~libraries/database/accessor.ts";
|
||||
|
||||
import { AccountInsert } from "./account/schema.ts";
|
||||
|
||||
export const db = getDatabaseAccessor<{
|
||||
accounts: AccountInsert;
|
||||
}>(`${config.name}:read-store`);
|
||||
|
||||
export function takeOne<TDocument>(documents: TDocument[]): TDocument | undefined {
|
||||
return documents[0];
|
||||
}
|
||||
3
api/libraries/read-store/mod.ts
Normal file
3
api/libraries/read-store/mod.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./account/methods.ts";
|
||||
export * from "./account/schema.ts";
|
||||
export * from "./database.ts";
|
||||
415
api/libraries/server/api.ts
Normal file
415
api/libraries/server/api.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
import {
|
||||
BadRequestError,
|
||||
ForbiddenError,
|
||||
InternalServerError,
|
||||
NotFoundError,
|
||||
NotImplementedError,
|
||||
Route,
|
||||
RouteMethod,
|
||||
ServerError,
|
||||
type ServerErrorResponse,
|
||||
UnauthorizedError,
|
||||
ZodValidationError,
|
||||
} from "@spec/relay";
|
||||
import { treeifyError } from "zod";
|
||||
|
||||
import { logger } from "~libraries/logger/mod.ts";
|
||||
|
||||
import { getRequestContext } from "./context.ts";
|
||||
import { req } from "./request.ts";
|
||||
|
||||
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);
|
||||
}
|
||||
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" && req.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ### 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(new ZodValidationError("Invalid request params", treeifyError(result.error)), 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(new ZodValidationError("Invalid request query", treeifyError(result.error)), 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(new ZodValidationError("Invalid request body", treeifyError(result.error)), 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(getRequestContext(request));
|
||||
|
||||
// ### 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: {
|
||||
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;
|
||||
};
|
||||
16
api/libraries/server/context.ts
Normal file
16
api/libraries/server/context.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { RouteContext } from "@spec/relay";
|
||||
|
||||
export function getRequestContext(request: Request): RouteContext {
|
||||
return {
|
||||
request,
|
||||
};
|
||||
}
|
||||
|
||||
declare module "@spec/relay" {
|
||||
interface RouteContext {
|
||||
/**
|
||||
* Current request instance being handled.
|
||||
*/
|
||||
request: Request;
|
||||
}
|
||||
}
|
||||
5
api/libraries/server/mod.ts
Normal file
5
api/libraries/server/mod.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./api.ts";
|
||||
export * from "./context.ts";
|
||||
export * from "./modules.ts";
|
||||
export * from "./request.ts";
|
||||
export * from "./storage.ts";
|
||||
40
api/libraries/server/modules.ts
Normal file
40
api/libraries/server/modules.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Route } from "@spec/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`, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
57
api/libraries/server/request.ts
Normal file
57
api/libraries/server/request.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { asyncLocalStorage } from "./storage.ts";
|
||||
|
||||
export const req = {
|
||||
get store() {
|
||||
const store = asyncLocalStorage.getStore();
|
||||
if (store === undefined) {
|
||||
throw new Error("Request > AsyncLocalStorage not defined.");
|
||||
}
|
||||
return store;
|
||||
},
|
||||
|
||||
get socket() {
|
||||
return this.store.socket;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get store that is potentially undefined.
|
||||
* Typically used when utility functions might run in and out of request scope.
|
||||
*/
|
||||
get unsafeStore() {
|
||||
return asyncLocalStorage.getStore();
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if the request is authenticated.
|
||||
*/
|
||||
get isAuthenticated() {
|
||||
return this.session !== undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current session.
|
||||
*/
|
||||
get session() {
|
||||
return this.store.session;
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets the meta information stored in the request.
|
||||
*/
|
||||
get info() {
|
||||
return this.store.info;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sends a JSON-RPC 2.0 notification to the request if sent through a
|
||||
* WebSocket connection.
|
||||
*
|
||||
* @param method - Method to send notification to.
|
||||
* @param params - Params to pass to the method.
|
||||
*/
|
||||
notify(method: string, params: any): void {
|
||||
this.socket?.send(JSON.stringify({ jsonrpc: "2.0", method, params }));
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type ReqContext = typeof req;
|
||||
16
api/libraries/server/storage.ts
Normal file
16
api/libraries/server/storage.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
import type { Session } from "~libraries/auth/mod.ts";
|
||||
|
||||
export const asyncLocalStorage = new AsyncLocalStorage<{
|
||||
session?: Session;
|
||||
info: {
|
||||
method: string;
|
||||
start: number;
|
||||
end?: number;
|
||||
};
|
||||
socket?: WebSocket;
|
||||
response: {
|
||||
headers: Headers;
|
||||
};
|
||||
}>();
|
||||
81
api/libraries/socket/channels.ts
Normal file
81
api/libraries/socket/channels.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { Params } from "@valkyr/json-rpc";
|
||||
|
||||
import { Sockets } from "./sockets.ts";
|
||||
|
||||
export class Channels {
|
||||
readonly #channels = new Map<string, Sockets>();
|
||||
|
||||
/**
|
||||
* Add a new channel.
|
||||
*
|
||||
* @param channel
|
||||
*/
|
||||
add(channel: string): this {
|
||||
this.#channels.set(channel, new Sockets());
|
||||
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 Sockets().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();
|
||||
1
api/libraries/socket/mod.ts
Normal file
1
api/libraries/socket/mod.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Sockets } from "./sockets.ts";
|
||||
49
api/libraries/socket/sockets.ts
Normal file
49
api/libraries/socket/sockets.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Params } from "@valkyr/json-rpc";
|
||||
|
||||
export class Sockets {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export const sockets = new Sockets();
|
||||
60
api/libraries/socket/upgrade.ts
Normal file
60
api/libraries/socket/upgrade.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { toJsonRpc } from "@valkyr/json-rpc";
|
||||
|
||||
import { Session } from "~libraries/auth/mod.ts";
|
||||
import { logger } from "~libraries/logger/mod.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 body = toJsonRpc(event.data);
|
||||
|
||||
logger.prefix("Socket").info(body);
|
||||
|
||||
asyncLocalStorage.run(
|
||||
{
|
||||
session,
|
||||
info: {
|
||||
method: body.method!,
|
||||
start: Date.now(),
|
||||
},
|
||||
socket,
|
||||
response: {
|
||||
headers: new Headers(),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
api
|
||||
.handleCommand(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;
|
||||
}
|
||||
4
api/libraries/testing/config.ts
Normal file
4
api/libraries/testing/config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const config = {
|
||||
mongodb: "mongo:8.0.3",
|
||||
postgres: "postgres:17",
|
||||
};
|
||||
154
api/libraries/testing/containers/api-container.ts
Normal file
154
api/libraries/testing/containers/api-container.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { getAvailablePort } from "@std/net";
|
||||
import cookie from "cookie";
|
||||
|
||||
import { auth, Session } from "~libraries/auth/mod.ts";
|
||||
import { Code } from "~libraries/code/aggregates/code.ts";
|
||||
import { handler } from "~libraries/server/handler.ts";
|
||||
|
||||
import { Api, QueryMethod } from "../.generated/api.ts";
|
||||
|
||||
export class ApiTestContainer {
|
||||
#server?: Deno.HttpServer;
|
||||
#client?: Api;
|
||||
#cookie?: string;
|
||||
#session?: Session;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Accessors
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
get accountId(): string | undefined {
|
||||
if (this.#session?.valid === true) {
|
||||
return this.#session.accountId;
|
||||
}
|
||||
}
|
||||
|
||||
get client() {
|
||||
if (this.#client === undefined) {
|
||||
throw new Error("ApiContainer > .start() has not been executed.");
|
||||
}
|
||||
return this.#client;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Lifecycle
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async start(): Promise<this> {
|
||||
const port = await getAvailablePort();
|
||||
this.#server = await Deno.serve({ port, hostname: "127.0.0.1" }, handler);
|
||||
this.#client = makeApiClient(port, {
|
||||
onBeforeRequest: (headers: Headers) => {
|
||||
if (this.#cookie !== undefined) {
|
||||
headers.set("cookie", this.#cookie);
|
||||
}
|
||||
},
|
||||
onAfterResponse: (response) => {
|
||||
const cookie = response.headers.get("set-cookie");
|
||||
if (cookie !== null) {
|
||||
this.#cookie = cookie;
|
||||
}
|
||||
},
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await this.#server?.shutdown();
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Utilities
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async authorize(accountId: string): Promise<void> {
|
||||
const code = await Code.create({ identity: { type: "admin", accountId } }).save();
|
||||
await this.client.auth.code(accountId, code.id, code.value, {});
|
||||
this.#session = await this.getSession();
|
||||
}
|
||||
|
||||
async getSession(): Promise<Session | undefined> {
|
||||
const token = cookie.parse(this.#cookie ?? "").token;
|
||||
if (token !== undefined) {
|
||||
const session = await auth.resolve(token);
|
||||
if (session.valid === true) {
|
||||
return session;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unauthorize(): void {
|
||||
this.#cookie = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function makeApiClient(
|
||||
port: number,
|
||||
{
|
||||
onBeforeRequest,
|
||||
onAfterResponse,
|
||||
}: {
|
||||
onBeforeRequest: (headers: Headers) => void;
|
||||
onAfterResponse: (response: Response) => void;
|
||||
},
|
||||
): Api {
|
||||
return new Api({
|
||||
async command(payload) {
|
||||
const headers = new Headers();
|
||||
onBeforeRequest(headers);
|
||||
headers.set("content-type", "application/json");
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/v1/command`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (response.status >= 300) {
|
||||
console.error(
|
||||
`Command '${payload.method}' responded with error status '${response.status} ${response.statusText}'.`,
|
||||
);
|
||||
}
|
||||
if (response.headers.get("content-type")?.includes("json") === true) {
|
||||
return JSON.parse(text);
|
||||
}
|
||||
},
|
||||
async query(method: QueryMethod, path: string, query: Record<string, unknown>, body: any = {}) {
|
||||
const headers = new Headers();
|
||||
onBeforeRequest(headers);
|
||||
if (method !== "GET") {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}${getSearchQuery(query)}`, {
|
||||
method,
|
||||
headers,
|
||||
body: method === "GET" ? undefined : JSON.stringify(body),
|
||||
});
|
||||
onAfterResponse(response);
|
||||
const text = await response.text();
|
||||
if (response.status >= 300) {
|
||||
console.error(`Query '${path}' responded with error status '${response.status} ${response.statusText}'.`);
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
if (response.headers.get("content-type")?.includes("json") === true) {
|
||||
return JSON.parse(text);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getSearchQuery(query: Record<string, unknown>): string {
|
||||
const search: string[] = [];
|
||||
for (const key in query) {
|
||||
search.push(`${key}=${query[key]}`);
|
||||
}
|
||||
if (search.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return `?${search.join("&")}`;
|
||||
}
|
||||
41
api/libraries/testing/containers/database-container.ts
Normal file
41
api/libraries/testing/containers/database-container.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { MongoTestContainer } from "@valkyr/testcontainers/mongodb";
|
||||
|
||||
import { container } from "~database/container.ts";
|
||||
import { logger } from "~libraries/logger/mod.ts";
|
||||
import { bootstrap } from "~libraries/utilities/bootstrap.ts";
|
||||
import { API_DOMAINS_DIR, API_PACKAGES_DIR } from "~paths";
|
||||
|
||||
export class DatabaseTestContainer {
|
||||
constructor(readonly mongo: MongoTestContainer) {
|
||||
container.set("client", mongo.client);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Lifecycle
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async start(): Promise<this> {
|
||||
logger.prefix("Database").info("DatabaseTestContainer Started");
|
||||
|
||||
await bootstrap(API_DOMAINS_DIR);
|
||||
await bootstrap(API_PACKAGES_DIR);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async truncate() {
|
||||
const promises: Promise<any>[] = [];
|
||||
for (const dbName of ["balto:auth", "balto:code", "balto:consultant", "balto:task"]) {
|
||||
const db = this.mongo.client.db(dbName);
|
||||
const collections = await db.listCollections().toArray();
|
||||
promises.push(...collections.map(({ name }) => db.collection(name).deleteMany({})));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
async stop() {
|
||||
logger.prefix("Database").info("DatabaseTestContainer stopped");
|
||||
}
|
||||
}
|
||||
178
api/libraries/testing/containers/test-container.ts
Normal file
178
api/libraries/testing/containers/test-container.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { MongoTestContainer } from "@valkyr/testcontainers/mongodb";
|
||||
|
||||
import { config } from "../config.ts";
|
||||
import { ApiTestContainer } from "./api-container.ts";
|
||||
import { DatabaseTestContainer } from "./database-container.ts";
|
||||
|
||||
export class TestContainer {
|
||||
readonly id = crypto.randomUUID();
|
||||
|
||||
// ### Enablers
|
||||
// A map of services to enable when the TestContainer is started. These toggles
|
||||
// must be toggled before the container is started.
|
||||
|
||||
#with: With = {
|
||||
mongodb: false,
|
||||
database: false,
|
||||
api: false,
|
||||
};
|
||||
|
||||
// ### Needs
|
||||
|
||||
#needs: Needs = {
|
||||
mongodb: [],
|
||||
database: ["mongodb"],
|
||||
api: ["mongodb", "database"],
|
||||
};
|
||||
|
||||
// ### Services
|
||||
// Any services that has been enabled will be running under the following
|
||||
// assignments. Make sure to .stop any running services to avoid shutdown
|
||||
// leaks.
|
||||
|
||||
#mongodb?: MongoTestContainer;
|
||||
#database?: DatabaseTestContainer;
|
||||
#api?: ApiTestContainer;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Accessors
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
get accountId() {
|
||||
if (this.#api === undefined) {
|
||||
throw new Error("TestContainer > .withApi() must be called before starting the TestContainer.");
|
||||
}
|
||||
return this.#api.accountId;
|
||||
}
|
||||
|
||||
get mongodb(): MongoTestContainer {
|
||||
if (this.#mongodb === undefined) {
|
||||
throw new Error("TestContainer > .withMongo() must be called before starting the TestContainer.");
|
||||
}
|
||||
return this.#mongodb;
|
||||
}
|
||||
|
||||
get database(): DatabaseTestContainer {
|
||||
if (this.#database === undefined) {
|
||||
throw new Error("TestContainer > .withDatabase() must be called before starting the TestContainer.");
|
||||
}
|
||||
return this.#database;
|
||||
}
|
||||
|
||||
get api() {
|
||||
if (this.#api === undefined) {
|
||||
throw new Error("TestContainer > .withApi() must be called before starting the TestContainer.");
|
||||
}
|
||||
return this.#api.client;
|
||||
}
|
||||
|
||||
get authorize() {
|
||||
if (this.#api === undefined) {
|
||||
throw new Error("TestContainer > .withApi() must be called before starting the TestContainer.");
|
||||
}
|
||||
return this.#api.authorize.bind(this.#api);
|
||||
}
|
||||
|
||||
get unauthorize() {
|
||||
if (this.#api === undefined) {
|
||||
throw new Error("TestContainer > .withApi() must be called before starting the TestContainer.");
|
||||
}
|
||||
return this.#api.unauthorize.bind(this.#api);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Builder
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
withMongo(): this {
|
||||
this.#with.mongodb = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
withDatabase(): this {
|
||||
this.#with.database = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
withApi(): this {
|
||||
this.#with.api = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Lifecycle
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async start(): Promise<this> {
|
||||
const promises: Promise<void>[] = [];
|
||||
if (this.#isNeeded("mongodb") === true) {
|
||||
promises.push(
|
||||
(async () => {
|
||||
this.#mongodb = await MongoTestContainer.start(config.mongodb);
|
||||
if (this.#isNeeded("database") === true) {
|
||||
this.#database = await new DatabaseTestContainer(this.mongodb).start();
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
if (this.#isNeeded("api") === true) {
|
||||
promises.push(
|
||||
(async () => {
|
||||
this.#api = await new ApiTestContainer().start();
|
||||
})(),
|
||||
);
|
||||
}
|
||||
await Promise.all(promises);
|
||||
return this;
|
||||
}
|
||||
|
||||
async stop(): Promise<this> {
|
||||
await this.#api?.stop();
|
||||
await this.#database?.stop();
|
||||
await this.#mongodb?.stop();
|
||||
|
||||
this.#api = undefined;
|
||||
this.#database = undefined;
|
||||
this.#mongodb = undefined;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Helpers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
#isNeeded(target: keyof With): boolean {
|
||||
if (this.#with[target] !== false) {
|
||||
return true;
|
||||
}
|
||||
for (const key in this.#needs) {
|
||||
if (this.#with[key as keyof With] !== false && this.#needs[key as keyof With].includes(target) === true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type Needs = Record<keyof With, (keyof With)[]>;
|
||||
|
||||
type With = {
|
||||
mongodb: boolean;
|
||||
database: boolean;
|
||||
api: boolean;
|
||||
};
|
||||
24
api/libraries/testing/describe.ts
Normal file
24
api/libraries/testing/describe.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as assertSuite from "@std/assert";
|
||||
import * as bddSuite from "@std/testing/bdd";
|
||||
|
||||
import type { TestContainer } from "~libraries/testing/containers/test-container.ts";
|
||||
|
||||
import { authorize } from "./utilities/account.ts";
|
||||
|
||||
export function describe(name: string, runner: TestRunner): (container: TestContainer) => void {
|
||||
return (container: TestContainer) =>
|
||||
bddSuite.describe(name, () => runner(container, bddSuite, assertSuite, { authorize: authorize(container) }));
|
||||
}
|
||||
|
||||
export type TestRunner = (
|
||||
container: TestContainer,
|
||||
bdd: {
|
||||
[key in keyof typeof bddSuite]: (typeof bddSuite)[key];
|
||||
},
|
||||
assert: {
|
||||
[key in keyof typeof assertSuite]: (typeof assertSuite)[key];
|
||||
},
|
||||
utils: {
|
||||
authorize: ReturnType<typeof authorize>;
|
||||
},
|
||||
) => void;
|
||||
68
api/libraries/testing/utilities/account.ts
Normal file
68
api/libraries/testing/utilities/account.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { EventData } from "@valkyr/event-store";
|
||||
|
||||
import { AccountCreated, AccountEmailAdded } from "~libraries/auth/.generated/events.ts";
|
||||
import { Account } from "~libraries/auth/aggregates/account.ts";
|
||||
import { Role } from "~libraries/auth/aggregates/role.ts";
|
||||
import type { TestContainer } from "~libraries/testing/containers/test-container.ts";
|
||||
|
||||
type AuthorizationOptions = {
|
||||
name?: { family?: string; given?: string };
|
||||
email?: Partial<EventData<AccountEmailAdded>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return a function which provides the ability to create a new account which
|
||||
* is authorized and ready to use for testing authorized requests.
|
||||
*
|
||||
* @param container - Container to authorize against.
|
||||
*/
|
||||
export function authorize(container: TestContainer): AuthorizeFn {
|
||||
return async (data: EventData<AccountCreated>, { name = {}, email = {} }: AuthorizationOptions = {}) => {
|
||||
const role = await makeRole(data.type).save();
|
||||
const account = await Account.create(data, "test")
|
||||
.addName(name?.family ?? "Doe", name?.given ?? "John", "test")
|
||||
.addEmail({ value: "john.doe@fixture.none", type: "work", primary: true, verified: true, ...email }, "test")
|
||||
.addRole(role.id, "test")
|
||||
.save();
|
||||
await container.authorize(account.id);
|
||||
return account;
|
||||
};
|
||||
}
|
||||
|
||||
function makeRole(type: "admin" | "consultant" | "organization"): Role {
|
||||
switch (type) {
|
||||
case "admin": {
|
||||
return Role.create(
|
||||
{
|
||||
name: "Admin",
|
||||
permissions: [
|
||||
{ resource: "admin", actions: ["create", "update", "delete"] },
|
||||
{ resource: "consultant", actions: ["create", "update", "delete"] },
|
||||
{ resource: "organization", actions: ["create", "update", "delete"] },
|
||||
],
|
||||
},
|
||||
"test",
|
||||
);
|
||||
}
|
||||
case "consultant": {
|
||||
return Role.create(
|
||||
{
|
||||
name: "Consultant",
|
||||
permissions: [{ resource: "consultant", actions: ["create", "update", "delete"] }],
|
||||
},
|
||||
"test",
|
||||
);
|
||||
}
|
||||
case "organization": {
|
||||
return Role.create(
|
||||
{
|
||||
name: "Organization",
|
||||
permissions: [{ resource: "organization", actions: ["create", "update", "delete"] }],
|
||||
},
|
||||
"test",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type AuthorizeFn = (data: EventData<AccountCreated>, optional?: AuthorizationOptions) => Promise<Account>;
|
||||
62
api/libraries/utilities/dedent.ts
Normal file
62
api/libraries/utilities/dedent.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Removes excess indentation caused by using multiline template strings.
|
||||
*
|
||||
* Ported from `dedent-js` solution.
|
||||
*
|
||||
* @see https://github.com/MartinKolarik/dedent-js
|
||||
*
|
||||
* @param templateStrings - Template strings to dedent.
|
||||
*
|
||||
* @example
|
||||
* {
|
||||
* nested: {
|
||||
* examples: [
|
||||
* dedent(`
|
||||
* I am 8 spaces off from the beginning of this file.
|
||||
* But I will be 2 spaces based on the trimmed distance
|
||||
* of the first line.
|
||||
* `),
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export function dedent(templateStrings: TemplateStringsArray | string, ...values: any[]) {
|
||||
const matches = [];
|
||||
const strings = typeof templateStrings === "string" ? [templateStrings] : templateStrings.slice();
|
||||
|
||||
// Remove trailing whitespace.
|
||||
|
||||
strings[strings.length - 1] = strings[strings.length - 1].replace(/\r?\n([\t ]*)$/, "");
|
||||
|
||||
// Find all line breaks to determine the highest common indentation level.
|
||||
|
||||
for (let i = 0; i < strings.length; i++) {
|
||||
const match = strings[i].match(/\n[\t ]+/g);
|
||||
if (match) {
|
||||
matches.push(...match);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the common indentation from all strings.
|
||||
|
||||
if (matches.length) {
|
||||
const size = Math.min(...matches.map((value) => value.length - 1));
|
||||
const pattern = new RegExp(`\n[\t ]{${size}}`, "g");
|
||||
for (let i = 0; i < strings.length; i++) {
|
||||
strings[i] = strings[i].replace(pattern, "\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove leading whitespace.
|
||||
|
||||
strings[0] = strings[0].replace(/^\r?\n/, "");
|
||||
|
||||
// Perform interpolation.
|
||||
|
||||
let string = strings[0];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
string += values[i] + strings[i + 1];
|
||||
}
|
||||
|
||||
return string;
|
||||
}
|
||||
41
api/libraries/utilities/generate.ts
Normal file
41
api/libraries/utilities/generate.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Traverse path and look for a `generate.ts` file in each folder found under
|
||||
* the given path. If a `generate.ts` file is found it is imported so its content
|
||||
* is executed.
|
||||
*
|
||||
* @param path - Path to resolve `generate.ts` files.
|
||||
* @param filter - Which folders found under the given path to ignore.
|
||||
*/
|
||||
export async function generate(path: string, filter: string[] = []): Promise<void> {
|
||||
const generate: string[] = [];
|
||||
for await (const entry of Deno.readDir(path)) {
|
||||
if (entry.isDirectory === true) {
|
||||
const moduleName = path.split("/").pop();
|
||||
if (moduleName === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (filter.length > 0 && filter.includes(moduleName) === false) {
|
||||
continue;
|
||||
}
|
||||
const filePath = `${path}/${entry.name}/.tasks/generate.ts`;
|
||||
if (await hasFile(filePath)) {
|
||||
generate.push(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const filePath of generate) {
|
||||
await import(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
async function hasFile(filePath: string) {
|
||||
try {
|
||||
await Deno.lstat(filePath);
|
||||
} catch (err) {
|
||||
if (!(err instanceof Deno.errors.NotFound)) {
|
||||
throw err;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
5
api/modules/auth/routes/authenticate.ts
Normal file
5
api/modules/auth/routes/authenticate.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { authenticate } from "@spec/modules/auth/routes/authenticate.ts";
|
||||
|
||||
export default authenticate.access("public").handle(async ({ body }) => {
|
||||
console.log({ body });
|
||||
});
|
||||
23
api/package.json
Normal file
23
api/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "deno --allow-all --watch-hmr=routes/ server.ts",
|
||||
"migrate": "deno run --allow-all .tasks/migrate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@felix/bcrypt": "npm:@jsr/felix__bcrypt@1",
|
||||
"@spec/modules": "workspace:*",
|
||||
"@spec/relay": "workspace:*",
|
||||
"@spec/shared": "workspace:*",
|
||||
"@std/cli": "npm:@jsr/std__cli@1",
|
||||
"@std/dotenv": "npm:@jsr/std__dotenv@0.225",
|
||||
"@std/fs": "npm:@jsr/std__fs@1",
|
||||
"@std/path": "npm:@jsr/std__path@1",
|
||||
"@valkyr/auth": "npm:@jsr/valkyr__auth@2",
|
||||
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.0-beta.5",
|
||||
"@valkyr/inverse": "npm:@jsr/valkyr__inverse@1",
|
||||
"cookie": "1",
|
||||
"mongodb": "6",
|
||||
"zod": "4"
|
||||
}
|
||||
}
|
||||
93
api/server.ts
Normal file
93
api/server.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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 { asyncLocalStorage } from "~libraries/server/mod.ts";
|
||||
import { Api, resolveRoutes } from "~libraries/server/mod.ts";
|
||||
|
||||
import { config } from "./config.ts";
|
||||
|
||||
const MODULES_DIR = resolve(import.meta.dirname!, "modules");
|
||||
|
||||
const log = logger.prefix("Server");
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Bootstrap
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
await import("./tasks/bootstrap.ts");
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Service
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const api = new Api(await resolveRoutes(MODULES_DIR));
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Server
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
Deno.serve(
|
||||
{
|
||||
port: config.port,
|
||||
hostname: config.host,
|
||||
onListen({ port, hostname }) {
|
||||
logger.prefix("Server").info(`Listening at http://${hostname}:${port}`);
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// ### Session
|
||||
|
||||
let session: Session | undefined;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ### Headers
|
||||
// Set the default headers.
|
||||
|
||||
const headers = new Headers();
|
||||
|
||||
// ### Handle
|
||||
|
||||
const ts = performance.now();
|
||||
|
||||
return asyncLocalStorage.run(
|
||||
{
|
||||
session,
|
||||
info: {
|
||||
method: request.url,
|
||||
start: Date.now(),
|
||||
},
|
||||
response: {
|
||||
headers,
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
return api.fetch(request).finally(() => {
|
||||
log.info(`${request.method} ${url.pathname} [${((performance.now() - ts) / 1000).toLocaleString()} seconds]`);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
68
api/tasks/bootstrap.ts
Normal file
68
api/tasks/bootstrap.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { logger } from "~libraries/logger/mod.ts";
|
||||
|
||||
const LIBRARIES_DIR = resolve(import.meta.dirname!, "..", "libraries");
|
||||
|
||||
const log = logger.prefix("Bootstrap");
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Database
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
await import("~libraries/database/tasks/bootstrap.ts");
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Packages
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
await bootstrap(LIBRARIES_DIR);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Helpers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Traverse path and look for a `bootstrap.ts` file in each folder found under
|
||||
* the given path. If a `boostrap.ts` file is found it is imported so its content
|
||||
* is executed.
|
||||
*
|
||||
* @param path - Path to resolve `bootstrap.ts` files.
|
||||
*/
|
||||
export async function bootstrap(path: string): Promise<void> {
|
||||
const bootstrap: { name: string; path: string }[] = [];
|
||||
for await (const entry of Deno.readDir(path)) {
|
||||
if (entry.isDirectory === true) {
|
||||
const moduleName = path.split("/").pop();
|
||||
if (moduleName === undefined) {
|
||||
continue;
|
||||
}
|
||||
const filePath = `${path}/${entry.name}/.tasks/bootstrap.ts`;
|
||||
if (await hasFile(filePath)) {
|
||||
bootstrap.push({ name: entry.name, path: filePath });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const entry of bootstrap) {
|
||||
log.info(entry.name);
|
||||
await import(entry.path);
|
||||
}
|
||||
}
|
||||
|
||||
async function hasFile(filePath: string) {
|
||||
try {
|
||||
await Deno.lstat(filePath);
|
||||
} catch (err) {
|
||||
if (!(err instanceof Deno.errors.NotFound)) {
|
||||
throw err;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
66
api/tasks/migrate.ts
Normal file
66
api/tasks/migrate.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { resolve } from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
import { exists } from "@std/fs";
|
||||
|
||||
import { config } from "~libraries/database/config.ts";
|
||||
import { getMongoClient } from "~libraries/database/connection.ts";
|
||||
import { container } from "~libraries/database/container.ts";
|
||||
import { logger } from "~libraries/logger/mod.ts";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Dependencies
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const client = getMongoClient(config.mongo);
|
||||
|
||||
container.set("client", client);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Migrate
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const db = client.db("api:migrations");
|
||||
const collection = db.collection<MigrationDocument>("migrations");
|
||||
|
||||
const { default: journal } = await import(resolve(import.meta.dirname!, "migrations", "meta", "_journal.json"), {
|
||||
with: { type: "json" },
|
||||
});
|
||||
|
||||
const migrations =
|
||||
(await collection.findOne({ name: journal.name })) ?? ({ name: journal.name, entries: [] } as MigrationDocument);
|
||||
|
||||
for (const entry of journal.entries) {
|
||||
const migrationFileName = `${String(entry.idx).padStart(4, "0")}_${entry.name}.ts`;
|
||||
if (migrations.entries.includes(migrationFileName)) {
|
||||
continue;
|
||||
}
|
||||
const migrationPath = resolve(import.meta.dirname!, "migrations", migrationFileName);
|
||||
if (await exists(migrationPath)) {
|
||||
await import(migrationPath);
|
||||
await collection.updateOne(
|
||||
{
|
||||
name: journal.name,
|
||||
},
|
||||
{
|
||||
$set: { name: journal.name },
|
||||
$push: { entries: migrationFileName }, // Assuming 'entries' is an array
|
||||
},
|
||||
{
|
||||
upsert: true,
|
||||
},
|
||||
);
|
||||
logger.info(`Migrated ${migrationPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
type MigrationDocument = {
|
||||
name: string;
|
||||
entries: string[];
|
||||
};
|
||||
|
||||
process.exit(0);
|
||||
4
api/tasks/migrations/meta/_journal.json
Normal file
4
api/tasks/migrations/meta/_journal.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "api",
|
||||
"entries": []
|
||||
}
|
||||
Reference in New Issue
Block a user