Skip to content

Commit

Permalink
Merge branch 'master' into malik.9207.tokenized-community-step1-api-i…
Browse files Browse the repository at this point in the history
…ntegration
  • Loading branch information
mzparacha committed Sep 16, 2024
2 parents 12ad685 + 889a63d commit 7696622
Show file tree
Hide file tree
Showing 51 changed files with 1,890 additions and 993 deletions.
2 changes: 1 addition & 1 deletion libs/adapters/src/trpc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const trpcerror = (error: unknown): TRPCError => {
* Builds tRPC command POST endpoint
* @param factory command factory
* @param tag command tag used for OpenAPI spec grouping
* @param track analytics tracking metadata as tuple of [event, output mapper]
* @param track analytics tracking metadata as tuple of [event, output mapper] or (input,output) => Promise<[event, data]|undefined>
* @returns tRPC mutation procedure
*/
export const command = <
Expand Down
1 change: 1 addition & 0 deletions libs/model/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"node-fetch": "2",
"node-object-hash": "^3.0.0",
"pg": "^8.11.3",
"quill-delta-to-markdown": "^0.6.0",
"sequelize": "^6.32.1",
"umzug": "^3.7.0",
"uuid": "^9.0.1",
Expand Down
5 changes: 2 additions & 3 deletions libs/model/src/comment/CreateComment.command.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EventNames, InvalidState, type Command } from '@hicommonwealth/core';
import { getCommentSearchVector } from '@hicommonwealth/model';
import { decodeContent, getCommentSearchVector } from '@hicommonwealth/model';
import * as schemas from '@hicommonwealth/schemas';
import { models } from '../database';
import { isAuthorized, type AuthContext } from '../middleware';
Expand All @@ -10,7 +10,6 @@ import {
emitMentions,
parseUserMentions,
quillToPlain,
sanitizeQuillText,
uniqueMentions,
} from '../utils';
import { getCommentDepth } from '../utils/getCommentDepth';
Expand Down Expand Up @@ -53,7 +52,7 @@ export function CreateComment(): Command<
throw new InvalidState(CreateCommentErrors.NestingTooDeep);
}

const text = sanitizeQuillText(payload.text);
const text = decodeContent(payload.text);
const plaintext = quillToPlain(text);
const mentions = uniqueMentions(parseUserMentions(text));

Expand Down
8 changes: 4 additions & 4 deletions libs/model/src/comment/UpdateComment.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import { isAuthorized, type AuthContext } from '../middleware';
import { mustBeAuthorized } from '../middleware/guards';
import { getCommentSearchVector } from '../models';
import {
decodeContent,
emitMentions,
findMentionDiff,
parseUserMentions,
quillToPlain,
sanitizeQuillText,
uniqueMentions,
} from '../utils';

Expand All @@ -24,9 +24,9 @@ export function UpdateComment(): Command<
const { address } = mustBeAuthorized(actor, auth);
const { comment_id, discord_meta } = payload;

// find by comment_id or discord_meta
// find discord_meta first if present
const comment = await models.Comment.findOne({
where: comment_id ? { id: comment_id } : { discord_meta },
where: discord_meta ? { discord_meta } : { id: comment_id },
include: [{ model: models.Thread, required: true }],
});
if (!comment) throw new InvalidInput('Comment not found');
Expand All @@ -38,7 +38,7 @@ export function UpdateComment(): Command<
});

if (currentVersion?.text !== payload.text) {
const text = sanitizeQuillText(payload.text);
const text = decodeContent(payload.text);
const plaintext = quillToPlain(text);
const mentions = findMentionDiff(
parseUserMentions(currentVersion?.text),
Expand Down
71 changes: 56 additions & 15 deletions libs/model/src/middleware/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export type AuthContext = {
thread?: ThreadInstance | null;
comment?: CommentInstance | null;
author_address_id?: number;
is_author?: boolean;
is_author: boolean;
is_collaborator?: boolean;
};

export type AuthHandler<Input extends ZodSchema = ZodSchema> = Handler<
Expand Down Expand Up @@ -69,16 +70,17 @@ export class RejectedMember extends InvalidActor {
}

/**
* Prepares authorization context
* Builds authorization context
*
* @param actor command actor
* @param payload command payload
* @param auth authorization context
* @param roles roles filter
*/
async function authorizeAddress(
async function buildAuth(
ctx: Context<ZodSchema, AuthContext>,
roles: Role[],
collaborators = false,
): Promise<AuthContext> {
const { actor, payload } = ctx;
if (!actor.address)
Expand All @@ -99,7 +101,7 @@ async function authorizeAddress(
* 2. Find by thread_id when payload contains thread_id
* 3. Find by comment_id when payload contains comment_id
*/
const auth: AuthContext = { address: null };
const auth: AuthContext = { address: null, is_author: false };
(ctx as { auth: AuthContext }).auth = auth;

auth.community_id =
Expand All @@ -124,10 +126,19 @@ async function authorizeAddress(
throw new InvalidInput('Must provide a valid comment id');
auth.community_id = auth.comment.Thread!.community_id;
auth.topic_id = auth.comment.Thread!.topic_id;
auth.thread_id = auth.comment.Thread!.id;
auth.author_address_id = auth.comment.address_id;
} else {
const include = collaborators
? {
model: models.Address,
as: 'collaborators',
required: false,
}
: undefined;
auth.thread = await models.Thread.findOne({
where: { id: auth.thread_id },
include,
});
if (!auth.thread)
throw new InvalidInput('Must provide a valid thread id');
Expand All @@ -147,6 +158,8 @@ async function authorizeAddress(
});
if (!auth.address)
throw new InvalidActor(actor, `User is not ${roles} in the community`);

auth.is_author = auth.address!.id === auth.author_address_id;
return auth;
}

Expand Down Expand Up @@ -226,31 +239,59 @@ export const isSuperAdmin: AuthHandler = async (ctx) => {
};

/**
* Validates if actor address is authorized by checking for:
* - **super admin**: Allow all operations when the user is a super admin (god mode)
* - **in roles**: Allow when user is in the provides community roles
* - **not banned**: Reject if user is banned
* - **author**: Allow when the user is the creator of the entity
* - **topic group**: Allow when user has group permissions in topic
* Validates if actor's address is authorized by checking in the following order:
* - 1. **in roles**: User address must be in the provided community roles
* - 2. **admin**: Allows all operations when the user is an admin or super admin (god mode, site admin)
* - 3. **not banned**: Reject if address is banned
* - 4. **topic group**: Allows when address has group permissions in topic
* - 5. **author**: Allows when address is the creator of the entity
* - 6. **collaborators**: Allows when address is a collaborator
*
* @param roles specific community roles - all by default
* @param action specific group permission action
* @param collaborators authorize thread collaborators
* @throws InvalidActor when not authorized
*/
export function isAuthorized({
roles = ['admin', 'moderator', 'member'],
action,
collaborators = false,
}: {
roles?: Role[];
action?: GroupPermissionAction;
collaborators?: boolean;
}): AuthHandler {
return async (ctx) => {
if (ctx.actor.user.isAdmin) return;
const auth = await authorizeAddress(ctx, roles);
const isAdmin = ctx.actor.user.isAdmin;

const auth = await buildAuth(
ctx,
isAdmin ? ['admin', 'moderator', 'member'] : roles,
collaborators,
);

if (isAdmin || auth.address?.role === 'admin') return;

if (auth.address!.is_banned) throw new BannedActor(ctx.actor);
if (auth.author_address_id && auth.address!.id === auth.author_address_id)
return; // author
if (action && auth.address!.role === 'member')

if (action) {
// waterfall stops here after validating the action
await isTopicMember(ctx.actor, auth, action);
return;
}

if (auth.is_author) return;

if (collaborators) {
const found = auth.thread?.collaborators?.find(
({ address }) => address === ctx.actor.address,
);
auth.is_collaborator = !!found;
if (auth.is_collaborator) return;
throw new InvalidActor(ctx.actor, 'Not authorized collaborator');
}

// at this point, the address is either a moderator or member
// without any action or collaboration requirements
};
}
7 changes: 7 additions & 0 deletions libs/model/src/middleware/guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ export function mustBeAuthorizedThread(actor: Actor, auth?: AuthContext) {
return auth as AuthContext & {
address: AddressInstance;
thread: ThreadInstance;
community_id: string;
topic_id: number;
thread_id: number;
};
}

Expand All @@ -94,5 +97,9 @@ export function mustBeAuthorizedComment(actor: Actor, auth?: AuthContext) {
return auth as AuthContext & {
address: AddressInstance;
comment: ThreadInstance;
community_id: string;
topic_id: number;
thread_id: number;
comment_id: number;
};
}
9 changes: 5 additions & 4 deletions libs/model/src/models/collaboration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ import type { ModelInstance } from './types';
export type CollaborationAttributes = {
address_id: number;
thread_id: number;
created_at: Date;
updated_at: Date;

Address: AddressAttributes;
Thread: ThreadAttributes;
created_at?: Date;
updated_at?: Date;

Address?: AddressAttributes;
Thread?: ThreadAttributes;
};

export type CollaborationInstance = ModelInstance<CollaborationAttributes> & {
Expand Down
4 changes: 0 additions & 4 deletions libs/model/src/models/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,13 @@ import { Thread } from '@hicommonwealth/schemas';
import Sequelize from 'sequelize';
import { z } from 'zod';
import { emitEvent, getThreadContestManagers } from '../utils';
import type { AddressAttributes } from './address';
import type { CommunityAttributes } from './community';
import type { ReactionAttributes } from './reaction';
import type { ThreadSubscriptionAttributes } from './thread_subscriptions';
import type { ModelInstance } from './types';

export type ThreadAttributes = z.infer<typeof Thread> & {
// associations
Community?: CommunityAttributes;
collaborators?: AddressAttributes[];
reactions?: ReactionAttributes[];
subscriptions?: ThreadSubscriptionAttributes[];
};

Expand Down
4 changes: 2 additions & 2 deletions libs/model/src/thread/CreateThread.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ import { mustBeAuthorized } from '../middleware/guards';
import { getThreadSearchVector } from '../models/thread';
import { tokenBalanceCache } from '../services';
import {
decodeContent,
emitMentions,
parseUserMentions,
quillToPlain,
sanitizeQuillText,
uniqueMentions,
} from '../utils';

Expand Down Expand Up @@ -112,7 +112,7 @@ export function CreateThread(): Command<
checkContestLimits(activeContestManagers, actor.address!);
}

const body = sanitizeQuillText(payload.body);
const body = decodeContent(payload.body);
const plaintext = kind === 'discussion' ? quillToPlain(body) : body;
const mentions = uniqueMentions(parseUserMentions(body));

Expand Down
Loading

0 comments on commit 7696622

Please sign in to comment.