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

hicommonwealth / commonwealth / 15685020342

16 Jun 2025 03:26PM UTC coverage: 39.614% (-0.3%) from 39.908%
15685020342

Pull #11963

github

web-flow
Merge 4b19f2081 into 2240b5874
Pull Request #11963: External API Quests

1786 of 4904 branches covered (36.42%)

Branch coverage included in aggregate %.

8 of 56 new or added lines in 2 files covered. (14.29%)

67 existing lines in 2 files now uncovered.

3222 of 7738 relevant lines covered (41.64%)

36.6 hits per line

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

75.84
/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 { ZodType, 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)
38!
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({
38✔
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) {
38✔
162
    // fire and forget address activity tracking
163
    address.last_active = new Date();
37✔
164
    void address.save();
37✔
165
    return { address };
37✔
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)
96✔
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)
34✔
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
  {
336
    actor,
337
    context,
338
  }:
339
    | Context<typeof AuthContextInput, typeof AuthContext>
340
    | Context<typeof ThreadContextInput, typeof ThreadContext>
341
    | Context<typeof CommentContextInput, typeof CommentContext>
342
    | Context<typeof TopicContextInput, typeof TopicContext>
343
    | Context<typeof ReactionContextInput, typeof ReactionContext>
344
    | Context<typeof PollContextInput, typeof PollContext>
345
    | Context<typeof VerifiedContextInput, typeof VerifiedContext>,
346
  check: {
347
    permissions?: {
348
      topic_id: number;
349
      action: GroupGatedActionKey;
350
    };
351
    author?: boolean;
352
    collaborators?: z.infer<typeof Address>[];
353
    roles?: Role[];
354
  },
355
) {
356
  // System actors are always allowed
357
  if (actor.is_system_actor) return;
182✔
358

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

362
  // Banned actors are always rejected (if not admin or system actors)
363
  if (context!.address.is_banned) throw new BannedActor(actor);
72✔
364

365
  // Author is always allowed to act on their own entity, unless banned
366
  if ('is_author' in context! && context!.is_author) return;
70✔
367

368
  if (
62!
369
    check.roles?.includes('moderator') &&
62!
370
    context?.address.role === 'moderator'
371
  )
372
    return;
×
373

374
  // Allows when actor has group permissions in topic
375
  if (check.permissions)
62✔
376
    return await checkGatedActions(
55✔
377
      actor,
378
      context!.address!.id!,
379
      check.permissions.action,
380
      check.permissions.topic_id,
381
    );
382

383
  // Allows when actor is a collaborator in the thread
384
  if ('is_collaborator' in context! && check.collaborators) {
7✔
385
    const found = check.collaborators?.find(
5✔
386
      ({ address }) => address === actor.address,
8✔
387
    );
388
    context!.is_collaborator = !!found;
5✔
389
    if (context!.is_collaborator) return;
5✔
390
    throw new InvalidActor(actor, 'Not authorized collaborator');
1✔
391
  }
392

393
  // At this point, we know the actor is not the author of the entity
394
  // and it's also not an admin, system actor, or collaborator...
395
  // This guard is used to enforce that the author is the only one who can
396
  // perform actions on the entity.
397
  if (check.author)
2!
398
    throw new InvalidActor(actor, 'Not the author of the entity');
2✔
399
}
400

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

426
export async function isSuperAdmin(ctx: Context<ZodType, ZodType>) {
427
  if (!ctx.actor.user.isAdmin)
59!
428
    await Promise.reject(new InvalidActor(ctx.actor, 'Must be a super admin'));
×
429
}
430

431
/**
432
 * Validates if actor's address is authorized/verified, not tied to any specific community
433
 * @throws InvalidActor when not authorized
434
 */
435
export function authVerified() {
436
  return async (
33✔
437
    ctx: Context<typeof VerifiedContextInput, typeof VerifiedContext>,
438
  ) => {
439
    const { address } = await findVerifiedAddress(ctx.actor);
33✔
440
    (ctx as { context: VerifiedContext }).context = { address };
32✔
441
  };
442
}
443

444
/**
445
 * Optionally validates if actor's address is authorized/verified, not tied to any specific community
446
 */
447
export async function authOptionalVerified(
448
  ctx: Context<typeof VerifiedContextInput, typeof VerifiedContext>,
449
) {
450
  try {
6✔
451
    if (!ctx.actor.user || !ctx.actor.address) return;
6✔
452
    const { address } = await findVerifiedAddress(ctx.actor);
5✔
453
    (ctx as { context: VerifiedContext }).context = { address };
5✔
454
  } catch (err) {
455
    // ignore InvalidActor errors
UNCOV
456
    if (err instanceof InvalidActor) return;
×
UNCOV
457
    throw err;
×
458
  }
459
}
460

461
/**
462
 * Creates an authorization context for the actor when authenticated,
463
 * but anonymous access is allowed.
464
 * This is mainly used when querying communities with gating conditions.
465
 */
466
export async function authOptional(
467
  ctx: Context<typeof AuthContextInput, typeof AuthContext>,
468
) {
469
  if (!ctx.actor.user || !ctx.actor.address || !ctx.payload.community_id)
8!
470
    return;
×
471
  if (ctx.payload.community_id === ALL_COMMUNITIES) return;
8!
472

473
  try {
8✔
474
    const { address, is_author } = await findAddress(
8✔
475
      ctx.actor,
476
      ctx.payload.community_id,
477
      ['admin', 'moderator', 'member'],
478
    );
479

480
    (ctx as { context: AuthContext }).context = {
8✔
481
      address,
482
      is_author,
483
      community_id: ctx.payload.community_id,
484
    };
485
  } catch (err) {
486
    // ignore InvalidActor errors
487
    if (err instanceof InvalidActor) return;
×
UNCOV
488
    throw err;
×
489
  }
490
}
491

492
/**
493
 * Creates an authorization context for the actor when authenticated,
494
 * but anonymous access is allowed.
495
 * This is mainly used when querying threads with gating conditions.
496
 */
497
export async function authOptionalForThread(
498
  ctx: Context<typeof ThreadContextInput, typeof ThreadContext>,
499
) {
UNCOV
500
  if (!ctx.actor.user || !ctx.actor.address || !ctx.payload.thread_id) return;
×
501

502
  try {
×
UNCOV
503
    const auth = await findThread(ctx.actor, ctx.payload.thread_id, false);
×
UNCOV
504
    const { address, is_author } = await findAddress(
×
505
      ctx.actor,
506
      auth.community_id,
507
      ['admin', 'moderator', 'member'],
508
      auth.author_address_id,
509
    );
510

UNCOV
511
    (ctx as { context: AuthContext }).context = {
×
512
      address,
513
      is_author,
514
      community_id: auth.community_id,
515
    };
516
  } catch (err) {
517
    // ignore InvalidActor errors
UNCOV
518
    if (err instanceof InvalidActor) return;
×
UNCOV
519
    throw err;
×
520
  }
521
}
522

523
/**
524
 * Validates if actor's address is authorized
525
 * @param roles specific community roles - all by default
526
 * @throws InvalidActor when not authorized
527
 */
528
export function authRoles(...roles: Role[]) {
529
  return async (ctx: Context<typeof AuthContextInput, typeof AuthContext>) => {
92✔
530
    const { address, is_author } = await findAddress(
91✔
531
      ctx.actor,
532
      ctx.payload.community_id,
533
      ctx.actor.user.isAdmin || !roles.length
254✔
534
        ? ['admin', 'moderator', 'member']
535
        : roles,
536
    );
537

538
    (ctx as { context: AuthContext }).context = {
80✔
539
      address,
540
      is_author,
541
      community_id: ctx.payload.community_id,
542
    };
543

544
    await mustBeAuthorized(ctx, {});
80✔
545
  };
546
}
547

548
type AggregateAuthOptions = {
549
  roles?: Role[];
550
  action?: GroupGatedActionKey;
551
  author?: boolean;
552
  collaborators?: boolean;
553
};
554

555
/**
556
 * Validates if actor's address is authorized to perform actions on a comment
557
 * @param action specific group permission action
558
 * @param author when true, rejects members that are not the author
559
 * @param roles the roles that are authorized
560
 * @throws InvalidActor when not authorized
561
 */
562
export function authComment({ action, author, roles }: AggregateAuthOptions) {
563
  return async (
16✔
564
    ctx: Context<typeof CommentContextInput, typeof CommentContext>,
565
  ) => {
566
    const auth = await findComment(ctx.actor, ctx.payload.comment_id);
16✔
567
    const { address, is_author } = await findAddress(
14✔
568
      ctx.actor,
569
      auth.community_id,
570
      roles ?? ['admin', 'moderator', 'member'],
25✔
571
      auth.author_address_id,
572
    );
573

574
    (ctx as { context: CommentContext }).context = {
13✔
575
      ...auth,
576
      address,
577
      is_author,
578
    };
579

580
    await mustBeAuthorized(ctx, {
13✔
581
      permissions: action ? { action, topic_id: auth.topic_id! } : undefined,
13✔
582
      author,
583
      roles,
584
    });
585
  };
586
}
587

588
/**
589
 * Validates if actor's address is authorized to perform actions on a thread
590
 * @param action specific group permission action
591
 * @param author when true, rejects members that are not the author
592
 * @param collaborators authorize thread collaborators
593
 * @throws InvalidActor when not authorized
594
 */
595
export function authThread({
596
  action,
597
  author,
598
  collaborators,
599
}: AggregateAuthOptions) {
600
  return async (
51✔
601
    ctx: Context<typeof ThreadContextInput, typeof ThreadContext>,
602
  ) => {
603
    const auth = await findThread(
51✔
604
      ctx.actor,
605
      ctx.payload.thread_id,
606
      collaborators ?? false,
88✔
607
    );
608
    const { address, is_author } = await findAddress(
48✔
609
      ctx.actor,
610
      auth.community_id,
611
      ['admin', 'moderator', 'member'],
612
      auth.author_address_id,
613
    );
614

615
    (ctx as { context: ThreadContext }).context = {
48✔
616
      ...auth,
617
      address,
618
      is_author,
619
    };
620

621
    await mustBeAuthorized(ctx, {
48✔
622
      permissions: action ? { action, topic_id: auth.topic_id! } : undefined,
48✔
623
      author,
624
      collaborators: collaborators ? auth.thread.collaborators! : undefined,
48✔
625
    });
626
  };
627
}
628

629
/**
630
 * Validates if actor's address is authorized to perform actions on a topic
631
 * @param action specific group permission action
632
 * @throws InvalidActor when not authorized
633
 */
634
export function authTopic({ roles, action }: AggregateAuthOptions) {
635
  return async (
41✔
636
    ctx: Context<typeof TopicContextInput, typeof TopicContext>,
637
  ) => {
638
    const auth = await findTopic(ctx.actor, ctx.payload.topic_id);
41✔
639
    const { address, is_author } = await findAddress(
40✔
640
      ctx.actor,
641
      auth.community_id,
642
      roles ?? ['admin', 'moderator', 'member'],
73✔
643
    );
644

645
    (ctx as { context: TopicContext }).context = {
38✔
646
      ...auth,
647
      address,
648
      is_author,
649
    };
650

651
    await mustBeAuthorized(ctx, {
38✔
652
      permissions: action ? { action, topic_id: auth.topic_id } : undefined,
38✔
653
    });
654
  };
655
}
656

657
/**
658
 * Validates if actor's address is authorized to perform actions on a reaction
659
 * @throws InvalidActor when not authorized
660
 */
661
export function authReaction() {
662
  return async (
4✔
663
    ctx: Context<typeof ReactionContextInput, typeof ReactionContext>,
664
  ) => {
665
    const auth = await findReaction(
4✔
666
      ctx.actor,
667
      ctx.payload.community_id,
668
      ctx.payload.reaction_id,
669
    );
670
    const { address, is_author } = await findAddress(
3✔
671
      ctx.actor,
672
      auth.community_id,
673
      ['admin', 'moderator', 'member'],
674
      auth.author_address_id,
675
    );
676

677
    (ctx as { context: ReactionContext }).context = {
3✔
678
      ...auth,
679
      address,
680
      is_author,
681
    };
682

683
    // reactions are only authorized by the author
684
    await mustBeAuthorized(ctx, { author: true });
3✔
685
  };
686
}
687

688
export function authPoll({ action }: AggregateAuthOptions) {
UNCOV
689
  return async (ctx: Context<typeof PollContextInput, typeof PollContext>) => {
×
UNCOV
690
    const poll = await findPoll(ctx.actor, ctx.payload.poll_id);
×
UNCOV
691
    const threadAuth = await findThread(ctx.actor, poll.thread_id, false);
×
UNCOV
692
    const { address, is_author } = await findAddress(
×
693
      ctx.actor,
694
      threadAuth.community_id,
695
      ['admin', 'moderator', 'member'],
696
      poll.Thread!.address_id,
697
    );
698

UNCOV
699
    if (threadAuth.thread.archived_at)
×
UNCOV
700
      throw new InvalidState('Thread is archived');
×
UNCOV
701
    (ctx as { context: PollContext }).context = {
×
702
      address,
703
      is_author,
704
      poll,
705
      poll_id: poll.id!,
706
      community_id: threadAuth.community_id,
707
      thread: threadAuth.thread,
708
    };
709

UNCOV
710
    await mustBeAuthorized(ctx, {
×
711
      author: true,
712
      permissions: action
×
713
        ? {
714
            topic_id: threadAuth.topic_id,
715
            action,
716
          }
717
        : undefined,
718
    });
719
  };
720
}
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