Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
ygrishajev committed Aug 20, 2024
1 parent edf3d3a commit c6cf971
Show file tree
Hide file tree
Showing 14 changed files with 377 additions and 77 deletions.
3 changes: 2 additions & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,10 @@ if (BILLING_ENABLED === "true") {
appHono.route("/", getWalletListRouter);
appHono.route("/", signAndBroadcastTxRouter);
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { createAnonymousUserRouter, getAnonymousUserRouter } = require("./user");
const { createAnonymousUserRouter, getAnonymousUserRouter, createOrGetUserRouter } = require("./user");
appHono.route("/", createAnonymousUserRouter);
appHono.route("/", getAnonymousUserRouter);
appHono.route("/", createOrGetUserRouter);
}

appHono.get("/status", c => {
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/auth/services/ability/ability.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import type { TemplateExecutor } from "lodash";
import template from "lodash/template";
import { singleton } from "tsyringe";

type Role = "REGULAR_USER" | "REGULAR_ANONYMOUS_USER" | "SUPER_USER";
type Role = "REGULAR_UNREGISTERED_USER" | "REGULAR_USER" | "REGULAR_ANONYMOUS_USER" | "SUPER_USER";

@singleton()
export class AbilityService {
readonly EMPTY_ABILITY = new Ability([]);

private readonly RULES: Record<Role, RawRule[]> = {
REGULAR_UNREGISTERED_USER: [{ action: "create", subject: "User", conditions: { userId: "${user.userId}" } }],
REGULAR_USER: [
{ action: ["create", "read", "sign"], subject: "UserWallet", conditions: { userId: "${user.id}" } },
{ action: "read", subject: "User", conditions: { id: "${user.id}" } }
Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/auth/services/auth.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export class AuthInterceptor implements HonoInterceptor {
intercept() {
return async (c: Context, next: Next) => {
const bearer = c.req.header("authorization");
const anonymousBearer = c.req.header("x-anonymous-authorization");

Check failure on line 25 in apps/api/src/auth/services/auth.interceptor.ts

View workflow job for this annotation

GitHub Actions / validate-n-build

'anonymousBearer' is assigned a value but never used

const anonymousUserId = bearer && (await this.anonymousUserAuthService.getValidUserId(bearer));

Expand All @@ -40,7 +41,9 @@ export class AuthInterceptor implements HonoInterceptor {
const currentUser = await this.userRepository.findByUserId(userId);

this.authService.currentUser = currentUser;
this.authService.ability = currentUser ? this.abilityService.getAbilityFor("REGULAR_USER", currentUser) : this.abilityService.EMPTY_ABILITY;
this.authService.ability = currentUser
? this.abilityService.getAbilityFor("REGULAR_USER", currentUser)
: this.abilityService.getAbilityFor("REGULAR_UNREGISTERED_USER", { userId });

return await next();
}
Expand Down
7 changes: 4 additions & 3 deletions apps/api/src/auth/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export class AuthService {
}

throwUnlessCan(action: string, subject: string) {
console.log("DEBUG this.ability", JSON.stringify(this.ability.rules, null, 2));
assert(this.ability.can(action, subject), 403);
}
}
Expand All @@ -39,11 +40,11 @@ export const Protected = (rules?: { action: string; subject: string }[]) => (tar

descriptor.value = function protectedFunction(...args: any[]) {

Check warning on line 41 in apps/api/src/auth/services/auth.service.ts

View workflow job for this annotation

GitHub Actions / validate-n-build

Unexpected any. Specify a different type
const authService = container.resolve(AuthService);

assert(authService.isAuthenticated, 401);

console.log("DEBUG rules", JSON.stringify(rules, null, 2));
if (rules) {
rules.forEach(rule => authService.throwUnlessCan(rule.action, rule.subject));
} else {
assert(authService.isAuthenticated, 401);
}

return originalMethod.apply(this, args);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, eq, lte, or } from "drizzle-orm";
import { eq, lte, or } from "drizzle-orm";
import first from "lodash/first";
import pick from "lodash/pick";
import { singleton } from "tsyringe";
Expand All @@ -8,7 +8,7 @@ import { ApiPgDatabase, InjectPg } from "@src/core/providers";
import { AbilityParams, BaseRepository } from "@src/core/repositories/base.repository";
import { TxService } from "@src/core/services";

export type DbUserWalletInput = Partial<UserWalletSchema["$inferSelect"]>;
export type DbUserWalletInput = Partial<UserWalletSchema["$inferInsert"]>;
export type UserWalletInput = Partial<
Omit<DbUserWalletInput, "deploymentAllowance" | "feeAllowance"> & {
deploymentAllowance: number;
Expand All @@ -27,18 +27,14 @@ export interface ListOptions {
offset?: number;
}

interface UpdateOptions {
returning: true;
}

@singleton()
export class UserWalletRepository extends BaseRepository<UserWalletSchema> {
export class UserWalletRepository extends BaseRepository<UserWalletSchema, UserWalletInput, UserWalletOutput> {
constructor(
@InjectPg() protected readonly pg: ApiPgDatabase,
@InjectUserWalletSchema() protected readonly schema: UserWalletSchema,
protected readonly txManager: TxService
) {
super(pg, schema, txManager, "UserWallet");
super(pg, schema, txManager, "UserWallet", "userWalletSchema");
}

accessibleBy(...abilityParams: AbilityParams) {
Expand All @@ -56,42 +52,6 @@ export class UserWalletRepository extends BaseRepository<UserWalletSchema> {
return this.toOutput(first(await this.cursor.insert(this.schema).values(value).returning()));
}

async updateById(id: UserWalletOutput["id"], payload: Partial<UserWalletInput>, options?: UpdateOptions): Promise<UserWalletOutput>;
async updateById(id: UserWalletOutput["id"], payload: Partial<UserWalletInput>): Promise<void>;
async updateById(id: UserWalletOutput["id"], payload: Partial<UserWalletInput>, options?: UpdateOptions): Promise<void | UserWalletOutput> {
return this.updateBy({ id }, payload, options);
}

async updateBy(query: Partial<DbUserWalletOutput>, payload: Partial<UserWalletInput>, options?: UpdateOptions): Promise<UserWalletOutput>;
async updateBy(query: Partial<DbUserWalletOutput>, payload: Partial<UserWalletInput>): Promise<void>;
async updateBy(query: Partial<DbUserWalletOutput>, payload: Partial<UserWalletInput>, options?: UpdateOptions): Promise<void | UserWalletOutput> {
const cursor = this.cursor.update(this.schema).set(this.toInput(payload)).where(this.queryToWhere(query));

if (options?.returning) {
const items = await cursor.returning();
return this.toOutput(first(items));
}

await cursor;

return undefined;
}

async find(query?: Partial<DbUserWalletOutput>) {
return this.toOutputList(
await this.cursor.query.userWalletSchema.findMany({
where: this.queryToWhere(query)
})
);
}

private queryToWhere(query: Partial<DbUserWalletOutput>) {
const fields = query && (Object.keys(query) as Array<keyof DbUserWalletOutput>);
const where = fields?.length ? and(...fields.map(field => eq(this.schema[field], query[field]))) : undefined;

return this.whereAccessibleBy(where);
}

async findDrainingWallets(thresholds = { fee: 0, deployment: 0 }, options?: Pick<ListOptions, "limit">) {
const where = or(lte(this.schema.deploymentAllowance, thresholds.deployment.toString()), lte(this.schema.feeAllowance, thresholds.fee.toString()));

Expand All @@ -103,19 +63,11 @@ export class UserWalletRepository extends BaseRepository<UserWalletSchema> {
);
}

async findById(id: UserWalletOutput["id"]) {
return this.toOutput(await this.cursor.query.userWalletSchema.findFirst({ where: this.whereAccessibleBy(eq(this.schema.id, id)) }));
}

async findByUserId(userId: UserWalletOutput["userId"]) {
return this.toOutput(await this.cursor.query.userWalletSchema.findFirst({ where: this.whereAccessibleBy(eq(this.schema.userId, userId)) }));
}

private toOutputList(dbOutput: UserWalletSchema["$inferSelect"][]): UserWalletOutput[] {
return dbOutput.map(item => this.toOutput(item));
}

private toOutput(dbOutput?: UserWalletSchema["$inferSelect"]): UserWalletOutput {
protected toOutput(dbOutput: DbUserWalletOutput): UserWalletOutput {
return (
dbOutput && {
...dbOutput,
Expand All @@ -126,7 +78,7 @@ export class UserWalletRepository extends BaseRepository<UserWalletSchema> {
);
}

private toInput({ deploymentAllowance, feeAllowance, ...input }: UserWalletInput): DbUserWalletInput {
protected toInput({ deploymentAllowance, feeAllowance, ...input }: UserWalletInput): DbUserWalletInput {
const dbInput: DbUserWalletInput = {
...input,
updatedAt: new Date()
Expand Down
87 changes: 85 additions & 2 deletions apps/api/src/core/repositories/base.repository.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,48 @@
import { AnyAbility } from "@casl/ability";
import { and, eq } from "drizzle-orm";
import { PgTableWithColumns } from "drizzle-orm/pg-core/table";
import { SQL } from "drizzle-orm/sql/sql";
import first from "lodash/first";

import { ApiPgDatabase, InjectPg, TxService } from "@src/core";
import { DrizzleAbility } from "@src/lib/drizzle-ability/drizzle-ability";
import { InjectUserSchema } from "@src/user/providers";

export type AbilityParams = [AnyAbility, Parameters<AnyAbility["can"]>[0]];

export abstract class BaseRepository<T extends PgTableWithColumns<any>> {
interface UpdateOptions {
returning: true;
}

export interface BaseRecordInput<T> {
id?: T;
}

export interface BaseRecordOutput<T> {
id: T;
}

export abstract class BaseRepository<
T extends PgTableWithColumns<any>,

Check warning on line 26 in apps/api/src/core/repositories/base.repository.ts

View workflow job for this annotation

GitHub Actions / validate-n-build

Unexpected any. Specify a different type
Input extends BaseRecordInput<string | number>,
Output extends BaseRecordOutput<string | number>
> {
protected ability?: DrizzleAbility<T>;

get cursor() {
return this.txManager.getPgTx() || this.pg;
}

get queryCursor() {
return this.cursor.query[this.schemaName] as unknown as T;
}

constructor(
@InjectPg() protected readonly pg: ApiPgDatabase,
@InjectUserSchema() protected readonly schema: T,
protected readonly txManager: TxService,
protected readonly entityName: string
protected readonly entityName: string,
protected readonly schemaName: keyof ApiPgDatabase["query"]
) {}

protected withAbility(ability: AnyAbility, action: Parameters<AnyAbility["can"]>[0]) {
Expand All @@ -32,4 +55,64 @@ export abstract class BaseRepository<T extends PgTableWithColumns<any>> {
}

abstract accessibleBy(...abilityParams: AbilityParams): this;

async findById(id: Output["id"]) {
return this.toOutput(await this.queryCursor.findFirst({ where: this.whereAccessibleBy(eq(this.schema.id, id)) }));
}

async findOneBy(query?: Partial<Output>) {
return this.toOutput(
await this.queryCursor.findFirst({
where: this.queryToWhere(query)
})
);
}

async find(query?: Partial<Output>) {
return this.toOutputList(
await this.queryCursor.findMany({
where: this.queryToWhere(query)
})
);
}

async updateById(id: Output["id"], payload: Partial<Input>, options?: UpdateOptions): Promise<Output>;
async updateById(id: Output["id"], payload: Partial<Input>): Promise<void>;
async updateById(id: Output["id"], payload: Partial<Input>, options?: UpdateOptions): Promise<void | Output> {
return this.updateBy({ id } as Partial<Output>, payload, options);
}

async updateBy(query: Partial<Output>, payload: Partial<Input>, options?: UpdateOptions): Promise<Output>;
async updateBy(query: Partial<Output>, payload: Partial<Input>): Promise<void>;
async updateBy(query: Partial<Output>, payload: Partial<Input>, options?: UpdateOptions): Promise<void | Output> {
const cursor = this.cursor.update(this.schema).set(this.toInput(payload)).where(this.queryToWhere(query));

if (options?.returning) {
const items = await cursor.returning();
return this.toOutput(first(items));
}

await cursor;

return undefined;
}

protected queryToWhere(query: Partial<T["$inferSelect"]>) {
const fields = query && (Object.keys(query) as Array<keyof T["$inferSelect"]>);
const where = fields?.length ? and(...fields.map(field => eq(this.schema[field], query[field]))) : undefined;

return this.whereAccessibleBy(where);
}

protected toInput(payload: Partial<Input>): Partial<T["$inferInsert"]> {
return payload as Partial<T["$inferSelect"]>;
}

protected toOutputList(dbOutput: T["$inferSelect"][]): Output[] {
return dbOutput.map(item => this.toOutput(item));
}

protected toOutput(payload: Partial<T["$inferSelect"]>): Output {
return payload as Output;
}
}
13 changes: 11 additions & 2 deletions apps/api/src/user/controllers/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,34 @@ import { singleton } from "tsyringe";
import { AuthService, Protected } from "@src/auth/services/auth.service";
import { AuthTokenService } from "@src/auth/services/auth-token/auth-token.service";
import { UserRepository } from "@src/user/repositories";
import { UserCreateRequestInput, UserResponseOutput } from "@src/user/routes/create-or-get-user/create-or-get-user.router";
import { GetUserParams } from "@src/user/routes/get-anonymous-user/get-anonymous-user.router";
import { AnonymousUserResponseOutput } from "@src/user/schemas/user.schema";
import { UserInitInput, UserInitService } from "@src/user/services/user-init/user-init.service";

@singleton()
export class UserController {
constructor(
private readonly userRepository: UserRepository,
private readonly authService: AuthService,
private readonly anonymousUserAuthService: AuthTokenService
private readonly anonymousUserAuthService: AuthTokenService,
private readonly userService: UserInitService
) {}

async create(): Promise<AnonymousUserResponseOutput> {
async createAnonymous(): Promise<AnonymousUserResponseOutput> {
const user = await this.userRepository.create();
return {
data: user,
token: this.anonymousUserAuthService.signTokenFor({ id: user.id })
};
}

async createOrGet(input: UserCreateRequestInput): Promise<UserResponseOutput> {
return {
data: await this.userService.registerOrGetUser(input.data as UserInitInput)
};
}

@Protected([{ action: "read", subject: "User" }])
async getById({ id }: GetUserParams): Promise<AnonymousUserResponseOutput> {
const user = await this.userRepository.accessibleBy(this.authService.ability, "read").findById(id);
Expand Down
13 changes: 5 additions & 8 deletions apps/api/src/user/repositories/user/user.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,33 +8,30 @@ import { TxService } from "@src/core/services";
import { InjectUserSchema, UserSchema } from "@src/user/providers";

export type UserOutput = UserSchema["$inferSelect"];
export type UserInput = Partial<UserOutput>;

@singleton()
export class UserRepository extends BaseRepository<UserSchema> {
export class UserRepository extends BaseRepository<UserSchema, UserInput, UserOutput> {
constructor(
@InjectPg() protected readonly pg: ApiPgDatabase,
@InjectUserSchema() protected readonly schema: UserSchema,
protected readonly txManager: TxService
) {
super(pg, schema, txManager, "User");
super(pg, schema, txManager, "User", "userSchema");
}

accessibleBy(...abilityParams: AbilityParams) {
return new UserRepository(this.pg, this.schema, this.txManager).withAbility(...abilityParams) as this;
}

async create() {
return first(await this.cursor.insert(this.schema).values({}).returning({ id: this.schema.id }));
async create(input: Partial<UserInput> = {}) {
return first(await this.cursor.insert(this.schema).values(input).returning());
}

async findByUserId(userId: UserOutput["userId"]) {
return await this.cursor.query.userSchema.findFirst({ where: this.whereAccessibleBy(eq(this.schema.userId, userId)) });
}

async findById(id: UserOutput["id"]) {
return await this.cursor.query.userSchema.findFirst({ where: this.whereAccessibleBy(eq(this.schema.id, id)) });
}

async findAnonymousById(id: UserOutput["id"]) {
return await this.cursor.query.userSchema.findFirst({ where: this.whereAccessibleBy(and(eq(this.schema.id, id), isNull(this.schema.userId))) });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,5 @@ const route = createRoute({
export const createAnonymousUserRouter = new OpenAPIHono();

createAnonymousUserRouter.openapi(route, async function routeCreateUser(c) {
return c.json(await container.resolve(UserController).create(), 200);
return c.json(await container.resolve(UserController).createAnonymous(), 200);
});
Loading

0 comments on commit c6cf971

Please sign in to comment.