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

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

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

View File

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

View File

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

View File

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

View File

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