@meith/runtime 0.16.0

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.
@@ -0,0 +1,373 @@
1
+ import { AttachmentService, type ImageProcessor } from '@meith/attachments'
2
+ import { Authorizer } from '@meith/authorization'
3
+ import { AvatarService } from '@meith/avatars'
4
+ import type { FileStore, MailDriver, QueueDriver } from '@meith/core'
5
+ import { env, logger, metrics, optional, withSpan } from '@meith/core'
6
+ import {
7
+ ActorBuilder,
8
+ type Database,
9
+ expireTimedGroupMemberships,
10
+ getDb,
11
+ PostgresAttachmentRepository,
12
+ PostgresAuthEventRepository,
13
+ PostgresAuthorizationSource,
14
+ PostgresAvatarRepository,
15
+ PostgresBanRepository,
16
+ PostgresContentCounterRepository,
17
+ PostgresCounterRecount,
18
+ PostgresMaintenanceRepository,
19
+ PostgresMarketplaceCacheRepository,
20
+ PostgresNotificationRepository,
21
+ PostgresOutboxReader,
22
+ PostgresPresenceRepository,
23
+ PostgresPromotionRepository,
24
+ PostgresRateLimitBucketStore,
25
+ PostgresRenderBackfill,
26
+ PostgresSearchRepository,
27
+ PostgresSettingsRepository,
28
+ PostgresStatsRepository,
29
+ PostgresSubscriptionRepository,
30
+ PostgresTaskRepository,
31
+ PostgresThreadViewBuffer,
32
+ PostgresUserBulkRepository,
33
+ PostgresWarningRepository,
34
+ PostgresWebhookRepository,
35
+ readPluginHealth,
36
+ recordPluginFailure,
37
+ syncRenderSignature,
38
+ } from '@meith/db'
39
+ import { EN_CATALOG, sourceTranslator } from '@meith/i18n'
40
+ import { renderMail } from '@meith/mail'
41
+ import {
42
+ deliverNotificationEmail,
43
+ deliverNotificationPush,
44
+ NotificationService,
45
+ type NotificationTranslatorResolver,
46
+ type VapidDetails,
47
+ } from '@meith/notifications'
48
+ import { type PluginDefinition, PluginHost, renderingSignature } from '@meith/plugin-kit'
49
+ import { resolvePushConfig, SettingsSnapshot } from '@meith/settings'
50
+ import { builtinTasks, type TaskDefinition, type TaskRepository } from '@meith/tasks'
51
+
52
+ import { buildEventRegistry } from './event-handlers'
53
+ import { SEED_GROUP } from './groups'
54
+ import { resolveMailBrand, type ThemeTokenRegistry } from './mail-brand'
55
+ import { pluginMarkdownPipeline, sendAudited } from './plugin-rendering'
56
+ import { pluginTasks } from './plugin-tasks'
57
+ import { defaultPromotionGuards, taskWorkers } from './task-workers'
58
+ import { visibleForumSource } from './visible-forums'
59
+
60
+ export interface SchedulerBundle {
61
+ readonly repository: TaskRepository
62
+ readonly tasks: readonly TaskDefinition[]
63
+ readonly onTaskFailure: (taskId: string, error: unknown) => void
64
+ }
65
+
66
+ export function buildSchedulerBundle(deps: {
67
+ readonly queue: QueueDriver
68
+ readonly db?: Database
69
+ readonly mail?: MailDriver
70
+ readonly themeKey?: string
71
+ readonly themeTokens?: ThemeTokenRegistry
72
+ readonly files?: FileStore
73
+ readonly images?: ImageProcessor
74
+ readonly plugins?: readonly PluginDefinition[]
75
+ readonly translatorForLocale?: NotificationTranslatorResolver
76
+ }): SchedulerBundle {
77
+ const db = deps.db ?? getDb()
78
+ const themeDeps = {
79
+ ...optional(deps.themeKey, (themeKey) => ({ themeKey })),
80
+ ...optional(deps.themeTokens, (themeTokens) => ({ themeTokens })),
81
+ }
82
+ const threadViews = new PostgresThreadViewBuffer(db)
83
+ const notifications = new PostgresNotificationRepository(db)
84
+ const mail = deps.mail
85
+
86
+ const attachmentService =
87
+ deps.files === undefined || deps.images === undefined
88
+ ? undefined
89
+ : new AttachmentService({
90
+ attachments: new PostgresAttachmentRepository(db),
91
+ files: deps.files,
92
+ images: deps.images,
93
+ })
94
+
95
+ const avatarService =
96
+ deps.files === undefined || deps.images === undefined
97
+ ? undefined
98
+ : new AvatarService({
99
+ avatars: new PostgresAvatarRepository(db),
100
+ files: deps.files,
101
+ images: deps.images,
102
+ })
103
+
104
+ const contributed = pluginTasks({ db, plugins: deps.plugins ?? [] })
105
+ const plugins = deps.plugins ?? []
106
+ const renderHost = new PluginHost({
107
+ plugins,
108
+ health: {
109
+ failed: (failure) => {
110
+ void recordPluginFailure(db, {
111
+ pluginKey: failure.pluginKey,
112
+ threshold: failure.threshold,
113
+ reason: `${failure.threshold} failures, most recently in "${failure.hook}": ${failure.message}`,
114
+ }).catch((error: unknown) => {
115
+ logger({ module: 'scheduler' }).warn(
116
+ { err: String(error), plugin: failure.pluginKey },
117
+ 'could not record plugin failure',
118
+ )
119
+ })
120
+ },
121
+ },
122
+ })
123
+ const backfill = new PostgresRenderBackfill(db, pluginMarkdownPipeline(renderHost))
124
+
125
+ const taskRuns = metrics.counter(
126
+ 'meith_task_runs_total',
127
+ 'Scheduled task runs, labelled by task and outcome.',
128
+ )
129
+ const taskRunSeconds = metrics.histogram(
130
+ 'meith_task_run_duration_seconds',
131
+ 'Scheduled task run duration in seconds, labelled by task and outcome.',
132
+ )
133
+
134
+ /**
135
+ * Every task, announced. The host is told before and after each run — a
136
+ * plugin watching its own task, or the board's, gets the same two events
137
+ * whichever scheduled it.
138
+ */
139
+ const announced = (tasks: readonly TaskDefinition[]): TaskDefinition[] =>
140
+ tasks.map((task) => ({
141
+ ...task,
142
+ async run(context) {
143
+ await renderHost.emit('task.run.before', { taskId: task.id }, {})
144
+
145
+ const startedAt = Date.now()
146
+ try {
147
+ const result = await withSpan('task.run', { 'task.id': task.id }, () => task.run(context))
148
+ const durationMs = Date.now() - startedAt
149
+ taskRuns.inc(1, { task: task.id, status: 'ok' })
150
+ taskRunSeconds.observe(durationMs / 1000, { task: task.id })
151
+ await renderHost.emit('task.run.after', { taskId: task.id, ok: true, durationMs }, {})
152
+ return result
153
+ } catch (error) {
154
+ const durationMs = Date.now() - startedAt
155
+ taskRuns.inc(1, { task: task.id, status: 'error' })
156
+ taskRunSeconds.observe(durationMs / 1000, { task: task.id })
157
+ await renderHost.emit('task.run.after', { taskId: task.id, ok: false, durationMs }, {})
158
+ throw error
159
+ }
160
+ },
161
+ }))
162
+
163
+ return {
164
+ repository: new PostgresTaskRepository(db),
165
+ onTaskFailure: taskFailureNotifier(notifications),
166
+ tasks: announced([
167
+ ...builtinTasks(
168
+ taskWorkers({
169
+ queue: deps.queue,
170
+ bans: new PostgresBanRepository(db),
171
+ promotions: new PostgresPromotionRepository(db),
172
+ guards: defaultPromotionGuards(),
173
+ maintenance: new PostgresMaintenanceRepository(db),
174
+ rateLimits: new PostgresRateLimitBucketStore(db),
175
+ authEvents: {
176
+ retentionDays: () => authEventRetentionDays(db),
177
+ pruneBefore: (cutoff, limit) =>
178
+ new PostgresAuthEventRepository(db).pruneBefore(cutoff, limit),
179
+ },
180
+ timedGroups: { expire: (limit) => expireTimedGroupMemberships(db, limit) },
181
+ outbox: new PostgresOutboxReader(db),
182
+ ...optional(attachmentService, (attachments) => ({ attachments })),
183
+ ...optional(avatarService, (avatars) => ({ avatars })),
184
+ events: buildEventRegistry({
185
+ counters: new PostgresContentCounterRepository(db),
186
+ ...optional(attachmentService, (attachments) => ({
187
+ attachments: { process: (id: number) => attachments.process(id) },
188
+ })),
189
+ ...optional(avatarService, (avatars) => ({
190
+ avatars: { process: (id: number) => avatars.process(id) },
191
+ })),
192
+ notifications: {
193
+ ...optional(mail, (mail) => ({
194
+ async deliverEmail(notificationId: number) {
195
+ await deliverNotificationEmail({
196
+ notifications,
197
+ mail,
198
+ brand: await resolveMailBrand({ db, ...themeDeps }),
199
+ notificationId,
200
+ ...optional(deps.translatorForLocale, (translatorForLocale) => ({
201
+ translatorForLocale,
202
+ })),
203
+ })
204
+ },
205
+ })),
206
+ async deliverPush(notificationId: number) {
207
+ const vapid = await vapidDetails(db)
208
+ if (vapid === null) return
209
+ await deliverNotificationPush({
210
+ notifications,
211
+ vapid,
212
+ notificationId,
213
+ ...optional(deps.translatorForLocale, (translatorForLocale) => ({
214
+ translatorForLocale,
215
+ })),
216
+ })
217
+ },
218
+ },
219
+ ...optional(mail, (mail) => ({
220
+ massMail: {
221
+ async send({ massMailId, email }) {
222
+ const campaign = await new PostgresUserBulkRepository(db).readMassMail(massMailId)
223
+ if (campaign === null) return
224
+
225
+ const brand = await resolveMailBrand({ db, ...themeDeps })
226
+ const t = await (
227
+ deps.translatorForLocale ?? (() => sourceTranslator(EN_CATALOG))
228
+ )('')
229
+
230
+ const rendered = renderMail({
231
+ brand,
232
+ t,
233
+ body: {
234
+ title: campaign.subject,
235
+ paragraphs: campaign.body.split(/\n{2,}/),
236
+ footer: [
237
+ {
238
+ text: t.t('mail.footer.sentBy', {
239
+ board:
240
+ brand.boardName === '' ? t.t('mail.boardFallback') : brand.boardName,
241
+ }),
242
+ },
243
+ ],
244
+ },
245
+ })
246
+
247
+ const fromName = brand.fromName ?? ''
248
+ await sendAudited(renderHost, mail, 'mass-mail', {
249
+ to: email,
250
+ subject: campaign.subject,
251
+ text: rendered.text,
252
+ html: rendered.html,
253
+ ...(fromName === '' ? {} : { fromName }),
254
+ })
255
+ },
256
+ },
257
+ })),
258
+ }),
259
+ recount: new PostgresCounterRecount(db),
260
+ renderBackfill: {
261
+ run: async (batchSize) => {
262
+ renderHost.setDurablyDisabled(
263
+ (await readPluginHealth(db))
264
+ .filter((row) => row.disabledAt !== null)
265
+ .map((row) => ({
266
+ key: row.pluginKey,
267
+ reason: row.reason ?? 'repeated failures',
268
+ })),
269
+ )
270
+ await syncRenderSignature(db, renderingSignature(plugins))
271
+ return backfill.run(batchSize)
272
+ },
273
+ },
274
+ searchIndex: new PostgresSearchRepository(db),
275
+ ...(env.DEMO_MODE ? {} : { webhooks: new PostgresWebhookRepository(db) }),
276
+ statistics: {
277
+ stats: new PostgresStatsRepository(db),
278
+ presence: new PostgresPresenceRepository(db),
279
+ },
280
+ threadViews,
281
+ warnings: new PostgresWarningRepository(db),
282
+ subscriptions: {
283
+ repository: new PostgresSubscriptionRepository(db),
284
+ notifications: new NotificationService({ notifications }),
285
+ forums: visibleForumSource({
286
+ authorizer: new Authorizer(new PostgresAuthorizationSource(db), {}),
287
+ actors: new ActorBuilder(db, { guestGroupId: SEED_GROUP.guest }),
288
+ }),
289
+ unsubscribeSecret: env.AUTH_SECRET ?? null,
290
+ },
291
+ marketplace: {
292
+ repository: new PostgresMarketplaceCacheRepository(db),
293
+ plugins,
294
+ feedUrl: () => marketplaceFeedUrl(db),
295
+ async notifyUpdate(listing) {
296
+ await new NotificationService({ notifications }).raiseForAdministrators({
297
+ kind: 'marketplace.update_available',
298
+ data: {
299
+ key: listing.key,
300
+ name: listing.name,
301
+ package: listing.package,
302
+ version: listing.version,
303
+ },
304
+ href: '/admin/plugins/browse',
305
+ dedupeKey: `marketplace.update_available:${listing.key}:${listing.version}`,
306
+ })
307
+ },
308
+ },
309
+ }),
310
+ ),
311
+ ...contributed,
312
+ ]),
313
+ }
314
+ }
315
+
316
+ async function marketplaceFeedUrl(db: Database): Promise<string> {
317
+ try {
318
+ const overrides = await new PostgresSettingsRepository(db).loadAll()
319
+ return SettingsSnapshot.fromOverrides(new Map(overrides)).get('marketplace.feed_url')
320
+ } catch (err) {
321
+ logger({ module: 'tick' }).warn({ err }, 'could not read the marketplace feed URL setting')
322
+ return SettingsSnapshot.fromOverrides(new Map()).get('marketplace.feed_url')
323
+ }
324
+ }
325
+
326
+ async function vapidDetails(db: Database): Promise<VapidDetails | null> {
327
+ try {
328
+ const overrides = await new PostgresSettingsRepository(db).loadAll()
329
+ const { config } = resolvePushConfig({
330
+ environment: env,
331
+ settings: SettingsSnapshot.fromOverrides(new Map(overrides)),
332
+ })
333
+ return config
334
+ } catch (err) {
335
+ logger({ module: 'tick' }).warn({ err }, 'could not read the web push configuration')
336
+ return null
337
+ }
338
+ }
339
+
340
+ async function authEventRetentionDays(db: Database): Promise<number> {
341
+ try {
342
+ const overrides = await new PostgresSettingsRepository(db).loadAll()
343
+ return SettingsSnapshot.fromOverrides(new Map(overrides)).get(
344
+ 'security.auth_event_retention_days',
345
+ )
346
+ } catch (err) {
347
+ logger({ module: 'tick' }).warn({ err }, 'could not read the sign-in activity retention')
348
+ return 0
349
+ }
350
+ }
351
+
352
+ const ERROR_DETAIL_MAX = 500
353
+
354
+ function taskFailureNotifier(
355
+ notifications: PostgresNotificationRepository,
356
+ ): (taskId: string, error: unknown) => void {
357
+ const log = logger({ module: 'tick' })
358
+
359
+ return (taskId, error) => {
360
+ const message = error instanceof Error ? error.message : String(error)
361
+ log.error({ taskId, err: error }, 'scheduled task failed')
362
+
363
+ void new NotificationService({ notifications })
364
+ .raiseForAdministrators({
365
+ kind: 'system.task_failed',
366
+ data: { taskId, error: message.slice(0, ERROR_DETAIL_MAX) },
367
+ dedupeKey: `system.task_failed:${taskId}`,
368
+ })
369
+ .catch((err: unknown) => {
370
+ log.error({ taskId, err }, 'could not raise the task-failure notification')
371
+ })
372
+ }
373
+ }
@@ -0,0 +1,293 @@
1
+ import { type BanRepository, BanService } from '@meith/accounts'
2
+ import type { AttachmentService } from '@meith/attachments'
3
+ import type { AvatarService } from '@meith/avatars'
4
+ import { optional, type QueueDriver } from '@meith/core'
5
+ import { type EventRegistry, type OutboxReader, relayOutbox as runOutboxRelay } from '@meith/events'
6
+ import { type PromotionGuards, PromotionService } from '@meith/groups'
7
+ import {
8
+ type MarketplaceCacheRepository,
9
+ type MarketplaceListing,
10
+ MEITH_VERSION,
11
+ PLUGIN_API_MAJOR,
12
+ refreshCatalog,
13
+ THEME_API_MAJOR,
14
+ } from '@meith/marketplace'
15
+ import { type WarningRepository, WarningService } from '@meith/moderation'
16
+ import type { NotificationService } from '@meith/notifications'
17
+ import type { PluginDefinition } from '@meith/plugin-kit'
18
+ import {
19
+ SubscriptionNotifier,
20
+ type SubscriptionRepository,
21
+ type VisibleForumSource,
22
+ } from '@meith/subscriptions'
23
+ import type { TaskWorkers } from '@meith/tasks'
24
+
25
+ import { SEED_GROUP } from './groups'
26
+ import { deliverWebhooks, type WebhookDeliveryStore } from './webhook-delivery'
27
+
28
+ export interface TaskWorkerDeps {
29
+ readonly queue: QueueDriver
30
+ readonly bans: BanRepository
31
+ readonly promotions: ConstructorParameters<typeof PromotionService>[0]['promotions']
32
+ readonly guards: PromotionGuards
33
+ readonly maintenance: {
34
+ pruneSessions(now: Date, limit?: number): Promise<number>
35
+ pruneExpiredTokens(now: Date, limit?: number): Promise<number>
36
+ }
37
+ readonly rateLimits?: { prune(before: Date, limit?: number): Promise<number> }
38
+ readonly authEvents?: {
39
+ retentionDays(): Promise<number>
40
+ pruneBefore(cutoff: Date, limit?: number): Promise<number>
41
+ }
42
+ readonly timedGroups?: { expire(limit: number): Promise<number> }
43
+ readonly outbox: OutboxReader
44
+ readonly events: EventRegistry
45
+ readonly recount: { run(batchSize: number): Promise<{ corrected: number }> }
46
+ readonly threadViews: { flush(limit: number): Promise<number> }
47
+ readonly renderBackfill: { run(batchSize: number): Promise<{ rendered: number }> }
48
+ readonly searchIndex?:
49
+ | { reindexChunk(afterPostId: number, limit: number): Promise<{ indexed: number }> }
50
+ | undefined
51
+ readonly webhooks?: WebhookDeliveryStore | undefined
52
+ readonly warnings: WarningRepository
53
+ readonly subscriptions?: {
54
+ readonly repository: SubscriptionRepository
55
+ readonly notifications: NotificationService
56
+ readonly forums: VisibleForumSource
57
+ readonly unsubscribeSecret: string | null
58
+ }
59
+ readonly attachments?: AttachmentService
60
+ readonly avatars?: AvatarService
61
+ readonly statistics?: {
62
+ readonly stats: { rollUp(now: Date): Promise<{ memberCount: number }> }
63
+ readonly presence: {
64
+ concurrentCount(now: Date): Promise<number>
65
+ recordIfHigher(count: number, now: Date): Promise<boolean>
66
+ }
67
+ }
68
+ readonly marketplace?: {
69
+ readonly repository: MarketplaceCacheRepository
70
+ readonly plugins: readonly PluginDefinition[]
71
+ readonly feedUrl: () => Promise<string>
72
+ readonly notifyUpdate: (listing: MarketplaceListing) => Promise<void>
73
+ }
74
+ }
75
+
76
+ export function defaultPromotionGuards(): PromotionGuards {
77
+ return {
78
+ protectedGroupIds: [SEED_GROUP.banned, SEED_GROUP.administrators, SEED_GROUP.superModerators],
79
+ rank: new Map([
80
+ [SEED_GROUP.guest, 0],
81
+ [SEED_GROUP.registered, 2],
82
+ [SEED_GROUP.superModerators, 8],
83
+ [SEED_GROUP.administrators, 9],
84
+ ]),
85
+ }
86
+ }
87
+
88
+ export function taskWorkers(deps: TaskWorkerDeps): Partial<TaskWorkers> {
89
+ const bans = new BanService({ bans: deps.bans, bannedGroupId: SEED_GROUP.banned })
90
+ const promotions = new PromotionService({
91
+ promotions: deps.promotions,
92
+ guards: deps.guards,
93
+ })
94
+
95
+ return {
96
+ async relayOutbox(batchSize) {
97
+ const { claimed } = await runOutboxRelay({
98
+ reader: deps.outbox,
99
+ target: {
100
+ async enqueue(jobs) {
101
+ for (const job of jobs) {
102
+ await deps.queue.enqueue(job.name, job.payload, {
103
+ dedupeKey: job.idempotencyKey,
104
+ })
105
+ }
106
+ },
107
+ },
108
+ handlerIdsFor: (event) => deps.events.handlerIdsFor(event),
109
+ batchSize,
110
+ })
111
+ return claimed
112
+ },
113
+
114
+ async drainQueue(batchSize, signal) {
115
+ const { processed } = await deps.queue.drain(
116
+ batchSize,
117
+ async (job) => {
118
+ await deps.events.dispatch(job.kind, job.payload)
119
+ },
120
+ { signal },
121
+ )
122
+ return processed
123
+ },
124
+
125
+ async reconcileCounters(batchSize) {
126
+ const { corrected } = await deps.recount.run(batchSize)
127
+ return corrected
128
+ },
129
+
130
+ async flushThreadViews(batchSize) {
131
+ return deps.threadViews.flush(batchSize)
132
+ },
133
+
134
+ async backfillPostRenders(batchSize) {
135
+ const { rendered } = await deps.renderBackfill.run(batchSize)
136
+ return rendered
137
+ },
138
+
139
+ ...optional(deps.searchIndex, (searchIndex) => ({
140
+ async reindexSearch(batchSize: number) {
141
+ const { indexed } = await searchIndex.reindexChunk(0, batchSize)
142
+ return indexed
143
+ },
144
+ })),
145
+
146
+ ...optional(deps.webhooks, (webhooks) => ({
147
+ async deliverWebhooks(batchSize: number) {
148
+ return deliverWebhooks(webhooks, batchSize)
149
+ },
150
+ })),
151
+
152
+ async pruneSessions() {
153
+ return deps.maintenance.pruneSessions(new Date())
154
+ },
155
+
156
+ async pruneExpiredTokens() {
157
+ return deps.maintenance.pruneExpiredTokens(new Date())
158
+ },
159
+
160
+ ...(deps.rateLimits === undefined
161
+ ? {}
162
+ : {
163
+ async pruneRateLimits() {
164
+ const before = new Date(Date.now() - 2 * 3600 * 1000)
165
+ return deps.rateLimits!.prune(before)
166
+ },
167
+ }),
168
+
169
+ ...optional(deps.authEvents, (authEvents) => ({
170
+ async pruneAuthEvents() {
171
+ const days = await authEvents.retentionDays()
172
+ if (days <= 0) return 0
173
+
174
+ return authEvents.pruneBefore(new Date(Date.now() - days * 86_400_000))
175
+ },
176
+ })),
177
+
178
+ async applyPromotions(batchSize) {
179
+ const result = await promotions.apply(batchSize)
180
+ return result.outcomes.length
181
+ },
182
+
183
+ async expireBans(batchSize) {
184
+ return bans.expireDue(batchSize)
185
+ },
186
+
187
+ ...optional(deps.timedGroups, (timedGroups) => ({
188
+ async expireGroupMemberships(batchSize: number) {
189
+ return timedGroups.expire(batchSize)
190
+ },
191
+ })),
192
+
193
+ async expireWarnings(batchSize) {
194
+ return new WarningService({ warnings: deps.warnings, bans: banPort(bans) }).expireDue(
195
+ batchSize,
196
+ )
197
+ },
198
+
199
+ ...optional(deps.subscriptions, () => ({
200
+ async notifySubscribers(batchSize: number, signal: AbortSignal) {
201
+ const { notified } = await subscriptionNotifier(deps).runInstant(batchSize, signal)
202
+ return notified
203
+ },
204
+
205
+ async sendDigests(batchSize: number, signal: AbortSignal) {
206
+ const notifier = subscriptionNotifier(deps)
207
+ const daily = await notifier.runDigest('daily', batchSize, signal)
208
+ if (signal.aborted) return daily.notified
209
+
210
+ const weekly = await notifier.runDigest('weekly', batchSize, signal)
211
+ return daily.notified + weekly.notified
212
+ },
213
+ })),
214
+
215
+ ...optional(deps.attachments, (attachments) => ({
216
+ async sweepAttachments(batchSize: number) {
217
+ return attachments.sweep(batchSize)
218
+ },
219
+ })),
220
+
221
+ ...optional(deps.avatars, (avatars) => ({
222
+ async sweepAvatars(batchSize: number) {
223
+ return avatars.sweep(batchSize)
224
+ },
225
+ })),
226
+
227
+ ...optional(deps.marketplace, (marketplace) => ({
228
+ async refreshMarketplaceCatalog() {
229
+ const url = await marketplace.feedUrl()
230
+ return refreshCatalog({
231
+ url,
232
+ repository: marketplace.repository,
233
+ build: {
234
+ meithVersion: MEITH_VERSION,
235
+ pluginApiMajor: PLUGIN_API_MAJOR,
236
+ themeApiMajor: THEME_API_MAJOR,
237
+ },
238
+ resolveInstalled: (listing) => {
239
+ const definition = marketplace.plugins.find((plugin) => plugin.key === listing.key)
240
+ return definition === undefined ? null : { enabled: true, version: definition.version }
241
+ },
242
+ notifyUpdate: marketplace.notifyUpdate,
243
+ })
244
+ },
245
+ })),
246
+
247
+ ...optional(deps.statistics, (statistics) => ({
248
+ async rollUpStatistics() {
249
+ const now = new Date()
250
+ const { memberCount } = await statistics.stats.rollUp(now)
251
+ const online = await statistics.presence.concurrentCount(now)
252
+ const record = await statistics.presence.recordIfHigher(online, now)
253
+ return { memberCount, online, record }
254
+ },
255
+ })),
256
+ }
257
+ }
258
+
259
+ function subscriptionNotifier(deps: TaskWorkerDeps): SubscriptionNotifier {
260
+ const wiring = deps.subscriptions!
261
+ return new SubscriptionNotifier({
262
+ subscriptions: wiring.repository,
263
+ forums: wiring.forums,
264
+ unsubscribeSecret: wiring.unsubscribeSecret,
265
+ notifications: {
266
+ async raise(input) {
267
+ await wiring.notifications.raise({
268
+ userId: input.userId,
269
+ kind: input.kind,
270
+ data: input.data as Parameters<NotificationService['raise']>[0]['data'],
271
+ href: input.href,
272
+ dedupeKey: input.dedupeKey,
273
+ })
274
+ },
275
+ },
276
+ })
277
+ }
278
+
279
+ function banPort(bans: BanService): {
280
+ ban: (input: {
281
+ userId: number
282
+ bannedByUserId: number
283
+ reason: string
284
+ publicReason: string
285
+ expiresAt?: Date | undefined
286
+ }) => Promise<void>
287
+ } {
288
+ return {
289
+ async ban(input) {
290
+ await bans.ban(input)
291
+ },
292
+ }
293
+ }
@@ -0,0 +1,23 @@
1
+ import type { ActorSource, Authorizer } from '@meith/authorization'
2
+ import { logger } from '@meith/core'
3
+ import type { VisibleForumSource } from '@meith/subscriptions'
4
+
5
+ export function visibleForumSource(deps: {
6
+ readonly authorizer: Authorizer
7
+ readonly actors: ActorSource
8
+ }): VisibleForumSource {
9
+ const log = logger({ module: 'subscriptions' })
10
+
11
+ return {
12
+ async visibleForumIdsFor(userId) {
13
+ try {
14
+ const actor = await deps.actors.buildForUser(userId)
15
+ if (actor === null) return []
16
+ return await deps.authorizer.visibleForumIds(actor)
17
+ } catch (err) {
18
+ log.warn({ err, userId }, 'could not resolve visible forums for a subscriber')
19
+ return []
20
+ }
21
+ },
22
+ }
23
+ }