feat: add cerbos access control
This commit is contained in:
9
.editoconfig
Normal file
9
.editoconfig
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
FROM denoland/deno:2.3.1
|
FROM denoland/deno:2.5.1
|
||||||
|
|
||||||
ENV TZ=UTC
|
ENV TZ=UTC
|
||||||
ENV PORT=8370
|
ENV PORT=8370
|
||||||
|
|
||||||
EXPOSE 8370
|
EXPOSE 8370
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -10,10 +12,6 @@ COPY relay/ ./relay/
|
|||||||
COPY .npmrc .
|
COPY .npmrc .
|
||||||
COPY deno-docker.json ./deno.json
|
COPY deno-docker.json ./deno.json
|
||||||
|
|
||||||
RUN chown -R deno:deno /app/
|
|
||||||
|
|
||||||
USER deno
|
|
||||||
|
|
||||||
RUN deno install --allow-scripts
|
RUN deno install --allow-scripts
|
||||||
|
|
||||||
CMD ["sh", "-c", "deno run --allow-all ./api/.tasks/migrate.ts && deno run --allow-all ./api/server.ts"]
|
CMD ["sh", "-c", "deno run --allow-all ./api/.tasks/migrate.ts && deno run --allow-all ./api/server.ts"]
|
||||||
19
api/.bruno/account/GetById.bru
Normal file
19
api/.bruno/account/GetById.bru
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
meta {
|
||||||
|
name: Get By ID
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{url}}/accounts/:id
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
params:path {
|
||||||
|
id:
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
}
|
||||||
19
api/libraries/auth/access.ts
Normal file
19
api/libraries/auth/access.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { cerbos } from "./cerbos.ts";
|
||||||
|
import type { Principal } from "./principal.ts";
|
||||||
|
import { Resource } from "./resources.ts";
|
||||||
|
|
||||||
|
export function access(principal: Principal) {
|
||||||
|
return {
|
||||||
|
isAllowed(resource: Resource, action: string) {
|
||||||
|
return cerbos.isAllowed({ principal, resource, action });
|
||||||
|
},
|
||||||
|
checkResource(resource: Resource, actions: string[]) {
|
||||||
|
return cerbos.checkResource({ principal, resource, actions });
|
||||||
|
},
|
||||||
|
checkResources(resources: { resource: Resource; actions: string[] }[]) {
|
||||||
|
return cerbos.checkResources({ principal, resources });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Access = ReturnType<typeof access>;
|
||||||
@@ -1,82 +1,21 @@
|
|||||||
import { Auth, ResolvedSession } from "@valkyr/auth";
|
import { Auth } from "@valkyr/auth";
|
||||||
import z from "zod";
|
|
||||||
|
|
||||||
import { db } from "~stores/read-store/database.ts";
|
|
||||||
|
|
||||||
|
import { access } from "./access.ts";
|
||||||
import { config } from "./config.ts";
|
import { config } from "./config.ts";
|
||||||
|
import { principal } from "./principal.ts";
|
||||||
|
import { resources } from "./resources.ts";
|
||||||
|
|
||||||
export const auth = new Auth(
|
export const auth = new Auth({
|
||||||
{
|
principal,
|
||||||
settings: {
|
resources,
|
||||||
algorithm: "RS256",
|
access,
|
||||||
privateKey: config.privateKey,
|
jwt: {
|
||||||
publicKey: config.publicKey,
|
algorithm: "RS256",
|
||||||
issuer: "http://localhost",
|
privateKey: config.privateKey,
|
||||||
audience: "http://localhost",
|
publicKey: config.publicKey,
|
||||||
},
|
issuer: "http://localhost",
|
||||||
session: z.object({
|
audience: "http://localhost",
|
||||||
accountId: z.string(),
|
|
||||||
}),
|
|
||||||
permissions: {} as const,
|
|
||||||
guards: [],
|
|
||||||
},
|
},
|
||||||
{
|
});
|
||||||
roles: {
|
|
||||||
async add(role) {
|
|
||||||
await db.collection("roles").insertOne(role);
|
|
||||||
},
|
|
||||||
|
|
||||||
async getById(id) {
|
export type Session = typeof auth.$session;
|
||||||
const role = await db.collection("roles").findOne({ id });
|
|
||||||
if (role === null) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return role;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getBySession({ accountId }) {
|
|
||||||
const account = await db.collection("accounts").findOne({ id: accountId });
|
|
||||||
if (account === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return db
|
|
||||||
.collection("roles")
|
|
||||||
.find({ id: { $in: account.roles } })
|
|
||||||
.toArray();
|
|
||||||
},
|
|
||||||
|
|
||||||
async setPermissions() {
|
|
||||||
throw new Error("MongoRolesProvider > .setPermissions is managed by Role aggregate projections");
|
|
||||||
},
|
|
||||||
|
|
||||||
async delete(id) {
|
|
||||||
await db.collection("roles").deleteOne({ id });
|
|
||||||
},
|
|
||||||
|
|
||||||
async assignAccount(roleId: string, accountId: string): Promise<void> {
|
|
||||||
await db.collection("accounts").updateOne(
|
|
||||||
{ id: accountId },
|
|
||||||
{
|
|
||||||
$push: {
|
|
||||||
roles: roleId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async removeAccount(roleId: string, accountId: string): Promise<void> {
|
|
||||||
await db.collection("roles").updateOne(
|
|
||||||
{ id: accountId },
|
|
||||||
{
|
|
||||||
$pull: {
|
|
||||||
roles: roleId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export type Session = ResolvedSession<typeof auth>;
|
|
||||||
export type Permissions = (typeof auth)["$permissions"];
|
|
||||||
|
|||||||
8
api/libraries/auth/cerbos.ts
Normal file
8
api/libraries/auth/cerbos.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { HTTP } from "@cerbos/http";
|
||||||
|
|
||||||
|
export const cerbos = new HTTP("http://localhost:3592", {
|
||||||
|
adminCredentials: {
|
||||||
|
username: "cerbos",
|
||||||
|
password: "cerbosAdmin",
|
||||||
|
},
|
||||||
|
});
|
||||||
18
api/libraries/auth/principal.ts
Normal file
18
api/libraries/auth/principal.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { RoleSchema } from "@spec/schemas/account/role.ts";
|
||||||
|
import { PrincipalProvider } from "@valkyr/auth";
|
||||||
|
|
||||||
|
import { db } from "~stores/read-store/database.ts";
|
||||||
|
|
||||||
|
export const principal = new PrincipalProvider(RoleSchema, {}, async function (id: string) {
|
||||||
|
const account = await db.collection("accounts").findOne({ id });
|
||||||
|
if (account === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
roles: account.roles,
|
||||||
|
attributes: {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Principal = typeof principal.$principal;
|
||||||
10
api/libraries/auth/resources.ts
Normal file
10
api/libraries/auth/resources.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ResourceRegistry } from "@valkyr/auth";
|
||||||
|
|
||||||
|
export const resources = new ResourceRegistry([
|
||||||
|
{
|
||||||
|
kind: "account",
|
||||||
|
attributes: {},
|
||||||
|
},
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
export type Resource = typeof resources.$resource;
|
||||||
@@ -2,9 +2,9 @@ import { parseArgs } from "@std/cli";
|
|||||||
|
|
||||||
import { Parser, toString } from "./parsers.ts";
|
import { Parser, toString } from "./parsers.ts";
|
||||||
|
|
||||||
export function getArgsVariable(key: string, fallback?: string): string;
|
export function getArgsVariable(key: string, fallback?: any): string;
|
||||||
export function getArgsVariable<T extends Parser>(key: string, parse: T, fallback?: string): ReturnType<T>;
|
export function getArgsVariable<T extends Parser>(key: string, parse: T, fallback?: any): ReturnType<T>;
|
||||||
export function getArgsVariable<T extends Parser>(key: string, parse?: T, fallback?: string): ReturnType<T> {
|
export function getArgsVariable<T extends Parser>(key: string, parse?: T, fallback?: any): ReturnType<T> {
|
||||||
if (typeof parse === "string") {
|
if (typeof parse === "string") {
|
||||||
fallback = parse;
|
fallback = parse;
|
||||||
parse = undefined;
|
parse = undefined;
|
||||||
@@ -17,5 +17,5 @@ export function getArgsVariable<T extends Parser>(key: string, parse?: T, fallba
|
|||||||
}
|
}
|
||||||
throw new Error(`Config Exception: Missing ${key} variable in arguments`);
|
throw new Error(`Config Exception: Missing ${key} variable in arguments`);
|
||||||
}
|
}
|
||||||
return parse ? parse(value) : toString(value);
|
return parse ? parse(value) : (toString(value) as any);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ const env = await load();
|
|||||||
* @param key - Environment key to resolve.
|
* @param key - Environment key to resolve.
|
||||||
* @param parse - Parser function to convert the value to the desired type. Default: `string`.
|
* @param parse - Parser function to convert the value to the desired type. Default: `string`.
|
||||||
*/
|
*/
|
||||||
export function getEnvironmentVariable(key: string, fallback?: string): string;
|
export function getEnvironmentVariable(key: string, fallback?: any): string;
|
||||||
export function getEnvironmentVariable<T extends Parser>(key: string, parse: T, fallback?: string): ReturnType<T>;
|
export function getEnvironmentVariable<T extends Parser>(key: string, parse: T, fallback?: any): ReturnType<T>;
|
||||||
export function getEnvironmentVariable<T extends Parser>(key: string, parse?: T, fallback?: string): ReturnType<T> {
|
export function getEnvironmentVariable<T extends Parser>(key: string, parse?: T, fallback?: any): ReturnType<T> {
|
||||||
if (typeof parse === "string") {
|
if (typeof parse === "string") {
|
||||||
fallback = parse;
|
fallback = parse;
|
||||||
parse = undefined;
|
parse = undefined;
|
||||||
@@ -25,7 +25,7 @@ export function getEnvironmentVariable<T extends Parser>(key: string, parse?: T,
|
|||||||
}
|
}
|
||||||
throw new Error(`Config Exception: Missing ${key} variable in configuration`);
|
throw new Error(`Config Exception: Missing ${key} variable in configuration`);
|
||||||
}
|
}
|
||||||
return parse ? parse(value) : toString(value);
|
return parse ? parse(value) : (toString(value) as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export class Api {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.state.access === "session" && req.isAuthenticated === false) {
|
if (route.state.access === "authenticated" && req.isAuthenticated === false) {
|
||||||
return toResponse(new UnauthorizedError(), request);
|
return toResponse(new UnauthorizedError(), request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { ServerContext } from "@spec/relay";
|
|||||||
|
|
||||||
import type { Sockets } from "~libraries/socket/sockets.ts";
|
import type { Sockets } from "~libraries/socket/sockets.ts";
|
||||||
|
|
||||||
|
import { Access } from "../auth/access.ts";
|
||||||
import { Session } from "../auth/auth.ts";
|
import { Session } from "../auth/auth.ts";
|
||||||
|
import { Principal } from "../auth/principal.ts";
|
||||||
import { req } from "./request.ts";
|
import { req } from "./request.ts";
|
||||||
|
|
||||||
declare module "@spec/relay" {
|
declare module "@spec/relay" {
|
||||||
@@ -17,17 +19,21 @@ declare module "@spec/relay" {
|
|||||||
*/
|
*/
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
|
|
||||||
/**
|
|
||||||
* Get account id from session, throws an error if the request
|
|
||||||
* does not have a valid session.
|
|
||||||
*/
|
|
||||||
accountId: string;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get request session instance.
|
* Get request session instance.
|
||||||
*/
|
*/
|
||||||
session: Session;
|
session: Session;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get request principal.
|
||||||
|
*/
|
||||||
|
principal: Principal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get access control session.
|
||||||
|
*/
|
||||||
|
access: Access;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sockets instance attached to the server.
|
* Sockets instance attached to the server.
|
||||||
*/
|
*/
|
||||||
@@ -43,14 +49,18 @@ export function getRequestContext(request: Request): ServerContext {
|
|||||||
return req.isAuthenticated;
|
return req.isAuthenticated;
|
||||||
},
|
},
|
||||||
|
|
||||||
get accountId() {
|
|
||||||
return this.session.accountId;
|
|
||||||
},
|
|
||||||
|
|
||||||
get session(): Session {
|
get session(): Session {
|
||||||
return req.session;
|
return req.session;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
get principal(): Principal {
|
||||||
|
return req.session.principal;
|
||||||
|
},
|
||||||
|
|
||||||
|
get access(): Access {
|
||||||
|
return req.session.access;
|
||||||
|
},
|
||||||
|
|
||||||
get sockets(): Sockets {
|
get sockets(): Sockets {
|
||||||
return req.sockets;
|
return req.sockets;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { InternalServerError, UnauthorizedError } from "@spec/relay";
|
import { InternalServerError, UnauthorizedError } from "@spec/relay";
|
||||||
|
|
||||||
import { Session } from "../auth/auth.ts";
|
import { Session } from "../auth/auth.ts";
|
||||||
import { asyncLocalStorage } from "./storage.ts";
|
import { storage } from "./storage.ts";
|
||||||
|
|
||||||
export const req = {
|
export const req = {
|
||||||
get store() {
|
get store() {
|
||||||
const store = asyncLocalStorage.getStore();
|
const store = storage.getStore();
|
||||||
if (store === undefined) {
|
if (store === undefined) {
|
||||||
throw new InternalServerError("AsyncLocalStorage not defined.");
|
throw new InternalServerError("AsyncLocalStorage not defined.");
|
||||||
}
|
}
|
||||||
@@ -55,7 +55,7 @@ export const req = {
|
|||||||
* Typically used when utility functions might run in and out of request scope.
|
* Typically used when utility functions might run in and out of request scope.
|
||||||
*/
|
*/
|
||||||
getStore() {
|
getStore() {
|
||||||
return asyncLocalStorage.getStore();
|
return storage.getStore();
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|||||||
import type { Session } from "~libraries/auth/mod.ts";
|
import type { Session } from "~libraries/auth/mod.ts";
|
||||||
import type { Sockets } from "~libraries/socket/sockets.ts";
|
import type { Sockets } from "~libraries/socket/sockets.ts";
|
||||||
|
|
||||||
export const asyncLocalStorage = new AsyncLocalStorage<{
|
export const storage = new AsyncLocalStorage<Storage>();
|
||||||
|
|
||||||
|
export type Storage = {
|
||||||
session?: Session;
|
session?: Session;
|
||||||
info: {
|
info: {
|
||||||
method: string;
|
method: string;
|
||||||
@@ -14,4 +16,4 @@ export const asyncLocalStorage = new AsyncLocalStorage<{
|
|||||||
response: {
|
response: {
|
||||||
headers: Headers;
|
headers: Headers;
|
||||||
};
|
};
|
||||||
}>();
|
};
|
||||||
|
|||||||
@@ -5,20 +5,22 @@
|
|||||||
"migrate": "deno run --allow-all .tasks/migrate.ts"
|
"migrate": "deno run --allow-all .tasks/migrate.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@felix/bcrypt": "npm:@jsr/felix__bcrypt@1",
|
"@cerbos/grpc": "0.23.1",
|
||||||
|
"@cerbos/http": "0.23.1",
|
||||||
|
"@felix/bcrypt": "npm:@jsr/felix__bcrypt@1.0.5",
|
||||||
"@spec/modules": "workspace:*",
|
"@spec/modules": "workspace:*",
|
||||||
"@spec/relay": "workspace:*",
|
"@spec/relay": "workspace:*",
|
||||||
"@spec/shared": "workspace:*",
|
"@spec/shared": "workspace:*",
|
||||||
"@std/cli": "npm:@jsr/std__cli@1",
|
"@std/cli": "npm:@jsr/std__cli@1.0.22",
|
||||||
"@std/dotenv": "npm:@jsr/std__dotenv@0.225",
|
"@std/dotenv": "npm:@jsr/std__dotenv@0.225.5",
|
||||||
"@std/fs": "npm:@jsr/std__fs@1",
|
"@std/fs": "npm:@jsr/std__fs@1.0.19",
|
||||||
"@std/path": "npm:@jsr/std__path@1",
|
"@std/path": "npm:@jsr/std__path@1.1.2",
|
||||||
"@valkyr/auth": "npm:@jsr/valkyr__auth@2",
|
"@valkyr/auth": "npm:@jsr/valkyr__auth@2.1.3",
|
||||||
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.0-beta.6",
|
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2",
|
||||||
"@valkyr/inverse": "npm:@jsr/valkyr__inverse@1",
|
"@valkyr/inverse": "npm:@jsr/valkyr__inverse@1.0.1",
|
||||||
"@valkyr/json-rpc": "npm:@jsr/valkyr__json-rpc@1",
|
"@valkyr/json-rpc": "npm:@jsr/valkyr__json-rpc@1.1.0",
|
||||||
"cookie": "1",
|
"cookie": "1.0.2",
|
||||||
"mongodb": "6",
|
"mongodb": "6.20.0",
|
||||||
"zod": "4"
|
"zod": "4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ export default create.access("public").handle(async ({ body: { name, email } })
|
|||||||
.create()
|
.create()
|
||||||
.addName(name)
|
.addName(name)
|
||||||
.addEmailStrategy(email)
|
.addEmailStrategy(email)
|
||||||
|
.addRole("user")
|
||||||
.save()
|
.save()
|
||||||
.then((account) => account.id);
|
.then((account) => account.id);
|
||||||
});
|
});
|
||||||
|
|||||||
17
api/routes/account/get-by-id.ts
Normal file
17
api/routes/account/get-by-id.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { ForbiddenError } from "@spec/relay/mod.ts";
|
||||||
|
import { NotFoundError } from "@spec/relay/mod.ts";
|
||||||
|
import { getById } from "@spec/schemas/account/routes.ts";
|
||||||
|
|
||||||
|
import { db } from "~stores/read-store/database.ts";
|
||||||
|
|
||||||
|
export default getById.access("authenticated").handle(async ({ params: { id } }, { access }) => {
|
||||||
|
const account = await db.collection("accounts").findOne({ id });
|
||||||
|
if (account === null) {
|
||||||
|
return new NotFoundError();
|
||||||
|
}
|
||||||
|
const decision = await access.isAllowed({ kind: "account", id: account.id, attributes: {} }, "read");
|
||||||
|
if (decision === false) {
|
||||||
|
return new ForbiddenError();
|
||||||
|
}
|
||||||
|
return account;
|
||||||
|
});
|
||||||
@@ -70,7 +70,7 @@ export default code.access("public").handle(async ({ params: { accountId, codeId
|
|||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
location: next,
|
location: next,
|
||||||
"set-cookie": cookie.serialize("token", await auth.generate({ accountId: account.id }, "1 week"), options),
|
"set-cookie": cookie.serialize("token", await auth.generate({ id: account.id }, "1 week"), options),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,7 @@ export default code.access("public").handle(async ({ params: { accountId, codeId
|
|||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"set-cookie": cookie.serialize("token", await auth.generate({ accountId: account.id }, "1 week"), options),
|
"set-cookie": cookie.serialize("token", await auth.generate({ id: account.id }, "1 week"), options),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { password } from "~libraries/crypto/mod.ts";
|
|||||||
import { logger } from "~libraries/logger/mod.ts";
|
import { logger } from "~libraries/logger/mod.ts";
|
||||||
import { getPasswordStrategyByAlias } from "~stores/read-store/methods.ts";
|
import { getPasswordStrategyByAlias } from "~stores/read-store/methods.ts";
|
||||||
|
|
||||||
export default route.handle(async ({ body: { alias, password: userPassword } }) => {
|
export default route.access("public").handle(async ({ body: { alias, password: userPassword } }) => {
|
||||||
const strategy = await getPasswordStrategyByAlias(alias);
|
const strategy = await getPasswordStrategyByAlias(alias);
|
||||||
if (strategy === undefined) {
|
if (strategy === undefined) {
|
||||||
return logger.info({
|
return logger.info({
|
||||||
@@ -28,7 +28,7 @@ export default route.handle(async ({ body: { alias, password: userPassword } })
|
|||||||
headers: {
|
headers: {
|
||||||
"set-cookie": cookie.serialize(
|
"set-cookie": cookie.serialize(
|
||||||
"token",
|
"token",
|
||||||
await auth.generate({ accountId: strategy.accountId }, "1 week"),
|
await auth.generate({ id: strategy.accountId }, "1 week"),
|
||||||
config.cookie(1000 * 60 * 60 * 24 * 7),
|
config.cookie(1000 * 60 * 60 * 24 * 7),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { session } from "@spec/schemas/auth/routes.ts";
|
|||||||
|
|
||||||
import { getAccountById } from "~stores/read-store/methods.ts";
|
import { getAccountById } from "~stores/read-store/methods.ts";
|
||||||
|
|
||||||
export default session.access("session").handle(async ({ accountId }) => {
|
export default session.access("authenticated").handle(async ({ principal }) => {
|
||||||
const account = await getAccountById(accountId);
|
const account = await getAccountById(principal.id);
|
||||||
if (account === undefined) {
|
if (account === undefined) {
|
||||||
return new UnauthorizedError();
|
return new UnauthorizedError();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import cookie from "cookie";
|
|||||||
|
|
||||||
import { auth, type Session } from "~libraries/auth/mod.ts";
|
import { auth, type Session } from "~libraries/auth/mod.ts";
|
||||||
import { logger } from "~libraries/logger/mod.ts";
|
import { logger } from "~libraries/logger/mod.ts";
|
||||||
import { asyncLocalStorage } from "~libraries/server/mod.ts";
|
import { type Storage, storage } from "~libraries/server/mod.ts";
|
||||||
import { Api, resolveRoutes } from "~libraries/server/mod.ts";
|
import { Api, resolveRoutes } from "~libraries/server/mod.ts";
|
||||||
|
|
||||||
import { config } from "./config.ts";
|
import { config } from "./config.ts";
|
||||||
@@ -45,8 +45,6 @@ Deno.serve(
|
|||||||
async (request) => {
|
async (request) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
// ### Session
|
|
||||||
|
|
||||||
let session: Session | undefined;
|
let session: Session | undefined;
|
||||||
|
|
||||||
const token = cookie.parse(request.headers.get("cookie") ?? "").token;
|
const token = cookie.parse(request.headers.get("cookie") ?? "").token;
|
||||||
@@ -63,31 +61,23 @@ Deno.serve(
|
|||||||
session = resolved;
|
session = resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ### Headers
|
const context = {
|
||||||
// Set the default headers.
|
session,
|
||||||
|
info: {
|
||||||
const headers = new Headers();
|
method: request.url,
|
||||||
|
start: Date.now(),
|
||||||
// ### Handle
|
|
||||||
|
|
||||||
const ts = performance.now();
|
|
||||||
|
|
||||||
return asyncLocalStorage.run(
|
|
||||||
{
|
|
||||||
session,
|
|
||||||
info: {
|
|
||||||
method: request.url,
|
|
||||||
start: Date.now(),
|
|
||||||
},
|
|
||||||
response: {
|
|
||||||
headers,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
async () => {
|
response: {
|
||||||
return api.fetch(request).finally(() => {
|
headers: new Headers(),
|
||||||
log.info(`${request.method} ${url.pathname} [${((performance.now() - ts) / 1000).toLocaleString()} seconds]`);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
} satisfies Storage;
|
||||||
|
|
||||||
|
return storage.run(context, async () => {
|
||||||
|
return api.fetch(request).finally(() => {
|
||||||
|
log.info(
|
||||||
|
`${request.method} ${url.pathname} [${((Date.now() - context.info.start) / 1000).toLocaleString()} seconds]`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { toAccountDocument } from "@spec/schemas/account/account.ts";
|
import { toAccountDocument } from "@spec/schemas/account/account.ts";
|
||||||
|
import { Role } from "@spec/schemas/account/role.ts";
|
||||||
import { Strategy } from "@spec/schemas/account/strategies.ts";
|
import { Strategy } from "@spec/schemas/account/strategies.ts";
|
||||||
import { Avatar } from "@spec/schemas/avatar.ts";
|
import { Avatar } from "@spec/schemas/avatar.ts";
|
||||||
import { Contact } from "@spec/schemas/contact.ts";
|
import { Contact } from "@spec/schemas/contact.ts";
|
||||||
@@ -22,6 +23,7 @@ export class Account extends AggregateRoot<EventStoreFactory> {
|
|||||||
emails: [],
|
emails: [],
|
||||||
};
|
};
|
||||||
strategies: Strategy[] = [];
|
strategies: Strategy[] = [];
|
||||||
|
roles: Role[] = [];
|
||||||
|
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
@@ -51,6 +53,11 @@ export class Account extends AggregateRoot<EventStoreFactory> {
|
|||||||
this.updatedAt = getDate(event.created);
|
this.updatedAt = getDate(event.created);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "account:role:added": {
|
||||||
|
this.roles.push(event.data);
|
||||||
|
this.updatedAt = getDate(event.created);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "strategy:email:added": {
|
case "strategy:email:added": {
|
||||||
this.strategies.push({ type: "email", value: event.data });
|
this.strategies.push({ type: "email", value: event.data });
|
||||||
this.updatedAt = getDate(event.created);
|
this.updatedAt = getDate(event.created);
|
||||||
@@ -103,11 +110,11 @@ export class Account extends AggregateRoot<EventStoreFactory> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
addRole(roleId: string, meta: Auditor = systemAuditor): this {
|
addRole(role: Role, meta: Auditor = systemAuditor): this {
|
||||||
return this.push({
|
return this.push({
|
||||||
stream: this.id,
|
stream: this.id,
|
||||||
type: "account:role:added",
|
type: "account:role:added",
|
||||||
data: roleId,
|
data: role,
|
||||||
meta,
|
meta,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -194,8 +201,8 @@ projector.on("account:email:added", async ({ stream: id, data: email }) => {
|
|||||||
await db.collection("accounts").updateOne({ id }, { $push: { "contact.emails": email } });
|
await db.collection("accounts").updateOne({ id }, { $push: { "contact.emails": email } });
|
||||||
});
|
});
|
||||||
|
|
||||||
projector.on("account:role:added", async ({ stream: id, data: roleId }) => {
|
projector.on("account:role:added", async ({ stream: id, data: role }) => {
|
||||||
await db.collection("accounts").updateOne({ id }, { $push: { roles: roleId } });
|
await db.collection("accounts").updateOne({ id }, { $push: { roles: role } });
|
||||||
});
|
});
|
||||||
|
|
||||||
projector.on("strategy:email:added", async ({ stream: id, data: email }) => {
|
projector.on("strategy:email:added", async ({ stream: id, data: email }) => {
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
|
|
||||||
|
|
||||||
import { db } from "~libraries/read-store/database.ts";
|
|
||||||
|
|
||||||
import type { Auditor } from "../events/auditor.ts";
|
|
||||||
import { EventStoreFactory } from "../events/mod.ts";
|
|
||||||
import type { RoleCreatedData, RolePermissionOperation } from "../events/role.ts";
|
|
||||||
import { projector } from "../projector.ts";
|
|
||||||
|
|
||||||
export class Role extends AggregateRoot<EventStoreFactory> {
|
|
||||||
static override readonly name = "role";
|
|
||||||
|
|
||||||
id!: string;
|
|
||||||
|
|
||||||
name!: string;
|
|
||||||
permissions: { [resource: string]: Set<string> } = {};
|
|
||||||
|
|
||||||
createdAt!: Date;
|
|
||||||
updatedAt!: Date;
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Factories
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
static #reducer = makeAggregateReducer(Role);
|
|
||||||
|
|
||||||
static create(data: RoleCreatedData, meta: Auditor): Role {
|
|
||||||
return new Role().push({
|
|
||||||
type: "role:created",
|
|
||||||
data,
|
|
||||||
meta,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getById(stream: string): Promise<Role | undefined> {
|
|
||||||
return this.$store.reduce({ name: "role", stream, reducer: this.#reducer });
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Reducer
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
override with(event: EventStoreFactory["$events"][number]["$record"]): void {
|
|
||||||
switch (event.type) {
|
|
||||||
case "role:created": {
|
|
||||||
this.id = event.stream;
|
|
||||||
this.createdAt = getDate(event.created);
|
|
||||||
this.updatedAt = getDate(event.created);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "role:name-set": {
|
|
||||||
this.name = event.data;
|
|
||||||
this.updatedAt = getDate(event.created);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "role:permissions-set": {
|
|
||||||
for (const operation of event.data) {
|
|
||||||
if (operation.type === "grant") {
|
|
||||||
if (this.permissions[operation.resource] === undefined) {
|
|
||||||
this.permissions[operation.resource] = new Set();
|
|
||||||
}
|
|
||||||
this.permissions[operation.resource].add(operation.action);
|
|
||||||
}
|
|
||||||
if (operation.type === "deny") {
|
|
||||||
if (operation.action === undefined) {
|
|
||||||
delete this.permissions[operation.resource];
|
|
||||||
} else {
|
|
||||||
this.permissions[operation.resource]?.delete(operation.action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Actions
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
setName(name: string, meta: Auditor): this {
|
|
||||||
return this.push({
|
|
||||||
type: "role:name-set",
|
|
||||||
stream: this.id,
|
|
||||||
data: name,
|
|
||||||
meta,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setPermissions(operations: RolePermissionOperation[], meta: Auditor): this {
|
|
||||||
return this.push({
|
|
||||||
type: "role:permissions-set",
|
|
||||||
stream: this.id,
|
|
||||||
data: operations,
|
|
||||||
meta,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------------
|
|
||||||
| Projectors
|
|
||||||
|--------------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
projector.on("role:created", async ({ stream, data: { name, permissions } }) => {
|
|
||||||
await db.collection("roles").insertOne({
|
|
||||||
id: stream,
|
|
||||||
name,
|
|
||||||
permissions: permissions.reduce(
|
|
||||||
(map, permission) => {
|
|
||||||
map[permission.resource] = permission.actions;
|
|
||||||
return map;
|
|
||||||
},
|
|
||||||
{} as Record<string, string[]>,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { RoleSchema } from "@spec/schemas/account/role.ts";
|
||||||
import { EmailSchema } from "@spec/schemas/email.ts";
|
import { EmailSchema } from "@spec/schemas/email.ts";
|
||||||
import { NameSchema } from "@spec/schemas/name.ts";
|
import { NameSchema } from "@spec/schemas/name.ts";
|
||||||
import { event } from "@valkyr/event-store";
|
import { event } from "@valkyr/event-store";
|
||||||
@@ -10,5 +11,5 @@ export default [
|
|||||||
event.type("account:avatar:added").data(z.string()).meta(AuditorSchema),
|
event.type("account:avatar:added").data(z.string()).meta(AuditorSchema),
|
||||||
event.type("account:name:added").data(NameSchema).meta(AuditorSchema),
|
event.type("account:name:added").data(NameSchema).meta(AuditorSchema),
|
||||||
event.type("account:email:added").data(EmailSchema).meta(AuditorSchema),
|
event.type("account:email:added").data(EmailSchema).meta(AuditorSchema),
|
||||||
event.type("account:role:added").data(z.string()).meta(AuditorSchema),
|
event.type("account:role:added").data(RoleSchema).meta(AuditorSchema),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import { EventFactory } from "@valkyr/event-store";
|
|||||||
import account from "./account.ts";
|
import account from "./account.ts";
|
||||||
import code from "./code.ts";
|
import code from "./code.ts";
|
||||||
import organization from "./organization.ts";
|
import organization from "./organization.ts";
|
||||||
import role from "./role.ts";
|
|
||||||
import strategy from "./strategy.ts";
|
import strategy from "./strategy.ts";
|
||||||
|
|
||||||
export const events = new EventFactory([...account, ...code, ...organization, ...role, ...strategy]);
|
export const events = new EventFactory([...account, ...code, ...organization, ...strategy]);
|
||||||
|
|
||||||
export type EventStoreFactory = typeof events;
|
export type EventStoreFactory = typeof events;
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { event } from "@valkyr/event-store";
|
|
||||||
import z from "zod";
|
|
||||||
|
|
||||||
import { AuditorSchema } from "./auditor.ts";
|
|
||||||
|
|
||||||
const CreatedSchema = z.object({
|
|
||||||
name: z.string(),
|
|
||||||
permissions: z.array(
|
|
||||||
z.object({
|
|
||||||
resource: z.string(),
|
|
||||||
actions: z.array(z.string()),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const OperationSchema = z.discriminatedUnion("type", [
|
|
||||||
z.object({
|
|
||||||
type: z.literal("grant"),
|
|
||||||
resource: z.string(),
|
|
||||||
action: z.string(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
type: z.literal("deny"),
|
|
||||||
resource: z.string(),
|
|
||||||
action: z.string().optional(),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export default [
|
|
||||||
event.type("role:created").data(CreatedSchema).meta(AuditorSchema),
|
|
||||||
event.type("role:name-set").data(z.string()).meta(AuditorSchema),
|
|
||||||
event.type("role:permissions-set").data(z.array(OperationSchema)).meta(AuditorSchema),
|
|
||||||
];
|
|
||||||
|
|
||||||
export type RoleCreatedData = z.infer<typeof CreatedSchema>;
|
|
||||||
|
|
||||||
export type RolePermissionOperation = z.infer<typeof OperationSchema>;
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { RoleDocument } from "@spec/schemas/access/role.ts";
|
|
||||||
import type { AccountDocument } from "@spec/schemas/account/account.ts";
|
import type { AccountDocument } from "@spec/schemas/account/account.ts";
|
||||||
|
|
||||||
import { config } from "~config";
|
import { config } from "~config";
|
||||||
@@ -6,7 +5,6 @@ import { getDatabaseAccessor } from "~libraries/database/accessor.ts";
|
|||||||
|
|
||||||
export const db = getDatabaseAccessor<{
|
export const db = getDatabaseAccessor<{
|
||||||
accounts: AccountDocument;
|
accounts: AccountDocument;
|
||||||
roles: RoleDocument;
|
|
||||||
}>(`${config.name}:read-store`);
|
}>(`${config.name}:read-store`);
|
||||||
|
|
||||||
export function takeOne<TDocument>(documents: TDocument[]): TDocument | undefined {
|
export function takeOne<TDocument>(documents: TDocument[]): TDocument | undefined {
|
||||||
|
|||||||
@@ -12,29 +12,29 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@spec/relay": "workspace:*",
|
"@spec/relay": "workspace:*",
|
||||||
"@spec/schemas": "workspace:*",
|
"@spec/schemas": "workspace:*",
|
||||||
"@tanstack/react-query": "5",
|
"@tanstack/react-query": "5.89.0",
|
||||||
"@tanstack/react-router": "1",
|
"@tanstack/react-router": "1.131.47",
|
||||||
"@valkyr/db": "npm:@jsr/valkyr__db@2.0.0-beta.3",
|
"@valkyr/db": "npm:@jsr/valkyr__db@2.0.0",
|
||||||
"@valkyr/event-emitter": "npm:@jsr/valkyr__event-emitter@1",
|
"@valkyr/event-emitter": "npm:@jsr/valkyr__event-emitter@1.0.1",
|
||||||
"fast-equals": "5",
|
"fast-equals": "5.2.2",
|
||||||
"react": "19",
|
"react": "19.1.1",
|
||||||
"react-dom": "19",
|
"react-dom": "19.1.1",
|
||||||
"tailwindcss": "4",
|
"tailwindcss": "4.1.13",
|
||||||
"zod": "4"
|
"zod": "4.1.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "9",
|
"@eslint/js": "9.35.0",
|
||||||
"@tailwindcss/vite": "4",
|
"@tailwindcss/vite": "4.1.13",
|
||||||
"@tanstack/react-router-devtools": "1",
|
"@tanstack/react-router-devtools": "1.131.47",
|
||||||
"@types/react": "19",
|
"@types/react": "19.1.13",
|
||||||
"@types/react-dom": "19",
|
"@types/react-dom": "19.1.9",
|
||||||
"@vitejs/plugin-react": "4",
|
"@vitejs/plugin-react": "4.7.0",
|
||||||
"eslint": "9",
|
"eslint": "9.35.0",
|
||||||
"eslint-plugin-react-hooks": "5",
|
"eslint-plugin-react-hooks": "5.2.0",
|
||||||
"eslint-plugin-react-refresh": "0.4",
|
"eslint-plugin-react-refresh": "0.4.20",
|
||||||
"globals": "16",
|
"globals": "16.4.0",
|
||||||
"typescript": "5",
|
"typescript": "5.9.2",
|
||||||
"typescript-eslint": "8",
|
"typescript-eslint": "8.44.0",
|
||||||
"vite": "7"
|
"vite": "7.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
cerbos/config.yaml
Normal file
14
cerbos/config.yaml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
server:
|
||||||
|
adminAPI:
|
||||||
|
enabled: true
|
||||||
|
adminCredentials:
|
||||||
|
username: cerbos
|
||||||
|
passwordHash: JDJ5JDEwJDc5VzBkQ0NUWHFTT3N1OW9xZkx5ZC43M0tuM0JBSTU0dVRsMVBkOEtuYVBCaWFzVXk5d0phCgo=
|
||||||
|
httpListenAddr: ":3592"
|
||||||
|
grpcListenAddr: ":3593"
|
||||||
|
|
||||||
|
storage:
|
||||||
|
driver: disk
|
||||||
|
disk:
|
||||||
|
directory: /data/policies
|
||||||
|
watchForChanges: true
|
||||||
47
cerbos/policies/account.yaml
Normal file
47
cerbos/policies/account.yaml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 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: account
|
||||||
|
version: default
|
||||||
|
rules:
|
||||||
|
|
||||||
|
### Read
|
||||||
|
|
||||||
|
- actions:
|
||||||
|
- read
|
||||||
|
effect: EFFECT_ALLOW
|
||||||
|
roles:
|
||||||
|
- admin
|
||||||
|
|
||||||
|
- actions:
|
||||||
|
- read
|
||||||
|
effect: EFFECT_ALLOW
|
||||||
|
roles:
|
||||||
|
- user
|
||||||
|
condition:
|
||||||
|
match:
|
||||||
|
expr: request.resource.id == request.principal.id
|
||||||
|
|
||||||
|
### Update
|
||||||
|
|
||||||
|
- actions:
|
||||||
|
- update
|
||||||
|
effect: EFFECT_ALLOW
|
||||||
|
roles:
|
||||||
|
- user
|
||||||
|
condition:
|
||||||
|
match:
|
||||||
|
expr: request.resource.id == request.principal.id
|
||||||
|
|
||||||
|
### Delete
|
||||||
|
|
||||||
|
- actions:
|
||||||
|
- delete
|
||||||
|
effect: EFFECT_ALLOW
|
||||||
|
roles:
|
||||||
|
- user
|
||||||
|
condition:
|
||||||
|
match:
|
||||||
|
expr: request.resource.id == request.principal.id
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"description": "Start react application instance."
|
"description": "Start react application instance."
|
||||||
},
|
},
|
||||||
"check": {
|
"check": {
|
||||||
"command": "deno check ./mod.ts",
|
"command": "deno check ./api/server.ts",
|
||||||
"description": "Runs a check on all the projects main entry files."
|
"description": "Runs a check on all the projects main entry files."
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
services:
|
services:
|
||||||
|
cerbos:
|
||||||
|
container_name: cerbos
|
||||||
|
image: ghcr.io/cerbos/cerbos:latest
|
||||||
|
command: ["server", "--config=/config.yaml"] # <--- ensure config is used
|
||||||
|
ports:
|
||||||
|
- "3592:3592"
|
||||||
|
- "3593:3593"
|
||||||
|
- "3594:3594"
|
||||||
|
volumes:
|
||||||
|
- ./cerbos/config.yaml:/config.yaml # <--- mount config
|
||||||
|
- ./cerbos/policies:/data/policies # <--- mount policies
|
||||||
|
networks:
|
||||||
|
- localdev
|
||||||
|
|
||||||
mongo:
|
mongo:
|
||||||
image: mongo:8
|
image: mongo:8
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@std/assert": "npm:@jsr/std__assert@1",
|
"@std/assert": "npm:@jsr/std__assert@1.0.14",
|
||||||
"@std/testing": "npm:@jsr/std__testing@1",
|
"@std/testing": "npm:@jsr/std__testing@1.0.15",
|
||||||
"eslint": "9",
|
"eslint": "9.35.0",
|
||||||
"eslint-plugin-simple-import-sort": "12",
|
"eslint-plugin-simple-import-sort": "12.1.1",
|
||||||
"prettier": "3",
|
"prettier": "3.6.2",
|
||||||
"typescript-eslint": "8"
|
"typescript-eslint": "8.44.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import z, { ZodType } from "zod";
|
import z, { ZodType } from "zod";
|
||||||
|
|
||||||
import { ServerError, ServerErrorClass } from "./errors.ts";
|
import { ServerError, ServerErrorClass } from "./errors.ts";
|
||||||
import { Access, ServerContext } from "./types.ts";
|
import { RouteAccess, ServerContext } from "./route.ts";
|
||||||
|
|
||||||
export class Procedure<const TState extends State = State> {
|
export class Procedure<const TState extends State = State> {
|
||||||
readonly type = "procedure" as const;
|
readonly type = "procedure" as const;
|
||||||
@@ -64,7 +64,7 @@ export class Procedure<const TState extends State = State> {
|
|||||||
* });
|
* });
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
access<TAccess extends Access>(access: TAccess): Procedure<Omit<TState, "access"> & { access: TAccess }> {
|
access<TAccess extends RouteAccess>(access: TAccess): Procedure<Omit<TState, "access"> & { access: TAccess }> {
|
||||||
return new Procedure({ ...this.state, access: access as TAccess });
|
return new Procedure({ ...this.state, access: access as TAccess });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +220,7 @@ export type Procedures = {
|
|||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
method: string;
|
method: string;
|
||||||
access?: Access;
|
access?: RouteAccess;
|
||||||
params?: ZodType;
|
params?: ZodType;
|
||||||
errors?: ServerErrorClass[];
|
errors?: ServerErrorClass[];
|
||||||
response?: ZodType;
|
response?: ZodType;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import z, { ZodObject, ZodRawShape, ZodType } from "zod";
|
|||||||
|
|
||||||
import { ServerError, ServerErrorClass } from "./errors.ts";
|
import { ServerError, ServerErrorClass } from "./errors.ts";
|
||||||
import { Hooks } from "./hooks.ts";
|
import { Hooks } from "./hooks.ts";
|
||||||
import { ServerContext } from "./types.ts";
|
|
||||||
|
|
||||||
export class Route<const TState extends RouteState = RouteState> {
|
export class Route<const TState extends RouteState = RouteState> {
|
||||||
readonly type = "route" as const;
|
readonly type = "route" as const;
|
||||||
@@ -81,7 +80,7 @@ export class Route<const TState extends RouteState = RouteState> {
|
|||||||
* route.post("/foo").meta({ description: "Super route" });
|
* route.post("/foo").meta({ description: "Super route" });
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
meta<TRouteMeta extends RouteMeta>(meta: TRouteMeta): Route<Omit<TState, "meta"> & { meta: TRouteMeta }> {
|
meta<TRouteMeta extends RouteMeta>(meta: TRouteMeta): Route<Prettify<Omit<TState, "meta"> & { meta: TRouteMeta }>> {
|
||||||
return new Route({ ...this.state, meta });
|
return new Route({ ...this.state, meta });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +133,7 @@ export class Route<const TState extends RouteState = RouteState> {
|
|||||||
* });
|
* });
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
access<TAccess extends RouteAccess>(access: TAccess): Route<Omit<TState, "access"> & { access: TAccess }> {
|
access<TAccess extends RouteAccess>(access: TAccess): Route<Prettify<Omit<TState, "access"> & { access: TAccess }>> {
|
||||||
return new Route({ ...this.state, access: access as TAccess });
|
return new Route({ ...this.state, access: access as TAccess });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +156,9 @@ export class Route<const TState extends RouteState = RouteState> {
|
|||||||
* });
|
* });
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
params<TParams extends ZodRawShape>(params: TParams): Route<Omit<TState, "params"> & { params: ZodObject<TParams> }> {
|
params<TParams extends ZodRawShape>(
|
||||||
|
params: TParams,
|
||||||
|
): Route<Prettify<Omit<TState, "params"> & { params: ZodObject<TParams> }>> {
|
||||||
return new Route({ ...this.state, params: z.object(params) as any });
|
return new Route({ ...this.state, params: z.object(params) as any });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +181,9 @@ export class Route<const TState extends RouteState = RouteState> {
|
|||||||
* });
|
* });
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
query<TQuery extends ZodRawShape>(query: TQuery): Route<Omit<TState, "search"> & { query: ZodObject<TQuery> }> {
|
query<TQuery extends ZodRawShape>(
|
||||||
|
query: TQuery,
|
||||||
|
): Route<Prettify<Omit<TState, "search"> & { query: ZodObject<TQuery> }>> {
|
||||||
return new Route({ ...this.state, query: z.object(query) as any });
|
return new Route({ ...this.state, query: z.object(query) as any });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +208,7 @@ export class Route<const TState extends RouteState = RouteState> {
|
|||||||
* });
|
* });
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
body<TBody extends ZodType>(body: TBody): Route<Omit<TState, "body"> & { body: TBody }> {
|
body<TBody extends ZodType>(body: TBody): Route<Prettify<Omit<TState, "body"> & { body: TBody }>> {
|
||||||
return new Route({ ...this.state, body });
|
return new Route({ ...this.state, body });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +230,9 @@ export class Route<const TState extends RouteState = RouteState> {
|
|||||||
* });
|
* });
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
errors<TErrors extends ServerErrorClass[]>(errors: TErrors): Route<Omit<TState, "errors"> & { errors: TErrors }> {
|
errors<TErrors extends ServerErrorClass[]>(
|
||||||
|
errors: TErrors,
|
||||||
|
): Route<Prettify<Omit<TState, "errors"> & { errors: TErrors }>> {
|
||||||
return new Route({ ...this.state, errors });
|
return new Route({ ...this.state, errors });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +259,9 @@ export class Route<const TState extends RouteState = RouteState> {
|
|||||||
* });
|
* });
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
response<TResponse extends ZodType>(response: TResponse): Route<Omit<TState, "response"> & { response: TResponse }> {
|
response<TResponse extends ZodType>(
|
||||||
|
response: TResponse,
|
||||||
|
): Route<Prettify<Omit<TState, "response"> & { response: TResponse }>> {
|
||||||
return new Route({ ...this.state, response });
|
return new Route({ ...this.state, response });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +299,7 @@ export class Route<const TState extends RouteState = RouteState> {
|
|||||||
*
|
*
|
||||||
* @param hooks - Hooks to register with the route.
|
* @param hooks - Hooks to register with the route.
|
||||||
*/
|
*/
|
||||||
hooks<THooks extends Hooks>(hooks: THooks): Route<Omit<TState, "hooks"> & { hooks: THooks }> {
|
hooks<THooks extends Hooks>(hooks: THooks): Route<Prettify<Omit<TState, "hooks"> & { hooks: THooks }>> {
|
||||||
return new Route({ ...this.state, hooks });
|
return new Route({ ...this.state, hooks });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,9 +451,10 @@ export type RouteMeta = {
|
|||||||
|
|
||||||
export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE";
|
export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE";
|
||||||
|
|
||||||
export type RouteAccess = "public" | "session" | (() => boolean)[];
|
export type RouteAccess = "public" | "authenticated";
|
||||||
|
|
||||||
export type AccessFn = (resource: string, action: string) => () => boolean;
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
|
export interface ServerContext {}
|
||||||
|
|
||||||
type HandleFn<TArgs extends Array<any> = any[], TResponse = any> = (
|
type HandleFn<TArgs extends Array<any> = any[], TResponse = any> = (
|
||||||
...args: TArgs
|
...args: TArgs
|
||||||
@@ -471,3 +479,7 @@ type HasInputArgs<TState extends RouteState> = TState["params"] extends ZodObjec
|
|||||||
: TState["body"] extends ZodType
|
: TState["body"] extends ZodType
|
||||||
? true
|
? true
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
|
type Prettify<T> = {
|
||||||
|
[K in keyof T]: T[K];
|
||||||
|
} & {};
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export type Access = "public" | "session" | (() => boolean)[];
|
|
||||||
|
|
||||||
export type AccessFn = (resource: string, action: string) => () => boolean;
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
||||||
export interface ServerContext {}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import z from "zod";
|
|
||||||
|
|
||||||
import { makeSchemaParser } from "../database.ts";
|
|
||||||
|
|
||||||
export const RoleSchema = z.object({
|
|
||||||
id: z.uuid(),
|
|
||||||
name: z.string(),
|
|
||||||
permissions: z.record(z.string(), z.array(z.string())),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const parseRole = makeSchemaParser(RoleSchema);
|
|
||||||
|
|
||||||
export type Role = z.infer<typeof RoleSchema>;
|
|
||||||
export type RoleDocument = z.infer<typeof RoleSchema>;
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { RoleSchema } from "../access/role.ts";
|
|
||||||
import { AvatarSchema } from "../avatar.ts";
|
import { AvatarSchema } from "../avatar.ts";
|
||||||
import { ContactSchema } from "../contact.ts";
|
import { ContactSchema } from "../contact.ts";
|
||||||
import { makeSchemaParser } from "../database.ts";
|
import { makeSchemaParser } from "../database.ts";
|
||||||
import { NameSchema } from "../name.ts";
|
import { NameSchema } from "../name.ts";
|
||||||
|
import { RoleSchema } from "./role.ts";
|
||||||
import { StrategySchema } from "./strategies.ts";
|
import { StrategySchema } from "./strategies.ts";
|
||||||
|
|
||||||
export const AccountSchema = z.object({
|
export const AccountSchema = z.object({
|
||||||
@@ -18,10 +18,8 @@ export const AccountSchema = z.object({
|
|||||||
roles: z.array(RoleSchema).default([]),
|
roles: z.array(RoleSchema).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AccountDocumentSchema = AccountSchema.omit({ roles: true }).extend({ roles: z.array(z.string()) });
|
export const toAccountDocument = makeSchemaParser(AccountSchema);
|
||||||
|
|
||||||
export const toAccountDocument = makeSchemaParser(AccountDocumentSchema);
|
|
||||||
export const fromAccountDocument = makeSchemaParser(AccountSchema);
|
export const fromAccountDocument = makeSchemaParser(AccountSchema);
|
||||||
|
|
||||||
export type Account = z.infer<typeof AccountSchema>;
|
export type Account = z.infer<typeof AccountSchema>;
|
||||||
export type AccountDocument = z.infer<typeof AccountDocumentSchema>;
|
export type AccountDocument = z.infer<typeof AccountSchema>;
|
||||||
|
|||||||
5
spec/schemas/account/role.ts
Normal file
5
spec/schemas/account/role.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export const RoleSchema = z.union([z.literal("user"), z.literal("admin")]);
|
||||||
|
|
||||||
|
export type Role = z.infer<typeof RoleSchema>;
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { route } from "@spec/relay";
|
import { ForbiddenError, NotFoundError, route, UnauthorizedError } from "@spec/relay";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
import { NameSchema } from "../name.ts";
|
import { NameSchema } from "../name.ts";
|
||||||
|
import { AccountSchema } from "./account.ts";
|
||||||
import { AccountEmailClaimedError } from "./errors.ts";
|
import { AccountEmailClaimedError } from "./errors.ts";
|
||||||
|
|
||||||
export const create = route
|
export const create = route
|
||||||
@@ -15,6 +16,15 @@ export const create = route
|
|||||||
.errors([AccountEmailClaimedError])
|
.errors([AccountEmailClaimedError])
|
||||||
.response(z.uuid());
|
.response(z.uuid());
|
||||||
|
|
||||||
|
export const getById = route
|
||||||
|
.get("/api/v1/accounts/:id")
|
||||||
|
.params({
|
||||||
|
id: z.string(),
|
||||||
|
})
|
||||||
|
.errors([UnauthorizedError, ForbiddenError, NotFoundError])
|
||||||
|
.response(AccountSchema);
|
||||||
|
|
||||||
export const routes = {
|
export const routes = {
|
||||||
create,
|
create,
|
||||||
|
getById,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user