Template
1
0

feat: add supertokens

This commit is contained in:
2025-09-24 01:20:09 +02:00
parent 0d70749670
commit 99111b69eb
92 changed files with 1613 additions and 1141 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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"]

View 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;
*/

View File

@@ -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 ...
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
import { importVault } from "@platform/vault";
import { config } from "../../../config.ts";
export const vault = importVault(config.internal);

View File

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

View 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 });
});

View 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]);

View File

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

View File

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

View File

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

View File

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

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

View 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(),
}),
);

View File

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

View 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());

View 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) });
});

View 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]);

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View 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

View 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)

View 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;
*/

View File

View 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));
}

View File

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

View 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),
];

View 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),
];

View 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>;

View 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>;

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

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

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

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

View 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>;