@atproto/ozone 0.1.150 → 0.1.151

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/api/moderation/queryEvents.d.ts.map +1 -1
  3. package/dist/api/moderation/queryEvents.js +2 -1
  4. package/dist/api/moderation/queryEvents.js.map +1 -1
  5. package/dist/context.d.ts +3 -0
  6. package/dist/context.d.ts.map +1 -1
  7. package/dist/context.js +7 -1
  8. package/dist/context.js.map +1 -1
  9. package/dist/daemon/context.d.ts +3 -0
  10. package/dist/daemon/context.d.ts.map +1 -1
  11. package/dist/daemon/context.js +11 -1
  12. package/dist/daemon/context.js.map +1 -1
  13. package/dist/daemon/index.d.ts +1 -0
  14. package/dist/daemon/index.d.ts.map +1 -1
  15. package/dist/daemon/index.js +3 -1
  16. package/dist/daemon/index.js.map +1 -1
  17. package/dist/daemon/strike-expiry-processor.d.ts +18 -0
  18. package/dist/daemon/strike-expiry-processor.d.ts.map +1 -0
  19. package/dist/daemon/strike-expiry-processor.js +111 -0
  20. package/dist/daemon/strike-expiry-processor.js.map +1 -0
  21. package/dist/db/migrations/20251008T120000000Z-add-strike-system.d.ts +4 -0
  22. package/dist/db/migrations/20251008T120000000Z-add-strike-system.d.ts.map +1 -0
  23. package/dist/db/migrations/20251008T120000000Z-add-strike-system.js +75 -0
  24. package/dist/db/migrations/20251008T120000000Z-add-strike-system.js.map +1 -0
  25. package/dist/db/migrations/index.d.ts +1 -0
  26. package/dist/db/migrations/index.d.ts.map +1 -1
  27. package/dist/db/migrations/index.js +2 -1
  28. package/dist/db/migrations/index.js.map +1 -1
  29. package/dist/db/schema/account_strike.d.ts +12 -0
  30. package/dist/db/schema/account_strike.d.ts.map +1 -0
  31. package/dist/db/schema/account_strike.js +5 -0
  32. package/dist/db/schema/account_strike.js.map +1 -0
  33. package/dist/db/schema/index.d.ts +3 -1
  34. package/dist/db/schema/index.d.ts.map +1 -1
  35. package/dist/db/schema/index.js.map +1 -1
  36. package/dist/db/schema/job_cursor.d.ts +11 -0
  37. package/dist/db/schema/job_cursor.d.ts.map +1 -0
  38. package/dist/db/schema/job_cursor.js +5 -0
  39. package/dist/db/schema/job_cursor.js.map +1 -0
  40. package/dist/db/schema/moderation_event.d.ts +3 -0
  41. package/dist/db/schema/moderation_event.d.ts.map +1 -1
  42. package/dist/db/schema/moderation_event.js.map +1 -1
  43. package/dist/lexicon/lexicons.d.ts +176 -0
  44. package/dist/lexicon/lexicons.d.ts.map +1 -1
  45. package/dist/lexicon/lexicons.js +88 -0
  46. package/dist/lexicon/lexicons.js.map +1 -1
  47. package/dist/lexicon/types/tools/ozone/moderation/defs.d.ts +35 -0
  48. package/dist/lexicon/types/tools/ozone/moderation/defs.d.ts.map +1 -1
  49. package/dist/lexicon/types/tools/ozone/moderation/defs.js +9 -0
  50. package/dist/lexicon/types/tools/ozone/moderation/defs.js.map +1 -1
  51. package/dist/lexicon/types/tools/ozone/moderation/queryEvents.d.ts +2 -0
  52. package/dist/lexicon/types/tools/ozone/moderation/queryEvents.d.ts.map +1 -1
  53. package/dist/lexicon/types/tools/ozone/moderation/queryEvents.js.map +1 -1
  54. package/dist/lexicon/types/tools/ozone/moderation/queryStatuses.d.ts +2 -0
  55. package/dist/lexicon/types/tools/ozone/moderation/queryStatuses.d.ts.map +1 -1
  56. package/dist/lexicon/types/tools/ozone/moderation/queryStatuses.js.map +1 -1
  57. package/dist/mod-service/index.d.ts +9 -3
  58. package/dist/mod-service/index.d.ts.map +1 -1
  59. package/dist/mod-service/index.js +58 -5
  60. package/dist/mod-service/index.js.map +1 -1
  61. package/dist/mod-service/status.d.ts +44 -2
  62. package/dist/mod-service/status.d.ts.map +1 -1
  63. package/dist/mod-service/status.js +7 -0
  64. package/dist/mod-service/status.js.map +1 -1
  65. package/dist/mod-service/strike.d.ts +19 -0
  66. package/dist/mod-service/strike.d.ts.map +1 -0
  67. package/dist/mod-service/strike.js +86 -0
  68. package/dist/mod-service/strike.js.map +1 -0
  69. package/dist/mod-service/types.d.ts +4 -0
  70. package/dist/mod-service/types.d.ts.map +1 -1
  71. package/dist/mod-service/types.js.map +1 -1
  72. package/dist/mod-service/views.d.ts.map +1 -1
  73. package/dist/mod-service/views.js +20 -4
  74. package/dist/mod-service/views.js.map +1 -1
  75. package/dist/setting/constants.d.ts +1 -0
  76. package/dist/setting/constants.d.ts.map +1 -1
  77. package/dist/setting/constants.js +2 -1
  78. package/dist/setting/constants.js.map +1 -1
  79. package/dist/setting/validators.d.ts.map +1 -1
  80. package/dist/setting/validators.js +179 -0
  81. package/dist/setting/validators.js.map +1 -1
  82. package/package.json +3 -3
  83. package/src/api/moderation/queryEvents.ts +2 -0
  84. package/src/context.ts +20 -11
  85. package/src/daemon/context.ts +15 -1
  86. package/src/daemon/index.ts +1 -0
  87. package/src/daemon/strike-expiry-processor.ts +111 -0
  88. package/src/db/migrations/20251008T120000000Z-add-strike-system.ts +87 -0
  89. package/src/db/migrations/index.ts +1 -0
  90. package/src/db/schema/account_strike.ts +13 -0
  91. package/src/db/schema/index.ts +4 -0
  92. package/src/db/schema/job_cursor.ts +13 -0
  93. package/src/db/schema/moderation_event.ts +3 -0
  94. package/src/lexicon/lexicons.ts +103 -0
  95. package/src/lexicon/types/tools/ozone/moderation/defs.ts +44 -0
  96. package/src/lexicon/types/tools/ozone/moderation/queryEvents.ts +2 -0
  97. package/src/lexicon/types/tools/ozone/moderation/queryStatuses.ts +2 -0
  98. package/src/mod-service/index.ts +69 -2
  99. package/src/mod-service/status.ts +9 -0
  100. package/src/mod-service/strike.ts +96 -0
  101. package/src/mod-service/types.ts +6 -0
  102. package/src/mod-service/views.ts +25 -4
  103. package/src/setting/constants.ts +1 -0
  104. package/src/setting/validators.ts +231 -1
  105. package/tests/__snapshots__/account-strikes.test.ts.snap +159 -0
  106. package/tests/account-strikes.test.ts +184 -0
  107. package/tests/query-labels.test.ts +1 -0
  108. package/tests/strike-expiry-processor.test.ts +299 -0
  109. package/tsconfig.build.tsbuildinfo +1 -1
  110. package/tsconfig.tests.tsbuildinfo +1 -1
@@ -36,6 +36,7 @@ import {
36
36
  isModEventMute,
37
37
  isModEventPriorityScore,
38
38
  isModEventReport,
39
+ isModEventReverseTakedown,
39
40
  isModEventTag,
40
41
  isModEventTakedown,
41
42
  isRecordEvent,
@@ -49,6 +50,7 @@ import {
49
50
  getStatusIdentifierFromSubject,
50
51
  moderationSubjectStatusQueryBuilder,
51
52
  } from './status'
53
+ import { StrikeService, StrikeServiceCreator } from './strike'
52
54
  import {
53
55
  ModSubject,
54
56
  RecordSubject,
@@ -89,6 +91,7 @@ export class ModerationService {
89
91
  aud: string,
90
92
  method: string,
91
93
  ) => Promise<AuthHeaders>,
94
+ public strikeService: StrikeService,
92
95
  public imgInvalidator?: ImageInvalidator,
93
96
  ) {}
94
97
 
@@ -101,10 +104,12 @@ export class ModerationService {
101
104
  eventPusher: EventPusher,
102
105
  appviewAgent: AtpAgent,
103
106
  createAuthHeaders: (aud: string, method: string) => Promise<AuthHeaders>,
107
+ strikeServiceCreator: StrikeServiceCreator,
104
108
  imgInvalidator?: ImageInvalidator,
105
109
  ) {
106
- return (db: Database) =>
107
- new ModerationService(
110
+ return (db: Database) => {
111
+ const strikeService = strikeServiceCreator(db)
112
+ return new ModerationService(
108
113
  db,
109
114
  signingKey,
110
115
  signingKeyId,
@@ -114,8 +119,10 @@ export class ModerationService {
114
119
  eventPusher,
115
120
  appviewAgent,
116
121
  createAuthHeaders,
122
+ strikeService,
117
123
  imgInvalidator,
118
124
  )
125
+ }
119
126
  }
120
127
 
121
128
  views = new ModerationViews(
@@ -190,6 +197,7 @@ export class ModerationService {
190
197
  modTool?: string[]
191
198
  ageAssuranceState?: string
192
199
  batchId?: string
200
+ withStrike?: boolean
193
201
  }): Promise<{ cursor?: string; events: ModerationEventRow[] }> {
194
202
  const {
195
203
  subject,
@@ -214,6 +222,7 @@ export class ModerationService {
214
222
  modTool,
215
223
  ageAssuranceState,
216
224
  batchId,
225
+ withStrike,
217
226
  } = opts
218
227
  const { ref } = this.db.db.dynamic
219
228
  let builder = this.db.db.selectFrom('moderation_event').selectAll()
@@ -333,6 +342,10 @@ export class ModerationService {
333
342
  .where(sql`meta->>'status'`, '=', ageAssuranceState)
334
343
  }
335
344
 
345
+ if (withStrike !== undefined) {
346
+ builder = builder.where('strikeCount', 'is not', null)
347
+ }
348
+
336
349
  const keyset = new TimeIdKeyset(
337
350
  ref(`moderation_event.createdAt`),
338
351
  ref('moderation_event.id'),
@@ -484,6 +497,9 @@ export class ModerationService {
484
497
  if (event.content) {
485
498
  meta.content = event.content
486
499
  }
500
+ if (event.policies?.length) {
501
+ meta.policies = event.policies.join(',')
502
+ }
487
503
  }
488
504
 
489
505
  if (isAccountEvent(event)) {
@@ -566,6 +582,32 @@ export class ModerationService {
566
582
 
567
583
  const subjectInfo = subject.info()
568
584
 
585
+ // Store severityLevel, strikeCount, and strikeExpiresAt if provided
586
+ // These values should be calculated by the client based on configuration
587
+ // processNewEvent will update the account_strike table with the new strike count
588
+ let severityLevel: string | null = null
589
+ let strikeCount: number | null = null
590
+ let strikeExpiresAt: string | null = null
591
+
592
+ if (
593
+ isModEventTakedown(event) ||
594
+ isModEventEmail(event) ||
595
+ isModEventReverseTakedown(event)
596
+ ) {
597
+ // Store severityLevel if provided (for display/tracking)
598
+ if (event.severityLevel) {
599
+ severityLevel = event.severityLevel
600
+ }
601
+ // Store explicit strikeCount if provided
602
+ if (event.strikeCount !== undefined) {
603
+ strikeCount = event.strikeCount
604
+ }
605
+ // Store strikeExpiresAt if provided by client
606
+ if ('strikeExpiresAt' in event && event.strikeExpiresAt) {
607
+ strikeExpiresAt = event.strikeExpiresAt
608
+ }
609
+ }
610
+
569
611
  const modEvent = await this.db.db
570
612
  .insertInto('moderation_event')
571
613
  .values({
@@ -599,6 +641,9 @@ export class ModerationService {
599
641
  subjectMessageId: subjectInfo.subjectMessageId,
600
642
  modTool: modTool ? jsonb(modTool) : null,
601
643
  externalId: externalId ?? null,
644
+ severityLevel,
645
+ strikeCount,
646
+ strikeExpiresAt,
602
647
  })
603
648
  .returningAll()
604
649
  .executeTakeFirstOrThrow()
@@ -609,6 +654,19 @@ export class ModerationService {
609
654
  subject.blobCids,
610
655
  )
611
656
 
657
+ // Updates are only needed if strikeCount is numeric (in some cases even 0)
658
+ if (modEvent.strikeCount !== null) {
659
+ try {
660
+ await this.strikeService.updateSubjectStrikeCount(modEvent.subjectDid)
661
+ } catch (error) {
662
+ // Log error but don't fail the entire operation to ensure that events are logged even if updating strike count fails
663
+ log.error(
664
+ { err: error, modEventId: modEvent.id },
665
+ 'Error processing strikes for moderation event',
666
+ )
667
+ }
668
+ }
669
+
612
670
  return { event: modEvent, subjectStatus }
613
671
  }
614
672
 
@@ -959,6 +1017,7 @@ export class ModerationService {
959
1017
  minReportedRecordsCount,
960
1018
  minTakendownRecordsCount,
961
1019
  minPriorityScore,
1020
+ minStrikeCount,
962
1021
  ageAssuranceState,
963
1022
  }: QueryStatusParams): Promise<{
964
1023
  statuses: ModerationSubjectStatusRowWithHandle[]
@@ -1224,6 +1283,14 @@ export class ModerationService {
1224
1283
  )
1225
1284
  }
1226
1285
 
1286
+ if (minStrikeCount != null && minStrikeCount >= 0) {
1287
+ builder = builder.where(
1288
+ 'account_strike.activeStrikeCount',
1289
+ '>=',
1290
+ minStrikeCount,
1291
+ )
1292
+ }
1293
+
1227
1294
  if (ageAssuranceState) {
1228
1295
  builder = builder.where(
1229
1296
  'moderation_subject_status.ageAssuranceState',
@@ -258,6 +258,15 @@ export const moderationSubjectStatusQueryBuilder = (db: DatabaseSchema) => {
258
258
  'account_record_status_stats.processedCount',
259
259
  'account_record_status_stats.takendownCount',
260
260
  ])
261
+ .leftJoin('account_strike', (join) =>
262
+ join.onRef('moderation_subject_status.did', '=', 'account_strike.did'),
263
+ )
264
+ .select([
265
+ 'account_strike.activeStrikeCount as strikeCount',
266
+ 'account_strike.totalStrikeCount',
267
+ 'account_strike.firstStrikeAt',
268
+ 'account_strike.lastStrikeAt',
269
+ ])
261
270
  }
262
271
 
263
272
  // Based on a given moderation action event, this function will update the moderation status of the subject
@@ -0,0 +1,96 @@
1
+ import { Database } from '../db'
2
+
3
+ export type StrikeServiceCreator = (db: Database) => StrikeService
4
+
5
+ export class StrikeService {
6
+ constructor(private db: Database) {}
7
+
8
+ static creator() {
9
+ return (db: Database) => {
10
+ return new StrikeService(db)
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Update the strike count in account_strike table
16
+ */
17
+ async updateSubjectStrikeCount(subjectDid: string): Promise<void> {
18
+ const now = new Date().toISOString()
19
+
20
+ // This should not incur too many rows since we tend to do permanent takedown on relatively low strike count
21
+ // and we have a very specific index to support this query
22
+ const events = await this.db.db
23
+ .selectFrom('moderation_event')
24
+ .where('subjectDid', '=', subjectDid)
25
+ .where('strikeCount', '<>', 0)
26
+ .select(['strikeCount', 'strikeExpiresAt', 'createdAt'])
27
+ .orderBy('createdAt', 'asc')
28
+ .execute()
29
+
30
+ if (!events.length) {
31
+ return
32
+ }
33
+
34
+ let activeStrikeCount = 0
35
+ let totalStrikeCount = 0
36
+
37
+ const firstStrikeAt = events[0].createdAt
38
+ const lastStrikeAt = events[events.length - 1].createdAt
39
+
40
+ for (const event of events) {
41
+ const strikeCount = event.strikeCount || 0
42
+ totalStrikeCount += strikeCount
43
+
44
+ // Count as active if not expired
45
+ const isActive =
46
+ event.strikeExpiresAt === null || event.strikeExpiresAt > now
47
+ if (isActive) {
48
+ activeStrikeCount += strikeCount
49
+ }
50
+ }
51
+
52
+ await this.db.db
53
+ .insertInto('account_strike')
54
+ .values({
55
+ did: subjectDid,
56
+ activeStrikeCount,
57
+ totalStrikeCount,
58
+ firstStrikeAt,
59
+ lastStrikeAt,
60
+ })
61
+ .onConflict((oc) =>
62
+ oc.column('did').doUpdateSet({
63
+ activeStrikeCount,
64
+ totalStrikeCount,
65
+ firstStrikeAt,
66
+ lastStrikeAt,
67
+ }),
68
+ )
69
+ .execute()
70
+ }
71
+
72
+ /**
73
+ * Get distinct subjects with expired strikes since a given timestamp
74
+ * Used by the strike expiry processor to find accounts that need strike count updates
75
+ */
76
+ async getExpiredStrikeSubjects(
77
+ afterTimestamp?: string,
78
+ ): Promise<Array<{ subjectDid: string }>> {
79
+ const now = new Date().toISOString()
80
+
81
+ let query = this.db.db
82
+ .selectFrom('moderation_event')
83
+ .where('strikeExpiresAt', 'is not', null)
84
+ .where('strikeExpiresAt', '<=', now)
85
+ .where('strikeCount', '<>', 0)
86
+ .select('subjectDid')
87
+ .distinct()
88
+
89
+ // Only process strikes that expired since the last run
90
+ if (afterTimestamp) {
91
+ query = query.where('strikeExpiresAt', '>=', afterTimestamp)
92
+ }
93
+
94
+ return await query.execute()
95
+ }
96
+ }
@@ -37,6 +37,12 @@ export type ModerationSubjectStatusRowWithStats = ModerationSubjectStatusRow & {
37
37
  pendingCount: number | null
38
38
  processedCount: number | null
39
39
  takendownCount: number | null
40
+
41
+ // account_strike
42
+ strikeCount: number | null
43
+ totalStrikeCount: number | null
44
+ firstStrikeAt: string | null
45
+ lastStrikeAt: string | null
40
46
  }
41
47
 
42
48
  export type ModerationSubjectStatusRowWithHandle =
@@ -41,6 +41,7 @@ import {
41
41
  isModEventMuteReporter,
42
42
  isModEventPriorityScore,
43
43
  isModEventReport,
44
+ isModEventReverseTakedown,
44
45
  isModEventTag,
45
46
  isModEventTakedown,
46
47
  isRecordEvent,
@@ -178,11 +179,20 @@ export class ModerationViews {
178
179
  }
179
180
 
180
181
  if (
181
- isModEventTakedown(event) &&
182
- typeof meta.policies === 'string' &&
183
- meta.policies.length > 0
182
+ isModEventTakedown(event) ||
183
+ isModEventEmail(event) ||
184
+ isModEventReverseTakedown(event)
184
185
  ) {
185
- event.policies = meta.policies.split(',')
186
+ if (typeof meta.policies === 'string' && meta.policies.length > 0) {
187
+ event.policies = meta.policies.split(',')
188
+ }
189
+
190
+ event.strikeCount = ifNumber(row.strikeCount)
191
+ event.severityLevel = ifString(row.severityLevel)
192
+
193
+ if (isModEventTakedown(event) || isModEventEmail(event)) {
194
+ event.strikeExpiresAt = ifString(row.strikeExpiresAt)
195
+ }
186
196
  }
187
197
 
188
198
  if (isModEventLabel(event)) {
@@ -745,6 +755,17 @@ export class ModerationViews {
745
755
  processedCount: status.processedCount ?? undefined,
746
756
  takendownCount: status.takendownCount ?? undefined,
747
757
  },
758
+
759
+ accountStrike:
760
+ status.strikeCount !== null || status.totalStrikeCount !== null
761
+ ? {
762
+ $type: 'tools.ozone.moderation.defs#accountStrike',
763
+ activeStrikeCount: status.strikeCount ?? undefined,
764
+ totalStrikeCount: status.totalStrikeCount ?? undefined,
765
+ firstStrikeAt: status.firstStrikeAt ?? undefined,
766
+ lastStrikeAt: status.lastStrikeAt ?? undefined,
767
+ }
768
+ : undefined,
748
769
  }
749
770
 
750
771
  if (status.recordPath !== '') {
@@ -1,2 +1,3 @@
1
1
  export const ProtectedTagSettingKey = 'tools.ozone.setting.protectedTags'
2
2
  export const PolicyListSettingKey = 'tools.ozone.setting.policyList'
3
+ export const SeverityLevelSettingKey = 'tools.ozone.setting.severityLevels'
@@ -1,7 +1,11 @@
1
1
  import { Selectable } from 'kysely'
2
2
  import { InvalidRequestError } from '@atproto/xrpc-server'
3
3
  import { Setting } from '../db/schema/setting'
4
- import { PolicyListSettingKey, ProtectedTagSettingKey } from './constants'
4
+ import {
5
+ PolicyListSettingKey,
6
+ ProtectedTagSettingKey,
7
+ SeverityLevelSettingKey,
8
+ } from './constants'
5
9
 
6
10
  export const settingValidators = new Map<
7
11
  string,
@@ -9,6 +13,21 @@ export const settingValidators = new Map<
9
13
  >([
10
14
  [
11
15
  ProtectedTagSettingKey,
16
+ /*
17
+ * Example configuration:
18
+ * {
19
+ * "sensitive-tag": {
20
+ * "roles": ["tools.ozone.team.defs#roleAdmin", "tools.ozone.team.defs#roleModerator"],
21
+ * "moderators": ["did:plc:example1", "did:plc:example2"]
22
+ * },
23
+ * "high-risk-tag": {
24
+ * "roles": ["tools.ozone.team.defs#roleAdmin"]
25
+ * },
26
+ * "admin-only-tag": {
27
+ * "moderators": ["did:plc:admin1"]
28
+ * }
29
+ * }
30
+ */
12
31
  async (setting: Partial<Selectable<Setting>>) => {
13
32
  if (setting.managerRole !== 'tools.ozone.team.defs#roleAdmin') {
14
33
  throw new InvalidRequestError(
@@ -60,6 +79,61 @@ export const settingValidators = new Map<
60
79
  ],
61
80
  [
62
81
  PolicyListSettingKey,
82
+ /*
83
+ * Example configuration:
84
+ * {
85
+ * "harassment": {
86
+ * "name": "Anti-Harassment",
87
+ * "description": "Content that harasses, intimidates, or bullies users",
88
+ * "severityLevels": {
89
+ * "sev-1": {
90
+ * "description": "Minor harassment",
91
+ * "isDefault": true
92
+ * },
93
+ * "sev-2": {
94
+ * "description": "Moderate harassment",
95
+ * "isDefault": false
96
+ * },
97
+ * "sev-4": {
98
+ * "description": "Severe harassment",
99
+ * "isDefault": false
100
+ * }
101
+ * }
102
+ * },
103
+ * "death-threats": {
104
+ * "name": "Death Threats",
105
+ * "description": "Threats of violence or death against individuals",
106
+ * "severityLevels": {
107
+ * "death-threat": {
108
+ * "description": "Death threat violation",
109
+ * "isDefault": true
110
+ * }
111
+ * }
112
+ * },
113
+ * "spam": {
114
+ * "name": "Spam",
115
+ * "description": "Unsolicited or repetitive content",
116
+ * "severityLevels": {
117
+ * "sev-0": {
118
+ * "description": "Minor spam",
119
+ * "isDefault": false
120
+ * },
121
+ * "sev-1": {
122
+ * "description": "Moderate spam",
123
+ * "isDefault": true
124
+ * },
125
+ * "sev-2": {
126
+ * "description": "Severe spam",
127
+ * "isDefault": false
128
+ * }
129
+ * }
130
+ * },
131
+ * "minimal-policy": {
132
+ * "name": "Basic Policy",
133
+ * "description": "Simple policy without severity levels"
134
+ * }
135
+ * }
136
+ */
63
137
  async (setting: Partial<Selectable<Setting>>) => {
64
138
  if (setting.managerRole !== 'tools.ozone.team.defs#roleAdmin') {
65
139
  throw new InvalidRequestError(
@@ -82,6 +156,162 @@ export const settingValidators = new Map<
82
156
  `Must define a name and description for policy ${key}`,
83
157
  )
84
158
  }
159
+
160
+ if (val['severityLevels'] !== undefined) {
161
+ if (typeof val['severityLevels'] !== 'object') {
162
+ throw new InvalidRequestError(
163
+ `Severity levels must be an object for policy ${key}`,
164
+ )
165
+ }
166
+
167
+ let hasDefault = false
168
+ for (const [severityKey, severityVal] of Object.entries(
169
+ val['severityLevels'],
170
+ )) {
171
+ if (!severityVal || typeof severityVal !== 'object') {
172
+ throw new InvalidRequestError(
173
+ `Invalid configuration for severity level ${severityKey} in policy ${key}`,
174
+ )
175
+ }
176
+
177
+ if (
178
+ severityVal['description'] !== undefined &&
179
+ typeof severityVal['description'] !== 'string'
180
+ ) {
181
+ throw new InvalidRequestError(
182
+ `Description must be a string for severity level ${severityKey} in policy ${key}`,
183
+ )
184
+ }
185
+
186
+ if (severityVal['isDefault'] !== undefined) {
187
+ if (typeof severityVal['isDefault'] !== 'boolean') {
188
+ throw new InvalidRequestError(
189
+ `isDefault must be a boolean for severity level ${severityKey} in policy ${key}`,
190
+ )
191
+ }
192
+ if (severityVal['isDefault']) {
193
+ if (hasDefault) {
194
+ throw new InvalidRequestError(
195
+ `Only one severity level can be the default for policy ${key}`,
196
+ )
197
+ }
198
+ hasDefault = true
199
+ }
200
+ }
201
+ }
202
+ }
203
+ }
204
+ },
205
+ ],
206
+ [
207
+ SeverityLevelSettingKey,
208
+ /*
209
+ * Example configuration:
210
+ * {
211
+ * "sev-0": {
212
+ * "strikeCount": 0
213
+ * },
214
+ * "sev-1": {
215
+ * "strikeCount": 1,
216
+ * "strikeOnOccurrence": 2
217
+ * },
218
+ * "sev-2": {
219
+ * "strikeCount": 2
220
+ * },
221
+ * "sev-4": {
222
+ * "strikeCount": 4,
223
+ * "expiresInDays": 365
224
+ * },
225
+ * "sev-5": {
226
+ * "needsTakedown": true
227
+ * },
228
+ * "death-threat": {
229
+ * "strikeCount": 4,
230
+ * "firstOccurrenceStrikeCount": 4,
231
+ * },
232
+ * "custom-severity": {
233
+ * "strikeCount": 3,
234
+ * "strikeOnOccurrence": 1,
235
+ * },
236
+ * "escalating-severity": {
237
+ * "firstOccurrenceStrikeCount": 2,
238
+ * "repeatOccurrenceStrikeCount": 5
239
+ * }
240
+ * }
241
+ */
242
+ async (setting: Partial<Selectable<Setting>>) => {
243
+ if (setting.managerRole !== 'tools.ozone.team.defs#roleAdmin') {
244
+ throw new InvalidRequestError(
245
+ 'Only admins should be able to manage severity levels',
246
+ )
247
+ }
248
+
249
+ if (typeof setting.value !== 'object') {
250
+ throw new InvalidRequestError('Invalid value')
251
+ }
252
+
253
+ for (const [key, val] of Object.entries(setting.value)) {
254
+ if (!val || typeof val !== 'object') {
255
+ throw new InvalidRequestError(
256
+ `Invalid configuration for severity level ${key}`,
257
+ )
258
+ }
259
+
260
+ if (val['strikeCount'] !== undefined) {
261
+ if (
262
+ typeof val['strikeCount'] !== 'number' ||
263
+ !Number.isInteger(val['strikeCount']) ||
264
+ val['strikeCount'] < 0
265
+ ) {
266
+ throw new InvalidRequestError(
267
+ `Strike count must be a non-negative integer for severity level ${key}`,
268
+ )
269
+ }
270
+ }
271
+
272
+ if (val['strikeOnOccurrence'] !== undefined) {
273
+ if (
274
+ typeof val['strikeOnOccurrence'] !== 'number' ||
275
+ !Number.isInteger(val['strikeOnOccurrence']) ||
276
+ val['strikeOnOccurrence'] < 1
277
+ ) {
278
+ throw new InvalidRequestError(
279
+ `Strike on occurrence must be a positive integer for severity level ${key}`,
280
+ )
281
+ }
282
+ }
283
+
284
+ if (val['needsTakedown'] !== undefined) {
285
+ if (typeof val['needsTakedown'] !== 'boolean') {
286
+ throw new InvalidRequestError(
287
+ `Needs takedown must be a boolean for severity level ${key}`,
288
+ )
289
+ }
290
+ }
291
+
292
+ if (val['expiresInDays'] !== undefined) {
293
+ if (
294
+ typeof val['expiresInDays'] !== 'number' ||
295
+ !Number.isInteger(val['expiresInDays']) ||
296
+ val['expiresInDays'] < 0
297
+ ) {
298
+ throw new InvalidRequestError(
299
+ `Expires in days must be a non-negative integer for severity level ${key}`,
300
+ )
301
+ }
302
+ }
303
+
304
+ if (val['firstOccurrenceStrikeCount'] !== undefined) {
305
+ if (
306
+ typeof val['firstOccurrenceStrikeCount'] !== 'number' ||
307
+ !Number.isInteger(val['firstOccurrenceStrikeCount']) ||
308
+ val['firstOccurrenceStrikeCount'] < 0
309
+ ) {
310
+ throw new InvalidRequestError(
311
+ `First occurrence strike count must be a non-negative integer for severity level ${key}`,
312
+ )
313
+ }
314
+ }
85
315
  }
86
316
  },
87
317
  ],