feat: add supertokens
This commit is contained in:
@@ -1,66 +0,0 @@
|
||||
import { AggregateRoot, getDate } from "@valkyr/event-store";
|
||||
|
||||
import { EventRecord, EventStoreFactory } from "../event-store.ts";
|
||||
import { CodeIdentity } from "../events/code.ts";
|
||||
|
||||
export class Code extends AggregateRoot<EventStoreFactory> {
|
||||
static override readonly name = "code";
|
||||
|
||||
identity!: CodeIdentity;
|
||||
value!: string;
|
||||
|
||||
createdAt!: Date;
|
||||
claimedAt?: Date;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Accessors
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
get isClaimed(): boolean {
|
||||
return this.claimedAt !== undefined;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Folder
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
with(event: EventRecord): void {
|
||||
switch (event.type) {
|
||||
case "code:created": {
|
||||
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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
create(identity: CodeIdentity): this {
|
||||
return this.push({
|
||||
type: "code:created",
|
||||
stream: this.id,
|
||||
data: {
|
||||
identity,
|
||||
value: crypto
|
||||
.getRandomValues(new Uint8Array(5))
|
||||
.map((v) => v % 10)
|
||||
.join(""),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
claim(): this {
|
||||
return this.push({
|
||||
type: "code:claimed",
|
||||
stream: this.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import { AuditActor, auditors } from "@platform/spec/audit/actor.ts";
|
||||
import { AggregateRoot, getDate } from "@valkyr/event-store";
|
||||
|
||||
import { db } from "../database.ts";
|
||||
import { type EventRecord, eventStore, type EventStoreFactory, projector } from "../event-store.ts";
|
||||
import type { Avatar } from "../schemas/avatar.ts";
|
||||
import type { Contact } from "../schemas/contact.ts";
|
||||
import type { Email } from "../schemas/email.ts";
|
||||
import type { Name } from "../schemas/name.ts";
|
||||
import type { Role } from "../schemas/role.ts";
|
||||
import type { Strategy } from "../schemas/strategies.ts";
|
||||
|
||||
export class Identity extends AggregateRoot<EventStoreFactory> {
|
||||
static override readonly name = "identity";
|
||||
|
||||
avatar?: Avatar;
|
||||
name?: Name;
|
||||
contact: Contact = {
|
||||
emails: [],
|
||||
};
|
||||
strategies: Strategy[] = [];
|
||||
roles: Role[] = [];
|
||||
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reducer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
with(event: EventRecord): void {
|
||||
switch (event.type) {
|
||||
case "identity:created": {
|
||||
this.id = event.stream;
|
||||
this.createdAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "identity:avatar:added": {
|
||||
this.avatar = { url: event.data };
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "identity:name:added": {
|
||||
this.name = event.data;
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "identity:email:added": {
|
||||
this.contact.emails.push(event.data);
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "identity:role:added": {
|
||||
this.roles.push(event.data);
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "identity:strategy:email:added": {
|
||||
this.strategies.push({ type: "email", value: event.data });
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
case "identity:strategy:password:added": {
|
||||
this.strategies.push({ type: "password", ...event.data });
|
||||
this.updatedAt = getDate(event.created);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Actions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
create(meta: AuditActor = auditors.system) {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "identity:created",
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addAvatar(url: string, meta: AuditActor = auditors.system): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "identity:avatar:added",
|
||||
data: url,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addName(name: Name, meta: AuditActor = auditors.system): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "identity:name:added",
|
||||
data: name,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addEmail(email: Email, meta: AuditActor = auditors.system): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "identity:email:added",
|
||||
data: email,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addRole(role: Role, meta: AuditActor = auditors.system): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "identity:role:added",
|
||||
data: role,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addEmailStrategy(email: string, meta: AuditActor = auditors.system): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "identity:strategy:email:added",
|
||||
data: email,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
addPasswordStrategy(alias: string, password: string, meta: AuditActor = auditors.system): this {
|
||||
return this.push({
|
||||
stream: this.id,
|
||||
type: "identity:strategy:password:added",
|
||||
data: { alias, password },
|
||||
meta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Utilities
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export async function isEmailClaimed(email: string): Promise<boolean> {
|
||||
const relations = await eventStore.relations.getByKey(getIdentityEmailRelation(email));
|
||||
if (relations.length > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Relations
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export function getIdentityEmailRelation(email: string): string {
|
||||
return `/identities/emails/${email}`;
|
||||
}
|
||||
|
||||
export function getIdentityAliasRelation(alias: string): string {
|
||||
return `/identities/aliases/${alias}`;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Projectors
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
projector.on("identity:created", async ({ stream: id }) => {
|
||||
await db.collection("identities").insertOne({
|
||||
id,
|
||||
name: {
|
||||
given: null,
|
||||
family: null,
|
||||
},
|
||||
contact: {
|
||||
emails: [],
|
||||
},
|
||||
strategies: [],
|
||||
roles: [],
|
||||
});
|
||||
});
|
||||
|
||||
projector.on("identity:avatar:added", async ({ stream: id, data: url }) => {
|
||||
await db.collection("identities").updateOne({ id }, { $set: { avatar: { url } } });
|
||||
});
|
||||
|
||||
projector.on("identity:name:added", async ({ stream: id, data: name }) => {
|
||||
await db.collection("identities").updateOne({ id }, { $set: { name } });
|
||||
});
|
||||
|
||||
projector.on("identity:email:added", async ({ stream: id, data: email }) => {
|
||||
await db.collection("identities").updateOne({ id }, { $push: { "contact.emails": email } });
|
||||
});
|
||||
|
||||
projector.on("identity:role:added", async ({ stream: id, data: role }) => {
|
||||
await db.collection("identities").updateOne({ id }, { $push: { roles: role } });
|
||||
});
|
||||
|
||||
projector.on("identity:strategy:email:added", async ({ stream: id, data: email }) => {
|
||||
await eventStore.relations.insert(getIdentityEmailRelation(email), id);
|
||||
await db.collection("identities").updateOne({ id }, { $push: { strategies: { type: "email", value: email } } });
|
||||
});
|
||||
|
||||
projector.on("identity:strategy:password:added", async ({ stream: id, data: strategy }) => {
|
||||
await eventStore.relations.insert(getIdentityAliasRelation(strategy.alias), id);
|
||||
await db.collection("identities").updateOne({ id }, { $push: { strategies: { type: "password", ...strategy } } });
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { resources } from "@platform/cerbos/resources.ts";
|
||||
import { Auth } from "@valkyr/auth";
|
||||
|
||||
import { access } from "./auth/access.ts";
|
||||
import { jwt } from "./auth/jwt.ts";
|
||||
import { principal } from "./auth/principal.ts";
|
||||
|
||||
export const auth = new Auth({
|
||||
principal,
|
||||
resources,
|
||||
access,
|
||||
jwt,
|
||||
});
|
||||
|
||||
export type Session = typeof auth.$session;
|
||||
@@ -1,89 +0,0 @@
|
||||
import { cerbos } from "@platform/cerbos/client.ts";
|
||||
import { Resource } from "@platform/cerbos/resources.ts";
|
||||
|
||||
import type { Principal } from "./principal.ts";
|
||||
|
||||
export function access(principal: Principal) {
|
||||
return {
|
||||
/**
|
||||
* 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(resource: Resource, action: string) {
|
||||
return cerbos.isAllowed({ principal, resource, action });
|
||||
},
|
||||
|
||||
/**
|
||||
* 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(resource: Resource, actions: string[]) {
|
||||
return cerbos.checkResource({ principal, resource, actions });
|
||||
},
|
||||
|
||||
/**
|
||||
* 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(resources: { resource: Resource; actions: string[] }[]) {
|
||||
return cerbos.checkResources({ principal, resources });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type Access = ReturnType<typeof access>;
|
||||
@@ -1,9 +0,0 @@
|
||||
import { config } from "../config.ts";
|
||||
|
||||
export const jwt = {
|
||||
algorithm: "RS256",
|
||||
privateKey: config.auth.privateKey,
|
||||
publicKey: config.auth.publicKey,
|
||||
issuer: "http://localhost",
|
||||
audience: "http://localhost",
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import { HttpAdapter, makeClient } from "@platform/relay";
|
||||
import { PrincipalProvider } from "@valkyr/auth";
|
||||
import z from "zod";
|
||||
|
||||
import { config } from "../config.ts";
|
||||
import resolve from "../routes/identities/resolve/spec.ts";
|
||||
import { RoleSchema } from "../schemas/role.ts";
|
||||
|
||||
export const identity = makeClient(
|
||||
{
|
||||
adapter: new HttpAdapter({
|
||||
url: config.url,
|
||||
}),
|
||||
},
|
||||
{
|
||||
resolve: resolve.crypto({
|
||||
publicKey: config.internal.publicKey,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
export const principal = new PrincipalProvider(
|
||||
RoleSchema,
|
||||
{
|
||||
workspaceIds: z.array(z.string()).optional().default([]),
|
||||
},
|
||||
async function (id: string) {
|
||||
const response = await identity.resolve({ params: { id } });
|
||||
if ("data" in response) {
|
||||
return {
|
||||
id,
|
||||
roles: response.data.roles,
|
||||
attributes: this.attributes.parse(response.data.attributes),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export type Principal = typeof principal.$principal;
|
||||
23
modules/identity/cerbos/policies/identity.yaml
Normal file
23
modules/identity/cerbos/policies/identity.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
# 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
|
||||
14
modules/identity/cerbos/policies/role.yaml
Normal file
14
modules/identity/cerbos/policies/role.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
# 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: ["admin"]
|
||||
11
modules/identity/cerbos/resources.ts
Normal file
11
modules/identity/cerbos/resources.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
export const resources = new ResourceRegistry([
|
||||
{
|
||||
kind: "identity",
|
||||
actions: ["read", "update", "delete"],
|
||||
attr: {},
|
||||
},
|
||||
] as const);
|
||||
|
||||
export type Resource = typeof resources.$resource;
|
||||
*/
|
||||
@@ -2,11 +2,10 @@ import { HttpAdapter, makeClient } from "@platform/relay";
|
||||
|
||||
import { config } from "./config.ts";
|
||||
import getById from "./routes/identities/get/spec.ts";
|
||||
import me from "./routes/identities/me/spec.ts";
|
||||
import register from "./routes/identities/register/spec.ts";
|
||||
import loginByPassword from "./routes/login/code/spec.ts";
|
||||
import loginByEmail from "./routes/login/email/spec.ts";
|
||||
import loginByCode from "./routes/login/password/spec.ts";
|
||||
import me from "./routes/me/spec.ts";
|
||||
|
||||
export const identity = makeClient(
|
||||
{
|
||||
@@ -15,11 +14,6 @@ export const identity = makeClient(
|
||||
}),
|
||||
},
|
||||
{
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
register,
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { getEnvironmentVariable } from "@platform/config/environment.ts";
|
||||
import type { SerializeOptions } from "cookie";
|
||||
import z from "zod";
|
||||
|
||||
export const config = {
|
||||
@@ -11,49 +7,4 @@ export const config = {
|
||||
type: z.url(),
|
||||
fallback: "http://localhost:8370",
|
||||
}),
|
||||
auth: {
|
||||
privateKey: getEnvironmentVariable({
|
||||
key: "AUTH_PRIVATE_KEY",
|
||||
type: z.string(),
|
||||
fallback: await readFile(resolve(import.meta.dirname!, ".keys", "private"), "utf-8"),
|
||||
}),
|
||||
publicKey: getEnvironmentVariable({
|
||||
key: "AUTH_PUBLIC_KEY",
|
||||
type: z.string(),
|
||||
fallback: await readFile(resolve(import.meta.dirname!, ".keys", "public"), "utf-8"),
|
||||
}),
|
||||
},
|
||||
internal: {
|
||||
privateKey: getEnvironmentVariable({
|
||||
key: "INTERNAL_PRIVATE_KEY",
|
||||
type: z.string(),
|
||||
fallback:
|
||||
"-----BEGIN PRIVATE KEY-----\n" +
|
||||
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg2WYKMJZUWff5XOWC\n" +
|
||||
"XGuU+wmsRzhQGEIzfUoL6rrGoaehRANCAATCpiGiFQxTA76EIVG0cBbj+AFt6BuJ\n" +
|
||||
"t4q+zoInPUzkChCdwI+XfAYokrZwBjcyRGluC02HaN3cptrmjYSGSMSx\n" +
|
||||
"-----END PRIVATE KEY-----",
|
||||
}),
|
||||
publicKey: getEnvironmentVariable({
|
||||
key: "INTERNAL_PUBLIC_KEY",
|
||||
type: z.string(),
|
||||
fallback:
|
||||
"-----BEGIN PUBLIC KEY-----\n" +
|
||||
"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwqYhohUMUwO+hCFRtHAW4/gBbegb\n" +
|
||||
"ibeKvs6CJz1M5AoQncCPl3wGKJK2cAY3MkRpbgtNh2jd3Kba5o2EhkjEsQ==\n" +
|
||||
"-----END PUBLIC KEY-----",
|
||||
}),
|
||||
},
|
||||
cookie: (maxAge: number) =>
|
||||
({
|
||||
httpOnly: true,
|
||||
secure: getEnvironmentVariable({
|
||||
key: "AUTH_COOKIE_SECURE",
|
||||
type: z.coerce.boolean(),
|
||||
fallback: "false",
|
||||
}), // Set to true for HTTPS in production
|
||||
maxAge,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
}) satisfies SerializeOptions,
|
||||
};
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { getDatabaseAccessor } from "@platform/database/accessor.ts";
|
||||
|
||||
import { type Identity, parseIdentity } from "./models/identity.ts";
|
||||
import type { PasswordStrategy } from "./schemas/strategies.ts";
|
||||
|
||||
export const db = getDatabaseAccessor<{
|
||||
identities: Identity;
|
||||
}>(`identity:read-store`);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Identity
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Retrieve a single account by its primary identifier.
|
||||
*
|
||||
* @param id - Unique identity.
|
||||
*/
|
||||
export async function getIdentityById(id: string): Promise<Identity | undefined> {
|
||||
return db
|
||||
.collection("identities")
|
||||
.findOne({ id })
|
||||
.then((document) => parseIdentity(document));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get strategy details for the given password strategy alias.
|
||||
*
|
||||
* @param alias - Alias to get strategy for.
|
||||
*/
|
||||
export async function getPasswordStrategyByAlias(
|
||||
alias: string,
|
||||
): Promise<({ accountId: string } & PasswordStrategy) | undefined> {
|
||||
const account = await db.collection("identities").findOne({
|
||||
strategies: {
|
||||
$elemMatch: { type: "password", alias },
|
||||
},
|
||||
});
|
||||
if (account === null) {
|
||||
return undefined;
|
||||
}
|
||||
const strategy = account.strategies.find((strategy) => strategy.type === "password" && strategy.alias === alias);
|
||||
if (strategy === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return { accountId: account.id, ...strategy } as { accountId: string } & PasswordStrategy;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { ConflictError } from "@platform/relay";
|
||||
|
||||
export class IdentityEmailClaimedError extends ConflictError {
|
||||
constructor(email: string) {
|
||||
super(`Email '${email}' is already claimed by another identity.`);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { event } from "@valkyr/event-store";
|
||||
import z from "zod";
|
||||
|
||||
const CodeIdentitySchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export default [
|
||||
event.type("code:created").data(
|
||||
z.object({
|
||||
identity: CodeIdentitySchema,
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
event.type("code:claimed"),
|
||||
];
|
||||
|
||||
export type CodeIdentity = z.infer<typeof CodeIdentitySchema>;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { AuditActorSchema } from "@platform/spec/audit/actor.ts";
|
||||
import { event } from "@valkyr/event-store";
|
||||
import z from "zod";
|
||||
|
||||
import { EmailSchema } from "../schemas/email.ts";
|
||||
import { NameSchema } from "../schemas/name.ts";
|
||||
import { RoleSchema } from "../schemas/role.ts";
|
||||
|
||||
export default [
|
||||
event.type("identity:created").meta(AuditActorSchema),
|
||||
event.type("identity:avatar:added").data(z.string()).meta(AuditActorSchema),
|
||||
event.type("identity:name:added").data(NameSchema).meta(AuditActorSchema),
|
||||
event.type("identity:email:added").data(EmailSchema).meta(AuditActorSchema),
|
||||
event.type("identity:role:added").data(RoleSchema).meta(AuditActorSchema),
|
||||
event.type("identity:strategy:email:added").data(z.string()).meta(AuditActorSchema),
|
||||
event.type("identity:strategy:passkey:added").meta(AuditActorSchema),
|
||||
event
|
||||
.type("identity:strategy:password:added")
|
||||
.data(z.object({ alias: z.string(), password: z.string() }))
|
||||
.meta(AuditActorSchema),
|
||||
];
|
||||
@@ -1,36 +0,0 @@
|
||||
import { makeDocumentParser } from "@platform/database/utilities.ts";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AvatarSchema } from "../schemas/avatar.ts";
|
||||
import { ContactSchema } from "../schemas/contact.ts";
|
||||
import { NameSchema } from "../schemas/name.ts";
|
||||
import { RoleSchema } from "../schemas/role.ts";
|
||||
import { StrategySchema } from "../schemas/strategies.ts";
|
||||
|
||||
export const IdentitySchema = z.object({
|
||||
id: z.uuid(),
|
||||
avatar: AvatarSchema.optional(),
|
||||
name: NameSchema.optional(),
|
||||
contact: ContactSchema.default({
|
||||
emails: [],
|
||||
}),
|
||||
strategies: z.array(StrategySchema).default([]),
|
||||
roles: z.array(RoleSchema).default([]),
|
||||
attributes: z.record(z.string(), z.any()),
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Parsers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const parseIdentity = makeDocumentParser(IdentitySchema);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type Identity = z.infer<typeof IdentitySchema>;
|
||||
@@ -7,22 +7,11 @@
|
||||
"./client.ts": "./client.ts",
|
||||
"./server.ts": "./server.ts"
|
||||
},
|
||||
"types": "types.d.ts",
|
||||
"dependencies": {
|
||||
"@cerbos/http": "0.23.1",
|
||||
"@felix/bcrypt": "npm:@jsr/felix__bcrypt@1.0.5",
|
||||
"@platform/cerbos": "workspace:*",
|
||||
"@platform/config": "workspace:*",
|
||||
"@platform/database": "workspace:*",
|
||||
"@platform/logger": "workspace:*",
|
||||
"@platform/relay": "workspace:*",
|
||||
"@platform/server": "workspace:*",
|
||||
"@platform/spec": "workspace:*",
|
||||
"@platform/storage": "workspace:*",
|
||||
"@platform/vault": "workspace:*",
|
||||
"@valkyr/auth": "npm:@jsr/valkyr__auth@2.1.4",
|
||||
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.1",
|
||||
"cookie": "1.0.2",
|
||||
"supertokens-node": "23.0.1",
|
||||
"zod": "4.1.11"
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
import { ForbiddenError, NotFoundError } from "@platform/relay";
|
||||
import { getPrincipalAttributes, getPrincipalRoles } from "@platform/supertoken/principal.ts";
|
||||
import { getUserById } from "@platform/supertoken/users.ts";
|
||||
|
||||
import { getIdentityById } from "../../../database.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("session").handle(async ({ params: { id } }, { access }) => {
|
||||
const identity = await getIdentityById(id);
|
||||
if (identity === undefined) {
|
||||
const user = await getUserById(id);
|
||||
if (user === undefined) {
|
||||
return new NotFoundError("Identity does not exist, or has been removed.");
|
||||
}
|
||||
const decision = await access.isAllowed({ kind: "identity", id: identity.id, attr: {} }, "read");
|
||||
const decision = await access.isAllowed({ kind: "identity", id: user.id, attr: {} }, "read");
|
||||
if (decision === false) {
|
||||
return new ForbiddenError("You do not have permission to view this identity.");
|
||||
}
|
||||
return identity;
|
||||
return {
|
||||
id: user.id,
|
||||
roles: await getPrincipalRoles(id),
|
||||
attr: await getPrincipalAttributes(id),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { ForbiddenError, NotFoundError, route, UnauthorizedError } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
import { IdentitySchema } from "../../../models/identity.ts";
|
||||
|
||||
export default route
|
||||
.get("/api/v1/identities/:id")
|
||||
.get("/api/v1/identity/:id")
|
||||
.params({
|
||||
id: z.string(),
|
||||
})
|
||||
.errors([UnauthorizedError, ForbiddenError, NotFoundError])
|
||||
.response(IdentitySchema);
|
||||
.response(z.any());
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { UnauthorizedError } from "@platform/relay";
|
||||
|
||||
import { getIdentityById } from "../../../database.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("session").handle(async ({ principal }) => {
|
||||
const identity = await getIdentityById(principal.id);
|
||||
if (identity === undefined) {
|
||||
return new UnauthorizedError("You must be signed in to view your session.");
|
||||
}
|
||||
return identity;
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { NotFoundError, route, UnauthorizedError } from "@platform/relay";
|
||||
|
||||
import { IdentitySchema } from "../../../models/identity.ts";
|
||||
|
||||
export default route.get("/api/v1/identities/me").response(IdentitySchema).errors([UnauthorizedError, NotFoundError]);
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Identity, isEmailClaimed } from "../../../aggregates/identity.ts";
|
||||
import { IdentityEmailClaimedError } from "../../../errors.ts";
|
||||
import { eventStore } from "../../../event-store.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("public").handle(async ({ body: { name, email } }) => {
|
||||
if ((await isEmailClaimed(email)) === true) {
|
||||
return new IdentityEmailClaimedError(email);
|
||||
}
|
||||
return eventStore.aggregate.from(Identity).create().addName(name).addEmailStrategy(email).addRole("user").save();
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { route } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
import { IdentityEmailClaimedError } from "../../../errors.ts";
|
||||
import { IdentitySchema } from "../../../models/identity.ts";
|
||||
import { NameSchema } from "../../../schemas/name.ts";
|
||||
|
||||
export default route
|
||||
.post("/api/v1/identities")
|
||||
.body(
|
||||
z.object({
|
||||
name: NameSchema,
|
||||
email: z.email(),
|
||||
}),
|
||||
)
|
||||
.errors([IdentityEmailClaimedError])
|
||||
.response(IdentitySchema);
|
||||
@@ -1,13 +0,0 @@
|
||||
import { NotFoundError } from "@platform/relay";
|
||||
|
||||
import { config } from "../../../config.ts";
|
||||
import { getIdentityById } from "../../../database.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access(["internal:public", config.internal.privateKey]).handle(async ({ params: { id } }) => {
|
||||
const identity = await getIdentityById(id);
|
||||
if (identity === undefined) {
|
||||
return new NotFoundError();
|
||||
}
|
||||
return identity;
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { importVault } from "@platform/vault";
|
||||
|
||||
import { config } from "../../../config.ts";
|
||||
|
||||
export const vault = importVault(config.internal);
|
||||
@@ -1,12 +0,0 @@
|
||||
import { NotFoundError, route, UnauthorizedError } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
import { IdentitySchema } from "../../../models/identity.ts";
|
||||
|
||||
export default route
|
||||
.get("/api/v1/identities/:id/resolve")
|
||||
.params({
|
||||
id: z.string(),
|
||||
})
|
||||
.response(IdentitySchema)
|
||||
.errors([UnauthorizedError, NotFoundError]);
|
||||
40
modules/identity/routes/identities/update/handle.ts
Normal file
40
modules/identity/routes/identities/update/handle.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ForbiddenError } from "@platform/relay";
|
||||
import { getPrincipalAttributes } from "@platform/supertoken/principal.ts";
|
||||
import UserMetadata from "supertokens-node/recipe/usermetadata";
|
||||
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("session").handle(async ({ params: { id }, body: ops }, { access }) => {
|
||||
const decision = await access.isAllowed({ kind: "identity", id, attr: {} }, "update");
|
||||
if (decision === false) {
|
||||
return new ForbiddenError("You do not have permission to update this identity.");
|
||||
}
|
||||
const attr = await getPrincipalAttributes(id);
|
||||
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 UserMetadata.updateUserMetadata(id, { attr });
|
||||
});
|
||||
29
modules/identity/routes/identities/update/spec.ts
Normal file
29
modules/identity/routes/identities/update/spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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]);
|
||||
@@ -1,85 +1,25 @@
|
||||
import { logger } from "@platform/logger";
|
||||
import cookie from "cookie";
|
||||
import { NotFoundError } from "@platform/relay";
|
||||
import { getSessionHeaders } from "@platform/supertoken/session.ts";
|
||||
import Passwordless from "supertokens-node/recipe/passwordless";
|
||||
|
||||
import { Code } from "../../../aggregates/code.ts";
|
||||
import { Identity } from "../../../aggregates/identity.ts";
|
||||
import { auth } from "../../../auth.ts";
|
||||
import { config } from "../../../config.ts";
|
||||
import { eventStore } from "../../../event-store.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("public").handle(async ({ params: { identityId, codeId, value }, query: { next } }) => {
|
||||
const code = await eventStore.aggregate.getByStream(Code, codeId);
|
||||
|
||||
if (code === undefined) {
|
||||
return logger.info({
|
||||
type: "code:claimed",
|
||||
session: false,
|
||||
message: "Invalid Code ID",
|
||||
received: codeId,
|
||||
});
|
||||
export default route.access("public").handle(async ({ body: { preAuthSessionId, deviceId, userInputCode } }) => {
|
||||
const response = await Passwordless.consumeCode({ tenantId: "public", preAuthSessionId, deviceId, userInputCode });
|
||||
if (response.status !== "OK") {
|
||||
return new NotFoundError();
|
||||
}
|
||||
|
||||
if (code.claimedAt !== undefined) {
|
||||
return logger.info({
|
||||
type: "code:claimed",
|
||||
session: false,
|
||||
message: "Code Already Claimed",
|
||||
received: codeId,
|
||||
});
|
||||
}
|
||||
|
||||
await code.claim().save();
|
||||
|
||||
if (code.value !== value) {
|
||||
return logger.info({
|
||||
type: "code:claimed",
|
||||
session: false,
|
||||
message: "Invalid Value",
|
||||
expected: code.value,
|
||||
received: value,
|
||||
});
|
||||
}
|
||||
|
||||
if (code.identity.id !== identityId) {
|
||||
return logger.info({
|
||||
type: "code:claimed",
|
||||
session: false,
|
||||
message: "Invalid Identity ID",
|
||||
expected: code.identity.id,
|
||||
received: identityId,
|
||||
});
|
||||
}
|
||||
|
||||
const account = await eventStore.aggregate.getByStream(Identity, identityId);
|
||||
if (account === undefined) {
|
||||
return logger.info({
|
||||
type: "code:claimed",
|
||||
session: false,
|
||||
message: "Account Not Found",
|
||||
expected: code.identity.id,
|
||||
received: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({ type: "code:claimed", session: true });
|
||||
|
||||
const options = config.cookie(1000 * 60 * 60 * 24 * 7);
|
||||
|
||||
if (next !== undefined) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: next,
|
||||
"set-cookie": cookie.serialize("token", await auth.generate({ id: account.id }, "1 week"), options),
|
||||
},
|
||||
});
|
||||
}
|
||||
logger.info({
|
||||
type: "code:claimed",
|
||||
session: true,
|
||||
message: "Identity resolved",
|
||||
user: response.user.toJson(),
|
||||
});
|
||||
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"set-cookie": cookie.serialize("token", await auth.generate({ id: account.id }, "1 week"), options),
|
||||
},
|
||||
headers: await getSessionHeaders("public", response.recipeUserId),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,14 @@ import { route } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
export default route
|
||||
.get("/api/v1/identities/login/code/:identityId/code/:codeId/:value")
|
||||
.params({
|
||||
identityId: z.string(),
|
||||
codeId: z.string(),
|
||||
value: z.string(),
|
||||
})
|
||||
.post("/api/v1/identity/login/code")
|
||||
.body(
|
||||
z.strictObject({
|
||||
deviceId: z.string(),
|
||||
preAuthSessionId: z.string(),
|
||||
userInputCode: z.string(),
|
||||
}),
|
||||
)
|
||||
.query({
|
||||
next: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
import { logger } from "@platform/logger";
|
||||
import Passwordless from "supertokens-node/recipe/passwordless";
|
||||
|
||||
import { Code } from "../../../aggregates/code.ts";
|
||||
import { getIdentityEmailRelation, Identity } from "../../../aggregates/identity.ts";
|
||||
import { eventStore } from "../../../event-store.ts";
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("public").handle(async ({ body: { base, email } }) => {
|
||||
const identity = await eventStore.aggregate.getByRelation(Identity, getIdentityEmailRelation(email));
|
||||
if (identity === undefined) {
|
||||
export default route.access("public").handle(async ({ body: { email } }) => {
|
||||
const response = await Passwordless.createCode({ tenantId: "public", email });
|
||||
if (response.status !== "OK") {
|
||||
return logger.info({
|
||||
type: "auth:email",
|
||||
code: false,
|
||||
message: "Identity Not Found",
|
||||
type: "auth:passwordless",
|
||||
message: "Create code failed.",
|
||||
received: email,
|
||||
});
|
||||
}
|
||||
const code = await eventStore.aggregate.from(Code).create({ id: identity.id }).save();
|
||||
logger.info({
|
||||
type: "auth:email",
|
||||
type: "auth:passwordless",
|
||||
data: {
|
||||
code: code.id,
|
||||
identityId: identity.id,
|
||||
deviceId: response.deviceId,
|
||||
preAuthSessionId: response.preAuthSessionId,
|
||||
userInputCode: response.userInputCode,
|
||||
},
|
||||
link: `${base}/api/v1/admin/auth/${identity.id}/code/${code.id}/${code.value}?next=${base}/admin`,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { route } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
export default route.post("/api/v1/identities/login/email").body(
|
||||
export default route.post("/api/v1/identity/login/email").body(
|
||||
z.object({
|
||||
base: z.url(),
|
||||
email: z.email(),
|
||||
}),
|
||||
);
|
||||
|
||||
48
modules/identity/routes/login/sudo/handle.ts
Normal file
48
modules/identity/routes/login/sudo/handle.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { logger } from "@platform/logger";
|
||||
import { NotFoundError } from "@platform/relay";
|
||||
import { getSessionHeaders } from "@platform/supertoken/session.ts";
|
||||
import Passwordless from "supertokens-node/recipe/passwordless";
|
||||
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("public").handle(async ({ body: { email } }) => {
|
||||
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),
|
||||
});
|
||||
});
|
||||
8
modules/identity/routes/login/sudo/spec.ts
Normal file
8
modules/identity/routes/login/sudo/spec.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { route } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
export default route.post("/api/v1/identities/login/sudo").body(
|
||||
z.object({
|
||||
email: z.email(),
|
||||
}),
|
||||
);
|
||||
5
modules/identity/routes/me/handle.ts
Normal file
5
modules/identity/routes/me/handle.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("session").handle(async ({ principal }) => {
|
||||
return principal;
|
||||
});
|
||||
4
modules/identity/routes/me/spec.ts
Normal file
4
modules/identity/routes/me/spec.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
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());
|
||||
30
modules/identity/routes/roles/handle.ts
Normal file
30
modules/identity/routes/roles/handle.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ForbiddenError } from "@platform/relay";
|
||||
import { getPrincipalRoles } from "@platform/supertoken/principal.ts";
|
||||
import UserMetadata from "supertokens-node/recipe/usermetadata";
|
||||
|
||||
import route from "./spec.ts";
|
||||
|
||||
export default route.access("session").handle(async ({ params: { id }, body: ops }, { access }) => {
|
||||
const decision = await access.isAllowed({ kind: "role", id, 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(await getPrincipalRoles(id));
|
||||
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 UserMetadata.updateUserMetadata(id, { roles: Array.from(roles) });
|
||||
});
|
||||
19
modules/identity/routes/roles/spec.ts
Normal file
19
modules/identity/routes/roles/spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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]);
|
||||
@@ -1,9 +0,0 @@
|
||||
import z from "zod";
|
||||
|
||||
import { EmailSchema } from "./email.ts";
|
||||
|
||||
export const ContactSchema = z.object({
|
||||
emails: z.array(EmailSchema).default([]).describe("A list of email addresses associated with the contact."),
|
||||
});
|
||||
|
||||
export type Contact = z.infer<typeof ContactSchema>;
|
||||
@@ -1,5 +0,0 @@
|
||||
import z from "zod";
|
||||
|
||||
export const RoleSchema = z.union([z.literal("user"), z.literal("admin")]);
|
||||
|
||||
export type Role = z.infer<typeof RoleSchema>;
|
||||
@@ -1,37 +0,0 @@
|
||||
import z from "zod";
|
||||
|
||||
const EmailStrategySchema = z.object({
|
||||
type: z.literal("email"),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const PasswordStrategySchema = z.object({
|
||||
type: z.literal("password"),
|
||||
alias: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
const PasskeyStrategySchema = z.object({
|
||||
type: z.literal("passkey"),
|
||||
credId: z.string(),
|
||||
credPublicKey: z.string(),
|
||||
webauthnUserId: z.string(),
|
||||
counter: z.number(),
|
||||
backupEligible: z.boolean(),
|
||||
backupStatus: z.boolean(),
|
||||
transports: z.string(),
|
||||
createdAt: z.date(),
|
||||
lastUsed: z.date(),
|
||||
});
|
||||
|
||||
export const StrategySchema = z.discriminatedUnion("type", [
|
||||
EmailStrategySchema,
|
||||
PasswordStrategySchema,
|
||||
PasskeyStrategySchema,
|
||||
]);
|
||||
|
||||
export type EmailStrategy = z.infer<typeof EmailStrategySchema>;
|
||||
export type PasswordStrategy = z.infer<typeof PasswordStrategySchema>;
|
||||
export type PasskeyStrategy = z.infer<typeof PasskeyStrategySchema>;
|
||||
|
||||
export type Strategy = z.infer<typeof StrategySchema>;
|
||||
@@ -1,96 +1,12 @@
|
||||
import "./types.d.ts";
|
||||
|
||||
import { idIndex } from "@platform/database/id.ts";
|
||||
import { register as registerReadStore } from "@platform/database/registrar.ts";
|
||||
import { UnauthorizedError } from "@platform/relay";
|
||||
import { context } from "@platform/relay";
|
||||
import { storage } from "@platform/storage";
|
||||
import { register as registerEventStore } from "@valkyr/event-store/mongo";
|
||||
import cookie from "cookie";
|
||||
|
||||
import { auth } from "./auth.ts";
|
||||
import { db } from "./database.ts";
|
||||
import { eventStore } from "./event-store.ts";
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
(await import("./routes/identities/get/handle.ts")).default,
|
||||
(await import("./routes/identities/register/handle.ts")).default,
|
||||
(await import("./routes/identities/me/handle.ts")).default,
|
||||
(await import("./routes/identities/resolve/handle.ts")).default,
|
||||
(await import("./routes/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/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,
|
||||
],
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
bootstrap: async (): Promise<void> => {
|
||||
await registerReadStore(db.db, [
|
||||
{
|
||||
name: "identities",
|
||||
indexes: [
|
||||
idIndex,
|
||||
[{ "strategies.type": 1, "strategies.alias": 1 }, { name: "strategy.password" }],
|
||||
[{ "strategies.type": 1, "strategies.value": 1 }, { name: "strategy.email" }],
|
||||
],
|
||||
},
|
||||
]);
|
||||
await registerEventStore(eventStore.db.db, console.info);
|
||||
Object.defineProperties(context, {
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
isAuthenticated: {
|
||||
get() {
|
||||
return storage.getStore()?.principal !== undefined;
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
principal: {
|
||||
get() {
|
||||
const principal = storage.getStore()?.principal;
|
||||
if (principal === undefined) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
return principal;
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
access: {
|
||||
get() {
|
||||
const access = storage.getStore()?.access;
|
||||
if (access === undefined) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
return access;
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
resolve: async (request: Request): Promise<void> => {
|
||||
const token = cookie.parse(request.headers.get("cookie") ?? "").token;
|
||||
if (token !== undefined) {
|
||||
const session = await auth.resolve(token);
|
||||
if (session.valid === true) {
|
||||
const context = storage.getStore();
|
||||
if (context === undefined) {
|
||||
return;
|
||||
}
|
||||
context.principal = session.principal;
|
||||
context.access = session.access;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
38
modules/identity/types.d.ts
vendored
38
modules/identity/types.d.ts
vendored
@@ -1,38 +0,0 @@
|
||||
import "@platform/relay";
|
||||
import "@platform/storage";
|
||||
|
||||
import type { Access } from "./auth/access.ts";
|
||||
import type { Principal } from "./auth/principal.ts";
|
||||
|
||||
declare module "@platform/storage" {
|
||||
interface StorageContext {
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
principal?: Principal;
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
access?: Access;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@platform/relay" {
|
||||
interface ServerContext {
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
isAuthenticated: boolean;
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
principal: Principal;
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
access: Access;
|
||||
}
|
||||
}
|
||||
66
modules/workspace/aggregates/workspace-user.ts
Normal file
66
modules/workspace/aggregates/workspace-user.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { AuditActor, auditors } from "@platform/spec/audit/actor.ts";
|
||||
import { AggregateRoot, getDate } from "@valkyr/event-store";
|
||||
|
||||
import { db } from "../database.ts";
|
||||
import { EventRecord, 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",
|
||||
});
|
||||
});
|
||||
120
modules/workspace/aggregates/workspace.ts
Normal file
120
modules/workspace/aggregates/workspace.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { AuditActor, auditors } from "@platform/spec/audit/actor.ts";
|
||||
import { AggregateRoot, getDate } from "@valkyr/event-store";
|
||||
|
||||
import { db } from "../database.ts";
|
||||
import { EventRecord, 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",
|
||||
});
|
||||
});
|
||||
33
modules/workspace/cerbos/policies/workspace.yaml
Normal file
33
modules/workspace/cerbos/policies/workspace.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
# 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: workspace
|
||||
version: default
|
||||
rules:
|
||||
|
||||
- actions: ["create"]
|
||||
effect: EFFECT_ALLOW
|
||||
roles: ["super"]
|
||||
|
||||
- actions: ["read"]
|
||||
effect: EFFECT_ALLOW
|
||||
roles: ["super", "admin", "user"]
|
||||
condition:
|
||||
match:
|
||||
expr: R.attr.id in P.attr.workspaceIds
|
||||
|
||||
- actions: ["update"]
|
||||
effect: EFFECT_ALLOW
|
||||
roles: ["super", "admin"]
|
||||
condition:
|
||||
match:
|
||||
expr: R.attr.id in P.attr.workspaceIds
|
||||
|
||||
- actions: ["delete"]
|
||||
effect: EFFECT_ALLOW
|
||||
roles: ["super"]
|
||||
condition:
|
||||
match:
|
||||
expr: R.attr.id in P.attr.workspaceIds
|
||||
54
modules/workspace/cerbos/policies/workspace_user.yaml
Normal file
54
modules/workspace/cerbos/policies/workspace_user.yaml
Normal file
@@ -0,0 +1,54 @@
|
||||
# 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: workspace_user
|
||||
version: default
|
||||
rules:
|
||||
|
||||
# Admins can invite new members into their own workspace
|
||||
|
||||
- actions:
|
||||
- invite
|
||||
effect: EFFECT_ALLOW
|
||||
roles:
|
||||
- admin
|
||||
condition:
|
||||
match:
|
||||
expr: request.principal.workspaceIds.includes(request.resource.workspaceId)
|
||||
|
||||
# Admins can remove members from their own workspace
|
||||
|
||||
- actions:
|
||||
- remove
|
||||
effect: EFFECT_ALLOW
|
||||
roles:
|
||||
- admin
|
||||
condition:
|
||||
match:
|
||||
expr: request.principal.workspaceIds.includes(request.resource.workspaceId)
|
||||
|
||||
# Admins can update member roles in their own workspace
|
||||
|
||||
- actions:
|
||||
- update_role
|
||||
effect: EFFECT_ALLOW
|
||||
roles:
|
||||
- admin
|
||||
condition:
|
||||
match:
|
||||
expr: request.principal.workspaceIds.includes(request.resource.workspaceId)
|
||||
|
||||
# Admins and users can list/read members of their own workspace
|
||||
|
||||
- actions:
|
||||
- list
|
||||
- read
|
||||
effect: EFFECT_ALLOW
|
||||
roles:
|
||||
- admin
|
||||
- user
|
||||
condition:
|
||||
match:
|
||||
expr: request.principal.workspaceIds.includes(request.resource.workspaceId)
|
||||
22
modules/workspace/cerbos/resources.ts
Normal file
22
modules/workspace/cerbos/resources.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import z from "zod";
|
||||
|
||||
/*
|
||||
export const resources = new ResourceRegistry([
|
||||
{
|
||||
kind: "workspace",
|
||||
actions: [],
|
||||
attr: {
|
||||
workspaceId: z.string(),
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "workspace_user",
|
||||
actions: [],
|
||||
attr: {
|
||||
workspaceId: z.string(),
|
||||
},
|
||||
},
|
||||
] as const);
|
||||
|
||||
export type Resource = typeof resources.$resource;
|
||||
*/
|
||||
0
modules/workspace/client.ts
Normal file
0
modules/workspace/client.ts
Normal file
27
modules/workspace/database.ts
Normal file
27
modules/workspace/database.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getDatabaseAccessor } from "@platform/database/accessor.ts";
|
||||
|
||||
import { parseWorkspace, type Workspace } from "./models/workspace.ts";
|
||||
import { 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));
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import { MongoAdapter } from "@valkyr/event-store/mongo";
|
||||
*/
|
||||
|
||||
const eventFactory = new EventFactory([
|
||||
...(await import("./events/code.ts")).default,
|
||||
...(await import("./events/identity.ts")).default,
|
||||
...(await import("./events/workspace.ts")).default,
|
||||
...(await import("./events/workspace-user.ts")).default,
|
||||
]);
|
||||
|
||||
/*
|
||||
@@ -20,7 +20,7 @@ const eventFactory = new EventFactory([
|
||||
*/
|
||||
|
||||
export const eventStore = new EventStore({
|
||||
adapter: new MongoAdapter(() => container.get("mongo"), `identity:event-store`),
|
||||
adapter: new MongoAdapter(() => container.get("mongo"), `workspace:event-store`),
|
||||
events: eventFactory,
|
||||
snapshot: "auto",
|
||||
});
|
||||
23
modules/workspace/events/workspace-user.ts
Normal file
23
modules/workspace/events/workspace-user.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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),
|
||||
];
|
||||
19
modules/workspace/events/workspace.ts
Normal file
19
modules/workspace/events/workspace.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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),
|
||||
];
|
||||
38
modules/workspace/models/workspace-user.ts
Normal file
38
modules/workspace/models/workspace-user.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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>;
|
||||
32
modules/workspace/models/workspace.ts
Normal file
32
modules/workspace/models/workspace.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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>;
|
||||
19
modules/workspace/package.json
Normal file
19
modules/workspace/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"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": {
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
20
modules/workspace/routes/workspaces/create/handle.ts
Normal file
20
modules/workspace/routes/workspaces/create/handle.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
14
modules/workspace/routes/workspaces/create/spec.ts
Normal file
14
modules/workspace/routes/workspaces/create/spec.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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);
|
||||
30
modules/workspace/server.ts
Normal file
30
modules/workspace/server.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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 { 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);
|
||||
},
|
||||
};
|
||||
13
modules/workspace/value-objects/contact.ts
Normal file
13
modules/workspace/value-objects/contact.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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>;
|
||||
Reference in New Issue
Block a user