Template
1
0

feat: react zitadel

This commit is contained in:
2025-11-23 22:56:58 +01:00
parent 2b462993cc
commit fe4220ede0
139 changed files with 3389 additions and 2771 deletions

View File

@@ -0,0 +1,4 @@
export const account = {
create: (await import("./routes/create/spec.ts")).default,
get: (await import("./routes/get/spec.ts")).default,
};

View File

@@ -0,0 +1,14 @@
{
"name": "@module/account",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./server": "./server.ts",
"./client": "./client.ts"
},
"dependencies": {
"@platform/relay": "workspace:*",
"zod": "4.1.12"
}
}

View File

@@ -0,0 +1,5 @@
import route from "./spec.ts";
export default route.handle(async ({ body }) => {
console.log(body);
});

View File

@@ -0,0 +1,13 @@
import { route } from "@platform/relay";
import z from "zod";
export default route.post("/api/v1/account").body(
z.strictObject({
tenantId: z.uuid().describe("Tenant identifier the account belongs to"),
userId: z.uuid().describe("User identifier the account belongs to"),
account: z.strictObject({
type: z.string().describe("Type of account being created"),
number: z.number().describe("Unique account identifier to create for the account"),
}),
}),
);

View File

@@ -0,0 +1,5 @@
import route from "./spec.ts";
export default route.handle(async ({ params }) => {
console.log(params);
});

View File

@@ -0,0 +1,6 @@
import { route } from "@platform/relay";
import z from "zod";
export default route.get("/api/v1/account/:number").params({
number: z.number().describe("Account number to retrieve"),
});

View File

@@ -1,17 +0,0 @@
import { HTTP } from "@cerbos/http";
import { getEnvironmentVariable } from "@platform/config/environment.ts";
import z from "zod";
export const cerbos = new HTTP(
getEnvironmentVariable({
key: "CERBOS_URL",
type: z.string(),
fallback: "http://localhost:3592",
}),
{
adminCredentials: {
username: "cerbos",
password: "cerbosAdmin",
},
},
);

View File

@@ -1,23 +0,0 @@
# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/Policy.schema.json
# docs: https://docs.cerbos.dev/cerbos/latest/policies/resource_policies
apiVersion: api.cerbos.dev/v1
resourcePolicy:
resource: identity
version: default
rules:
# Admins can read any identity with limited fields
- actions: ["read", "update"]
effect: EFFECT_ALLOW
roles: ["admin"]
# Users can fully read, update, or delete their own identity
- actions: ["read", "update", "delete"]
effect: EFFECT_ALLOW
roles: ["user"]
condition:
match:
expr: request.resource.id == request.principal.id

View File

@@ -1,14 +0,0 @@
# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/Policy.schema.json
# docs: https://docs.cerbos.dev/cerbos/latest/policies/resource_policies
apiVersion: api.cerbos.dev/v1
resourcePolicy:
resource: role
version: default
rules:
# Admin can manage roles
- actions: ["manage"]
effect: EFFECT_ALLOW
roles: ["super"]

View File

@@ -1,11 +0,0 @@
/*
export const resources = new ResourceRegistry([
{
kind: "identity",
actions: ["read", "update", "delete"],
attr: {},
},
] as const);
export type Resource = typeof resources.$resource;
*/

View File

@@ -1,163 +0,0 @@
import { CheckResourcesResponse } from "@cerbos/core";
import { HttpAdapter, makeClient } from "@platform/relay";
import { config } from "./config.ts";
import checkResource from "./routes/access/check-resource/spec.ts";
import checkResources from "./routes/access/check-resources/spec.ts";
import isAllowed from "./routes/access/is-allowed/spec.ts";
import getById from "./routes/identities/get/spec.ts";
import loginByPassword from "./routes/login/code/spec.ts";
import loginByEmail from "./routes/login/email/spec.ts";
import loginByCode from "./routes/login/password/spec.ts";
import me from "./routes/me/spec.ts";
const adapter = new HttpAdapter({
url: config.url,
});
const access = makeClient(
{
adapter,
},
{
isAllowed,
checkResource,
checkResources,
},
);
export const identity = makeClient(
{
adapter,
},
{
/**
* TODO ...
*/
getById,
/**
* TODO ...
*/
me,
/**
* TODO ...
*/
login: {
/**
* TODO ...
*/
email: loginByEmail,
/**
* TODO ...
*/
password: loginByPassword,
/**
* TODO ...
*/
code: loginByCode,
},
access: {
/**
* Check if a principal is allowed to perform an action on a resource.
*
* @param resource - Resource which we are validating.
* @param action - Action which we are validating.
*
* @example
*
* await access.isAllowed(
* {
* kind: "document",
* id: "1",
* attr: { owner: "user@example.com" },
* },
* "view"
* ); // => true
*/
isAllowed: async (resource: Resource, action: string) => {
const response = await access.isAllowed({ body: { resource, action } });
if ("error" in response) {
throw response.error;
}
return response.data;
},
/**
* Check a principal's permissions on a resource.
*
* @param resource - Resource which we are validating.
* @param actions - Actions which we are validating.
*
* @example
*
* const decision = await access.checkResource(
* {
* kind: "document",
* id: "1",
* attr: { owner: "user@example.com" },
* },
* ["view", "edit"],
* );
*
* decision.isAllowed("view"); // => true
*/
checkResource: async (resource: Resource, actions: string[]) => {
const response = await access.checkResource({ body: { resource, actions } });
if ("error" in response) {
throw response.error;
}
return new CheckResourcesResponse(response.data);
},
/**
* Check a principal's permissions on a set of resources.
*
* @param resources - Resources which we are validating.
*
* @example
*
* const decision = await access.checkResources([
* {
* resource: {
* kind: "document",
* id: "1",
* attr: { owner: "user@example.com" },
* },
* actions: ["view", "edit"],
* },
* {
* resource: {
* kind: "image",
* id: "1",
* attr: { owner: "user@example.com" },
* },
* actions: ["delete"],
* },
* ]);
*
* decision.isAllowed({
* resource: { kind: "document", id: "1" },
* action: "view",
* }); // => true
*/
checkResources: async (resources: { resource: Resource; actions: string[] }[]) => {
const response = await access.checkResources({ body: resources });
if ("error" in response) {
throw response.error;
}
return new CheckResourcesResponse(response.data);
},
},
},
);
type Resource = {
kind: string;
id: string;
attr: Record<string, any>;
};

View File

@@ -1,44 +0,0 @@
import { getEnvironmentVariable } from "@platform/config/environment.ts";
import type { SerializeOptions } from "cookie";
import z from "zod";
export const config = {
url: getEnvironmentVariable({
key: "IDENTITY_SERVICE_URL",
type: z.url(),
fallback: "http://localhost:8370",
}),
internal: {
privateKey: getEnvironmentVariable({
key: "IDENTITY_PRIVATE_KEY",
type: z.string(),
fallback:
"-----BEGIN PRIVATE KEY-----\n" +
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg2WYKMJZUWff5XOWC\n" +
"XGuU+wmsRzhQGEIzfUoL6rrGoaehRANCAATCpiGiFQxTA76EIVG0cBbj+AFt6BuJ\n" +
"t4q+zoInPUzkChCdwI+XfAYokrZwBjcyRGluC02HaN3cptrmjYSGSMSx\n" +
"-----END PRIVATE KEY-----",
}),
publicKey: getEnvironmentVariable({
key: "IDENTITY_PUBLIC_KEY",
type: z.string(),
fallback:
"-----BEGIN PUBLIC KEY-----\n" +
"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwqYhohUMUwO+hCFRtHAW4/gBbegb\n" +
"ibeKvs6CJz1M5AoQncCPl3wGKJK2cAY3MkRpbgtNh2jd3Kba5o2EhkjEsQ==\n" +
"-----END PUBLIC KEY-----",
}),
},
cookie: (maxAge: number) =>
({
httpOnly: true,
secure: getEnvironmentVariable({
key: "AUTH_COOKIE_SECURE",
type: z.coerce.boolean(),
fallback: "false",
}), // Set to true for HTTPS in production
maxAge,
path: "/",
sameSite: "strict",
}) satisfies SerializeOptions,
};

View File

@@ -1,46 +0,0 @@
import { makeDocumentParser } from "@platform/database/utilities.ts";
import z from "zod";
export enum PrincipalTypeId {
User = 1,
Group = 2,
Other = 99,
}
export const PRINCIPAL_TYPE_NAMES = {
[PrincipalTypeId.User]: "User",
[PrincipalTypeId.Group]: "Group",
[PrincipalTypeId.Other]: "Other",
};
/*
|--------------------------------------------------------------------------------
| Schema
|--------------------------------------------------------------------------------
*/
export const PrincipalSchema = z.object({
id: z.string(),
type: z.strictObject({
id: z.enum(PrincipalTypeId),
name: z.string(),
}),
roles: z.array(z.string()),
attr: z.record(z.string(), z.any()),
});
/*
|--------------------------------------------------------------------------------
| Parsers
|--------------------------------------------------------------------------------
*/
export const parsePrincipal = makeDocumentParser(PrincipalSchema);
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
export type Principal = z.infer<typeof PrincipalSchema>;

View File

@@ -1,26 +0,0 @@
import z from "zod";
/*
|--------------------------------------------------------------------------------
| Schema
|--------------------------------------------------------------------------------
*/
export const SessionSchema = z.object({
id: z.string(),
userId: z.string(),
token: z.string(),
ipAddress: z.string().nullable().optional(),
userAgent: z.string().nullable().optional(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
expiresAt: z.coerce.date(),
});
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
export type Session = z.infer<typeof SessionSchema>;

View File

@@ -1,23 +0,0 @@
{
"name": "@modules/identity",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./client.ts": "./client.ts",
"./server.ts": "./server.ts",
"./types.ts": "./types.ts"
},
"dependencies": {
"@cerbos/core": "0.24.1",
"@cerbos/http": "0.23.1",
"@platform/config": "workspace:*",
"@platform/logger": "workspace:*",
"@platform/relay": "workspace:*",
"@platform/storage": "workspace:*",
"@platform/vault": "workspace:*",
"better-auth": "1.3.16",
"cookie": "1.0.2",
"zod": "4.1.11"
}
}

View File

@@ -1,6 +0,0 @@
import { cerbos } from "../../../cerbos/client.ts";
import route from "./spec.ts";
export default route.access("session").handle(async ({ body: { resource, actions } }, { principal }) => {
return cerbos.checkResource({ principal, resource, actions });
});

View File

@@ -1,16 +0,0 @@
import { route } from "@platform/relay";
import z from "zod";
export default route
.post("/api/v1/identity/access/check-resource")
.body(
z.strictObject({
resource: z.strictObject({
kind: z.string(),
id: z.string(),
attr: z.record(z.string(), z.any()),
}),
actions: z.array(z.string()),
}),
)
.response(z.any());

View File

@@ -1,6 +0,0 @@
import { cerbos } from "../../../cerbos/client.ts";
import route from "./spec.ts";
export default route.access("session").handle(async ({ body: resources }, { principal }) => {
return cerbos.checkResources({ principal, resources });
});

View File

@@ -1,18 +0,0 @@
import { route } from "@platform/relay";
import z from "zod";
export default route
.post("/api/v1/identity/access/check-resources")
.body(
z.array(
z.strictObject({
resource: z.strictObject({
kind: z.string(),
id: z.string(),
attr: z.record(z.string(), z.any()),
}),
actions: z.array(z.string()),
}),
),
)
.response(z.any());

View File

@@ -1,6 +0,0 @@
import { cerbos } from "../../../cerbos/client.ts";
import route from "./spec.ts";
export default route.access("session").handle(async ({ body: { resource, action } }, { principal }) => {
return cerbos.isAllowed({ principal, resource, action });
});

View File

@@ -1,16 +0,0 @@
import { route } from "@platform/relay";
import z from "zod";
export default route
.post("/api/v1/identity/access/is-allowed")
.body(
z.strictObject({
resource: z.strictObject({
kind: z.string(),
id: z.string(),
attr: z.record(z.string(), z.any()),
}),
action: z.string(),
}),
)
.response(z.boolean());

View File

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

View File

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

View File

@@ -1,43 +0,0 @@
import { ForbiddenError, NotFoundError } from "@platform/relay";
import { getPrincipalById, setPrincipalAttributesById } from "../../../services/database.ts";
import route from "./spec.ts";
export default route.access("session").handle(async ({ params: { id }, body: ops }, { access }) => {
const principal = await getPrincipalById(id);
if (principal === undefined) {
return new NotFoundError();
}
const decision = await access.isAllowed({ kind: "identity", id: principal.id, attr: principal.attr }, "update");
if (decision === false) {
return new ForbiddenError("You do not have permission to update this identity.");
}
const attr = principal.attr;
for (const op of ops) {
switch (op.type) {
case "add": {
attr[op.key] = op.value;
break;
}
case "push": {
if (attr[op.key] === undefined) {
attr[op.key] = op.values;
} else {
attr[op.key] = [...attr[op.key], ...op.values];
}
break;
}
case "pop": {
if (Array.isArray(attr[op.key])) {
attr[op.key] = attr[op.key].filter((value: any) => op.values.includes(value) === false);
}
break;
}
case "remove": {
delete attr[op.key];
break;
}
}
}
await setPrincipalAttributesById(id, attr);
});

View File

@@ -1,29 +0,0 @@
import { ForbiddenError, NotFoundError, route, UnauthorizedError } from "@platform/relay";
import z from "zod";
export default route
.put("/api/v1/identity/:id")
.params({
id: z.string(),
})
.body(
z.array(
z.union([
z.strictObject({
type: z.union([z.literal("add")]),
key: z.string(),
value: z.any(),
}),
z.strictObject({
type: z.union([z.literal("push"), z.literal("pop")]),
key: z.string(),
values: z.array(z.any()),
}),
z.strictObject({
type: z.union([z.literal("remove")]),
key: z.string(),
}),
]),
),
)
.errors([UnauthorizedError, ForbiddenError, NotFoundError]);

View File

@@ -1,16 +0,0 @@
import { NotFoundError } from "@platform/relay";
import { auth } from "../../../services/auth.ts";
import { logger } from "../../../services/logger.ts";
import route from "./spec.ts";
export default route.access("public").handle(async ({ body: { email, otp } }) => {
const response = await auth.api.signInEmailOTP({ body: { email, otp }, asResponse: true, returnHeaders: true });
if (response.status !== 200) {
logger.error("OTP Signin Failed", await response.json());
return new NotFoundError();
}
return new Response(null, {
headers: response.headers,
});
});

View File

@@ -1,14 +0,0 @@
import { route } from "@platform/relay";
import z from "zod";
export default route
.post("/api/v1/identity/login/code")
.body(
z.strictObject({
email: z.string(),
otp: z.string(),
}),
)
.query({
next: z.string().optional(),
});

View File

@@ -1,14 +0,0 @@
import { auth } from "../../../services/auth.ts";
import { logger } from "../../../services/logger.ts";
import route from "./spec.ts";
export default route.access("public").handle(async ({ body: { email } }) => {
const response = await auth.api.sendVerificationOTP({ body: { email, type: "sign-in" } });
if (response.success === false) {
logger.info({
type: "auth:passwordless",
message: "OTP Email verification failed.",
received: email,
});
}
});

View File

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

View File

@@ -1,36 +0,0 @@
import { logger } from "@platform/logger";
import { BadRequestError } from "@platform/relay";
import cookie from "cookie";
import { auth } from "../../../auth.ts";
import { config } from "../../../config.ts";
import { password } from "../../../crypto/password.ts";
import { getPasswordStrategyByAlias } from "../../../database.ts";
import route from "./spec.ts";
export default route.access("public").handle(async ({ body: { alias, password: userPassword } }) => {
const strategy = await getPasswordStrategyByAlias(alias);
if (strategy === undefined) {
return logger.info({
type: "auth:password",
message: "Failed to get account with 'password' strategy.",
alias,
});
}
const isValidPassword = await password.verify(userPassword, strategy.password);
if (isValidPassword === false) {
return new BadRequestError("Invalid email/password provided.");
}
return new Response(null, {
status: 204,
headers: {
"set-cookie": cookie.serialize(
"token",
await auth.generate({ id: strategy.accountId }, "1 week"),
config.cookie(1000 * 60 * 60 * 24 * 7),
),
},
});
});

View File

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

View File

@@ -1,39 +0,0 @@
import route from "./spec.ts";
export default route.access("public").handle(async () => {
// const code = await Passwordless.createCode({ tenantId: "public", email });
// if (code.status !== "OK") {
// return logger.info({
// type: "auth:passwordless",
// message: "Create code failed.",
// received: email,
// });
// }
// logger.info({
// type: "auth:passwordless",
// data: {
// deviceId: code.deviceId,
// preAuthSessionId: code.preAuthSessionId,
// userInputCode: code.userInputCode,
// },
// });
// const response = await Passwordless.consumeCode({
// tenantId: "public",
// preAuthSessionId: code.preAuthSessionId,
// deviceId: code.deviceId,
// userInputCode: code.userInputCode,
// });
// if (response.status !== "OK") {
// return new NotFoundError();
// }
// logger.info({
// type: "code:claimed",
// session: true,
// message: "Identity resolved",
// user: response.user.toJson(),
// });
// return new Response(null, {
// status: 200,
// headers: await getSessionHeaders("public", response.recipeUserId),
// });
});

View File

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

View File

@@ -1,5 +0,0 @@
import route from "./spec.ts";
export default route.access("session").handle(async ({ principal }) => {
return principal;
});

View File

@@ -1,4 +0,0 @@
import { NotFoundError, route, UnauthorizedError } from "@platform/relay";
import z from "zod";
export default route.get("/api/v1/identity/me").errors([UnauthorizedError, NotFoundError]).response(z.any());

View File

@@ -1,33 +0,0 @@
import { ForbiddenError, NotFoundError } from "@platform/relay";
import { getPrincipalById, setPrincipalRolesById } from "../../services/database.ts";
import route from "./spec.ts";
export default route.access("session").handle(async ({ params: { id }, body: ops }, { access }) => {
const principal = await getPrincipalById(id);
if (principal === undefined) {
return new NotFoundError();
}
const decision = await access.isAllowed({ kind: "role", id: principal.id, attr: principal.attr }, "manage");
if (decision === false) {
return new ForbiddenError("You do not have permission to modify roles for this identity.");
}
const roles: Set<string> = new Set(principal.roles);
for (const op of ops) {
switch (op.type) {
case "add": {
for (const role of op.roles) {
roles.add(role);
}
break;
}
case "remove": {
for (const role of op.roles) {
roles.delete(role);
}
break;
}
}
}
await setPrincipalRolesById(id, Array.from(roles));
});

View File

@@ -1,19 +0,0 @@
import { ForbiddenError, NotFoundError, route, UnauthorizedError } from "@platform/relay";
import z from "zod";
export default route
.put("/api/v1/identity/:id/roles")
.params({
id: z.string(),
})
.body(
z.array(
z.union([
z.strictObject({
type: z.union([z.literal("add"), z.literal("remove")]),
roles: z.array(z.any()),
}),
]),
),
)
.errors([UnauthorizedError, ForbiddenError, NotFoundError]);

View File

@@ -1,17 +0,0 @@
import { NotFoundError } from "@platform/relay";
import { config } from "../../../config.ts";
import { getPrincipalByUserId } from "../../../services/database.ts";
import { getSessionByRequestHeader } from "../../../services/session.ts";
import route from "./spec.ts";
export default route.access(["internal:public", config.internal.privateKey]).handle(async ({ request }) => {
const session = await getSessionByRequestHeader(request.headers);
if (session === undefined) {
return new NotFoundError();
}
return {
session,
principal: await getPrincipalByUserId(session.userId),
};
});

View File

@@ -1,12 +0,0 @@
import { route } from "@platform/relay";
import z from "zod";
import { PrincipalSchema } from "../../../models/principal.ts";
import { SessionSchema } from "../../../models/session.ts";
export default route.get("/api/v1/identity/session").response(
z.object({
session: SessionSchema,
principal: PrincipalSchema,
}),
);

View File

@@ -1,62 +0,0 @@
import { HttpAdapter, makeClient } from "@platform/relay";
import { config } from "./config.ts";
import resolve from "./routes/session/resolve/spec.ts";
/*
|--------------------------------------------------------------------------------
| Internal Session Resolver
|--------------------------------------------------------------------------------
*/
const identity = makeClient(
{
adapter: new HttpAdapter({
url: config.url,
}),
},
{
resolve: resolve.crypto({
publicKey: config.internal.publicKey,
}),
},
);
export async function getPrincipalSession(payload: { headers: Headers }) {
const response = await identity.resolve(payload);
if ("data" in response) {
return response.data;
}
}
/*
|--------------------------------------------------------------------------------
| Server Exports
|--------------------------------------------------------------------------------
*/
export * from "./services/session.ts";
export * from "./types.ts";
/*
|--------------------------------------------------------------------------------
| Module Server
|--------------------------------------------------------------------------------
*/
export default {
routes: [
(await import("./routes/identities/get/handle.ts")).default,
(await import("./routes/identities/update/handle.ts")).default,
(await import("./routes/login/code/handle.ts")).default,
(await import("./routes/login/email/handle.ts")).default,
// (await import("./routes/login/password/handle.ts")).default,
(await import("./routes/login/sudo/handle.ts")).default,
(await import("./routes/me/handle.ts")).default,
(await import("./routes/roles/handle.ts")).default,
(await import("./routes/session/resolve/handle.ts")).default,
(await import("./routes/access/is-allowed/handle.ts")).default,
(await import("./routes/access/check-resource/handle.ts")).default,
(await import("./routes/access/check-resources/handle.ts")).default,
],
};

View File

@@ -1,29 +0,0 @@
import { logger } from "@platform/logger";
import { betterAuth } from "better-auth";
import { mongodbAdapter } from "better-auth/adapters/mongodb";
import { emailOTP } from "better-auth/plugins";
import { db } from "./database.ts";
export const auth = betterAuth({
database: mongodbAdapter(db.db),
session: {
cookieCache: {
enabled: true,
maxAge: 5 * 60, // Cache duration in seconds
},
},
plugins: [
emailOTP({
async sendVerificationOTP({ email, otp, type }) {
if (type === "sign-in") {
logger.info({ email, otp, type });
} else if (type === "email-verification") {
// Send the OTP for email verification
} else {
// Send the OTP for password reset
}
},
}),
],
});

View File

@@ -1,61 +0,0 @@
import { getDatabaseAccessor } from "@platform/database/accessor.ts";
import {
PRINCIPAL_TYPE_NAMES,
type Principal,
PrincipalSchema,
PrincipalTypeId,
parsePrincipal,
} from "../models/principal.ts";
export const db = getDatabaseAccessor<{
principal: Principal;
}>("auth");
/*
|--------------------------------------------------------------------------------
| Methods
|--------------------------------------------------------------------------------
*/
export async function getPrincipalById(id: string): Promise<Principal | undefined> {
return db
.collection("principal")
.findOne({ id })
.then((value) => parsePrincipal(value));
}
export async function setPrincipalRolesById(id: string, roles: string[]): Promise<void> {
await db.collection("principal").updateOne({ id }, { $set: { roles } });
}
export async function setPrincipalAttributesById(id: string, attr: Record<string, any>): Promise<void> {
await db.collection("principal").updateOne({ id }, { $set: { attr } });
}
/**
* Retrieve a principal for a better-auth user.
*
* @param userId - User id from better-auth user list.
*/
export async function getPrincipalByUserId(userId: string): Promise<Principal> {
const principal = await db.collection("principal").findOneAndUpdate(
{ id: userId },
{
$setOnInsert: {
id: userId,
type: {
id: PrincipalTypeId.User,
name: PRINCIPAL_TYPE_NAMES[PrincipalTypeId.User],
},
roles: ["user"],
attr: {},
},
},
{ upsert: true, returnDocument: "after" },
);
if (principal === null) {
throw new Error("Failed to resolve Principal");
}
return PrincipalSchema.parse(principal);
}

View File

@@ -1,3 +0,0 @@
import { logger as platformLogger } from "@platform/logger";
export const logger = platformLogger.prefix("Modules/Identity");

View File

@@ -1,34 +0,0 @@
import cookie from "cookie";
import { config } from "../config.ts";
import { auth } from "./auth.ts";
/**
* Get session headers which can be applied on a Response object to apply
* an authenticated session to the respondent.
*
* @param accessToken - Token to apply to the cookie.
* @param maxAge - Max age of the token.
*/
export async function getSessionHeaders(accessToken: string, maxAge: number): Promise<Headers> {
return new Headers({
"set-cookie": cookie.serialize(
"better-auth.session_token",
encodeURIComponent(accessToken), // URL-encode the token
config.cookie(maxAge),
),
});
}
/**
* Get session container from request headers.
*
* @param headers - Request headers to extract session from.
*/
export async function getSessionByRequestHeader(headers: Headers) {
const response = await auth.api.getSession({ headers });
if (response === null) {
return undefined;
}
return response.session;
}

View File

@@ -1,50 +0,0 @@
import "@platform/relay";
import "@platform/storage";
import type { Session } from "better-auth";
import type { identity } from "./client.ts";
import type { Principal } from "./models/principal.ts";
declare module "@platform/storage" {
interface StorageContext {
/**
* TODO ...
*/
session?: Session;
/**
* TODO ...
*/
principal?: Principal;
/**
* TODO ...
*/
access?: typeof identity.access;
}
}
declare module "@platform/relay" {
interface ServerContext {
/**
* TODO ...
*/
isAuthenticated: boolean;
/**
* TODO ...
*/
session: Session;
/**
* TODO ...
*/
principal: Principal;
/**
* TODO ...
*/
access: typeof identity.access;
}
}

View File

@@ -0,0 +1,6 @@
{
"name": "@module/tenant",
"version": "0.0.0",
"private": true,
"type": "module"
}

View File

@@ -1,66 +0,0 @@
import { type AuditActor, auditors } from "@platform/spec/audit/actor.ts";
import { AggregateRoot, getDate } from "@valkyr/event-store";
import { db } from "../database.ts";
import { type EventRecord, type EventStoreFactory, projector } from "../event-store.ts";
export class WorkspaceUser extends AggregateRoot<EventStoreFactory> {
static override readonly name = "workspace:user";
workspaceId!: string;
identityId!: string;
createdAt!: Date;
updatedAt?: Date;
// -------------------------------------------------------------------------
// Reducer
// -------------------------------------------------------------------------
with(event: EventRecord): void {
switch (event.type) {
case "workspace:user:created": {
this.workspaceId = event.data.workspaceId;
this.identityId = event.data.identityId;
break;
}
}
}
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
create(workspaceId: string, identityId: string, meta: AuditActor = auditors.system) {
return this.push({
stream: this.id,
type: "workspace:user:created",
data: {
workspaceId,
identityId,
},
meta,
});
}
}
/*
|--------------------------------------------------------------------------------
| Projectors
|--------------------------------------------------------------------------------
*/
projector.on("workspace:user:created", async ({ stream: id, data: { workspaceId, identityId }, meta, created }) => {
await db.collection("workspace:users").insertOne({
id,
workspaceId,
identityId,
name: {
given: "",
family: "",
},
contacts: [],
createdAt: getDate(created),
createdBy: meta.user.uid ?? "Unknown",
});
});

View File

@@ -1,120 +0,0 @@
import { type AuditActor, auditors } from "@platform/spec/audit/actor.ts";
import { AggregateRoot, getDate } from "@valkyr/event-store";
import { db } from "../database.ts";
import { type EventRecord, type EventStoreFactory, projector } from "../event-store.ts";
export class Workspace extends AggregateRoot<EventStoreFactory> {
static override readonly name = "workspace";
ownerId!: string;
name!: string;
description?: string;
archived = false;
createdAt!: Date;
updatedAt?: Date;
// -------------------------------------------------------------------------
// Reducer
// -------------------------------------------------------------------------
with(event: EventRecord): void {
switch (event.type) {
case "workspace:created": {
this.id = event.stream;
this.ownerId = event.data.ownerId;
this.name = event.data.name;
this.createdAt = getDate(event.created);
break;
}
case "workspace:name:added": {
this.name = event.data;
this.updatedAt = getDate(event.created);
break;
}
case "workspace:description:added": {
this.description = event.data;
this.updatedAt = getDate(event.created);
break;
}
case "workspace:archived": {
this.archived = true;
this.updatedAt = getDate(event.created);
break;
}
case "workspace:restored": {
this.archived = false;
this.updatedAt = getDate(event.created);
break;
}
}
}
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
create(ownerId: string, name: string, meta: AuditActor = auditors.system) {
return this.push({
stream: this.id,
type: "workspace:created",
data: {
ownerId,
name,
},
meta,
});
}
setName(name: string, meta: AuditActor = auditors.system) {
return this.push({
stream: this.id,
type: "workspace:name:added",
data: name,
meta,
});
}
setDescription(description: string, meta: AuditActor = auditors.system) {
return this.push({
stream: this.id,
type: "workspace:description:added",
data: description,
meta,
});
}
archive(meta: AuditActor = auditors.system) {
return this.push({
stream: this.id,
type: "workspace:archived",
meta,
});
}
restore(meta: AuditActor = auditors.system) {
return this.push({
stream: this.id,
type: "workspace:restored",
meta,
});
}
}
/*
|--------------------------------------------------------------------------------
| Projectors
|--------------------------------------------------------------------------------
*/
projector.on("workspace:created", async ({ stream: id, data: { ownerId, name }, meta, created }) => {
await db.collection("workspaces").insertOne({
id,
ownerId,
name,
createdAt: getDate(created),
createdBy: meta.user.uid ?? "Unknown",
});
});

View File

@@ -1,27 +0,0 @@
import { getDatabaseAccessor } from "@platform/database/accessor.ts";
import { parseWorkspace, type Workspace } from "./models/workspace.ts";
import type { WorkspaceUser } from "./models/workspace-user.ts";
export const db = getDatabaseAccessor<{
workspaces: Workspace;
"workspace:users": WorkspaceUser;
}>(`workspace:read-store`);
/*
|--------------------------------------------------------------------------------
| Identity
|--------------------------------------------------------------------------------
*/
/**
* Retrieve a single workspace by its primary identifier.
*
* @param id - Unique identity.
*/
export async function getWorkspaceById(id: string): Promise<Workspace | undefined> {
return db
.collection("workspaces")
.findOne({ id })
.then((document) => parseWorkspace(document));
}

View File

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

View File

@@ -1,23 +0,0 @@
import { AuditActorSchema } from "@platform/spec/audit/actor.ts";
import { event } from "@valkyr/event-store";
import z from "zod";
import { AvatarSchema } from "../value-objects/avatar.ts";
import { ContactSchema } from "../value-objects/contact.ts";
import { NameSchema } from "../value-objects/name.ts";
export default [
event
.type("workspace:user:created")
.data(
z.strictObject({
workspaceId: z.string(),
identityId: z.string(),
}),
)
.meta(AuditActorSchema),
event.type("workspace:user:name-set").data(NameSchema).meta(AuditActorSchema),
event.type("workspace:user:avatar-set").data(AvatarSchema).meta(AuditActorSchema),
event.type("workspace:user:contacts-added").data(z.array(ContactSchema)).meta(AuditActorSchema),
event.type("workspace:user:contacts-removed").data(z.array(z.string())).meta(AuditActorSchema),
];

View File

@@ -1,19 +0,0 @@
import { AuditActorSchema } from "@platform/spec/audit/actor.ts";
import { event } from "@valkyr/event-store";
import z from "zod";
export default [
event
.type("workspace:created")
.data(
z.strictObject({
ownerId: z.uuid(),
name: z.string(),
}),
)
.meta(AuditActorSchema),
event.type("workspace:name:added").data(z.string()).meta(AuditActorSchema),
event.type("workspace:description:added").data(z.string()).meta(AuditActorSchema),
event.type("workspace:archived").meta(AuditActorSchema),
event.type("workspace:restored").meta(AuditActorSchema),
];

View File

@@ -1,38 +0,0 @@
import { makeDocumentParser } from "@platform/database/utilities.ts";
import { z } from "zod";
import { AvatarSchema } from "../value-objects/avatar.ts";
import { ContactSchema } from "../value-objects/contact.ts";
import { NameSchema } from "../value-objects/name.ts";
export const WorkspaceUserSchema = z.object({
id: z.uuid(),
workspaceId: z.uuid(),
identityId: z.string(),
name: NameSchema.optional(),
avatar: AvatarSchema.optional(),
contacts: z.array(ContactSchema).default([]),
createdAt: z.coerce.date(),
createdBy: z.string(),
updatedAt: z.coerce.date().optional(),
updatedBy: z.string().optional(),
});
/*
|--------------------------------------------------------------------------------
| Parsers
|--------------------------------------------------------------------------------
*/
export const parseWorkspaceUser = makeDocumentParser(WorkspaceUserSchema);
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
export type WorkspaceUser = z.infer<typeof WorkspaceUserSchema>;

View File

@@ -1,32 +0,0 @@
import { makeDocumentParser } from "@platform/database/utilities.ts";
import { z } from "zod";
export const WorkspaceSchema = z.object({
id: z.uuid(),
ownerId: z.uuid(),
name: z.string(),
description: z.string().optional(),
createdAt: z.coerce.date(),
createdBy: z.string(),
updatedAt: z.coerce.date().optional(),
updatedBy: z.string().optional(),
});
/*
|--------------------------------------------------------------------------------
| Parsers
|--------------------------------------------------------------------------------
*/
export const parseWorkspace = makeDocumentParser(WorkspaceSchema);
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
export type Workspace = z.infer<typeof WorkspaceSchema>;

View File

@@ -1,20 +0,0 @@
{
"name": "@modules/workspace",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./client.ts": "./client.ts",
"./server.ts": "./server.ts"
},
"types": "types.d.ts",
"dependencies": {
"@modules/iam": "workspace:*",
"@platform/database": "workspace:*",
"@platform/relay": "workspace:*",
"@platform/spec": "workspace:*",
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.1",
"cookie": "1.0.2",
"zod": "4.1.11"
}
}

View File

@@ -1,20 +0,0 @@
import { ForbiddenError } from "@platform/relay";
import { Workspace } from "../../../aggregates/workspace.ts";
import { eventStore } from "../../../event-store.ts";
import route from "./spec.ts";
export default route.access("session").handle(async ({ body: { name } }, { access, principal }) => {
const decision = await access.isAllowed({ kind: "workspace", id: "1", attr: {} }, "create");
if (decision === false) {
return new ForbiddenError("You do not have permission to create workspaces.");
}
const workspace = await eventStore.aggregate.from(Workspace).create(principal.id, name).save();
return {
id: workspace.id,
ownerId: workspace.ownerId,
name: workspace.name,
createdAt: workspace.createdAt,
createdBy: principal.id,
};
});

View File

@@ -1,14 +0,0 @@
import { ForbiddenError, InternalServerError, route, UnauthorizedError, ValidationError } from "@platform/relay";
import z from "zod";
import { WorkspaceSchema } from "../../../models/workspace.ts";
export default route
.post("/api/v1/workspace")
.body(
z.strictObject({
name: z.string(),
}),
)
.errors([UnauthorizedError, ForbiddenError, ValidationError, InternalServerError])
.response(WorkspaceSchema);

View File

@@ -1,31 +0,0 @@
import { idIndex } from "@platform/database/id.ts";
import { register as registerReadStore } from "@platform/database/registrar.ts";
import { register as registerEventStore } from "@valkyr/event-store/mongo";
import "@modules/iam/types.ts";
import { db } from "./database.ts";
import { eventStore } from "./event-store.ts";
export default {
routes: [(await import("./routes/workspaces/create/handle.ts")).default],
bootstrap: async (): Promise<void> => {
await registerReadStore(db.db, [
{
name: "workspaces",
indexes: [
idIndex,
// [{ "strategies.type": 1, "strategies.alias": 1 }, { name: "strategy.password" }],
],
},
{
name: "workspace:users",
indexes: [
idIndex,
// [{ "strategies.type": 1, "strategies.alias": 1 }, { name: "strategy.password" }],
],
},
]);
await registerEventStore(eventStore.db.db, console.info);
},
};

View File

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

View File

@@ -1,13 +0,0 @@
import z from "zod";
import { EmailSchema } from "./email.ts";
export const ContactSchema = z.union([
z.object({
id: z.string(),
type: z.literal("email"),
email: EmailSchema,
}),
]);
export type Contact = z.infer<typeof ContactSchema>;

View File

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

View File

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