• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

hicommonwealth / commonwealth / 15559623773

10 Jun 2025 12:34PM UTC coverage: 40.748% (-0.08%) from 40.827%
15559623773

push

github

web-flow
Merge pull request #12366 from hicommonwealth/rotorsoft/12364-topic-gate-getthreadsbyids

Adds new query for single ids and protect private topics

1771 of 4719 branches covered (37.53%)

Branch coverage included in aggregate %.

0 of 18 new or added lines in 4 files covered. (0.0%)

2 existing lines in 2 files now uncovered.

3206 of 7495 relevant lines covered (42.78%)

37.45 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

75.89
/libs/model/src/middleware/auth.ts
1
import {
2
  Actor,
3
  Context,
4
  InvalidActor,
5
  InvalidInput,
6
  InvalidState,
7
} from '@hicommonwealth/core';
8
import {
9
  Address,
10
  AuthContext,
11
  AuthContextInput,
12
  CommentContext,
13
  CommentContextInput,
14
  MembershipRejectReason,
15
  PollContext,
16
  PollContextInput,
17
  ReactionContext,
18
  ReactionContextInput,
19
  ThreadContext,
20
  ThreadContextInput,
21
  TopicContext,
22
  TopicContextInput,
23
  VerifiedContext,
24
  VerifiedContextInput,
25
} from '@hicommonwealth/schemas';
26
import {
27
  ALL_COMMUNITIES,
28
  GroupGatedActionKey,
29
  Role,
30
} from '@hicommonwealth/shared';
31
import { Op, QueryTypes } from 'sequelize';
32
import { ZodSchema, z } from 'zod';
33
import { models } from '../database';
34
import { AddressInstance } from '../models';
35
import { BannedActor, NonMember, RejectedMember } from './errors';
36

37
async function findComment(actor: Actor, comment_id: number) {
38
  const comment = await models.Comment.findOne({
16✔
39
    where: { id: comment_id },
40
    include: [
41
      {
42
        model: models.Thread,
43
        required: true,
44
      },
45
    ],
46
  });
47
  if (!comment)
16✔
48
    throw new InvalidInput('Must provide a valid comment id to authorize');
2✔
49

50
  return {
14✔
51
    comment_id,
52
    comment,
53
    author_address_id: comment.address_id,
54
    community_id: comment.Thread!.community_id!,
55
    topic_id: comment.Thread!.topic_id ?? undefined,
14!
56
    thread_id: comment.Thread!.id!,
57
  };
58
}
59

60
async function findThread(
61
  actor: Actor,
62
  thread_id: number,
63
  collaborators: boolean,
64
) {
65
  const include = collaborators
51✔
66
    ? {
67
        model: models.Address,
68
        as: 'collaborators',
69
        required: false,
70
      }
71
    : undefined;
72
  const thread = await models.Thread.findOne({
51✔
73
    where: { id: thread_id },
74
    include,
75
  });
76
  if (!thread)
51✔
77
    throw new InvalidInput('Must provide a valid thread id to authorize');
3✔
78

79
  let is_collaborator = false;
48✔
80
  if (collaborators) {
48✔
81
    const found = thread?.collaborators?.find(
14✔
82
      ({ address }) => address === actor.address,
21✔
83
    );
84
    is_collaborator = !!found;
14✔
85
  }
86

87
  return {
48✔
88
    thread_id,
89
    thread,
90
    author_address_id: thread.address_id,
91
    community_id: thread.community_id,
92
    topic_id: thread.topic_id,
93
    is_collaborator,
94
  };
95
}
96

97
async function findTopic(actor: Actor, topic_id: number) {
98
  const topic = await models.Topic.findOne({ where: { id: topic_id } });
41✔
99
  if (!topic)
41✔
100
    throw new InvalidInput('Must provide a valid topic id to authorize');
1✔
101

102
  return {
40✔
103
    topic_id,
104
    topic,
105
    community_id: topic.community_id,
106
  };
107
}
108

109
async function findReaction(
110
  actor: Actor,
111
  community_id: string,
112
  reaction_id: number,
113
) {
114
  const reaction = await models.Reaction.findOne({
4✔
115
    where: { id: reaction_id },
116
  });
117
  if (!reaction)
4✔
118
    throw new InvalidInput('Must provide a valid reaction id to authorize');
1✔
119

120
  return {
3✔
121
    reaction_id,
122
    reaction,
123
    community_id,
124
    author_address_id: reaction.address_id,
125
  };
126
}
127

128
async function findPoll(actor: Actor, poll_id: number) {
129
  const poll = await models.Poll.findOne({
×
130
    where: { id: poll_id },
131
    include: [
132
      {
133
        model: models.Thread,
134
        required: true,
135
      },
136
    ],
137
  });
138
  if (!poll) {
×
139
    throw new InvalidInput('Must provide a valid poll id to authorize');
×
140
  }
141

142
  return poll;
×
143
}
144

145
async function findVerifiedAddress(
146
  actor: Actor,
147
): Promise<{ address: AddressInstance }> {
148
  if (!actor.address)
33!
149
    throw new InvalidActor(actor, 'Must provide an address to authorize');
×
150

151
  // Loads and tracks real user's address activity
152
  const address = await models.Address.findOne({
33✔
153
    where: {
154
      user_id: actor.user.id,
155
      address: actor.address,
156
      verified: { [Op.ne]: null },
157
      // TODO: check verification token expiration
158
    },
159
  });
160

161
  if (address) {
33✔
162
    // fire and forget address activity tracking
163
    address.last_active = new Date();
32✔
164
    void address.save();
32✔
165
    return { address };
32✔
166
  }
167

168
  if (!actor.user.isAdmin)
1!
169
    throw new InvalidActor(actor, `User is not verified`);
1✔
170

171
  const super_address = await models.Address.findOne({
×
172
    where: {
173
      user_id: actor.user.id,
174
      address: actor.address,
175
    },
176
  });
177
  if (!super_address)
×
178
    throw new InvalidActor(actor, `Super admin address not found`);
×
179

180
  return { address: super_address };
×
181
}
182

183
async function findAddress(
184
  actor: Actor,
185
  community_id: string,
186
  roles: Role[],
187
  author_address_id?: number,
188
): Promise<{ address: AddressInstance; is_author: boolean }> {
189
  if (!actor.address)
204!
190
    throw new InvalidActor(actor, 'Must provide an address to authorize');
×
191

192
  // Policies as system actors behave like super admins
193
  // TODO: we can check if there is an address to load or fake it
194
  if (actor.is_system_actor) {
204✔
195
    return {
2✔
196
      address: {} as AddressInstance,
197
      is_author: false,
198
    };
199
  }
200

201
  if (!community_id)
202!
202
    throw new InvalidInput('Must provide a valid community id to authorize');
×
203

204
  // Loads and tracks real user's address activity
205
  const address = await models.Address.findOne({
202✔
206
    where: {
207
      user_id: actor.user.id,
208
      address: actor.address,
209
      community_id,
210
      [Op.or]: author_address_id
202✔
211
        ? [{ role: { [Op.in]: roles } }, { id: author_address_id }]
212
        : [{ role: { [Op.in]: roles } }],
213
      verified: { [Op.ne]: null },
214
      // TODO: check verification token expiration
215
    },
216
    order: [['role', 'DESC']],
217
  });
218

219
  if (address) {
202✔
220
    // fire and forget address activity tracking
221
    address.last_active = new Date();
169✔
222
    void address.save();
169✔
223
    return {
169✔
224
      address,
225
      is_author: address.id === author_address_id,
226
    };
227
  }
228

229
  // simulate non-member super admins
230
  if (!actor.user.isAdmin)
33✔
231
    throw new InvalidActor(actor, `User is not ${roles} in the community`);
14✔
232

233
  const super_address = await models.Address.findOne({
19✔
234
    where: {
235
      user_id: actor.user.id,
236
      address: actor.address,
237
    },
238
  });
239
  if (!super_address)
19!
240
    throw new InvalidActor(actor, `Super admin address not found`);
×
241

242
  return {
19✔
243
    address: super_address,
244
    is_author: false,
245
  };
246
}
247

248
/**
249
 * Checks if actor address passes a set of requirements and grants access for all groups of the given topic
250
 */
251
async function checkGatedActions(
252
  actor: Actor,
253
  address_id: number,
254
  action: GroupGatedActionKey,
255
  topic_id: number,
256
): Promise<void> {
257
  const [topic] = await models.sequelize.query<{
55✔
258
    topic_name: string;
259
    gates: Array<{
260
      group_id: number;
261
      group_name: string;
262
      actions: GroupGatedActionKey[];
263
      is_private: boolean;
264
      membership: {
265
        is_member: boolean;
266
        reject_reason: z.infer<typeof MembershipRejectReason> | null;
267
      } | null;
268
    }>;
269
  }>(
270
    `
271
SELECT
272
  T.name AS topic_name,
273
  JSONB_AGG(
274
    JSONB_BUILD_OBJECT(
275
      'group_id', G.id,
276
      'group_name', G.metadata->>'name',
277
      'actions', GA.gated_actions,
278
      'is_private', GA.is_private,
279
      'membership', (
280
        SELECT JSONB_BUILD_OBJECT(
281
          'is_member', (M.reject_reason IS NULL),
282
          'reject_reason', M.reject_reason
283
        )
284
        FROM "Memberships" M
285
        WHERE M.group_id = G.id AND M.address_id = :address_id
286
      )
287
    )
288
  ) AS gates
289
FROM
290
  "Topics" T
291
  JOIN "GroupGatedActions" GA ON GA.topic_id = T.id
292
  JOIN "Groups" G ON GA.group_id = G.id
293
WHERE
294
  T.id = :topic_id
295
  AND (:action = ANY(GA.gated_actions) OR GA.is_private = TRUE)
296
GROUP BY
297
  T.name;
298
`,
299
    {
300
      type: QueryTypes.SELECT,
301
      raw: true,
302
      replacements: { address_id, topic_id, action },
303
    },
304
  );
305

306
  // action not gated and public topic... allow it
307
  if (!topic) return;
55✔
308

309
  const closed_gates = topic.gates.filter(
45✔
310
    ({ actions, is_private, membership }) =>
311
      actions.includes(action) || (is_private && actions.length === 0)
81✔
312
        ? (membership?.is_member || false) === false
82✔
313
        : false,
314
  );
315

316
  // throw when at least one gate is closed (AND gates)
317
  if (closed_gates.length > 0) {
45✔
318
    const rejects = topic.gates
15✔
319
      .filter(({ membership }) => !!membership?.reject_reason)
27✔
320
      .map(({ membership }) =>
321
        membership!.reject_reason!.map(({ message }) => message).join('; '),
10✔
322
      );
323
    if (rejects.length)
15✔
324
      throw new RejectedMember(actor, topic.topic_name, action, rejects);
5✔
325
    else throw new NonMember(actor, topic.topic_name, action);
10✔
326
  }
327

328
  // all gates are open!
329
}
330

331
/**
332
 * Generic authorization guard used by all middleware once the authorization context is loaded
333
 */
334
async function mustBeAuthorized(
335
  { actor, context }: Context<ZodSchema, ZodSchema>,
336
  check: {
337
    permissions?: {
338
      topic_id: number;
339
      action: GroupGatedActionKey;
340
    };
341
    author?: boolean;
342
    collaborators?: z.infer<typeof Address>[];
343
    roles?: Role[];
344
  },
345
) {
346
  // System actors are always allowed
347
  if (actor.is_system_actor) return;
182✔
348

349
  // Admins (and super admins) are always allowed to act on any entity
350
  if (actor.user.isAdmin || context.address.role === 'admin') return;
180✔
351

352
  // Banned actors are always rejected (if not admin or system actors)
353
  if (context.address.is_banned) throw new BannedActor(actor);
72✔
354

355
  // Author is always allowed to act on their own entity, unless banned
356
  if (context.is_author) return;
70✔
357

358
  if (
62!
359
    check.roles?.includes('moderator') &&
62!
360
    context.address.role === 'moderator'
361
  )
362
    return;
×
363

364
  // Allows when actor has group permissions in topic
365
  if (check.permissions)
62✔
366
    return await checkGatedActions(
55✔
367
      actor,
368
      context.address!.id!,
369
      check.permissions.action,
370
      check.permissions.topic_id,
371
    );
372

373
  // Allows when actor is a collaborator in the thread
374
  if (check.collaborators) {
7✔
375
    const found = check.collaborators?.find(
5✔
376
      ({ address }) => address === actor.address,
8✔
377
    );
378
    context.is_collaborator = !!found;
5✔
379
    if (context.is_collaborator) return;
5✔
380
    throw new InvalidActor(actor, 'Not authorized collaborator');
1✔
381
  }
382

383
  // At this point, we know the actor is not the author of the entity
384
  // and it's also not an admin, system actor, or collaborator...
385
  // This guard is used to enforce that the author is the only one who can
386
  // perform actions on the entity.
387
  if (check.author)
2!
388
    throw new InvalidActor(actor, 'Not the author of the entity');
2✔
389
}
390

391
/**
392
 * Utility to easily create a system actor.
393
 * We can identify each policy actor by a predefined system user id,
394
 * email, and address.
395
 * This will allow us to audit and track distinct policy actors.
396
 *
397
 * @param address a distict policy address, defaults to "0x0"
398
 * @param id a distict policy user id, defaults to 0
399
 * @param email a distict policy email address, defaults to `system@common.im`
400
 * @returns system actor flagged as a system actor
401
 */
402
export const systemActor = ({
39✔
403
  address = '0x0',
17✔
404
  id = 0,
17✔
405
  email = 'system@common.im',
17✔
406
}: {
407
  address?: string;
408
  id?: number;
409
  email?: string;
410
}): Actor => ({
17✔
411
  user: { id, email },
412
  address,
413
  is_system_actor: true,
414
});
415

416
export async function isSuperAdmin(ctx: Context<ZodSchema, ZodSchema>) {
417
  if (!ctx.actor.user.isAdmin)
57!
418
    await Promise.reject(new InvalidActor(ctx.actor, 'Must be a super admin'));
×
419
}
420

421
/**
422
 * Validates if actor's address is authorized/verified, not tied to any specific community
423
 * @throws InvalidActor when not authorized
424
 */
425
export function authVerified() {
426
  return async (
33✔
427
    ctx: Context<typeof VerifiedContextInput, typeof VerifiedContext>,
428
  ) => {
429
    const { address } = await findVerifiedAddress(ctx.actor);
33✔
430
    (ctx as { context: VerifiedContext }).context = { address };
32✔
431
  };
432
}
433

434
/**
435
 * Creates an authorization context for the actor when authenticated,
436
 * but anonymous access is allowed.
437
 * This is mainly used when querying communities with gating conditions.
438
 */
439
export async function authOptional(
440
  ctx: Context<typeof AuthContextInput, typeof AuthContext>,
441
) {
442
  if (!ctx.actor.user || !ctx.actor.address || !ctx.payload.community_id)
8!
443
    return;
×
444
  if (ctx.payload.community_id === ALL_COMMUNITIES) return;
8!
445

446
  try {
8✔
447
    const { address, is_author } = await findAddress(
8✔
448
      ctx.actor,
449
      ctx.payload.community_id,
450
      ['admin', 'moderator', 'member'],
451
    );
452

453
    (ctx as { context: AuthContext }).context = {
8✔
454
      address,
455
      is_author,
456
      community_id: ctx.payload.community_id,
457
    };
458
  } catch (err) {
459
    // ignore InvalidActor errors
460
    if (err instanceof InvalidActor) return;
×
461
    throw err;
×
462
  }
463
}
464

465
/**
466
 * Creates an authorization context for the actor when authenticated,
467
 * but anonymous access is allowed.
468
 * This is mainly used when querying threads with gating conditions.
469
 */
470
export async function authOptionalForThread(
471
  ctx: Context<typeof ThreadContextInput, typeof ThreadContext>,
472
) {
NEW
473
  if (!ctx.actor.user || !ctx.actor.address || !ctx.payload.thread_id) return;
×
474

NEW
475
  try {
×
NEW
476
    const auth = await findThread(ctx.actor, ctx.payload.thread_id, false);
×
NEW
477
    const { address, is_author } = await findAddress(
×
478
      ctx.actor,
479
      auth.community_id,
480
      ['admin', 'moderator', 'member'],
481
      auth.author_address_id,
482
    );
483

NEW
484
    (ctx as { context: AuthContext }).context = {
×
485
      address,
486
      is_author,
487
      community_id: auth.community_id,
488
    };
489
  } catch (err) {
490
    // ignore InvalidActor errors
NEW
491
    if (err instanceof InvalidActor) return;
×
NEW
492
    throw err;
×
493
  }
494
}
495

496
/**
497
 * Validates if actor's address is authorized
498
 * @param roles specific community roles - all by default
499
 * @throws InvalidActor when not authorized
500
 */
501
export function authRoles(...roles: Role[]) {
502
  return async (ctx: Context<typeof AuthContextInput, typeof AuthContext>) => {
92✔
503
    const { address, is_author } = await findAddress(
91✔
504
      ctx.actor,
505
      ctx.payload.community_id,
506
      ctx.actor.user.isAdmin || !roles.length
254✔
507
        ? ['admin', 'moderator', 'member']
508
        : roles,
509
    );
510

511
    (ctx as { context: AuthContext }).context = {
80✔
512
      address,
513
      is_author,
514
      community_id: ctx.payload.community_id,
515
    };
516

517
    await mustBeAuthorized(ctx, {});
80✔
518
  };
519
}
520

521
type AggregateAuthOptions = {
522
  roles?: Role[];
523
  action?: GroupGatedActionKey;
524
  author?: boolean;
525
  collaborators?: boolean;
526
};
527

528
/**
529
 * Validates if actor's address is authorized to perform actions on a comment
530
 * @param action specific group permission action
531
 * @param author when true, rejects members that are not the author
532
 * @param roles the roles that are authorized
533
 * @throws InvalidActor when not authorized
534
 */
535
export function authComment({ action, author, roles }: AggregateAuthOptions) {
536
  return async (
16✔
537
    ctx: Context<typeof CommentContextInput, typeof CommentContext>,
538
  ) => {
539
    const auth = await findComment(ctx.actor, ctx.payload.comment_id);
16✔
540
    const { address, is_author } = await findAddress(
14✔
541
      ctx.actor,
542
      auth.community_id,
543
      roles ?? ['admin', 'moderator', 'member'],
25✔
544
      auth.author_address_id,
545
    );
546

547
    (ctx as { context: CommentContext }).context = {
13✔
548
      ...auth,
549
      address,
550
      is_author,
551
    };
552

553
    await mustBeAuthorized(ctx, {
13✔
554
      permissions: action ? { action, topic_id: auth.topic_id! } : undefined,
13✔
555
      author,
556
      roles,
557
    });
558
  };
559
}
560

561
/**
562
 * Validates if actor's address is authorized to perform actions on a thread
563
 * @param action specific group permission action
564
 * @param author when true, rejects members that are not the author
565
 * @param collaborators authorize thread collaborators
566
 * @throws InvalidActor when not authorized
567
 */
568
export function authThread({
569
  action,
570
  author,
571
  collaborators,
572
}: AggregateAuthOptions) {
573
  return async (
51✔
574
    ctx: Context<typeof ThreadContextInput, typeof ThreadContext>,
575
  ) => {
576
    const auth = await findThread(
51✔
577
      ctx.actor,
578
      ctx.payload.thread_id,
579
      collaborators ?? false,
88✔
580
    );
581
    const { address, is_author } = await findAddress(
48✔
582
      ctx.actor,
583
      auth.community_id,
584
      ['admin', 'moderator', 'member'],
585
      auth.author_address_id,
586
    );
587

588
    (ctx as { context: ThreadContext }).context = {
48✔
589
      ...auth,
590
      address,
591
      is_author,
592
    };
593

594
    await mustBeAuthorized(ctx, {
48✔
595
      permissions: action ? { action, topic_id: auth.topic_id! } : undefined,
48✔
596
      author,
597
      collaborators: collaborators ? auth.thread.collaborators! : undefined,
48✔
598
    });
599
  };
600
}
601

602
/**
603
 * Validates if actor's address is authorized to perform actions on a topic
604
 * @param action specific group permission action
605
 * @throws InvalidActor when not authorized
606
 */
607
export function authTopic({ roles, action }: AggregateAuthOptions) {
608
  return async (
41✔
609
    ctx: Context<typeof TopicContextInput, typeof TopicContext>,
610
  ) => {
611
    const auth = await findTopic(ctx.actor, ctx.payload.topic_id);
41✔
612
    const { address, is_author } = await findAddress(
40✔
613
      ctx.actor,
614
      auth.community_id,
615
      roles ?? ['admin', 'moderator', 'member'],
73✔
616
    );
617

618
    (ctx as { context: TopicContext }).context = {
38✔
619
      ...auth,
620
      address,
621
      is_author,
622
    };
623

624
    await mustBeAuthorized(ctx, {
38✔
625
      permissions: action ? { action, topic_id: auth.topic_id } : undefined,
38✔
626
    });
627
  };
628
}
629

630
/**
631
 * Validates if actor's address is authorized to perform actions on a reaction
632
 * @throws InvalidActor when not authorized
633
 */
634
export function authReaction() {
635
  return async (
4✔
636
    ctx: Context<typeof ReactionContextInput, typeof ReactionContext>,
637
  ) => {
638
    const auth = await findReaction(
4✔
639
      ctx.actor,
640
      ctx.payload.community_id,
641
      ctx.payload.reaction_id,
642
    );
643
    const { address, is_author } = await findAddress(
3✔
644
      ctx.actor,
645
      auth.community_id,
646
      ['admin', 'moderator', 'member'],
647
      auth.author_address_id,
648
    );
649

650
    (ctx as { context: ReactionContext }).context = {
3✔
651
      ...auth,
652
      address,
653
      is_author,
654
    };
655

656
    // reactions are only authorized by the author
657
    await mustBeAuthorized(ctx, { author: true });
3✔
658
  };
659
}
660

661
export function authPoll({ action }: AggregateAuthOptions) {
662
  return async (ctx: Context<typeof PollContextInput, typeof PollContext>) => {
×
663
    const poll = await findPoll(ctx.actor, ctx.payload.poll_id);
×
664
    const threadAuth = await findThread(ctx.actor, poll.thread_id, false);
×
665
    const { address, is_author } = await findAddress(
×
666
      ctx.actor,
667
      threadAuth.community_id,
668
      ['admin', 'moderator', 'member'],
669
      poll.Thread!.address_id,
670
    );
671

672
    if (threadAuth.thread.archived_at)
×
673
      throw new InvalidState('Thread is archived');
×
674
    (ctx as { context: PollContext }).context = {
×
675
      address,
676
      is_author,
677
      poll,
678
      poll_id: poll.id!,
679
      community_id: threadAuth.community_id,
680
      thread: threadAuth.thread,
681
    };
682

683
    await mustBeAuthorized(ctx, {
×
684
      author: true,
685
      permissions: action
×
686
        ? {
687
            topic_id: threadAuth.topic_id,
688
            action,
689
          }
690
        : undefined,
691
    });
692
  };
693
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc