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

vzakharchenko / Forge-Secure-Notes-for-Jira / 21300212336

23 Jan 2026 08:27PM UTC coverage: 91.991% (+0.2%) from 91.786%
21300212336

push

github

vzakharchenko
security: use timingSafeEqual for hash verification

184 of 210 branches covered (87.62%)

Branch coverage included in aggregate %.

6 of 9 new or added lines in 2 files covered. (66.67%)

597 of 639 relevant lines covered (93.43%)

20.72 hits per line

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

72.34
/src/services/SecurityNoteService.ts
1
import { SecurityNoteStatus, SHARED_EVENT_NAME } from "../../shared/Types";
2
import { getAppContext, withAppContext } from "../controllers";
3
import { NewSecurityNote } from "../../shared/dto";
4
import { InferInsertModel, InferSelectModel } from "drizzle-orm";
5
import {
6
  calculateHash,
7
  verifyHashConstantTime,
8
  sendExpirationNotification,
9
  sendIssueNotification,
10
  sendNoteDeletedNotification,
11
  isIssueContext,
12
  IssueContext,
13
} from "../core";
14
import { v4 } from "uuid";
15
import {
16
  SecurityNoteData,
17
  ProjectInfo,
18
  ProjectIssue,
19
  OpenSecurityNote,
20
  UserViewInfoType,
21
  ViewMySecurityNotes,
22
} from "../../shared/responses";
23
import { publishGlobal } from "@forge/realtime";
24
import { inject, injectable } from "inversify";
25
import { FORGE_INJECTION_TOKENS } from "../constants";
26
import { JiraUserService } from "../jira";
27
import { SecurityNoteRepository, securityNotes } from "../database";
28
import { BootstrapService } from "./BootstrapService";
29
import { SecurityStorage } from "../storage";
30

31
@injectable()
32
export class SecurityNoteService {
29✔
33
  constructor(
34
    @inject(FORGE_INJECTION_TOKENS.JiraUserService)
35
    private readonly jiraUserService: JiraUserService,
190✔
36
    @inject(FORGE_INJECTION_TOKENS.SecurityNoteRepository)
37
    private readonly securityNoteRepository: SecurityNoteRepository,
190✔
38
    @inject(FORGE_INJECTION_TOKENS.BootstrapService)
39
    private readonly bootstrapService: BootstrapService,
190✔
40
    @inject(FORGE_INJECTION_TOKENS.SecurityStorage)
41
    private readonly securityStorage: SecurityStorage,
190✔
42
  ) {}
43

44
  private mapSecurityNotesToView(
45
    securityDbNotes: (InferSelectModel<typeof securityNotes> & {
46
      count: number;
47
    })[],
48
    defaults?: {
49
      issueId?: string;
50
      issueKey?: string;
51
      projectId?: string;
52
      projectKey?: string;
53
    },
54
  ): ViewMySecurityNotes[] {
55
    if (!securityDbNotes || securityDbNotes.length === 0) {
4!
56
      return [];
×
57
    }
58
    return securityDbNotes.map((sn) => ({
4✔
59
      id: sn.id,
60
      createdBy: {
61
        displayName: sn.createdUserName,
62
        accountId: sn.createdBy,
63
        avatarUrl: sn.createdAvatarUrl,
64
      },
65
      targetUser: {
66
        displayName: sn.targetUserName,
67
        accountId: sn.targetUserId,
68
        avatarUrl: sn.targetAvatarUrl,
69
      },
70
      viewTimeOut: "5mins",
71
      status: sn.status as SecurityNoteStatus,
72
      expiration: sn.expiryDate,
73
      issueId: sn.issueId ?? defaults?.issueId ?? undefined,
12✔
74
      issueKey: sn.issueKey ?? defaults?.issueKey ?? undefined,
8✔
75
      projectId: sn.projectId ?? defaults?.projectId ?? undefined,
12✔
76
      projectKey: sn.projectKey ?? defaults?.projectKey ?? undefined,
10✔
77
      createdAt: sn.createdAt,
78
      viewedAt: sn.viewedAt ?? undefined,
8✔
79
      deletedAt: sn.deletedAt ?? undefined,
8✔
80
      expiry: sn.expiry,
81
      description: sn.description ?? undefined,
8✔
82
      count: sn.count,
83
    }));
84
  }
85
  @withAppContext()
86
  async getSecurityNoteByIssue(
29✔
87
    issueIdOrKey: string,
88
    limit: number,
89
    offset: number,
90
  ): Promise<ViewMySecurityNotes[]> {
91
    const context = getAppContext()!;
2✔
92
    let isAdmin = await this.bootstrapService.isAdmin();
2✔
93
    const securityDbNotes = await this.securityNoteRepository.getAllSecurityNotesByIssue(
2✔
94
      issueIdOrKey,
95
      limit,
96
      offset,
97
      isAdmin ? null : context.accountId,
2✔
98
    );
99
    return this.mapSecurityNotesToView(securityDbNotes);
2✔
100
  }
101
  @withAppContext()
102
  async getSecurityNoteByProject(
29✔
103
    projectIdOrKey: string,
104
    limit: number,
105
    offset: number,
106
  ): Promise<ViewMySecurityNotes[]> {
107
    const context = getAppContext()!;
1✔
108
    let isAdmin = await this.bootstrapService.isAdmin();
1✔
109
    const securityDbNotes = await this.securityNoteRepository.getAllSecurityNotesByProject(
1✔
110
      projectIdOrKey,
111
      limit,
112
      offset,
113
      isAdmin ? null : context.accountId,
1!
114
    );
115
    return this.mapSecurityNotesToView(securityDbNotes);
1✔
116
  }
117

118
  async getIssuesAndProjects(): Promise<ProjectIssue> {
119
    return {
1✔
120
      result: (await this.securityNoteRepository.getIssuesAndProjects()) as ProjectInfo[],
121
    };
122
  }
123
  @withAppContext()
124
  async getSecurityNoteByAccountId(
29✔
125
    accountId: string,
126
    limit: number,
127
    offset: number,
128
  ): Promise<ViewMySecurityNotes[]> {
129
    const context = getAppContext()!;
2✔
130
    if (!(await this.bootstrapService.isAdmin()) && context.accountId !== accountId) {
2✔
131
      return [];
1✔
132
    }
133
    const securityDbNotes = await this.securityNoteRepository.getAllSecurityNotesByAccountId(
1✔
134
      accountId,
135
      limit,
136
      offset,
137
    );
138
    return this.mapSecurityNotesToView(securityDbNotes);
1✔
139
  }
140

141
  async getSecurityNoteUsers(): Promise<UserViewInfoType[]> {
142
    return this.securityNoteRepository.getSecurityNoteUsers();
1✔
143
  }
144

145
  async expireSecurityNotes(): Promise<void> {
146
    const notes = await this.securityNoteRepository.getAllExpiredNotes();
6✔
147
    if (notes?.length) {
6✔
148
      for (const note of notes) {
1✔
149
        await this.securityStorage.deletePayload(note.id);
1✔
150
        try {
1✔
151
          if (note.issueKey) {
1!
152
            await sendExpirationNotification({
1✔
153
              issueKey: note.issueKey,
154
              recipientAccountId: note.targetUserId,
155
              displayName: note.createdUserName,
156
            });
157
          }
158
        } catch (e) {
159
          // eslint-disable-next-line no-console
160
          console.error(e);
×
161
        }
162
      }
163
      await this.securityNoteRepository.expireSecurityNote(notes.map((n) => n.id));
1✔
164
    }
165
  }
166

167
  @withAppContext()
168
  async getSecuredData(securityNoteId: string, key: string): Promise<SecurityNoteData | undefined> {
29✔
169
    const sn = await this.securityNoteRepository.getSecurityNode(securityNoteId);
×
170
    if (!sn) {
×
171
      return undefined;
×
172
    }
NEW
173
    const calculatedHash = await calculateHash(key, sn.targetUserId);
×
NEW
174
    const errorMessage = `SecurityKey is not valid, please ask ${sn.createdUserName} to sent you it. `;
×
NEW
175
    verifyHashConstantTime(sn.encryptionKeyHash, calculatedHash, errorMessage);
×
176
    const encryptedData = await this.securityStorage.getPayload(securityNoteId);
×
177
    if (!encryptedData) {
×
178
      // eslint-disable-next-line no-console
179
      console.error("data does not exists");
×
180
      await this.securityNoteRepository.deleteSecurityNote(securityNoteId);
×
181
      return undefined;
×
182
    }
183
    await this.securityStorage.deletePayload(securityNoteId);
×
184
    await this.securityNoteRepository.viewSecurityNote(securityNoteId);
×
185
    await publishGlobal(SHARED_EVENT_NAME, sn.issueId ?? "");
×
186
    return {
×
187
      id: sn.id,
188
      iv: sn.iv,
189
      salt: sn.salt,
190
      encryptedData: encryptedData,
191
      viewTimeOut: 300,
192
      expiry: sn.expiry,
193
    };
194
  }
195

196
  @withAppContext()
197
  async isValidLink(securityNoteId: string): Promise<OpenSecurityNote> {
29✔
198
    const accountId = getAppContext()?.accountId;
×
199
    if (!accountId) return { valid: false };
×
200

201
    const sn = await this.securityNoteRepository.getSecurityNode(securityNoteId);
×
202
    if (!sn) return { valid: false };
×
203

204
    const isValid = sn.targetUserId === accountId;
×
205

206
    return {
×
207
      valid: isValid,
208
      sourceAccountId: isValid
×
209
        ? await calculateHash(sn.description ?? sn.createdBy, sn.createdBy)
×
210
        : undefined,
211
    };
212
  }
213

214
  addHours(date: Date, hours: number): Date {
215
    const result = new Date(date);
8✔
216
    result.setHours(result.getHours() + hours);
8✔
217
    return result;
8✔
218
  }
219

220
  getExpire(expire: string): Date {
221
    switch (expire) {
7✔
222
      case "1h": {
223
        return this.addHours(new Date(), 1);
4✔
224
      }
225
      case "1d": {
226
        return this.addHours(new Date(), 24);
1✔
227
      }
228
      case "7d": {
229
        return this.addHours(new Date(), 24 * 7);
1✔
230
      }
231
      default: {
232
        return this.addHours(new Date(), 24 * 10);
1✔
233
      }
234
    }
235
  }
236

237
  @withAppContext()
238
  async getMySecurityNoteIssue(): Promise<ViewMySecurityNotes[]> {
29✔
239
    const { accountId, context } = getAppContext()!;
×
240
    if (!isIssueContext(context)) {
×
241
      throw new Error("expected Issue context");
×
242
    }
243

244
    const issueContext = context;
×
245
    const { key, id } = issueContext.extension.issue;
×
246
    const securityDbNotes = await this.securityNoteRepository.getAllMySecurityNotes(key, accountId);
×
247
    return this.mapSecurityNotesToView(securityDbNotes, {
×
248
      issueId: id,
249
      issueKey: key,
250
      projectId: issueContext.extension.project?.id,
251
      projectKey: issueContext.extension.project?.key,
252
    });
253
  }
254

255
  private setCreatorInfo(
256
    data: Partial<InferInsertModel<typeof securityNotes>>,
257
    currentUser: any,
258
    accountId: string,
259
  ): void {
260
    data.createdBy = accountId;
4✔
261
    if (currentUser) {
4✔
262
      data.createdUserName = currentUser.displayName;
3✔
263
      data.createdAvatarUrl = currentUser.avatarUrls["32x32"];
3✔
264
    } else {
265
      data.createdUserName = accountId;
1✔
266
      data.createdAvatarUrl = "";
1✔
267
    }
268
  }
269

270
  private async setTargetUserInfo(
271
    data: Partial<InferInsertModel<typeof securityNotes>>,
272
  ): Promise<void> {
273
    const targetUserInfo = await this.jiraUserService.getUserById(String(data.targetUserId));
4✔
274
    if (targetUserInfo) {
4✔
275
      data.targetUserName = targetUserInfo.displayName;
3✔
276
      data.targetAvatarUrl = targetUserInfo.avatarUrls["32x32"];
3✔
277
    } else {
278
      data.targetAvatarUrl = "";
1✔
279
    }
280
  }
281

282
  private calculateExpiryDate(isCustomExpiry: number, expiry: string): Date {
283
    return Number(isCustomExpiry) > 0 ? new Date(String(expiry)) : this.getExpire(String(expiry));
4✔
284
  }
285

286
  private buildNoteLink(context: IssueContext, noteId: string): string {
287
    if (context.customerRequest) {
4!
288
      return context.customerRequest._links.web;
×
289
    }
290
    const appUrlParts = context.localId.split("/");
4✔
291
    const appUrl = `${appUrlParts[1]}/${appUrlParts[2]}/view/${noteId}`;
4✔
292
    return `${context.siteUrl}/jira/apps/${appUrl}`;
4✔
293
  }
294

295
  private async sendNotificationSafely(
296
    issueKey: string,
297
    recipientAccountId: string,
298
    displayName: string,
299
    noteLink: string,
300
    expiryDate: Date,
301
  ): Promise<void> {
302
    try {
4✔
303
      await sendIssueNotification({
4✔
304
        issueKey,
305
        recipientAccountId,
306
        displayName,
307
        noteLink,
308
        expiryDate,
309
      });
310
    } catch (e) {
311
      // eslint-disable-next-line no-console
312
      console.error(e);
1✔
313
    }
314
  }
315

316
  private async buildSecurityNoteData(
317
    targetUser: { accountId: string; userName: string },
318
    securityNote: NewSecurityNote,
319
    context: IssueContext,
320
    currentUser: any,
321
    accountId: string,
322
  ): Promise<Partial<InferInsertModel<typeof securityNotes>>> {
323
    const data: Partial<InferInsertModel<typeof securityNotes>> = {
4✔
324
      issueKey: context.extension.issue.key,
325
      issueId: context.extension.issue.id,
326
      projectId: context.extension.project?.id,
327
      projectKey: context.extension.project?.key,
328
      targetUserId: targetUser.accountId,
329
      targetUserName: targetUser.userName,
330
      encryptionKeyHash: await calculateHash(securityNote.encryptionKeyHash, targetUser.accountId),
331
      iv: securityNote.iv,
332
      salt: securityNote.salt,
333
      isCustomExpiry: securityNote.isCustomExpiry ? 1 : 0,
4✔
334
      expiry: securityNote.expiry,
335
      description: securityNote.description,
336
      createdAt: new Date(),
337
      status: "NEW",
338
      id: v4(),
339
    };
340

341
    this.setCreatorInfo(data, currentUser, accountId);
4✔
342
    await this.setTargetUserInfo(data);
4✔
343
    data.expiryDate = this.calculateExpiryDate(
4✔
344
      data.isCustomExpiry as number,
345
      data.expiry as string,
346
    );
347

348
    return data;
4✔
349
  }
350

351
  @withAppContext()
352
  async createSecurityNote(securityNote: NewSecurityNote): Promise<void> {
29✔
353
    const appContext = getAppContext()!;
4✔
354
    const accountId = appContext.accountId;
4✔
355
    const context = appContext.context as IssueContext;
4✔
356
    const currentUser = await this.jiraUserService.getCurrentUser();
4✔
357

358
    const datas = await Promise.all(
4✔
359
      securityNote.targetUsers.map((targetUser) =>
360
        this.buildSecurityNoteData(targetUser, securityNote, context, currentUser, accountId),
4✔
361
      ),
362
    );
363

364
    await this.securityNoteRepository.createSecurityNote(
4✔
365
      datas as Partial<InferInsertModel<typeof securityNotes>>[],
366
    );
367

368
    await Promise.all(
4✔
369
      datas.map(async (data) => {
370
        await this.securityStorage.savePayload(String(data.id), securityNote.encryptedPayload);
4✔
371
        const noteLink = this.buildNoteLink(context, String(data.id));
4✔
372
        await this.sendNotificationSafely(
4✔
373
          context.extension.issue.key,
374
          String(data.targetUserId),
375
          currentUser?.displayName ?? accountId,
5✔
376
          noteLink,
377
          data.expiryDate as Date,
378
        );
379
      }),
380
    );
381
  }
382

383
  async deleteSecurityNote(securityNoteId: string): Promise<void> {
384
    await this.securityStorage.deletePayload(securityNoteId);
3✔
385
    const sn = await this.securityNoteRepository.getSecurityNode(securityNoteId);
3✔
386
    if (sn) {
3✔
387
      await this.securityNoteRepository.deleteSecurityNote(securityNoteId);
2✔
388
      try {
2✔
389
        await sendNoteDeletedNotification({
2✔
390
          issueKey: sn!.issueKey ?? "",
2!
391
          recipientAccountId: sn.targetUserId,
392
          displayName: sn.createdUserName,
393
        });
394
      } catch (e) {
395
        // eslint-disable-next-line no-console
396
        console.error(e);
1✔
397
      }
398
    }
399
  }
400
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc