@atproto/ozone 0.2.9 → 0.2.11
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.
- package/CHANGELOG.md +26 -0
- package/package.json +25 -21
- package/bin/migration-create.ts +0 -38
- package/jest.config.cjs +0 -22
- package/src/api/chat/getActorMetadata.ts +0 -23
- package/src/api/chat/getConvo.ts +0 -23
- package/src/api/chat/getConvoMembers.ts +0 -23
- package/src/api/chat/getConvos.ts +0 -23
- package/src/api/chat/getMessageContext.ts +0 -42
- package/src/api/chat/index.ts +0 -16
- package/src/api/communication/createTemplate.ts +0 -51
- package/src/api/communication/deleteTemplate.ts +0 -23
- package/src/api/communication/listTemplates.ts +0 -31
- package/src/api/communication/updateTemplate.ts +0 -51
- package/src/api/health.ts +0 -27
- package/src/api/index.ts +0 -146
- package/src/api/label/fetchLabels.ts +0 -32
- package/src/api/label/queryLabels.ts +0 -57
- package/src/api/label/subscribeLabels.ts +0 -25
- package/src/api/moderation/cancelScheduledActions.ts +0 -72
- package/src/api/moderation/emitEvent.ts +0 -475
- package/src/api/moderation/getAccountTimeline.ts +0 -160
- package/src/api/moderation/getEvent.ts +0 -19
- package/src/api/moderation/getRecord.ts +0 -40
- package/src/api/moderation/getRecords.ts +0 -50
- package/src/api/moderation/getRepo.ts +0 -34
- package/src/api/moderation/getReporterStats.ts +0 -18
- package/src/api/moderation/getRepos.ts +0 -41
- package/src/api/moderation/getSubjects.ts +0 -101
- package/src/api/moderation/listScheduledActions.ts +0 -45
- package/src/api/moderation/queryEvents.ts +0 -72
- package/src/api/moderation/queryStatuses.ts +0 -23
- package/src/api/moderation/scheduleAction.ts +0 -129
- package/src/api/moderation/searchRepos.ts +0 -46
- package/src/api/moderation/util.ts +0 -96
- package/src/api/proxied.ts +0 -327
- package/src/api/queue/assignModerator.ts +0 -31
- package/src/api/queue/createQueue.ts +0 -62
- package/src/api/queue/deleteQueue.ts +0 -56
- package/src/api/queue/getAssignments.ts +0 -19
- package/src/api/queue/listQueues.ts +0 -39
- package/src/api/queue/routeReports.ts +0 -44
- package/src/api/queue/unassignModerator.ts +0 -26
- package/src/api/queue/updateQueue.ts +0 -54
- package/src/api/report/assignModerator.ts +0 -36
- package/src/api/report/createActivity.ts +0 -57
- package/src/api/report/createReport.ts +0 -93
- package/src/api/report/getAssignments.ts +0 -20
- package/src/api/report/getHistoricalStats.ts +0 -41
- package/src/api/report/getLatestReport.ts +0 -44
- package/src/api/report/getLiveStats.ts +0 -26
- package/src/api/report/getReport.ts +0 -55
- package/src/api/report/listActivities.ts +0 -37
- package/src/api/report/queryActivities.ts +0 -64
- package/src/api/report/queryReports.ts +0 -44
- package/src/api/report/reassignQueue.ts +0 -68
- package/src/api/report/refreshStats.ts +0 -27
- package/src/api/report/unassignModerator.ts +0 -21
- package/src/api/safelink/addRule.ts +0 -48
- package/src/api/safelink/queryEvents.ts +0 -32
- package/src/api/safelink/queryRules.ts +0 -58
- package/src/api/safelink/removeRule.ts +0 -42
- package/src/api/safelink/updateRule.ts +0 -48
- package/src/api/server/getConfig.ts +0 -35
- package/src/api/set/addValues.ts +0 -28
- package/src/api/set/deleteSet.ts +0 -34
- package/src/api/set/deleteValues.ts +0 -31
- package/src/api/set/getValues.ts +0 -42
- package/src/api/set/querySets.ts +0 -36
- package/src/api/set/upsertSet.ts +0 -38
- package/src/api/setting/listOptions.ts +0 -44
- package/src/api/setting/removeOptions.ts +0 -64
- package/src/api/setting/upsertOption.ts +0 -156
- package/src/api/team/addMember.ts +0 -51
- package/src/api/team/deleteMember.ts +0 -29
- package/src/api/team/listMembers.ts +0 -20
- package/src/api/team/updateMember.ts +0 -47
- package/src/api/util.ts +0 -265
- package/src/api/verification/grantVerifications.ts +0 -90
- package/src/api/verification/listVerifications.ts +0 -44
- package/src/api/verification/revokeVerifications.ts +0 -43
- package/src/api/well-known.ts +0 -46
- package/src/assignment/index.ts +0 -728
- package/src/auth-verifier.ts +0 -227
- package/src/background.ts +0 -183
- package/src/communication-service/template.ts +0 -110
- package/src/communication-service/util.ts +0 -8
- package/src/config/config.ts +0 -211
- package/src/config/env.ts +0 -95
- package/src/config/index.ts +0 -3
- package/src/config/secrets.ts +0 -17
- package/src/context.ts +0 -399
- package/src/daemon/blob-diverter.ts +0 -186
- package/src/daemon/context.ts +0 -247
- package/src/daemon/event-pusher.ts +0 -363
- package/src/daemon/event-reverser.ts +0 -128
- package/src/daemon/index.ts +0 -33
- package/src/daemon/job-cursor.ts +0 -33
- package/src/daemon/materialized-view-refresher.ts +0 -33
- package/src/daemon/queue-router.ts +0 -101
- package/src/daemon/scheduled-action-processor.ts +0 -304
- package/src/daemon/stats-computer.ts +0 -101
- package/src/daemon/strike-expiry-processor.ts +0 -95
- package/src/daemon/team-profile-synchronizer.ts +0 -15
- package/src/daemon/verification-listener.ts +0 -169
- package/src/db/index.ts +0 -203
- package/src/db/migrations/20231219T205730722Z-init.ts +0 -170
- package/src/db/migrations/20240116T085607200Z-communication-template.ts +0 -23
- package/src/db/migrations/20240201T051104136Z-mod-event-blobs.ts +0 -15
- package/src/db/migrations/20240208T213404429Z-add-tags-column-to-moderation-subject.ts +0 -31
- package/src/db/migrations/20240228T003647759Z-add-label-sigs.ts +0 -25
- package/src/db/migrations/20240408T192432676Z-mute-reporting.ts +0 -15
- package/src/db/migrations/20240506T225055595Z-message-subject.ts +0 -21
- package/src/db/migrations/20240521T211332580Z-member.ts +0 -17
- package/src/db/migrations/20240814T003647759Z-event-created-at-index.ts +0 -13
- package/src/db/migrations/20240903T205730722Z-add-template-lang.ts +0 -12
- package/src/db/migrations/20240904T205730722Z-add-subject-did-index.ts +0 -13
- package/src/db/migrations/20241001T205730722Z-subject-status-review-state-index.ts +0 -15
- package/src/db/migrations/20241008T205730722Z-sets.ts +0 -53
- package/src/db/migrations/20241018T205730722Z-setting.ts +0 -27
- package/src/db/migrations/20241026T205730722Z-add-hosting-status-to-subject-status.ts +0 -57
- package/src/db/migrations/20241220T144630860Z-stats-materialized-views.ts +0 -215
- package/src/db/migrations/20250204T003647759Z-add-subject-priority-score.ts +0 -22
- package/src/db/migrations/20250211T003647759Z-add-reporter-stats-index.ts +0 -38
- package/src/db/migrations/20250211T132135150Z-moderation-event-message-partial-idx.ts +0 -26
- package/src/db/migrations/20250221T132135150Z-member-details.ts +0 -14
- package/src/db/migrations/20250404T201720309Z-subject-status-sort-idxs.ts +0 -18
- package/src/db/migrations/20250415T201720309Z-verification.ts +0 -34
- package/src/db/migrations/20250417T201720309Z-firehose-cursor.ts +0 -16
- package/src/db/migrations/20250609T110704000Z-safelink.ts +0 -53
- package/src/db/migrations/20250618T180246000Z-add-mod-tool-to-moderation-event.ts +0 -18
- package/src/db/migrations/20250701T000000000Z-add-age-assurance-state.ts +0 -25
- package/src/db/migrations/20250715T000000000Z-add-mod-event-external-id.ts +0 -15
- package/src/db/migrations/20250718T150931000Z-update-appeal-reason-stats.ts +0 -310
- package/src/db/migrations/20250813T000000000Z-mod-tool-batch-id-index.ts +0 -14
- package/src/db/migrations/20250923T000000000Z-scheduled-actions.ts +0 -56
- package/src/db/migrations/20251008T120000000Z-add-strike-system.ts +0 -87
- package/src/db/migrations/20260210T154806448Z-mod-event-created-by-indexes.ts +0 -22
- package/src/db/migrations/20260219T164523000Z-create-report-table.ts +0 -155
- package/src/db/migrations/20260219T165302248Z-moderator-assignment.ts +0 -42
- package/src/db/migrations/20260225T000000000Z-add-report-queue-table.ts +0 -41
- package/src/db/migrations/20260313T000000000Z-add-report-activity-table.ts +0 -48
- package/src/db/migrations/20260318T152058935Z-add-report-stat.ts +0 -35
- package/src/db/migrations/20260428T000000000Z-add-expiring-tag-table.ts +0 -32
- package/src/db/migrations/20260513T202941104Z-add-subject-convo-id.ts +0 -114
- package/src/db/migrations/20260602T120000000Z-add-report-activity-created-index.ts +0 -17
- package/src/db/migrations/index.ts +0 -44
- package/src/db/migrations/provider.ts +0 -26
- package/src/db/pagination.ts +0 -335
- package/src/db/schema/account_events_stats.ts +0 -16
- package/src/db/schema/account_record_events_stats.ts +0 -15
- package/src/db/schema/account_record_status_stats.ts +0 -15
- package/src/db/schema/account_strike.ts +0 -13
- package/src/db/schema/blob_push_event.ts +0 -21
- package/src/db/schema/communication_template.ts +0 -19
- package/src/db/schema/expiring_tag.ts +0 -18
- package/src/db/schema/firehose_cursor.ts +0 -13
- package/src/db/schema/index.ts +0 -60
- package/src/db/schema/job_cursor.ts +0 -13
- package/src/db/schema/label.ts +0 -22
- package/src/db/schema/member.ts +0 -22
- package/src/db/schema/moderation_event.ts +0 -61
- package/src/db/schema/moderation_subject_status.ts +0 -52
- package/src/db/schema/moderator_assignment.ts +0 -16
- package/src/db/schema/ozone_set.ts +0 -24
- package/src/db/schema/record_events_stats.ts +0 -15
- package/src/db/schema/record_push_event.ts +0 -21
- package/src/db/schema/repo_push_event.ts +0 -19
- package/src/db/schema/report.ts +0 -28
- package/src/db/schema/report_activity.ts +0 -22
- package/src/db/schema/report_queue.ts +0 -21
- package/src/db/schema/report_stat.ts +0 -27
- package/src/db/schema/safelink.ts +0 -39
- package/src/db/schema/scheduled-action.ts +0 -25
- package/src/db/schema/setting.ts +0 -24
- package/src/db/schema/signing_key.ts +0 -10
- package/src/db/schema/verification.ts +0 -21
- package/src/db/types.ts +0 -24
- package/src/error.ts +0 -12
- package/src/image-invalidator.ts +0 -7
- package/src/index.ts +0 -154
- package/src/jetstream/service.ts +0 -107
- package/src/logger.ts +0 -29
- package/src/mod-service/expiring-tags.ts +0 -104
- package/src/mod-service/index.ts +0 -1842
- package/src/mod-service/profile.ts +0 -139
- package/src/mod-service/report.ts +0 -429
- package/src/mod-service/status.ts +0 -549
- package/src/mod-service/strike.ts +0 -96
- package/src/mod-service/subject.ts +0 -311
- package/src/mod-service/types.ts +0 -96
- package/src/mod-service/util.ts +0 -99
- package/src/mod-service/views.ts +0 -912
- package/src/queue/service.ts +0 -603
- package/src/report/activity.ts +0 -281
- package/src/report/handle-report-update.ts +0 -209
- package/src/report/reassign.ts +0 -109
- package/src/report/stats.ts +0 -852
- package/src/report/views.ts +0 -239
- package/src/safelink/service.ts +0 -304
- package/src/scheduled-action/service.ts +0 -281
- package/src/scheduled-action/types.ts +0 -17
- package/src/sequencer/index.ts +0 -2
- package/src/sequencer/outbox.ts +0 -123
- package/src/sequencer/sequencer.ts +0 -147
- package/src/set/service.ts +0 -230
- package/src/setting/constants.ts +0 -3
- package/src/setting/service.ts +0 -148
- package/src/setting/types.ts +0 -3
- package/src/setting/validators.ts +0 -333
- package/src/tag-service/content-tagger.ts +0 -30
- package/src/tag-service/embed-tagger.ts +0 -70
- package/src/tag-service/index.ts +0 -70
- package/src/tag-service/language-data.ts +0 -561
- package/src/tag-service/language-tagger.ts +0 -101
- package/src/tag-service/util.ts +0 -13
- package/src/team/index.ts +0 -296
- package/src/util.ts +0 -230
- package/src/verification/issuer.ts +0 -146
- package/src/verification/service.ts +0 -208
- package/src/verification/util.ts +0 -53
- package/test.env +0 -2
- package/tests/3p-labeler.test.ts +0 -288
- package/tests/__snapshots__/account-strikes.test.ts.snap +0 -159
- package/tests/__snapshots__/age-assurance.test.ts.snap +0 -66
- package/tests/__snapshots__/blob-divert.test.ts.snap +0 -219
- package/tests/__snapshots__/get-account-timeline.test.ts.snap +0 -36
- package/tests/__snapshots__/get-record.test.ts.snap +0 -271
- package/tests/__snapshots__/get-records.test.ts.snap +0 -175
- package/tests/__snapshots__/get-repo.test.ts.snap +0 -91
- package/tests/__snapshots__/get-repos.test.ts.snap +0 -127
- package/tests/__snapshots__/get-starter-pack.test.ts.snap +0 -535
- package/tests/__snapshots__/get-subjects.test.ts.snap +0 -529
- package/tests/__snapshots__/moderation-events.test.ts.snap +0 -347
- package/tests/__snapshots__/moderation-statuses.test.ts.snap +0 -276
- package/tests/__snapshots__/moderation.test.ts.snap +0 -85
- package/tests/__snapshots__/report-reason.test.ts.snap +0 -14
- package/tests/__snapshots__/safelink.test.ts.snap +0 -179
- package/tests/__snapshots__/scheduled-action.test.ts.snap +0 -61
- package/tests/__snapshots__/sets.test.ts.snap +0 -46
- package/tests/__snapshots__/settings.test.ts.snap +0 -52
- package/tests/__snapshots__/team.test.ts.snap +0 -374
- package/tests/__snapshots__/verification-listener.test.ts.snap +0 -152
- package/tests/__snapshots__/verification.test.ts.snap +0 -302
- package/tests/_util.ts +0 -242
- package/tests/account-strikes.test.ts +0 -184
- package/tests/ack-all-subjects-of-account.test.ts +0 -177
- package/tests/age-assurance.test.ts +0 -372
- package/tests/blob-divert.test.ts +0 -106
- package/tests/communication-templates.test.ts +0 -149
- package/tests/content-tagger.test.ts +0 -170
- package/tests/db.test.ts +0 -184
- package/tests/expiring-label.test.ts +0 -72
- package/tests/expiring-tags.test.ts +0 -232
- package/tests/get-account-timeline.test.ts +0 -85
- package/tests/get-config.test.ts +0 -55
- package/tests/get-lists.test.ts +0 -111
- package/tests/get-profiles.test.ts +0 -70
- package/tests/get-record.test.ts +0 -130
- package/tests/get-records.test.ts +0 -91
- package/tests/get-repo.test.ts +0 -171
- package/tests/get-report.test.ts +0 -136
- package/tests/get-reporter-stats.test.ts +0 -132
- package/tests/get-repos.test.ts +0 -91
- package/tests/get-starter-pack.test.ts +0 -115
- package/tests/get-subjects.test.ts +0 -81
- package/tests/mod-tool.test.ts +0 -268
- package/tests/moderation-appeals.test.ts +0 -260
- package/tests/moderation-events.test.ts +0 -756
- package/tests/moderation-status-tags.test.ts +0 -140
- package/tests/moderation-statuses.test.ts +0 -495
- package/tests/moderation.test.ts +0 -992
- package/tests/protected-tags.test.ts +0 -218
- package/tests/query-labels.test.ts +0 -238
- package/tests/query-reports.test.ts +0 -608
- package/tests/queue-assignment.test.ts +0 -428
- package/tests/queue-router.test.ts +0 -306
- package/tests/queues.test.ts +0 -690
- package/tests/record-and-account-events.test.ts +0 -197
- package/tests/repo-search.test.ts +0 -136
- package/tests/report-action.test.ts +0 -308
- package/tests/report-activity.test.ts +0 -711
- package/tests/report-assignment.test.ts +0 -517
- package/tests/report-muting.test.ts +0 -100
- package/tests/report-reason.test.ts +0 -154
- package/tests/report-reassign-queue.test.ts +0 -340
- package/tests/report-routing.test.ts +0 -245
- package/tests/report-stats.test.ts +0 -545
- package/tests/revoke-account-credentials.test.ts +0 -54
- package/tests/safelink.test.ts +0 -534
- package/tests/scheduled-action-processor.test.ts +0 -488
- package/tests/scheduled-action.test.ts +0 -334
- package/tests/sequencer.test.ts +0 -227
- package/tests/server.test.ts +0 -62
- package/tests/sets.test.ts +0 -246
- package/tests/settings.test.ts +0 -308
- package/tests/strike-expiry-processor.test.ts +0 -299
- package/tests/subject-priority-score.test.ts +0 -96
- package/tests/takedown.test.ts +0 -105
- package/tests/team.test.ts +0 -216
- package/tests/verification-listener.test.ts +0 -129
- package/tests/verification.test.ts +0 -186
- package/tsconfig.build.json +0 -9
- package/tsconfig.build.tsbuildinfo +0 -1
- package/tsconfig.json +0 -7
- package/tsconfig.tests.json +0 -8
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import { MINUTE } from '@atproto/common'
|
|
2
|
-
import { Database } from '../db/index.js'
|
|
3
|
-
import { dbLogger } from '../logger.js'
|
|
4
|
-
import {
|
|
5
|
-
deleteExpiringTagsByIds,
|
|
6
|
-
getExpiredTags,
|
|
7
|
-
} from '../mod-service/expiring-tags.js'
|
|
8
|
-
import {
|
|
9
|
-
ModerationServiceCreator,
|
|
10
|
-
ReversalSubject,
|
|
11
|
-
} from '../mod-service/index.js'
|
|
12
|
-
import { subjectFromStatusRow } from '../mod-service/subject.js'
|
|
13
|
-
|
|
14
|
-
export class EventReverser {
|
|
15
|
-
destroyed = false
|
|
16
|
-
reversalPromise: Promise<void> = Promise.resolve()
|
|
17
|
-
timer?: NodeJS.Timeout
|
|
18
|
-
|
|
19
|
-
constructor(
|
|
20
|
-
private db: Database,
|
|
21
|
-
private modService: ModerationServiceCreator,
|
|
22
|
-
) {}
|
|
23
|
-
|
|
24
|
-
start() {
|
|
25
|
-
this.poll()
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
poll() {
|
|
29
|
-
if (this.destroyed) return
|
|
30
|
-
this.reversalPromise = this.findAndRevertDueActions()
|
|
31
|
-
.catch((err) =>
|
|
32
|
-
dbLogger.error({ err }, 'moderation action reversal errored'),
|
|
33
|
-
)
|
|
34
|
-
.finally(() => {
|
|
35
|
-
this.timer = setTimeout(() => this.poll(), getInterval())
|
|
36
|
-
})
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async destroy() {
|
|
40
|
-
this.destroyed = true
|
|
41
|
-
if (this.timer) {
|
|
42
|
-
clearTimeout(this.timer)
|
|
43
|
-
this.timer = undefined
|
|
44
|
-
}
|
|
45
|
-
await this.reversalPromise
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async revertState(subject: ReversalSubject) {
|
|
49
|
-
await this.db.transaction(async (dbTxn) => {
|
|
50
|
-
const moderationTxn = this.modService(dbTxn)
|
|
51
|
-
const originalEvent =
|
|
52
|
-
await moderationTxn.getLastReversibleEventForSubject(subject)
|
|
53
|
-
if (originalEvent) {
|
|
54
|
-
await moderationTxn.revertState({
|
|
55
|
-
action: originalEvent.action,
|
|
56
|
-
createdBy: originalEvent.createdBy,
|
|
57
|
-
comment:
|
|
58
|
-
'[SCHEDULED_REVERSAL] Reverting action as originally scheduled',
|
|
59
|
-
subject: subject.subject,
|
|
60
|
-
createdAt: new Date(),
|
|
61
|
-
})
|
|
62
|
-
}
|
|
63
|
-
})
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
async findAndRevertDueActions() {
|
|
67
|
-
const moderationService = this.modService(this.db)
|
|
68
|
-
const subjectsDueForReversal =
|
|
69
|
-
await moderationService.getSubjectsDueForReversal()
|
|
70
|
-
|
|
71
|
-
// We shouldn't have too many actions due for reversal at any given time, so running in parallel is probably fine
|
|
72
|
-
// Internally, each reversal runs within its own transaction
|
|
73
|
-
await Promise.all([
|
|
74
|
-
...subjectsDueForReversal.map(this.revertState.bind(this)),
|
|
75
|
-
this.findAndRevertExpiredTags(),
|
|
76
|
-
])
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async findAndRevertExpiredTags() {
|
|
80
|
-
const groups = await getExpiredTags(this.db)
|
|
81
|
-
if (!groups.length) return
|
|
82
|
-
|
|
83
|
-
for (const group of groups) {
|
|
84
|
-
await this.db.transaction(async (dbTxn) => {
|
|
85
|
-
// Check which tags are still present on the subject
|
|
86
|
-
const status = await dbTxn.db
|
|
87
|
-
.selectFrom('moderation_subject_status')
|
|
88
|
-
.where('did', '=', group.did)
|
|
89
|
-
.where('recordPath', '=', group.recordPath)
|
|
90
|
-
.where('convoId', '=', group.convoId)
|
|
91
|
-
.selectAll()
|
|
92
|
-
.executeTakeFirst()
|
|
93
|
-
|
|
94
|
-
const currentTags: string[] = status?.tags ?? []
|
|
95
|
-
const tagsToRemove = group.tags.filter((t) => currentTags.includes(t))
|
|
96
|
-
|
|
97
|
-
// Delete the expiring_tag rows regardless
|
|
98
|
-
await deleteExpiringTagsByIds(dbTxn, group.ids)
|
|
99
|
-
|
|
100
|
-
// Only emit removal event if there are tags still present to remove
|
|
101
|
-
if (tagsToRemove.length > 0 && status) {
|
|
102
|
-
const subject = subjectFromStatusRow(status)
|
|
103
|
-
const moderationTxn = this.modService(dbTxn)
|
|
104
|
-
await moderationTxn.logEvent({
|
|
105
|
-
event: {
|
|
106
|
-
$type: 'tools.ozone.moderation.defs#modEventTag',
|
|
107
|
-
add: [],
|
|
108
|
-
remove: tagsToRemove,
|
|
109
|
-
comment:
|
|
110
|
-
'[SCHEDULED_REVERSAL] Reverting temporary tags as originally scheduled',
|
|
111
|
-
},
|
|
112
|
-
createdBy: group.createdBy,
|
|
113
|
-
subject,
|
|
114
|
-
createdAt: new Date(),
|
|
115
|
-
})
|
|
116
|
-
}
|
|
117
|
-
})
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const getInterval = (): number => {
|
|
123
|
-
// super basic synchronization by agreeing when the intervals land relative to unix timestamp
|
|
124
|
-
const now = Date.now()
|
|
125
|
-
const intervalMs = MINUTE
|
|
126
|
-
const nextIteration = Math.ceil(now / intervalMs)
|
|
127
|
-
return nextIteration * intervalMs - now
|
|
128
|
-
}
|
package/src/daemon/index.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { OzoneConfig, OzoneSecrets } from '../config/index.js'
|
|
2
|
-
import { AppContextOptions } from '../context.js'
|
|
3
|
-
import { DaemonContext } from './context.js'
|
|
4
|
-
|
|
5
|
-
export { EventPusher } from './event-pusher.js'
|
|
6
|
-
export { BlobDiverter } from './blob-diverter.js'
|
|
7
|
-
export { EventReverser } from './event-reverser.js'
|
|
8
|
-
export { ScheduledActionProcessor } from './scheduled-action-processor.js'
|
|
9
|
-
export { StrikeExpiryProcessor } from './strike-expiry-processor.js'
|
|
10
|
-
|
|
11
|
-
export class OzoneDaemon {
|
|
12
|
-
constructor(public ctx: DaemonContext) {}
|
|
13
|
-
static async create(
|
|
14
|
-
cfg: OzoneConfig,
|
|
15
|
-
secrets: OzoneSecrets,
|
|
16
|
-
overrides?: Partial<AppContextOptions>,
|
|
17
|
-
): Promise<OzoneDaemon> {
|
|
18
|
-
const ctx = await DaemonContext.fromConfig(cfg, secrets, overrides)
|
|
19
|
-
return new OzoneDaemon(ctx)
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async start() {
|
|
23
|
-
await this.ctx.start()
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async processAll() {
|
|
27
|
-
await this.ctx.processAll()
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
async destroy() {
|
|
31
|
-
await this.ctx.destroy()
|
|
32
|
-
}
|
|
33
|
-
}
|
package/src/daemon/job-cursor.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { Database } from '../db/index.js'
|
|
2
|
-
|
|
3
|
-
export async function initJobCursor(db: Database, job: string): Promise<void> {
|
|
4
|
-
await db.db
|
|
5
|
-
.insertInto('job_cursor')
|
|
6
|
-
.values({ job, cursor: null })
|
|
7
|
-
.onConflict((oc) => oc.doNothing())
|
|
8
|
-
.execute()
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export async function getJobCursor(
|
|
12
|
-
db: Database,
|
|
13
|
-
job: string,
|
|
14
|
-
): Promise<string | null> {
|
|
15
|
-
const entry = await db.db
|
|
16
|
-
.selectFrom('job_cursor')
|
|
17
|
-
.select('cursor')
|
|
18
|
-
.where('job', '=', job)
|
|
19
|
-
.executeTakeFirst()
|
|
20
|
-
return entry?.cursor ?? null
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export async function updateJobCursor(
|
|
24
|
-
db: Database,
|
|
25
|
-
job: string,
|
|
26
|
-
cursor: string,
|
|
27
|
-
): Promise<void> {
|
|
28
|
-
await db.db
|
|
29
|
-
.updateTable('job_cursor')
|
|
30
|
-
.set({ cursor })
|
|
31
|
-
.where('job', '=', job)
|
|
32
|
-
.execute()
|
|
33
|
-
}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { sql } from 'kysely'
|
|
2
|
-
import { MINUTE } from '@atproto/common'
|
|
3
|
-
import { BackgroundQueue, PeriodicBackgroundTask } from '../background.js'
|
|
4
|
-
import { dbLogger } from '../logger.js'
|
|
5
|
-
|
|
6
|
-
export class MaterializedViewRefresher extends PeriodicBackgroundTask {
|
|
7
|
-
constructor(backgroundQueue: BackgroundQueue, interval = 30 * MINUTE) {
|
|
8
|
-
super(backgroundQueue, interval, async ({ db }, signal) => {
|
|
9
|
-
for (const view of [
|
|
10
|
-
'account_events_stats',
|
|
11
|
-
'record_events_stats',
|
|
12
|
-
'account_record_events_stats',
|
|
13
|
-
'account_record_status_stats',
|
|
14
|
-
]) {
|
|
15
|
-
if (signal.aborted) break
|
|
16
|
-
|
|
17
|
-
// Kysely does not provide a way to cancel a running query. Because of
|
|
18
|
-
// this, killing the process during a refresh will cause the process to
|
|
19
|
-
// wait for the current refresh to finish before exiting. This is not
|
|
20
|
-
// ideal, but it is the best we can do until Kysely provides a way to
|
|
21
|
-
// cancel a query.
|
|
22
|
-
try {
|
|
23
|
-
await sql`REFRESH MATERIALIZED VIEW CONCURRENTLY ${sql.id(view)}`.execute(
|
|
24
|
-
db,
|
|
25
|
-
)
|
|
26
|
-
dbLogger.info(`refreshed materialized view ${view}`)
|
|
27
|
-
} catch (err) {
|
|
28
|
-
dbLogger.error({ err, view }, 'failed to refresh materialized view')
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
})
|
|
32
|
-
}
|
|
33
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { MINUTE } from '@atproto/common'
|
|
2
|
-
import { Database } from '../db/index.js'
|
|
3
|
-
import { dbLogger } from '../logger.js'
|
|
4
|
-
import { QueueServiceCreator } from '../queue/service.js'
|
|
5
|
-
import { initJobCursor } from './job-cursor.js'
|
|
6
|
-
|
|
7
|
-
const JOB_NAME = 'queue_router'
|
|
8
|
-
const BATCH_SIZE = 100
|
|
9
|
-
|
|
10
|
-
export class QueueRouter {
|
|
11
|
-
destroyed = false
|
|
12
|
-
processingPromise: Promise<void> = Promise.resolve()
|
|
13
|
-
timer?: NodeJS.Timeout
|
|
14
|
-
|
|
15
|
-
constructor(
|
|
16
|
-
private db: Database,
|
|
17
|
-
private queueServiceCreator: QueueServiceCreator,
|
|
18
|
-
) {}
|
|
19
|
-
|
|
20
|
-
start() {
|
|
21
|
-
this.initializeCursor().then(() => this.poll())
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
poll() {
|
|
25
|
-
if (this.destroyed) return
|
|
26
|
-
this.processingPromise = this.routeReports()
|
|
27
|
-
.catch((err) => dbLogger.error({ err }, 'queue routing errored'))
|
|
28
|
-
.finally(() => {
|
|
29
|
-
this.timer = setTimeout(() => this.poll(), getInterval())
|
|
30
|
-
})
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
async destroy() {
|
|
34
|
-
this.destroyed = true
|
|
35
|
-
if (this.timer) {
|
|
36
|
-
clearTimeout(this.timer)
|
|
37
|
-
this.timer = undefined
|
|
38
|
-
}
|
|
39
|
-
await this.processingPromise
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async initializeCursor() {
|
|
43
|
-
await initJobCursor(this.db, JOB_NAME)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
async getCursor(): Promise<number | null> {
|
|
47
|
-
const row = await this.db.db
|
|
48
|
-
.selectFrom('job_cursor')
|
|
49
|
-
.select('cursor')
|
|
50
|
-
.where('job', '=', JOB_NAME)
|
|
51
|
-
.executeTakeFirst()
|
|
52
|
-
return row?.cursor ? parseInt(row.cursor, 10) : null
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async routeReports() {
|
|
56
|
-
await this.db.transaction(async (txn) => {
|
|
57
|
-
// Acquire row lock on the job_cursor row. A second daemon instance
|
|
58
|
-
// hitting this same query blocks here until the first transaction
|
|
59
|
-
// commits, then reads the now-advanced cursor and processes the next
|
|
60
|
-
// range. The lock is held for the whole batch (~50–200ms).
|
|
61
|
-
const row = await txn.db
|
|
62
|
-
.selectFrom('job_cursor')
|
|
63
|
-
.selectAll()
|
|
64
|
-
.where('job', '=', JOB_NAME)
|
|
65
|
-
.forUpdate()
|
|
66
|
-
.executeTakeFirst()
|
|
67
|
-
if (!row) return
|
|
68
|
-
const cursor = row.cursor ? parseInt(row.cursor, 10) : null
|
|
69
|
-
|
|
70
|
-
const queueService = this.queueServiceCreator(txn)
|
|
71
|
-
const result = await queueService.insertReportsFromEvents({
|
|
72
|
-
cursor,
|
|
73
|
-
limit: BATCH_SIZE,
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
if (result.processed === 0) {
|
|
77
|
-
dbLogger.info('no new report events to route')
|
|
78
|
-
return
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
await txn.db
|
|
82
|
-
.updateTable('job_cursor')
|
|
83
|
-
.set({ cursor: String(result.maxEventId) })
|
|
84
|
-
.where('job', '=', JOB_NAME)
|
|
85
|
-
.execute()
|
|
86
|
-
|
|
87
|
-
dbLogger.info(
|
|
88
|
-
{
|
|
89
|
-
processed: result.processed,
|
|
90
|
-
assigned: result.assigned,
|
|
91
|
-
unmatched: result.unmatched,
|
|
92
|
-
maxEventId: result.maxEventId,
|
|
93
|
-
},
|
|
94
|
-
'queue routing completed',
|
|
95
|
-
)
|
|
96
|
-
})
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Poll every 1 minute
|
|
101
|
-
const getInterval = (): number => 1 * MINUTE
|
|
@@ -1,304 +0,0 @@
|
|
|
1
|
-
import { Selectable } from 'kysely'
|
|
2
|
-
import { MINUTE, SECOND } from '@atproto/common'
|
|
3
|
-
import {
|
|
4
|
-
assertProtectedTagAction,
|
|
5
|
-
getProtectedTags,
|
|
6
|
-
} from '../api/moderation/util.js'
|
|
7
|
-
import { Database } from '../db/index.js'
|
|
8
|
-
import { ScheduledAction } from '../db/schema/scheduled-action.js'
|
|
9
|
-
import {
|
|
10
|
-
ModEventTakedown,
|
|
11
|
-
ModTool,
|
|
12
|
-
} from '../lexicon/types/tools/ozone/moderation/defs.js'
|
|
13
|
-
import { dbLogger } from '../logger.js'
|
|
14
|
-
import {
|
|
15
|
-
ModerationService,
|
|
16
|
-
ModerationServiceCreator,
|
|
17
|
-
} from '../mod-service/index.js'
|
|
18
|
-
import { RepoSubject } from '../mod-service/subject.js'
|
|
19
|
-
import { ModEventType } from '../mod-service/types.js'
|
|
20
|
-
import { ScheduledActionServiceCreator } from '../scheduled-action/service.js'
|
|
21
|
-
import { SettingService, SettingServiceCreator } from '../setting/service.js'
|
|
22
|
-
import { retryHttp } from '../util.js'
|
|
23
|
-
|
|
24
|
-
export class ScheduledActionProcessor {
|
|
25
|
-
destroyed = false
|
|
26
|
-
processingPromise: Promise<void> = Promise.resolve()
|
|
27
|
-
timer?: NodeJS.Timeout
|
|
28
|
-
|
|
29
|
-
constructor(
|
|
30
|
-
private db: Database,
|
|
31
|
-
private serviceDid: string,
|
|
32
|
-
private settingService: SettingServiceCreator,
|
|
33
|
-
private modService: ModerationServiceCreator,
|
|
34
|
-
private scheduledActionService: ScheduledActionServiceCreator,
|
|
35
|
-
) {}
|
|
36
|
-
|
|
37
|
-
start() {
|
|
38
|
-
this.poll()
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
poll() {
|
|
42
|
-
if (this.destroyed) return
|
|
43
|
-
this.processingPromise = this.findAndExecuteScheduledActions()
|
|
44
|
-
.catch((err) =>
|
|
45
|
-
dbLogger.error({ err }, 'scheduled action processing errored'),
|
|
46
|
-
)
|
|
47
|
-
.finally(() => {
|
|
48
|
-
this.timer = setTimeout(() => this.poll(), getInterval())
|
|
49
|
-
})
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async destroy() {
|
|
53
|
-
this.destroyed = true
|
|
54
|
-
if (this.timer) {
|
|
55
|
-
clearTimeout(this.timer)
|
|
56
|
-
this.timer = undefined
|
|
57
|
-
}
|
|
58
|
-
await this.processingPromise
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async executeScheduledAction(actionId: number) {
|
|
62
|
-
await this.db.transaction(async (dbTxn) => {
|
|
63
|
-
const settingService = this.settingService(dbTxn)
|
|
64
|
-
const moderationTxn = this.modService(dbTxn)
|
|
65
|
-
const scheduledActionTxn = this.scheduledActionService(dbTxn)
|
|
66
|
-
|
|
67
|
-
try {
|
|
68
|
-
// maybe overfetching here to get the action again within the transaction to ensure it's still pending
|
|
69
|
-
const action = await dbTxn.db
|
|
70
|
-
.selectFrom('scheduled_action')
|
|
71
|
-
.selectAll()
|
|
72
|
-
.where('id', '=', actionId)
|
|
73
|
-
.where('status', '=', 'pending')
|
|
74
|
-
.executeTakeFirst()
|
|
75
|
-
|
|
76
|
-
if (!action) {
|
|
77
|
-
// already processed or cancelled
|
|
78
|
-
return
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
let event: ModEventType
|
|
82
|
-
const email = {
|
|
83
|
-
subject: '',
|
|
84
|
-
content: '',
|
|
85
|
-
}
|
|
86
|
-
let modTool: ModTool | undefined
|
|
87
|
-
|
|
88
|
-
// Create the appropriate moderation action based on the scheduled action type
|
|
89
|
-
switch (action.action) {
|
|
90
|
-
case 'takedown':
|
|
91
|
-
{
|
|
92
|
-
const eventData = action.eventData as ModEventTakedown & {
|
|
93
|
-
modTool?: ModTool
|
|
94
|
-
emailSubject?: string
|
|
95
|
-
emailContent?: string
|
|
96
|
-
}
|
|
97
|
-
modTool = eventData.modTool
|
|
98
|
-
event = {
|
|
99
|
-
$type: 'tools.ozone.moderation.defs#modEventTakedown',
|
|
100
|
-
comment: `[SCHEDULED_ACTION] ${eventData.comment || 'Scheduled takedown executed'}`,
|
|
101
|
-
durationInHours: eventData.durationInHours,
|
|
102
|
-
acknowledgeAccountSubjects:
|
|
103
|
-
eventData.acknowledgeAccountSubjects,
|
|
104
|
-
policies: eventData.policies,
|
|
105
|
-
severityLevel: eventData.severityLevel,
|
|
106
|
-
strikeCount: eventData.strikeCount,
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (eventData.emailSubject && eventData.emailContent) {
|
|
110
|
-
email.subject = eventData.emailSubject
|
|
111
|
-
email.content = eventData.emailContent
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
break
|
|
115
|
-
default:
|
|
116
|
-
throw new Error(
|
|
117
|
-
`Unsupported scheduled action type: ${action.action}`,
|
|
118
|
-
)
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const moderationEvent = await this.performTakedown({
|
|
122
|
-
action,
|
|
123
|
-
event,
|
|
124
|
-
modTool,
|
|
125
|
-
moderationTxn,
|
|
126
|
-
settingService,
|
|
127
|
-
email,
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
// Mark the scheduled action as executed
|
|
131
|
-
await scheduledActionTxn.markActionAsExecuted(
|
|
132
|
-
actionId,
|
|
133
|
-
moderationEvent.event.id,
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
dbLogger.info(
|
|
137
|
-
{
|
|
138
|
-
did: action.did,
|
|
139
|
-
scheduledActionId: actionId,
|
|
140
|
-
moderationEventId: moderationEvent.event.id,
|
|
141
|
-
},
|
|
142
|
-
'executed scheduled action',
|
|
143
|
-
)
|
|
144
|
-
} catch (error) {
|
|
145
|
-
const errorMessage =
|
|
146
|
-
error instanceof Error ? error.message : 'Unknown error'
|
|
147
|
-
|
|
148
|
-
// mark as failed
|
|
149
|
-
await scheduledActionTxn.markActionAsFailed(actionId, errorMessage)
|
|
150
|
-
|
|
151
|
-
dbLogger.error(
|
|
152
|
-
{
|
|
153
|
-
scheduledActionId: actionId,
|
|
154
|
-
error: errorMessage,
|
|
155
|
-
},
|
|
156
|
-
'failed to execute scheduled action',
|
|
157
|
-
)
|
|
158
|
-
}
|
|
159
|
-
})
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
async performTakedown({
|
|
163
|
-
email,
|
|
164
|
-
action,
|
|
165
|
-
event,
|
|
166
|
-
modTool,
|
|
167
|
-
moderationTxn,
|
|
168
|
-
settingService,
|
|
169
|
-
}: {
|
|
170
|
-
email: { subject: string; content: string }
|
|
171
|
-
action: Selectable<ScheduledAction>
|
|
172
|
-
event: ModEventType
|
|
173
|
-
modTool: ModTool | undefined
|
|
174
|
-
|
|
175
|
-
moderationTxn: ModerationService
|
|
176
|
-
settingService: SettingService
|
|
177
|
-
}) {
|
|
178
|
-
const subject = new RepoSubject(action.did)
|
|
179
|
-
|
|
180
|
-
const status = await moderationTxn.getStatus(subject)
|
|
181
|
-
|
|
182
|
-
if (status?.takendown) {
|
|
183
|
-
throw new Error(`Account is already taken down`)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (status?.tags?.length) {
|
|
187
|
-
const protectedTags = await getProtectedTags(
|
|
188
|
-
settingService,
|
|
189
|
-
this.serviceDid,
|
|
190
|
-
)
|
|
191
|
-
|
|
192
|
-
if (protectedTags) {
|
|
193
|
-
assertProtectedTagAction({
|
|
194
|
-
protectedTags,
|
|
195
|
-
subjectTags: status.tags,
|
|
196
|
-
actionAuthor: action.createdBy,
|
|
197
|
-
isAdmin: true,
|
|
198
|
-
isModerator: false,
|
|
199
|
-
isTriage: false,
|
|
200
|
-
})
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// log the event which also applies the necessary state changes to moderation subject
|
|
205
|
-
const moderationEvent = await moderationTxn.logEvent({
|
|
206
|
-
event,
|
|
207
|
-
subject,
|
|
208
|
-
modTool,
|
|
209
|
-
createdBy: action.createdBy,
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
// register the takedown in event pusher
|
|
213
|
-
await moderationTxn.takedownRepo(
|
|
214
|
-
subject,
|
|
215
|
-
moderationEvent.event.id,
|
|
216
|
-
new Set(
|
|
217
|
-
moderationEvent.event.meta?.targetServices
|
|
218
|
-
? `${moderationEvent.event.meta.targetServices}`.split(',')
|
|
219
|
-
: undefined,
|
|
220
|
-
),
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
if (email.content && email.subject) {
|
|
224
|
-
let isDelivered = false
|
|
225
|
-
try {
|
|
226
|
-
await retryHttp(() =>
|
|
227
|
-
moderationTxn.sendEmail({
|
|
228
|
-
...email,
|
|
229
|
-
recipientDid: action.did,
|
|
230
|
-
}),
|
|
231
|
-
)
|
|
232
|
-
isDelivered = true
|
|
233
|
-
} catch (err) {
|
|
234
|
-
dbLogger.error(
|
|
235
|
-
{ err, did: action.did },
|
|
236
|
-
'failed to send takedown email',
|
|
237
|
-
)
|
|
238
|
-
}
|
|
239
|
-
await moderationTxn.logEvent({
|
|
240
|
-
event: {
|
|
241
|
-
content: email.content,
|
|
242
|
-
subjectLine: email.subject,
|
|
243
|
-
$type: 'tools.ozone.moderation.defs#modEventEmail',
|
|
244
|
-
comment: [
|
|
245
|
-
'Communication attached to scheduled action',
|
|
246
|
-
isDelivered ? '' : 'Email delivery failed',
|
|
247
|
-
].join('.'),
|
|
248
|
-
isDelivered,
|
|
249
|
-
},
|
|
250
|
-
subject,
|
|
251
|
-
modTool,
|
|
252
|
-
createdBy: action.createdBy,
|
|
253
|
-
})
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
return moderationEvent
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
async findAndExecuteScheduledActions() {
|
|
260
|
-
const scheduledActionService = this.scheduledActionService(this.db)
|
|
261
|
-
const now = new Date()
|
|
262
|
-
|
|
263
|
-
const actionsToExecute =
|
|
264
|
-
await scheduledActionService.getPendingActionsToExecute(now)
|
|
265
|
-
|
|
266
|
-
for (const action of actionsToExecute) {
|
|
267
|
-
// For randomized execution, check if we should execute now or wait
|
|
268
|
-
if (action.randomizeExecution && action.executeAfter) {
|
|
269
|
-
const executeAfter = new Date(action.executeAfter)
|
|
270
|
-
// Default to a 30 second window for execution
|
|
271
|
-
const executeUntil = action.executeUntil
|
|
272
|
-
? new Date(action.executeUntil)
|
|
273
|
-
: new Date(executeAfter.getTime() + 30 * SECOND)
|
|
274
|
-
|
|
275
|
-
// Only execute if we're past the earliest time
|
|
276
|
-
if (now < executeAfter) {
|
|
277
|
-
continue
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// For randomized scheduling, randomly decide whether to execute now
|
|
281
|
-
// The probability increases as we get closer to the deadline
|
|
282
|
-
const timeRange = executeUntil.getTime() - executeAfter.getTime()
|
|
283
|
-
const timeElapsed = now.getTime() - executeAfter.getTime()
|
|
284
|
-
const executeProb = Math.min(timeElapsed / timeRange, 1)
|
|
285
|
-
|
|
286
|
-
// Execute with increasing probability as we approach the deadline
|
|
287
|
-
// Always execute if we're at or past the deadline
|
|
288
|
-
if (now < executeUntil && Math.random() > executeProb * 0.1) {
|
|
289
|
-
continue
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
await this.executeScheduledAction(action.id)
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const getInterval = (): number => {
|
|
299
|
-
// Process scheduled actions every minute
|
|
300
|
-
const now = Date.now()
|
|
301
|
-
const intervalMs = MINUTE
|
|
302
|
-
const nextIteration = Math.ceil(now / intervalMs)
|
|
303
|
-
return nextIteration * intervalMs - now
|
|
304
|
-
}
|