@meith/plugin-kit 0.1.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,414 @@
1
+ /**
2
+ * What each hook is handed.
3
+ *
4
+ * One entry per hook in `HOOKS`, both directions asserted at the bottom, so a
5
+ * hook without a payload (or a payload without a hook) fails `pnpm typecheck`
6
+ * rather than surfacing as `unknown` inside a plugin.
7
+ *
8
+ * ## Every hook has the same shape: a value and a context
9
+ *
10
+ * `value` is the thing. For a **filter** it is what the handler returns a
11
+ * replacement for; for an **event** it is what happened. `context` is the
12
+ * surroundings — who, where, which thread — and is never returned by anything.
13
+ *
14
+ * Uniformity is worth more here than expressiveness. A plugin author learns one
15
+ * signature, the host has one dispatch path, and the generated documentation has
16
+ * one table shape. The alternative — a bespoke argument list per hook — reads
17
+ * better in isolation and is unusable across eighty of them.
18
+ *
19
+ * ## The payload rule: plain data, and the actor is not in it
20
+ *
21
+ * Payloads are JSON-shaped, for the same reasons view models are (they cross to
22
+ * the outbox, to a webhook, and into a log). And they carry a **viewer** —
23
+ * `{ userId, isGuest }` — never an `Actor`.
24
+ *
25
+ * That is a security boundary, not an ergonomic one. An `Actor` carries resolved
26
+ * group membership and is the input to `authorization.can()`; handing one to a
27
+ * plugin invites the plugin to make its own permission decision from the group
28
+ * ids, which is exactly what R4 forbids of core code and doubly so of code an
29
+ * operator installed from a directory. Plugins are handed the *result* of
30
+ * authorization — a link that is present or absent, a post that is in the list
31
+ * or is not — and never its inputs.
32
+ *
33
+ * ## Ids, not rows
34
+ *
35
+ * Most event payloads are ids plus the few fields a handler cannot look up
36
+ * cheaply. A plugin that needs the whole post can fetch it; a payload carrying
37
+ * every row would be a second read model to keep in step with the schema, and
38
+ * the first migration would break every plugin.
39
+ */
40
+
41
+ import type {
42
+ AnnouncementModel,
43
+ BoardIndexModel,
44
+ BoardStatsModel,
45
+ CategoryBlockModel,
46
+ ErrorNoticeModel,
47
+ FooterModel,
48
+ ForumJumpModel,
49
+ ForumDisplayModel,
50
+ ForumRowSlotModel,
51
+ HeaderModel,
52
+ LatestPostsModel,
53
+ LatestThreadsModel,
54
+ MemberProfileModel,
55
+ NavigationModel,
56
+ NoticeModel,
57
+ PaginationModel,
58
+ PostActionsSlotModel,
59
+ PostBitSlotModel,
60
+ PostFormModel,
61
+ RedirectNoticeModel,
62
+ SearchFormModel,
63
+ ShellModel,
64
+ SubforumListModel,
65
+ ThreadRowSlotModel,
66
+ ThreadViewModel,
67
+ UserPanelModel,
68
+ WhoIsOnlineModel,
69
+ } from '@meith/theme-kit'
70
+
71
+ import type { HookName } from './hooks'
72
+
73
+ /* ------------------------------------------------------------------ *
74
+ * Shared context pieces
75
+ * ------------------------------------------------------------------ */
76
+
77
+ /**
78
+ * Who is looking, as much as a plugin is told.
79
+ *
80
+ * Not an `Actor`. See this file's header — the omission is the point.
81
+ */
82
+ export interface ViewerRef {
83
+ readonly userId: number | null
84
+ readonly isGuest: boolean
85
+ }
86
+
87
+ /** The request a hook is running inside, when there is one. */
88
+ export interface RequestRef {
89
+ /** The request's correlation id, so a plugin's own logging joins up with the board's. */
90
+ readonly requestId: string | null
91
+ }
92
+
93
+ export interface ForumRef {
94
+ readonly forumId: number
95
+ }
96
+
97
+ export interface ThreadRef {
98
+ readonly threadId: number
99
+ readonly forumId: number
100
+ }
101
+
102
+ export interface PostRef {
103
+ readonly postId: number
104
+ readonly threadId: number
105
+ readonly forumId: number
106
+ }
107
+
108
+ export interface UserRef {
109
+ readonly userId: number
110
+ }
111
+
112
+ /** A moderator acting, with the reason they gave. */
113
+ export interface ModerationRef {
114
+ readonly moderatorId: number
115
+ readonly reason: string | null
116
+ }
117
+
118
+ /** A draft, as the composer has it before anything is written. */
119
+ export interface DraftPayload {
120
+ readonly subject: string | null
121
+ readonly body: string
122
+ readonly prefixId: number | null
123
+ readonly forumId: number
124
+ readonly authorId: number
125
+ }
126
+
127
+ /** Validation messages. Empty means "no objection"; anything else refuses. */
128
+ export type ValidationMessages = readonly string[]
129
+
130
+ /* ------------------------------------------------------------------ *
131
+ * The registry
132
+ * ------------------------------------------------------------------ */
133
+
134
+ export interface HookSignatures {
135
+ /* ---- Content rendering ---- */
136
+ 'markdown.parse.text': {
137
+ value: string
138
+ context: ViewerRef & { source: 'post' | 'signature' | 'pm' }
139
+ }
140
+ 'markdown.render.html': {
141
+ value: string
142
+ context: ViewerRef & { source: 'post' | 'signature' | 'pm' }
143
+ }
144
+ 'markdown.directives': { value: readonly string[]; context: ForumRef | Record<string, never> }
145
+ 'post.body.html': { value: string; context: PostRef & ViewerRef }
146
+ 'signature.html': { value: string; context: ViewerRef & { authorId: number } }
147
+ 'smilies.list': {
148
+ value: readonly { readonly code: string; readonly imageUrl: string }[]
149
+ context: ViewerRef
150
+ }
151
+ 'word-filter.patterns': {
152
+ value: readonly { readonly pattern: string; readonly replacement: string }[]
153
+ context: Record<string, never>
154
+ }
155
+
156
+ /* ---- View models ---- */
157
+ 'view.header': { value: HeaderModel; context: ViewerRef & RequestRef }
158
+ 'view.user-panel': { value: UserPanelModel; context: ViewerRef & RequestRef }
159
+ 'view.navigation': { value: NavigationModel; context: ViewerRef & RequestRef }
160
+ 'view.footer': { value: FooterModel; context: ViewerRef & RequestRef }
161
+ 'view.forum-jump': { value: ForumJumpModel; context: ViewerRef & RequestRef }
162
+ 'view.announcement': { value: AnnouncementModel; context: ViewerRef }
163
+ 'view.board-index': { value: BoardIndexModel; context: ViewerRef }
164
+ 'view.forum-row': { value: ForumRowSlotModel; context: ViewerRef }
165
+ 'view.thread-row': { value: ThreadRowSlotModel; context: ViewerRef & ForumRef }
166
+ 'view.post-bit': { value: PostBitSlotModel; context: ViewerRef & ThreadRef }
167
+ 'view.post-actions': { value: PostActionsSlotModel; context: ViewerRef & ThreadRef }
168
+ 'view.member-profile': { value: MemberProfileModel; context: ViewerRef }
169
+ 'view.board-stats': { value: BoardStatsModel; context: ViewerRef }
170
+ 'view.who-is-online': { value: WhoIsOnlineModel; context: ViewerRef }
171
+ 'view.latest-threads': { value: LatestThreadsModel; context: ViewerRef }
172
+ 'view.latest-posts': { value: LatestPostsModel; context: ViewerRef }
173
+ 'view.pagination': { value: PaginationModel; context: ViewerRef }
174
+ 'view.search-form': { value: SearchFormModel; context: ViewerRef }
175
+ 'view.error-notice': { value: ErrorNoticeModel; context: ViewerRef & RequestRef }
176
+ 'view.shell': { value: ShellModel; context: ViewerRef & RequestRef }
177
+ 'view.notice': { value: NoticeModel; context: ViewerRef }
178
+ 'view.category-block': { value: CategoryBlockModel; context: ViewerRef }
179
+ 'view.subforum-list': { value: SubforumListModel; context: ViewerRef & ForumRef }
180
+ 'view.forum-display': { value: ForumDisplayModel; context: ViewerRef & ForumRef }
181
+ 'view.thread-view': { value: ThreadViewModel; context: ViewerRef & ThreadRef }
182
+ 'view.post-form': { value: PostFormModel; context: ViewerRef }
183
+ 'view.redirect-notice': { value: RedirectNoticeModel; context: ViewerRef }
184
+
185
+ /* ---- Posting ---- */
186
+ 'thread.create.validate': { value: ValidationMessages; context: { draft: DraftPayload } }
187
+ 'thread.create.before': { value: DraftPayload; context: ViewerRef }
188
+ 'thread.created': { value: ThreadRef & { authorId: number; subject: string }; context: ViewerRef }
189
+ 'post.create.validate': { value: ValidationMessages; context: { draft: DraftPayload; threadId: number } }
190
+ 'post.create.before': { value: DraftPayload; context: ViewerRef & { threadId: number } }
191
+ 'post.created': { value: PostRef & { authorId: number }; context: ViewerRef }
192
+ 'post.edit.before': {
193
+ value: { readonly body: string; readonly reason: string | null }
194
+ context: PostRef & ViewerRef
195
+ }
196
+ 'post.edited': { value: PostRef & { editorId: number; revision: number }; context: ViewerRef }
197
+ 'post.delete.before': { value: PostRef; context: ModerationRef }
198
+ 'post.deleted': { value: PostRef; context: ModerationRef }
199
+ 'post.restored': { value: PostRef; context: ModerationRef }
200
+ 'thread.moved': {
201
+ value: { readonly threadId: number; readonly fromForumId: number; readonly toForumId: number }
202
+ context: ModerationRef
203
+ }
204
+ 'thread.merged': {
205
+ value: { readonly keptThreadId: number; readonly mergedThreadId: number; readonly postCount: number }
206
+ context: ModerationRef
207
+ }
208
+ 'thread.split': {
209
+ value: { readonly sourceThreadId: number; readonly newThreadId: number; readonly postCount: number }
210
+ context: ModerationRef
211
+ }
212
+ 'thread.locked': { value: ThreadRef & { isLocked: boolean }; context: ModerationRef }
213
+ 'thread.stickied': { value: ThreadRef & { isSticky: boolean }; context: ModerationRef }
214
+ 'attachment.upload.validate': {
215
+ value: ValidationMessages
216
+ context: {
217
+ readonly filename: string
218
+ readonly bytes: number
219
+ /** What the *bytes* say it is, not what the name claims. */
220
+ readonly detectedMimeType: string
221
+ readonly uploaderId: number
222
+ }
223
+ }
224
+ 'attachment.uploaded': {
225
+ value: { readonly attachmentId: number; readonly postId: number | null; readonly bytes: number }
226
+ context: ViewerRef
227
+ }
228
+ 'attachment.deleted': { value: { readonly attachmentId: number }; context: ViewerRef }
229
+ 'poll.created': { value: ThreadRef & { pollId: number; optionCount: number }; context: ViewerRef }
230
+ 'poll.voted': { value: { readonly pollId: number; readonly optionId: number }; context: ViewerRef }
231
+ 'rating.recorded': {
232
+ value: { readonly threadId: number; readonly rating: number; readonly average: number }
233
+ context: ViewerRef
234
+ }
235
+
236
+ /* ---- Moderation ---- */
237
+ 'report.created': {
238
+ value: {
239
+ readonly reportId: number
240
+ readonly target: 'post' | 'thread' | 'user' | 'pm'
241
+ readonly targetId: number
242
+ readonly reporterId: number
243
+ }
244
+ context: RequestRef
245
+ }
246
+ 'report.resolved': {
247
+ value: { readonly reportId: number; readonly resolution: 'actioned' | 'rejected' }
248
+ context: ModerationRef
249
+ }
250
+ 'approval.queued': {
251
+ value: { readonly kind: 'thread' | 'post' | 'attachment'; readonly id: number }
252
+ context: ViewerRef
253
+ }
254
+ 'approval.decided': {
255
+ value: {
256
+ readonly kind: 'thread' | 'post' | 'attachment'
257
+ readonly id: number
258
+ readonly approved: boolean
259
+ }
260
+ context: ModerationRef
261
+ }
262
+ 'warning.issued': {
263
+ value: {
264
+ readonly warningId: number
265
+ readonly userId: number
266
+ readonly points: number
267
+ readonly expiresAt: string | null
268
+ }
269
+ context: ModerationRef
270
+ }
271
+ 'warning.revoked': { value: { readonly warningId: number; readonly userId: number }; context: ModerationRef }
272
+ 'moderation.logged': {
273
+ value: { readonly action: string; readonly targetId: number | null }
274
+ context: ModerationRef
275
+ }
276
+
277
+ /* ---- Identity ---- */
278
+ 'user.register.validate': {
279
+ value: ValidationMessages
280
+ context: { readonly username: string; readonly email: string; readonly ipPrefix: string | null }
281
+ }
282
+ 'user.registered': { value: UserRef & { username: string; requiresActivation: boolean }; context: RequestRef }
283
+ 'user.activated': { value: UserRef; context: RequestRef }
284
+ 'user.login.attempted': {
285
+ value: {
286
+ readonly username: string
287
+ readonly outcome: 'ok' | 'bad-credentials' | 'locked-out' | 'banned'
288
+ /** Truncated. Never a full address. */
289
+ readonly ipPrefix: string | null
290
+ }
291
+ context: RequestRef
292
+ }
293
+ 'user.logged-in': { value: UserRef; context: RequestRef }
294
+ 'user.logged-out': { value: UserRef & { reason: 'requested' | 'revoked' }; context: RequestRef }
295
+ 'user.banned': {
296
+ value: UserRef & { expiresAt: string | null }
297
+ context: ModerationRef
298
+ }
299
+ 'user.unbanned': { value: UserRef & { expired: boolean }; context: ModerationRef }
300
+ 'user.groups.changed': {
301
+ value: UserRef & { primaryGroupId: number; secondaryGroupIds: readonly number[] }
302
+ context: RequestRef
303
+ }
304
+ 'user.profile.updated': { value: UserRef & { fields: readonly string[] }; context: RequestRef }
305
+ 'user.merged': { value: { readonly keptUserId: number; readonly mergedUserId: number }; context: RequestRef }
306
+ 'user.deleted': { value: UserRef & { reason: 'pruned' | 'deleted' }; context: RequestRef }
307
+
308
+ /* ---- Mail, notifications, messages ---- */
309
+ 'notification.create.before': {
310
+ value: {
311
+ readonly userId: number
312
+ readonly kind: string
313
+ readonly subjectText: string
314
+ readonly href: string
315
+ } | null
316
+ context: RequestRef
317
+ }
318
+ 'notification.created': { value: { readonly notificationId: number; readonly userId: number }; context: RequestRef }
319
+ 'mail.send.before': {
320
+ value: {
321
+ readonly to: string
322
+ readonly subject: string
323
+ readonly textBody: string
324
+ readonly htmlBody: string | null
325
+ } | null
326
+ context: { readonly template: string }
327
+ }
328
+ 'mail.sent': { value: { readonly to: string; readonly template: string }; context: RequestRef }
329
+ 'pm.send.before': {
330
+ value: {
331
+ readonly senderId: number
332
+ readonly recipientIds: readonly number[]
333
+ readonly subject: string
334
+ readonly body: string
335
+ } | null
336
+ context: RequestRef
337
+ }
338
+ 'pm.sent': { value: { readonly messageId: number; readonly recipientIds: readonly number[] }; context: RequestRef }
339
+ 'subscription.changed': {
340
+ value: {
341
+ readonly userId: number
342
+ readonly target: 'thread' | 'forum'
343
+ readonly targetId: number
344
+ readonly subscribed: boolean
345
+ }
346
+ context: RequestRef
347
+ }
348
+ 'reputation.changed': {
349
+ value: { readonly userId: number; readonly delta: number; readonly total: number }
350
+ context: ViewerRef
351
+ }
352
+
353
+ /* ---- Search, discovery, syndication ---- */
354
+ 'search.query.before': { value: string; context: ViewerRef }
355
+ 'search.results': {
356
+ value: readonly { readonly postId: number; readonly threadId: number; readonly rank: number }[]
357
+ context: ViewerRef & { terms: string }
358
+ }
359
+ 'feed.items': {
360
+ value: readonly {
361
+ readonly title: string
362
+ readonly href: string
363
+ readonly publishedAt: string
364
+ readonly summary: string
365
+ }[]
366
+ /** Always a guest: a feed is cached under a shared URL. */
367
+ context: { readonly feed: 'board' | 'forum' | 'thread' }
368
+ }
369
+ 'sitemap.entries': {
370
+ value: readonly { readonly href: string; readonly lastModified: string | null }[]
371
+ context: { readonly chunk: number }
372
+ }
373
+ 'metadata.page': {
374
+ value: {
375
+ readonly title: string
376
+ readonly description: string | null
377
+ readonly canonical: string
378
+ readonly imageUrl: string | null
379
+ }
380
+ context: { readonly route: string }
381
+ }
382
+
383
+ /* ---- Admin and system ---- */
384
+ 'admin.navigation': {
385
+ value: readonly { readonly label: string; readonly href: string }[]
386
+ context: ViewerRef
387
+ }
388
+ 'settings.saved': { value: { readonly keys: readonly string[] }; context: { readonly adminId: number } }
389
+ 'task.run.before': { value: { readonly taskId: string }; context: Record<string, never> }
390
+ 'task.run.after': {
391
+ value: { readonly taskId: string; readonly ok: boolean; readonly durationMs: number }
392
+ context: Record<string, never>
393
+ }
394
+ 'cache.invalidated': { value: { readonly tag: string }; context: Record<string, never> }
395
+ 'plugin.enabled': { value: { readonly pluginKey: string }; context: Record<string, never> }
396
+ 'plugin.disabled': {
397
+ value: { readonly pluginKey: string; readonly reason: 'operator' | 'failures' }
398
+ context: Record<string, never>
399
+ }
400
+ }
401
+
402
+ /** What a handler for `K` is given. */
403
+ export type HookValue<K extends HookName> = HookSignatures[K]['value']
404
+ export type HookContext<K extends HookName> = HookSignatures[K]['context']
405
+
406
+ /* ------------------------------------------------------------------ *
407
+ * Compile-time proofs
408
+ * ------------------------------------------------------------------ */
409
+
410
+ /** Fails with the offending name in the message rather than a bare `never`. */
411
+ type AssertNever<T extends never> = T
412
+
413
+ type _NoHookWithoutSignature = AssertNever<Exclude<HookName, keyof HookSignatures>>
414
+ type _NoSignatureWithoutHook = AssertNever<Exclude<keyof HookSignatures, HookName>>